SPICE-0025: pkl run CLI framework (#1367)

This commit is contained in:
Jen Basch
2026-02-12 07:53:02 -08:00
committed by GitHub
parent 63a20dd453
commit 72a57af164
35 changed files with 4706 additions and 147 deletions

View File

@@ -0,0 +1,158 @@
/*
* Copyright © 2025-2026 Apple Inc. and the Pkl project authors. All rights reserved.
*
* 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
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.pkl.core;
import java.net.URI;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.BiFunction;
import java.util.function.Function;
import org.pkl.core.util.Nullable;
public record CommandSpec(
String name,
@Nullable String helpText,
boolean hidden,
boolean noOp,
Iterable<Option> options,
List<CommandSpec> subcommands,
ApplyFunction apply) {
public sealed interface Option {
String name();
String[] getNames();
class MissingOption extends RuntimeException {
public MissingOption() {}
}
class BadValue extends RuntimeException {
public BadValue(String message) {
super(message);
}
public static BadValue invalid(String value, String type) {
return new BadValue(String.format("%s is not a valid %s", value, type));
}
public static BadValue badKeyValue(String value) {
return new BadValue(String.format("%s is not a valid key=value pair", value));
}
public static BadValue invalidChoice(String value, List<String> choices) {
return new BadValue(
String.format(
"invalid choice: %s. (choose from %s)", value, String.join(", ", choices)));
}
public static BadValue invalidChoice(String value, String choice) {
return new BadValue(String.format("invalid choice: %s. (choose from %s)", value, choice));
}
}
}
public abstract static sealed class CompletionCandidates {
public static final CompletionCandidates PATH = new StaticCompletionCandidates();
public static final class Fixed extends CompletionCandidates {
private final Set<String> values;
public Fixed(Set<String> values) {
this.values = values;
}
public Set<String> getValues() {
return values;
}
}
private static final class StaticCompletionCandidates extends CompletionCandidates {}
}
public record Flag(
String name,
@Nullable String helpText,
boolean showAsRequired,
BiFunction<String, URI, Object> transformEach,
Function<List<Object>, Object> transformAll,
@Nullable CompletionCandidates completionCandidates,
@Nullable String shortName,
String metavar,
boolean hidden,
@Nullable String defaultValue)
implements Option {
@Override
public String[] getNames() {
return shortName == null
? new String[] {"--" + name}
: new String[] {"--" + name, "-" + shortName};
}
}
public record BooleanFlag(
String name,
@Nullable String helpText,
@Nullable String shortName,
boolean hidden,
@Nullable Boolean defaultValue)
implements Option {
@Override
public String[] getNames() {
return shortName == null
? new String[] {"--" + name}
: new String[] {"--" + name, "-" + shortName};
}
}
public record CountedFlag(
String name, @Nullable String helpText, @Nullable String shortName, boolean hidden)
implements Option {
@Override
public String[] getNames() {
return shortName == null
? new String[] {"--" + name}
: new String[] {"--" + name, "-" + shortName};
}
}
public record Argument(
String name,
@Nullable String helpText,
BiFunction<String, URI, Object> transformEach,
Function<List<Object>, Object> transformAll,
@Nullable CompletionCandidates completionCandidates,
boolean repeated)
implements Option {
@Override
public String[] getNames() {
return new String[] {name};
}
}
public interface ApplyFunction {
State apply(Map<String, Object> options, @Nullable State parent);
}
public record State(Object contents, Function<Object, Result> reify) {
public Result evaluate() {
return reify.apply(contents);
}
}
public record Result(byte[] outputBytes, Map<String, FileOutput> outputFiles) {}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -16,6 +16,7 @@
package org.pkl.core;
import java.util.Map;
import java.util.function.Consumer;
import org.pkl.core.runtime.VmEvalException;
/**
@@ -224,6 +225,20 @@ public interface Evaluator extends AutoCloseable {
*/
TestResults evaluateTest(ModuleSource moduleSource, boolean overwrite);
/**
* Parses the command module into a spec that describes the CLI options and subcommands.
*
* <p>This requires that the target module be a {@code "pkl:Command"} instance.
*
* <p>Unlike other evaluator methods, the resulting {@link CommandSpec} must be handled in a
* closure. This is because specs must be applied to parsed CLI options to produce command state
* that is eventually evaluated, which must happen in the active context of an evaluator.
*
* @throws PklException if an error occurs during evaluation
* @throws IllegalStateException if this evaluator has already been closed
*/
void evaluateCommand(ModuleSource moduleSource, Consumer<CommandSpec> run);
/**
* Releases all resources held by this evaluator. If an {@code evaluate} method is currently
* executing, this method blocks until cancellation of that execution has completed.

View File

@@ -20,11 +20,11 @@ import java.io.IOException;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
import org.graalvm.polyglot.Context;
@@ -40,6 +40,7 @@ import org.pkl.core.packages.PackageResolver;
import org.pkl.core.project.DeclaredDependencies;
import org.pkl.core.resource.ResourceReader;
import org.pkl.core.runtime.BaseModule;
import org.pkl.core.runtime.CommandSpecParser;
import org.pkl.core.runtime.Identifier;
import org.pkl.core.runtime.ModuleResolver;
import org.pkl.core.runtime.ResourceManager;
@@ -48,8 +49,6 @@ import org.pkl.core.runtime.VmContext;
import org.pkl.core.runtime.VmException;
import org.pkl.core.runtime.VmExceptionBuilder;
import org.pkl.core.runtime.VmLanguage;
import org.pkl.core.runtime.VmMapping;
import org.pkl.core.runtime.VmNull;
import org.pkl.core.runtime.VmPklBinaryEncoder;
import org.pkl.core.runtime.VmStackOverflowException;
import org.pkl.core.runtime.VmTyped;
@@ -148,7 +147,7 @@ public final class EvaluatorImpl implements Evaluator {
return doEvaluate(
moduleSource,
(module) -> {
var output = readModuleOutput(module);
var output = VmUtils.readModuleOutput(module);
return VmUtils.readTextProperty(output);
});
}
@@ -157,7 +156,7 @@ public final class EvaluatorImpl implements Evaluator {
return doEvaluate(
moduleSource,
(module) -> {
var output = readModuleOutput(module);
var output = VmUtils.readModuleOutput(module);
var vmBytes = VmUtils.readBytesProperty(output);
return vmBytes.export();
});
@@ -168,7 +167,7 @@ public final class EvaluatorImpl implements Evaluator {
return doEvaluate(
moduleSource,
(module) -> {
var output = readModuleOutput(module);
var output = VmUtils.readModuleOutput(module);
var value = VmUtils.readMember(output, Identifier.VALUE);
if (value instanceof VmValue vmValue) {
vmValue.force(false);
@@ -183,20 +182,9 @@ public final class EvaluatorImpl implements Evaluator {
return doEvaluate(
moduleSource,
(module) -> {
var output = readModuleOutput(module);
var filesOrNull = VmUtils.readMember(output, Identifier.FILES);
if (filesOrNull instanceof VmNull) {
return Map.of();
}
var files = (VmMapping) filesOrNull;
var result = new LinkedHashMap<String, FileOutput>();
files.forceAndIterateMemberValues(
(key, member, value) -> {
assert member.isEntry();
result.put((String) key, new FileOutputImpl(this, (VmTyped) value));
return true;
});
return result;
var output = VmUtils.readModuleOutput(module);
return VmUtils.readFilesProperty(
output, (fileOutput) -> new FileOutputImpl(this, fileOutput));
});
}
@@ -239,10 +227,10 @@ public final class EvaluatorImpl implements Evaluator {
var expressionResult =
switch (expression) {
case "module" -> module;
case "output.text" -> VmUtils.readTextProperty(readModuleOutput(module));
case "output.text" -> VmUtils.readTextProperty(VmUtils.readModuleOutput(module));
case "output.value" ->
VmUtils.readMember(readModuleOutput(module), Identifier.VALUE);
case "output.bytes" -> VmUtils.readBytesProperty(readModuleOutput(module));
VmUtils.readMember(VmUtils.readModuleOutput(module), Identifier.VALUE);
case "output.bytes" -> VmUtils.readBytesProperty(VmUtils.readModuleOutput(module));
default ->
VmUtils.evaluateExpression(module, expression, securityManager, moduleResolver);
};
@@ -289,12 +277,27 @@ public final class EvaluatorImpl implements Evaluator {
});
}
@Override
public void evaluateCommand(ModuleSource moduleSource, Consumer<CommandSpec> run) {
doEvaluate(
moduleSource,
(module) -> {
var commandRunner =
new CommandSpecParser(
moduleResolver,
securityManager,
(fileOutput) -> new FileOutputImpl(this, fileOutput));
run.accept(commandRunner.parse(module));
return null;
});
}
@Override
public <T> T evaluateOutputValueAs(ModuleSource moduleSource, PClassInfo<T> classInfo) {
return doEvaluate(
moduleSource,
(module) -> {
var output = readModuleOutput(module);
var output = VmUtils.readModuleOutput(module);
var value = VmUtils.readMember(output, Identifier.VALUE);
var valueClassInfo = VmUtils.getClass(value).getPClassInfo();
if (valueClassInfo.equals(classInfo)) {
@@ -367,6 +370,9 @@ public final class EvaluatorImpl implements Evaluator {
} catch (VmException e) {
handleTimeout(timeoutTask);
throw e.toPklException(frameTransformer, color);
} catch (PklException e) {
// evaluateCommand can throw PklException from the CLI layer, pass them through
throw e;
} catch (Exception e) {
throw new PklBugException(e);
} catch (ExceptionInInitializerError e) {
@@ -420,32 +426,6 @@ public final class EvaluatorImpl implements Evaluator {
"evaluationTimedOut", (timeout.getSeconds() + timeout.getNano() / 1_000_000_000d)));
}
private VmTyped readModuleOutput(VmTyped module) {
var value = VmUtils.readMember(module, Identifier.OUTPUT);
if (value instanceof VmTyped typedOutput
&& typedOutput.getVmClass().getPClassInfo() == PClassInfo.ModuleOutput) {
return typedOutput;
}
var moduleUri = module.getModuleInfo().getModuleKey().getUri();
var builder =
new VmExceptionBuilder()
.evalError(
"invalidModuleOutput",
"output",
PClassInfo.ModuleOutput.getDisplayName(),
VmUtils.getClass(value).getPClassInfo().getDisplayName(),
moduleUri);
var outputMember = module.getMember(Identifier.OUTPUT);
assert outputMember != null;
var uriOfValueMember = outputMember.getSourceSection().getSource().getURI();
// If `output` was explicitly re-assigned, show that in the stack trace.
if (!uriOfValueMember.equals(PClassInfo.pklBaseUri)) {
builder.withSourceSection(outputMember.getBodySection()).withMemberName("output");
}
throw builder.build();
}
private VmException moduleOutputValueTypeMismatch(
VmTyped module, PClassInfo<?> expectedClassInfo, Object value, VmTyped output) {
var moduleUri = module.getModuleInfo().getModuleKey().getUri();

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -146,6 +146,10 @@ public final class VmModifier {
return (modifiers & (LOCAL | EXTERNAL | ABSTRACT)) != 0;
}
public static boolean isLocalOrExternalOrAbstractOrFixedOrConst(int modifiers) {
return (modifiers & (LOCAL | EXTERNAL | ABSTRACT | FIXED | CONST)) != 0;
}
public static boolean isConstOrFixed(int modifiers) {
return (modifiers & (CONST | FIXED)) != 0;
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -45,6 +45,10 @@ public abstract class AmendModuleNode extends SpecializedObjectLiteralNode {
this.moduleInfo = moduleInfo;
}
public ModuleInfo getModuleInfo() {
return moduleInfo;
}
@Override
@TruffleBoundary
protected AmendModuleNode copy(ExpressionNode newParentNode) {

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -16,6 +16,7 @@
package org.pkl.core.ast.member;
import com.oracle.truffle.api.source.SourceSection;
import java.util.ArrayList;
import java.util.List;
import org.pkl.core.runtime.Identifier;
import org.pkl.core.runtime.VmClass;
@@ -53,6 +54,33 @@ public abstract class ClassMember extends Member {
return annotations;
}
public List<VmTyped> getAllAnnotations(boolean ascending) {
var annotations = new ArrayList<VmTyped>();
if (ascending) {
for (var clazz = getDeclaringClass(); clazz != null; clazz = clazz.getSuperclass()) {
var p = clazz.getDeclaredProperty(getName());
if (p != null) {
annotations.addAll(p.getAnnotations());
}
}
} else {
doGetAllAnnotationsDescending(getDeclaringClass(), annotations);
}
return annotations;
}
private void doGetAllAnnotationsDescending(VmClass clazz, List<VmTyped> annotations) {
if (clazz.getSuperclass() != null) {
doGetAllAnnotationsDescending(clazz.getSuperclass(), annotations);
}
var p = clazz.getDeclaredProperty(getName());
if (p != null) {
annotations.addAll(p.getAnnotations());
}
}
/** Returns the prototype of the class that declares this member. */
public final VmTyped getOwner() {
return owner;

View File

@@ -16,7 +16,6 @@
package org.pkl.core.ast.member;
import com.oracle.truffle.api.source.SourceSection;
import java.util.ArrayList;
import java.util.List;
import org.pkl.core.Member.SourceLocation;
import org.pkl.core.PClass;
@@ -54,33 +53,6 @@ public final class ClassProperty extends ClassMember {
this.initializer = initializer;
}
public List<VmTyped> getAllAnnotations(boolean ascending) {
var annotations = new ArrayList<VmTyped>();
if (ascending) {
for (var clazz = getDeclaringClass(); clazz != null; clazz = clazz.getSuperclass()) {
var p = clazz.getDeclaredProperty(getName());
if (p != null) {
annotations.addAll(p.getAnnotations());
}
}
} else {
doGetAllAnnotationsDescending(getDeclaringClass(), annotations);
}
return annotations;
}
private void doGetAllAnnotationsDescending(VmClass clazz, List<VmTyped> annotations) {
if (clazz.getSuperclass() != null) {
doGetAllAnnotationsDescending(clazz.getSuperclass(), annotations);
}
var p = clazz.getDeclaredProperty(getName());
if (p != null) {
annotations.addAll(p.getAnnotations());
}
}
public VmSet getAllModifierMirrors() {
var mods = 0;
for (var clazz = getDeclaringClass(); clazz != null; clazz = clazz.getSuperclass()) {

View File

@@ -57,6 +57,10 @@ import org.pkl.core.util.Nullable;
public abstract class TypeNode extends PklNode {
public interface ClassTypeNode {
VmClass getVmClass();
}
protected TypeNode(SourceSection sourceSection) {
super(sourceSection);
}
@@ -402,7 +406,8 @@ public abstract class TypeNode extends PklNode {
}
/** The `module` type for a final module. */
public static final class FinalModuleTypeNode extends ObjectSlotTypeNode {
public static final class FinalModuleTypeNode extends ObjectSlotTypeNode
implements ClassTypeNode {
private final VmClass moduleClass;
public FinalModuleTypeNode(SourceSection sourceSection, VmClass moduleClass) {
@@ -456,7 +461,8 @@ public abstract class TypeNode extends PklNode {
}
/** The `module` type for an open module. */
public static final class NonFinalModuleTypeNode extends ObjectSlotTypeNode {
public static final class NonFinalModuleTypeNode extends ObjectSlotTypeNode
implements ClassTypeNode {
private final VmClass moduleClass; // only used by getVmClass()
@Child private ExpressionNode getModuleNode;
@@ -641,7 +647,7 @@ public abstract class TypeNode extends PklNode {
* String/Boolean/Int/Float and their supertypes, only `VmValue`s can possibly pass its type
* check.
*/
public static final class FinalClassTypeNode extends ObjectSlotTypeNode {
public static final class FinalClassTypeNode extends ObjectSlotTypeNode implements ClassTypeNode {
private final VmClass clazz;
public FinalClassTypeNode(SourceSection sourceSection, VmClass clazz) {
@@ -697,7 +703,8 @@ public abstract class TypeNode extends PklNode {
* String/Boolean/Int/Float and their supertypes, only {@link VmValue}s can possibly pass its type
* check.
*/
public abstract static class NonFinalClassTypeNode extends ObjectSlotTypeNode {
public abstract static class NonFinalClassTypeNode extends ObjectSlotTypeNode
implements ClassTypeNode {
protected final VmClass clazz;
public NonFinalClassTypeNode(SourceSection sourceSection, VmClass clazz) {
@@ -1088,6 +1095,14 @@ public abstract class TypeNode extends PklNode {
return unionDefault;
}
public Set<String> getStringLiterals() {
return stringLiterals;
}
public @Nullable String getUnionDefault() {
return unionDefault;
}
}
public static final class CollectionTypeNode extends ObjectSlotTypeNode {
@@ -1421,6 +1436,10 @@ public abstract class TypeNode extends PklNode {
return BaseModule.getMapClass();
}
public TypeNode getKeyTypeNode() {
return keyTypeNode;
}
public TypeNode getValueTypeNode() {
return valueTypeNode;
}
@@ -2131,6 +2150,14 @@ public abstract class TypeNode extends PklNode {
protected boolean isParametric() {
return true;
}
public TypeNode getFirstTypeNode() {
return firstTypeNode;
}
public TypeNode getSecondTypeNode() {
return secondTypeNode;
}
}
public static class VarArgsTypeNode extends ObjectSlotTypeNode {
@@ -2313,6 +2340,10 @@ public abstract class TypeNode extends PklNode {
protected final boolean acceptTypeNode(boolean visitTypeArguments, TypeNodeConsumer consumer) {
return consumer.accept(this);
}
public long getMask() {
return mask;
}
}
public static final class UIntTypeAliasTypeNode extends IntMaskSlotTypeNode {
@@ -2505,6 +2536,10 @@ public abstract class TypeNode extends PklNode {
aliasedTypeNode = typeAlias.instantiate(typeArgumentNodes);
}
public TypeNode getAliasedTypeNode() {
return aliasedTypeNode;
}
@Override
public FrameSlotKind getFrameSlotKind() {
return aliasedTypeNode.getFrameSlotKind();
@@ -2670,6 +2705,10 @@ public abstract class TypeNode extends PklNode {
this.constraintNodes = constraintNodes;
}
public TypeNode getChildTypeNode() {
return childNode;
}
@Override
public FrameSlotKind getFrameSlotKind() {
return childNode.getFrameSlotKind();

View File

@@ -227,10 +227,26 @@ public final class BaseModule extends StdLibModule {
return MixinTypeAlias.instance;
}
public static VmTypeAlias getUIntTypeAlias() {
return UIntTypeAlias.instance;
}
public static VmTypeAlias getUInt8TypeAlias() {
return UInt8TypeAlias.instance;
}
public static VmTypeAlias getUInt16TypeAlias() {
return UInt16TypeAlias.instance;
}
public static VmTypeAlias getUInt32TypeAlias() {
return UInt32TypeAlias.instance;
}
public static VmTypeAlias getCharTypeAlias() {
return CharTypeAlias.instance;
}
private static final class AnyClass {
static final VmClass instance = loadClass("Any");
}
@@ -403,10 +419,26 @@ public final class BaseModule extends StdLibModule {
static final VmTypeAlias instance = loadTypeAlias("Int32");
}
private static final class UIntTypeAlias {
static final VmTypeAlias instance = loadTypeAlias("UInt");
}
private static final class UInt8TypeAlias {
static final VmTypeAlias instance = loadTypeAlias("UInt8");
}
private static final class UInt16TypeAlias {
static final VmTypeAlias instance = loadTypeAlias("UInt16");
}
private static final class UInt32TypeAlias {
static final VmTypeAlias instance = loadTypeAlias("UInt32");
}
private static final class CharTypeAlias {
static final VmTypeAlias instance = loadTypeAlias("Char");
}
private static final class MixinTypeAlias {
static final VmTypeAlias instance = loadTypeAlias("Mixin");
}

View File

@@ -0,0 +1,95 @@
/*
* Copyright © 2025-2026 Apple Inc. and the Pkl project authors. All rights reserved.
*
* 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
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.pkl.core.runtime;
import com.oracle.truffle.api.CompilerDirectives.TruffleBoundary;
import java.net.URI;
public final class CommandModule extends StdLibModule {
static final VmTyped instance = VmUtils.createEmptyModule();
static {
loadModule(URI.create("pkl:Command"), instance);
}
private CommandModule() {}
public static VmTyped getModule() {
return instance;
}
public static VmClass getCommandInfoClass() {
return CommandInfoClass.instance;
}
public static VmClass getBaseFlagClass() {
return BaseFlagClass.instance;
}
public static VmClass getFlagClass() {
return FlagClass.instance;
}
public static VmClass getBooleanFlagClass() {
return BooleanFlagClass.instance;
}
public static VmClass getCountedFlagClass() {
return CountedFlagClass.instance;
}
public static VmClass getArgumentClass() {
return ArgumentClass.instance;
}
public static VmClass getImportClass() {
return ImportClass.instance;
}
private static final class CommandInfoClass {
static final VmClass instance = loadClass("CommandInfo");
}
private static final class BaseFlagClass {
static final VmClass instance = loadClass("BaseFlag");
}
private static final class FlagClass {
static final VmClass instance = loadClass("Flag");
}
private static final class BooleanFlagClass {
static final VmClass instance = loadClass("BooleanFlag");
}
private static final class CountedFlagClass {
static final VmClass instance = loadClass("CountedFlag");
}
private static final class ArgumentClass {
static final VmClass instance = loadClass("Argument");
}
private static final class ImportClass {
static final VmClass instance = loadClass("Import");
}
@TruffleBoundary
private static VmClass loadClass(String className) {
var theModule = getModule();
return (VmClass) VmUtils.readMember(theModule, Identifier.get(className));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -147,6 +147,22 @@ public final class Identifier implements Comparable<Identifier> {
// members of pkl.yaml
public static final Identifier MAX_COLLECTION_ALIASES = get("maxCollectionAliases");
// members of pkl.Command
public static final Identifier OPTIONS = get("options");
public static final Identifier PARENT = get("parent");
public static final Identifier COMMAND = get("command");
public static final Identifier DESCRIPTION = get("description");
public static final Identifier HIDE = get("hide");
public static final Identifier NOOP = get("noOp");
public static final Identifier SUBCOMMANDS = get("subcommands");
public static final Identifier SHORT_NAME = get("shortName");
public static final Identifier METAVAR = get("metavar");
public static final Identifier MULTIPLE = get("multiple");
public static final Identifier CONVERT = get("convert");
public static final Identifier TRANSFORM_ALL = get("transformAll");
public static final Identifier GLOB = get("glob");
public static final Identifier COMPLETION_CANDIDATES = get("completionCandidates");
// common in lambdas etc
public static final Identifier IT = get("it");

View File

@@ -92,6 +92,8 @@ public final class ModuleCache {
return BaseModule.getModule();
case "Benchmark":
return BenchmarkModule.getModule();
case "Command":
return CommandModule.getModule();
case "jsonnet":
return JsonnetModule.getModule();
case "math":

View File

@@ -69,7 +69,7 @@ public final class TestRunner {
var resultsBuilder = new TestResults.Builder(info.getModuleName(), getDisplayUri(info));
try {
checkAmendsPklTest(testModule);
VmUtils.checkAmends(testModule, TestModule.getModule().getVmClass());
} catch (VmException v) {
var error =
new TestResults.Error(v.getMessage(), v.toPklException(stackFrameTransformer, useColor));
@@ -83,17 +83,6 @@ public final class TestRunner {
return resultsBuilder.build();
}
private void checkAmendsPklTest(VmTyped value) {
var testModuleClass = TestModule.getModule().getVmClass();
var moduleClass = value.getVmClass();
while (moduleClass != testModuleClass) {
moduleClass = moduleClass.getSuperclass();
if (moduleClass == null) {
throw new VmExceptionBuilder().typeMismatch(value, testModuleClass).build();
}
}
}
private TestSectionResults runFacts(VmTyped testModule) {
var facts = VmUtils.readMember(testModule, Identifier.FACTS);
if (facts instanceof VmNull) return new TestSectionResults(TestSectionName.FACTS, List.of());

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.

View File

@@ -30,12 +30,14 @@ import java.net.URI;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.util.*;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import java.util.stream.Collectors;
import org.graalvm.polyglot.Context;
import org.graalvm.polyglot.Engine;
import org.organicdesign.fp.collections.ImMap;
import org.pkl.core.FileOutput;
import org.pkl.core.PClassInfo;
import org.pkl.core.PObject;
import org.pkl.core.SecurityManager;
@@ -179,6 +181,49 @@ public final class VmUtils {
return (VmBytes) VmUtils.readMember(receiver, Identifier.BYTES);
}
public static VmTyped readModuleOutput(VmTyped module) {
var value = VmUtils.readMember(module, Identifier.OUTPUT);
if (value instanceof VmTyped typedOutput
&& typedOutput.getVmClass().getPClassInfo() == PClassInfo.ModuleOutput) {
return typedOutput;
}
var moduleUri = module.getModuleInfo().getModuleKey().getUri();
var builder =
new VmExceptionBuilder()
.evalError(
"invalidModuleOutput",
"output",
PClassInfo.ModuleOutput.getDisplayName(),
VmUtils.getClass(value).getPClassInfo().getDisplayName(),
moduleUri);
var outputMember = module.getMember(Identifier.OUTPUT);
assert outputMember != null;
var uriOfValueMember = outputMember.getSourceSection().getSource().getURI();
// If `output` was explicitly re-assigned, show that in the stack trace.
if (!uriOfValueMember.equals(PClassInfo.pklBaseUri)) {
builder.withSourceSection(outputMember.getBodySection()).withMemberName("output");
}
throw builder.build();
}
public static Map<String, FileOutput> readFilesProperty(
VmObjectLike receiver, Function<VmTyped, FileOutput> fileOutputFactory) {
var filesOrNull = VmUtils.readMember(receiver, Identifier.FILES);
if (filesOrNull instanceof VmNull) {
return Map.of();
}
var files = (VmMapping) filesOrNull;
var result = new LinkedHashMap<String, FileOutput>();
files.forceAndIterateMemberValues(
(key, member, value) -> {
assert member.isEntry();
result.put((String) key, fileOutputFactory.apply((VmTyped) value));
return true;
});
return result;
}
@TruffleBoundary
public static Object readMember(VmObjectLike receiver, Object memberKey) {
var result = readMemberOrNull(receiver, memberKey);
@@ -855,7 +900,7 @@ public final class VmUtils {
String expression,
SecurityManager securityManager,
ModuleResolver moduleResolver) {
var syntheticModule = ModuleKeys.synthetic(URI.create(REPL_TEXT), expression);
var syntheticModule = ModuleKeys.synthetic(REPL_TEXT_URI, expression);
ResolvedModuleKey resolvedModule;
try {
resolvedModule = syntheticModule.resolve(securityManager);
@@ -870,7 +915,7 @@ public final class VmUtils {
source.createSection(0, source.getLength()),
VmUtils.unavailableSourceSection(),
null,
"repl:text",
REPL_TEXT,
syntheticModule,
resolvedModule,
false);
@@ -914,4 +959,20 @@ public final class VmUtils {
public static String concat(String str1, String str2) {
return str1 + str2;
}
/** Check that a value is a VmTyped and that it inherits from the given class */
public static VmTyped checkAmends(Object value, VmClass clazz) {
if (!(value instanceof VmTyped typed)) {
throw new VmExceptionBuilder().typeMismatch(value, clazz).build();
}
return checkAmends(typed, clazz);
}
/** Check that a typed value inherits from the given class */
public static VmTyped checkAmends(VmTyped value, VmClass clazz) {
if (!value.getVmClass().isSubclassOf(clazz)) {
throw new VmExceptionBuilder().typeMismatch(value.getVmClass(), clazz).build();
}
return value;
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -51,14 +51,14 @@ public final class MathNodes {
public abstract static class minInt8 extends ExternalPropertyNode {
@Specialization
protected long eval(VmTyped self) {
return -128;
return Byte.MIN_VALUE;
}
}
public abstract static class maxInt8 extends ExternalPropertyNode {
@Specialization
protected long eval(VmTyped self) {
return 127;
return Byte.MAX_VALUE;
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved.
* Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -471,13 +471,15 @@ public final class GlobResolver {
* Resolves a glob expression.
*
* <p>Each pair is the expanded form of the glob pattern, paired with its resolved absolute URI.
*
* <p>globPattern must be absolute if enclosing module details are null.
*/
@TruffleBoundary
public static Map<String, ResolvedGlobElement> resolveGlob(
SecurityManager securityManager,
ReaderBase reader,
ModuleKey enclosingModuleKey,
URI enclosingUri,
@Nullable ModuleKey enclosingModuleKey,
@Nullable URI enclosingUri,
String globPattern)
throws IOException,
SecurityManagerException,
@@ -486,6 +488,10 @@ public final class GlobResolver {
var result = new LinkedHashMap<String, ResolvedGlobElement>();
var hasAbsoluteGlob = globPattern.matches("\\w+:.*");
if ((enclosingModuleKey == null || enclosingUri == null) && !hasAbsoluteGlob) {
throw new PklBugException(
"GlobResolver.resolveGlob() callers must check that the glob pattern is absolute if calling with null enclosing module info");
}
if (reader.hasHierarchicalUris()) {
var splitPattern = splitGlobPatternIntoBaseAndWildcards(reader, globPattern, hasAbsoluteGlob);
@@ -493,7 +499,10 @@ public final class GlobResolver {
var globParts = splitPattern.second;
// short-circuit for glob pattern with no wildcards (can only match 0 or 1 element)
if (globParts.length == 0) {
var resolvedUri = IoUtils.resolve(reader, enclosingUri, globPattern);
var resolvedUri =
enclosingUri == null
? URI.create(globPattern)
: IoUtils.resolve(reader, enclosingUri, globPattern);
if (reader.hasElement(securityManager, resolvedUri)) {
result.put(globPattern, new ResolvedGlobElement(globPattern, resolvedUri, true));
}
@@ -501,7 +510,10 @@ public final class GlobResolver {
}
URI baseUri;
try {
baseUri = IoUtils.resolve(securityManager, enclosingModuleKey, URI.create(basePath));
baseUri =
enclosingModuleKey == null
? URI.create(basePath)
: IoUtils.resolve(securityManager, enclosingModuleKey, URI.create(basePath));
} catch (URISyntaxException e) {
// assertion: this is only thrown if the pattern starts with a triple-dot import.
// the language will throw an error if glob imports is combined with triple-dots.

View File

@@ -1076,3 +1076,54 @@ invalidStringBase64=\
characterCodingException=\
Invalid bytes for charset "{0}".
commandSubcommandConflict=\
Command `{0}` has subcommands with conflicting name "{1}".\n\
Elements of `command.subcommands` must have unique `name` values.
commandMustNotAssignOrAmendProperty=\
Commands must not assign or amend property `{0}`.
commandOptionNoTypeAnnotation=\
No type annotation found for `{0}` property.
commandOptionsTypeNotClass=\
Type annotation `{0}` on `options` property in `pkl:Command` subclass must be a class type.
commandOptionsTypeAbstractClass=\
Command options class `{0}` may not be abstract.
commandOptionBothFlagAndArgument=\
Found both `@Flag` and `@Argument` annotations for options property `{0}`.\n\
\n\
Only one option type may be specified.
commandOptionTypeNullableWithDefaultValue=\
Unexpected option property `{0}` with nullable type and default value.\n\
\n\
Options with default values must not be nullable.
commandOptionUnsupportedType=\
Command option property `{0}` has unsupported {1}type `{2}`.
commandOptionUnexpectedDefaultValue=\
Unexpected default value for `@{1}` property `{0}`.\n\
\n\
{1}s may not specify a default value.
commandArgumentUnexpectedNonRepeatedNullableType=\
Unexpected nullable type for non-collection `@Argument` property `{0}`.\n\
\n\
Arguments may not be nullable.
commandArgumentsMultipleRepeated=\
More than one repeated option annotated with `@Argument` found: `{0}` and `{1}`.\n\
\n\
Only one repeated argument is permitted per command.
commandFlagHelpCollision=\
Flag option `{0}` may not have name "help" or short name "h".
commandFlagInvalidType=\
Option `{0}` with annotation `@{1}` has invalid type `{2}`.\n\
Expected type: `{3}`

View File

@@ -9,6 +9,7 @@ Available standard library modules:
pkl:analyze
pkl:base
pkl:Benchmark
pkl:Command
pkl:DocPackageInfo
pkl:DocsiteInfo
pkl:EvaluatorSettings

View File

@@ -0,0 +1,791 @@
/*
* Copyright © 2025-2026 Apple Inc. and the Pkl project authors. All rights reserved.
*
* 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
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.pkl.core.runtime
import java.net.URI
import java.nio.file.Path
import kotlin.io.path.createParentDirectories
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertDoesNotThrow
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.io.TempDir
import org.pkl.commons.writeString
import org.pkl.core.CommandSpec
import org.pkl.core.Evaluator
import org.pkl.core.ModuleSource.uri
import org.pkl.core.PklException
class CommandSpecParserTest {
companion object {
private val renderOptions =
"""
extends "pkl:Command"
import "pkl:Command"
options: Options
output {
value = options
}
"""
.trimIndent()
private val evaluator = Evaluator.preconfigured()
}
@TempDir private lateinit var tempDir: Path
private fun writePklFile(fileName: String, contents: String): URI {
tempDir.resolve(fileName).createParentDirectories()
return tempDir.resolve(fileName).writeString(contents).toUri()
}
private fun parse(moduleUri: URI): CommandSpec {
var spec: CommandSpec? = null
evaluator.evaluateCommand(uri(moduleUri)) { spec = it }
return spec!!
}
@Test
fun `command module does not amend pkl_Command`() {
val moduleUri = writePklFile("cmd.pkl", "")
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("Expected value of type `pkl.Command`, but got type")
}
@Test
fun `options property assigned`() {
val moduleUri =
writePklFile(
"cmd.pkl",
"""
extends "pkl:Command"
options = new {}
"""
.trimIndent(),
)
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("options = ")
assertThat(exc.message).contains("Commands must not assign or amend property `options`.")
}
@Test
fun `options property amended`() {
val moduleUri =
writePklFile(
"cmd.pkl",
"""
extends "pkl:Command"
options {}
"""
.trimIndent(),
)
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("options {")
assertThat(exc.message).contains("Commands must not assign or amend property `options`.")
}
@Test
fun `parent property assigned`() {
val moduleUri =
writePklFile(
"cmd.pkl",
"""
extends "pkl:Command"
parent = new {}
"""
.trimIndent(),
)
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("parent = ")
assertThat(exc.message).contains("Commands must not assign or amend property `parent`.")
}
@Test
fun `parent property amended`() {
val moduleUri =
writePklFile(
"cmd.pkl",
"""
extends "pkl:Command"
parent {}
"""
.trimIndent(),
)
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("parent {")
assertThat(exc.message).contains("Commands must not assign or amend property `parent`.")
}
@Test
fun `options type annotation does not reference class`() {
val moduleUri =
writePklFile(
"cmd.pkl",
"""
extends "pkl:Command"
options: "nope" | "try again"
"""
.trimIndent(),
)
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("options: \"nope\" | \"try again\"")
assertThat(exc.message)
.contains(
"Type annotation `\"nope\" | \"try again\"` on `options` property in `pkl:Command` subclass must be a class type."
)
}
@Test
fun `options class is abstract`() {
val moduleUri =
writePklFile(
"cmd.pkl",
"""
extends "pkl:Command"
options: Options
abstract class Options {}
"""
.trimIndent(),
)
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("abstract class Options {")
assertThat(exc.message).contains("Command options class `cmd#Options` may not be abstract.")
}
@Test
fun `command property value does not amend CommandInfo`() {
val moduleUri =
writePklFile(
"cmd.pkl",
"""
extends "pkl:Command"
command = new Foo {}
class Foo
"""
.trimIndent(),
)
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("command = new Foo {}")
assertThat(exc.message)
.contains("Expected value of type `pkl.Command#CommandInfo`, but got type `cmd#Foo`.")
}
@Test
fun `first annotation of the same type wins`() {
val moduleUri =
writePklFile(
"cmd.pkl",
renderOptions +
"""
open class BaseOptions {
/// foo in BaseOptions
@Flag { shortName = "a" }
foo: String
/// bar in BaseOptions
@Flag { shortName = "b" }
bar: String
}
class Options extends BaseOptions {
/// bar in Options
@Flag { shortName = "x" }
bar: String
/// baz in Options
@Flag { shortName = "y" }
@CountedFlag { shortName = "z" }
baz: Int
}
"""
.trimIndent(),
)
val spec = parse(moduleUri)
// assert class overrides its superclass
assertThat(spec.options.toList()[0]).isInstanceOf(CommandSpec.Flag::class.java)
(spec.options.toList()[0] as CommandSpec.Flag).apply {
assertThat(this.name).isEqualTo("bar")
assertThat(this.shortName).isEqualTo("x")
assertThat(this.helpText).isEqualTo("bar in Options")
}
// assert first flag annotation wins
assertThat(spec.options.toList()[1]).isInstanceOf(CommandSpec.Flag::class.java)
(spec.options.toList()[1] as CommandSpec.Flag).apply {
assertThat(this.name).isEqualTo("baz")
assertThat(this.shortName).isEqualTo("y")
assertThat(this.helpText).isEqualTo("baz in Options")
}
// assert superclass options are inherited
assertThat(spec.options.toList()[2]).isInstanceOf(CommandSpec.Flag::class.java)
(spec.options.toList()[2] as CommandSpec.Flag).apply {
assertThat(this.name).isEqualTo("foo")
assertThat(this.shortName).isEqualTo("a")
assertThat(this.helpText).isEqualTo("foo in BaseOptions")
}
}
@Test
fun `@Flag and @Argument on the same option`() {
val moduleUri =
writePklFile(
"cmd.pkl",
renderOptions +
"""
class Options {
@Flag
@Argument
foo: String
}
"""
.trimIndent(),
)
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("foo: String")
assertThat(exc.message)
.contains("Found both `@Flag` and `@Argument` annotations for options property `foo`.")
}
@Test
fun `option with no type annotation`() {
val moduleUri =
writePklFile(
"cmd.pkl",
renderOptions +
"""
class Options {
foo = "bar"
}
"""
.trimIndent(),
)
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("foo = \"bar\"")
assertThat(exc.message).contains("No type annotation found for `foo` property.")
}
@Test
fun `nullable option with default not allowed`() {
val moduleUri =
writePklFile(
"cmd.pkl",
renderOptions +
"""
class Options {
foo: String? = "bar"
}
"""
.trimIndent(),
)
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("foo: String? = \"bar\"")
assertThat(exc.message)
.contains("Unexpected option property `foo` with nullable type and default value")
}
@Test
fun `option with union type containing non-string-literals`() {
val moduleUri =
writePklFile(
"cmd.pkl",
renderOptions +
"""
class Options {
foo: "oops" | String
}
"""
.trimIndent(),
)
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("foo: \"oops\" | String")
assertThat(exc.message)
.contains("Command option property `foo` has unsupported type `\"oops\" | String`.")
}
@Test
fun `argument with default not allowed`() {
val moduleUri =
writePklFile(
"cmd.pkl",
renderOptions +
"""
class Options {
@Argument
foo: String = "bar"
}
"""
.trimIndent(),
)
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("foo: String = \"bar\"")
assertThat(exc.message).contains("Unexpected default value for `@Argument` property `foo`.")
}
@Test
fun `nullable non-collection argument not allowed`() {
val moduleUri =
writePklFile(
"cmd.pkl",
renderOptions +
"""
class Options {
@Argument
foo: String?
}
"""
.trimIndent(),
)
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("foo: String?")
assertThat(exc.message)
.contains("Unexpected nullable type for non-collection `@Argument` property `foo`.")
}
@Test
fun `non-constant default values result in an optional flag with no default`() {
val moduleUri =
writePklFile(
"cmd.pkl",
renderOptions +
"""
class Options {
foo: String = "hi"
bar: String = foo
baz: Map<String, String> = Map()
qux: Map<String, String> = baz
quux: Int = 5
}
"""
.trimIndent(),
)
val spec = parse(moduleUri)
assertThat(spec.options.toList()[0]).isInstanceOf(CommandSpec.Flag::class.java)
(spec.options.toList()[0] as CommandSpec.Flag).apply {
assertThat(this.name).isEqualTo("foo")
assertThat(this.defaultValue).isEqualTo("hi")
}
assertThat(spec.options.toList()[1]).isInstanceOf(CommandSpec.Flag::class.java)
(spec.options.toList()[1] as CommandSpec.Flag).apply {
assertThat(this.name).isEqualTo("bar")
assertThat(this.defaultValue).isNull()
}
assertThat(spec.options.toList()[2]).isInstanceOf(CommandSpec.Flag::class.java)
(spec.options.toList()[2] as CommandSpec.Flag).apply {
assertThat(this.name).isEqualTo("baz")
assertThat(this.defaultValue).isNull()
}
assertThat(spec.options.toList()[3]).isInstanceOf(CommandSpec.Flag::class.java)
(spec.options.toList()[3] as CommandSpec.Flag).apply {
assertThat(this.name).isEqualTo("qux")
assertThat(this.defaultValue).isNull()
}
assertThat(spec.options.toList()[4]).isInstanceOf(CommandSpec.Flag::class.java)
(spec.options.toList()[4] as CommandSpec.Flag).apply {
assertThat(this.name).isEqualTo("quux")
assertThat(this.defaultValue).isEqualTo("5")
}
}
@Test
fun `flag with collision on --help`() {
val moduleUri =
writePklFile(
"cmd.pkl",
renderOptions +
"""
class Options {
help: Boolean
}
"""
.trimIndent(),
)
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\".")
}
@Test
fun `flag with collision on -h`() {
val moduleUri =
writePklFile(
"cmd.pkl",
renderOptions +
"""
class Options {
@Flag { shortName = "h" }
showHelp: Boolean
}
"""
.trimIndent(),
)
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\".")
}
@Test
fun `multiple arguments with collection types not allowed`() {
val moduleUri =
writePklFile(
"cmd.pkl",
renderOptions +
"""
class Options {
@Argument
list: List<String>
@Argument
set: Set<String>
}
"""
.trimIndent(),
)
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("class Options {")
assertThat(exc.message)
.contains("More than one repeated option annotated with `@Argument` found: `list` and `set`.")
assertThat(exc.message).contains("Only one repeated argument is permitted per command.")
}
@Test
fun `collection option with collection element type`() {
val moduleUri =
writePklFile(
"cmd.pkl",
renderOptions +
"""
class Options {
foo: List<List<"a" | "b">>
}
"""
.trimIndent(),
)
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("foo: List<List<\"a\" | \"b\">>")
assertThat(exc.message)
.contains("Command option property `foo` has unsupported element type `List<\"a\" | \"b\">`.")
}
@Test
fun `collection option with map element type`() {
val moduleUri =
writePklFile(
"cmd.pkl",
renderOptions +
"""
class Options {
foo: List<Map<String, "a" | "b">>
}
"""
.trimIndent(),
)
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("foo: List<Map<String, \"a\" | \"b\">>")
assertThat(exc.message)
.contains(
"Command option property `foo` has unsupported element type `Map<String, \"a\" | \"b\">`."
)
}
@Test
fun `map option with collection value type`() {
val moduleUri =
writePklFile(
"cmd.pkl",
renderOptions +
"""
class Options {
foo: Map<String, List<"a" | "b">>
}
"""
.trimIndent(),
)
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("foo: Map<String, List<\"a\" | \"b\">>")
assertThat(exc.message)
.contains("Command option property `foo` has unsupported value type `List<\"a\" | \"b\">`.")
}
@Test
fun `map option with map value type`() {
val moduleUri =
writePklFile(
"cmd.pkl",
renderOptions +
"""
class Options {
foo: Map<String, Map<String, "a" | "b">>
}
"""
.trimIndent(),
)
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("foo: Map<String, Map<String, \"a\" | \"b\">>")
assertThat(exc.message)
.contains(
"Command option property `foo` has unsupported value type `Map<String, \"a\" | \"b\">`."
)
}
@Test
fun `map option with collection key type`() {
val moduleUri =
writePklFile(
"cmd.pkl",
renderOptions +
"""
class Options {
foo: Map<Map<String, "a" | "b">, String>
}
"""
.trimIndent(),
)
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("foo: Map<Map<String, \"a\" | \"b\">, String>")
assertThat(exc.message)
.contains(
"Command option property `foo` has unsupported key type `Map<String, \"a\" | \"b\">`."
)
}
@Test
fun `map option with map key type`() {
val moduleUri =
writePklFile(
"cmd.pkl",
renderOptions +
"""
class Options {
foo: Map<Map<String, "a" | "b">, String>
}
"""
.trimIndent(),
)
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("foo: Map<Map<String, \"a\" | \"b\">, String>")
assertThat(exc.message)
.contains(
"Command option property `foo` has unsupported key type `Map<String, \"a\" | \"b\">`."
)
}
@Test
fun `map option with map key type allowed with convert`() {
val moduleUri =
writePklFile(
"cmd.pkl",
renderOptions +
"""
class Options {
@Flag { convert = (it) -> Pair("foo", "a") }
foo: Map<Map<String, "a" | "b">, String>
}
"""
.trimIndent(),
)
assertDoesNotThrow { parse(moduleUri) }
}
@Test
fun `unsupported option type`() {
val moduleUri =
writePklFile(
"cmd.pkl",
renderOptions +
"""
class Options {
foo: Foo
}
class Foo
"""
.trimIndent(),
)
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("foo: Foo")
assertThat(exc.message).contains("Command option property `foo` has unsupported type `Foo`.")
}
@Test
fun `options constraints in all positions are erased`() {
val moduleUri =
writePklFile(
"cmd.pkl",
renderOptions +
"""
class Options {
a: String(true)
b: String?(true)
c: String(true)?
d: List<String(true)>
e: List<String(true)>(true)
f: List<String(true)>(true)?(true)
g: (Map<String(true), String(true)>(true)?(true))(true)
}
"""
.trimIndent(),
)
parse(moduleUri)
}
@Test
fun `conflicting subcommand names`() {
val moduleUri =
writePklFile(
"cmd.pkl",
"""
extends "pkl:Command"
import "pkl:Command"
command {
subcommands {
new Sub { command { name = "foo" } }
new Sub { command { name = "foo" } }
}
}
class Sub extends Command
"""
.trimIndent(),
)
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("Command `cmd` has subcommands with conflicting name \"foo\".")
}
@Test
fun `list or set option with no type arguments`() {
for (type in listOf("List", "Set")) {
val moduleUri =
writePklFile(
"cmd_$type.pkl",
renderOptions +
"""
class Options {
foo: $type
}
"""
.trimIndent(),
)
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("foo: $type")
assertThat(exc.message)
.contains("Command option property `foo` has unsupported type `$type`.")
assertThat(exc.message).contains("$type options must provide one type argument.")
}
}
@Test
fun `map option with no type arguments`() {
val moduleUri =
writePklFile(
"cmd.pkl",
renderOptions +
"""
class Options {
foo: Map
}
"""
.trimIndent(),
)
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("foo: Map")
assertThat(exc.message).contains("Command option property `foo` has unsupported type `Map`.")
assertThat(exc.message).contains("Map options must provide two type arguments.")
}
@Test
fun `boolean flag with incorrect type`() {
val moduleUri =
writePklFile(
"cmd.pkl",
renderOptions +
"""
class Options {
@BooleanFlag
foo: String
}
"""
.trimIndent(),
)
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("foo: String")
assertThat(exc.message)
.contains("Option `foo` with annotation `@BooleanFlag` has invalid type `String`.")
assertThat(exc.message).contains("Expected type: `Boolean`")
}
@Test
fun `counted flag with incorrect type`() {
val moduleUri =
writePklFile(
"cmd.pkl",
renderOptions +
"""
class Options {
@CountedFlag
foo: String
}
"""
.trimIndent(),
)
val exc = assertThrows<PklException> { parse(moduleUri) }
assertThat(exc.message).contains("foo: String")
assertThat(exc.message)
.contains("Option `foo` with annotation `@CountedFlag` has invalid type `String`.")
assertThat(exc.message).contains("Expected type: `Int`")
}
}