From 6f786889453cfe6d4c8ff1138e61894bca7e1253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Pedersen?= Date: Thu, 26 Feb 2026 13:42:11 +0100 Subject: [PATCH] Integrate OSC 8 hyperlink support into aesh command framework Expose hyperlink capabilities from aesh-readline through the aesh API so command authors can easily output clickable links and the framework can render hyperlinks in help output. - Add writeHyperlink()/supportsHyperlinks() to Shell and CommandInvocation - Add helpUrl to @CommandDefinition/@GroupCommandDefinition annotations - Add descriptionUrl and url attributes to @Option/@Argument/@Arguments - Add URL/URI converters and register in CLConverterManager - Add getHyperlinkUrl() default method to OptionRenderer - Wire new attributes through ProcessedCommand/Option and their builders - Update printHelp() and getFormattedOption() to render hyperlinks - Auto-detect URL/URI field types for hyperlink rendering - Add HyperlinkTest with 14 tests covering all new functionality --- .../org/aesh/command/CommandDefinition.java | 8 + .../aesh/command/GroupCommandDefinition.java | 8 + .../AeshCommandContainerBuilder.java | 6 + .../command/impl/converter/URIConverter.java | 41 +++ .../command/impl/converter/URLConverter.java | 41 +++ .../impl/internal/ProcessedCommand.java | 55 ++- .../internal/ProcessedCommandBuilder.java | 9 +- .../impl/internal/ProcessedOption.java | 70 +++- .../impl/internal/ProcessedOptionBuilder.java | 19 +- .../command/invocation/CommandInvocation.java | 22 ++ .../org/aesh/command/option/Argument.java | 6 + .../org/aesh/command/option/Arguments.java | 6 + .../java/org/aesh/command/option/Option.java | 14 + .../aesh/command/renderer/OptionRenderer.java | 9 + .../java/org/aesh/command/shell/Shell.java | 28 ++ .../aesh/converter/CLConverterManager.java | 6 + .../java/org/aesh/command/HyperlinkTest.java | 317 ++++++++++++++++++ 17 files changed, 653 insertions(+), 12 deletions(-) create mode 100644 aesh/src/main/java/org/aesh/command/impl/converter/URIConverter.java create mode 100644 aesh/src/main/java/org/aesh/command/impl/converter/URLConverter.java create mode 100644 aesh/src/test/java/org/aesh/command/HyperlinkTest.java diff --git a/aesh/src/main/java/org/aesh/command/CommandDefinition.java b/aesh/src/main/java/org/aesh/command/CommandDefinition.java index f7e9f162..e1019761 100644 --- a/aesh/src/main/java/org/aesh/command/CommandDefinition.java +++ b/aesh/src/main/java/org/aesh/command/CommandDefinition.java @@ -106,4 +106,12 @@ * @return CommandActivator */ Class activator() default NullCommandActivator.class; + + /** + * Optional URL to the command's documentation. + * When the terminal supports hyperlinks, this is shown as a clickable link in --help output. + * + * @return documentation URL + */ + String helpUrl() default ""; } diff --git a/aesh/src/main/java/org/aesh/command/GroupCommandDefinition.java b/aesh/src/main/java/org/aesh/command/GroupCommandDefinition.java index 28cab939..a49bd792 100644 --- a/aesh/src/main/java/org/aesh/command/GroupCommandDefinition.java +++ b/aesh/src/main/java/org/aesh/command/GroupCommandDefinition.java @@ -105,4 +105,12 @@ * @return CommandActivator */ Class activator() default NullCommandActivator.class; + + /** + * Optional URL to the command's documentation. + * When the terminal supports hyperlinks, this is shown as a clickable link in --help output. + * + * @return documentation URL + */ + String helpUrl() default ""; } diff --git a/aesh/src/main/java/org/aesh/command/impl/container/AeshCommandContainerBuilder.java b/aesh/src/main/java/org/aesh/command/impl/container/AeshCommandContainerBuilder.java index a5aae059..86bd80d5 100644 --- a/aesh/src/main/java/org/aesh/command/impl/container/AeshCommandContainerBuilder.java +++ b/aesh/src/main/java/org/aesh/command/impl/container/AeshCommandContainerBuilder.java @@ -88,6 +88,7 @@ private AeshCommandContainer doGenerateCommandLineParser(Command command .generateHelp(command.generateHelp()) .disableParsing(command.disableParsing()) .version(command.version()) + .helpUrl(command.helpUrl()) .create(); processCommand(processedCommand, clazz); @@ -110,6 +111,7 @@ CommandLineParserBuilder., CI> builder() .generateHelp(groupCommand.generateHelp()) .version(groupCommand.version()) .resultHandler(groupCommand.resultHandler()) + .helpUrl(groupCommand.helpUrl()) .create(); processCommand(processedGroupCommand, clazz); @@ -192,6 +194,8 @@ private static void processField(ProcessedCommand processedCommand, Field field) .negatable(o.negatable()) .negationPrefix(o.negationPrefix()) .inherited(o.inherited()) + .descriptionUrl(o.descriptionUrl()) + .url(o.url()) .build()); } else if ((ol = field.getAnnotation(OptionList.class)) != null) { if (!Collection.class.isAssignableFrom(field.getType())) @@ -279,6 +283,7 @@ else if ((a = field.getAnnotation(Arguments.class)) != null) { .validator(a.validator()) .activator(a.activator()) .parser(a.parser()) + .url(a.url()) .build()); } else if ((arg = field.getAnnotation(Argument.class)) != null) { if (processedCommand.getArgument() != null) @@ -309,6 +314,7 @@ else if ((a = field.getAnnotation(Arguments.class)) != null) { .parser(arg.parser()) .overrideRequired(arg.overrideRequired()) .inherited(arg.inherited()) + .url(arg.url()) .build()); } } diff --git a/aesh/src/main/java/org/aesh/command/impl/converter/URIConverter.java b/aesh/src/main/java/org/aesh/command/impl/converter/URIConverter.java new file mode 100644 index 00000000..28428662 --- /dev/null +++ b/aesh/src/main/java/org/aesh/command/impl/converter/URIConverter.java @@ -0,0 +1,41 @@ +/* + * JBoss, Home of Professional Open Source + * Copyright 2014 Red Hat Inc. and/or its affiliates and other contributors + * as indicated by the @authors tag + * See the copyright.txt in the distribution for a + * full listing of individual contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.aesh.command.impl.converter; + +import java.net.URI; +import java.net.URISyntaxException; + +import org.aesh.command.converter.Converter; +import org.aesh.command.converter.ConverterInvocation; +import org.aesh.command.validator.OptionValidatorException; + +/** + * @author Aesh team + */ +public class URIConverter implements Converter { + @Override + public URI convert(ConverterInvocation input) throws OptionValidatorException { + try { + return new URI(input.getInput()); + } catch (URISyntaxException e) { + throw new OptionValidatorException("Invalid URI: " + input.getInput()); + } + } +} diff --git a/aesh/src/main/java/org/aesh/command/impl/converter/URLConverter.java b/aesh/src/main/java/org/aesh/command/impl/converter/URLConverter.java new file mode 100644 index 00000000..17ea110b --- /dev/null +++ b/aesh/src/main/java/org/aesh/command/impl/converter/URLConverter.java @@ -0,0 +1,41 @@ +/* + * JBoss, Home of Professional Open Source + * Copyright 2014 Red Hat Inc. and/or its affiliates and other contributors + * as indicated by the @authors tag + * See the copyright.txt in the distribution for a + * full listing of individual contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.aesh.command.impl.converter; + +import java.net.MalformedURLException; +import java.net.URL; + +import org.aesh.command.converter.Converter; +import org.aesh.command.converter.ConverterInvocation; +import org.aesh.command.validator.OptionValidatorException; + +/** + * @author Aesh team + */ +public class URLConverter implements Converter { + @Override + public URL convert(ConverterInvocation input) throws OptionValidatorException { + try { + return new URL(input.getInput()); + } catch (MalformedURLException e) { + throw new OptionValidatorException("Invalid URL: " + input.getInput()); + } + } +} diff --git a/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedCommand.java b/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedCommand.java index cd92a572..62fff729 100644 --- a/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedCommand.java +++ b/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedCommand.java @@ -38,6 +38,7 @@ import org.aesh.command.validator.CommandValidator; import org.aesh.selector.SelectorType; import org.aesh.terminal.formatting.TerminalString; +import org.aesh.terminal.utils.ANSI; import org.aesh.terminal.utils.Config; import org.aesh.terminal.utils.Parser; @@ -55,6 +56,7 @@ public class ProcessedCommand, CI extends CommandInvocatio private CommandActivator activator; private final boolean generateHelp; private String version; + private String helpUrl; private List options; private ProcessedOption arguments; @@ -72,12 +74,26 @@ public ProcessedCommand(String name, List aliases, C command, ProcessedOption arguments, List options, ProcessedOption argument, CommandPopulator populator, CommandActivator activator) throws OptionParserException { + this(name, aliases, command, description, validator, resultHandler, generateHelp, disableParsing, + version, arguments, options, argument, populator, activator, null); + } + + public ProcessedCommand(String name, List aliases, C command, + String description, CommandValidator validator, + ResultHandler resultHandler, + boolean generateHelp, boolean disableParsing, + String version, + ProcessedOption arguments, List options, + ProcessedOption argument, + CommandPopulator populator, CommandActivator activator, + String helpUrl) throws OptionParserException { this.name = name; this.description = description; this.aliases = aliases == null ? Collections.emptyList() : aliases; this.validator = validator; this.generateHelp = generateHelp; this.disableParsing = disableParsing; + this.helpUrl = helpUrl; if (resultHandler != null) this.resultHandler = resultHandler; else @@ -124,7 +140,8 @@ public void addOption(ProcessedOption opt) throws OptionParserException { opt.acceptNameWithoutDashes(), opt.selectorType(), opt.getDefaultValues(), opt.type(), opt.getFieldName(), opt.getOptionType(), opt.converter(), opt.completer(), opt.validator(), opt.activator(), opt.getRenderer(), opt.parser(), opt.doOverrideRequired(), - opt.isNegatable(), opt.getNegationPrefix(), opt.isInherited())); + opt.isNegatable(), opt.getNegationPrefix(), opt.isInherited(), + opt.getDescriptionUrl(), opt.isUrl())); options.get(options.size() - 1).setParent(this); } @@ -136,7 +153,8 @@ private void setOptions(List options) throws OptionParserExcept opt.acceptNameWithoutDashes(), opt.selectorType(), opt.getDefaultValues(), opt.type(), opt.getFieldName(), opt.getOptionType(), opt.converter(), opt.completer(), opt.validator(), opt.activator(), opt.getRenderer(), - opt.parser(), opt.doOverrideRequired(), opt.isNegatable(), opt.getNegationPrefix(), opt.isInherited())); + opt.parser(), opt.doOverrideRequired(), opt.isNegatable(), opt.getNegationPrefix(), opt.isInherited(), + opt.getDescriptionUrl(), opt.isUrl())); this.options.get(this.options.size() - 1).setParent(this); } @@ -191,6 +209,10 @@ public String version() { return version; } + public String helpUrl() { + return helpUrl; + } + private char verifyThatNamesAreUnique(String name, String longName) throws OptionParserException { if (name != null) return verifyThatNamesAreUnique(name.charAt(0), longName); @@ -565,6 +587,17 @@ public List getAllAskIfNotSet() { * */ public String printHelp(String commandName) { + return printHelp(commandName, false); + } + + /** + * Returns a description String based on the defined command and options. + * Useful when printing "help" info etc. + * + * @param commandName the command name to display + * @param supportsHyperlinks whether the terminal supports OSC 8 hyperlinks + */ + public String printHelp(String commandName, boolean supportsHyperlinks) { int maxLength = 0; int width = 80; List opts = getOptions(); @@ -604,14 +637,26 @@ public String printHelp(String commandName) { if (opts.size() > 0) sb.append(Config.getLineSeparator()).append("Options:").append(Config.getLineSeparator()); for (ProcessedOption o : opts) - sb.append(o.getFormattedOption(2, maxLength + 4, width)).append(Config.getLineSeparator()); + sb.append(o.getFormattedOption(2, maxLength + 4, width, supportsHyperlinks)).append(Config.getLineSeparator()); if (arguments != null) { sb.append(Config.getLineSeparator()).append("Arguments:").append(Config.getLineSeparator()); - sb.append(arguments.getFormattedOption(2, maxLength + 4, width)).append(Config.getLineSeparator()); + sb.append(arguments.getFormattedOption(2, maxLength + 4, width, supportsHyperlinks)) + .append(Config.getLineSeparator()); } if (argument != null) { sb.append(Config.getLineSeparator()).append("Argument:").append(Config.getLineSeparator()); - sb.append(argument.getFormattedOption(2, maxLength + 4, width)).append(Config.getLineSeparator()); + sb.append(argument.getFormattedOption(2, maxLength + 4, width, supportsHyperlinks)) + .append(Config.getLineSeparator()); + } + // Append documentation link if helpUrl is set + if (helpUrl != null && helpUrl.length() > 0) { + sb.append(Config.getLineSeparator()); + if (supportsHyperlinks) { + sb.append("Documentation: ").append(ANSI.hyperlink(helpUrl, helpUrl)); + } else { + sb.append("Documentation: ").append(helpUrl); + } + sb.append(Config.getLineSeparator()); } return sb.toString(); } diff --git a/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedCommandBuilder.java b/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedCommandBuilder.java index 2a5ff706..fce97969 100644 --- a/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedCommandBuilder.java +++ b/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedCommandBuilder.java @@ -57,6 +57,7 @@ public class ProcessedCommandBuilder, CI extends CommandIn private boolean generateHelp; private boolean disableParsing; private String version; + private String helpUrl; private ProcessedCommandBuilder() { options = new ArrayList<>(); @@ -97,6 +98,11 @@ public ProcessedCommandBuilder disableParsing(boolean disableParsing) { return this; } + public ProcessedCommandBuilder helpUrl(String helpUrl) { + this.helpUrl = helpUrl; + return this; + } + public ProcessedCommandBuilder arguments(ProcessedOption arguments) { this.arguments = arguments; return this; @@ -198,6 +204,7 @@ public ProcessedCommand create() throws CommandLineParserException { resultHandler = new NullResultHandler(); return new ProcessedCommand<>(name, aliases, command, description, validator, - resultHandler, generateHelp, disableParsing, version, arguments, options, arg, populator, activator); + resultHandler, generateHelp, disableParsing, version, arguments, options, arg, populator, activator, + helpUrl); } } diff --git a/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedOption.java b/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedOption.java index 49875502..f042cb8f 100644 --- a/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedOption.java +++ b/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedOption.java @@ -90,6 +90,8 @@ public final class ProcessedOption { private String negationPrefix = "no-"; private boolean negatedByUser = false; private boolean inherited = false; + private String descriptionUrl; + private boolean isUrl = false; public ProcessedOption(char shortName, String name, String description, String argument, boolean required, char valueSeparator, boolean askIfNotSet, boolean acceptNameWithoutDashes, @@ -101,6 +103,21 @@ public ProcessedOption(char shortName, String name, String description, OptionRenderer renderer, OptionParser parser, boolean overrideRequired, boolean negatable, String negationPrefix, boolean inherited) throws OptionParserException { + this(shortName, name, description, argument, required, valueSeparator, askIfNotSet, acceptNameWithoutDashes, + selectorType, defaultValue, type, fieldName, optionType, converter, completer, optionValidator, + activator, renderer, parser, overrideRequired, negatable, negationPrefix, inherited, null, false); + } + + public ProcessedOption(char shortName, String name, String description, + String argument, boolean required, char valueSeparator, boolean askIfNotSet, boolean acceptNameWithoutDashes, + SelectorType selectorType, + List defaultValue, Class type, String fieldName, + OptionType optionType, Converter converter, OptionCompleter completer, + OptionValidator optionValidator, + OptionActivator activator, + OptionRenderer renderer, OptionParser parser, + boolean overrideRequired, boolean negatable, String negationPrefix, + boolean inherited, String descriptionUrl, boolean isUrl) throws OptionParserException { if (shortName != '\u0000') this.shortName = String.valueOf(shortName); @@ -135,6 +152,8 @@ public ProcessedOption(char shortName, String name, String description, this.negatable = negatable; this.negationPrefix = negationPrefix != null ? negationPrefix : "no-"; this.inherited = inherited; + this.descriptionUrl = descriptionUrl; + this.isUrl = isUrl || java.net.URL.class.isAssignableFrom(type) || java.net.URI.class.isAssignableFrom(type); properties = new HashMap<>(); values = new ArrayList<>(); @@ -333,6 +352,34 @@ public boolean isInherited() { return inherited; } + /** + * Returns the documentation URL for this option's description. + */ + public String getDescriptionUrl() { + return descriptionUrl; + } + + /** + * Returns true if this option's value should be treated as a URL. + */ + public boolean isUrl() { + return isUrl; + } + + /** + * Returns the option value formatted as a hyperlink when appropriate. + * + * @param supportsHyperlinks whether the terminal supports OSC 8 hyperlinks + * @return the value, optionally wrapped in hyperlink escape sequences + */ + public String getFormattedValue(boolean supportsHyperlinks) { + String val = getValue(); + if (val != null && isUrl && supportsHyperlinks) { + return ANSI.hyperlink(val, val); + } + return val; + } + public void clear() { if (values != null) values.clear(); @@ -356,12 +403,17 @@ public String getDisplayName() { public TerminalString getRenderedNameWithDashes() { String prefix = acceptNameWithoutDashes ? "" : "--"; + String text = hasValue() ? prefix + name + "=" : prefix + name; if (renderer == null || !ansiMode) //if hasValue append a = after the name - return new TerminalString(hasValue() ? prefix + name + "=" : prefix + name, true); - else - return new TerminalString(hasValue() ? prefix + name + "=" : prefix + name, renderer.getColor(), - renderer.getTextType()); + return new TerminalString(text, true); + else { + String hyperlinkUrl = renderer.getHyperlinkUrl(); + if (hyperlinkUrl != null) { + return new TerminalString(text, hyperlinkUrl, renderer.getColor(), renderer.getTextType()); + } + return new TerminalString(text, renderer.getColor(), renderer.getTextType()); + } } /** @@ -402,6 +454,10 @@ public int getFormattedLength() { //TODO: add offset, offset for descriptionstart and break on width public String getFormattedOption(int offset, int descriptionStart, int width) { + return getFormattedOption(offset, descriptionStart, width, false); + } + + public String getFormattedOption(int offset, int descriptionStart, int width, boolean supportsHyperlinks) { StringBuilder sb = new StringBuilder(); if (required && ansiMode) sb.append(ANSI.BOLD); @@ -431,7 +487,11 @@ public String getFormattedOption(int offset, int descriptionStart, int width) { else sb.append(" "); - sb.append(description); + if (supportsHyperlinks && descriptionUrl != null && descriptionUrl.length() > 0) { + sb.append(ANSI.hyperlink(descriptionUrl, description)); + } else { + sb.append(description); + } } return sb.toString(); diff --git a/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedOptionBuilder.java b/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedOptionBuilder.java index deacf4cc..5770b32f 100644 --- a/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedOptionBuilder.java +++ b/aesh/src/main/java/org/aesh/command/impl/internal/ProcessedOptionBuilder.java @@ -78,6 +78,8 @@ public class ProcessedOptionBuilder { private boolean negatable = false; private String negationPrefix = "no-"; private boolean inherited = false; + private String descriptionUrl; + private boolean isUrl = false; private ProcessedOptionBuilder() { defaultValues = new ArrayList<>(); @@ -323,6 +325,20 @@ public ProcessedOptionBuilder inherited(boolean inherited) { return apply(c -> c.inherited = inherited); } + /** + * Set the documentation URL for this option's description in help output. + */ + public ProcessedOptionBuilder descriptionUrl(String descriptionUrl) { + return apply(c -> c.descriptionUrl = descriptionUrl); + } + + /** + * Set whether this option's value should be treated as a URL. + */ + public ProcessedOptionBuilder url(boolean isUrl) { + return apply(c -> c.isUrl = isUrl); + } + public ProcessedOption build() throws OptionParserException { if (optionType == null) { if (!hasValue) @@ -377,6 +393,7 @@ else if (hasMultipleValues) return new ProcessedOption(shortName, name, description, argument, required, valueSeparator, askIfNotSet, acceptNameWithoutDashes, selectorType, defaultValues, type, fieldName, optionType, converter, - completer, validator, activator, renderer, parser, overrideRequired, negatable, negationPrefix, inherited); + completer, validator, activator, renderer, parser, overrideRequired, negatable, negationPrefix, inherited, + descriptionUrl, isUrl); } } diff --git a/aesh/src/main/java/org/aesh/command/invocation/CommandInvocation.java b/aesh/src/main/java/org/aesh/command/invocation/CommandInvocation.java index 57a2b9be..1eced61b 100644 --- a/aesh/src/main/java/org/aesh/command/invocation/CommandInvocation.java +++ b/aesh/src/main/java/org/aesh/command/invocation/CommandInvocation.java @@ -299,4 +299,26 @@ default boolean exitSubCommandMode() { return false; // Default implementation does nothing } + // ========== Hyperlink Methods ========== + + /** + * Print a hyperlink to the terminal. If the terminal supports OSC 8 hyperlinks, + * the text will be rendered as a clickable link. Otherwise, plain text is printed. + * + * @param url the URL target of the hyperlink + * @param text the visible text for the hyperlink + */ + default void printHyperlink(String url, String text) { + getShell().writeHyperlink(url, text); + } + + /** + * Check if the terminal supports OSC 8 hyperlinks. + * + * @return true if hyperlinks are supported + */ + default boolean supportsHyperlinks() { + return getShell().supportsHyperlinks(); + } + } diff --git a/aesh/src/main/java/org/aesh/command/option/Argument.java b/aesh/src/main/java/org/aesh/command/option/Argument.java index f63e4ad8..efb4a935 100644 --- a/aesh/src/main/java/org/aesh/command/option/Argument.java +++ b/aesh/src/main/java/org/aesh/command/option/Argument.java @@ -125,4 +125,10 @@ */ boolean inherited() default false; + /** + * When true, the argument value is treated as a URL. + * If the terminal supports hyperlinks, the value is rendered as a clickable link. + */ + boolean url() default false; + } diff --git a/aesh/src/main/java/org/aesh/command/option/Arguments.java b/aesh/src/main/java/org/aesh/command/option/Arguments.java index f7e3410e..05299dc9 100644 --- a/aesh/src/main/java/org/aesh/command/option/Arguments.java +++ b/aesh/src/main/java/org/aesh/command/option/Arguments.java @@ -112,4 +112,10 @@ * Only change this if you want to specify a custom parser */ Class parser() default AeshOptionParser.class; + + /** + * When true, the argument values are treated as URLs. + * If the terminal supports hyperlinks, the values are rendered as clickable links. + */ + boolean url() default false; } diff --git a/aesh/src/main/java/org/aesh/command/option/Option.java b/aesh/src/main/java/org/aesh/command/option/Option.java index b6f6fd72..de345c01 100644 --- a/aesh/src/main/java/org/aesh/command/option/Option.java +++ b/aesh/src/main/java/org/aesh/command/option/Option.java @@ -174,4 +174,18 @@ */ boolean inherited() default false; + /** + * Optional URL for this option's documentation. + * When the terminal supports hyperlinks, the option description in --help output + * becomes a clickable link to this URL. + */ + String descriptionUrl() default ""; + + /** + * When true, the option value is treated as a URL. + * If the terminal supports hyperlinks, the value is rendered as a clickable link + * when displayed in help output or via getFormattedValue(). + */ + boolean url() default false; + } diff --git a/aesh/src/main/java/org/aesh/command/renderer/OptionRenderer.java b/aesh/src/main/java/org/aesh/command/renderer/OptionRenderer.java index 59bd2ea8..a0d08b16 100644 --- a/aesh/src/main/java/org/aesh/command/renderer/OptionRenderer.java +++ b/aesh/src/main/java/org/aesh/command/renderer/OptionRenderer.java @@ -30,4 +30,13 @@ public interface OptionRenderer { TerminalColor getColor(); TerminalTextStyle getTextType(); + + /** + * Optional URL that wraps the option name as a hyperlink in help/completion output. + * + * @return hyperlink URL, or null if not applicable + */ + default String getHyperlinkUrl() { + return null; + } } diff --git a/aesh/src/main/java/org/aesh/command/shell/Shell.java b/aesh/src/main/java/org/aesh/command/shell/Shell.java index 782e0d77..51e07bef 100644 --- a/aesh/src/main/java/org/aesh/command/shell/Shell.java +++ b/aesh/src/main/java/org/aesh/command/shell/Shell.java @@ -26,6 +26,7 @@ import org.aesh.terminal.Connection; import org.aesh.terminal.Key; import org.aesh.terminal.tty.Size; +import org.aesh.terminal.utils.ANSI; /** * @author Aesh team @@ -140,4 +141,31 @@ default String readLine(String prompt) throws InterruptedException { default Connection connection() { return null; } + + /** + * Write a hyperlink to the terminal. If the terminal supports OSC 8 hyperlinks, + * the text will be rendered as a clickable link. Otherwise, plain text is written. + * Routes through write(String) so paging is preserved. + * + * @param url the URL target of the hyperlink + * @param text the visible text for the hyperlink + */ + default void writeHyperlink(String url, String text) { + Connection conn = connection(); + if (conn != null && conn.supportsHyperlinks()) { + write(ANSI.hyperlink(url, text)); + } else { + write(text); + } + } + + /** + * Check if the terminal supports OSC 8 hyperlinks. + * + * @return true if hyperlinks are supported + */ + default boolean supportsHyperlinks() { + Connection conn = connection(); + return conn != null && conn.supportsHyperlinks(); + } } diff --git a/aesh/src/main/java/org/aesh/converter/CLConverterManager.java b/aesh/src/main/java/org/aesh/converter/CLConverterManager.java index 9e2458b8..04e84b63 100644 --- a/aesh/src/main/java/org/aesh/converter/CLConverterManager.java +++ b/aesh/src/main/java/org/aesh/converter/CLConverterManager.java @@ -20,6 +20,8 @@ package org.aesh.converter; import java.io.File; +import java.net.URI; +import java.net.URL; import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -37,6 +39,8 @@ import org.aesh.command.impl.converter.LongConverter; import org.aesh.command.impl.converter.ShortConverter; import org.aesh.command.impl.converter.StringConverter; +import org.aesh.command.impl.converter.URIConverter; +import org.aesh.command.impl.converter.URLConverter; import org.aesh.io.Resource; /** @@ -79,6 +83,8 @@ private void initMap() { converters.put(String.class, new StringConverter()); converters.put(File.class, new FileConverter()); converters.put(Resource.class, new FileResourceConverter()); + converters.put(URL.class, new URLConverter()); + converters.put(URI.class, new URIConverter()); } public boolean hasConverter(Class clazz) { diff --git a/aesh/src/test/java/org/aesh/command/HyperlinkTest.java b/aesh/src/test/java/org/aesh/command/HyperlinkTest.java new file mode 100644 index 00000000..a7728b86 --- /dev/null +++ b/aesh/src/test/java/org/aesh/command/HyperlinkTest.java @@ -0,0 +1,317 @@ +package org.aesh.command; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.net.URI; +import java.net.URL; + +import org.aesh.command.activator.CommandActivator; +import org.aesh.command.activator.OptionActivator; +import org.aesh.command.completer.CompleterInvocation; +import org.aesh.command.converter.ConverterInvocation; +import org.aesh.command.impl.internal.ProcessedCommand; +import org.aesh.command.impl.internal.ProcessedCommandBuilder; +import org.aesh.command.impl.internal.ProcessedOption; +import org.aesh.command.impl.internal.ProcessedOptionBuilder; +import org.aesh.command.impl.registry.AeshCommandRegistryBuilder; +import org.aesh.command.invocation.CommandInvocation; +import org.aesh.command.option.Option; +import org.aesh.command.registry.CommandRegistry; +import org.aesh.command.registry.CommandRegistryException; +import org.aesh.command.settings.Settings; +import org.aesh.command.settings.SettingsBuilder; +import org.aesh.command.validator.ValidatorInvocation; +import org.aesh.console.ReadlineConsole; +import org.aesh.converter.CLConverterManager; +import org.aesh.terminal.utils.ANSI; +import org.aesh.terminal.utils.Config; +import org.aesh.tty.TestConnection; +import org.junit.Test; + +public class HyperlinkTest { + + @Test + public void testURLConverter() throws Exception { + assertTrue(CLConverterManager.getInstance().hasConverter(URL.class)); + assertNotNull(CLConverterManager.getInstance().getConverter(URL.class)); + } + + @Test + public void testURIConverter() throws Exception { + assertTrue(CLConverterManager.getInstance().hasConverter(URI.class)); + assertNotNull(CLConverterManager.getInstance().getConverter(URI.class)); + } + + @Test + public void testURLOptionAutoDetection() throws Exception { + ProcessedOption urlOption = ProcessedOptionBuilder.builder() + .name("endpoint") + .type(URL.class) + .fieldName("endpoint") + .build(); + + assertTrue("URL-typed option should have isUrl=true", urlOption.isUrl()); + } + + @Test + public void testURIOptionAutoDetection() throws Exception { + ProcessedOption uriOption = ProcessedOptionBuilder.builder() + .name("docLink") + .type(URI.class) + .fieldName("docLink") + .build(); + + assertTrue("URI-typed option should have isUrl=true", uriOption.isUrl()); + } + + @Test + public void testExplicitUrlAttribute() throws Exception { + ProcessedOption opt = ProcessedOptionBuilder.builder() + .name("homepage") + .type(String.class) + .fieldName("homepage") + .url(true) + .build(); + + assertTrue("Explicit url=true should set isUrl", opt.isUrl()); + } + + @Test + public void testStringOptionNotUrl() throws Exception { + ProcessedOption opt = ProcessedOptionBuilder.builder() + .name("name") + .type(String.class) + .fieldName("name") + .build(); + + assertFalse("String-typed option should not be URL by default", opt.isUrl()); + } + + @Test + public void testGetFormattedValueWithHyperlinks() throws Exception { + ProcessedOption opt = ProcessedOptionBuilder.builder() + .name("endpoint") + .type(String.class) + .fieldName("endpoint") + .url(true) + .build(); + + opt.addValue("https://example.com"); + + String formatted = opt.getFormattedValue(true); + assertTrue("Formatted value should contain hyperlink start", + formatted.contains(ANSI.buildHyperlinkStart("https://example.com", null))); + assertTrue("Formatted value should contain hyperlink end", + formatted.contains(ANSI.buildHyperlinkEnd())); + + String plain = opt.getFormattedValue(false); + assertEquals("Without hyperlinks, should return plain value", + "https://example.com", plain); + } + + @Test + public void testDescriptionUrl() throws Exception { + ProcessedOption opt = ProcessedOptionBuilder.builder() + .name("env") + .shortName('e') + .type(String.class) + .fieldName("environment") + .description("Target environment") + .descriptionUrl("https://docs.example.com/environments") + .build(); + + assertEquals("https://docs.example.com/environments", opt.getDescriptionUrl()); + + // With hyperlinks supported, description should be wrapped + String formatted = opt.getFormattedOption(2, 30, 80, true); + assertTrue("Formatted option with hyperlink support should contain hyperlink", + formatted.contains(ANSI.buildHyperlinkStart("https://docs.example.com/environments", null))); + + // Without hyperlinks, should be plain text + String plain = opt.getFormattedOption(2, 30, 80, false); + assertTrue("Plain formatted option should contain description", + plain.contains("Target environment")); + assertFalse("Plain formatted option should not contain hyperlink codes", + plain.contains(ANSI.buildHyperlinkStart("https://docs.example.com/environments", null))); + } + + @Test + public void testHelpUrlInPrintHelp() throws Exception { + ProcessedCommand cmd = ProcessedCommandBuilder.builder() + .name("deploy") + .description("Deploy an application") + .helpUrl("https://docs.example.com/deploy") + .create(); + + // Without hyperlinks + String helpPlain = cmd.printHelp("deploy", false); + assertTrue("Help should contain Documentation line", + helpPlain.contains("Documentation: https://docs.example.com/deploy")); + + // With hyperlinks + String helpHyperlink = cmd.printHelp("deploy", true); + assertTrue("Help with hyperlinks should contain hyperlink codes", + helpHyperlink.contains(ANSI.hyperlink("https://docs.example.com/deploy", "https://docs.example.com/deploy"))); + } + + @Test + public void testHelpUrlOmittedWhenEmpty() throws Exception { + ProcessedCommand cmd = ProcessedCommandBuilder.builder() + .name("test") + .description("A test command") + .create(); + + String help = cmd.printHelp("test"); + assertFalse("Help should not contain Documentation line when helpUrl is empty", + help.contains("Documentation:")); + } + + @Test + public void testCommandDefinitionHelpUrl() throws IOException, InterruptedException, CommandRegistryException { + TestConnection connection = new TestConnection(); + + CommandRegistry registry = AeshCommandRegistryBuilder.builder() + .command(HelpUrlCommand.class) + .create(); + + Settings settings = SettingsBuilder + .builder() + .commandRegistry(registry) + .enableOperatorParser(true) + .connection(connection) + .setPersistExport(false) + .logging(true) + .build(); + + ReadlineConsole console = new ReadlineConsole(settings); + console.start(); + connection.read("helpurl -h" + Config.getLineSeparator()); + Thread.sleep(100); + + String output = connection.getOutputBuffer(); + assertTrue("Help output should contain documentation URL", + output.contains("Documentation: https://docs.example.com/helpurl")); + + console.stop(); + } + + @Test + public void testCommandWithUrlOption() throws Exception { + CommandRegistry registry = AeshCommandRegistryBuilder.builder() + .command(UrlOptionCommand.class) + .create(); + CommandRuntime runtime = AeshCommandRuntimeBuilder.builder() + .commandRegistry(registry) + .build(); + + runtime.executeCommand("urlopt --endpoint https://api.example.com"); + } + + @Test + public void testCommandWithDescriptionUrl() throws IOException, InterruptedException, CommandRegistryException { + TestConnection connection = new TestConnection(); + + CommandRegistry registry = AeshCommandRegistryBuilder.builder() + .command(DescUrlCommand.class) + .create(); + + Settings settings = SettingsBuilder + .builder() + .commandRegistry(registry) + .enableOperatorParser(true) + .connection(connection) + .setPersistExport(false) + .logging(true) + .build(); + + ReadlineConsole console = new ReadlineConsole(settings); + console.start(); + connection.read("descurl -h" + Config.getLineSeparator()); + Thread.sleep(100); + + String output = connection.getOutputBuffer(); + assertTrue("Help output should contain the option description", + output.contains("Target environment")); + + console.stop(); + } + + @Test + public void testPrintHyperlinkFromCommand() throws Exception { + // Use TestConnection without stripping ANSI codes to verify hyperlink output + TestConnection connection = new TestConnection(false); + + CommandRegistry registry = AeshCommandRegistryBuilder.builder() + .command(HyperlinkOutputCommand.class) + .create(); + + Settings settings = SettingsBuilder + .builder() + .commandRegistry(registry) + .enableOperatorParser(true) + .connection(connection) + .setPersistExport(false) + .logging(true) + .build(); + + ReadlineConsole console = new ReadlineConsole(settings); + console.start(); + connection.read("hyperlinkout" + Config.getLineSeparator()); + Thread.sleep(100); + + // The command calls printHyperlink. Since TestConnection's device + // is vt100 which doesn't support hyperlinks, it should fall back to plain text. + String output = connection.getOutputBuffer(); + assertTrue("Output should contain the link text", output.contains("Click here")); + + console.stop(); + } + + // ========== Test Command Classes ========== + + @CommandDefinition(name = "helpurl", generateHelp = true, description = "A command with help URL", helpUrl = "https://docs.example.com/helpurl") + public static class HelpUrlCommand implements Command { + @Option(description = "a value") + private String value; + + @Override + public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + return CommandResult.SUCCESS; + } + } + + @CommandDefinition(name = "urlopt", description = "Command with URL option") + public static class UrlOptionCommand implements Command { + @Option(description = "API endpoint", url = true) + private String endpoint; + + @Override + public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + return CommandResult.SUCCESS; + } + } + + @CommandDefinition(name = "descurl", generateHelp = true, description = "Command with description URL") + public static class DescUrlCommand implements Command { + @Option(shortName = 'e', description = "Target environment", descriptionUrl = "https://docs.example.com/environments") + private String environment; + + @Override + public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + return CommandResult.SUCCESS; + } + } + + @CommandDefinition(name = "hyperlinkout", description = "Command that outputs hyperlinks") + public static class HyperlinkOutputCommand implements Command { + @Override + public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + commandInvocation.printHyperlink("https://example.com", "Click here"); + return CommandResult.SUCCESS; + } + } +}