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,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) {}
@@ -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<PathElement> {
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<PathElement> {
throw NotImplementedError()
}
}
fun main() {
val client = ExternalReaderClient(listOf(ModuleReader), listOf(ResourceReader), stdioTransport)
client.run()
}
@@ -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.");
@@ -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<? super String, ? super String, Path> 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<String>) pSettings.get("allowedModules");
var allowedModules =
@@ -82,13 +77,10 @@ public record PklEvaluatorSettings(
: allowedResourcesStrs.stream().map(Pattern::compile).toList();
var modulePathStrs = (List<String>) 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<String, Value>) pSettings.get("externalModuleReaders");
var externalModuleReaders =
@@ -219,13 +211,16 @@ public record PklEvaluatorSettings(
}
}
public record ExternalReader(String executable, @Nullable List<String> arguments) {
public record ExternalReader(
String executable, @Nullable List<String> 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<String>) 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();
}
@@ -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();
@@ -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<URI> 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<String>) 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<URI> 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;
}
@@ -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 =
@@ -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);
}
}
}
@@ -0,0 +1,6 @@
@NullMarked
@PklName("EvaluatorSettings")
package org.pkl.core.stdlib.evaluatorsettings;
import org.jspecify.annotations.NullMarked;
import org.pkl.core.stdlib.PklName;
@@ -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<String> 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;
}
}
@@ -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 '/';
}
}
}
@@ -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;
}
}
@@ -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}:`.
@@ -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""")
}
}
}