Command flag behavior improvements (#1432)

* Forbid overlap of built-in and command-defined flag names 
* Allow interleaving built-in and command-defined flags on the command
line
* List abbreviated flag names first, matching the behavior of built-in
flags
This commit is contained in:
Jen Basch
2026-02-20 12:00:18 -08:00
committed by GitHub
parent 08712e8b26
commit a5dc91f0a5
11 changed files with 146 additions and 31 deletions

View File

@@ -100,7 +100,7 @@ public record CommandSpec(
public String[] getNames() {
return shortName == null
? new String[] {"--" + name}
: new String[] {"--" + name, "-" + shortName};
: new String[] {"-" + shortName, "--" + name};
}
}
@@ -115,7 +115,7 @@ public record CommandSpec(
public String[] getNames() {
return shortName == null
? new String[] {"--" + name}
: new String[] {"--" + name, "-" + shortName};
: new String[] {"-" + shortName, "--" + name};
}
}
@@ -126,7 +126,7 @@ public record CommandSpec(
public String[] getNames() {
return shortName == null
? new String[] {"--" + name}
: new String[] {"--" + name, "-" + shortName};
: new String[] {"-" + shortName, "--" + name};
}
}

View File

@@ -16,6 +16,7 @@
package org.pkl.core;
import java.util.Map;
import java.util.Set;
import java.util.function.Consumer;
import org.pkl.core.runtime.VmEvalException;
@@ -237,7 +238,11 @@ public interface Evaluator extends AutoCloseable {
* @throws PklException if an error occurs during evaluation
* @throws IllegalStateException if this evaluator has already been closed
*/
void evaluateCommand(ModuleSource moduleSource, Consumer<CommandSpec> run);
void evaluateCommand(
ModuleSource moduleSource,
Set<String> reservedFlagNames,
Set<String> reservedFlagShortNames,
Consumer<CommandSpec> run);
/**
* Releases all resources held by this evaluator. If an {@code evaluate} method is currently

View File

@@ -20,6 +20,7 @@ import java.nio.file.Path;
import java.time.Duration;
import java.util.Collection;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@@ -277,7 +278,11 @@ public final class EvaluatorImpl implements Evaluator {
}
@Override
public void evaluateCommand(ModuleSource moduleSource, Consumer<CommandSpec> run) {
public void evaluateCommand(
ModuleSource moduleSource,
Set<String> reservedFlagNames,
Set<String> reservedFlagShortNames,
Consumer<CommandSpec> run) {
doEvaluate(
moduleSource,
(module) -> {
@@ -287,6 +292,8 @@ public final class EvaluatorImpl implements Evaluator {
securityManager,
frameTransformer,
color,
reservedFlagNames,
reservedFlagShortNames,
(fileOutput) -> new FileOutputImpl(this, fileOutput));
run.accept(commandRunner.parse(module));
return null;

View File

@@ -75,6 +75,8 @@ public final class CommandSpecParser {
private final SecurityManager securityManager;
private final StackFrameTransformer frameTransformer;
private final boolean color;
private final Set<String> reservedFlagNames;
private final Set<String> reservedFlagShortNames;
private final Function<VmTyped, FileOutput> makeFileOutput;
public CommandSpecParser(
@@ -82,11 +84,15 @@ public final class CommandSpecParser {
SecurityManager securityManager,
StackFrameTransformer frameTransformer,
boolean color,
Set<String> reservedFlagNames,
Set<String> reservedFlagShortNames,
Function<VmTyped, FileOutput> makeFileOutput) {
this.moduleResolver = moduleResolver;
this.securityManager = securityManager;
this.frameTransformer = frameTransformer;
this.color = color;
this.reservedFlagNames = reservedFlagNames;
this.reservedFlagShortNames = reservedFlagShortNames;
this.makeFileOutput = makeFileOutput;
}
@@ -249,11 +255,22 @@ public final class CommandSpecParser {
}
private void checkFlagNames(ClassProperty prop, String name, @Nullable String shortName) {
if ("help".equals(name) || "h".equals(shortName)) {
throw exceptionBuilder()
.withSourceSection(prop.getHeaderSection())
.evalError("commandFlagHelpCollision", prop.getName())
.build();
for (var reserved : reservedFlagNames) {
if (reserved.equals(name)) {
throw exceptionBuilder()
.withSourceSection(prop.getHeaderSection())
.evalError("commandFlagNameCollision", prop.getName(), "name", "")
.build();
}
}
for (var reserved : reservedFlagShortNames) {
if (reserved.equals(shortName)) {
throw exceptionBuilder()
.withSourceSection(prop.getHeaderSection())
.evalError(
"commandFlagNameCollision", prop.getName(), "short name", "`" + shortName + "` ")
.build();
}
}
}

View File

@@ -1121,8 +1121,10 @@ More than one repeated option annotated with `@Argument` found: `{0}` and `{1}`.
\n\
Only one repeated argument is permitted per command.
commandFlagHelpCollision=\
Flag option `{0}` may not have name "help" or short name "h".
commandFlagNameCollision=\
Flag option `{0}` {1} {2}collides with a reserved flag {1}.\n\
\n\
Option {1}s must not overlap with built-in options.
commandFlagInvalidType=\
Option `{0}` with annotation `@{1}` has invalid type `{2}`.\n\

View File

@@ -57,7 +57,7 @@ class CommandSpecParserTest {
private fun parse(moduleUri: URI): CommandSpec {
var spec: CommandSpec? = null
evaluator.evaluateCommand(uri(moduleUri)) { spec = it }
evaluator.evaluateCommand(uri(moduleUri), setOf("help", "root-dir"), setOf("h")) { spec = it }
return spec!!
}
@@ -440,8 +440,7 @@ class CommandSpecParserTest {
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("help: Boolean")
assertThat(exc.message)
.contains("Flag option `help` may not have name \"help\" or short name \"h\".")
assertThat(exc.message).contains("Flag option `help` name collides with a reserved flag name.")
}
@Test
@@ -462,7 +461,27 @@ class CommandSpecParserTest {
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("showHelp: Boolean")
assertThat(exc.message)
.contains("Flag option `showHelp` may not have name \"help\" or short name \"h\".")
.contains("Flag option `showHelp` short name `h` collides with a reserved flag short name.")
}
@Test
fun `flag with collision on reserved option name`() {
val moduleUri =
writePklFile(
"cmd.pkl",
renderOptions +
"""
class Options {
`root-dir`: String
}
"""
.trimIndent(),
)
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("`root-dir`: String")
assertThat(exc.message)
.contains("Flag option `root-dir` name collides with a reserved flag name.")
}
@Test