diff --git a/.gitignore b/.gitignore index 9b50ffe..d2e5e90 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ # Build System files target/ .flattened-pom.xml +.build/ # Virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* diff --git a/base-commandline/src/main/java/build/base/commandline/Command.java b/base-commandline/src/main/java/build/base/commandline/Command.java new file mode 100644 index 0000000..7ff1ee9 --- /dev/null +++ b/base-commandline/src/main/java/build/base/commandline/Command.java @@ -0,0 +1,269 @@ +package build.base.commandline; + +/*- + * #%L + * base.build Command Line + * %% + * Copyright (C) 2026 Workday Inc + * %% + * 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. + * #L% + */ + +import build.base.commandline.CommandLineParser.UnknownOptionConsumer; +import build.base.configuration.Option; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Function; + +/** + * A fluent builder for constructing a {@link CommandLineParser}, providing a chainable API for registering + * {@link Option}s, positional argument labels, help handlers, help section grouping, and environment variable + * fallbacks. + *

+ * All {@link Option} registrations are deferred until {@link #build()} is called. Any {@link NoSuchMethodException} + * raised during registration is wrapped in an {@link IllegalArgumentException}, eliminating the need for callers + * to handle checked exceptions. + *

+ * Example: + *

{@code
+ * CommandLineParser parser = Command.create("spin")
+ *     .argument("...")
+ *     .helpHandler(help -> { System.out.println(help); System.exit(0); })
+ *     .unknownOption(CommandLineParser.IGNORE_UNKNOWN_OPTIONS)
+ *     .section("Global options")
+ *     .option("-w", WorkingDirectory.class, "of", String.class)
+ *     .option(NetworkAccess.class)
+ *     .envVar("SPIN_WORKING_DIR", "-w")
+ *     .section("Task options")
+ *     .option(JavaTask.class)
+ *     .build();
+ * }
+ * + * @author reed.vonredwitz + * @since Apr-2026 + * @see CommandLineParser + */ +public final class Command { + + /** + * The program name, used in the help usage line. + */ + private final String name; + + /** + * The positional arguments label for the help usage line (e.g. {@code "..."}). + */ + private String usageArguments; + + /** + * An optional {@link Consumer} to call with help text instead of throwing a {@link CommandLineParser.HelpException}. + */ + private Consumer helpHandler; + + /** + * The {@link UnknownOptionConsumer} for unrecognised arguments. + */ + private UnknownOptionConsumer unknownOptionConsumer; + + /** + * Deferred registration actions applied to the {@link CommandLineParser} during {@link #build()}. + */ + private final List> registrations; + + /** + * Constructs a {@link Command} for the specified program name. + * + * @param name the program name + */ + private Command(final String name) { + this.name = name; + this.registrations = new ArrayList<>(); + } + + /** + * Creates a {@link Command} for the specified program name. + * + * @param name the program name (used in the help usage line) + * @return a new {@link Command} + */ + public static Command create(final String name) { + return new Command(name); + } + + /** + * Sets the positional arguments label for the help usage line. + *

+ * When set alongside a program name, the usage line becomes: + * {@code Usage: [options] }. + * + * @param label the positional arguments label (e.g. {@code "..."}) + * @return this {@link Command} + */ + public Command argument(final String label) { + this.usageArguments = label; + + return this; + } + + /** + * Sets a {@link Consumer} to call with help text instead of throwing a {@link CommandLineParser.HelpException}. + * + * @param handler the handler to receive the formatted help text + * @return this {@link Command} + */ + public Command helpHandler(final Consumer handler) { + this.helpHandler = handler; + + return this; + } + + /** + * Sets the {@link UnknownOptionConsumer} for arguments that do not match any registered {@link Option} prefix. + * + * @param consumer the consumer, or {@code null} to clear + * @return this {@link Command} + */ + public Command unknownOption(final UnknownOptionConsumer consumer) { + this.unknownOptionConsumer = consumer; + + return this; + } + + /** + * Sets the current help section name for subsequently registered {@link Option}s. + *

+ * All options added after this call appear under the specified heading in the help output, + * mirroring cliffy's {@code .group()} API. Call again with a different name to start a new + * section, or with {@code null} to return to the default (unsectioned) group. + * + * @param name the section heading (e.g. {@code "Global options"}), or {@code null} to clear + * @return this {@link Command} + */ + public Command section(final String name) { + this.registrations.add(parser -> parser.setHelpSection(name)); + + return this; + } + + /** + * Registers a {@link CommandLine}-annotated {@link Option} class. + *

+ * The class must have at least one {@code static} method annotated with {@link CommandLine.Prefix}. + * + * @param optionClass the {@link Class} of {@link Option} to register + * @return this {@link Command} + */ + public Command option(final Class optionClass) { + this.registrations.add(parser -> parser.add(optionClass)); + + return this; + } + + /** + * Registers an {@link Option} class with an explicit prefix and factory method. + *

+ * The {@link Option} class does not need a {@link CommandLine.Prefix}-annotated method; the factory method is + * located by name and parameter types at {@link #build()} time. Any {@link NoSuchMethodException} is wrapped + * in an {@link IllegalArgumentException}. + * + * @param prefix the command-line prefix (e.g. {@code "-w"} or {@code "--working-dir"}) + * @param optionClass the {@link Class} of {@link Option} + * @param methodName the name of the {@code static} factory method + * @param parameterTypes the parameter types of the factory method + * @return this {@link Command} + */ + public Command option(final String prefix, + final Class optionClass, + final String methodName, + final Class... parameterTypes) { + + this.registrations.add(parser -> { + try { + parser.add(prefix, optionClass, methodName, parameterTypes); + } + catch (final NoSuchMethodException e) { + throw new IllegalArgumentException( + "No method [" + methodName + "] with the given parameter types on [" + + optionClass.getName() + "]", e); + } + }); + + return this; + } + + /** + * Registers an environment variable fallback for the specified command-line prefix. + *

+ * When the registered environment variable is set and the option has not been provided on the command line, + * the environment variable value is injected as if the user had passed {@code }. + * Command-line arguments always take precedence. + *

+ * The default resolver uses {@link System#getenv(String)}. To use a custom resolver + * (e.g. for testing), call {@link #envVarResolver(Function)}. + * + * @param envVarName the name of the environment variable (e.g. {@code "SPIN_WORKING_DIR"}) + * @param prefix the command-line prefix this variable maps to (e.g. {@code "-w"}) + * @return this {@link Command} + */ + public Command envVar(final String envVarName, final String prefix) { + this.registrations.add(parser -> parser.registerEnvVar(envVarName, prefix)); + + return this; + } + + /** + * Sets a custom environment variable resolver used to look up registered environment variables. + *

+ * Defaults to {@link System#getenv(String)} wrapped in {@link Optional#ofNullable(Object)}. + * Pass {@code null} to disable environment variable resolution entirely. + * + * @param resolver a function that maps an environment variable name to its value, or {@code null} to disable + * @return this {@link Command} + */ + public Command envVarResolver(final Function> resolver) { + this.registrations.add(parser -> parser.setEnvironmentVariableResolver(resolver)); + + return this; + } + + /** + * Builds and returns a configured {@link CommandLineParser}. + * + * @return a new {@link CommandLineParser} configured according to this {@link Command} + * @throws IllegalArgumentException if any registered option method cannot be found + */ + public CommandLineParser build() { + final var parser = new CommandLineParser() + .setHelpUsageProgramName(this.name); + + if (this.usageArguments != null) { + parser.setHelpUsageArguments(this.usageArguments); + } + + if (this.helpHandler != null) { + parser.setHelpHandler(this.helpHandler); + } + + if (this.unknownOptionConsumer != null) { + parser.setUnknownOptionConsumer(this.unknownOptionConsumer); + } + + this.registrations.forEach(registration -> registration.accept(parser)); + + return parser; + } +} diff --git a/base-commandline/src/main/java/build/base/commandline/CommandLineParser.java b/base-commandline/src/main/java/build/base/commandline/CommandLineParser.java index 204d15c..4c8e4e2 100644 --- a/base-commandline/src/main/java/build/base/commandline/CommandLineParser.java +++ b/base-commandline/src/main/java/build/base/commandline/CommandLineParser.java @@ -34,9 +34,13 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Modifier; +import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -44,7 +48,10 @@ import java.util.TreeMap; import java.util.TreeSet; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Function; import java.util.stream.Collectors; +import java.util.stream.Stream; /** * Parses {@link String} arguments, typically passed into a {@code public static void main(String... arguments)} method, @@ -199,9 +206,19 @@ public int compare(final String prefix1, final String prefix2) { }; /** - * The {@link Method}s used to construct each {@link Option}. + * {@link Method}s used to construct each {@link Option}, grouped by help section name. + *

+ * Insertion order is preserved for both sections and methods within each section. The empty string + * key represents the default (unsectioned) group, which renders without a section header. + */ + private final LinkedHashMap> sectionedMethods; + + /** + * The current help section name applied to subsequently registered {@link Option}s. + *

+ * Defaults to {@code ""} (no section header). Changed via {@link #setHelpSection(String)}. */ - private final Set constructionMethods; + private String currentHelpSection; /** * A {@link Map} of construction {@link Method}s by {@link CommandLine.Prefix} value. @@ -213,6 +230,14 @@ public int compare(final String prefix1, final String prefix2) { */ private final Capture unknownOptionConsumer; + /** + * The {@link Capture}d positional argument {@link Consumer} for the {@link CommandLineParser}. + *

+ * When set, non-hyphen-prefixed arguments that do not match any registered {@link Option} prefix are + * routed here rather than to the {@link #unknownOptionConsumer}. + */ + private final Capture> positionalArgumentConsumer; + /** * Should {@link #HELP_ARGUMENT_SHORT} and {@link #HELP_ARGUMENT_LONG} arguments be ignored. */ @@ -223,6 +248,29 @@ public int compare(final String prefix1, final String prefix2) { */ private String helpUsageProgramName; + /** + * Help text usage positional arguments label (e.g. {@code "..."}). + */ + private String helpUsageArguments; + + /** + * An optional {@link Consumer} to call with the help text instead of throwing a {@link HelpException}. + */ + private Consumer helpHandler; + + /** + * A {@link Function} used to resolve environment variable values by name. + *

+ * Defaults to {@link System#getenv(String)} wrapped in {@link Optional#ofNullable(Object)}. + * Set to {@code null} to disable environment variable resolution entirely. + */ + private Function> envVarResolver; + + /** + * Registered environment variable mappings: environment variable name to command-line prefix. + */ + private final LinkedHashMap envVarMappings; + /** * Constructs a {@link CommandLineParser}. */ @@ -233,8 +281,12 @@ public CommandLineParser() { ? x.compareTo(y) : y.length() - x.length()); - this.constructionMethods = new HashSet<>(); + this.sectionedMethods = new LinkedHashMap<>(); + this.currentHelpSection = ""; this.unknownOptionConsumer = Capture.empty(); + this.positionalArgumentConsumer = Capture.empty(); + this.envVarResolver = name -> Optional.ofNullable(System.getenv(name)); + this.envVarMappings = new LinkedHashMap<>(); } /** @@ -249,6 +301,120 @@ public CommandLineParser setHelpUsageProgramName(final String programName) { return this; } + /** + * Sets the positional arguments label for the {@link CommandLineParser} help usage line. + *

+ * When set alongside {@link #setHelpUsageProgramName(String)}, the usage line becomes: + * {@code Usage: [options] }. + * + * @param arguments the positional arguments label (e.g. {@code "..."}) + * @return this {@link CommandLineParser} to permit fluent-style method invocation + */ + public CommandLineParser setHelpUsageArguments(final String arguments) { + this.helpUsageArguments = arguments; + + return this; + } + + /** + * Sets a {@link Consumer} to call with the help text instead of throwing a {@link HelpException}. + *

+ * When set, {@link #parse(String...)} will invoke the handler and return an empty + * {@link ConfigurationBuilder} rather than propagating a {@link HelpException}. + * + * @param handler the {@link Consumer} to receive the help text, or {@code null} to revert to exception behaviour + * @return this {@link CommandLineParser} to permit fluent-style method invocation + */ + public CommandLineParser setHelpHandler(final Consumer handler) { + this.helpHandler = handler; + + return this; + } + + /** + * Sets the current help section name for subsequently registered {@link Option}s. + *

+ * All {@link Option}s added after this call will appear under the specified section heading in the + * help output, until this method is called again with a different name. Pass {@code null} or an + * empty string to return to the default (unsectioned) group. + *

+ * Example output with sections: + *

+     * Options:
+     *   Global options
+     *     -w    ...
+     *   Task options
+     *     -j    ...
+     * 
+ * + * @param section the section heading, or {@code null} / empty to clear + * @return this {@link CommandLineParser} to permit fluent-style method invocation + */ + public CommandLineParser setHelpSection(final String section) { + this.currentHelpSection = section == null ? "" : section; + + return this; + } + + /** + * Sets the environment variable resolver used to look up registered environment variables. + *

+ * Defaults to {@link System#getenv(String)} wrapped in {@link Optional#ofNullable(Object)}. + * Pass {@code null} to disable environment variable resolution entirely. + * + * @param resolver a function mapping an environment variable name to its {@link Optional} value, + * or {@code null} to disable + * @return this {@link CommandLineParser} to permit fluent-style method invocation + */ + public CommandLineParser setEnvironmentVariableResolver(final Function> resolver) { + this.envVarResolver = resolver; + + return this; + } + + /** + * Registers an environment variable fallback for the specified command-line prefix. + *

+ * When the registered environment variable is set and no explicit command-line value for the corresponding + * option was provided, the environment variable value is injected as if the user had passed + * {@code } on the command line. Command-line arguments always take precedence because + * env-var-derived arguments are prepended before the actual arguments (last write wins). + * + * @param envVarName the name of the environment variable (e.g. {@code "MY_WORKING_DIR"}) + * @param prefix the command-line prefix this variable maps to (e.g. {@code "-w"}) + * @return this {@link CommandLineParser} to permit fluent-style method invocation + */ + public CommandLineParser registerEnvVar(final String envVarName, final String prefix) { + Objects.requireNonNull(envVarName, "The env var name must not be null"); + Objects.requireNonNull(prefix, "The prefix must not be null"); + + this.envVarMappings.put(envVarName, prefix); + + return this; + } + + /** + * Sets a {@link Consumer} that receives non-hyphen-prefixed arguments that do not match any registered + * {@link Option} prefix. + *

+ * This is the preferred way to declare first-class positional arguments (e.g. task names) rather than + * relying on {@link #CAPTURE_UNKNOWN_OPTIONS_AS_ARGUMENTS}. When a positional consumer is set, hyphen-prefixed + * unknowns still fall through to the {@link UnknownOptionConsumer}. + * + * @param consumer the {@link Consumer} to receive positional arguments, or {@code null} to clear + * @return this {@link CommandLineParser} to permit fluent-style method invocation + */ + public CommandLineParser setPositionalArgumentConsumer(final Consumer consumer) { + if (consumer == null) { + this.positionalArgumentConsumer.clear(); + } + else { + this.positionalArgumentConsumer.set(consumer); + } + + return this; + } + /** * Configures the {@link CommandLineParser} to ignore {@link #HELP_ARGUMENT_SHORT} and {@link #HELP_ARGUMENT_LONG} * arguments. @@ -332,7 +498,9 @@ public CommandLineParser add(final String prefix, // + "option will be ignored.", prefix, optionClass.getName()); } else { - this.constructionMethods.add(method); + this.sectionedMethods + .computeIfAbsent(this.currentHelpSection, k -> new LinkedHashSet<>()) + .add(method); this.prefixMethods.compute(prefix, (__, methods) -> { if (methods == null) { return new HashSet<>(); @@ -404,51 +572,61 @@ private void throwHelpException(final String reason) { } if (this.helpUsageProgramName != null) { - helpTable.addRow("Usage: " + this.helpUsageProgramName + " [options]"); + final var usageLine = new StringBuilder("Usage: ").append(this.helpUsageProgramName).append(" [options]"); + if (this.helpUsageArguments != null) { + usageLine.append(" ").append(this.helpUsageArguments); + } + helpTable.addRow(usageLine.toString()); } helpTable.addRow("Options:"); - final var optionsTable = Table.create(); - optionsTable.options() - .add(CellSeparator.of(" ")) - .add((RowComparator) (row1, row2) -> { - final String row1Prefix = row1.getCell(1).getLine(0).orElse(""); - final String row2Prefix = row2.getCell(1).getLine(0).orElse(""); - return PREFIX_COMPARATOR.compare(row1Prefix, row2Prefix); - }); - - this.constructionMethods.forEach(method -> { - // create an internal table for the option information that orders rows by the prefix for that row - final var optionTable = Table.create(); - - // add the prefixes to the option table - optionTable.addRow(String.join(", ", - Arrays.stream(method.getAnnotationsByType(CommandLine.Prefix.class)) - .map(CommandLine.Prefix::value) - .collect(Collectors.toCollection(() -> new TreeSet<>(PREFIX_COMPARATOR))))); - - // create a new table for the class/description - final var infoTable = Table.create(); - infoTable.options().add(CellSeparator.of(" ")); - - // add the option class as a new row - infoTable.addRow(Cell.create(), Cell.of("(" + method.getDeclaringClass().getName() + ")")); - - // add the description if it exists - Optional.ofNullable(method.getAnnotation(CommandLine.Description.class)) - .map(CommandLine.Description::value) - .ifPresent(description -> infoTable.addRow(Cell.create(), Cell.of(description))); + this.sectionedMethods.forEach((section, methods) -> { + if (!section.isEmpty()) { + helpTable.addRow(" " + section); + } - // add the info table to the optionTable - optionTable.addRow(infoTable.toString()); + final var optionsTable = Table.create(); + optionsTable.options() + .add(CellSeparator.of(" ")) + .add((RowComparator) (row1, row2) -> { + final String row1Prefix = row1.getCell(1).getLine(0).orElse(""); + final String row2Prefix = row2.getCell(1).getLine(0).orElse(""); + return PREFIX_COMPARATOR.compare(row1Prefix, row2Prefix); + }); + + methods.forEach(method -> { + // create an internal table for the option information that orders rows by the prefix for that row + final var optionTable = Table.create(); + + // add the prefixes to the option table + optionTable.addRow(String.join(", ", + Arrays.stream(method.getAnnotationsByType(CommandLine.Prefix.class)) + .map(CommandLine.Prefix::value) + .collect(Collectors.toCollection(() -> new TreeSet<>(PREFIX_COMPARATOR))))); + + // create a new table for the class/description + final var infoTable = Table.create(); + infoTable.options().add(CellSeparator.of(" ")); + + // add the option class as a new row + infoTable.addRow(Cell.create(), Cell.of("(" + method.getDeclaringClass().getName() + ")")); + + // add the description if it exists + Optional.ofNullable(method.getAnnotation(CommandLine.Description.class)) + .map(CommandLine.Description::value) + .ifPresent(description -> infoTable.addRow(Cell.create(), Cell.of(description))); + + // add the info table to the optionTable + optionTable.addRow(infoTable.toString()); + + // add the table as a new row to the optionsTable + optionsTable.addRow(Cell.create(), Cell.of(optionTable.toString())); + }); - // add the table as a new row to the optionsTable - optionsTable.addRow(Cell.create(), Cell.of(optionTable.toString())); + helpTable.addRow(optionsTable.toString()); }); - helpTable.addRow(optionsTable.toString()); - throw new HelpException(helpTable.toString()); } @@ -465,11 +643,67 @@ private void throwHelpException(final String reason) { public ConfigurationBuilder parse(final String... arguments) throws IllegalArgumentException { + try { + return doParse(arguments); + } + catch (final HelpException e) { + if (this.helpHandler != null) { + this.helpHandler.accept(e.getMessage()); + return ConfigurationBuilder.create(); + } + throw e; + } + } + + /** + * Synthesizes command-line arguments derived from registered environment variables. + *

+ * For each registered env var that has a value, injects {@code prefix} followed by the value + * (for options with parameters) or just {@code prefix} (for zero-parameter flags). + * Returns an empty list when no resolver is set or no env vars are registered. + * + * @return a list of synthesized arguments to prepend before the actual command-line arguments + */ + private List synthesizeEnvVarArgs() { + if (this.envVarResolver == null || this.envVarMappings.isEmpty()) { + return List.of(); + } + + final var args = new ArrayList(); + + this.envVarMappings.forEach((envVarName, prefix) -> + this.envVarResolver.apply(envVarName).ifPresent(value -> { + final var methods = this.prefixMethods.get(prefix); + if (methods != null && !methods.isEmpty()) { + final var method = methods.iterator().next(); + args.add(prefix); + if (method.getParameterCount() > 0) { + args.add(value); + } + } + })); + + return args; + } + + /** + * Internal implementation of {@link #parse(String...)}. + */ + private ConfigurationBuilder doParse(final String... arguments) { + final var configurationBuilder = ConfigurationBuilder.create(); - final var canonical = arguments == null || arguments.length == 0 + // prepend env-var-derived args; command-line args come after and override (last write wins) + final var envVarArgs = synthesizeEnvVarArgs(); + final String[] effectiveArguments = envVarArgs.isEmpty() ? arguments - : Arrays.stream(arguments) + : Stream.concat(envVarArgs.stream(), + arguments != null ? Arrays.stream(arguments) : Stream.empty()) + .toArray(String[]::new); + + final var canonical = effectiveArguments == null || effectiveArguments.length == 0 + ? effectiveArguments + : Arrays.stream(effectiveArguments) .map(Strings::trim) .filter(string -> !Strings.isEmpty(string)) .flatMap(string -> Arrays.stream(string.split("="))) @@ -603,8 +837,15 @@ else if (methods.size() != 1) { .map(configurationBuilder::add) .isPresent()) { - // attempt to consume the unknown argument - if (!this.unknownOptionConsumer + // route non-hyphen-prefixed arguments to the positional consumer when set + if (this.positionalArgumentConsumer.isPresent() && !current.startsWith("-")) { + this.positionalArgumentConsumer.map(consumer -> { + consumer.accept(current); + return null; + }); + } + // otherwise attempt to consume as an unknown option + else if (!this.unknownOptionConsumer .map(consumer -> consumer.consume(current, configurationBuilder)) .orElse(false)) { diff --git a/base-commandline/src/test/java/build/base/commandline/CommandLineParserEnvVarTests.java b/base-commandline/src/test/java/build/base/commandline/CommandLineParserEnvVarTests.java new file mode 100644 index 0000000..1034dff --- /dev/null +++ b/base-commandline/src/test/java/build/base/commandline/CommandLineParserEnvVarTests.java @@ -0,0 +1,78 @@ +package build.base.commandline; + +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link CommandLineParser} environment variable fallback + * ({@link CommandLineParser#registerEnvVar(String, String)} and + * {@link CommandLineParser#setEnvironmentVariableResolver(java.util.function.Function)}). + * + * @author reed.vonredwitz + * @since Apr-2026 + */ +class CommandLineParserEnvVarTests { + + /** + * Ensure an env var value is used when no command-line argument is provided. + */ + @Test + void shouldUseEnvVarWhenNoCommandLineArgProvided() { + final var parser = new CommandLineParser() + .add(CommandLineParserTests.OneArgumentOption.class) + .setEnvironmentVariableResolver(name -> "ENABLED".equals(name) ? Optional.of("true") : Optional.empty()) + .registerEnvVar("ENABLED", "-enabled"); + + assertThat(parser.parse().build() + .get(CommandLineParserTests.OneArgumentOption.class)) + .isEqualTo(CommandLineParserTests.OneArgumentOption.of(true)); + } + + /** + * Ensure command-line arguments take precedence over env var values. + */ + @Test + void commandLineArgShouldOverrideEnvVar() { + final var parser = new CommandLineParser() + .add(CommandLineParserTests.OneArgumentOption.class) + .setEnvironmentVariableResolver(name -> "ENABLED".equals(name) ? Optional.of("true") : Optional.empty()) + .registerEnvVar("ENABLED", "-enabled"); + + assertThat(parser.parse("-enabled", "false").build() + .get(CommandLineParserTests.OneArgumentOption.class)) + .isEqualTo(CommandLineParserTests.OneArgumentOption.of(false)); + } + + /** + * Ensure a zero-parameter option is activated when its env var is set. + */ + @Test + void shouldActivateZeroParamOptionFromEnvVar() { + final var parser = new CommandLineParser() + .add(CommandLineParserTests.ZeroArgumentOption.class) + .setEnvironmentVariableResolver(name -> "USE_DEFAULT".equals(name) ? Optional.of("") : Optional.empty()) + .registerEnvVar("USE_DEFAULT", "-default"); + + assertThat(parser.parse().build() + .get(CommandLineParserTests.ZeroArgumentOption.class)) + .isEqualTo(CommandLineParserTests.ZeroArgumentOption.autodetect()); + } + + /** + * Ensure that setting the resolver to {@code null} disables env var resolution entirely. + */ + @Test + void shouldDisableEnvVarResolutionWhenResolverIsNull() { + final var parser = new CommandLineParser() + .add(CommandLineParserTests.OneArgumentOption.class) + .setEnvironmentVariableResolver(null) + .registerEnvVar("ENABLED", "-enabled"); + + assertThat(parser.parse().build() + .get(CommandLineParserTests.OneArgumentOption.class)) + .isNull(); + } +} diff --git a/base-commandline/src/test/java/build/base/commandline/CommandLineParserSectionTests.java b/base-commandline/src/test/java/build/base/commandline/CommandLineParserSectionTests.java new file mode 100644 index 0000000..f29f07a --- /dev/null +++ b/base-commandline/src/test/java/build/base/commandline/CommandLineParserSectionTests.java @@ -0,0 +1,112 @@ +package build.base.commandline; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link CommandLineParser} help section grouping ({@link CommandLineParser#setHelpSection(String)}). + * + * @author reed.vonredwitz + * @since Apr-2026 + */ +class CommandLineParserSectionTests { + + /** + * Ensure {@link CommandLineParser#setHelpSection(String)} causes options to render under named headings + * in the correct order. + */ + @Test + void shouldRenderSectionHeadersInHelp() { + final List helpOutput = new ArrayList<>(); + + final var parser = new CommandLineParser() + .setHelpUsageProgramName("spin") + .setHelpHandler(helpOutput::add) + .setHelpSection("Global options") + .add(CommandLineParserTests.ZeroArgumentOption.class) + .setHelpSection("Task options") + .add(CommandLineParserTests.OneArgumentOption.class); + + parser.parse("--help"); + + assertThat(helpOutput).hasSize(1); + final var help = helpOutput.getFirst(); + assertThat(help).contains("Global options"); + assertThat(help).contains("Task options"); + assertThat(help).containsSubsequence("Global options", "-default", "Task options", "-enabled"); + } + + /** + * Ensure that options registered under sections are still parsed correctly — sections are purely + * a help-rendering concern and must not affect parse behaviour. + */ + @Test + void sectionsShouldNotAffectParsing() { + final var parser = new CommandLineParser() + .setHelpSection("Global options") + .add(CommandLineParserTests.ZeroArgumentOption.class) + .setHelpSection("Task options") + .add(CommandLineParserTests.OneArgumentOption.class); + + final var configuration = parser.parse("-default", "-enabled", "true").build(); + + assertThat(configuration.get(CommandLineParserTests.ZeroArgumentOption.class)) + .isEqualTo(CommandLineParserTests.ZeroArgumentOption.autodetect()); + + assertThat(configuration.get(CommandLineParserTests.OneArgumentOption.class)) + .isEqualTo(CommandLineParserTests.OneArgumentOption.of(true)); + } + + /** + * Ensure options registered without a section render without a section header (backward compatibility). + */ + @Test + void shouldRenderUnsectionedOptionsWithoutHeader() { + final List helpOutput = new ArrayList<>(); + + final var parser = new CommandLineParser() + .setHelpHandler(helpOutput::add) + .add(CommandLineParserTests.ZeroArgumentOption.class); + + parser.parse("--help"); + + assertThat(helpOutput).hasSize(1); + assertThat(helpOutput.getFirst()).doesNotContain("Global options"); + } + + /** + * Ensure {@link CommandLineParser#setHelpSection(String)} with {@code null} resets to the unsectioned group: + * the subsequently registered option appears in help but is not nested under any section heading. + */ + @Test + void shouldResetSectionToDefaultWhenNullPassed() { + final List helpOutput = new ArrayList<>(); + + final var parser = new CommandLineParser() + .setHelpHandler(helpOutput::add) + .setHelpSection("My section") + .add(CommandLineParserTests.ZeroArgumentOption.class) + .setHelpSection(null) + .add(CommandLineParserTests.OneArgumentOption.class); + + parser.parse("--help"); + + assertThat(helpOutput).hasSize(1); + final var help = helpOutput.getFirst(); + + assertThat(help).contains("My section"); + assertThat(help).contains("-enabled"); + assertThat(help).containsSubsequence("My section", "-default", "-enabled"); + + final int mySectionIndex = help.indexOf("My section"); + final int enabledIndex = help.indexOf("-enabled"); + final int defaultIndex = help.indexOf("-default"); + + assertThat(defaultIndex).isGreaterThan(mySectionIndex); + assertThat(enabledIndex).isGreaterThan(defaultIndex); + } +} diff --git a/base-commandline/src/test/java/build/base/commandline/CommandLineParserTests.java b/base-commandline/src/test/java/build/base/commandline/CommandLineParserTests.java index 23b7ded..3d0f65b 100644 --- a/base-commandline/src/test/java/build/base/commandline/CommandLineParserTests.java +++ b/base-commandline/src/test/java/build/base/commandline/CommandLineParserTests.java @@ -5,6 +5,8 @@ import build.base.configuration.Option; import org.junit.jupiter.api.Test; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; import java.util.stream.Stream; @@ -572,6 +574,155 @@ void shouldFailUnambiguousArgumentsIfInvalidOverrideIsUsed() { System.out.println(illegalDisambiguationException.toString()); } + /** + * Ensure {@link CommandLineParser#setHelpUsageArguments(String)} appends the positional label to the usage line. + */ + @Test + void shouldIncludePositionalArgumentsInUsageLine() { + final List helpOutput = new ArrayList<>(); + + final var parser = new CommandLineParser() + .setHelpUsageProgramName("spin") + .setHelpUsageArguments("...") + .setHelpHandler(helpOutput::add) + .add(ZeroArgumentOption.class); + + parser.parse("--help"); + + assertThat(helpOutput).hasSize(1); + assertThat(helpOutput.getFirst()).contains("Usage: spin [options] ..."); + } + + /** + * Ensure {@link CommandLineParser#setHelpUsageProgramName(String)} without positional arguments only shows + * {@code [options]} on the usage line. + */ + @Test + void shouldIncludeOnlyOptionsInUsageLineWhenNoPositionalArgumentsSet() { + final List helpOutput = new ArrayList<>(); + + final var parser = new CommandLineParser() + .setHelpUsageProgramName("spin") + .setHelpHandler(helpOutput::add) + .add(ZeroArgumentOption.class); + + parser.parse("--help"); + + assertThat(helpOutput).hasSize(1); + assertThat(helpOutput.getFirst()).contains("Usage: spin [options]"); + assertThat(helpOutput.getFirst()).doesNotContain(""); + } + + /** + * Ensure {@link CommandLineParser#setHelpHandler(java.util.function.Consumer)} is called with help text + * instead of throwing a {@link CommandLineParser.HelpException}. + */ + @Test + void shouldCallHelpHandlerInsteadOfThrowing() { + final List helpOutput = new ArrayList<>(); + + final var parser = new CommandLineParser() + .setHelpHandler(helpOutput::add) + .add(ZeroArgumentOption.class); + + // should not throw + final var result = parser.parse("--help"); + + assertThat(helpOutput).hasSize(1); + assertThat(result.stream()).isEmpty(); + } + + /** + * Ensure that without a help handler, {@link CommandLineParser.HelpException} is still thrown. + */ + @Test + void shouldStillThrowHelpExceptionWhenNoHandlerIsSet() { + final var parser = new CommandLineParser() + .add(ZeroArgumentOption.class); + + assertThrows(CommandLineParser.HelpException.class, () -> parser.parse("--help")); + } + + /** + * Ensure {@link CommandLineParser#setPositionalArgumentConsumer(java.util.function.Consumer)} receives + * non-hyphen-prefixed arguments. + */ + @Test + void shouldRoutePositionalArgsToPositionalConsumer() { + final List positionals = new ArrayList<>(); + + final var parser = new CommandLineParser() + .setPositionalArgumentConsumer(positionals::add); + + parser.parse("compile", "test", "jlink"); + + assertThat(positionals).containsExactly("compile", "test", "jlink"); + } + + /** + * Ensure positional arguments and named options are correctly separated when both a positional consumer + * and option classes are registered — modelling the primary spin use case. + */ + @Test + void shouldSeparatePositionalArgsFromOptions() { + final List positionals = new ArrayList<>(); + + final var parser = new CommandLineParser() + .add(ZeroArgumentOption.class) + .add(OneArgumentOption.class) + .setPositionalArgumentConsumer(positionals::add); + + final var configuration = parser.parse( + "-default", "compile", "-enabled", "true", "test", "jlink") + .build(); + + assertThat(positionals).containsExactly("compile", "test", "jlink"); + assertThat(configuration.get(ZeroArgumentOption.class)).isNotNull(); + assertThat(configuration.get(OneArgumentOption.class)).isEqualTo(OneArgumentOption.of(true)); + } + + /** + * Ensure hyphen-prefixed unknown arguments still fall through to the {@link CommandLineParser.UnknownOptionConsumer} + * when a positional consumer is set. + */ + @Test + void shouldRouteHyphenPrefixedUnknownsToUnknownOptionConsumer() { + final List positionals = new ArrayList<>(); + + final var parser = new CommandLineParser() + .setPositionalArgumentConsumer(positionals::add) + .setUnknownOptionConsumer(CommandLineParser.IGNORE_UNKNOWN_OPTIONS) + .add(ZeroArgumentOption.class); + + final var configuration = parser.parse( + "-default", "--unknown-flag", "my-task") + .build(); + + // positional consumer gets the non-hyphen arg + assertThat(positionals).containsExactly("my-task"); + // option was parsed correctly + assertThat(configuration.get(ZeroArgumentOption.class)).isNotNull(); + // --unknown-flag was silently ignored by the unknown option consumer + } + + /** + * Ensure that without a positional consumer, non-hyphen args not preceded by an option still reach the + * {@link CommandLineParser.UnknownOptionConsumer} as before (backward compatibility). + */ + @Test + void shouldPreserveBackwardCompatibilityWithCaptureUnknownOptionsAsArguments() { + final var parser = new CommandLineParser() + .setUnknownOptionConsumer(CommandLineParser.CAPTURE_UNKNOWN_OPTIONS_AS_ARGUMENTS); + + final var configuration = parser.parse("compile", "test").build(); + + final var args = configuration.stream(CommandLine.Argument.class) + .map(CommandLine.Argument::get) + .toList(); + + assertThat(args).containsExactly("compile", "test"); + } + /** * A simple zero argument {@link Option}. */ diff --git a/base-commandline/src/test/java/build/base/commandline/CommandTests.java b/base-commandline/src/test/java/build/base/commandline/CommandTests.java new file mode 100644 index 0000000..c6a9b60 --- /dev/null +++ b/base-commandline/src/test/java/build/base/commandline/CommandTests.java @@ -0,0 +1,149 @@ +package build.base.commandline; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Tests for the {@link Command} fluent builder. + * + * @author reed.vonredwitz + * @since Apr-2026 + */ +class CommandTests { + + /** + * Ensure {@link Command#create(String)} produces a {@link CommandLineParser} that parses an annotated option class. + */ + @Test + void commandBuilderShouldParseAnnotatedOption() { + final var parser = Command.create("test") + .option(CommandLineParserTests.ZeroArgumentOption.class) + .build(); + + assertThat(parser.parse("-default").build() + .get(CommandLineParserTests.ZeroArgumentOption.class)) + .isEqualTo(CommandLineParserTests.ZeroArgumentOption.autodetect()); + } + + /** + * Ensure {@link Command#option(String, Class, String, Class[])} registers an explicit factory method. + */ + @Test + void commandBuilderShouldRegisterExplicitOption() { + final var parser = Command.create("test") + .option("-enabled", CommandLineParserTests.OneArgumentOption.class, "of", boolean.class) + .build(); + + assertThat(parser.parse("-enabled", "true").build() + .get(CommandLineParserTests.OneArgumentOption.class)) + .isEqualTo(CommandLineParserTests.OneArgumentOption.of(true)); + } + + /** + * Ensure {@link Command#option(String, Class, String, Class[])} wraps {@link NoSuchMethodException} as + * {@link IllegalArgumentException} when the method does not exist. + */ + @Test + void commandBuilderShouldWrapNoSuchMethodException() { + final var command = Command.create("test") + .option("-enabled", CommandLineParserTests.OneArgumentOption.class, "nonExistentMethod", boolean.class); + + assertThrows(IllegalArgumentException.class, command::build); + } + + /** + * Ensure {@link Command#argument(String)} and {@link Command#helpHandler(java.util.function.Consumer)} + * are forwarded to the parser. + */ + @Test + void commandBuilderShouldForwardArgumentAndHelpHandler() { + final List helpOutput = new ArrayList<>(); + + final var parser = Command.create("spin") + .argument("...") + .helpHandler(helpOutput::add) + .option(CommandLineParserTests.ZeroArgumentOption.class) + .build(); + + parser.parse("--help"); + + assertThat(helpOutput).hasSize(1); + assertThat(helpOutput.getFirst()).contains("Usage: spin [options] ..."); + } + + /** + * Ensure {@link Command#unknownOption(CommandLineParser.UnknownOptionConsumer)} is forwarded to the parser. + */ + @Test + void commandBuilderShouldForwardUnknownOptionConsumer() { + final var parser = Command.create("test") + .unknownOption(CommandLineParser.IGNORE_UNKNOWN_OPTIONS) + .option(CommandLineParserTests.ZeroArgumentOption.class) + .build(); + + assertThat(parser.parse("-default", "--unknown").build() + .get(CommandLineParserTests.ZeroArgumentOption.class)) + .isNotNull(); + } + + /** + * Ensure {@link Command#section(String)} groups options under named headings in help output. + */ + @Test + void commandBuilderShouldGroupOptionsIntoSections() { + final List helpOutput = new ArrayList<>(); + + final var parser = Command.create("spin") + .helpHandler(helpOutput::add) + .section("Global options") + .option(CommandLineParserTests.ZeroArgumentOption.class) + .section("Task options") + .option(CommandLineParserTests.OneArgumentOption.class) + .build(); + + parser.parse("--help"); + + assertThat(helpOutput).hasSize(1); + assertThat(helpOutput.getFirst()) + .containsSubsequence("Global options", "-default", "Task options", "-enabled"); + } + + /** + * Ensure {@link Command#envVar(String, String)} and {@link Command#envVarResolver(java.util.function.Function)} + * wire up correctly via the builder. + */ + @Test + void commandBuilderEnvVarShouldInjectValue() { + final var parser = Command.create("test") + .option(CommandLineParserTests.OneArgumentOption.class) + .envVar("MY_ENABLED", "-enabled") + .envVarResolver(name -> "MY_ENABLED".equals(name) ? Optional.of("false") : Optional.empty()) + .build(); + + assertThat(parser.parse().build() + .get(CommandLineParserTests.OneArgumentOption.class)) + .isEqualTo(CommandLineParserTests.OneArgumentOption.of(false)); + } + + /** + * Ensure command-line arg overrides env var when wired through the {@link Command} builder. + */ + @Test + void commandBuilderCommandLineShouldOverrideEnvVar() { + final var parser = Command.create("test") + .option(CommandLineParserTests.OneArgumentOption.class) + .envVar("MY_ENABLED", "-enabled") + .envVarResolver(name -> "MY_ENABLED".equals(name) ? Optional.of("false") : Optional.empty()) + .build(); + + assertThat(parser.parse("-enabled", "true").build() + .get(CommandLineParserTests.OneArgumentOption.class)) + .isEqualTo(CommandLineParserTests.OneArgumentOption.of(true)); + } +} diff --git a/docs/analysis.md b/docs/analysis.md index f078865..0970e53 100644 --- a/docs/analysis.md +++ b/docs/analysis.md @@ -210,21 +210,6 @@ No file, environment variable, or system property sourcing beyond individual `@D | `Connection` uses `synchronized` with Virtual Threads | base-network | **Performance** | ⏭ Deferred | Pins carrier thread on JDK <24 | | ~~Multiple correctness and threading bugs in telemetry modules~~ | ~~base-telemetry, base-telemetry-foundation, base-telemetry-ansi~~ | ~~**Bug**~~ | ✅ Fixed | | ---- - -## Unused POM Properties - -Five version properties in the root POM are pure forward-looking placeholders with zero usage anywhere in the codebase. They were introduced in the initial commit and have never been consumed by any module. - -| Property | Version | Intended Future Module | -|---|---|---| -| `jakarta-inject.version` | 2.0.1 | DI container (`base-inject`) | -| `kie-dmn-feel.version` | 9.44.0.Final | DMN/business-rules expression module | -| `mustache-java.version` | 0.9.14 | Template engine (`base-template`) | -| `auto-service.version` | 1.1.1 | Annotation processor module | -| `compile-testing.version` | 0.21.0 | Annotation processor test harness | - -None of these have a corresponding `` entry in the root `` block — they exist only as ``. They are safe to keep as intent markers or remove to reduce confusion. --- diff --git a/pom.xml b/pom.xml index 6f0e629..2acca6b 100644 --- a/pom.xml +++ b/pom.xml @@ -67,16 +67,11 @@ 3.27.7 - 1.1.1 - 1.18.4 - 0.21.0 2.21.2 6.0.3 2.0.1 5.0.1 5.0.0-M1 - 9.44.0.Final - 0.9.14 5.23.0 @@ -123,17 +118,6 @@ base-transport-json - - - - - net.bytebuddy - byte-buddy - ${byte-buddy.version} - - - -