From 035ef0a789a5e4fd45318645d43967cf952da50b Mon Sep 17 00:00:00 2001 From: Daniel Chao Date: Tue, 2 Jun 2026 11:02:52 -0700 Subject: [PATCH] Change loading of external readers (#1394) This introduces breaking changes for external readers are loaded: 1. In PklProject, relative paths are resolved relative to the enclosing PklProject file (make behavior consistent with how other settings work) 2. Make CLI flags blow away any settings set on a PklProject 3. Introduce a new `workingDir` property, which defaults to the PklProject dir The overall goal is to make this behavior consistent with how other settings work. For example, relative paths for other evaluator settings are already relative to the project directory. Additionally, in every other case, CLI flags will overwrite any setting set within PklProject. --- .idea/inspectionProfiles/Project_Default.xml | 8 +- .../ROOT/partials/component-attributes.adoc | 2 + docs/modules/release-notes/pages/0.32.adoc | 51 +++- .../org/pkl/commons/cli/CliBaseOptions.kt | 4 +- .../kotlin/org/pkl/commons/cli/CliCommand.kt | 6 +- .../pkl/commons/cli/commands/BaseOptions.kt | 9 +- .../org/pkl/commons/cli/BaseCommandTest.kt | 14 +- .../org/pkl/commons/cli/CliCommandTest.kt | 30 ++- pkl-core/pkl-core.gradle.kts | 54 +++- .../org/pkl/core/externalreader/util.kt | 24 ++ .../pkl/core/externalreaderfixture/Main.kt | 60 +++++ .../java/org/pkl/core/EvaluatorBuilder.java | 2 +- .../PklEvaluatorSettings.java | 23 +- .../ExternalReaderProcessImpl.java | 5 + .../java/org/pkl/core/project/Project.java | 55 ++-- .../pkl/core/runtime/CommandSpecParser.java | 2 +- .../EvaluatorSettingsNodes.java | 54 ++++ .../evaluatorsettings/package-info.java | 6 + .../main/java/org/pkl/core/util/IoUtils.java | 46 +++- .../java/org/pkl/core/util/PathResolver.java | 198 ++++++++++++++ .../java/org/pkl/core/util/PathResolvers.java | 35 +++ .../org/pkl/core/errorMessages.properties | 3 + .../api/evaluatorSettingsModulePosix.pkl | 130 ++++++++++ .../api/evaluatorSettingsModuleWindows.pkl | 243 ++++++++++++++++++ .../input/api/project1.pkl | 32 +++ .../input/projects/badPklProject4/PklProject | 7 + .../input/projects/badPklProject4/bug.pkl | 1 + .../input/projects/badPklProject5/PklProject | 11 + .../input/projects/badPklProject5/bug.pkl | 0 .../projects/evaluatorSettings2/PklProject | 5 + .../projects/evaluatorSettings2/badRead.pkl | 1 + .../projects/evaluatorSettings2/goodRead.pkl | 1 + .../api/evaluatorSettingsModulePosix.pcf | 66 +++++ .../api/evaluatorSettingsModuleWindows.pcf | 141 ++++++++++ .../output/api/project1.pcf | 25 ++ .../output/projects/badPklProject4/bug.err | 6 + .../output/projects/badPklProject5/bug.err | 16 ++ .../projects/evaluatorSettings2/badRead.err | 14 + .../projects/evaluatorSettings2/goodRead.pcf | 4 + .../pkl/core/module/ModuleKeyFactoriesTest.kt | 69 ++++- .../org/pkl/core/project/ProjectTest.kt | 103 +++++++- .../org/pkl/core/util/PathResolverTest.kt | 240 +++++++++++++++++ .../main/java/org/pkl/gradle/PklPlugin.java | 8 + .../pkl/gradle/spec/ExternalReaderSpec.java | 8 +- .../src/main/kotlin/org/pkl/server/Server.kt | 4 +- .../pkl/server/ServerMessagePackDecoder.kt | 6 +- .../pkl/server/ServerMessagePackEncoder.kt | 3 +- .../kotlin/org/pkl/server/ServerMessages.kt | 6 +- .../pkl/server/ServerMessagePackCodecTest.kt | 2 +- stdlib/EvaluatorSettings.pkl | 107 +++++++- stdlib/Project.pkl | 22 +- 51 files changed, 1880 insertions(+), 92 deletions(-) create mode 100644 pkl-core/src/externalReaderFixture/kotlin/org/pkl/core/externalreader/util.kt create mode 100644 pkl-core/src/externalReaderFixture/kotlin/org/pkl/core/externalreaderfixture/Main.kt create mode 100644 pkl-core/src/main/java/org/pkl/core/stdlib/evaluatorsettings/EvaluatorSettingsNodes.java create mode 100644 pkl-core/src/main/java/org/pkl/core/stdlib/evaluatorsettings/package-info.java create mode 100644 pkl-core/src/main/java/org/pkl/core/util/PathResolver.java create mode 100644 pkl-core/src/main/java/org/pkl/core/util/PathResolvers.java create mode 100644 pkl-core/src/test/files/LanguageSnippetTests/input/api/evaluatorSettingsModulePosix.pkl create mode 100644 pkl-core/src/test/files/LanguageSnippetTests/input/api/evaluatorSettingsModuleWindows.pkl create mode 100644 pkl-core/src/test/files/LanguageSnippetTests/input/api/project1.pkl create mode 100644 pkl-core/src/test/files/LanguageSnippetTests/input/projects/badPklProject4/PklProject create mode 100644 pkl-core/src/test/files/LanguageSnippetTests/input/projects/badPklProject4/bug.pkl create mode 100644 pkl-core/src/test/files/LanguageSnippetTests/input/projects/badPklProject5/PklProject create mode 100644 pkl-core/src/test/files/LanguageSnippetTests/input/projects/badPklProject5/bug.pkl create mode 100644 pkl-core/src/test/files/LanguageSnippetTests/input/projects/evaluatorSettings2/PklProject create mode 100644 pkl-core/src/test/files/LanguageSnippetTests/input/projects/evaluatorSettings2/badRead.pkl create mode 100644 pkl-core/src/test/files/LanguageSnippetTests/input/projects/evaluatorSettings2/goodRead.pkl create mode 100644 pkl-core/src/test/files/LanguageSnippetTests/output/api/evaluatorSettingsModulePosix.pcf create mode 100644 pkl-core/src/test/files/LanguageSnippetTests/output/api/evaluatorSettingsModuleWindows.pcf create mode 100644 pkl-core/src/test/files/LanguageSnippetTests/output/api/project1.pcf create mode 100644 pkl-core/src/test/files/LanguageSnippetTests/output/projects/badPklProject4/bug.err create mode 100644 pkl-core/src/test/files/LanguageSnippetTests/output/projects/badPklProject5/bug.err create mode 100644 pkl-core/src/test/files/LanguageSnippetTests/output/projects/evaluatorSettings2/badRead.err create mode 100644 pkl-core/src/test/files/LanguageSnippetTests/output/projects/evaluatorSettings2/goodRead.pcf create mode 100644 pkl-core/src/test/kotlin/org/pkl/core/util/PathResolverTest.kt diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 8cc562db..45a03ea6 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -27,6 +27,12 @@ @@ -98,4 +104,4 @@ - \ No newline at end of file + diff --git a/docs/modules/ROOT/partials/component-attributes.adoc b/docs/modules/ROOT/partials/component-attributes.adoc index 7ad1a656..c7caac5c 100644 --- a/docs/modules/ROOT/partials/component-attributes.adoc +++ b/docs/modules/ROOT/partials/component-attributes.adoc @@ -78,8 +78,10 @@ endif::[] :uri-stdlib-pklbinaryModule: {uri-pkl-stdlib-docs}/pklbinary :uri-stdlib-yamlModule: {uri-pkl-stdlib-docs}/yaml :uri-stdlib-YamlParser: {uri-stdlib-yamlModule}/Parser +:uri-stdlib-projectModule: {uri-pkl-stdlib-docs}/Project :uri-stdlib-evaluatorSettingsModule: {uri-pkl-stdlib-docs}/EvaluatorSettings :uri-stdlib-evaluatorSettingsHttpClass: {uri-stdlib-evaluatorSettingsModule}/Http +:uri-stdlib-evaluatorSettingsExternalReaderClass: {uri-stdlib-evaluatorSettingsModule}/ExternalReader :uri-stdlib-Boolean: {uri-stdlib-baseModule}/Boolean :uri-stdlib-xor: {uri-stdlib-baseModule}/Boolean#xor() :uri-stdlib-implies: {uri-stdlib-baseModule}/Boolean#implies() diff --git a/docs/modules/release-notes/pages/0.32.adoc b/docs/modules/release-notes/pages/0.32.adoc index 53595679..f2687322 100644 --- a/docs/modules/release-notes/pages/0.32.adoc +++ b/docs/modules/release-notes/pages/0.32.adoc @@ -21,17 +21,31 @@ XXX Ready when you need them. -.XXX -[%collapsible] -==== -XXX -==== +=== Standard Library Changes + +==== `pkl:EvaluatorSettings` + +**Additions** + +* New method: link:{uri-stdlib-evaluatorSettingsModule}#resolve()[`EvaluatorSettings.resolve()`] +* New method: link:{uri-stdlib-evaluatorSettingsModule}#resolveForOs()[`EvaluatorSettings.resolveForOs()`] +* New property: link:{uri-stdlib-evaluatorSettingsExternalReaderClass}#workingDir[`EvaluatorSettings#ExternalReader.workingDir`] + +==== `pkl:Project` + +**Additions** + +* New property: link:{uri-stdlib-projectModule}#resolvedEvaluatorSettings[`Project.resolvedEvaluatorSettings`] == Breaking Changes [small]#💔# Things to watch out for when upgrading. -=== Removed Java APIs +=== Java API changes + +Changes have been made to the Java API. + +==== Removals and deprecations The following APIs have been removed without replacement. @@ -41,11 +55,26 @@ The following APIs have been deprecated for removal. * `org.pkl.config.java.mapper.NonNull` (https://github.com/apple/pkl/pull/1607[#1607]). -.XXX -[%collapsible] -==== -XXX -==== +==== Changes + +* `org.pkl.core.evaluatorSettings.PklEvaluatorSettings.parse` no longer accepts a `pathNormalizer` argument. + +=== Loading rule changes in `pkl:EvaluatorSettings` + +Breaking changes have been made to how evaluator settings are loaded (https://github.com/apple/pkl/pull/1394[#1394]). + +==== Loading rule changes for the external reader executable + +The following changes have been made for the `executable` property in an external reader: + +* If the executable does not contain a slash (`/` on POSIX, `\` on Windows) character, it is always resolved against the `PATH` environment variable. +* If it does contain a slash, it is resolved relative to the enclosing PklProject directory, instead of the current working directory. + +=== Changes to `--external-module-reader` and `--external-resource-reader` CLI flags + +The `--external-module-reader` and `--external-resource-reader` CLI flags will _replace_ any external readers otherwise configured within a PklProject, instead of add to it (https://github.com/apple/pkl/pull/1394[#1394]). + +This makes this behavior consistent with how other settings work. == Work In Progress [small]#🚆# 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 af58ad48..0a7274fe 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 @@ -148,10 +148,10 @@ data class CliBaseOptions( val httpHeaders: Map>>? = null, /** External module reader process specs */ - val externalModuleReaders: Map = mapOf(), + val externalModuleReaders: Map? = null, /** External resource reader process specs */ - val externalResourceReaders: Map = mapOf(), + val externalResourceReaders: Map? = null, /** Defines options for the formatting of calls to the trace() method. */ val traceMode: TraceMode? = null, 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 c944a0ee..c34611d9 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 @@ -110,7 +110,7 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) { private val evaluatorSettings: PklEvaluatorSettings? by lazy { @Suppress("PklCliDirectProjectEvaluatorSettingsAccess") - if (cliOptions.omitProjectSettings) null else project?.evaluatorSettings + if (cliOptions.omitProjectSettings) null else project?.resolvedEvaluatorSettings } protected val allowedModules: List by lazy { @@ -193,11 +193,11 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) { } protected val externalModuleReaders: Map by lazy { - (evaluatorSettings?.externalModuleReaders ?: emptyMap()) + cliOptions.externalModuleReaders + cliOptions.externalModuleReaders ?: evaluatorSettings?.externalModuleReaders ?: mapOf() } protected val externalResourceReaders: Map by lazy { - (evaluatorSettings?.externalResourceReaders ?: emptyMap()) + cliOptions.externalResourceReaders + cliOptions.externalResourceReaders ?: evaluatorSettings?.externalResourceReaders ?: mapOf() } private val externalProcesses: diff --git a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt index 62ba7653..359ae10c 100644 --- a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt +++ b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/commands/BaseOptions.kt @@ -55,7 +55,8 @@ class BaseOptions : OptionGroup() { if (IoUtils.isUriLike(moduleName)) URI(moduleName) // Can't just use URI constructor, because URI(null, null, "C:/foo/bar", null) turns // into `URI("C", null, "/foo/bar", null)`. - else if (IoUtils.isWindowsAbsolutePath(moduleName)) Path.of(moduleName).toUri() + else if (IoUtils.isWindows() && IoUtils.isWindowsAbsolutePath(moduleName)) + Path.of(moduleName).toUri() else URI(null, null, IoUtils.toNormalizedPathString(Path.of(moduleName)), null) } catch (e: URISyntaxException) { val message = buildString { @@ -91,7 +92,7 @@ class BaseOptions : OptionGroup() { > { return splitPair(delimiter).convert { val cmd = shlex(it.second) - Pair(it.first, ExternalReader(cmd.first(), cmd.drop(1))) + Pair(it.first, ExternalReader(cmd.first(), cmd.drop(1), null)) } } @@ -386,8 +387,8 @@ class BaseOptions : OptionGroup() { httpNoProxy = noProxy, httpRewrites = httpRewrites.ifEmpty { null }, httpHeaders = httpHeaders.ifEmpty { null }, - externalModuleReaders = externalModuleReaders, - externalResourceReaders = externalResourceReaders, + externalModuleReaders = externalModuleReaders.ifEmpty { null }, + externalResourceReaders = externalResourceReaders.ifEmpty { null }, traceMode = traceMode, powerAssertionsEnabled = powerAssertionsEnabled, ) diff --git a/pkl-commons-cli/src/test/kotlin/org/pkl/commons/cli/BaseCommandTest.kt b/pkl-commons-cli/src/test/kotlin/org/pkl/commons/cli/BaseCommandTest.kt index ac7ed75a..15b3dd10 100644 --- a/pkl-commons-cli/src/test/kotlin/org/pkl/commons/cli/BaseCommandTest.kt +++ b/pkl-commons-cli/src/test/kotlin/org/pkl/commons/cli/BaseCommandTest.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. @@ -98,17 +98,17 @@ class BaseCommandTest { assertThat(cmd.baseOptions.externalModuleReaders) .isEqualTo( mapOf( - "scheme3" to ExternalReader("reader3", emptyList()), - "scheme4" to ExternalReader("reader4", listOf("with", "args")), - "scheme+ext" to ExternalReader("reader5", listOf("with", "args")), + "scheme3" to ExternalReader("reader3", emptyList(), null), + "scheme4" to ExternalReader("reader4", listOf("with", "args"), null), + "scheme+ext" to ExternalReader("reader5", listOf("with", "args"), null), ) ) assertThat(cmd.baseOptions.externalResourceReaders) .isEqualTo( mapOf( - "scheme1" to ExternalReader("reader1", emptyList()), - "scheme2" to ExternalReader("reader2", listOf("with", "args")), - "scheme+ext" to ExternalReader("reader5", listOf("with", "args")), + "scheme1" to ExternalReader("reader1", emptyList(), null), + "scheme2" to ExternalReader("reader2", listOf("with", "args"), null), + "scheme+ext" to ExternalReader("reader5", listOf("with", "args"), null), ) ) } diff --git a/pkl-commons-cli/src/test/kotlin/org/pkl/commons/cli/CliCommandTest.kt b/pkl-commons-cli/src/test/kotlin/org/pkl/commons/cli/CliCommandTest.kt index 07a5f981..d6c1ab75 100644 --- a/pkl-commons-cli/src/test/kotlin/org/pkl/commons/cli/CliCommandTest.kt +++ b/pkl-commons-cli/src/test/kotlin/org/pkl/commons/cli/CliCommandTest.kt @@ -28,12 +28,12 @@ import org.pkl.commons.cli.commands.BaseCommand import org.pkl.commons.cli.commands.ProjectOptions import org.pkl.commons.writeString import org.pkl.core.SecurityManagers +import org.pkl.core.evaluatorSettings.PklEvaluatorSettings import org.pkl.core.evaluatorSettings.TraceMode import org.pkl.core.util.IoUtils @OptIn(ExperimentalPathApi::class) class CliCommandTest { - private val cmd = object : BaseCommand("test", "") { val projectOptions: ProjectOptions by ProjectOptions() @@ -172,4 +172,32 @@ class CliCommandTest { .isNotNull } } + + @Test + fun `--external-module-reader blows away PklProject externalModuleReaders`( + @TempDir tempDir: Path + ) { + tempDir + .resolve("PklProject") + .writeString( + // language=pkl + """ + amends "pkl:Project" + + evaluatorSettings { + externalModuleReaders { + ["foo"] { + executable = "foo" + } + } + } + """ + .trimIndent() + ) + cmd.parse(arrayOf("--external-module-reader", "bar=bar")) + val opts = cmd.baseOptions.baseOptions(emptyList(), null, true) + val cliTest = CliTest(opts) + assertThat(cliTest.myExternalModuleReaders) + .isEqualTo(mapOf("bar" to PklEvaluatorSettings.ExternalReader("bar", listOf(), null))) + } } diff --git a/pkl-core/pkl-core.gradle.kts b/pkl-core/pkl-core.gradle.kts index 243e94f4..d715386d 100644 --- a/pkl-core/pkl-core.gradle.kts +++ b/pkl-core/pkl-core.gradle.kts @@ -27,7 +27,17 @@ plugins { idea } -val generatorSourceSet = sourceSets.register("generator") +val generatorSourceSet: NamedDomainObjectProvider = sourceSets.register("generator") + +val externalReaderFixtureSourceSet: NamedDomainObjectProvider = + sourceSets.register("externalReaderFixture") { + compileClasspath += sourceSets.test.get().output + sourceSets.test.get().compileClasspath + runtimeClasspath += sourceSets.test.get().output + sourceSets.test.get().runtimeClasspath + } + +val externalReaderFixtureImplementation: Configuration by configurations.getting { + extendsFrom(configurations.testImplementation.get()) +} idea { module { @@ -126,8 +136,45 @@ tasks.withType().configureEach { tasks.compileKotlin { enabled = false } +val externalReaderFixture by tasks.registering { + group = "build" + dependsOn(tasks.named("compileExternalReaderFixtureJava")) + inputs.files(externalReaderFixtureSourceSet.map { it.output }) + val fileName = if (buildInfo.os.isWindows) "externalreader.bat" else "externalreader" + val outputFile = layout.buildDirectory.file("fixtures/$fileName") + outputs.file(outputFile) + doLast { + val classpath = externalReaderFixtureSourceSet.get().runtimeClasspath.asPath + val scriptContent = + if (buildInfo.os.isWindows) { + """ + @echo off + java -cp $classpath org.pkl.core.externalreaderfixture.Main + """ + .trimIndent() + } else { + """ + #!/usr/bin/env bash + + java -cp $classpath org.pkl.core.externalreaderfixture.Main + """ + .trimIndent() + } + + outputFile.get().asFile.writeText(scriptContent) + outputFile.get().asFile.setExecutable(true) + println("Created external reader ${outputFile.get().asFile.absolutePath}") + } +} + tasks.test { configureTest() + dependsOn(externalReaderFixture) + environment( + "PATH", + listOf(System.getenv("PATH"), layout.buildDirectory.dir("fixtures/").get()) + .joinToString(File.pathSeparator), + ) useJUnitPlatform { excludeEngines("MacAmd64LanguageSnippetTestsEngine") excludeEngines("MacAarch64LanguageSnippetTestsEngine") @@ -139,6 +186,11 @@ tasks.test { // testing very large lists requires more memory than the default 512m! maxHeapSize = "1g" + + systemProperty( + "org.pkl.core.testExternalReaderPath", + externalReaderFixture.map { it.outputs.files.singleFile.absolutePath }, + ) } val generateBaseModuleMembers by diff --git a/pkl-core/src/externalReaderFixture/kotlin/org/pkl/core/externalreader/util.kt b/pkl-core/src/externalReaderFixture/kotlin/org/pkl/core/externalreader/util.kt new file mode 100644 index 00000000..c321eed9 --- /dev/null +++ b/pkl-core/src/externalReaderFixture/kotlin/org/pkl/core/externalreader/util.kt @@ -0,0 +1,24 @@ +/* + * 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. + */ +package org.pkl.core.externalreader + +import org.pkl.core.messaging.MessageTransports + +private val systemInDecoder = ExternalReaderMessagePackDecoder(System.`in`) + +private val systemOutEncoder = ExternalReaderMessagePackEncoder(System.out) + +val stdioTransport = MessageTransports.stream(systemInDecoder, systemOutEncoder) {} diff --git a/pkl-core/src/externalReaderFixture/kotlin/org/pkl/core/externalreaderfixture/Main.kt b/pkl-core/src/externalReaderFixture/kotlin/org/pkl/core/externalreaderfixture/Main.kt new file mode 100644 index 00000000..478466a7 --- /dev/null +++ b/pkl-core/src/externalReaderFixture/kotlin/org/pkl/core/externalreaderfixture/Main.kt @@ -0,0 +1,60 @@ +/* + * 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. + */ +@file:JvmName("Main") + +package org.pkl.core.externalreaderfixture + +import java.net.URI +import org.pkl.core.externalreader.ExternalModuleReader +import org.pkl.core.externalreader.ExternalReaderClient +import org.pkl.core.externalreader.ExternalResourceReader +import org.pkl.core.externalreader.stdioTransport +import org.pkl.core.module.PathElement + +object ModuleReader : ExternalModuleReader { + override val isLocal: Boolean = true + + override fun read(uri: URI): String = "hello" + + override val scheme: String = "foo" + + override val hasHierarchicalUris: Boolean = false + + override val isGlobbable: Boolean = false + + override fun listElements(uri: URI): List { + throw NotImplementedError() + } +} + +object ResourceReader : ExternalResourceReader { + override fun read(uri: URI): ByteArray = "hello".toByteArray() + + override val scheme: String = "foo" + + override val hasHierarchicalUris: Boolean = false + + override val isGlobbable: Boolean = false + + override fun listElements(uri: URI): List { + throw NotImplementedError() + } +} + +fun main() { + val client = ExternalReaderClient(listOf(ModuleReader), listOf(ResourceReader), stdioTransport) + client.run() +} diff --git a/pkl-core/src/main/java/org/pkl/core/EvaluatorBuilder.java b/pkl-core/src/main/java/org/pkl/core/EvaluatorBuilder.java index 8e1d9c4f..3f163616 100644 --- a/pkl-core/src/main/java/org/pkl/core/EvaluatorBuilder.java +++ b/pkl-core/src/main/java/org/pkl/core/EvaluatorBuilder.java @@ -488,7 +488,7 @@ public final class EvaluatorBuilder { */ public EvaluatorBuilder applyFromProject(Project project) { this.dependencies = project.getDependencies(); - var settings = project.getEvaluatorSettings(); + var settings = project.getResolvedEvaluatorSettings(); if (securityManager != null) { throw new IllegalStateException( "Cannot call both `setSecurityManager` and `setProject`, because both define security manager settings. Call `setProjectOnly` if the security manager is desired."); diff --git a/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java b/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java index 255f9284..4fdfdecb 100644 --- a/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java +++ b/pkl-core/src/main/java/org/pkl/core/evaluatorSettings/PklEvaluatorSettings.java @@ -26,7 +26,6 @@ import java.util.Locale; import java.util.Map; import java.util.Map.Entry; import java.util.Objects; -import java.util.function.BiFunction; import java.util.regex.Pattern; import java.util.stream.Collectors; import org.jspecify.annotations.Nullable; @@ -57,17 +56,13 @@ public record PklEvaluatorSettings( /** Initializes a {@link PklEvaluatorSettings} from a raw object representation. */ @SuppressWarnings("unchecked") - public static PklEvaluatorSettings parse( - Value input, BiFunction pathNormalizer) { + public static PklEvaluatorSettings parse(Value input) { if (!(input instanceof PObject pSettings)) { throw PklBugException.unreachableCode(); } var moduleCacheDirStr = (String) pSettings.get("moduleCacheDir"); - var moduleCacheDir = - moduleCacheDirStr == null - ? null - : pathNormalizer.apply(moduleCacheDirStr, "moduleCacheDir"); + var moduleCacheDir = moduleCacheDirStr == null ? null : Path.of(moduleCacheDirStr); var allowedModulesStrs = (List) pSettings.get("allowedModules"); var allowedModules = @@ -82,13 +77,10 @@ public record PklEvaluatorSettings( : allowedResourcesStrs.stream().map(Pattern::compile).toList(); var modulePathStrs = (List) pSettings.get("modulePath"); - var modulePath = - modulePathStrs == null - ? null - : modulePathStrs.stream().map(it -> pathNormalizer.apply(it, "modulePath")).toList(); + var modulePath = modulePathStrs == null ? null : modulePathStrs.stream().map(Path::of).toList(); var rootDirStr = (String) pSettings.get("rootDir"); - var rootDir = rootDirStr == null ? null : pathNormalizer.apply(rootDirStr, "rootDir"); + var rootDir = rootDirStr == null ? null : Path.of(rootDirStr); var externalModuleReadersRaw = (Map) pSettings.get("externalModuleReaders"); var externalModuleReaders = @@ -219,13 +211,16 @@ public record PklEvaluatorSettings( } } - public record ExternalReader(String executable, @Nullable List arguments) { + public record ExternalReader( + String executable, @Nullable List arguments, @Nullable String workingDir) { @SuppressWarnings("unchecked") public static ExternalReader parse(Value input) { if (input instanceof PObject externalReader) { var executable = (String) externalReader.getProperty("executable"); var arguments = (List) externalReader.get("arguments"); - return new ExternalReader(executable, arguments); + var workingDir = externalReader.getProperty("workingDir"); + return new ExternalReader( + executable, arguments, workingDir instanceof PNull ? null : (String) workingDir); } throw PklBugException.unreachableCode(); } diff --git a/pkl-core/src/main/java/org/pkl/core/externalreader/ExternalReaderProcessImpl.java b/pkl-core/src/main/java/org/pkl/core/externalreader/ExternalReaderProcessImpl.java index fc5fa8fc..1c81b5ae 100644 --- a/pkl-core/src/main/java/org/pkl/core/externalreader/ExternalReaderProcessImpl.java +++ b/pkl-core/src/main/java/org/pkl/core/externalreader/ExternalReaderProcessImpl.java @@ -16,6 +16,7 @@ package org.pkl.core.externalreader; import com.google.errorprone.annotations.concurrent.GuardedBy; +import java.io.File; import java.io.IOException; import java.lang.ProcessBuilder.Redirect; import java.time.Duration; @@ -105,6 +106,10 @@ final class ExternalReaderProcessImpl implements ExternalReaderProcess { } var builder = new ProcessBuilder(command); + var workingDir = spec.workingDir(); + if (workingDir != null) { + builder.directory(new File(workingDir)); + } builder.redirectError(Redirect.INHERIT); // inherit stderr from this pkl process try { process = builder.start(); diff --git a/pkl-core/src/main/java/org/pkl/core/project/Project.java b/pkl-core/src/main/java/org/pkl/core/project/Project.java index a004d0b8..99214346 100644 --- a/pkl-core/src/main/java/org/pkl/core/project/Project.java +++ b/pkl-core/src/main/java/org/pkl/core/project/Project.java @@ -62,6 +62,7 @@ public final class Project { private final @Nullable Package pkg; private final DeclaredDependencies dependencies; private final PklEvaluatorSettings evaluatorSettings; + private final PklEvaluatorSettings resolvedEvaluatorSettings; private final URI projectFileUri; private final URI projectBaseUri; private final List tests; @@ -290,7 +291,7 @@ public final class Project { public static Project parseProject(PObject module) throws URISyntaxException { var pkgObj = getNullableProperty(module, "package"); - var projectFileUri = URI.create((String) module.getProperty("projectFileUri")); + var projectFileUri = new URI((String) module.getProperty("projectFileUri")); var dependencies = parseDependencies(module, projectFileUri, null); var projectBaseUri = IoUtils.resolve(projectFileUri, "."); Package pkg = null; @@ -301,9 +302,14 @@ public final class Project { getProperty( module, "evaluatorSettings", - (settings) -> - PklEvaluatorSettings.parse( - (Value) settings, (it, name) -> resolveNullablePath(it, projectBaseUri, name))); + (settings) -> PklEvaluatorSettings.parse((Value) settings)); + + var resolvedEvaluatorSettings = + getProperty( + module, + "resolvedEvaluatorSettings", + (settings) -> PklEvaluatorSettings.parse((Value) settings)); + @SuppressWarnings("unchecked") var testPathStrs = (List) getProperty(module, "tests"); var tests = @@ -316,6 +322,7 @@ public final class Project { pkg, dependencies, evaluatorSettings, + resolvedEvaluatorSettings, projectFileUri, projectBaseUri, tests, @@ -371,24 +378,6 @@ public final class Project { return new URI((String) value); } - /** - * Resolve a path string against projectBaseUri. - * - * @throws PackageLoadError if projectBaseUri is not a {@code file:} URI. - */ - private static @Nullable Path resolveNullablePath( - @Nullable String path, URI projectBaseUri, String propertyName) { - if (path == null) { - return null; - } - try { - return Path.of(projectBaseUri).resolve(path).normalize(); - } catch (FileSystemNotFoundException e) { - throw new PackageLoadError( - "relativePathPropertyDefinedByProjectFromNonFileUri", projectBaseUri, propertyName); - } - } - @SuppressWarnings("unchecked") private static Package parsePackage(PObject pObj) throws URISyntaxException { var name = (String) pObj.getProperty("name"); @@ -430,6 +419,7 @@ public final class Project { @Nullable Package pkg, DeclaredDependencies dependencies, PklEvaluatorSettings evaluatorSettings, + PklEvaluatorSettings resolvedEvaluatorSettings, URI projectFileUri, URI projectBaseUri, List tests, @@ -438,6 +428,7 @@ public final class Project { this.pkg = pkg; this.dependencies = dependencies; this.evaluatorSettings = evaluatorSettings; + this.resolvedEvaluatorSettings = resolvedEvaluatorSettings; this.projectFileUri = projectFileUri; this.projectBaseUri = projectBaseUri; this.tests = tests; @@ -459,6 +450,15 @@ public final class Project { return evaluatorSettings; } + /** + * The evaluator settings whose paths have been resolved against the project dir. + * + * @since 0.32.0 + */ + public PklEvaluatorSettings getResolvedEvaluatorSettings() { + return resolvedEvaluatorSettings; + } + public URI getProjectFileUri() { return projectFileUri; } @@ -488,6 +488,7 @@ public final class Project { return Objects.equals(pkg, project.pkg) && dependencies.equals(project.dependencies) && evaluatorSettings.equals(project.evaluatorSettings) + && resolvedEvaluatorSettings.equals(project.resolvedEvaluatorSettings) && projectFileUri.equals(project.projectFileUri) && tests.equals(project.tests) && annotations.equals(project.annotations); @@ -495,7 +496,14 @@ public final class Project { @Override public int hashCode() { - return Objects.hash(pkg, dependencies, evaluatorSettings, projectFileUri, tests, annotations); + return Objects.hash( + pkg, + dependencies, + evaluatorSettings, + resolvedEvaluatorSettings, + projectFileUri, + tests, + annotations); } public DeclaredDependencies getDependencies() { @@ -506,6 +514,7 @@ public final class Project { return localProjectDependencies; } + @SuppressWarnings("unused") public URI getProjectBaseUri() { return projectBaseUri; } diff --git a/pkl-core/src/main/java/org/pkl/core/runtime/CommandSpecParser.java b/pkl-core/src/main/java/org/pkl/core/runtime/CommandSpecParser.java index 400a4e1a..720dc894 100644 --- a/pkl-core/src/main/java/org/pkl/core/runtime/CommandSpecParser.java +++ b/pkl-core/src/main/java/org/pkl/core/runtime/CommandSpecParser.java @@ -1160,7 +1160,7 @@ public final class CommandSpecParser { var uri = IoUtils.isUriLike(moduleName) ? new URI(moduleName) - : IoUtils.isWindowsAbsolutePath(moduleName) + : IoUtils.isWindows() && IoUtils.isWindowsAbsolutePath(moduleName) ? Path.of(moduleName).toUri() : new URI(null, null, IoUtils.toNormalizedPathString(Path.of(moduleName)), null); uriString = diff --git a/pkl-core/src/main/java/org/pkl/core/stdlib/evaluatorsettings/EvaluatorSettingsNodes.java b/pkl-core/src/main/java/org/pkl/core/stdlib/evaluatorsettings/EvaluatorSettingsNodes.java new file mode 100644 index 00000000..734ed89c --- /dev/null +++ b/pkl-core/src/main/java/org/pkl/core/stdlib/evaluatorsettings/EvaluatorSettingsNodes.java @@ -0,0 +1,54 @@ +/* + * 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. + */ +package org.pkl.core.stdlib.evaluatorsettings; + +import com.oracle.truffle.api.CompilerDirectives.TruffleBoundary; +import com.oracle.truffle.api.dsl.Specialization; +import java.net.URI; +import java.net.URISyntaxException; +import org.pkl.core.runtime.VmTyped; +import org.pkl.core.stdlib.ExternalMethod3Node; +import org.pkl.core.util.PathResolver; +import org.pkl.core.util.PathResolvers; + +public class EvaluatorSettingsNodes { + + public abstract static class resolvePath extends ExternalMethod3Node { + @TruffleBoundary + private URI toUri(String baseUri) { + try { + var uri = new URI(baseUri); + // guaranteed by Pkl + assert uri.getScheme().equals("file"); + return uri; + } catch (URISyntaxException e) { + throw exceptionBuilder().evalError("invalidUri", baseUri).build(); + } + } + + private PathResolver getPathResolver(boolean forWindows) { + return forWindows ? PathResolvers.forWindows() : PathResolvers.forPosix(); + } + + @Specialization + protected String eval(VmTyped ignored, String uriStr, String path, boolean forWindows) { + var uri = toUri(uriStr); + var baseUri = uri.resolve("."); + var resolver = getPathResolver(forWindows); + return resolver.resolvePath(baseUri, path); + } + } +} diff --git a/pkl-core/src/main/java/org/pkl/core/stdlib/evaluatorsettings/package-info.java b/pkl-core/src/main/java/org/pkl/core/stdlib/evaluatorsettings/package-info.java new file mode 100644 index 00000000..78455282 --- /dev/null +++ b/pkl-core/src/main/java/org/pkl/core/stdlib/evaluatorsettings/package-info.java @@ -0,0 +1,6 @@ +@NullMarked +@PklName("EvaluatorSettings") +package org.pkl.core.stdlib.evaluatorsettings; + +import org.jspecify.annotations.NullMarked; +import org.pkl.core.stdlib.PklName; diff --git a/pkl-core/src/main/java/org/pkl/core/util/IoUtils.java b/pkl-core/src/main/java/org/pkl/core/util/IoUtils.java index 9ef9a44f..c784a078 100644 --- a/pkl-core/src/main/java/org/pkl/core/util/IoUtils.java +++ b/pkl-core/src/main/java/org/pkl/core/util/IoUtils.java @@ -16,6 +16,7 @@ package org.pkl.core.util; import com.oracle.truffle.api.TruffleOptions; +import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; @@ -52,7 +53,7 @@ public final class IoUtils { // Don't match paths like `C:\`, which are drive letters on Windows. private static final Pattern uriLike = Pattern.compile("[\\w+.-]+:[^\\\\].*"); - private static final Pattern windowsPathLike = Pattern.compile("\\w:\\\\.*"); + public static final Pattern windowsDriveLetterLike = Pattern.compile("[a-zA-Z]:.*"); private static final Pattern headerNameLike = Pattern.compile("[a-zA-Z0-9!#$%&'*+-.^_`|~]+"); @@ -107,8 +108,7 @@ public final class IoUtils { } public static boolean isWindowsAbsolutePath(String str) { - if (!isWindows()) return false; - return windowsPathLike.matcher(str).matches(); + return windowsDriveLetterLike.matcher(str).matches(); } /** @@ -935,4 +935,44 @@ public final class IoUtils { ErrorMessages.create("invalidHttpHeaderValueTooLong", headerValue)); } } + + private static @Nullable String getFilenameExtension(String fileName) { + var dotIndex = fileName.lastIndexOf('.'); + // 0 if hidden file (e.g. `.gitignore`); not an extension + if (dotIndex == -1 || dotIndex == 0) { + return null; + } + return fileName.substring(dotIndex + 1); + } + + public static @Nullable Path findExecutableOnPath(String executable) { + var pathEnvVar = System.getenv("PATH"); + if (pathEnvVar == null) { + return null; + } + var executableExtension = getFilenameExtension(executable); + List extensions; + if (executableExtension != null || !isWindows()) { + extensions = List.of(""); + } else { + extensions = List.of(".exe", ".cmd", ".bat", ".com"); + } + var pathDirs = pathEnvVar.split(File.pathSeparator); + for (var dir : pathDirs) { + for (var extension : extensions) { + var candidate = Path.of(dir, executable + extension); + if (Files.exists(candidate) && Files.isExecutable(candidate)) { + return candidate; + } + } + } + return null; + } + + public static URI fixFileUri(URI uri) { + if ("file".equals(uri.getScheme()) && !uri.getSchemeSpecificPart().startsWith("//")) { + return URI.create("file://" + uri.getSchemeSpecificPart()); + } + return uri; + } } diff --git a/pkl-core/src/main/java/org/pkl/core/util/PathResolver.java b/pkl-core/src/main/java/org/pkl/core/util/PathResolver.java new file mode 100644 index 00000000..731349a8 --- /dev/null +++ b/pkl-core/src/main/java/org/pkl/core/util/PathResolver.java @@ -0,0 +1,198 @@ +/* + * 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. + */ +package org.pkl.core.util; + +import java.net.URI; +import java.util.ArrayList; +import java.util.regex.Pattern; + +// These are implemented by hand instead of relying on the NIO Path API because the JDK does not +// provide libraries for cross-platform resolvers. +// For example, from POSIX systems, you cannot resolve Windows-style paths. +// The alternative to this approach is to depend on a library (e.g. Apache Commons IO or jimfs). +public abstract sealed class PathResolver { + public final String resolvePath(URI uri, String path) { + if (isAbsolute(path)) { + return normalize(path); + } + var basePath = uriToPath(uri); + var resolved = join(basePath, path); + return normalize(resolved); + } + + protected abstract String uriToPath(URI uri); + + protected abstract String join(String base, String path); + + protected abstract boolean isAbsolute(String path); + + protected abstract String getRoot(String path); + + protected abstract char getSeparator(); + + protected String normalize(String path) { + var root = getRoot(path); + var separator = getSeparator(); + var remainder = path.substring(root.length()); + + var parts = remainder.split(Pattern.quote(((Character) separator).toString())); + var stack = new ArrayList<>(); + + for (var part : parts) { + if (part.equals("..")) { + if (!stack.isEmpty()) { + stack.remove(stack.size() - 1); + } + } else if (!part.isEmpty() && !part.equals(".")) { + stack.add(part); + } + } + + if (stack.isEmpty()) { + return root; + } + + var sb = new StringBuilder(root); + + for (var i = 0; i < stack.size(); i++) { + if (i > 0) { + sb.append(separator); + } + sb.append(stack.get(i)); + } + // path ends with trailing separator + if (parts[parts.length - 1].isEmpty()) { + sb.append(separator); + } + + return sb.toString(); + } + + static final class WindowsPathResolver extends PathResolver { + @Override + protected String uriToPath(URI uri) { + var host = uri.getHost(); + var path = uri.getPath(); + if (host != null) { + // UNC path: \\server\share\path + return "\\\\%s%s".formatted(host, path.replace('/', '\\')); + } + var ret = path.matches("/[A-Z]:/.*") ? path.substring(1) : path; + return ret.replace('/', '\\'); + } + + @Override + protected boolean isAbsolute(String path) { + // Normalize forward slashes first + path = path.replace('/', '\\'); + return isDriveLetter(path) || path.startsWith("\\\\"); + } + + @Override + protected String join(String base, String path) { + path = path.replace('/', '\\'); + if (isAbsolute(path)) { + return path; + } + if (path.startsWith("\\")) { + // Root-relative path: skip the leading backslash to avoid double backslash + return getRoot(base) + path; + } + if (base.endsWith("\\")) { + return base + path; + } + return base + '\\' + path; + } + + private boolean isDriveLetter(String path) { + return path.length() >= 2 && isAlpha(path.charAt(0)) && path.charAt(1) == ':'; + } + + private boolean isAlpha(char character) { + return character >= 65 && character <= 90 || character >= 97 && character <= 122; + } + + @Override + protected String getRoot(String path) { + // UNC path, e.g. \\server\share + if (path.startsWith("\\\\")) { + var firstBackslash = path.indexOf('\\', 2); + if (firstBackslash == -1) { + // Malformed UNC, just return what we have + return path; + } + var secondBackslash = path.indexOf('\\', firstBackslash + 1); + if (secondBackslash == -1) { + return path + "\\"; + } + return path.substring(0, secondBackslash + 1); + } else if (isDriveLetter(path)) { + // drive letter without leading slash, e.g. `C:foo\bar` (uncommon but valid) + if (path.length() > 2 && path.charAt(2) == '\\') { + return path.substring(0, 3); + } + return path.substring(0, 2); + } else if (path.startsWith("\\")) { + // drive-relative path + return "\\"; + } + return ""; + } + + @Override + protected char getSeparator() { + return '\\'; + } + + @Override + protected String normalize(String path) { + return super.normalize(path.replace('/', '\\')); + } + } + + static final class PosixPathResolver extends PathResolver { + @Override + protected String uriToPath(URI uri) { + return uri.getPath(); + } + + @Override + protected boolean isAbsolute(String path) { + return path.startsWith("/"); + } + + @Override + protected String join(String base, String path) { + if (isAbsolute(path)) { + return path; + } + if (base.endsWith("/")) { + return base + path; + } + return base + '/' + path; + } + + @Override + protected String getRoot(String ignored) { + return "/"; + } + + @Override + protected char getSeparator() { + return '/'; + } + } +} diff --git a/pkl-core/src/main/java/org/pkl/core/util/PathResolvers.java b/pkl-core/src/main/java/org/pkl/core/util/PathResolvers.java new file mode 100644 index 00000000..e58b2fee --- /dev/null +++ b/pkl-core/src/main/java/org/pkl/core/util/PathResolvers.java @@ -0,0 +1,35 @@ +/* + * 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. + */ +package org.pkl.core.util; + +import org.pkl.core.util.PathResolver.PosixPathResolver; +import org.pkl.core.util.PathResolver.WindowsPathResolver; + +public final class PathResolvers { + private PathResolvers() {} + + private static final PathResolver WINDOWS = new WindowsPathResolver(); + + private static final PathResolver POSIX = new PosixPathResolver(); + + public static PathResolver forWindows() { + return WINDOWS; + } + + public static PathResolver forPosix() { + return POSIX; + } +} diff --git a/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties b/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties index 98fcd8ba..37cb3ce0 100644 --- a/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties +++ b/pkl-core/src/main/resources/org/pkl/core/errorMessages.properties @@ -619,6 +619,9 @@ Cannot analyze relative module URI `{0}`. invalidModuleUri=\ Module URI `{0}` has invalid syntax. +invalidUri=\ +URI `{0}` has invalid syntax. + invalidModuleUriMissingSlash=\ Module URI `{0}` is missing a `/` after `{1}:`. diff --git a/pkl-core/src/test/files/LanguageSnippetTests/input/api/evaluatorSettingsModulePosix.pkl b/pkl-core/src/test/files/LanguageSnippetTests/input/api/evaluatorSettingsModulePosix.pkl new file mode 100644 index 00000000..cdcdbcfe --- /dev/null +++ b/pkl-core/src/test/files/LanguageSnippetTests/input/api/evaluatorSettingsModulePosix.pkl @@ -0,0 +1,130 @@ +amends "../snippetTest.pkl" + +import "pkl:EvaluatorSettings" +import "pkl:platform" + +// macOS and linux have the same impl; both POSIX systems +local macOS = new platform.OperatingSystem { name = "macOS" } + +local function resolve(base: String, path: String) = + new EvaluatorSettings { rootDir = path }.resolveForOs(base, macOS).rootDir + +examples { + ["resolve()"] { + local settings: EvaluatorSettings = new { + modulePath { + "first/module/path" + "second/module/path" + } + moduleCacheDir = "my/cache/dir" + rootDir = "my/root/dir" + externalModuleReaders { + ["foo"] { + executable = "path/to/my/executable" + } + } + externalResourceReaders { + ["foo"] { + executable = "path/to/my/executable" + } + } + } + settings.resolveForOs("file:///path/to/dir/PklProject", macOS) + } + + ["relative path"] { + resolve("file:///path/to/dir/PklProject", "foo/bar") + } + + ["absolute path"] { + resolve("file:///path/to/dir/PklProject", "/foo/bar") + } + + ["enclosing URI has spaces"] { + resolve("file:///path/to/dir%20with%20spaces/PklProject", "foo/bar") + } + + ["relative path with dot segments"] { + resolve("file:///path/to/dir/PklProject", "../my/module/path") + } + + ["relative path with dot segments 2"] { + resolve("file:///path/to/dir/PklProject", "../../my/module/path") + } + + ["relative path with dot segments 3"] { + resolve("file:///path/to/dir/PklProject", ".") + } + + ["relative path with dot segments 4"] { + resolve("file:///path/to/dir/PklProject", "./") + } + + ["relative path with dot segments 5"] { + resolve("file:///path/to/dir/PklProject", "../../../../../../../../") + } + + ["file:/ instead of file:///"] { + resolve("file:/path/to/dir/PklProject", "foo/bar") + } + + ["executable with simple name is not resolved"] { + local settings: EvaluatorSettings = new { + externalModuleReaders { + ["foo"] { + executable = "my-reader" + } + } + } + settings.resolveForOs("file:///path/to/dir/PklProject", macOS).externalModuleReaders!!["foo"] + .executable + } + + ["executable with path segments is resolved against enclosingUri"] { + local settings: EvaluatorSettings = new { + externalModuleReaders { + ["foo"] { + executable = "path/to/reader" + } + } + } + settings.resolveForOs("file:///path/to/dir/PklProject", macOS).externalModuleReaders!!["foo"] + .executable + } + + ["workingDir defaults to enclosingUri"] { + local settings: EvaluatorSettings = new { + externalModuleReaders { + ["foo"] { + workingDir = null + } + } + } + settings.resolveForOs("file:///path/to/dir/PklProject", macOS).externalModuleReaders!!["foo"] + .workingDir + } + + ["workingDir with relative path"] { + local settings: EvaluatorSettings = new { + externalModuleReaders { + ["foo"] { + workingDir = "." + } + } + } + settings.resolveForOs("file:///path/to/dir/PklProject", macOS).externalModuleReaders!!["foo"] + .workingDir + } + + ["workingDir with absolute path"] { + local settings: EvaluatorSettings = new { + externalModuleReaders { + ["foo"] { + workingDir = "/foo/bar" + } + } + } + settings.resolveForOs("file:///path/to/dir/PklProject", macOS).externalModuleReaders!!["foo"] + .workingDir + } +} diff --git a/pkl-core/src/test/files/LanguageSnippetTests/input/api/evaluatorSettingsModuleWindows.pkl b/pkl-core/src/test/files/LanguageSnippetTests/input/api/evaluatorSettingsModuleWindows.pkl new file mode 100644 index 00000000..137e34af --- /dev/null +++ b/pkl-core/src/test/files/LanguageSnippetTests/input/api/evaluatorSettingsModuleWindows.pkl @@ -0,0 +1,243 @@ +amends "../snippetTest.pkl" + +import "pkl:EvaluatorSettings" +import "pkl:platform" + +local windows = new platform.OperatingSystem { name = "Windows" } + +local function resolve(base: String, path: String) = + new EvaluatorSettings { rootDir = path } + .resolveForOs(base, windows) + .rootDir + +examples { + ["resolve()"] { + local settings: EvaluatorSettings = new { + modulePath { + "first/module/path" + "second/module/path" + } + moduleCacheDir = "my/cache/dir" + rootDir = "my/root/dir" + externalModuleReaders { + ["foo"] { + executable = "path/to/my/executable" + } + } + externalResourceReaders { + ["foo"] { + executable = "path/to/my/executable" + } + } + } + settings.resolveForOs("file:///C:/path/to/dir/PklProject", windows) + } + + ["relative path"] { + resolve("file:///C:/path/to/dir/PklProject", "foo\\bar") + } + + ["relative path 2"] { + resolve("file:///C:/path/to/dir/PklProject", "foo/bar") + } + + ["absolute path"] { + resolve("file:///C:/path/to/dir/PklProject", #"\foo\bar"#) + } + + ["absolute path with drive letter"] { + resolve("file:///C:/path/to/dir/PklProject", #"C:\foo\bar"#) + } + + ["absolute path with drive letter 2"] { + resolve("file:///C:/path/to/dir/PklProject", #"C:/foo/bar"#) + } + + ["absolute path with drive letter 3"] { + resolve("file:///C:/path/to/dir/PklProject", #"C:/"#) + } + + ["absolute path with drive letter 4"] { + resolve("file:///C:/path/to/dir/PklProject", #"C:"#) + } + + ["absolute path with drive letter 5"] { + resolve("file:///C:", #"\path\to\foo"#) + } + + ["base path with drive letter"] { + resolve("file:///C:", #"path\to\foo"#) + } + + ["enclosing URI has spaces"] { + resolve("file:///C:/path/to/dir%20with%20spaces/PklProject", "foo/bar") + } + + ["relative path with dot segments"] { + resolve("file:///C:/path/to/dir/PklProject", "../my/module/path") + } + + ["relative path with dot segments 2"] { + resolve("file:///C:/path/to/dir/PklProject", "../../my/module/path") + } + + ["relative path with dot segments 3"] { + resolve("file:///C:/path/to/dir/PklProject", ".") + } + + ["relative path with dot segments 4"] { + resolve("file:///C:/path/to/dir/PklProject", "./") + } + + ["relative path with dot segments 5"] { + resolve("file:///C:/path/to/dir/PklProject", #"..\..\..\..\..\..\..\..\"#) + } + + ["file URI with no drive"] { + resolve("file:///path/to/dir/PklProject", "foo\\bar") + } + + ["executable with simple name is not resolved"] { + local settings: EvaluatorSettings = new { + externalModuleReaders { + ["foo"] { + executable = "my-reader" + } + } + } + settings + .resolveForOs("file:///C:/path/to/dir/PklProject", windows) + .externalModuleReaders!!["foo"].executable + } + + ["executable with path segments is resolved against enclosingUri"] { + local settings: EvaluatorSettings = new { + externalModuleReaders { + ["foo"] { + executable = "path/to/reader" + } + } + } + settings + .resolveForOs("file:///C:/path/to/dir/PklProject", windows) + .externalModuleReaders!!["foo"].executable + } + + ["workingDir defaults to enclosingUri"] { + local settings: EvaluatorSettings = new { + externalModuleReaders { + ["foo"] { + workingDir = null + } + } + } + settings + .resolveForOs("file:///C:/path/to/dir/PklProject", windows) + .externalModuleReaders!!["foo"].workingDir + } + + ["workingDir with relative path"] { + local settings: EvaluatorSettings = new { + externalModuleReaders { + ["foo"] { + workingDir = "." + } + } + } + settings + .resolveForOs("file:///C:/path/to/dir/PklProject", windows) + .externalModuleReaders!!["foo"].workingDir + } + + ["workingDir with absolute path"] { + local settings: EvaluatorSettings = new { + externalModuleReaders { + ["foo"] { + workingDir = "/foo/bar" + } + } + } + settings + .resolveForOs("file:///C:/path/to/dir/PklProject", windows) + .externalModuleReaders!!["foo"].workingDir + } + + ["UNC path"] { + resolve("file:///path/to/foo", #"\\server\share\path\to\foo"#) + } + + ["UNC path 2"] { + resolve(#"file://server/share/path/to/foo"#, #"\new\path"#) + } + + ["empty path"] { + resolve("file:///C:/path/to/dir/PklProject", "") + } + + ["multiple consecutive slashes"] { + resolve("file:///C:/path/to/dir/PklProject", #"my\\\\path"#) + } + + ["different drive letter"] { + resolve("file:///C:/path/to/dir/PklProject", #"D:\my\path"#) + } + + ["UNC path as base with relative path"] { + resolve(#"file://server/share/dir/PklProject"#, #"my\path"#) + } + + ["UNC path as base with .."] { + resolve(#"file://server/share/dir/sub/PklProject"#, #"..\my\path"#) + } + + ["UNC path as base with ."] { + resolve(#"file://server/share/dir/PklProject"#, ".") + } + + ["UNC path as base, cannot remove root"] { + resolve(#"file://server/share/PklProject"#, #"..\..\..\"#) + } + + ["UNC path as base, cannot remove root 2"] { + resolve(#"file://server/share/dir/PklProject"#, #"../../.."#) + } + + ["UNC path as base with drive letter path"] { + resolve(#"file://server/share/dir/PklProject"#, #"C:\local\path"#) + } + + ["UNC path as base with no drive"] { + resolve(#"file://server/"#, #"/my/path"#) + } + + ["trailing separator preserved"] { + resolve("file:///C:/path/to/dir/PklProject", #"my\path\"#) + } + + ["trailing separator preserved with normalization"] { + resolve("file:///C:/path/to/dir/PklProject", #"..\my\path\"#) + } + + ["no trailing separator"] { + resolve("file:///C:/path/to/dir/PklProject", #"my\path"#) + } + + ["complex normalization"] { + resolve("file:///C:/path/to/dir/PklProject", #".\foo\..\bar\.\baz"#) + } + + ["UNC with forward slashes"] { + resolve(#"file://server/share/dir/PklProject"#, "my/path") + } + + ["absolute path with different drive and forward slashes"] { + resolve("file:///C:/path/to/dir/PklProject", "D:/my/path") + } +} + +output { + renderer = new PcfRenderer { + useCustomStringDelimiters = true + omitNullProperties = true + } +} diff --git a/pkl-core/src/test/files/LanguageSnippetTests/input/api/project1.pkl b/pkl-core/src/test/files/LanguageSnippetTests/input/api/project1.pkl new file mode 100644 index 00000000..a4db49bb --- /dev/null +++ b/pkl-core/src/test/files/LanguageSnippetTests/input/api/project1.pkl @@ -0,0 +1,32 @@ +amends "../snippetTest.pkl" + +import "pkl:platform" +import "pkl:Project" + +examples { + local macos = new platform.OperatingSystem { name = "macOS" } + + ["resolvedEvaluatorSettings"] { + new Project { + projectFileUri = "file:///path/to/PklProject" + evaluatorSettings { + modulePath { + "modulepath/first" + "modulepath/second" + } + moduleCacheDir = "path/to/cache" + externalModuleReaders { + ["relative path"] { + executable = "foo/executable" + } + ["absolute path"] { + executable = "/path/to/executable" + } + ["command name"] { + executable = "foo" + } + } + } + }.evaluatorSettings.resolveForOs("file:///path/to/PklProject", macos) + } +} diff --git a/pkl-core/src/test/files/LanguageSnippetTests/input/projects/badPklProject4/PklProject b/pkl-core/src/test/files/LanguageSnippetTests/input/projects/badPklProject4/PklProject new file mode 100644 index 00000000..ef0dc083 --- /dev/null +++ b/pkl-core/src/test/files/LanguageSnippetTests/input/projects/badPklProject4/PklProject @@ -0,0 +1,7 @@ +amends "pkl:Project" + +projectFileUri = "file:///not a valid uri" + +evaluatorSettings { + moduleCacheDir = "foo" +} diff --git a/pkl-core/src/test/files/LanguageSnippetTests/input/projects/badPklProject4/bug.pkl b/pkl-core/src/test/files/LanguageSnippetTests/input/projects/badPklProject4/bug.pkl new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/pkl-core/src/test/files/LanguageSnippetTests/input/projects/badPklProject4/bug.pkl @@ -0,0 +1 @@ + diff --git a/pkl-core/src/test/files/LanguageSnippetTests/input/projects/badPklProject5/PklProject b/pkl-core/src/test/files/LanguageSnippetTests/input/projects/badPklProject5/PklProject new file mode 100644 index 00000000..0b16faa3 --- /dev/null +++ b/pkl-core/src/test/files/LanguageSnippetTests/input/projects/badPklProject5/PklProject @@ -0,0 +1,11 @@ +amends "pkl:Project" + +projectFileUri = "modulepath:/foo/bar/PklProject" + +evaluatorSettings { + externalModuleReaders { + ["foo"] { + executable = "foo/bar" + } + } +} diff --git a/pkl-core/src/test/files/LanguageSnippetTests/input/projects/badPklProject5/bug.pkl b/pkl-core/src/test/files/LanguageSnippetTests/input/projects/badPklProject5/bug.pkl new file mode 100644 index 00000000..e69de29b diff --git a/pkl-core/src/test/files/LanguageSnippetTests/input/projects/evaluatorSettings2/PklProject b/pkl-core/src/test/files/LanguageSnippetTests/input/projects/evaluatorSettings2/PklProject new file mode 100644 index 00000000..9cc526b3 --- /dev/null +++ b/pkl-core/src/test/files/LanguageSnippetTests/input/projects/evaluatorSettings2/PklProject @@ -0,0 +1,5 @@ +amends "pkl:Project" + +evaluatorSettings { + rootDir = "." +} diff --git a/pkl-core/src/test/files/LanguageSnippetTests/input/projects/evaluatorSettings2/badRead.pkl b/pkl-core/src/test/files/LanguageSnippetTests/input/projects/evaluatorSettings2/badRead.pkl new file mode 100644 index 00000000..131d2c9a --- /dev/null +++ b/pkl-core/src/test/files/LanguageSnippetTests/input/projects/evaluatorSettings2/badRead.pkl @@ -0,0 +1 @@ +res = read("file:///file.txt") diff --git a/pkl-core/src/test/files/LanguageSnippetTests/input/projects/evaluatorSettings2/goodRead.pkl b/pkl-core/src/test/files/LanguageSnippetTests/input/projects/evaluatorSettings2/goodRead.pkl new file mode 100644 index 00000000..37c61567 --- /dev/null +++ b/pkl-core/src/test/files/LanguageSnippetTests/input/projects/evaluatorSettings2/goodRead.pkl @@ -0,0 +1 @@ +res = read("badRead.pkl").text diff --git a/pkl-core/src/test/files/LanguageSnippetTests/output/api/evaluatorSettingsModulePosix.pcf b/pkl-core/src/test/files/LanguageSnippetTests/output/api/evaluatorSettingsModulePosix.pcf new file mode 100644 index 00000000..df39a01d --- /dev/null +++ b/pkl-core/src/test/files/LanguageSnippetTests/output/api/evaluatorSettingsModulePosix.pcf @@ -0,0 +1,66 @@ +examples { + ["resolve()"] { + new { + modulePath { + "/path/to/dir/first/module/path" + "/path/to/dir/second/module/path" + } + moduleCacheDir = "/path/to/dir/my/cache/dir" + rootDir = "/path/to/dir/my/root/dir" + externalModuleReaders { + ["foo"] { + executable = "/path/to/dir/path/to/my/executable" + workingDir = "/path/to/dir" + } + } + externalResourceReaders { + ["foo"] { + executable = "/path/to/dir/path/to/my/executable" + workingDir = "/path/to/dir" + } + } + } + } + ["relative path"] { + "/path/to/dir/foo/bar" + } + ["absolute path"] { + "/foo/bar" + } + ["enclosing URI has spaces"] { + "/path/to/dir with spaces/foo/bar" + } + ["relative path with dot segments"] { + "/path/to/my/module/path" + } + ["relative path with dot segments 2"] { + "/path/my/module/path" + } + ["relative path with dot segments 3"] { + "/path/to/dir" + } + ["relative path with dot segments 4"] { + "/path/to/dir" + } + ["relative path with dot segments 5"] { + "/" + } + ["file:/ instead of file:///"] { + "/path/to/dir/foo/bar" + } + ["executable with simple name is not resolved"] { + "my-reader" + } + ["executable with path segments is resolved against enclosingUri"] { + "/path/to/dir/path/to/reader" + } + ["workingDir defaults to enclosingUri"] { + "/path/to/dir" + } + ["workingDir with relative path"] { + "/path/to/dir" + } + ["workingDir with absolute path"] { + "/foo/bar" + } +} diff --git a/pkl-core/src/test/files/LanguageSnippetTests/output/api/evaluatorSettingsModuleWindows.pcf b/pkl-core/src/test/files/LanguageSnippetTests/output/api/evaluatorSettingsModuleWindows.pcf new file mode 100644 index 00000000..d88f294c --- /dev/null +++ b/pkl-core/src/test/files/LanguageSnippetTests/output/api/evaluatorSettingsModuleWindows.pcf @@ -0,0 +1,141 @@ +examples { + ["resolve()"] { + new { + modulePath { + #"C:\path\to\dir\first\module\path"# + #"C:\path\to\dir\second\module\path"# + } + moduleCacheDir = #"C:\path\to\dir\my\cache\dir"# + rootDir = #"C:\path\to\dir\my\root\dir"# + externalModuleReaders { + ["foo"] { + executable = #"C:\path\to\dir\path\to\my\executable"# + workingDir = #"C:\path\to\dir"# + } + } + externalResourceReaders { + ["foo"] { + executable = #"C:\path\to\dir\path\to\my\executable"# + workingDir = #"C:\path\to\dir"# + } + } + } + } + ["relative path"] { + #"C:\path\to\dir\foo\bar"# + } + ["relative path 2"] { + #"C:\path\to\dir\foo\bar"# + } + ["absolute path"] { + #"C:\foo\bar"# + } + ["absolute path with drive letter"] { + #"C:\foo\bar"# + } + ["absolute path with drive letter 2"] { + #"C:\foo\bar"# + } + ["absolute path with drive letter 3"] { + #"C:\"# + } + ["absolute path with drive letter 4"] { + "C:" + } + ["absolute path with drive letter 5"] { + #"\\path\to\foo"# + } + ["base path with drive letter"] { + #"\path\to\foo"# + } + ["enclosing URI has spaces"] { + #"C:\path\to\dir with spaces\foo\bar"# + } + ["relative path with dot segments"] { + #"C:\path\to\my\module\path"# + } + ["relative path with dot segments 2"] { + #"C:\path\my\module\path"# + } + ["relative path with dot segments 3"] { + #"C:\path\to\dir"# + } + ["relative path with dot segments 4"] { + #"C:\path\to\dir"# + } + ["relative path with dot segments 5"] { + #"C:\"# + } + ["file URI with no drive"] { + #"\path\to\dir\foo\bar"# + } + ["executable with simple name is not resolved"] { + "my-reader" + } + ["executable with path segments is resolved against enclosingUri"] { + #"C:\path\to\dir\path\to\reader"# + } + ["workingDir defaults to enclosingUri"] { + #"C:\path\to\dir"# + } + ["workingDir with relative path"] { + #"C:\path\to\dir"# + } + ["workingDir with absolute path"] { + #"C:\foo\bar"# + } + ["UNC path"] { + #"\\server\share\path\to\foo"# + } + ["UNC path 2"] { + #"\\server\share\new\path"# + } + ["empty path"] { + #"C:\path\to\dir"# + } + ["multiple consecutive slashes"] { + #"C:\path\to\dir\my\path"# + } + ["different drive letter"] { + #"D:\my\path"# + } + ["UNC path as base with relative path"] { + #"\\server\share\dir\my\path"# + } + ["UNC path as base with .."] { + #"\\server\share\dir\my\path"# + } + ["UNC path as base with ."] { + #"\\server\share\dir"# + } + ["UNC path as base, cannot remove root"] { + #"\\server\share\"# + } + ["UNC path as base, cannot remove root 2"] { + #"\\server\share\"# + } + ["UNC path as base with drive letter path"] { + #"C:\local\path"# + } + ["UNC path as base with no drive"] { + #"\\server\\my\path"# + } + ["trailing separator preserved"] { + #"C:\path\to\dir\my\path"# + } + ["trailing separator preserved with normalization"] { + #"C:\path\to\my\path"# + } + ["no trailing separator"] { + #"C:\path\to\dir\my\path"# + } + ["complex normalization"] { + #"C:\path\to\dir\bar\baz"# + } + ["UNC with forward slashes"] { + #"\\server\share\dir\my\path"# + } + ["absolute path with different drive and forward slashes"] { + #"D:\my\path"# + } +} diff --git a/pkl-core/src/test/files/LanguageSnippetTests/output/api/project1.pcf b/pkl-core/src/test/files/LanguageSnippetTests/output/api/project1.pcf new file mode 100644 index 00000000..c207fbc6 --- /dev/null +++ b/pkl-core/src/test/files/LanguageSnippetTests/output/api/project1.pcf @@ -0,0 +1,25 @@ +examples { + ["resolvedEvaluatorSettings"] { + new { + modulePath { + "/path/to/modulepath/first" + "/path/to/modulepath/second" + } + moduleCacheDir = "/path/to/path/to/cache" + externalModuleReaders { + ["relative path"] { + executable = "/path/to/foo/executable" + workingDir = "/path/to" + } + ["absolute path"] { + executable = "/path/to/executable" + workingDir = "/path/to" + } + ["command name"] { + executable = "foo" + workingDir = "/path/to" + } + } + } + } +} diff --git a/pkl-core/src/test/files/LanguageSnippetTests/output/projects/badPklProject4/bug.err b/pkl-core/src/test/files/LanguageSnippetTests/output/projects/badPklProject4/bug.err new file mode 100644 index 00000000..4cfca8b8 --- /dev/null +++ b/pkl-core/src/test/files/LanguageSnippetTests/output/projects/badPklProject4/bug.err @@ -0,0 +1,6 @@ +–– Pkl Error –– +URI `file:///not a valid uri` has invalid syntax. + +xxx | moduleCacheDir = resolvePath(enclosingUri, module.moduleCacheDir!!, forWindows) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +at pkl.EvaluatorSettings#resolveForOs.moduleCacheDir (pkl:EvaluatorSettings) diff --git a/pkl-core/src/test/files/LanguageSnippetTests/output/projects/badPklProject5/bug.err b/pkl-core/src/test/files/LanguageSnippetTests/output/projects/badPklProject5/bug.err new file mode 100644 index 00000000..35d10ef2 --- /dev/null +++ b/pkl-core/src/test/files/LanguageSnippetTests/output/projects/badPklProject5/bug.err @@ -0,0 +1,16 @@ +–– Pkl Error –– +Type constraint `(externalModuleReaders != null).implies(isFileBasedProject)` violated. +Value: new ModuleClass { externalProperties = ?; env = ?; allowedModules = ?; allowe... + + (externalModuleReaders != null).implies(isFileBasedProject) + │ │ │ │ + │ true false false + new Mapping { ["foo"] { executable = ?; arguments = ?; workingDir = ? } } + +xxx | (externalModuleReaders != null).implies(isFileBasedProject), + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +at pkl.Project#evaluatorSettings (pkl:Project) + +x | evaluatorSettings { + ^^^^^^^^^^^^^^^^^^^ +at PklProject#evaluatorSettings (file:///$snippetsDir/input/projects/badPklProject5/PklProject) diff --git a/pkl-core/src/test/files/LanguageSnippetTests/output/projects/evaluatorSettings2/badRead.err b/pkl-core/src/test/files/LanguageSnippetTests/output/projects/evaluatorSettings2/badRead.err new file mode 100644 index 00000000..0a9c223b --- /dev/null +++ b/pkl-core/src/test/files/LanguageSnippetTests/output/projects/evaluatorSettings2/badRead.err @@ -0,0 +1,14 @@ +–– Pkl Error –– +Refusing to read resource `file:///file.txt` because it is not within the root directory (`--root-dir`). + +x | res = read("file:///file.txt") + ^^^^^^^^^^^^^^^^^^^^^^^^ +at badRead#res (file:///$snippetsDir/input/projects/evaluatorSettings2/badRead.pkl) + +xxx | renderer.renderDocument(value) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +at pkl.base#Module.output.text (pkl:base) + +xxx | if (renderer is BytesRenderer) renderer.renderDocument(value) else text.encodeToBytes("UTF-8") + ^^^^ +at pkl.base#Module.output.bytes (pkl:base) diff --git a/pkl-core/src/test/files/LanguageSnippetTests/output/projects/evaluatorSettings2/goodRead.pcf b/pkl-core/src/test/files/LanguageSnippetTests/output/projects/evaluatorSettings2/goodRead.pcf new file mode 100644 index 00000000..383df4c2 --- /dev/null +++ b/pkl-core/src/test/files/LanguageSnippetTests/output/projects/evaluatorSettings2/goodRead.pcf @@ -0,0 +1,4 @@ +res = """ + res = read("file:///file.txt") + + """ diff --git a/pkl-core/src/test/kotlin/org/pkl/core/module/ModuleKeyFactoriesTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/module/ModuleKeyFactoriesTest.kt index 58ada865..0fb5167b 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/module/ModuleKeyFactoriesTest.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/module/ModuleKeyFactoriesTest.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. @@ -15,20 +15,55 @@ */ package org.pkl.core.module +import java.io.File import java.net.URI +import java.nio.file.Files import java.nio.file.Path +import java.util.regex.Pattern import kotlin.io.path.createDirectories import kotlin.io.path.createParentDirectories import kotlin.io.path.outputStream import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assumptions.assumeTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir +import org.pkl.commons.test.FileTestUtils import org.pkl.commons.toPath import org.pkl.commons.writeString +import org.pkl.core.EvaluatorBuilder +import org.pkl.core.ModuleSource import org.pkl.core.SecurityManagers -import org.pkl.core.externalreader.* +import org.pkl.core.evaluatorSettings.PklEvaluatorSettings +import org.pkl.core.externalreader.ExternalReaderProcess +import org.pkl.core.externalreader.TestExternalModuleReader +import org.pkl.core.externalreader.TestExternalReaderProcess +import org.pkl.core.resource.ResourceReaders +import org.pkl.core.util.IoUtils class ModuleKeyFactoriesTest { + companion object { + private val externalReaderFixture by lazy { + val readerPath = + "pkl-core/build/fixtures/externalreader".let { if (IoUtils.isWindows()) "$it.bat" else it } + + FileTestUtils.rootProjectDir.resolve(readerPath).also { path -> + if (!Files.exists(path)) { + throw AssertionError( + "Fixture `externalreader` not found. To fix this problem, first run" + + " `./gradlew pkl-core:externalReaderFixture`." + ) + } + } + } + + @JvmStatic + private fun pathEnvIsSet(): Boolean { + return System.getenv("PATH") + ?.split(File.pathSeparator) + ?.contains(externalReaderFixture.toAbsolutePath().toString()) ?: false + } + } + @Test fun `standard library`() { val factory = ModuleKeyFactories.standardLibrary @@ -146,4 +181,34 @@ class ModuleKeyFactoriesTest { proc.close() runtime.close() } + + @Test + fun `external process -- spawning an executable using a path`() { + testExternalReader(externalReaderFixture.toAbsolutePath().toString()) + } + + @Test + fun `external process -- spawning an executable using a simple name off PATH`() { + assumeTrue(pathEnvIsSet(), "PATH contains fixtures dir") + testExternalReader("externalreader") + } + + private fun testExternalReader(executable: String) { + val evaluator = makeEvaluatorWithExternalReader(executable) + val result = evaluator.use { + evaluator.evaluateExpression(ModuleSource.uri("pkl:base"), "read(\"foo:foo\").text") + } + assertThat(result).isEqualTo("hello") + } + + private fun makeEvaluatorWithExternalReader(reader: String) = + with(EvaluatorBuilder.preconfigured()) { + val process = + ExternalReaderProcess.of(PklEvaluatorSettings.ExternalReader(reader, listOf(), null)) + addModuleKeyFactory(ModuleKeyFactories.externalProcess("foo", process)) + addResourceReader(ResourceReaders.externalProcess("foo", process)) + setAllowedModules(allowedModules + listOf(Pattern.compile("foo:"))) + setAllowedResources(allowedResources + listOf(Pattern.compile("foo:"))) + build() + } } diff --git a/pkl-core/src/test/kotlin/org/pkl/core/project/ProjectTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/project/ProjectTest.kt index fac9b02b..8ba576b1 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/project/ProjectTest.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/project/ProjectTest.kt @@ -18,6 +18,7 @@ package org.pkl.core.project import java.net.URI import java.nio.file.Path import java.util.regex.Pattern +import kotlin.io.path.createDirectories import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.assertThatCode import org.junit.jupiter.api.Test @@ -153,12 +154,57 @@ class ProjectTest { ) val project = Project.loadFromPath(projectPath) assertThat(project.`package`).isEqualTo(expectedPackage) - assertThat(project.evaluatorSettings).isEqualTo(expectedSettings) + assertThat(project.resolvedEvaluatorSettings).isEqualTo(expectedSettings) assertThat(project.annotations).isEqualTo(expectedAnnotations) assertThat(project.tests) .isEqualTo(listOf(path.resolve("test1.pkl"), path.resolve("test2.pkl"))) } + @Test + fun `loadFromPath() resolvedEvaluatorSettings`(@TempDir path: Path) { + val projectPath = + path.resolve("PklProject").also { + it.writeString( + """ + amends "pkl:Project" + + projectFileUri = "file:///path/to/PklProject" + + evaluatorSettings { + rootDir = "." + moduleCacheDir = "cache/" + modulePath { + "modulepath1/" + "modulepath2/" + } + } + """ + .trimIndent() + ) + } + val project = Project.loadFromPath(projectPath) + assertThat(project.resolvedEvaluatorSettings) + .usingRecursiveComparison() + .isEqualTo( + PklEvaluatorSettings( + null, + null, + null, + null, + null, + null, + Path.of("/path/to/cache/"), + listOf(Path.of("/path/to/modulepath1/"), Path.of("/path/to/modulepath2/")), + null, + Path.of("/path/to"), + null, + null, + null, + null, + ) + ) + } + @Test fun `load wrong type`(@TempDir path: Path) { val projectPath = path.resolve("PklProject") @@ -261,4 +307,59 @@ class ProjectTest { .trimIndent() ) } + + @Test + fun `external readers -- executable path is relative to project dir`(@TempDir tempDir: Path) { + val projectDir = tempDir.resolve("project").also { it.createDirectories() } + val pklProject = + projectDir.resolve("PklProject").also { + it.writeString( + // language=pkl + """ + amends "pkl:Project" + + evaluatorSettings { + externalModuleReaders { + ["foo"] { + executable = "foo/bar/baz" + } + } + } + """ + .trimIndent() + ) + } + val project = Project.loadFromPath(pklProject, SecurityManagers.defaultManager, null) + assertThat(project.resolvedEvaluatorSettings.externalModuleReaders).hasSize(1) + assertThat(project.resolvedEvaluatorSettings.externalModuleReaders?.get("foo")!!.executable()) + .isEqualTo(projectDir.resolve("foo/bar/baz").toString()) + } + + @Test + fun `external readers -- executable is unmodified simple name`(@TempDir tempDir: Path) { + val projectDir = tempDir.resolve("project").also { it.createDirectories() } + val pklProject = + projectDir.resolve("PklProject").also { + it.writeString( + // language=pkl + """ + amends "pkl:Project" + + evaluatorSettings { + externalModuleReaders { + ["foo"] { + executable = "my-command" + } + } + } + """ + .trimIndent() + ) + } + + val project = Project.loadFromPath(pklProject, SecurityManagers.defaultManager, null) + assertThat(project.evaluatorSettings.externalModuleReaders).hasSize(1) + assertThat(project.evaluatorSettings.externalModuleReaders?.get("foo")!!.executable()) + .isEqualTo("my-command") + } } diff --git a/pkl-core/src/test/kotlin/org/pkl/core/util/PathResolverTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/util/PathResolverTest.kt new file mode 100644 index 00000000..c1102a68 --- /dev/null +++ b/pkl-core/src/test/kotlin/org/pkl/core/util/PathResolverTest.kt @@ -0,0 +1,240 @@ +/* + * 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. + */ +package org.pkl.core.util + +import java.net.URI +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +class PathResolverTest { + private val posix = PathResolvers.forPosix() + private val windows = PathResolvers.forWindows() + + @Nested + inner class PosixTests { + @Test + fun `simple relative path appended to file base`() { + assertThat(posix.resolvePath(URI("file:///home/user/base.pkl"), "sibling.pkl")) + .isEqualTo("/home/user/base.pkl/sibling.pkl") + } + + @Test + fun `relative path appended to directory base (trailing slash)`() { + assertThat(posix.resolvePath(URI("file:///home/user/dir/"), "file.pkl")) + .isEqualTo("/home/user/dir/file.pkl") + } + + @Test + fun `nested relative path`() { + assertThat(posix.resolvePath(URI("file:///home/user/base.pkl"), "sub/dir/file.pkl")) + .isEqualTo("/home/user/base.pkl/sub/dir/file.pkl") + } + + @Test + fun `absolute path overrides base`() { + assertThat(posix.resolvePath(URI("file:///home/user/base.pkl"), "/absolute/path.pkl")) + .isEqualTo("/absolute/path.pkl") + } + + @Test + fun `absolute path containing dot is normalized`() { + assertThat(posix.resolvePath(URI("file:///home/user/base.pkl"), "/foo/./bar.pkl")) + .isEqualTo("/foo/bar.pkl") + } + + @Test + fun `absolute path containing double-dot is normalized`() { + assertThat(posix.resolvePath(URI("file:///home/user/base.pkl"), "/foo/../bar.pkl")) + .isEqualTo("/bar.pkl") + } + + @Test + fun `single dot in relative path is elided`() { + assertThat(posix.resolvePath(URI("file:///home/user/base.pkl"), "./sibling.pkl")) + .isEqualTo("/home/user/base.pkl/sibling.pkl") + } + + @Test + fun `double-dot in relative path goes up one segment`() { + assertThat(posix.resolvePath(URI("file:///home/user/base.pkl"), "../sibling.pkl")) + .isEqualTo("/home/user/sibling.pkl") + } + + @Test + fun `two double-dots in relative path go up two segments`() { + assertThat(posix.resolvePath(URI("file:///home/user/a/b.pkl"), "../../c.pkl")) + .isEqualTo("/home/user/c.pkl") + } + + @Test + fun `mixed relative path with dot-dot`() { + assertThat(posix.resolvePath(URI("file:///home/user/base.pkl"), "sub/dir/../../other.pkl")) + .isEqualTo("/home/user/base.pkl/other.pkl") + } + + @Test + fun `double-dot beyond root clamps to root`() { + assertThat(posix.resolvePath(URI("file:///file.pkl"), "../../root.pkl")) + .isEqualTo("/root.pkl") + } + + @Test + fun `root base with relative path`() { + assertThat(posix.resolvePath(URI("file:///"), "file.pkl")).isEqualTo("/file.pkl") + } + + @Test + fun `URI with percent-encoded path is decoded`() { + // URI.getPath() decodes percent-encoding + assertThat(posix.resolvePath(URI("file:///home/user%20name/base.pkl"), "file.pkl")) + .isEqualTo("/home/user name/base.pkl/file.pkl") + } + } + + @Nested + inner class WindowsTests { + @Test + fun `drive letter URI with simple relative path`() { + assertThat(windows.resolvePath(URI("file:///C:/Users/user/base.pkl"), "relative.pkl")) + .isEqualTo("""C:\Users\user\base.pkl\relative.pkl""") + } + + @Test + fun `drive letter URI with nested relative path`() { + assertThat(windows.resolvePath(URI("file:///C:/Users/user/base.pkl"), "sub\\dir\\file.pkl")) + .isEqualTo("""C:\Users\user\base.pkl\sub\dir\file.pkl""") + } + + @Test + fun `drive letter URI with forward-slash relative path is normalised to backslash`() { + assertThat(windows.resolvePath(URI("file:///C:/Users/user/base.pkl"), "sub/dir/file.pkl")) + .isEqualTo("""C:\Users\user\base.pkl\sub\dir\file.pkl""") + } + + @Test + fun `drive letter URI with directory base (trailing backslash)`() { + assertThat(windows.resolvePath(URI("file:///C:/Users/dir/"), "file.pkl")) + .isEqualTo("""C:\Users\dir\file.pkl""") + } + + @Test + fun `backslash dot in relative path is elided`() { + assertThat(windows.resolvePath(URI("file:///C:/Users/user/base.pkl"), """..\sibling.pkl""")) + .isEqualTo("""C:\Users\user\sibling.pkl""") + } + + @Test + fun `forward-slash dot-dot in relative path is normalised`() { + assertThat(windows.resolvePath(URI("file:///C:/Users/user/base.pkl"), "../sibling.pkl")) + .isEqualTo("""C:\Users\user\sibling.pkl""") + } + + @Test + fun `backslash single-dot in relative path is elided`() { + assertThat(windows.resolvePath(URI("file:///C:/Users/user/base.pkl"), """.\\sibling.pkl""")) + .isEqualTo("""C:\Users\user\base.pkl\sibling.pkl""") + } + + @Test + fun `two double-dots go up two segments`() { + // join: C:\Users\user\a\b.pkl\..\..\c.pkl -> C:\Users\user\c.pkl + assertThat(windows.resolvePath(URI("file:///C:/Users/user/a/b.pkl"), "..\\..\\c.pkl")) + .isEqualTo("""C:\Users\user\c.pkl""") + } + + @Test + fun `double-dot beyond drive root clamps to root`() { + assertThat(windows.resolvePath(URI("file:///C:/base.pkl"), "..\\..\\out.pkl")) + .isEqualTo("""C:\out.pkl""") + } + + @Test + fun `absolute path on same drive overrides base`() { + assertThat(windows.resolvePath(URI("file:///C:/Users/base.pkl"), """C:\other\path.pkl""")) + .isEqualTo("""C:\other\path.pkl""") + } + + @Test + fun `absolute path on different drive overrides base`() { + assertThat(windows.resolvePath(URI("file:///C:/Users/base.pkl"), """D:\other.pkl""")) + .isEqualTo("""D:\other.pkl""") + } + + @Test + fun `absolute path with forward slashes is accepted`() { + assertThat(windows.resolvePath(URI("file:///C:/Users/base.pkl"), "D:/other.pkl")) + .isEqualTo("""D:\other.pkl""") + } + + @Test + fun `root-relative backslash path takes drive root from base`() { + // \root.pkl is root-relative; drive letter is inherited from base + assertThat(windows.resolvePath(URI("file:///C:/Users/base.pkl"), """\root.pkl""")) + .isEqualTo("""C:\root.pkl""") + } + + @Test + fun `root-relative forward-slash path takes drive root from base`() { + assertThat(windows.resolvePath(URI("file:///C:/Users/base.pkl"), "/root.pkl")) + .isEqualTo("""C:\root.pkl""") + } + + @Test + fun `UNC URI with simple relative path`() { + assertThat(windows.resolvePath(URI("file://server/share/base.pkl"), "relative.pkl")) + .isEqualTo("""\\server\share\base.pkl\relative.pkl""") + } + + @Test + fun `UNC URI with double-dot goes up within share`() { + assertThat(windows.resolvePath(URI("file://server/share/dir/base.pkl"), """..\sibling.pkl""")) + .isEqualTo("""\\server\share\dir\sibling.pkl""") + } + + @Test + fun `UNC URI double-dot beyond share root clamps to share root`() { + assertThat(windows.resolvePath(URI("file://server/share/base.pkl"), "..\\..\\.\\out.pkl")) + .isEqualTo("""\\server\share\out.pkl""") + } + + @Test + fun `UNC URI with absolute UNC path overrides base`() { + assertThat( + windows.resolvePath(URI("file://server/share/base.pkl"), """\\other\share\file.pkl""") + ) + .isEqualTo("""\\other\share\file.pkl""") + } + + @Test + fun `absolute path containing dot is normalized`() { + assertThat(windows.resolvePath(URI("file:///C:/Users/base.pkl"), """C:\foo\.\bar.pkl""")) + .isEqualTo("""C:\foo\bar.pkl""") + } + + @Test + fun `absolute path containing double-dot is normalized`() { + assertThat(windows.resolvePath(URI("file:///C:/Users/base.pkl"), """C:\foo\..\bar.pkl""")) + .isEqualTo("""C:\bar.pkl""") + } + + @Test + fun `file URI without drive letter`() { + assertThat(windows.resolvePath(URI("file:///path/to/foo"), "bar")) + .isEqualTo("""\path\to\foo\bar""") + } + } +} diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java b/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java index a65cacbb..fb3291d4 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java @@ -326,6 +326,14 @@ public class PklPlugin implements Plugin { spec.getTestPort().convention(-1); spec.getHttpNoProxy().convention(List.of()); + + var gradleProjectDir = + project.provider( + () -> project.getLayout().getProjectDirectory().getAsFile().getAbsolutePath()); + spec.getExternalModuleReaders() + .configureEach(reader -> reader.getWorkingDir().convention(gradleProjectDir)); + spec.getExternalResourceReaders() + .configureEach(reader -> reader.getWorkingDir().convention(gradleProjectDir)); } private void configureCodeGenSpec(Project project, CodeGenSpec spec) { diff --git a/pkl-gradle/src/main/java/org/pkl/gradle/spec/ExternalReaderSpec.java b/pkl-gradle/src/main/java/org/pkl/gradle/spec/ExternalReaderSpec.java index 0d856fb8..f3a844f6 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/spec/ExternalReaderSpec.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/spec/ExternalReaderSpec.java @@ -23,6 +23,7 @@ import org.gradle.api.Named; import org.gradle.api.provider.ListProperty; import org.gradle.api.provider.Property; import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.Optional; import org.pkl.core.evaluatorSettings.PklEvaluatorSettings.ExternalReader; public abstract class ExternalReaderSpec implements Named { @@ -45,8 +46,13 @@ public abstract class ExternalReaderSpec implements Named { @Input public abstract ListProperty getArguments(); + @Input + @Optional + public abstract Property getWorkingDir(); + public ExternalReader toExternalReader() { - return new ExternalReader(getExecutable().get(), getArguments().get()); + return new ExternalReader( + getExecutable().get(), getArguments().get(), getWorkingDir().getOrNull()); } public static Map toExternalReaderMap( diff --git a/pkl-server/src/main/kotlin/org/pkl/server/Server.kt b/pkl-server/src/main/kotlin/org/pkl/server/Server.kt index 8ec95b4b..6a6cc698 100644 --- a/pkl-server/src/main/kotlin/org/pkl/server/Server.kt +++ b/pkl-server/src/main/kotlin/org/pkl/server/Server.kt @@ -304,6 +304,8 @@ class Server(private val transport: MessageTransport) : AutoCloseable { externalReaderProcesses .computeIfAbsent(evaluatorId) { ConcurrentHashMap() } .computeIfAbsent(spec) { - ExternalReaderProcess.of(PklEvaluatorSettings.ExternalReader(it.executable, it.arguments)) + ExternalReaderProcess.of( + PklEvaluatorSettings.ExternalReader(it.executable, it.arguments, it.workingDir) + ) } } diff --git a/pkl-server/src/main/kotlin/org/pkl/server/ServerMessagePackDecoder.kt b/pkl-server/src/main/kotlin/org/pkl/server/ServerMessagePackDecoder.kt index 0039cf04..5c4fc31e 100644 --- a/pkl-server/src/main/kotlin/org/pkl/server/ServerMessagePackDecoder.kt +++ b/pkl-server/src/main/kotlin/org/pkl/server/ServerMessagePackDecoder.kt @@ -149,5 +149,9 @@ class ServerMessagePackDecoder(unpacker: MessageUnpacker) : BaseMessagePackDecod } private fun unpackExternalReader(map: Map): ExternalReader = - ExternalReader(unpackString(map, "executable"), unpackStringListOrNull(map, "arguments")) + ExternalReader( + unpackString(map, "executable"), + unpackStringListOrNull(map, "arguments"), + unpackStringOrNull(map, "workingDir"), + ) } diff --git a/pkl-server/src/main/kotlin/org/pkl/server/ServerMessagePackEncoder.kt b/pkl-server/src/main/kotlin/org/pkl/server/ServerMessagePackEncoder.kt index e19f6220..e3dac1fe 100644 --- a/pkl-server/src/main/kotlin/org/pkl/server/ServerMessagePackEncoder.kt +++ b/pkl-server/src/main/kotlin/org/pkl/server/ServerMessagePackEncoder.kt @@ -99,9 +99,10 @@ class ServerMessagePackEncoder(packer: MessagePacker) : BaseMessagePackEncoder(p } private fun packExternalReader(spec: ExternalReader) { - packMapHeader(1, spec.arguments) + packMapHeader(1, spec.arguments, spec.workingDir) packKeyValue("executable", spec.executable) spec.arguments?.let { packKeyValue("arguments", it) } + spec.workingDir?.let { packKeyValue("workingDir", it) } } override fun encodeMessage(msg: Message) { diff --git a/pkl-server/src/main/kotlin/org/pkl/server/ServerMessages.kt b/pkl-server/src/main/kotlin/org/pkl/server/ServerMessages.kt index 02cbf959..9b978da6 100644 --- a/pkl-server/src/main/kotlin/org/pkl/server/ServerMessages.kt +++ b/pkl-server/src/main/kotlin/org/pkl/server/ServerMessages.kt @@ -49,7 +49,11 @@ data class CreateEvaluatorRequest( override fun requestId(): Long = requestId } -data class ExternalReader(val executable: String, val arguments: List?) +data class ExternalReader( + val executable: String, + val arguments: List?, + val workingDir: String?, +) data class Proxy(val address: URI?, val noProxy: List?) diff --git a/pkl-server/src/test/kotlin/org/pkl/server/ServerMessagePackCodecTest.kt b/pkl-server/src/test/kotlin/org/pkl/server/ServerMessagePackCodecTest.kt index c3c18092..0d4443a0 100644 --- a/pkl-server/src/test/kotlin/org/pkl/server/ServerMessagePackCodecTest.kt +++ b/pkl-server/src/test/kotlin/org/pkl/server/ServerMessagePackCodecTest.kt @@ -54,7 +54,7 @@ class ServerMessagePackCodecTest { val resourceReader2 = Messages.ResourceReaderSpec("resourceReader2", true, false) val moduleReader1 = Messages.ModuleReaderSpec("moduleReader1", true, true, true) val moduleReader2 = Messages.ModuleReaderSpec("moduleReader2", true, false, false) - val externalReader = ExternalReader("external-cmd", listOf("arg1", "arg2")) + val externalReader = ExternalReader("external-cmd", listOf("arg1", "arg2"), "/foo/bar") roundtrip( CreateEvaluatorRequest( requestId = 123, diff --git a/stdlib/EvaluatorSettings.pkl b/stdlib/EvaluatorSettings.pkl index 33c608e2..736c1dbf 100644 --- a/stdlib/EvaluatorSettings.pkl +++ b/stdlib/EvaluatorSettings.pkl @@ -19,6 +19,11 @@ @Since { version = "0.26.0" } module pkl.EvaluatorSettings +import "pkl:EvaluatorSettings" +import "pkl:platform" +// used by doc comments +import "pkl:Project" + /// The external properties available to Pkl, read using the `prop:` scheme. externalProperties: Mapping? @@ -118,6 +123,89 @@ externalResourceReaders: Mapping? @Since { version = "0.30.0" } traceMode: ("compact" | "pretty")? +/// These evaluator settings, whose settings are resolved against [enclosingUri] using OS rules for +/// [os]. +@Since { version = "0.32.0" } +function resolveForOs(enclosingUri: Uri, os: platform.OperatingSystem): EvaluatorSettings = (module) { + local forWindows = os.name == "Windows" + + when (module.modulePath != null) { + modulePath = new { + for (path in module.modulePath!!) { + resolvePath(enclosingUri, path, forWindows) + } + } + } + + when (module.moduleCacheDir != null) { + moduleCacheDir = resolvePath(enclosingUri, module.moduleCacheDir!!, forWindows) + } + + when (module.rootDir != null) { + rootDir = resolvePath(enclosingUri, module.rootDir!!, forWindows) + } + + when (module.externalResourceReaders != null) { + externalResourceReaders { + [[true]] { + executable = resolveExecutable(enclosingUri, super.executable, forWindows) + workingDir = resolvePath(enclosingUri, super.workingDir ?? "./", forWindows) + } + } + } + + when (module.externalModuleReaders != null) { + externalModuleReaders { + for (readerName, reader in module.externalModuleReaders!!) { + [readerName] { + executable = resolveExecutable(enclosingUri, reader.executable, forWindows) + workingDir = + if (reader.workingDir == null) + resolvePath(enclosingUri, "./", forWindows) + else + resolvePath(enclosingUri, reader.workingDir!!, forWindows) + } + } + } + } +} + +/// These evaluator settings, whose settings are resolved to URIs against [enclosingUri]. +/// +/// The following settings are resolved: +/// +/// * [modulePath] +/// * [rootDir] +/// * [moduleCacheDir] +/// * [ExternalReader.executable] +/// * [ExternalReader.workingDir] +/// +/// Returns file paths based on the host OS filesystem. +/// +/// If [ExternalReader.workingDir] is `null`, it resolves to the `./` path off of +/// [enclosingUri]. +/// +/// On POSIX-based systems (like macOS and linux), this resolves to paths such as `/path/to/dir`. +/// +/// On Windows, this resolves to drive letter paths such as `C:\path\to\dir`, or UNC paths such as +/// `\\network\share\path\to\dir`. +@Since { version = "0.32.0" } +function resolve(enclosingUri: Uri): EvaluatorSettings = + resolveForOs( + enclosingUri, + platform.current.operatingSystem, + ) + +local function resolveExecutable(base: String, path: String, forWindows: Boolean) = + let (hasSep = if (forWindows) path.contains(Regex(#"[/\\]"#)) else path.contains("/")) + if (hasSep) resolvePath(base, path, forWindows) else path + +external local function resolvePath( + baseUri: String(startsWith("file:/")), + path: String, + forWindows: Boolean, +): String + local const hostnameRegex = Regex(#"https?://([^/?#]*)"#) local const hasNonEmptyHostname = (it: String) -> @@ -268,16 +356,25 @@ class ExternalReader { /// The external reader executable. /// /// Will be spawned with the same environment variables and working directory as the Pkl process. - /// Executable is resolved according to the operating system's process spawning rules. - /// On macOS, Linux, and Windows platforms, this may be: /// - /// * An absolute path - /// * A relative path (to the current working directory) - /// * The name of the executable, to be resolved against the `PATH` environment variable + /// This may be: + /// + /// * An absolute or relative path. + /// * The name of the executable, to be resolved against the `PATH` environment variable. + /// + /// When declared inside a [Project], relative paths are resolved against + /// [Project.projectFileUri]. executable: String /// Additional command line arguments passed to the external reader process. arguments: Listing? + + /// The working directory used to spawn [executable]. + /// + /// When declared inside a [Project], relative paths are resolved against + /// [Project.projectFileUri]. + @Since { version = "0.32.0" } + workingDir: String? } local typealias ReservedHttpHeaderName = diff --git a/stdlib/Project.pkl b/stdlib/Project.pkl index 571a5e50..8f68ab1f 100644 --- a/stdlib/Project.pkl +++ b/stdlib/Project.pkl @@ -191,23 +191,32 @@ local isFileBasedProject = projectFileUri.startsWith("file:") /// Evaluator settings do not get published as part of a package. /// It is not possible for a package dependency to influence the evaluator settings of a project. /// -/// The following values can only be set if this is a file-based project. +/// The following values can only be set if this is a file-based project: /// /// - [modulePath][EvaluatorSettings.modulePath] /// - [rootDir][EvaluatorSettings.rootDir] /// - [moduleCacheDir][EvaluatorSettings.moduleCacheDir] -/// -/// For each of these, relative paths are resolved against the project's enclosing directory. +/// - [externalModuleReaders][EvaluatorSettings.externalModuleReaders] +/// - [externalResourceReaders][EvaluatorSettings.externalResourceReaders] evaluatorSettings: EvaluatorSettingsModule( (modulePath != null).implies(isFileBasedProject), (rootDir != null).implies(isFileBasedProject), (moduleCacheDir != null).implies(isFileBasedProject), + (externalModuleReaders != null).implies(isFileBasedProject), + (externalResourceReaders != null).implies(isFileBasedProject), ) +/// The [evaluatorSettings] resolved against the project dir. +@Since { version = "0.32.0" } +fixed resolvedEvaluatorSettings: EvaluatorSettingsModule = evaluatorSettings.resolve(projectFileUri) + /// The URI of the PklProject file. /// -/// This value is used to resolve relative paths when importing another local project as a -/// dependency. +/// This value is used to: +/// +/// * Resolve relative paths when importing another local project as a +/// dependency. +/// * Resolve relative paths declared within [evaluatorSettings]. projectFileUri: String = reflect.Module(module).uri /// Instantiates a project definition within the enclosing module. @@ -229,7 +238,8 @@ local const hasVersion = (it: Uri) -> if (versionSep == -1) false else - let (version = it.drop(versionSep + 1)) semver.parseOrNull(version) != null + let (version = it.drop(versionSep + 1)) + semver.parseOrNull(version) != null typealias PackageUri = Uri(startsWith("package:"), hasVersion)