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.
This commit is contained in:
Daniel Chao
2026-06-02 11:02:52 -07:00
committed by GitHub
parent c9f3823952
commit 035ef0a789
51 changed files with 1880 additions and 92 deletions
@@ -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
}
}
@@ -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
}
}
@@ -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)
}
}
@@ -0,0 +1,7 @@
amends "pkl:Project"
projectFileUri = "file:///not a valid uri"
evaluatorSettings {
moduleCacheDir = "foo"
}
@@ -0,0 +1,11 @@
amends "pkl:Project"
projectFileUri = "modulepath:/foo/bar/PklProject"
evaluatorSettings {
externalModuleReaders {
["foo"] {
executable = "foo/bar"
}
}
}
@@ -0,0 +1,5 @@
amends "pkl:Project"
evaluatorSettings {
rootDir = "."
}
@@ -0,0 +1 @@
res = read("file:///file.txt")
@@ -0,0 +1 @@
res = read("badRead.pkl").text
@@ -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"
}
}
@@ -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"#
}
}
@@ -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"
}
}
}
}
}
@@ -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)
@@ -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)
@@ -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)
@@ -0,0 +1,4 @@
res = """
res = read("file:///file.txt")
"""
@@ -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()
}
}
@@ -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")
}
}
@@ -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""")
}
}
}