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
@@ -68,6 +68,7 @@ endif::[]
:uri-pkldoc-example: {uri-pkl-examples-tree}/pkldoc
:uri-stdlib-baseModule: {uri-pkl-stdlib-docs}/base
:uri-stdlib-CommandModule: {uri-pkl-stdlib-docs}/Command
:uri-stdlib-analyzeModule: {uri-pkl-stdlib-docs}/analyze
:uri-stdlib-jsonnetModule: {uri-pkl-stdlib-docs}/jsonnet
:uri-stdlib-reflectModule: {uri-pkl-stdlib-docs}/reflect
@@ -150,6 +151,13 @@ endif::[]
:uri-stdlib-Resource: {uri-stdlib-baseModule}/Resource
:uri-stdlib-outputFiles: {uri-stdlib-baseModule}/ModuleOutput#files
:uri-stdlib-FileOutput: {uri-stdlib-baseModule}/FileOutput
:uri-stdlib-Annotation: {uri-stdlib-baseModule}/Annotation
:uri-stdlib-ConvertProperty: {uri-stdlib-baseModule}/ConvertProperty
:uri-stdlib-Command-Flag: {uri-stdlib-CommandModule}/Flag
:uri-stdlib-Command-BooleanFlag: {uri-stdlib-CommandModule}/BooleanFlag
:uri-stdlib-Command-CountedFlag: {uri-stdlib-CommandModule}/CountedFlag
:uri-stdlib-Command-Argument: {uri-stdlib-CommandModule}/Argument
:uri-stdlib-Command-Import: {uri-stdlib-CommandModule}/Import
:uri-messagepack: https://msgpack.org/index.html
:uri-messagepack-spec: https://github.com/msgpack/msgpack/blob/master/spec.md
@@ -3961,6 +3961,7 @@ emailList: List<EmailAddress> // <2>
<1> equivalent to `email: String(contains("@"))` for type checking purposes
<2> equivalent to `emailList: List<String(contains("@"))>` for type checking purposes
[[nullable-types]]
==== Nullable Types
Class types such as `Bird` (see above) do not admit `null` values.
+316 -2
View File
@@ -489,7 +489,9 @@ If these are the only failures, the command exits with exit code 10.
Otherwise, failures result in exit code 1.
<modules>::
The absolute or relative URIs of the modules to test. Relative URIs are resolved against the working directory.
The absolute or relative URIs of the modules to test.
The module must extend `pkl:test`.
Relative URIs are resolved against the working directory.
==== Options
@@ -546,6 +548,23 @@ Use `--no-power-assertions` to disable this feature if you prefer simpler output
This command also takes <<common-options, common options>>.
[[command-run]]
=== `pkl run`
*Synopsis:* `pkl run [<options>] [<module>] [<command options>]`
Evaluate a <<cli-tools,CLI command>> defined by `<module>`.
<module>::
The absolute or relative URIs of the command module to run.
The module must extend `pkl:Command`.
Relative URIs are resolved against the working directory.
<command options>::
Additional CLI options and arguments defined by `<module>`.
This command also takes <<common-options, common options>>, but they must be specified before `<module>`.
[[command-repl]]
=== `pkl repl`
@@ -800,7 +819,7 @@ Write the path of files with formatting violations to stdout.
[[common-options]]
=== Common options
The <<command-eval>>, <<command-test>>, <<command-repl>>, <<command-project-resolve>>, <<command-project-package>>, <<command-download-package>>, and <<command-analyze-imports>> commands support the following common options:
The <<command-eval>>, <<command-test>>, <<command-run>>, <<command-repl>>, <<command-project-resolve>>, <<command-project-package>>, <<command-download-package>>, and <<command-analyze-imports>> commands support the following common options:
include::../../pkl-cli/partials/cli-common-options.adoc[]
@@ -901,6 +920,301 @@ If multiple module outputs are written to the same file, or to standard output,
By default, module outputs are separated with `---`, as in a YAML stream.
The separator can be customized using the `--module-output-separator` option.
[[cli-tools]]
== Implementing CLI Tools
CLI tools can be implemented in Pkl by modules extending the `pkl:Command` module.
With `pkl:Command`, you can define a script in Pkl that is executed by your shell, providing a better CLI experience.
Regular evaluation requires use of xref:language-reference:index.adoc#resources[resources] like properties and evironment variables to provide parameters:
[source,bash]
----
$ pkl eval script.pkl -p username=me -p password=password
----
Commands provide a native, familiar CLI experience:
[source,bash]
----
$ pkl run script.pkl --username=admin --password=hunter2
$ ./script.pkl --username=admin --password=hunter2
----
Pkl commands have a few properties that distinguish them from standard module evaluation:
* Users provide input to commands using familiar command line idioms, providing a better experience than deriving inputs from xref:language-reference:index.adoc#resources[resources] like external properties or environment variables.
* Commands can dynamically import modules when they are specified as command line options.
* Commands may write to standard output (via `output.text` or `output.bytes`) and the filesystem (via `output.files`) in the same evaluation.
* Command file output may write to any absolute path (not only relative to the `--multiple-file-output-path` option).
** Relative output paths are written relative to the current working directory (or `--working-dir`, if specified).
** Paths of output file are printed to the command's standard error.
IMPORTANT: Users of `pkl run` must be aware of the security implications of this behavior.
Using `pkl eval` prevents accidental overwrites by not allowing absolute paths, but `pkl run` does not offer this protection.
Commands may write to any path the invoking user has permissions to modify.
Commands are implemented as regular modules and declare their supported command line flags and positional arguments using a class with annotated properties.
=== Defining Commands
Commands are defined by creating a module that extends `pkl:Command`:
[source,pkl%tested]
.my-tool.pkl
----
/// This doc comment becomes part of the command's CLI help!
/// Markdown formatting is **allowed!**
extends "pkl:Command"
options: Options // <1>
class Options {
// Define CLI flags/arguments...
}
// Regular module code...
----
<1> Re-declaration of the `options` property's type.
Like `pkl eval`, when a command completes without an evaluation error the process exits successfully (exit code 0).
Commands can return a failure using `throw` (exit code 1), but otherwise may not control the exit code.
Other than the differences listed above, commands behave like any other Pkl module.
For example, there is no way to execute other programs or make arbitrary HTTP requests.
If additional functionality is desired, xref:language-reference:index.adoc#external-readers[external readers] may be used to extends Pkl's capabilities.
=== Command Options
Each property of a command's options class becomes a command line option.
Properties with the `local`, `hidden`, `fixed`, and/or `const` modifiers are not parsed as options
A property's doc comment, if present, becomes the corresponding option's CLI help description.
Doc comments are interpreted as Markdown text and formatted nicely when displayed to users.
Properties must have xref:language-reference:index.adoc#type-annotations[type annotations] to determine how they are parsed.
Properties may be xref:language-reference:index.adoc#annotations[annotated] to influence how they behave:
* Properties annotated with link:{uri-stdlib-Command-Flag}[`@Flag`] become CLI flags named `--<property name>` that accept a value.
* `Boolean` properties annotated with link:{uri-stdlib-Command-BooleanFlag}[`@BooleanFlag`] become CLI flags named `--<property name>` and `--no-<property name>` that result in `true` and `false` values, respectively.
* `Int` (and type aliases of `Int`) properties annotated with link:{uri-stdlib-Command-CountedFlag}[`@CountedFlag`] become CLI flags named `--<property name>` that produce a value equal to the number of times they are present on the command line.
* Properties annotated with link:{uri-stdlib-Command-Argument}[`@Argument`] become positional CLI arguments and are parsed in the order they appear in the class.
* Properties with no annotation are treated the same as `@Flag` with no further customization.
Flag options may set a `shortName` property to define a single-character abbreviation (`-<short name>`).
Flag abbreviations may be combined (e.g. `-a -b -v -v -f some-value` is equivalent to `-abvvf some-value`).
A `@Flag` or `@Argument` property's type annotation determines how it is converted from the raw string value:
|===
|Type |Behavior
|`String`
|Value is used verbatim.
|`Char`
|Value is used verbatim but must be exactly one character.
|`Boolean`
|True values: `true`, `t`, `1`, `yes`, `y`, `on`
False values: `false`, `f`, `0`, `no`, `n`, `off`
|`Number`
|Value is parsed as an `Int` if possible, otherwise parsed as `Float`.
|`Float`
|Value is parsed as a `Float`.
|`Int`
|Value is parsed as a `Int`.
|`Int8`, `Int16`, `Int32`, `UInt`, `UInt8`, `UInt16`, `UInt32`
|Value is parsed as a `Int` and must be within the type's range.
|xref:language-reference:index.adoc#union-types[Union] of xref:language-reference:index.adoc#string-literal-types[string literals]
|Value is used verbatim but must match a member of the union.
|`List<Element>`, `Listing<Element>`, `Set<Element>`
|Each occurrence of the option becomes an element of the final value.
`Element` values are parsed based on the above primitive types.
|`Map<Key, Value>`, `Mapping<Key, Value>`
|Each occurrence of the option becomes an entry of the final value.
Values are split on the first `"="` character; the first part is parsed as `Key` and the second as `Value`, both based on the above primitive types.
|`Pair<First, Second>`
|Value is split on the first `"="` character; the first part is parsed as `First` and the second as `Second`, both based on the above primitive types.
|===
If a flag that accepts only a single value is provided multiple times, the last occurrence becomes the final value.
Only a single positional argument accepting multiple values is permitted per command.
A property with a xref:language-reference:index.adoc#nullable-types[nullable type] is optional and, if not specified on the command line, will have value `null`.
Properties with default values are also optional.
Type constraints are evaluated when the command is executed, so additional restrictions on option values are enforced at runtime.
==== Custom Option Conversion and Aggregation
A property may be annotated with any type if its `@Flag` or `@Argument` annotation sets the `convert` or `transformAll` properties.
The `convert` property is a xref:language-reference:index.adoc#anonymous-functions[function] that overrides how _each_ raw option value is interpreted.
The `transformAll` property is a function that overrides how _all_ parsed option values become the final property value.
The `convert` function may return an link:{uri-stdlib-Command-Import}[`Import`] value that is replaced during option parsing with the actual value of the module specified by its `uri` property.
If `glob` is `true`, the replacement value is a `Mapping`; its keys are the _absolute_ URIs of the matched modules and its values are the actual module values.
When specifying glob import options on the command line, it is often necessary to quote the value to avoid it being interpreted by the shell.
If the return value of `convert` is a `List`, `Set`, `Map`, or `Pair`, each contained value (elements and entry keys/values) that are `Import` values are also replaced.
[IMPORTANT]
====
If an option has type `Mapping<String, «some module type»>` and should accept a single glob pattern value, the option's annotation must also set `multiple = false` to override the default behavior of `Mapping` options accepting multiple values.
Example:
[source%parsed,{pkl}]
----
@Flag {
convert = (it) -> new Import { uri = it; glob = true }
multiple = false
}
birds: Mapping<String, Bird>
----
If multiple glob patterns values should be accepted and merged, `transformAll` may be used to merge every glob-imported `Mapping`:
[source%parsed,{pkl}]
----
@Flag {
convert = (it) -> new Import { uri = it; glob = true }
transformAll =
(values) -> values.fold(new Mapping {}, (result, element) ->
(result) { ...element }
)
}
birds: Mapping<String, Bird>
----
====
=== Subcommands
Like many other command line libraries, `pkl:Command` allows building commands into a hierarchy with a root command and subcommands:
[source,pkl%tested]
.my-tool.pkl
----
extends "pkl:Command"
command {
subcommands {
import("subcommand1.pkl")
import("subcommand2.pkl")
for (_, subcommand in import*("./subcommands/*.pkl")) {
subcommand
}
}
}
----
[source,pkl%tested]
.subcommand1.pkl
----
extends "pkl:Command"
import "my-tool.pkl"
parent: `my-tool` // <1>
// Regular module code...
----
<1> Optional; asserts that this is a subcommand of `my-tool` and simplifies accessing properties and options of the parent command
Each element of `subcommands` must have a unique value for `command.name`.
=== Testing Commands
Command modules are normal Pkl modules, so they may be imported and used like any other module.
This is particularly helpful when testing commands, as the command's `options` and `parent` properties can be populated by test code.
Testing the above command and subcommand might look like this:
[source,pkl%tested]
----
amends "pkl:test"
import "my-tool.pkl"
import "subcommand1.pkl"
examples {
["Test my-tool"] {
(`my-tool`) {
options {
// Set my-tool options here...
}
}.output.text
}
["Test subcommand1"] {
(subcommand1) {
parent { // this amends `my-tool`
options {
// Set my-tool options here...
}
}
options {
// Set subcommand options here...
}
}.output.text
}
}
----
[[commands-as-standalone-scripts]]
=== Commands as standalone scripts
On *nix platforms, Pkl commands can be configured to run as standalone tools that can be invoked without the `pkl run` command.
To achieve this, the command file must be marked executable (i.e. `chmod +x my-tool.pkl`) and a link:https://en.wikipedia.org/wiki/Shebang_(Unix)[shebang comment] must be added on the first line of the file:
[source,pkl%parsed]
----
#!/usr/bin/env -S pkl run
----
NOTE: The `-S` flag for `env` is required on Linux systems due to a limitation of shebang handling in the Linux kernel.
While not required on other *nix platforms like macOS, but it should be included for compatibility.
==== Shell Completion
Like with Pkl's own CLI, <<command-shell-completion, shell completions>> can be generated for standalone scripts.
[source,shell]
----
# Generate shell completion script for bash
./my-tool.pkl shell-completion bash
# Generate shell completion script for zsh
./my-tool.pkl shell-completion zsh
----
==== Customizing Completion Candidates
`@Flag` and `@Argument` annotations may specify the `completionCandidates` to improve generated shell completions.
Valid values include:
* A `Listing<String>` of literal string values to offer for completion.
* The literal string `"path"`, which offers local file paths for completion.
Options with a string literal union type implicitly offer the members of the union as completion candidates.
=== Flag name ambiguities
It is possible for commands to define flags with names or short names that collide with Pkl's own command line options.
To avoid ambiguity in parsing these options, all flags for Pkl itself (e.g. `--root-dir`) must be placed before the root command module's URI.
Command authors are encouraged to avoid overlapping with Pkl's built-in flags, but this may not always be feasible, especially for single-character abbreviated names.
This imposes a limitation around <<commands-as-standalone-scripts,standalone commands>> that prevents users from customizing Pkl evaluator options when they are invoked.
There are two recommended workarounds for this limitation:
* Use a `PklProject` to define evaluator settings instead of doing so on the command line.
* If the command line must be used, switch to invoking via `pkl run [<flags>] [<root command module>]`.
[[repl]]
== Working with the REPL
@@ -0,0 +1,263 @@
/*
* 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.cli
import com.github.ajalt.clikt.completion.CompletionCandidates
import com.github.ajalt.clikt.completion.CompletionCommand
import com.github.ajalt.clikt.core.*
import com.github.ajalt.clikt.parameters.arguments.*
import com.github.ajalt.clikt.parameters.options.*
import com.github.ajalt.clikt.parameters.types.int
import java.io.OutputStream
import java.nio.file.Path
import kotlin.io.path.createParentDirectories
import kotlin.io.path.exists
import kotlin.io.path.isDirectory
import kotlin.io.path.writeBytes
import org.pkl.commons.cli.CliBaseOptions
import org.pkl.commons.cli.CliCommand
import org.pkl.commons.cli.CliException
import org.pkl.commons.cli.commands.installCommonOptions
import org.pkl.commons.currentWorkingDir
import org.pkl.core.Closeables
import org.pkl.core.CommandSpec
import org.pkl.core.EvaluatorBuilder
import org.pkl.core.FileOutput
import org.pkl.core.ModuleSource.uri
import org.pkl.core.PklBugException
import org.pkl.core.PklException
import org.pkl.core.util.IoUtils
class CliCommandRunner
@JvmOverloads
constructor(
private val options: CliBaseOptions,
private val args: List<String>,
private val outputStream: OutputStream = System.out,
private val errStream: OutputStream = System.err,
) : CliCommand(options) {
private val normalizedSourceModule = options.normalizedSourceModules.first()
override fun doRun() {
val builder = evaluatorBuilder()
try {
evalCmd(builder)
} finally {
Closeables.closeQuietly(builder.moduleKeyFactories)
Closeables.closeQuietly(builder.resourceReaders)
}
}
private fun evalCmd(builder: EvaluatorBuilder) {
val evaluator = builder.build()
evaluator.use {
evaluator.evaluateCommand(uri(normalizedSourceModule)) { spec ->
try {
val root = SynthesizedRunCommand(spec, this, options.sourceModules.first().toString())
root.installCommonOptions(includeVersion = false)
root.subcommands(
CompletionCommand(
name = "shell-completion",
help = "Generate a completion script for the given shell",
)
)
root.parse(args)
} catch (e: PklException) {
throw e
} catch (e: Exception) {
throw e.message?.let { PklException(it, e) } ?: PklException(e)
}
}
}
}
/** Renders the comand's `output.bytes`, writing it to the standard output stream. */
fun writeOutput(outputBytes: ByteArray) {
if (outputBytes.isEmpty()) return
outputStream.write(outputBytes)
outputStream.flush()
}
/**
* Renders the command's `output.files`, writing each entry as a file.
*
* File paths are written to the standard error stream.
*
* Unlike CliEvaluator, command outputs write relative to --working-dir and may write files
* anywhere in the filesystem. This is intentionally less sandboxed than `pkl eval` and directly
* targets the capabilities of CLI tools written in general purpose languages. Pkl commands should
* therefore be treated as untrusted code the way that any other CLI tool would be.
*/
fun writeMultipleFileOutput(outputFiles: Map<String, FileOutput>) {
if (outputFiles.isEmpty()) return
val writtenFiles = mutableMapOf<Path, String>()
val outputDir = options.normalizedWorkingDir
if (outputDir.exists() && !outputDir.isDirectory()) {
throw CliException("Output path `$outputDir` exists and is not a directory.")
}
for ((pathSpec, fileOutput) in outputFiles) {
checkPathSpec(pathSpec)
val resolvedPath = outputDir.resolve(pathSpec).normalize()
val realPath = if (resolvedPath.exists()) resolvedPath.toRealPath() else resolvedPath
val previousOutput = writtenFiles[realPath]
if (previousOutput != null) {
throw CliException(
"Output file conflict: `output.files` entries `\"${previousOutput}\"` and `\"$pathSpec\"` resolve to the same file path `$realPath`."
)
}
if (realPath.isDirectory()) {
throw CliException(
"Output file conflict: `output.files` entry `\"$pathSpec\"` resolves to file path `$realPath`, which is a directory."
)
}
writtenFiles[realPath] = pathSpec
realPath.createParentDirectories()
realPath.writeBytes(fileOutput.bytes)
val displayPath =
if (Path.of(pathSpec).isAbsolute) pathSpec
else IoUtils.relativize(resolvedPath, currentWorkingDir).toString()
errStream.writeText(displayPath + IoUtils.getLineSeparator())
errStream.flush()
}
}
class SynthesizedRunCommand(
private val spec: CommandSpec,
private val runner: CliCommandRunner,
name: String? = null,
) : CliktCommand(name = name ?: spec.name) {
init {
spec.options.forEach { opt ->
when (opt) {
is CommandSpec.Flag ->
registerOption(
option(
names = opt.names,
help = opt.helpText ?: "",
metavar = opt.metavar,
hidden = opt.hidden,
completionCandidates = opt.completionCandidates?.toClikt(),
)
.convert {
try {
opt.transformEach.apply(it, runner.options.normalizedWorkingDir.toUri())
} catch (e: CommandSpec.Option.BadValue) {
fail(e.message!!)
} catch (_: CommandSpec.Option.MissingOption) {
throw MissingOption(option)
}
}
.transformAll(opt.defaultValue, opt.showAsRequired) {
try {
opt.transformAll.apply(it)
} catch (e: CommandSpec.Option.BadValue) {
fail(e.message!!)
} catch (_: CommandSpec.Option.MissingOption) {
throw MissingOption(option)
}
}
)
is CommandSpec.BooleanFlag ->
registerOption(
if (opt.defaultValue != null)
option(names = opt.names, help = opt.helpText ?: "", hidden = opt.hidden)
.flag("--no-${opt.name}", default = opt.defaultValue!!)
else
option(names = opt.names, help = opt.helpText ?: "", hidden = opt.hidden)
.nullableFlag("--no-${opt.name}")
)
is CommandSpec.CountedFlag ->
registerOption(
option(names = opt.names, help = opt.helpText ?: "", hidden = opt.hidden)
.int()
.transformValues(0..0) { it.lastOrNull() ?: 1 }
.transformAll { it.sum().toLong() }
)
is CommandSpec.Argument ->
registerArgument(
argument(
opt.name,
opt.helpText ?: "",
completionCandidates = opt.completionCandidates?.toClikt(),
)
.convert {
try {
opt.transformEach.apply(it, runner.options.normalizedWorkingDir.toUri())
} catch (e: CommandSpec.Option.BadValue) {
fail(e.message!!)
} catch (_: CommandSpec.Option.MissingOption) {
throw MissingArgument(argument)
}
}
.transformAll(if (opt.repeated) -1 else 1, !opt.repeated) {
try {
opt.transformAll.apply(it)
} catch (e: CommandSpec.Option.BadValue) {
fail(e.message!!)
} catch (_: CommandSpec.Option.MissingOption) {
throw MissingArgument(argument)
}
}
)
}
}
spec.subcommands.forEach { subcommands(SynthesizedRunCommand(it, runner)) }
}
override val invokeWithoutSubcommand = true
override val hiddenFromHelp: Boolean = spec.hidden
override fun help(context: Context): String = spec.helpText ?: ""
override fun run() {
if (currentContext.invokedSubcommand is CompletionCommand) return
val opts =
registeredOptions()
.mapNotNull {
val opt = it as? OptionWithValues<*, *, *> ?: return@mapNotNull null
return@mapNotNull if (it.names.contains("--help")) null
else it.names.first().trimStart('-') to opt.value
}
.toMap() +
registeredArguments()
.mapNotNull { it as? ArgumentDelegate<*> }
.associateBy({ it.name }, { it.value })
val state = spec.apply.apply(opts, currentContext.obj as CommandSpec.State?)
currentContext.obj = state
if (currentContext.invokedSubcommand != null) return
if (spec.subcommands.isNotEmpty() && spec.noOp) {
throw PrintHelpMessage(currentContext, true, 1)
}
val result = state.evaluate()
runner.writeOutput(result.outputBytes)
runner.writeMultipleFileOutput(result.outputFiles)
}
}
}
fun CommandSpec.CompletionCandidates.toClikt(): CompletionCandidates =
when (this) {
CommandSpec.CompletionCandidates.PATH -> CompletionCandidates.Path
is CommandSpec.CompletionCandidates.Fixed -> CompletionCandidates.Fixed(values)
else -> throw PklBugException.unreachableCode()
}
@@ -143,17 +143,6 @@ constructor(
}
}
private fun Evaluator.writeOutput(moduleSource: ModuleSource, writeTo: Path): Boolean {
if (options.expression == null) {
val bytes = evaluateOutputBytes(moduleSource)
writeTo.writeBytes(bytes)
return bytes.isNotEmpty()
}
val text = evaluateExpressionString(moduleSource, options.expression)
writeTo.writeString(text)
return text.isNotEmpty()
}
private fun Evaluator.evalOutput(moduleSource: ModuleSource): ByteArray {
if (options.expression == null) {
return evaluateOutputBytes(moduleSource)
@@ -227,13 +216,6 @@ constructor(
}
}
private fun OutputStream.writeText(text: String) = write(text.toByteArray())
private fun OutputStream.writeLine(text: String) {
writeText(text)
writeText("\n")
}
private fun toModuleSource(uri: URI, reader: InputStream) =
if (uri == VmUtils.REPL_TEXT_URI) {
ModuleSource.create(uri, reader.readAllBytes().toString(StandardCharsets.UTF_8))
@@ -241,14 +223,6 @@ constructor(
ModuleSource.uri(uri)
}
private fun checkPathSpec(pathSpec: String) {
val illegal = pathSpec.indexOfFirst { IoUtils.isReservedFilenameChar(it) && it != '/' }
if (illegal == -1) {
return
}
throw CliException("Path spec `$pathSpec` contains illegal character `${pathSpec[illegal]}`.")
}
/**
* Renders each module's `output.files`, writing each entry as a file into the specified output
* directory.
@@ -0,0 +1,35 @@
/*
* 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.cli
import java.io.OutputStream
import org.pkl.commons.cli.CliException
import org.pkl.core.util.IoUtils
fun checkPathSpec(pathSpec: String) {
val illegal = pathSpec.indexOfFirst { IoUtils.isReservedFilenameChar(it) && it != '/' }
if (illegal == -1) {
return
}
throw CliException("Path spec `$pathSpec` contains illegal character `${pathSpec[illegal]}`.")
}
fun OutputStream.writeText(text: String) = write(text.toByteArray())
fun OutputStream.writeLine(text: String) {
writeText(text)
writeText("\n")
}
@@ -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.
@@ -50,6 +50,7 @@ class RootCommand : NoOpCliktCommand(name = "pkl") {
DownloadPackageCommand(),
AnalyzeCommand(),
FormatterCommand(),
RunCommand(),
CompletionCommand(
name = "shell-completion",
help = "Generate a completion script for the given shell",
@@ -0,0 +1,50 @@
/*
* 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.cli.commands
import com.github.ajalt.clikt.completion.CompletionCandidates
import com.github.ajalt.clikt.core.context
import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.arguments.convert
import com.github.ajalt.clikt.parameters.arguments.multiple
import com.github.ajalt.clikt.parameters.groups.provideDelegate
import java.net.URI
import org.pkl.cli.CliCommandRunner
import org.pkl.commons.cli.commands.BaseCommand
import org.pkl.commons.cli.commands.BaseOptions
import org.pkl.commons.cli.commands.ProjectOptions
class RunCommand : BaseCommand(name = "run", helpLink = helpLink) {
override val helpString = "Run a Pkl pkl:Command CLI tool"
override val treatUnknownOptionsAsArgs = true
init {
context { allowInterspersedArgs = false }
}
val module: URI by
argument(name = "module", completionCandidates = CompletionCandidates.Path).convert {
BaseOptions.parseModuleName(it)
}
val args: List<String> by argument(name = "args").multiple()
private val projectOptions by ProjectOptions()
override fun run() {
CliCommandRunner(baseOptions.baseOptions(listOf(module), projectOptions), args).run()
}
}
File diff suppressed because it is too large Load Diff
@@ -29,7 +29,7 @@ import org.pkl.core.util.IoUtils
/** Base options shared between CLI commands. */
data class CliBaseOptions(
/** The source modules to evaluate. Relative URIs are resolved against [workingDir]. */
private val sourceModules: List<URI> = listOf(),
val sourceModules: List<URI> = listOf(),
/**
* The URI patterns that determine which modules can be loaded and evaluated. Patterns are matched
@@ -15,6 +15,7 @@
*/
package org.pkl.commons.cli
import com.github.ajalt.clikt.core.CliktError
import java.net.URI
import java.nio.file.Files
import java.nio.file.Path
@@ -46,6 +47,7 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) {
proxyAddress?.let(IoUtils::setSystemProxy)
doRun()
} catch (e: PklException) {
if (e.cause is CliktError) throw e.cause!!
throw CliException(e.message!!)
} catch (e: CliException) {
throw e
@@ -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.
@@ -27,14 +27,16 @@ import org.pkl.core.Release
private val theme = Theme { styles["markdown.code.span"] = TextStyle(bold = true) }
fun <T : BaseCliktCommand<T>> T.installCommonOptions() {
fun <T : BaseCliktCommand<T>> T.installCommonOptions(includeVersion: Boolean = true) {
installMordantMarkdown()
versionOption(
Release.current().versionInfo,
names = setOf("-v", "--version"),
message = { if (commandName == "pkl") it else it.replaceFirst("Pkl", commandName) },
)
if (includeVersion) {
versionOption(
Release.current().versionInfo,
names = setOf("-v", "--version"),
message = { if (commandName == "pkl") it else it.replaceFirst("Pkl", commandName) },
)
}
context { terminal = Terminal(theme = theme) }
}
@@ -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) {}
}
@@ -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.
@@ -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();
@@ -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;
}
@@ -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) {
@@ -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;
@@ -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()) {
@@ -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();
@@ -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");
}
@@ -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
@@ -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");
@@ -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":
@@ -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());
@@ -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.
@@ -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;
}
}
@@ -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;
}
}
@@ -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.
@@ -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}`
@@ -9,6 +9,7 @@ Available standard library modules:
pkl:analyze
pkl:base
pkl:Benchmark
pkl:Command
pkl:DocPackageInfo
pkl:DocsiteInfo
pkl:EvaluatorSettings
@@ -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`")
}
}
+290
View File
@@ -0,0 +1,290 @@
//===----------------------------------------------------------------------===//
// Copyright © 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.
//===----------------------------------------------------------------------===//
/// Defines inputs and outputs for CLI commands implemented in Pkl.
///
/// Modules extending `pkl:Command` may configure [ModuleOutput.text], [ModuleOutput.bytes], and/or
/// [ModuleOutput.files] of [output] to influence the effect of the command.
///
/// Command modules should override [options] and provide their own class declaring options:
/// ```
/// extends "pkl:Command"
///
/// options: Options
///
/// class Options {
/// // ...
/// }
/// ```
@ModuleInfo { minPklVersion = "0.31.0" }
open module pkl.Command
import "pkl:Command"
import "pkl:reflect"
local commandClass = reflect.Class(getClass())
/// Command configuration.
hidden command: CommandInfo = new {
name = // choose the name of the module if the command is a module or the class otherwise
if (commandClass.name == "ModuleClass") reflect.Module(module).name else commandClass.name
description = commandClass.docComment // works for both classes and module classes
}
/// Command line options.
///
/// This property is set by the runtime during command execution.
/// It must not be amended or overridden by modules or classes extending [Command].
///
/// Command modules should override this property and provide their own options type.
/// The properties of the specified type declare the command line flags and arguments accepted by
/// the command.
///
/// Example:
/// ```
/// extends "pkl:Command"
///
/// options: Options
///
/// class Options {
/// /// Maximum number of tries to attempt operation before giving up.
/// `max-tries`: UInt = 3
///
/// /// Duration after which operation will be timed out.
/// @Flag { convert = module.convertDuration; metavar = "duration" }
/// timeout: Duration = 30.s
///
/// /// Whether to use cache data locally.
/// @BooleanFlag
/// cache: Boolean = true
///
/// /// Log verbosity.
/// @CountedFlag { shortName = "v" }
/// verbose: Int
///
/// /// File paths to operate on.
/// @Argument { completionCandidates = "paths" }
/// path: Listing<String>
/// }
/// ```
hidden options: Typed
/// The value of the parent command with parsed options.
///
/// This property is set by the runtime during command execution.
/// It must not be amended or overridden by modules or classes extending [Command].
///
/// The parent command is `null` for root commands.
hidden parent: Command?
/// The value of the root command with parsed options.
///
/// This property is set by the runtime during command execution.
///
/// The root command is `null` for root commands.
hidden fixed root: Command? = parent?.root ?? parent
/// Command configuration.
class CommandInfo {
/// The name of the subcommand.
///
/// Default value: the name of the module or class extending [Command].
name: String
/// A description of the command; shown in its CLI help.
///
/// Default value: the doc comment of the module or class extending [Command].
description: String?
/// Hide this command from CLI help.
hide: Boolean = false
/// If this command is executed, return an error and print CLI usage.
///
/// Only applicable to commands with [subcommands].
///
/// This is enabled by default when this command has [subcommands].
/// Overriding it to `false` will allow this command to be executed directly.
noOp: Boolean(implies(!subcommands.isEmpty)) = !subcommands.isEmpty
/// Child commands.
///
/// Must have unique [name] values.
// NB: not using isDistinctBy constraint because the command runtime can give better errors
subcommands: Listing<Command>
}
/// Annotates [options] properties to configure them as named CLI flags.
// NB: this should be a sealed class once Pkl supports them
abstract class BaseFlag extends Annotation {
/// Abbreviated flag name.
shortName: Char?
/// Hide this option from CLI help.
hide: Boolean = false
}
/// Annotates an [options] property to configure it as a named CLI flag that accepts a value.
class Flag extends BaseFlag {
/// Text to use in place of the option value in CLI help.
///
/// If not specified, the value is derived from the flag's type.
metavar: String?
/// Customize the behavior of parsing the raw option values.
///
/// When the return value is an [Import] value or a [Pair] member, [List] or [Set] element
/// containing an [Import], the URI or glob URI specified by the value is imported and the value
/// is replaced with the value of the imported module(s).
///
/// If no transform is provided, the raw flag value are parsed according to the option's type:
///
/// | Type | Behavior |
/// | -------------------------- | -------- |
/// | [String] | Value is used verbatim |
/// | [Char] | Value is used verbatim; must be exactly one character |
/// | [Boolean] | True values: `true`, `t`, `1`, `yes`, `y`, `on`; False values: `false`, `f`, `0`, `no`, `n`, `off` |
/// | [Number] | Value is parsed as an [Int] if possible, otherwise parsed as [Float] |
/// | [Float] | Value is parsed as a [Float] |
/// | [Int] | Value is parsed as an [Int] |
/// | [Int8], [Int16], [Int32], [UInt], [UInt8], [UInt16], [UInt32] | Value is parsed as an [Int] and must be within the type's range |
/// | Union of [String] literals | Value is used verbatim; must match a member of the union |
/// | [Listing], [List], [Set] | Element values are parsed based on the above primitive types |
/// | [Mapping], [Map], [Pair] | Value is split into a [Pair] on the first `"="` and each substring is parsed based on the above primitive types |
/// | Other types | An error is thrown; `convert` should be defined explicitly |
convert: ((String) -> Any)?
/// Specifies whether the flag may be specified more than once.
///
/// If not specified, this is determined based on the option's type.
/// Options with type [Listing], [List], [Set], [Mapping], or [Map] are mulitple by default.
/// Overriding this behavior generally requires setting [convert] and/or [transformAll].
multiple: Boolean?
/// Customize the behavior of turning all parsed flag values into the final option value.
///
/// If no value is provided, all flag values are transformed according to the option's type:
///
/// | Type | Behavior |
/// | ----------------- | -------- |
/// | [Mapping], [Map] | Each value must be a [Pair], each pair becomes an entry in the result. |
/// | [Listing], [List] | Result is all option values in the order specified |
/// | [Set] | Result is all unique option values |
/// | Other types | Result is the last value specified option value |
transformAll: ((List<Any>) -> Any)?
/// Specify how this flag should be completed in generated shell completions.
///
/// If set to `"paths"`, the completion candidates will be local file paths.
/// If set to a [Listing], the completion candidates will be the specified literal strings.
///
/// Options with a string literal union type use the members of the union as completion candidates
/// by default.
completionCandidates: "paths" | *Listing<String>(isDistinct)
}
/// Annotates [Boolean] [options] properties to configure them as named CLI flags that may be
/// specified zero or one times.
///
/// Boolean flags produce a pair of flags in the form `--<name>`/`--no-<name>`.
///
/// Annotating a property with a type other than [Boolean] is an error.
class BooleanFlag extends BaseFlag
/// Annotates [Integer] [options] properties to configure them as named CLI flags that may be
/// specified zero or more times.
///
/// Counted flags produce a value equal to the number of times a flag is specified.
///
/// Annotating a property with a type other than [Int] is an error.
class CountedFlag extends BaseFlag
/// Annotates an [options] property to configure it as a positional CLI argument.
class Argument extends Annotation {
/// Customize the behavior of turning the raw option values string into the final value.
///
/// When the return value is an [Import] value or a [Pair] member, [List] or [Set] element
/// containing an [Import], the URI or glob URI specified by the value is imported and the value
/// is replaced with the value of the imported module(s).
///
/// If no value is provided, each option value is transformed using the same rules as
/// [Flag.convert].
convert: ((String) -> Any)?
/// Specifies whether the argument may be specified more than once.
///
/// If not specified, this is determined based on the option's type.
/// Options with type [Listing], [List], [Set], [Mapping], or [Map] are multiple by default.
/// Overriding this behavior generally requires setting [convert] and/or [transformAll].
///
/// Only one argument per command may be multiple.
multiple: Boolean?
/// Customize the behavior of turning all parsed flag values into the final option value.
///
/// If no value is provided, all option values are transformed using the same rules as
/// [Flag.transformAll].
transformAll: ((List<Any>) -> Any)?
/// Specify how this flag should be completed in generated shell completions.
///
/// If set to `"paths"`, the completion candidates will be local file paths.
/// If set to a [Listing], the completion candidates will be the specified literal strings.
///
/// Options with a string literal union type use the members of the union as completion candidates
/// by default.
completionCandidates: "paths" | *Listing<String>(isDistinct)
}
/// A value used in [Flag.convert] and [Argument.convert] to declare an option as a dynamic
/// import.
class Import {
/// The absolute URI of the module to import.
uri: String
/// Whether [uri] should be interpreted as a glob pattern.
///
/// When `false`, the replacement value is the value of the specified module.
/// When `true`, the replacement value is a [Mapping] from [String] keys to matched module values.
glob: Boolean = false
}
local const quantityRegex = Regex(#"([0-9]+(?:\.[0-9]+)?)\.?([A-Za-z]+)"#)
local const function parseQuantity(value: String, typeName: String): Pair<Float, String> =
let (match = quantityRegex.matchEntire(value))
if (match == null)
throw("Unable to parse \(typeName) from string '\(value)'")
else
Pair(match.groups[1].value.toFloat(), match.groups[2].value.toLowerCase())
/// A convert function for [Duration] values.
///
/// For use with [Flag.convert] and [Argument.convert].
hidden const convertDuration: (String) -> Duration = (value: String) ->
let (quantity = parseQuantity(value, "Duration"))
let (_unit = quantity.second)
let (unit = if (_unit is DurationUnit) _unit else null)
quantity.first.toDuration(unit ?? throw("Unable to parse DurationUnit from '\(_unit)'"))
/// A convert function for [DataSize] values.
///
/// For use with [Flag.convert] and [Argument.convert].
hidden const convertDataSize: (String) -> DataSize = (value: String) ->
let (quantity = parseQuantity(value, "DataSize"))
let (_unit = quantity.second)
let (unit = if (_unit is DataSizeUnit) _unit else null)
quantity.first.toDataSize(unit ?? throw("Unable to parse DataSizeUnit from '\(_unit)'"))
+1 -1
View File
@@ -17,7 +17,7 @@
/// A template for writing tests.
///
/// To write tests, amend this module and define [facts] or [examples] (or both).
/// To run tests, evaluate the amended module.
/// To run tests, use the `pkl test` command to evaluate the amended module.
@ModuleInfo { minPklVersion = "0.31.0" }
open module pkl.test