From 254838fb134943acf38aaad5be71e6c5ed50eccb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Pedersen?= Date: Mon, 2 Feb 2026 14:55:35 +0100 Subject: [PATCH] Added support for negatable options, fixes #363 --- .gitignore | 3 + .../AeshCommandContainerBuilder.java | 2 + .../impl/internal/ProcessedCommand.java | 74 +++++++++++++++++-- .../impl/internal/ProcessedOption.java | 69 ++++++++++++++++- .../impl/internal/ProcessedOptionBuilder.java | 24 +++++- .../command/impl/parser/AeshOptionParser.java | 10 ++- .../java/org/aesh/command/option/Option.java | 15 ++++ .../completer/CompletionParserTest.java | 32 ++++++++ .../command/parser/CommandLineParserTest.java | 53 +++++++++++++ 9 files changed, 270 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 515c297a1..fca5066be 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ classes #ignore checkstile files .checkstyle + +# ignore maven versionsBackup +*.versionsBackup 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 172837b50..90102d136 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 @@ -189,6 +189,8 @@ private static void processField(ProcessedCommand processedCommand, Field field) .renderer(o.renderer()) .parser(o.parser()) .overrideRequired(o.overrideRequired()) + .negatable(o.negatable()) + .negationPrefix(o.negationPrefix()) .build() ); } 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 b58440e4c..bfc6e5daa 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 @@ -122,7 +122,8 @@ public void addOption(ProcessedOption opt) throws OptionParserException { this.options.add(new ProcessedOption(verifyThatNamesAreUnique(opt.shortName(), opt.name()), opt.name(), opt.description(), opt.getArgument(), opt.isRequired(), opt.getValueSeparator(), opt.askIfNotSet(), 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.completer(), opt.validator(), opt.activator(), opt.getRenderer(), opt.parser(), opt.doOverrideRequired(), + opt.isNegatable(), opt.getNegationPrefix())); options.get(options.size()-1).setParent(this); } @@ -133,7 +134,7 @@ private void setOptions(List options) throws OptionParserExcept opt.description(), opt.getArgument(), opt.isRequired(), opt.getValueSeparator(), opt.askIfNotSet(), 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.parser(), opt.doOverrideRequired(), opt.isNegatable(), opt.getNegationPrefix())); this.options.get(this.options.size()-1).setParent(this); } @@ -246,9 +247,14 @@ public ProcessedOption findOptionNoActivatorCheck(String name) { */ public ProcessedOption searchAllOptions(String input) { if (input.startsWith("--")) { - ProcessedOption currentOption = findLongOptionNoActivatorCheck(input.substring(2)); + String optionName = input.substring(2); + ProcessedOption currentOption = findLongOptionNoActivatorCheck(optionName); if(currentOption == null && input.contains("=")) - currentOption = startWithLongOptionNoActivatorCheck(input.substring(2)); + currentOption = startWithLongOptionNoActivatorCheck(optionName); + // Check for negated options (e.g., --no-verbose) + if (currentOption == null) { + currentOption = findNegatedOptionNoActivatorCheck(optionName); + } if (currentOption != null) currentOption.setLongNameUsed(true); //need to handle spaces in option names @@ -299,6 +305,42 @@ public ProcessedOption findLongOptionNoActivatorCheck(String name) { return null; } + /** + * Find an option by its negated name (e.g., "no-verbose" for option "verbose"). + * If found, marks the option as negated. + * + * @param name the negated name to search for + * @return the matching option, or null if not found + */ + public ProcessedOption findNegatedOption(String name) { + for (ProcessedOption option : getOptions()) { + if (option.isNegatable() && option.getNegatedName() != null && + option.getNegatedName().equals(name) && + option.activator().isActivated(new ParsedCommand(this))) { + option.setNegatedByUser(true); + return option; + } + } + return null; + } + + /** + * Find an option by its negated name without checking activator. + * + * @param name the negated name to search for + * @return the matching option, or null if not found + */ + public ProcessedOption findNegatedOptionNoActivatorCheck(String name) { + for (ProcessedOption option : getOptions()) { + if (option.isNegatable() && option.getNegatedName() != null && + option.getNegatedName().equals(name)) { + option.setNegatedByUser(true); + return option; + } + } + return null; + } + public ProcessedOption findBareLongOption(String name) { for (ProcessedOption option : getOptions()) if(option.name() != null && option.name().equals(name) && option.acceptNameWithoutDashes()) @@ -429,15 +471,21 @@ public boolean isGenerateVersionOptionSet() { /** * Return all option names that not already have a value - * and is enabled + * and is enabled. For negatable options, also includes the negated form. */ public List getOptionLongNamesWithDash() { List opts = getOptions(); List names = new ArrayList<>(opts.size()); for (ProcessedOption o : opts) { if(o.getValues().size() == 0 && - o.activator().isActivated(new ParsedCommand(this))) + o.activator().isActivated(new ParsedCommand(this))) { names.add(o.getRenderedNameWithDashes()); + // Also add the negated form for negatable options + TerminalString negated = o.getRenderedNegatedNameWithDashes(); + if (negated != null) { + names.add(negated); + } + } } return names; @@ -452,6 +500,15 @@ public List findPossibleLongNamesWithDash(String name) { (o.name().startsWith(name) && o.getValues().size() == 0)) && o.activator().isActivated(new ParsedCommand(this))) names.add(o.getRenderedNameWithDashes()); + // Also check negated option names for negatable options + if (o.isNegatable() && o.getNegatedName() != null && + o.getNegatedName().startsWith(name) && o.getValues().size() == 0 && + o.activator().isActivated(new ParsedCommand(this))) { + TerminalString negated = o.getRenderedNegatedNameWithDashes(); + if (negated != null) { + names.add(negated); + } + } } return names; } @@ -467,6 +524,11 @@ public List findPossibleLongNames(String name) { (o.name().startsWith(name) && o.getValues().size() == 0)) && o.activator().isActivated(new ParsedCommand(this))) names.add(o.name()); + // Also check negated option names for negatable options + if (o.isNegatable() && o.getNegatedName() != null && + o.getNegatedName().startsWith(name) && o.getValues().size() == 0 && + o.activator().isActivated(new ParsedCommand(this))) + names.add(o.getNegatedName()); } return names; } 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 2dbc55b39..6c4e28a31 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 @@ -86,6 +86,9 @@ public final class ProcessedOption { private boolean askIfNotSet = false; private boolean acceptNameWithoutDashes = false; private final SelectorType selectorType; + private boolean negatable = false; + private String negationPrefix = "no-"; + private boolean negatedByUser = false; public ProcessedOption(char shortName, String name, String description, String argument, boolean required, char valueSeparator, boolean askIfNotSet, boolean acceptNameWithoutDashes, @@ -95,7 +98,7 @@ public ProcessedOption(char shortName, String name, String description, OptionValidator optionValidator, OptionActivator activator, OptionRenderer renderer, OptionParser parser, - boolean overrideRequired) throws OptionParserException { + boolean overrideRequired, boolean negatable, String negationPrefix) throws OptionParserException { if(shortName != '\u0000') this.shortName = String.valueOf(shortName); @@ -127,6 +130,8 @@ public ProcessedOption(char shortName, String name, String description, this.renderer = renderer; this.defaultValues = PropertiesLookup.checkForSystemVariables(defaultValue); + this.negatable = negatable; + this.negationPrefix = negationPrefix != null ? negationPrefix : "no-"; properties = new HashMap<>(); values = new ArrayList<>(); @@ -285,6 +290,37 @@ public SelectorType selectorType() { return selectorType; } + public boolean isNegatable() { + return negatable; + } + + public String getNegationPrefix() { + return negationPrefix; + } + + /** + * Returns the negated form of this option's name. + * For example, if name is "verbose" and prefix is "no-", returns "no-verbose". + * Returns null if this option is not negatable. + */ + public String getNegatedName() { + return negatable && name != null ? negationPrefix + name : null; + } + + /** + * Returns true if this option was specified in its negated form (e.g., --no-verbose). + */ + public boolean isNegatedByUser() { + return negatedByUser; + } + + /** + * Sets whether this option was specified in its negated form. + */ + public void setNegatedByUser(boolean negatedByUser) { + this.negatedByUser = negatedByUser; + } + public void clear() { if(values != null) values.clear(); @@ -294,6 +330,7 @@ public void clear() { endsWithSeparator = false; cursorOption = false; cursorValue = false; + negatedByUser = false; } public String getDisplayName() { @@ -315,6 +352,22 @@ public TerminalString getRenderedNameWithDashes() { return new TerminalString( hasValue() ? prefix+name+"=" : prefix+name, renderer.getColor(), renderer.getTextType()); } + /** + * Returns the negated form of the option name with dashes for completion. + * For example, for option "verbose" with prefix "no-", returns "--no-verbose". + * Returns null if this option is not negatable. + */ + public TerminalString getRenderedNegatedNameWithDashes() { + if (!negatable || name == null) { + return null; + } + // Negatable options are boolean, so they don't have a value suffix + if (renderer == null || !ansiMode) + return new TerminalString("--" + negationPrefix + name, true); + else + return new TerminalString("--" + negationPrefix + name, renderer.getColor(), renderer.getTextType()); + } + public int getFormattedLength() { StringBuilder sb = new StringBuilder(); if(shortName != null) @@ -323,6 +376,10 @@ public int getFormattedLength() { if(sb.toString().trim().length() > 0) sb.append(", "); sb.append("--").append(name); + // Add negated form for negatable options + if(negatable) { + sb.append(", --").append(negationPrefix).append(name); + } } if(argument != null && argument.length() > 0) { sb.append("=<").append(argument).append(">"); @@ -344,6 +401,10 @@ public String getFormattedOption(int offset, int descriptionStart, int width) { if(shortName != null) sb.append(", "); sb.append("--").append(name); + // Add negated form for negatable options + if(negatable) { + sb.append(", --").append(negationPrefix).append(name); + } } if(argument != null && argument.length() > 0) { sb.append("=<").append(argument).append(">"); @@ -397,7 +458,11 @@ public void injectValueIntoField(Object instance, InvocationProviders invocation constructor.setAccessible(true); } if(optionType == OptionType.NORMAL || optionType == OptionType.BOOLEAN || optionType == OptionType.ARGUMENT) { - if(getValue() != null) + // Handle negatable options - when used in negated form (e.g., --no-verbose), inject false + if(negatedByUser && optionType == OptionType.BOOLEAN) { + field.set(instance, doConvert("false", invocationProviders, instance, aeshContext, doValidation)); + } + else if(getValue() != null) field.set(instance, doConvert(getValue(), invocationProviders, instance, aeshContext, doValidation)); else if(defaultValues.size() > 0) { field.set(instance, doConvert(defaultValues.get(0), invocationProviders, instance, aeshContext, doValidation)); 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 fee2e24ef..0f81c00c3 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 @@ -75,6 +75,8 @@ public class ProcessedOptionBuilder { private boolean askIfNotSet = false; private boolean acceptNameWithoutDashes = false; private SelectorType selectorType; + private boolean negatable = false; + private String negationPrefix = "no-"; private ProcessedOptionBuilder() { defaultValues = new ArrayList<>(); @@ -299,6 +301,21 @@ private OptionParser initParser(Class parser) { return null; } + /** + * Set whether this option is negatable (supports --no-{name} form). + * Only valid for boolean options. + */ + public ProcessedOptionBuilder negatable(boolean negatable) { + return apply(c -> c.negatable = negatable); + } + + /** + * Set the prefix used for negation. Default is "no-". + */ + public ProcessedOptionBuilder negationPrefix(String negationPrefix) { + return apply(c -> c.negationPrefix = negationPrefix); + } + public ProcessedOption build() throws OptionParserException { if(optionType == null) { if(!hasValue) @@ -345,8 +362,13 @@ else if(hasMultipleValues) //if(renderer == null) // renderer = new NullOptionRenderer(); + // Validate that negatable is only used with boolean types + if(negatable && type != Boolean.class && type != boolean.class) { + throw new OptionParserException("Option '" + name + "' is marked as negatable but is not a boolean type"); + } + return new ProcessedOption(shortName, name, description, argument, required, valueSeparator, askIfNotSet, acceptNameWithoutDashes, selectorType, defaultValues, type, fieldName, optionType, converter, - completer, validator, activator, renderer, parser, overrideRequired); + completer, validator, activator, renderer, parser, overrideRequired, negatable, negationPrefix); } } diff --git a/aesh/src/main/java/org/aesh/command/impl/parser/AeshOptionParser.java b/aesh/src/main/java/org/aesh/command/impl/parser/AeshOptionParser.java index 8d5559be4..d129ea28e 100644 --- a/aesh/src/main/java/org/aesh/command/impl/parser/AeshOptionParser.java +++ b/aesh/src/main/java/org/aesh/command/impl/parser/AeshOptionParser.java @@ -79,10 +79,14 @@ private void preProcessOption(ProcessedOption option, ParsedLineIterator iterato word = Parser.switchSpacesToEscapedSpacesInWord(word); if(option.isLongNameUsed()) { String optionPart = word.startsWith("--") ? word.substring(2) : word; - if(optionPart.length() != option.name().length()) - processOption(option, optionPart, option.name()); + // For negatable options, we need to use the correct name for comparison + String nameToMatch = option.isNegatedByUser() && option.getNegatedName() != null + ? option.getNegatedName() : option.name(); + if(optionPart.length() != nameToMatch.length()) + processOption(option, optionPart, nameToMatch); else if(option.getOptionType() == OptionType.BOOLEAN) { - option.addValue("true"); + // For negatable options, use "false" if specified in negated form + option.addValue(option.isNegatedByUser() ? "false" : "true"); status = Status.NULL; } else 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 88b9768d5..a00b0cfc8 100644 --- a/aesh/src/main/java/org/aesh/command/option/Option.java +++ b/aesh/src/main/java/org/aesh/command/option/Option.java @@ -151,4 +151,19 @@ */ Class parser() default AeshOptionParser.class; + /** + * When set to true for boolean options, automatically supports --no-{name} + * to set the value to false. Only valid for boolean/Boolean field types. + * For example, if the option is named "verbose", both --verbose and --no-verbose + * will be recognized. + */ + boolean negatable() default false; + + /** + * The prefix used for negation when negatable=true. + * Default is "no-". For example, with name="verbose" and default prefix, + * the negated form will be --no-verbose. + */ + String negationPrefix() default "no-"; + } diff --git a/aesh/src/test/java/org/aesh/command/completer/CompletionParserTest.java b/aesh/src/test/java/org/aesh/command/completer/CompletionParserTest.java index f957ce761..423611c08 100644 --- a/aesh/src/test/java/org/aesh/command/completer/CompletionParserTest.java +++ b/aesh/src/test/java/org/aesh/command/completer/CompletionParserTest.java @@ -646,5 +646,37 @@ public class ParseCompleteBareTest extends TestCom @Option(name = "standard", hasValue = false) // acceptNameWithoutDashes = false by default private Boolean standard; } + + @Test + public void testNegatableOptionCompletion() throws Exception { + CommandLineParser clp = new AeshCommandContainerBuilder<>().create(new ParseCompleteNegatableTest<>()).getParser(); + InvocationProviders ip = SettingsBuilder.builder().build().invocationProviders(); + + // Test that completion shows both --verbose and --no-verbose + AeshCompleteOperation co = new AeshCompleteOperation(aeshContext, "test --", 7); + clp.complete(co, ip); + List candidates = co.getFormattedCompletionCandidates(); + assertTrue("Should contain --verbose", candidates.stream().anyMatch(c -> c.contains("verbose"))); + assertTrue("Should contain --no-verbose", candidates.stream().anyMatch(c -> c.contains("no-verbose"))); + + // Test that --no-v completes to --no-verbose + co = new AeshCompleteOperation(aeshContext, "test --no-v", 11); + clp.complete(co, ip); + assertEquals(1, co.getFormattedCompletionCandidates().size()); + assertEquals("erbose", co.getFormattedCompletionCandidates().get(0)); + + // Test that --v completes to --verbose + co = new AeshCompleteOperation(aeshContext, "test --v", 8); + clp.complete(co, ip); + assertEquals(1, co.getFormattedCompletionCandidates().size()); + assertEquals("erbose", co.getFormattedCompletionCandidates().get(0)); + } + + @CommandDefinition(name = "test", description = "test negatable option completion") + public class ParseCompleteNegatableTest extends TestCommand { + + @Option(name = "verbose", hasValue = false, negatable = true, description = "enable verbose mode") + private boolean verbose; + } } diff --git a/aesh/src/test/java/org/aesh/command/parser/CommandLineParserTest.java b/aesh/src/test/java/org/aesh/command/parser/CommandLineParserTest.java index ae12e5a3a..73f042f15 100644 --- a/aesh/src/test/java/org/aesh/command/parser/CommandLineParserTest.java +++ b/aesh/src/test/java/org/aesh/command/parser/CommandLineParserTest.java @@ -684,4 +684,57 @@ public CommandResult execute(CI commandInvocation) throws CommandException, Inte } } + @Test + public void testNegatableOption() throws Exception { + AeshContext aeshContext = SettingsBuilder.builder().build().aeshContext(); + CommandLineParser parser = new AeshCommandContainerBuilder<>().create(new NegatableOptionCommand<>()).getParser(); + + // Test that --verbose sets verbose to true + parser.populateObject("negatable --verbose", invocationProviders, aeshContext, CommandLineParser.Mode.VALIDATE); + NegatableOptionCommand cmd = (NegatableOptionCommand) parser.getCommand(); + assertTrue(cmd.verbose); + + // Test that --no-verbose sets verbose to false + parser.populateObject("negatable --no-verbose", invocationProviders, aeshContext, CommandLineParser.Mode.VALIDATE); + cmd = (NegatableOptionCommand) parser.getCommand(); + assertFalse(cmd.verbose); + + // Test with custom prefix + parser.populateObject("negatable --without-debug", invocationProviders, aeshContext, CommandLineParser.Mode.VALIDATE); + cmd = (NegatableOptionCommand) parser.getCommand(); + assertFalse(cmd.debug); + + // Test that --debug sets debug to true + parser.populateObject("negatable --debug", invocationProviders, aeshContext, CommandLineParser.Mode.VALIDATE); + cmd = (NegatableOptionCommand) parser.getCommand(); + assertTrue(cmd.debug); + } + + @Test + public void testNegatableOptionHelp() throws Exception { + CommandLineParser parser = new AeshCommandContainerBuilder<>().create(new NegatableOptionCommand<>()).getParser(); + + String help = parser.printHelp(); + // Help should include both the normal and negated forms + assertTrue(help.contains("--verbose")); + assertTrue(help.contains("--no-verbose")); + assertTrue(help.contains("--debug")); + assertTrue(help.contains("--without-debug")); + } + + @CommandDefinition(name = "negatable", description = "test negatable options") + public class NegatableOptionCommand implements Command { + + @Option(hasValue = false, negatable = true, description = "enable verbose mode") + private boolean verbose; + + @Option(hasValue = false, negatable = true, negationPrefix = "without-", description = "enable debug mode") + private boolean debug; + + @Override + public CommandResult execute(CI commandInvocation) throws CommandException, InterruptedException { + return CommandResult.SUCCESS; + } + } + }