diff --git a/docs/modules/ROOT/partials/component-attributes.adoc b/docs/modules/ROOT/partials/component-attributes.adoc index 2ff24650..a9c3250f 100644 --- a/docs/modules/ROOT/partials/component-attributes.adoc +++ b/docs/modules/ROOT/partials/component-attributes.adoc @@ -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 diff --git a/docs/modules/language-reference/pages/index.adoc b/docs/modules/language-reference/pages/index.adoc index 4032f6b5..208c6cbf 100644 --- a/docs/modules/language-reference/pages/index.adoc +++ b/docs/modules/language-reference/pages/index.adoc @@ -3961,6 +3961,7 @@ emailList: List // <2> <1> equivalent to `email: String(contains("@"))` for type checking purposes <2> equivalent to `emailList: List` for type checking purposes +[[nullable-types]] ==== Nullable Types Class types such as `Bird` (see above) do not admit `null` values. diff --git a/docs/modules/pkl-cli/pages/index.adoc b/docs/modules/pkl-cli/pages/index.adoc index 75783cb7..346b4917 100644 --- a/docs/modules/pkl-cli/pages/index.adoc +++ b/docs/modules/pkl-cli/pages/index.adoc @@ -489,7 +489,9 @@ If these are the only failures, the command exits with exit code 10. Otherwise, failures result in exit code 1. :: -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 <>. +[[command-run]] +=== `pkl run` + +*Synopsis:* `pkl run [] [] []` + +Evaluate a <> defined by ``. + +:: +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. + +:: +Additional CLI options and arguments defined by ``. + +This command also takes <>, but they must be specified before ``. + [[command-repl]] === `pkl repl` @@ -800,7 +819,7 @@ Write the path of files with formatting violations to stdout. [[common-options]] === Common options -The <>, <>, <>, <>, <>, <>, and <> commands support the following common options: +The <>, <>, <>, <>, <>, <>, <>, and <> 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 `--` that accept a value. +* `Boolean` properties annotated with link:{uri-stdlib-Command-BooleanFlag}[`@BooleanFlag`] become CLI flags named `--` and `--no-` 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 `--` 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 (`-`). +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`, `Listing`, `Set` +|Each occurrence of the option becomes an element of the final value. + +`Element` values are parsed based on the above primitive types. + +|`Map`, `Mapping` +|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` +|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` 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 +---- + +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 +---- +==== + +=== 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, <> 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` 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 <> 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 [] []`. + [[repl]] == Working with the REPL diff --git a/pkl-cli/src/main/kotlin/org/pkl/cli/CliCommandRunner.kt b/pkl-cli/src/main/kotlin/org/pkl/cli/CliCommandRunner.kt new file mode 100644 index 00000000..69e0d68a --- /dev/null +++ b/pkl-cli/src/main/kotlin/org/pkl/cli/CliCommandRunner.kt @@ -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, + 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) { + if (outputFiles.isEmpty()) return + + val writtenFiles = mutableMapOf() + 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() + } diff --git a/pkl-cli/src/main/kotlin/org/pkl/cli/CliEvaluator.kt b/pkl-cli/src/main/kotlin/org/pkl/cli/CliEvaluator.kt index 9cc526fa..730b108d 100644 --- a/pkl-cli/src/main/kotlin/org/pkl/cli/CliEvaluator.kt +++ b/pkl-cli/src/main/kotlin/org/pkl/cli/CliEvaluator.kt @@ -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. diff --git a/pkl-cli/src/main/kotlin/org/pkl/cli/OutputUtils.kt b/pkl-cli/src/main/kotlin/org/pkl/cli/OutputUtils.kt new file mode 100644 index 00000000..5410eb70 --- /dev/null +++ b/pkl-cli/src/main/kotlin/org/pkl/cli/OutputUtils.kt @@ -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") +} diff --git a/pkl-cli/src/main/kotlin/org/pkl/cli/commands/RootCommand.kt b/pkl-cli/src/main/kotlin/org/pkl/cli/commands/RootCommand.kt index 690ccf9e..df14594b 100644 --- a/pkl-cli/src/main/kotlin/org/pkl/cli/commands/RootCommand.kt +++ b/pkl-cli/src/main/kotlin/org/pkl/cli/commands/RootCommand.kt @@ -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", diff --git a/pkl-cli/src/main/kotlin/org/pkl/cli/commands/RunCommand.kt b/pkl-cli/src/main/kotlin/org/pkl/cli/commands/RunCommand.kt new file mode 100644 index 00000000..64c3fdaa --- /dev/null +++ b/pkl-cli/src/main/kotlin/org/pkl/cli/commands/RunCommand.kt @@ -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 by argument(name = "args").multiple() + + private val projectOptions by ProjectOptions() + + override fun run() { + CliCommandRunner(baseOptions.baseOptions(listOf(module), projectOptions), args).run() + } +} diff --git a/pkl-cli/src/test/kotlin/org/pkl/cli/CliCommandRunnerTest.kt b/pkl-cli/src/test/kotlin/org/pkl/cli/CliCommandRunnerTest.kt new file mode 100644 index 00000000..e258062f --- /dev/null +++ b/pkl-cli/src/test/kotlin/org/pkl/cli/CliCommandRunnerTest.kt @@ -0,0 +1,1155 @@ +/* + * 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.core.CliktError +import com.github.ajalt.clikt.core.MissingOption +import com.github.ajalt.clikt.core.PrintCompletionMessage +import com.github.tomakehurst.wiremock.junit5.WireMockTest +import java.io.ByteArrayOutputStream +import java.net.URI +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.createDirectories +import kotlin.io.path.createParentDirectories +import kotlin.io.path.deleteRecursively +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.pkl.commons.cli.CliBaseOptions +import org.pkl.commons.cli.CliException +import org.pkl.commons.test.FileTestUtils +import org.pkl.commons.test.PackageServer +import org.pkl.commons.writeString + +@OptIn(ExperimentalPathApi::class) +@WireMockTest(httpsEnabled = true, proxyMode = true) +class CliCommandRunnerTest { + private val renderOptions = + """ + extends "pkl:Command" + + options: Options + + output { + value = options + } + + """ + .trimIndent() + + companion object { + private val packageServer = PackageServer() + + @AfterAll + @JvmStatic + fun afterAll() { + packageServer.close() + } + } + + // use manually constructed temp dir instead of @TempDir to work around + // https://forums.developer.apple.com/thread/118358 + private val tempDir: Path = run { + val baseDir = FileTestUtils.rootProjectDir.resolve("pkl-cli/build/tmp/CliCommandRunnerTest") + baseDir.createDirectories() + Files.createTempDirectory(baseDir, null) + } + + @AfterEach + fun afterEach() { + tempDir.deleteRecursively() + } + + private fun writePklFile(fileName: String, contents: String): URI { + tempDir.resolve(fileName).createParentDirectories() + return tempDir.resolve(fileName).writeString(contents).toUri() + } + + private fun runToStdout(options: CliBaseOptions, args: List): String { + val outWriter = ByteArrayOutputStream() + CliCommandRunner(options, args, outWriter, ByteArrayOutputStream()).run() + return outWriter.toString(StandardCharsets.UTF_8) + } + + @Test + fun `missing required flag`() { + val moduleUri = + writePklFile( + "cmd.pkl", + renderOptions + + """ + class Options { + foo: String + } + """ + .trimIndent(), + ) + + val exc = + assertThrows { + runToStdout(CliBaseOptions(sourceModules = listOf(moduleUri), testMode = true), listOf()) + } + assertThat(exc.paramName).isEqualTo("--foo") + } + + @Test + fun `primitive flags`() { + val moduleUri = + writePklFile( + "cmd.pkl", + renderOptions + + """ + class Options { + `number-as-int`: Number + `number-as-float`: Number + `number-nullable`: Number? + `number-default`: Number = 100.0 + `number-default-overridden`: Number = 100.0 + + float: Float + `float-without-decimals`: Float + `float-nullable`: Float? + `float-default`: Float = 100.0 + `float-default-overridden`: Float = 100.0 + + int: Int + `int-nullable`: Int? + `int-default`: Int = 100 + `int-default-overridden`: Int = 100 + + int8: Int8 + int16: Int16 + int32: Int32 + uint: UInt + uint8: UInt8 + uint16: UInt16 + uint32: UInt32 + + boolean: Boolean + `boolean-nullable`: Boolean? + `boolean-default`: Boolean = true + `boolean-default-overridden`: Boolean = false + + string: String + `string-nullable`: String? + `string-default`: String = "default" + `string-default-overridden`: String = "default" + + char: Char + `char-nullable`: Char? + `char-default`: Char = "a" + `char-default-overridden`: Char = "b" + } + """ + .trimIndent(), + ) + val output = + runToStdout( + CliBaseOptions(sourceModules = listOf(moduleUri)), + listOf( + "--number-as-int=123", + "--number-as-float=123.0", + "--number-default-overridden=-200.0", + "--float=123.456", + "--float-without-decimals=789", + "--float-default-overridden=-200", + "--int=123", + "--int-default-overridden=-200", + "--int8=127", + "--int16=32767", + "--int32=2147483647", + "--uint=0", + "--uint8=255", + "--uint16=65535", + "--uint32=4294967295", + "--boolean=n", + "--boolean-default-overridden=1", + "--string=foobar", + "--string-default-overridden=non-default", + "--char=X", + "--char-default-overridden=c", + ), + ) + assertThat(output) + .isEqualTo( + """ + `number-as-int` = 123 + `number-as-float` = 123.0 + `number-nullable` = null + `number-default` = 100.0 + `number-default-overridden` = -200.0 + float = 123.456 + `float-without-decimals` = 789.0 + `float-nullable` = null + `float-default` = 100.0 + `float-default-overridden` = -200.0 + int = 123 + `int-nullable` = null + `int-default` = 100 + `int-default-overridden` = -200 + int8 = 127 + int16 = 32767 + int32 = 2147483647 + uint = 0 + uint8 = 255 + uint16 = 65535 + uint32 = 4294967295 + boolean = false + `boolean-nullable` = null + `boolean-default` = true + `boolean-default-overridden` = true + string = "foobar" + `string-nullable` = null + `string-default` = "default" + `string-default-overridden` = "non-default" + char = "X" + `char-nullable` = null + `char-default` = "a" + `char-default-overridden` = "c" + + """ + .trimIndent() + ) + } + + @Test + fun `primitive arguments`() { + val moduleUri = + writePklFile( + "cmd.pkl", + renderOptions + + """ + class Options { + @Argument + `number-as-int`: Number + @Argument + `number-as-float`: Number + + @Argument + float: Float + @Argument + `float-without-decimals`: Float + + @Argument + int: Int + + @Argument + int8: Int8 + @Argument + int16: Int16 + @Argument + int32: Int32 + @Argument + uint: UInt + @Argument + uint8: UInt8 + @Argument + uint16: UInt16 + @Argument + uint32: UInt32 + + @Argument + boolean: Boolean + + @Argument + string: String + + @Argument + char: Char + } + """ + .trimIndent(), + ) + val output = + runToStdout( + CliBaseOptions(sourceModules = listOf(moduleUri)), + listOf( + "123", + "123.0", + "123.456", + "789", + "123", + "127", + "32767", + "2147483647", + "0", + "255", + "65535", + "4294967295", + "n", + "foobar", + "X", + ), + ) + assertThat(output) + .isEqualTo( + """ + `number-as-int` = 123 + `number-as-float` = 123.0 + float = 123.456 + `float-without-decimals` = 789.0 + int = 123 + int8 = 127 + int16 = 32767 + int32 = 2147483647 + uint = 0 + uint8 = 255 + uint16 = 65535 + uint32 = 4294967295 + boolean = false + string = "foobar" + char = "X" + + """ + .trimIndent() + ) + } + + @Test + fun `enum flags`() { + val moduleUri = + writePklFile( + "cmd.pkl", + renderOptions + + """ + typealias MyEnum = "d" | "e" | *"f" + class Options { + enum: "a" | "b" | "c" + `enum-default`: "a" | *"b" | "c" + `enum-explicit-default`: "a" | "b" | "c" = "c" + `enum-alias-default`: MyEnum + `enum-alias-explicit-default`: MyEnum = "e" + `enum-alias-default-overridden`: MyEnum + + `enum-single`: "x" + `enum-single-nullable`: "x"? + `enum-single-explicit-default`: "x" = "x" + `enum-single-overridden`: "x" + } + """ + .trimIndent(), + ) + val output = + runToStdout( + CliBaseOptions(sourceModules = listOf(moduleUri)), + listOf("--enum=a", "--enum-alias-default-overridden=d", "--enum-single-overridden=x"), + ) + assertThat(output) + .isEqualTo( + """ + enum = "a" + `enum-default` = "b" + `enum-explicit-default` = "c" + `enum-alias-default` = "f" + `enum-alias-explicit-default` = "e" + `enum-alias-default-overridden` = "d" + `enum-single` = "x" + `enum-single-nullable` = null + `enum-single-explicit-default` = "x" + `enum-single-overridden` = "x" + + """ + .trimIndent() + ) + } + + @Test + fun `enum args`() { + val moduleUri = + writePklFile( + "cmd.pkl", + renderOptions + + """ + typealias MyEnum = "d" | "e" | *"f" + class Options { + @Argument + enum: "a" | "b" | "c" + @Argument + `enum-default`: "a" | *"b" | "c" + @Argument + `enum-alias-default`: MyEnum + } + """ + .trimIndent(), + ) + val output = + runToStdout(CliBaseOptions(sourceModules = listOf(moduleUri)), listOf("a", "c", "d")) + assertThat(output) + .isEqualTo( + """ + enum = "a" + `enum-default` = "c" + `enum-alias-default` = "d" + + """ + .trimIndent() + ) + } + + @Test + fun `collection flags`() { + val moduleUri = + writePklFile( + "cmd.pkl", + renderOptions + + """ + class Options { + list: List + `list-nullable`: List? + `list-default`: List = List(1, 2, 300.0) + set: Set + `set-nullable`: Set? + `set-default`: Set = Set(1, 2, 300.0, 2) + + `enum-list`: List<"a" | "b" | *"c"> + `enum-set`: Set<"a" | "b" | *"c"> + } + """ + .trimIndent(), + ) + val output = + runToStdout( + CliBaseOptions(sourceModules = listOf(moduleUri)), + listOf( + "--list=1", + "--list=0", + "--list=0.0", + "--list=1", + "--set=1", + "--set=0", + "--set=0.0", + "--set=1", + "--enum-list=a", + "--enum-list=a", + "--enum-list=b", + "--enum-set=a", + "--enum-set=a", + "--enum-set=b", + ), + ) + assertThat(output) + .isEqualTo( + """ + list = List(1, 0, 0.0, 1) + `list-nullable` = null + `list-default` = List(1, 2, 300.0) + set = Set(1, 0, 0.0) + `set-nullable` = null + `set-default` = Set(1, 2, 300.0) + `enum-list` = List("a", "a", "b") + `enum-set` = Set("a", "b") + + """ + .trimIndent() + ) + } + + @Test + fun `sequence args`() { + val moduleUri = + writePklFile( + "cmd.pkl", + renderOptions + + """ + class Options { + @Argument + list: List + } + """ + .trimIndent(), + ) + val output = + runToStdout(CliBaseOptions(sourceModules = listOf(moduleUri)), listOf("1", "0", "0.0", "1")) + assertThat(output) + .isEqualTo( + """ + list = List(1, 0, 0.0, 1) + + """ + .trimIndent() + ) + + val moduleUri2 = + writePklFile( + "cmd.pkl", + renderOptions + + """ + class Options { + @Argument + set: Set + } + """ + .trimIndent(), + ) + val output2 = + runToStdout(CliBaseOptions(sourceModules = listOf(moduleUri2)), listOf("1", "0", "0.0", "1")) + assertThat(output2) + .isEqualTo( + """ + set = Set(1, 0, 0.0) + + """ + .trimIndent() + ) + + val moduleUri3 = + writePklFile( + "cmd.pkl", + renderOptions + + """ + class Options { + @Argument + listing: Listing + } + """ + .trimIndent(), + ) + val output3 = + runToStdout(CliBaseOptions(sourceModules = listOf(moduleUri3)), listOf("1", "0", "0.0", "1")) + assertThat(output3) + .isEqualTo( + """ + listing { + 1 + 0 + 0.0 + 1 + } + + """ + .trimIndent() + ) + } + + @Test + fun `keyval args`() { + val moduleUri = + writePklFile( + "cmd.pkl", + renderOptions + + """ + class Options { + @Argument + map: Map + } + """ + .trimIndent(), + ) + val output = + runToStdout(CliBaseOptions(sourceModules = listOf(moduleUri)), listOf("1=0", "0.0=1")) + assertThat(output) + .isEqualTo( + """ + map = Map(1, 0, 0.0, 1) + + """ + .trimIndent() + ) + + val moduleUri2 = + writePklFile( + "cmd.pkl", + renderOptions + + """ + class Options { + @Argument + mapping: Mapping + } + """ + .trimIndent(), + ) + val output2 = + runToStdout(CliBaseOptions(sourceModules = listOf(moduleUri2)), listOf("1=0", "0.0=1")) + assertThat(output2) + .isEqualTo( + """ + mapping { + [1] = 0 + [0.0] = 1 + } + + """ + .trimIndent() + ) + + val moduleUri3 = + writePklFile( + "cmd.pkl", + renderOptions + + """ + class Options { + @Argument + pair: Pair + } + """ + .trimIndent(), + ) + val output3 = runToStdout(CliBaseOptions(sourceModules = listOf(moduleUri3)), listOf("1=0.0")) + assertThat(output3) + .isEqualTo( + """ + pair = Pair(1, 0.0) + + """ + .trimIndent() + ) + } + + @Test + fun `map flags`() { + val moduleUri = + writePklFile( + "cmd.pkl", + renderOptions + + """ + typealias MyEnum = "a" | "b" | *"c" + class Options { + map: Map + `map-nullable`: Map? + `map-default`: Map = Map("x", 123, "y", 456.789) + + `enum-map`: Map + } + """ + .trimIndent(), + ) + val output = + runToStdout( + CliBaseOptions(sourceModules = listOf(moduleUri)), + listOf("--map=a=0.0", "--map=b=1", "--enum-map=a=b", "--enum-map=b=c"), + ) + assertThat(output) + .isEqualTo( + """ + map = Map("a", 0.0, "b", 1) + `map-nullable` = null + `map-default` = Map("x", 123, "y", 456.789) + `enum-map` = Map("a", "b", "b", "c") + + """ + .trimIndent() + ) + } + + @Test + fun `mapping flags`() { + val moduleUri = + writePklFile( + "cmd.pkl", + renderOptions + + """ + typealias MyEnum = "a" | "b" | *"c" + class Options { + mapping: Mapping + `mapping-nullable`: Mapping? + `mapping-default`: Mapping = new { ["x"] = 123; ["y"] = 456.789 } + + `enum-mapping`: Mapping + } + """ + .trimIndent(), + ) + val output = + runToStdout( + CliBaseOptions(sourceModules = listOf(moduleUri)), + listOf("--mapping=a=0.0", "--mapping=b=1", "--enum-mapping=a=b", "--enum-mapping=b=c"), + ) + assertThat(output) + .isEqualTo( + """ + mapping { + ["a"] = 0.0 + ["b"] = 1 + } + `mapping-nullable` = null + `mapping-default` { + ["x"] = 123 + ["y"] = 456.789 + } + `enum-mapping` { + ["a"] = "b" + ["b"] = "c" + } + + """ + .trimIndent() + ) + } + + @Test + fun `pair flags`() { + val moduleUri = + writePklFile( + "cmd.pkl", + renderOptions + + """ + typealias MyEnum = "a" | "b" | *"c" + class Options { + pair: Pair + `pair-nullable`: Pair? + `pair-default`: Pair = Pair("x", 123) + + `enum-pair`: Pair + } + """ + .trimIndent(), + ) + val output = + runToStdout( + CliBaseOptions(sourceModules = listOf(moduleUri)), + listOf("--pair=a=0.0", "--enum-pair=a=b"), + ) + assertThat(output) + .isEqualTo( + """ + pair = Pair("a", 0.0) + `pair-nullable` = null + `pair-default` = Pair("x", 123) + `enum-pair` = Pair("a", "b") + + """ + .trimIndent() + ) + } + + @Test + fun `convert Duration`() { + val moduleUri = + writePklFile( + "cmd.pkl", + renderOptions + + """ + class Options { + @Argument { convert = module.convertDuration } + a: Duration + @Argument { convert = module.convertDuration } + b: Duration + @Argument { convert = module.convertDuration } + c: Duration + @Argument { convert = module.convertDuration } + d: Duration + } + """ + .trimIndent(), + ) + val output = + runToStdout( + CliBaseOptions(sourceModules = listOf(moduleUri)), + listOf("10.h", "10H", "10.5.MS", "10.5d"), + ) + assertThat(output) + .isEqualTo( + """ + a = 10.h + b = 10.h + c = 10.5.ms + d = 10.5.d + + """ + .trimIndent() + ) + } + + @Test + fun `convert DataSize`() { + val moduleUri = + writePklFile( + "cmd.pkl", + renderOptions + + """ + class Options { + @Argument { convert = module.convertDataSize } + a: DataSize + @Argument { convert = module.convertDataSize } + b: DataSize + @Argument { convert = module.convertDataSize } + c: DataSize + @Argument { convert = module.convertDataSize } + d: DataSize + } + """ + .trimIndent(), + ) + val output = + runToStdout( + CliBaseOptions(sourceModules = listOf(moduleUri)), + listOf("10.gb", "10GB", "10.5.MB", "10.5tib"), + ) + assertThat(output) + .isEqualTo( + """ + a = 10.gb + b = 10.gb + c = 10.5.mb + d = 10.5.tib + + """ + .trimIndent() + ) + } + + @Test + fun `convert import`() { + val moduleUri = + writePklFile( + "cmd.pkl", + renderOptions + + """ + class Options { + @Argument { convert = (it) -> new Import{ uri = it } } + fromImport: Module + } + """ + .trimIndent(), + ) + + val importUri = + writePklFile( + "import.pkl", + """ + foo = 1 + bar = "baz" + """ + .trimIndent(), + ) + + val output = + runToStdout(CliBaseOptions(sourceModules = listOf(moduleUri)), listOf(importUri.toString())) + assertThat(output) + .isEqualTo( + """ + fromImport { + foo = 1 + bar = "baz" + } + + """ + .trimIndent() + ) + } + + @Test + fun `convert glob import`() { + val moduleUri = + writePklFile( + "cmd.pkl", + """ + extends "pkl:Command" + import "base.pkl" + + options: Options + + output { + value = options + } + + class Options { + @Argument { convert = (it) -> new Import { uri = it; glob = true }; multiple = false } + fromGlobImport: Mapping + } + """ + .trimIndent(), + ) + + val baseImport = + writePklFile( + "base.pkl", + """ + foo: Int + bar: String + """ + .trimIndent(), + ) + writePklFile( + "glob1.pkl", + """ + amends "base.pkl" + foo = 1 + bar = "baz" + """ + .trimIndent(), + ) + writePklFile( + "glob2.pkl", + """ + amends "base.pkl" + foo = 2 + bar = "qux" + """ + .trimIndent(), + ) + + val importDirUri = baseImport.resolve(".") + + val output = + runToStdout( + CliBaseOptions(sourceModules = listOf(moduleUri)), + listOf(importDirUri.resolve("./glob*.pkl").toString()), + ) + assertThat(output.replace(importDirUri.toString(), "file://")) + .isEqualTo( + """ + fromGlobImport { + ["file://glob1.pkl"] { + foo = 1 + bar = "baz" + } + ["file://glob2.pkl"] { + foo = 2 + bar = "qux" + } + } + + """ + .trimIndent() + ) + } + + @Test + fun `convert that throws`() { + val moduleUri = + writePklFile( + "cmd.pkl", + renderOptions + + """ + class Options { + @Argument { convert = (it) -> throw("oops!") } + foo: String + } + """ + .trimIndent(), + ) + + val exc = + assertThrows { + runToStdout(CliBaseOptions(sourceModules = listOf(moduleUri)), listOf("hi")) + } + assertThat(exc.message).isEqualTo("oops!") + } + + @Test + fun `boolean flag`() { + val moduleUri = + writePklFile( + "cmd.pkl", + renderOptions + + """ + class Options { + @BooleanFlag + `bool-true`: Boolean + @BooleanFlag + `bool-false`: Boolean + @BooleanFlag + `bool-nullable`: Boolean? + @BooleanFlag + `bool-default-true`: Boolean = true + @BooleanFlag + `bool-default-false`: Boolean = false + } + """ + .trimIndent(), + ) + + val output = + runToStdout( + CliBaseOptions(sourceModules = listOf(moduleUri)), + listOf("--bool-true", "--no-bool-false"), + ) + assertThat(output) + .isEqualTo( + """ + `bool-true` = true + `bool-false` = false + `bool-nullable` = null + `bool-default-true` = true + `bool-default-false` = false + + """ + .trimIndent() + ) + } + + @Test + fun `boolean flag with bad type`() { + val moduleUri = + writePklFile( + "cmd.pkl", + renderOptions + + """ + class Options { + @BooleanFlag + foo: String + } + """ + .trimIndent(), + ) + + val exc = + assertThrows { + runToStdout(CliBaseOptions(sourceModules = listOf(moduleUri)), listOf("hi")) + } + assertThat(exc.message) + .contains("Option `foo` with annotation `@BooleanFlag` has invalid type `String`.") + } + + @Test + fun `counted flag`() { + val moduleUri = + writePklFile( + "cmd.pkl", + renderOptions + + """ + class Options { + @CountedFlag { shortName = "a" } + int: Int + @CountedFlag { shortName = "b" } + int8: Int8 + @CountedFlag { shortName = "c" } + int16: Int16 + @CountedFlag { shortName = "d" } + int32: Int32 + @CountedFlag { shortName = "e" } + uint: UInt + @CountedFlag { shortName = "f" } + uint8: UInt8 + @CountedFlag { shortName = "g" } + uint16: UInt16 + @CountedFlag { shortName = "i" } + uint32: UInt32 + } + """ + .trimIndent(), + ) + + val output = + runToStdout( + CliBaseOptions(sourceModules = listOf(moduleUri)), + listOf("-abbcccddddeeeeeffffffgggggggiiiiiiii"), + ) + assertThat(output) + .isEqualTo( + """ + int = 1 + int8 = 2 + int16 = 3 + int32 = 4 + uint = 5 + uint8 = 6 + uint16 = 7 + uint32 = 8 + + """ + .trimIndent() + ) + } + + @Test + fun `test transformAll`() { + val moduleUri = + writePklFile( + "cmd.pkl", + renderOptions + + """ + class Options { + @Flag { + multiple = true + transformAll = (values) -> values.fold(0, (res, acc) -> res + acc) + } + foo: Int + } + """ + .trimIndent(), + ) + + val output = + runToStdout( + CliBaseOptions(sourceModules = listOf(moduleUri)), + listOf("--foo=1", "--foo=5", "--foo=8"), + ) + assertThat(output) + .isEqualTo( + """ + foo = 14 + + """ + .trimIndent() + ) + } + + @Test + fun `completion candidates`() { + val moduleUri = + writePklFile( + "cmd.pkl", + renderOptions + + """ + class Options { + none: String? + enum: *"a" | "b" | "c" + @Flag { completionCandidates = "paths" } + path: String? + @Flag { completionCandidates { "foo"; "bar"; "baz" } } + explicit: String? + @Argument + enumArg: *"a" | "b" | "c" + @Argument { completionCandidates = "paths" } + pathArg: String + @Argument { completionCandidates { "foo"; "bar"; "baz" } } + explicitArg: String + } + """ + .trimIndent(), + ) + val exc = + assertThrows { + runToStdout( + CliBaseOptions(sourceModules = listOf(moduleUri)), + listOf("a", "foo", "bar", "shell-completion", "bash"), + ) + } + assertThat(exc.message) + .contains( + """ + "--none") + ;; + "--enum") + COMPREPLY=($(compgen -W 'a b c' -- "${'$'}{word}")) + ;; + "--path") + __complete_files "${'$'}{word}" + ;; + "--explicit") + COMPREPLY=($(compgen -W 'bar baz foo' -- "${'$'}{word}")) + ;; + "--help") + ;; + "enumArg") + COMPREPLY=($(compgen -W '' -- "${'$'}{word}")) + ;; + "pathArg") + __complete_files "${'$'}{word}" + ;; + "explicitArg") + COMPREPLY=($(compgen -W 'bar baz foo' -- "${'$'}{word}")) + ;;""" + ) + } +} diff --git a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliBaseOptions.kt b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliBaseOptions.kt index 6fccb3c4..fda71e53 100644 --- a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliBaseOptions.kt +++ b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliBaseOptions.kt @@ -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 = listOf(), + val sourceModules: List = listOf(), /** * The URI patterns that determine which modules can be loaded and evaluated. Patterns are matched diff --git a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliCommand.kt b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliCommand.kt index af4836c2..9d875454 100644 --- a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliCommand.kt +++ b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliCommand.kt @@ -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 diff --git a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/extensions.kt b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/extensions.kt index 45a9ae61..bfe480ce 100644 --- a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/extensions.kt +++ b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/extensions.kt @@ -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.installCommonOptions() { +fun > 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) } } diff --git a/pkl-core/src/main/java/org/pkl/core/CommandSpec.java b/pkl-core/src/main/java/org/pkl/core/CommandSpec.java new file mode 100644 index 00000000..604343e1 --- /dev/null +++ b/pkl-core/src/main/java/org/pkl/core/CommandSpec.java @@ -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