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 extends Option> 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 extends Option> 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}
-
-
-
-