From ec92a9aeb131c7dce4e16e42322c8283847b7e30 Mon Sep 17 00:00:00 2001 From: Florin Ungur Date: Tue, 9 Jun 2026 22:12:00 +0100 Subject: [PATCH 1/2] Use XDG base directories and Known Folders on Windows This changes logic that previously read/wrote from `~/.pkl` to use XDG base directories (all OSes), and Known Folders locations on Windows. For example, Pkl will look for `settings.pkl` in: 1. `$XDG_CONFIG_HOME/pkl/settings.pkl` 2. `%APPDATA/pkl/settings.pkl` 3. `~/.pkl/settings.pkl` 4. Path pkl/settings/pkl within `$XDG_CONFIG_DIRS` 5. `/etc/xdg/pkl/settings.pkl` The settings and cacerts dirs will still read from the legacy `~/.pkl/` directory if not found in the new locations. --- .../src/main/kotlin/NativeImageBuild.kt | 3 + .../pages/02_filling_out_a_template.adoc | 2 +- docs/modules/pkl-cli/pages/index.adoc | 27 +- .../pkl-cli/partials/cli-common-options.adoc | 10 +- .../partials/gradle-common-properties.adoc | 4 +- .../partials/gradle-modules-properties.adoc | 2 +- docs/modules/release-notes/pages/0.33.adoc | 38 ++- .../release-notes/pages/changelog.adoc | 5 + .../src/main/kotlin/org/pkl/cli/repl/Repl.kt | 7 +- .../org/pkl/commons/cli/CliBaseOptions.kt | 10 +- .../kotlin/org/pkl/commons/cli/CliCommand.kt | 6 +- .../org/pkl/commons/cli/CliCommandTest.kt | 2 +- .../java/org/pkl/core/EvaluatorBuilder.java | 2 +- .../org/pkl/core/runtime/ModuleCache.java | 2 +- .../org/pkl/core/settings/PklSettings.java | 40 ++- .../org/pkl/core/util/BaseDirectories.java | 33 +++ .../java/org/pkl/core/util/BaseDirectory.java | 185 ++++++++++++++ .../main/java/org/pkl/core/util/IoUtils.java | 27 +- .../org/pkl/core/settings/PklSettingsTest.kt | 8 +- .../org/pkl/core/util/BaseDirectoryTest.kt | 231 ++++++++++++++++++ .../kotlin/org/pkl/core/util/IoUtilsTest.kt | 2 +- .../org/pkl/executor/ExecutorOptions.java | 29 ++- .../org/pkl/executor/ExecutorOptionsTest.kt | 69 ++++++ .../main/java/org/pkl/gradle/PklPlugin.java | 5 +- stdlib/settings.pkl | 10 +- 25 files changed, 719 insertions(+), 40 deletions(-) create mode 100644 pkl-core/src/main/java/org/pkl/core/util/BaseDirectories.java create mode 100644 pkl-core/src/main/java/org/pkl/core/util/BaseDirectory.java create mode 100644 pkl-core/src/test/kotlin/org/pkl/core/util/BaseDirectoryTest.kt create mode 100644 pkl-executor/src/test/kotlin/org/pkl/executor/ExecutorOptionsTest.kt diff --git a/build-logic/src/main/kotlin/NativeImageBuild.kt b/build-logic/src/main/kotlin/NativeImageBuild.kt index ed31555b0..8ecd7404e 100644 --- a/build-logic/src/main/kotlin/NativeImageBuild.kt +++ b/build-logic/src/main/kotlin/NativeImageBuild.kt @@ -210,6 +210,9 @@ abstract class NativeImageBuild : DefaultTask() { add("--initialize-at-build-time=") // needed for messagepack-java (see https://github.com/msgpack/msgpack-java/issues/600) add("--initialize-at-run-time=org.msgpack.core.buffer.DirectBufferAccess") + // prevent storing `homeDir` in native image + add("--initialize-at-run-time=org.pkl.core.util.BaseDirectory") + add("--initialize-at-run-time=org.pkl.core.util.BaseDirectories") // needed for jline-terminal-jni add("--initialize-at-run-time=org.jline.nativ,org.jline.terminal.impl.jni") add("--no-fallback") diff --git a/docs/modules/language-tutorial/pages/02_filling_out_a_template.adoc b/docs/modules/language-tutorial/pages/02_filling_out_a_template.adoc index 8e1440535..ccc4aeb89 100644 --- a/docs/modules/language-tutorial/pages/02_filling_out_a_template.adoc +++ b/docs/modules/language-tutorial/pages/02_filling_out_a_template.adoc @@ -360,7 +360,7 @@ at pkl.base#Module.output.text (https://github.com/apple/pkl/blob/e4d8c882d/stdl <6> What Pkl evaluated to discover the error. When Pkl prints source locations, it also prints clickable links for easy access. -For local files, it generates a link for your development environment (https://pkl-lang.org/main/current/pkl-cli/index.html#settings-file[configurable in `+~/.pkl/settings.pkl+`]). +For local files, it generates a link for your development environment (https://pkl-lang.org/main/current/pkl-cli/index.html#settings-file[configurable in `+~/.config/pkl/settings.pkl+`]). For packages imported from elsewhere, if available, Pkl produces `https://` links to their repository. Pkl complains about a _type constraint_. diff --git a/docs/modules/pkl-cli/pages/index.adoc b/docs/modules/pkl-cli/pages/index.adoc index 0f5a53e2f..471f7fbcb 100644 --- a/docs/modules/pkl-cli/pages/index.adoc +++ b/docs/modules/pkl-cli/pages/index.adoc @@ -1377,14 +1377,24 @@ it works as follows: The Pkl settings file allows to customize the CLI experience. A settings file is a Pkl module amending the `pkl.settings` standard library module. -Its default location is `~/.pkl/settings.pkl`. + +Unless configured explicitly, Pkl will look in the following locations: + +. `$XDG_CONFIG_HOME/pkl/settings.pkl` +. `%APPDATA%/pkl/settings.pkl` (on Windows only) +. `~/.config/pkl/settings.pkl` +. Subdirectory `pkl/settings.pkl` within one of the paths described by `$XDG_CONFIG_DIRS` +. `/etc/xdg/pkl/settings.pkl` +. `~/.pkl/settings.pkl` (legacy location used by Pkl 0.32 and lower) + To use a different settings file, set the `--settings` command line option, for example `--settings mysettings.pkl`. To enforce default settings, use `--settings pkl:settings`. + The settings file is also honored by (and configurable through) the Gradle plugin and `CliEvaluator` API. Here is a typical settings file: -.~/.pkl/settings.pkl +.~/.config/pkl/settings.pkl [source%parsed,{pkl}] ---- amends "pkl:settings" // <1> @@ -1406,10 +1416,19 @@ When making TLS requests, Pkl comes with its own set of {uri-certificates}[CA ce These certificates can be overridden via either of the two options: - Set them directly via the CLI option `--ca-certificates `. -- Add them to a directory at path `~/.pkl/cacerts/`. +- Add them to a user directory. + +If CA certificates are not explicitly configured, Pkl will look in the following locations: + +. `$XDG_CONFIG_HOME/pkl/cacerts` +. `%APPDATA%/pkl/cacerts` (on Windows only) +. `~/.config/pkl/cacerts` +. Subdirectory `pkl/cacerts` within one of the paths described by `$XDG_CONFIG_DIRS` +. `/etc/xdg/pkl/cacerts` +. `~/.pkl/cacerts` (legacy location used by Pkl 0.32 and lower) Both these options will *replace* the default CA certificates bundled with Pkl. + -The CLI option takes precedence over the certificates in `~/.pkl/cacerts/`. + +The CLI option takes precedence over the certificates in the cacerts directory. + Certificates need to be X.509 certificates in PEM format. [[http-proxy]] diff --git a/docs/modules/pkl-cli/partials/cli-common-options.adoc b/docs/modules/pkl-cli/partials/cli-common-options.adoc index 13d94bcd0..807004c45 100644 --- a/docs/modules/pkl-cli/partials/cli-common-options.adoc +++ b/docs/modules/pkl-cli/partials/cli-common-options.adoc @@ -36,9 +36,15 @@ Possible values: .--cache-dir [%collapsible] ==== -Default: `~/.pkl/cache` + Example: `/path/to/module/cache/` + + The cache directory for storing packages. + +If unset, defaults to the following locations: + +. `$XDG_CACHE_HOME/pkl` +. `$LOCALAPPDATA/pkl/cache` (on Windows only) +. `~/.cache/pkl` (if `$XDG_CACHE_HOME` and `$LOCALAPPDATA` are both unset) ==== .--no-cache @@ -97,7 +103,7 @@ Any symlinks are resolved before this check is performed. Default: (none) + Example: `mySettings.pkl` + File path of the Pkl settings file to use. -If not set, `~/.pkl/settings.pkl` or defaults specified in the `pkl.settings` standard library module are used. +If not set, `~/.config/pkl/settings.pkl` on Unix or `%APPDATA%/pkl/settings.pkl` on Windows (or the legacy `~/.pkl/settings.pkl`), or defaults specified in the `pkl.settings` standard library module are used. ==== .-t, --timeout diff --git a/docs/modules/pkl-gradle/partials/gradle-common-properties.adoc b/docs/modules/pkl-gradle/partials/gradle-common-properties.adoc index 10fad87cb..e62e874cc 100644 --- a/docs/modules/pkl-gradle/partials/gradle-common-properties.adoc +++ b/docs/modules/pkl-gradle/partials/gradle-common-properties.adoc @@ -64,7 +64,9 @@ Default: `null` + Example 1: `moduleCacheDir = layout.buildDirectory.dir("pkl-module-cache")` + Example 2: `moduleCacheDir.fileValue file("/absolute/path/to/cache")` + The cache directory for storing packages. -If `null`, defaults to `~/.pkl/cache`. +If `null`, defaults to `~/.cache/pkl` on Unix or `%LOCALAPPDATA%/pkl/cache` on Windows. + +This setting can also be configured using the `$XDG_CACHE_HOME` environment variable. ==== .color: Property diff --git a/docs/modules/pkl-gradle/partials/gradle-modules-properties.adoc b/docs/modules/pkl-gradle/partials/gradle-modules-properties.adoc index 4f145793d..94b7e11e5 100644 --- a/docs/modules/pkl-gradle/partials/gradle-modules-properties.adoc +++ b/docs/modules/pkl-gradle/partials/gradle-modules-properties.adoc @@ -69,7 +69,7 @@ Example: `settingsModule = layout.projectDirectory.file("mySettings.pkl")` + The Pkl settings module to use. This property accepts the same input types as the `sourceModules` property. -If `null`, `~/.pkl/settings.pkl` or defaults specified in the `pkl.settings` standard library module are used. +If `null`, `~/.config/pkl/settings.pkl` on Unix or `%APPDATA%/pkl/settings.pkl` on Windows (or the legacy `~/.pkl/settings.pkl`), or defaults specified in the `pkl.settings` standard library module are used. ==== include::../partials/gradle-common-properties.adoc[] diff --git a/docs/modules/release-notes/pages/0.33.adoc b/docs/modules/release-notes/pages/0.33.adoc index c662a7a1b..7a5436dc0 100644 --- a/docs/modules/release-notes/pages/0.33.adoc +++ b/docs/modules/release-notes/pages/0.33.adoc @@ -13,7 +13,43 @@ include::partial$intro.adoc[] == Noteworthy [small]#🎶# -=== XXX +=== CLI Changes + +==== Default file locations + +For new setups, the CLI no longer stores anything under `~/.pkl` (https://github.com/apple/pkl/pull/1674[#1674]). + +It uses XDG-style locations on Unix and Known Folder locations on Windows: + +[cols="1,2,2,2",options="header"] +|=== +| Concern | Unix (Linux/macOS) | Windows | Legacy fallback + +| Package cache +| `~/.cache/pkl` +| `$LOCALAPPDATA/pkl/Cache` +| none + +| Settings file +| `~/.config/pkl/settings.pkl` +| `$APPDATA/pkl/settings.pkl` +| `~/.pkl/settings.pkl` + +| CA certificates +| `~/.config/pkl/cacerts` +| `$APPDATA/pkl/cacerts` +| `~/.pkl/cacerts` + +| REPL history +| `~/.local/state/pkl/repl-history` +| `$LOCALAPPDATA/pkl/repl-history` +| none +|=== + +On every OS, these locations can be overriden with XDG-style env vars. +For example, setting `XDG_CACHE_HOME` will configure the cache directory. + +Note that the existing `~/.pkl/cache` directory is ignored, so Pkl will download packages to populate its cache if configured to do so. == Breaking Changes [small]#💔# diff --git a/docs/modules/release-notes/pages/changelog.adoc b/docs/modules/release-notes/pages/changelog.adoc index f91124d14..6134c83f8 100644 --- a/docs/modules/release-notes/pages/changelog.adoc +++ b/docs/modules/release-notes/pages/changelog.adoc @@ -27,6 +27,11 @@ Thank you to all the contributors for this release! xref:0.32.adoc[Release Notes] +=== Changes + +* Default the CLI's package cache, settings file, CA certificates directory, and REPL history to XDG-style locations instead of `~/.pkl`: `~/.cache/pkl`, `~/.config/pkl/settings.pkl`, `~/.config/pkl/cacerts`, and `~/.local/state/pkl/repl-history` (pr:https://github.com/apple/pkl/pull/1674[]). +The legacy `~/.pkl` locations are still used when they already exist, so existing setups keep working without migration. + [[release-0.31.1]] == 0.31.1 (2026-03-26) diff --git a/pkl-cli/src/main/kotlin/org/pkl/cli/repl/Repl.kt b/pkl-cli/src/main/kotlin/org/pkl/cli/repl/Repl.kt index c68ccc8d5..cc9241744 100644 --- a/pkl-cli/src/main/kotlin/org/pkl/cli/repl/Repl.kt +++ b/pkl-cli/src/main/kotlin/org/pkl/cli/repl/Repl.kt @@ -63,7 +63,10 @@ internal class Repl(workingDir: Path, private val server: ReplServer, private va } completer(AggregateCompleter(CommandCompleter, FileCompleter(workingDir))) option(Option.DISABLE_EVENT_EXPANSION, true) - variable(LineReader.HISTORY_FILE, (IoUtils.getPklHomeDir().resolve("repl-history"))) + // will be null if `$HOME` is not set. if so, don't bother writing repl history. + IoUtils.getReplHistoryFile()?.let { historyFile -> + variable(LineReader.HISTORY_FILE, historyFile) + } } .build() @@ -80,7 +83,7 @@ internal class Repl(workingDir: Path, private val server: ReplServer, private va fun run() { // JLine 2 history file is incompatible with JLine 3 - IoUtils.getPklHomeDir().resolve("repl-history.bin").deleteIfExists() + IoUtils.getLegacyPklHomeDir().resolve("repl-history.bin").deleteIfExists() println(ReplMessages.welcome) println() 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 0a7274feb..a1a3c18be 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 @@ -77,8 +77,9 @@ data class CliBaseOptions( /** * The Pkl settings file to use. A settings file is a Pkl module amending the `pkl.settings` - * standard library module. If `null`, `~/.pkl/settings.pkl` (if present) or the defaults - * specified in the `pkl:settings` standard library module are used. + * standard library module. If `null`, `~/.config/pkl/settings.pkl` (falling back to the legacy + * `~/.pkl/settings.pkl`), or the defaults specified in the `pkl:settings` standard library + * module, are used. */ private val settings: URI? = null, @@ -130,8 +131,9 @@ data class CliBaseOptions( * The given files must contain [X.509](https://en.wikipedia.org/wiki/X.509) certificates in PEM * format. * - * If [caCertificates] is the empty list, the certificate files in `~/.pkl/cacerts/` are used. If - * `~/.pkl/cacerts/` does not exist or is empty, Pkl's built-in CA certificates are used. + * If [caCertificates] is the empty list, the certificate files in `~/.config/pkl/cacerts/` (or + * the legacy `~/.pkl/cacerts/`) are used. If that directory does not exist or is empty, Pkl's + * built-in CA certificates are used. */ val caCertificates: List = listOf(), 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 fb587f3e3..19c856bde 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 @@ -69,7 +69,7 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) { if (cliOptions.normalizedSettingsModule != null) { PklSettings.load(ModuleSource.uri(cliOptions.normalizedSettingsModule)) } else { - PklSettings.loadFromPklHomeDir() + PklSettings.loadFromSystem() } } catch (e: PklException) { // do not use `errorRenderer` because it depends on `settings` @@ -146,7 +146,7 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) { ?: evaluatorSettings?.let { settings -> if (settings.noCache == true) null else settings.moduleCacheDir } - ?: IoUtils.getDefaultModuleCacheDir() + ?: IoUtils.getSystemModuleCacheDir() } protected val modulePath: List by lazy { @@ -215,7 +215,7 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) { } private fun HttpClient.Builder.addDefaultCliCertificates() { - val caCertsDir = IoUtils.getPklHomeDir().resolve("cacerts") + val caCertsDir = IoUtils.getSystemCaCertsDir() var certsAdded = false if (Files.isDirectory(caCertsDir)) { Files.list(caCertsDir) 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 d6c1ab755..cc07879a9 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 @@ -144,7 +144,7 @@ class CliCommandTest { assertThat(cliTest.myRootDir).isNull() assertThat(builder.environmentVariables).isEqualTo(System.getenv()) assertThat(builder.externalProperties).isEmpty() - assertThat(builder.moduleCacheDir).isEqualTo(IoUtils.getDefaultModuleCacheDir()) + assertThat(builder.moduleCacheDir).isEqualTo(IoUtils.getSystemModuleCacheDir()) assertThat(cliTest.myModulePath).isEmpty() assertThat(builder.color).isFalse assertThat(cliTest.myProxyAddress).isNull() 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 3f1636164..8034da316 100644 --- a/pkl-core/src/main/java/org/pkl/core/EvaluatorBuilder.java +++ b/pkl-core/src/main/java/org/pkl/core/EvaluatorBuilder.java @@ -58,7 +58,7 @@ public final class EvaluatorBuilder { private java.time.@Nullable Duration timeout; - private @Nullable Path moduleCacheDir = IoUtils.getDefaultModuleCacheDir(); + private @Nullable Path moduleCacheDir = IoUtils.getSystemModuleCacheDir(); private @Nullable String outputFormat; diff --git a/pkl-core/src/main/java/org/pkl/core/runtime/ModuleCache.java b/pkl-core/src/main/java/org/pkl/core/runtime/ModuleCache.java index 3467a381c..13e8971a8 100644 --- a/pkl-core/src/main/java/org/pkl/core/runtime/ModuleCache.java +++ b/pkl-core/src/main/java/org/pkl/core/runtime/ModuleCache.java @@ -113,7 +113,7 @@ public synchronized VmTyped getOrLoad( case "semver": return SemVerModule.getModule(); case "settings": - // always needed if ~/.pkl/settings.pkl is present + // always needed if ~/.config/pkl/settings.pkl is present return SettingsModule.getModule(); case "test": return TestModule.getModule(); diff --git a/pkl-core/src/main/java/org/pkl/core/settings/PklSettings.java b/pkl-core/src/main/java/org/pkl/core/settings/PklSettings.java index 8b54a1a55..ea45f607f 100644 --- a/pkl-core/src/main/java/org/pkl/core/settings/PklSettings.java +++ b/pkl-core/src/main/java/org/pkl/core/settings/PklSettings.java @@ -33,8 +33,10 @@ * {@literal pkl.settings} standard library module. To load a settings file, use one of the static * {@code load} methods. */ -// keep in sync with stdlib/settings.pkl public record PklSettings(Editor editor, PklEvaluatorSettings.@Nullable Http http) { + // keep in sync with stdlib/settings.pkl + public static final PklSettings defaultInstance = new PklSettings(Editor.SYSTEM, null); + private static final List ALLOWED_MODULES = List.of(Pattern.compile("pkl:"), Pattern.compile("file:")); @@ -42,16 +44,42 @@ public record PklSettings(Editor editor, PklEvaluatorSettings.@Nullable Http htt List.of(Pattern.compile("env:"), Pattern.compile("file:")); /** - * Loads the user settings file ({@literal ~/.pkl/settings.pkl}). If this file does not exist, - * returns default settings defined by module {@literal pkl.settings}. + * Loads the user settings file. + * + *

Prefers XDG_CONFIG_HOME (e.g. {@code ~/.config/pkl/settings.pkl}), falling back to the + * legacy {@code ~/.pkl/settings.pkl}. + * + *

If neither file exists, returns default settings defined by module {@code pkl.settings}. + */ + public static PklSettings loadFromSystem() throws VmEvalException { + var file = IoUtils.getSystemSettingsFile(); + if (Files.exists(file)) { + return load(ModuleSource.path(file)); + } + return defaultInstance; + } + + /** + * Loads the user settings file. + * + * @deprecated As of 0.33.0, renamed to {@link #loadFromSystem()}, which now prefers {@code + * ~/.config/pkl/settings.pkl} over the legacy {@code ~/.pkl/settings.pkl}. */ + @Deprecated(since = "0.33.0", forRemoval = true) public static PklSettings loadFromPklHomeDir() throws VmEvalException { - return loadFromPklHomeDir(IoUtils.getPklHomeDir()); + var candidate = IoUtils.getLegacyPklHomeDir().resolve("settings.pkl"); + if (Files.exists(candidate)) { + return load(ModuleSource.path(candidate)); + } + return defaultInstance; } /** For testing only. */ - static PklSettings loadFromPklHomeDir(Path pklHomeDir) throws VmEvalException { - var path = pklHomeDir.resolve("settings.pkl"); + static PklSettings loadFromSettingsDir(Path settingsDir) throws VmEvalException { + return loadFromSettingsFile(settingsDir.resolve("settings.pkl")); + } + + private static PklSettings loadFromSettingsFile(Path path) throws VmEvalException { return Files.exists(path) ? load(ModuleSource.path(path)) : new PklSettings(Editor.SYSTEM, null); diff --git a/pkl-core/src/main/java/org/pkl/core/util/BaseDirectories.java b/pkl-core/src/main/java/org/pkl/core/util/BaseDirectories.java new file mode 100644 index 000000000..3396b9c23 --- /dev/null +++ b/pkl-core/src/main/java/org/pkl/core/util/BaseDirectories.java @@ -0,0 +1,33 @@ +/* + * 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; + +public final class BaseDirectories { + public static final BaseDirectory config = + new BaseDirectory( + "XDG_CONFIG_HOME", + "XDG_CONFIG_DIRS", + "APPDATA", + null, + ".config", + new String[] {"/etc/xdg"}); + + public static final BaseDirectory cache = + new BaseDirectory("XDG_CACHE_HOME", null, "LOCALAPPDATA", "Cache", ".cache", null); + + public static final BaseDirectory state = + new BaseDirectory("XDG_STATE_HOME", null, "LOCALAPPDATA", null, ".local/state", null); +} diff --git a/pkl-core/src/main/java/org/pkl/core/util/BaseDirectory.java b/pkl-core/src/main/java/org/pkl/core/util/BaseDirectory.java new file mode 100644 index 000000000..ab1489c84 --- /dev/null +++ b/pkl-core/src/main/java/org/pkl/core/util/BaseDirectory.java @@ -0,0 +1,185 @@ +/* + * 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.io.File; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Map; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Utility library for accessing files in base directories. + * + *

On macOS and Linux, follows the XDG base directory specification. + * + *

On Windows, follows {@code $APPDATA} and {@code $LOCALAPPDATA} conventions, but can be + * overridden by {@code XDG} style env vars. + */ +public record BaseDirectory( + String xdgHomeEnvVar, + @Nullable String xdgDirsEnvVar, + String windowsEnvVar, + @Nullable String windowsSubpath, + String homeDefault, + String @Nullable [] dirsDefault) { + + /** Returns the first file within the search hierarchy that exists. */ + public @Nullable Path firstExistingPath(String subpath) { + return firstExistingPath(subpath, System.getenv(), IoUtils.isWindows()); + } + + /** Returns the subpath within the {@code home} of this base directory type. */ + public @Nullable Path resolveHome(String subpath) { + var homeDir = getHome(System.getenv(), IoUtils.isWindows()); + if (homeDir != null) { + return homeDir.resolve(subpath); + } + return null; + } + + // for testing only + @Nullable Path firstExistingPath(String subpath, Map envVars, boolean isWindows) { + var home = getHome(envVars, isWindows); + Path candidate; + if (home != null) { + candidate = home.resolve(subpath); + if (Files.exists(candidate)) { + return candidate; + } + } + var dirs = getDirs(envVars); + if (dirs != null) { + for (var dir : dirs) { + candidate = dir.resolve(subpath); + if (Files.exists(candidate)) { + return candidate; + } + } + } + return null; + } + + // possibly null if $HOME is not set. + private static final @Nullable Path homeDir; + + static { + var userHome = System.getProperty("user.home"); + if (userHome != null) { + homeDir = Path.of(userHome); + } else { + homeDir = null; + } + } + + private static Path @Nullable [] getConfiguredPaths(String envVar, Map envVars) { + try { + var value = envVars.get(envVar); + if (value == null || value.isEmpty()) { + return null; + } + var strs = value.split(File.pathSeparator); + var ret = new Path[strs.length]; + for (var i = 0; i < strs.length; i++) { + var dir = strs[i]; + if (dir.isEmpty()) { + continue; + } + ret[i] = Path.of(dir).resolve("pkl"); + } + return Arrays.stream(ret).filter(Objects::nonNull).toArray(Path[]::new); + } catch (InvalidPathException e) { + // can't use org.pkl.core.Logger here; logger isn't yet available + // (can't call `VmContext.get()`). + // do the next best thing and just write to stderr. + System.err.println( + "[org.pkl.core.util.BaseDirectory] '" + + envVar + + "' env var contains an invalid path: " + + e.getMessage()); + return null; + } + } + + private static @Nullable Path getConfiguredPath(String envVar, Map envVars) { + try { + var value = envVars.get(envVar); + if (value == null || value.isEmpty()) { + return null; + } + return Path.of(value); + } catch (InvalidPathException e) { + // can't use org.pkl.core.Logger here; logger isn't yet available + // (can't call `VmContext.get()`). + // do the next best thing and just write to stderr. + System.err.println( + "[org.pkl.core.util.BaseDirectory] '" + + envVar + + "' env var is an invalid path: " + + e.getMessage()); + return null; + } + } + + @Nullable Path getHome(Map envVars, boolean isWindows) { + var configuredHome = getConfiguredPath(xdgHomeEnvVar, envVars); + if (configuredHome != null) { + return configuredHome.resolve("pkl"); + } + if (isWindows) { + configuredHome = getConfiguredPath(windowsEnvVar, envVars); + if (configuredHome != null) { + configuredHome = configuredHome.resolve("pkl"); + if (windowsSubpath != null) { + configuredHome = configuredHome.resolve(windowsSubpath); + } + return configuredHome; + } + } + if (homeDir != null) { + return homeDir.resolve(homeDefault).resolve("pkl"); + } + return null; + } + + Path @Nullable [] getDirs(Map envVars) { + if (xdgDirsEnvVar != null) { + var paths = getConfiguredPaths(xdgDirsEnvVar, envVars); + if (paths != null) { + return paths; + } + } + if (dirsDefault == null) { + return null; + } + var ret = new Path[dirsDefault.length]; + for (var i = 0; i < dirsDefault.length; i++) { + var dir = dirsDefault[i]; + if (!dir.startsWith("/")) { + if (homeDir == null) { + return null; + } + ret[i] = homeDir.resolve(dir).resolve("pkl"); + } else { + ret[i] = Path.of(dir).resolve("pkl"); + } + } + return ret; + } +} 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 c784a0780..d1524f9f9 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 @@ -49,7 +49,6 @@ import org.pkl.core.util.GlobResolver.InvalidGlobPatternException; public final class IoUtils { - // Don't match paths like `C:\`, which are drive letters on Windows. private static final Pattern uriLike = Pattern.compile("[\\w+.-]+:[^\\\\].*"); @@ -228,13 +227,33 @@ public static Path getCurrentWorkingDir() { } // not stored to avoid build-time initialization by native-image - public static Path getPklHomeDir() { + public static Path getLegacyPklHomeDir() { return Path.of(System.getProperty("user.home"), ".pkl"); } + public static @Nullable Path getSystemModuleCacheDir() { + return BaseDirectories.cache.resolveHome(""); + } + + public static Path getSystemSettingsFile() { + var path = BaseDirectories.config.firstExistingPath("settings.pkl"); + if (path == null) { + return getLegacyPklHomeDir().resolve("settings.pkl"); + } + return path; + } + + public static Path getSystemCaCertsDir() { + var path = BaseDirectories.config.firstExistingPath("cacerts"); + if (path == null) { + return getLegacyPklHomeDir().resolve("cacerts"); + } + return path; + } + // not stored to avoid build-time initialization by native-image - public static Path getDefaultModuleCacheDir() { - return getPklHomeDir().resolve("cache"); + public static @Nullable Path getReplHistoryFile() { + return BaseDirectories.state.resolveHome("repl-history"); } // not stored to avoid build-time initialization by native-image diff --git a/pkl-core/src/test/kotlin/org/pkl/core/settings/PklSettingsTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/settings/PklSettingsTest.kt index a4094ac45..ce71e2746 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/settings/PklSettingsTest.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/settings/PklSettingsTest.kt @@ -42,7 +42,7 @@ class PklSettingsTest { .trimIndent() ) - val settings = PklSettings.loadFromPklHomeDir(tempDir) + val settings = PklSettings.loadFromSettingsDir(tempDir) assertThat(settings).isEqualTo(PklSettings(Editor.SUBLIME, null)) } @@ -80,7 +80,7 @@ class PklSettingsTest { .trimIndent() ) - val settings = PklSettings.loadFromPklHomeDir(tempDir) + val settings = PklSettings.loadFromSettingsDir(tempDir) val expectedHttp = PklEvaluatorSettings.Http( PklEvaluatorSettings.Proxy( @@ -113,7 +113,7 @@ class PklSettingsTest { .trimIndent() ) - val settings = PklSettings.loadFromPklHomeDir(tempDir) + val settings = PklSettings.loadFromSettingsDir(tempDir) val expectedHttp = PklEvaluatorSettings.Http( PklEvaluatorSettings.Proxy(URI("http://localhost:8080"), listOf()), @@ -169,7 +169,7 @@ class PklSettingsTest { @Test fun `invalid settings file`(@TempDir tempDir: Path) { val settingsFile = tempDir.resolve("settings.pkl").apply { writeString("foo = 1") } - assertThatCode { PklSettings.loadFromPklHomeDir(tempDir) } + assertThatCode { PklSettings.loadFromSettingsDir(tempDir) } .hasMessageContaining( "Expected `output.value` of module `${settingsFile.toUri()}` to be of type `pkl.settings`, but got type `settings`." ) diff --git a/pkl-core/src/test/kotlin/org/pkl/core/util/BaseDirectoryTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/util/BaseDirectoryTest.kt new file mode 100644 index 000000000..00e3ee69b --- /dev/null +++ b/pkl-core/src/test/kotlin/org/pkl/core/util/BaseDirectoryTest.kt @@ -0,0 +1,231 @@ +/* + * 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.io.File +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.createFile +import kotlin.io.path.createParentDirectories +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +class BaseDirectoryTest { + private val subject = BaseDirectories.config + + @Test + fun `firstExistingPath() - finds a file within the XDG-configured home`(@TempDir tempDir: Path) { + val xdgHome = tempDir.resolve("xdg-home").createDirectories() + xdgHome.resolve("pkl/settings.pkl").createParentDirectories().createFile() + val envVars = mapOf("XDG_CONFIG_HOME" to xdgHome.toString()) + + assertThat(subject.firstExistingPath("settings.pkl", envVars, false)) + .isEqualTo(xdgHome.resolve("pkl/settings.pkl")) + } + + @Test + fun `firstExistingPath() - prefers home over dirs when both contain the file`( + @TempDir tempDir: Path + ) { + val xdgHome = tempDir.resolve("xdg-home").createDirectories() + val dir1 = tempDir.resolve("dir1").createDirectories() + xdgHome.resolve("pkl/settings.pkl").createParentDirectories().createFile() + dir1.resolve("settings.pkl").createFile() + val envVars = + mapOf("XDG_CONFIG_HOME" to xdgHome.toString(), "XDG_CONFIG_DIRS" to dir1.toString()) + + assertThat(subject.firstExistingPath("settings.pkl", envVars, false)) + .isEqualTo(xdgHome.resolve("pkl/settings.pkl")) + } + + @Test + fun `firstExistingPath() - falls back to dirs when home does not contain the file`( + @TempDir tempDir: Path + ) { + val xdgHome = tempDir.resolve("xdg-home").createDirectories() + val dir1 = tempDir.resolve("dir1").createDirectories() + dir1.resolve("settings.pkl").createFile() + val envVars = + mapOf("XDG_CONFIG_HOME" to xdgHome.toString(), "XDG_CONFIG_DIRS" to dir1.toString()) + + assertThat(subject.firstExistingPath("settings.pkl", envVars, false)) + .isEqualTo(dir1.resolve("settings.pkl")) + } + + @Test + fun `firstExistingPath() - searches multiple dirs in order`(@TempDir tempDir: Path) { + val xdgHome = tempDir.resolve("xdg-home").createDirectories() + val dir1 = tempDir.resolve("dir1").createDirectories() + val dir2 = tempDir.resolve("dir2").createDirectories() + val expected = + dir2.resolve("pkl/settings.pkl").also { + it.createParentDirectories() + it.createFile() + } + val envVars = + mapOf( + "XDG_CONFIG_HOME" to xdgHome.toString(), + "XDG_CONFIG_DIRS" to "$dir1${File.pathSeparator}$dir2", + ) + + assertThat(subject.firstExistingPath("settings.pkl", envVars, false)).isEqualTo(expected) + } + + @Test + fun `firstExistingPath() - returns null when the file exists nowhere in the search hierarchy`( + @TempDir tempDir: Path + ) { + val xdgHome = tempDir.resolve("xdg-home").createDirectories() + val dir1 = tempDir.resolve("dir1").createDirectories() + val envVars = + mapOf("XDG_CONFIG_HOME" to xdgHome.toString(), "XDG_CONFIG_DIRS" to dir1.toString()) + + assertThat(subject.firstExistingPath("missing.pkl", envVars, false)).isNull() + } + + @Test + fun `firstExistingPath() - XDG env var wins over the Windows env var even when isWindows is true`( + @TempDir tempDir: Path + ) { + val xdgHome = tempDir.resolve("xdg-home").createDirectories() + val appData = tempDir.resolve("app-data").createDirectories() + xdgHome.resolve("pkl/settings.pkl").createParentDirectories().createFile() + appData.resolve("pkl/settings.pkl").createParentDirectories().createFile() + val envVars = mapOf("XDG_CONFIG_HOME" to xdgHome.toString(), "AppData" to appData.toString()) + + assertThat(subject.firstExistingPath("settings.pkl", envVars, true)) + .isEqualTo(xdgHome.resolve("pkl/settings.pkl")) + } + + @Test + fun `firstExistingPath() - uses the Windows env var when the XDG env var is unset and isWindows is true`( + @TempDir tempDir: Path + ) { + val appData = tempDir.resolve("app-data").createDirectories() + appData.resolve("pkl/settings.pkl").createParentDirectories().createFile() + val envVars = mapOf("APPDATA" to appData.toString()) + + assertThat(subject.firstExistingPath("settings.pkl", envVars, true)) + .isEqualTo(appData.resolve("pkl/settings.pkl")) + } + + @Test + fun `firstExistingPath() - ignores the Windows env var when isWindows is false`( + @TempDir tempDir: Path + ) { + val appData = tempDir.resolve("app-data").createDirectories() + appData.resolve("pkl/settings.pkl").createParentDirectories().createFile() + val envVars = mapOf("APPDATA" to appData.toString()) + + assertThat(subject.firstExistingPath("settings.pkl", envVars, false)).isNull() + } + + @Test + fun `firstExistingPath() - appends the Windows subpath after 'pkl' when configured`( + @TempDir tempDir: Path + ) { + val localAppData = tempDir.resolve("local-app-data").createDirectories() + localAppData.resolve("pkl/Cache/cache.db").createParentDirectories().createFile() + val envVars = mapOf("LOCALAPPDATA" to localAppData.toString()) + + assertThat(BaseDirectories.cache.firstExistingPath("cache.db", envVars, true)) + .isEqualTo(localAppData.resolve("pkl/Cache/cache.db")) + } + + @Test + fun `firstExistingPath() - returns null when falling back to defaults that do not contain the file`() { + // Doesn't touch the real filesystem: this subpath is not expected to exist under the real + // `~/.config` or `/etc/xdg`, so the defaults are exercised without creating any real files. + val subpath = "base-directory-test/definitely-does-not-exist.txt" + assertThat(subject.firstExistingPath(subpath, emptyMap(), false)).isNull() + assertThat(subject.firstExistingPath(subpath, emptyMap(), true)).isNull() + } + + @Test + fun `getHome() - appends 'pkl' to the default home directory when no env vars are set`() { + val expected = Path.of(System.getProperty("user.home")).resolve(".config").resolve("pkl") + + assertThat(subject.getHome(emptyMap(), false)).isEqualTo(expected) + // Same fallback applies on Windows when the Windows env var is also unset. + assertThat(subject.getHome(emptyMap(), true)).isEqualTo(expected) + } + + @Test + fun `getHome() - appends 'pkl' to the default cache home when no env vars are set`() { + assertThat(BaseDirectories.cache.getHome(emptyMap(), false)) + .isEqualTo(Path.of(System.getProperty("user.home")).resolve(".cache").resolve("pkl")) + } + + @Test + fun `getHome() - appends 'pkl' to the default state home when no env vars are set`() { + assertThat(BaseDirectories.state.getHome(emptyMap(), false)) + .isEqualTo(Path.of(System.getProperty("user.home")).resolve(".local/state").resolve("pkl")) + } + + @Test + fun `getHome() - treats an empty XDG env var as unset`() { + val expected = Path.of(System.getProperty("user.home")).resolve(".config").resolve("pkl") + + assertThat(subject.getHome(mapOf("XDG_CONFIG_HOME" to ""), false)).isEqualTo(expected) + } + + @Test + fun `getHome() - treats an empty Windows env var as unset`() { + val expected = Path.of(System.getProperty("user.home")).resolve(".config").resolve("pkl") + + assertThat(subject.getHome(mapOf("APPDATA" to ""), true)).isEqualTo(expected) + } + + @Test + fun `getHome() - an empty XDG env var falls through to a configured Windows env var`( + @TempDir tempDir: Path + ) { + val appData = tempDir.resolve("app-data") + val envVars = mapOf("XDG_CONFIG_HOME" to "", "APPDATA" to appData.toString()) + + assertThat(subject.getHome(envVars, true)).isEqualTo(appData.resolve("pkl")) + } + + @Test + fun `getDirs() - treats an empty XDG_CONFIG_DIRS as unset, falling back to defaults`() { + assertThat(subject.getDirs(mapOf("XDG_CONFIG_DIRS" to ""))) + .containsExactly(Path.of("/etc/xdg").resolve("pkl")) + } + + @Test + fun `getDirs() - skips empty entries within an otherwise non-empty XDG_CONFIG_DIRS list`( + @TempDir tempDir: Path + ) { + val dir1 = tempDir.resolve("dir1") + val dir2 = tempDir.resolve("dir2") + // A double separator produces a literal empty segment (`"/a::/b".split(":")` keeps the + // middle `""`, unlike a trailing separator, which java.lang.String#split drops). + val envVars = mapOf("XDG_CONFIG_DIRS" to "$dir1${File.pathSeparator}${File.pathSeparator}$dir2") + + assertThat(subject.getDirs(envVars)).containsExactly(dir1.resolve("pkl"), dir2.resolve("pkl")) + } + + @Test + fun `firstExistingPath() - does not crash when XDG_CONFIG_DIRS contains a leading empty segment`( + @TempDir tempDir: Path + ) { + val dir1 = tempDir.resolve("dir1").createDirectories() + val envVars = mapOf("XDG_CONFIG_DIRS" to "${File.pathSeparator}$dir1") + + assertThat(subject.firstExistingPath("missing.pkl", envVars, false)).isNull() + } +} diff --git a/pkl-core/src/test/kotlin/org/pkl/core/util/IoUtilsTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/util/IoUtilsTest.kt index 8a7076c71..913e8386a 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/util/IoUtilsTest.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/util/IoUtilsTest.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. diff --git a/pkl-executor/src/main/java/org/pkl/executor/ExecutorOptions.java b/pkl-executor/src/main/java/org/pkl/executor/ExecutorOptions.java index 0f534dd00..4ea002064 100644 --- a/pkl-executor/src/main/java/org/pkl/executor/ExecutorOptions.java +++ b/pkl-executor/src/main/java/org/pkl/executor/ExecutorOptions.java @@ -19,6 +19,7 @@ import java.nio.file.Path; import java.time.Duration; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import org.jspecify.annotations.Nullable; @@ -67,7 +68,33 @@ public final class ExecutorOptions { /** Returns the module cache dir that the CLI uses by default. */ public static Path defaultModuleCacheDir() { - return Path.of(System.getProperty("user.home"), ".pkl", "cache"); + return defaultModuleCacheDir( + Path.of(System.getProperty("user.home")), isWindowsOs(), System.getenv()); + } + + // Package-private; injectable so tests can exercise the Windows code path on a Unix CI box. + static Path defaultModuleCacheDir( + Path home, boolean isWindows, Map environmentVariables) { + // Keep in sync with org.pkl.core.util.IoUtils.getSystemModuleCacheDir (pkl-executor cannot + // depend on pkl-core). On Unix prefer the XDG-style `~/.cache/pkl`; on Windows prefer + // `%LOCALAPPDATA%/pkl/cache`. Keep using a pre-existing legacy `~/.pkl/cache` so that + // already-populated caches aren't orphaned. + var xdgConfig = environmentVariables.get("XDG_CACHE_HOME"); + if (xdgConfig != null) { + return Path.of(xdgConfig).resolve("pkl"); + } + if (isWindows) { + var localAppData = environmentVariables.get("LOCALAPPDATA"); + if (localAppData != null) { + return Path.of(localAppData).resolve("pkl/Cache"); + } + } + return home.resolve(".cache/pkl"); + } + + private static boolean isWindowsOs() { + var osName = System.getProperty("os.name"); + return osName != null && osName.toLowerCase(Locale.ROOT).contains("windows"); } public static Builder builder() { diff --git a/pkl-executor/src/test/kotlin/org/pkl/executor/ExecutorOptionsTest.kt b/pkl-executor/src/test/kotlin/org/pkl/executor/ExecutorOptionsTest.kt new file mode 100644 index 000000000..15f2ffd0d --- /dev/null +++ b/pkl-executor/src/test/kotlin/org/pkl/executor/ExecutorOptionsTest.kt @@ -0,0 +1,69 @@ +/* + * 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. + * 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.executor + +import java.nio.file.Path +import kotlin.io.path.createDirectories +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.pkl.core.util.IoUtils + +class ExecutorOptionsTest { + // `ExecutorOptions.defaultModuleCacheDir()` inlines the XDG/legacy fallback because pkl-executor + // cannot depend on pkl-core. This guards against drift from `IoUtils.getDefaultModuleCacheDir()`. + @Test + fun `defaultModuleCacheDir stays in sync with pkl-core`(@TempDir home: Path) { + val original = System.getProperty("user.home") + try { + System.setProperty("user.home", home.toString()) + assertThat(ExecutorOptions.defaultModuleCacheDir()) + .isEqualTo(home.resolve(".cache").resolve("pkl")) + assertThat(ExecutorOptions.defaultModuleCacheDir()) + .isEqualTo(IoUtils.getSystemModuleCacheDir()) + } finally { + System.setProperty("user.home", original) + } + } + + @Test + fun `defaultModuleCacheDir on Windows uses LOCALAPPDATA when set`(@TempDir home: Path) { + val localAppData = home.resolve("LocalAppData").createDirectories() + assertThat( + ExecutorOptions.defaultModuleCacheDir( + home, + true, + mapOf("LOCALAPPDATA" to localAppData.toString()), + ) + ) + .isEqualTo(localAppData.resolve("pkl").resolve("Cache")) + } + + @Test + fun `defaultModuleCacheDir on Windows falls back to Unix layout when LOCALAPPDATA is unset`( + @TempDir home: Path + ) { + assertThat(ExecutorOptions.defaultModuleCacheDir(home, true, mapOf())) + .isEqualTo(home.resolve(".cache").resolve("pkl")) + } + + @Test + fun `defaultModuleCacheDir on Windows still falls XDG style default dir`(@TempDir home: Path) { + home.resolve(".pkl").resolve("cache").createDirectories() + assertThat(ExecutorOptions.defaultModuleCacheDir(home, true, mapOf())) + .isEqualTo(home.resolve(".cache").resolve("pkl")) + } +} 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 1b37976c4..03208731f 100644 --- a/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java +++ b/pkl-gradle/src/main/java/org/pkl/gradle/PklPlugin.java @@ -322,7 +322,10 @@ private void configureBaseSpec(Project project, BasePklSpec spec) { // Therefore, we don't set any initial value for the environmentVariables property. // Not using `convention()` to allow the user to unset this property, disabling the cache. - spec.getModuleCacheDir().set(IoUtils.getDefaultModuleCacheDir().toFile()); + var systemCacheDir = IoUtils.getSystemModuleCacheDir(); + if (systemCacheDir != null) { + spec.getModuleCacheDir().set(systemCacheDir.toFile()); + } spec.getNoCache().convention(false); diff --git a/stdlib/settings.pkl b/stdlib/settings.pkl index 0b0fcb401..2208c2b04 100644 --- a/stdlib/settings.pkl +++ b/stdlib/settings.pkl @@ -17,8 +17,16 @@ /// Configuration settings for Pkl itself. /// /// Every settings file must amend this module. +/// /// Unless CLI commands and build tool plugins are explicitly configured with a settings file, -/// they will use `~/.pkl/settings.pkl` or the defaults specified in this module. +/// looks in the following locations in order of precedence: +/// +/// 1. `$XDG_CONFIG_HOME/pkl/settings.pkl` +/// 2. `$APPDATA/pkl/settings.pkl` (on Windows only) +/// 3. `~/.config/pkl/settings.pkl` +/// 4. Path `pkl/settings.pkl` within the `$XDG_CONFIG_DIRS` search path +/// (dirs separted by `:` on Unix, `;` on Windows). +/// 5. `~/.pkl/settings.pkl` (legacy location used by Pkl 0.32 and lower) @ModuleInfo { minPklVersion = "0.33.0" } module pkl.settings From 62eddaef063155447ab4d25558961d8a3373b2d8 Mon Sep 17 00:00:00 2001 From: Dan Chao Date: Thu, 6 Aug 2026 15:11:48 -0700 Subject: [PATCH 2/2] Address PR comments --- .../src/main/kotlin/NativeImageBuild.kt | 2 + .../pkl-cli/partials/cli-common-options.adoc | 2 +- .../partials/gradle-common-properties.adoc | 2 +- docs/modules/release-notes/pages/0.33.adoc | 4 +- .../release-notes/pages/changelog.adoc | 5 -- .../src/main/kotlin/org/pkl/cli/repl/Repl.kt | 3 +- .../kotlin/org/pkl/commons/cli/CliCommand.kt | 7 ++- .../org/pkl/core/settings/PklSettings.java | 4 +- .../org/pkl/core/util/BaseDirectories.java | 2 +- .../java/org/pkl/core/util/BaseDirectory.java | 48 +++++++++++----- .../java/org/pkl/core/util/DebugLogger.java | 35 ++++++++++++ .../main/java/org/pkl/core/util/IoUtils.java | 6 +- .../org/pkl/core/util/BaseDirectoryTest.kt | 55 ++++++++++--------- .../kotlin/org/pkl/core/util/IoUtilsTest.kt | 2 +- .../org/pkl/executor/ExecutorOptions.java | 28 +++++++--- stdlib/settings.pkl | 4 +- 16 files changed, 144 insertions(+), 65 deletions(-) create mode 100644 pkl-core/src/main/java/org/pkl/core/util/DebugLogger.java diff --git a/build-logic/src/main/kotlin/NativeImageBuild.kt b/build-logic/src/main/kotlin/NativeImageBuild.kt index 8ecd7404e..ff1b534c0 100644 --- a/build-logic/src/main/kotlin/NativeImageBuild.kt +++ b/build-logic/src/main/kotlin/NativeImageBuild.kt @@ -213,6 +213,8 @@ abstract class NativeImageBuild : DefaultTask() { // prevent storing `homeDir` in native image add("--initialize-at-run-time=org.pkl.core.util.BaseDirectory") add("--initialize-at-run-time=org.pkl.core.util.BaseDirectories") + // prevent storing `isEnabled` + add("--initialize-at-run-time=org.pkl.core.util.DebugLogger") // needed for jline-terminal-jni add("--initialize-at-run-time=org.jline.nativ,org.jline.terminal.impl.jni") add("--no-fallback") diff --git a/docs/modules/pkl-cli/partials/cli-common-options.adoc b/docs/modules/pkl-cli/partials/cli-common-options.adoc index 807004c45..3a15e77ef 100644 --- a/docs/modules/pkl-cli/partials/cli-common-options.adoc +++ b/docs/modules/pkl-cli/partials/cli-common-options.adoc @@ -43,7 +43,7 @@ The cache directory for storing packages. If unset, defaults to the following locations: . `$XDG_CACHE_HOME/pkl` -. `$LOCALAPPDATA/pkl/cache` (on Windows only) +. `$LOCALAPPDATA/pkl/Cache` (on Windows only) . `~/.cache/pkl` (if `$XDG_CACHE_HOME` and `$LOCALAPPDATA` are both unset) ==== diff --git a/docs/modules/pkl-gradle/partials/gradle-common-properties.adoc b/docs/modules/pkl-gradle/partials/gradle-common-properties.adoc index e62e874cc..d2642ca1f 100644 --- a/docs/modules/pkl-gradle/partials/gradle-common-properties.adoc +++ b/docs/modules/pkl-gradle/partials/gradle-common-properties.adoc @@ -64,7 +64,7 @@ Default: `null` + Example 1: `moduleCacheDir = layout.buildDirectory.dir("pkl-module-cache")` + Example 2: `moduleCacheDir.fileValue file("/absolute/path/to/cache")` + The cache directory for storing packages. -If `null`, defaults to `~/.cache/pkl` on Unix or `%LOCALAPPDATA%/pkl/cache` on Windows. +If `null`, defaults to `~/.cache/pkl` on Unix or `%LOCALAPPDATA%/pkl/Cache` on Windows. This setting can also be configured using the `$XDG_CACHE_HOME` environment variable. ==== diff --git a/docs/modules/release-notes/pages/0.33.adoc b/docs/modules/release-notes/pages/0.33.adoc index 7a5436dc0..903b9c188 100644 --- a/docs/modules/release-notes/pages/0.33.adoc +++ b/docs/modules/release-notes/pages/0.33.adoc @@ -17,7 +17,7 @@ include::partial$intro.adoc[] ==== Default file locations -For new setups, the CLI no longer stores anything under `~/.pkl` (https://github.com/apple/pkl/pull/1674[#1674]). +For new setups, the CLI no longer stores anything under `~/.pkl` (https://github.com/apple/pkl/pull/1809[#1809]). It uses XDG-style locations on Unix and Known Folder locations on Windows: @@ -46,7 +46,7 @@ It uses XDG-style locations on Unix and Known Folder locations on Windows: | none |=== -On every OS, these locations can be overriden with XDG-style env vars. +On every OS, these locations can be overridden with XDG-style env vars. For example, setting `XDG_CACHE_HOME` will configure the cache directory. Note that the existing `~/.pkl/cache` directory is ignored, so Pkl will download packages to populate its cache if configured to do so. diff --git a/docs/modules/release-notes/pages/changelog.adoc b/docs/modules/release-notes/pages/changelog.adoc index 6134c83f8..f91124d14 100644 --- a/docs/modules/release-notes/pages/changelog.adoc +++ b/docs/modules/release-notes/pages/changelog.adoc @@ -27,11 +27,6 @@ Thank you to all the contributors for this release! xref:0.32.adoc[Release Notes] -=== Changes - -* Default the CLI's package cache, settings file, CA certificates directory, and REPL history to XDG-style locations instead of `~/.pkl`: `~/.cache/pkl`, `~/.config/pkl/settings.pkl`, `~/.config/pkl/cacerts`, and `~/.local/state/pkl/repl-history` (pr:https://github.com/apple/pkl/pull/1674[]). -The legacy `~/.pkl` locations are still used when they already exist, so existing setups keep working without migration. - [[release-0.31.1]] == 0.31.1 (2026-03-26) diff --git a/pkl-cli/src/main/kotlin/org/pkl/cli/repl/Repl.kt b/pkl-cli/src/main/kotlin/org/pkl/cli/repl/Repl.kt index cc9241744..a70dbc90f 100644 --- a/pkl-cli/src/main/kotlin/org/pkl/cli/repl/Repl.kt +++ b/pkl-cli/src/main/kotlin/org/pkl/cli/repl/Repl.kt @@ -63,7 +63,8 @@ internal class Repl(workingDir: Path, private val server: ReplServer, private va } completer(AggregateCompleter(CommandCompleter, FileCompleter(workingDir))) option(Option.DISABLE_EVENT_EXPANSION, true) - // will be null if `$HOME` is not set. if so, don't bother writing repl history. + // Will be null if `user.home` property is not set. + // If so, don't bother writing repl history. IoUtils.getReplHistoryFile()?.let { historyFile -> variable(LineReader.HISTORY_FILE, historyFile) } 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 19c856bde..012a69039 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 @@ -20,6 +20,7 @@ import java.net.URI import java.nio.file.Files import java.nio.file.Path import java.util.regex.Pattern +import kotlin.io.path.absolutePathString import kotlin.io.path.isRegularFile import org.pkl.core.* import org.pkl.core.evaluatorSettings.PklEvaluatorSettings @@ -33,6 +34,7 @@ import org.pkl.core.project.Project import org.pkl.core.resource.ResourceReader import org.pkl.core.resource.ResourceReaders import org.pkl.core.settings.PklSettings +import org.pkl.core.util.DebugLogger import org.pkl.core.util.IoUtils /** Building block for CLI commands. Configured programmatically to allow for embedding. */ @@ -225,7 +227,10 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) { addCertificates(cert) } } - if (!certsAdded) { + if (certsAdded) { + DebugLogger.log("Loading CA certificates from ${caCertsDir.normalize().absolutePathString()}") + } else { + DebugLogger.log("Using built-in CA certificates") val defaultCerts = this@CliCommand.javaClass.classLoader.getResourceAsStream( "org/pkl/commons/cli/PklCARoots.pem" diff --git a/pkl-core/src/main/java/org/pkl/core/settings/PklSettings.java b/pkl-core/src/main/java/org/pkl/core/settings/PklSettings.java index ea45f607f..32d3705a9 100644 --- a/pkl-core/src/main/java/org/pkl/core/settings/PklSettings.java +++ b/pkl-core/src/main/java/org/pkl/core/settings/PklSettings.java @@ -26,6 +26,7 @@ import org.pkl.core.resource.ResourceReaders; import org.pkl.core.runtime.VmEvalException; import org.pkl.core.runtime.VmExceptionBuilder; +import org.pkl.core.util.DebugLogger; import org.pkl.core.util.IoUtils; /** @@ -54,6 +55,7 @@ public record PklSettings(Editor editor, PklEvaluatorSettings.@Nullable Http htt public static PklSettings loadFromSystem() throws VmEvalException { var file = IoUtils.getSystemSettingsFile(); if (Files.exists(file)) { + DebugLogger.log("Loading settings file from " + file.normalize().toAbsolutePath()); return load(ModuleSource.path(file)); } return defaultInstance; @@ -62,7 +64,7 @@ public static PklSettings loadFromSystem() throws VmEvalException { /** * Loads the user settings file. * - * @deprecated As of 0.33.0, renamed to {@link #loadFromSystem()}, which now prefers {@code + * @deprecated As of 0.33.0, use {@link #loadFromSystem()}, which now prefers {@code * ~/.config/pkl/settings.pkl} over the legacy {@code ~/.pkl/settings.pkl}. */ @Deprecated(since = "0.33.0", forRemoval = true) diff --git a/pkl-core/src/main/java/org/pkl/core/util/BaseDirectories.java b/pkl-core/src/main/java/org/pkl/core/util/BaseDirectories.java index 3396b9c23..2d150ef91 100644 --- a/pkl-core/src/main/java/org/pkl/core/util/BaseDirectories.java +++ b/pkl-core/src/main/java/org/pkl/core/util/BaseDirectories.java @@ -15,7 +15,7 @@ */ package org.pkl.core.util; -public final class BaseDirectories { +final class BaseDirectories { public static final BaseDirectory config = new BaseDirectory( "XDG_CONFIG_HOME", diff --git a/pkl-core/src/main/java/org/pkl/core/util/BaseDirectory.java b/pkl-core/src/main/java/org/pkl/core/util/BaseDirectory.java index ab1489c84..aec1c0751 100644 --- a/pkl-core/src/main/java/org/pkl/core/util/BaseDirectory.java +++ b/pkl-core/src/main/java/org/pkl/core/util/BaseDirectory.java @@ -16,12 +16,12 @@ package org.pkl.core.util; import java.io.File; -import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.util.Arrays; import java.util.Map; import java.util.Objects; +import java.util.function.Predicate; import org.jspecify.annotations.Nullable; /** @@ -32,21 +32,40 @@ *

On Windows, follows {@code $APPDATA} and {@code $LOCALAPPDATA} conventions, but can be * overridden by {@code XDG} style env vars. */ -public record BaseDirectory( - String xdgHomeEnvVar, - @Nullable String xdgDirsEnvVar, - String windowsEnvVar, - @Nullable String windowsSubpath, - String homeDefault, - String @Nullable [] dirsDefault) { +class BaseDirectory { + private final String xdgHomeEnvVar; + private final @Nullable String xdgDirsEnvVar; + private final String windowsEnvVar; + private final @Nullable String windowsSubpath; + private final String homeDefault; + private final String @Nullable [] dirsDefault; + + BaseDirectory( + String xdgHomeEnvVar, + @Nullable String xdgDirsEnvVar, + String windowsEnvVar, + @Nullable String windowsSubpath, + String homeDefault, + String @Nullable [] dirsDefault) { + this.xdgHomeEnvVar = xdgHomeEnvVar; + this.xdgDirsEnvVar = xdgDirsEnvVar; + this.windowsEnvVar = windowsEnvVar; + this.windowsSubpath = windowsSubpath; + this.homeDefault = homeDefault; + this.dirsDefault = dirsDefault; + } + + @Nullable Path getHome() { + return getHome(System.getenv(), IoUtils.isWindows()); + } /** Returns the first file within the search hierarchy that exists. */ - public @Nullable Path firstExistingPath(String subpath) { - return firstExistingPath(subpath, System.getenv(), IoUtils.isWindows()); + @Nullable Path firstMatchingPath(String subpath, Predicate predicate) { + return firstMatchingPath(subpath, System.getenv(), IoUtils.isWindows(), predicate); } /** Returns the subpath within the {@code home} of this base directory type. */ - public @Nullable Path resolveHome(String subpath) { + @Nullable Path resolveHome(String subpath) { var homeDir = getHome(System.getenv(), IoUtils.isWindows()); if (homeDir != null) { return homeDir.resolve(subpath); @@ -55,12 +74,13 @@ public record BaseDirectory( } // for testing only - @Nullable Path firstExistingPath(String subpath, Map envVars, boolean isWindows) { + @Nullable Path firstMatchingPath( + String subpath, Map envVars, boolean isWindows, Predicate predicate) { var home = getHome(envVars, isWindows); Path candidate; if (home != null) { candidate = home.resolve(subpath); - if (Files.exists(candidate)) { + if (predicate.test(candidate)) { return candidate; } } @@ -68,7 +88,7 @@ public record BaseDirectory( if (dirs != null) { for (var dir : dirs) { candidate = dir.resolve(subpath); - if (Files.exists(candidate)) { + if (predicate.test(candidate)) { return candidate; } } diff --git a/pkl-core/src/main/java/org/pkl/core/util/DebugLogger.java b/pkl-core/src/main/java/org/pkl/core/util/DebugLogger.java new file mode 100644 index 000000000..ac529302a --- /dev/null +++ b/pkl-core/src/main/java/org/pkl/core/util/DebugLogger.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 java.util.Objects; + +public final class DebugLogger { + private DebugLogger() {} + + private static final boolean isEnabled; + + static { + isEnabled = Objects.equals(System.getenv("PKL_DEBUG"), "1"); + } + + public static void log(String message) { + if (!isEnabled) { + return; + } + System.err.println("[pkl] " + message); + } +} 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 d1524f9f9..1a7d16c2c 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 @@ -232,11 +232,11 @@ public static Path getLegacyPklHomeDir() { } public static @Nullable Path getSystemModuleCacheDir() { - return BaseDirectories.cache.resolveHome(""); + return BaseDirectories.cache.getHome(); } public static Path getSystemSettingsFile() { - var path = BaseDirectories.config.firstExistingPath("settings.pkl"); + var path = BaseDirectories.config.firstMatchingPath("settings.pkl", Files::exists); if (path == null) { return getLegacyPklHomeDir().resolve("settings.pkl"); } @@ -244,7 +244,7 @@ public static Path getSystemSettingsFile() { } public static Path getSystemCaCertsDir() { - var path = BaseDirectories.config.firstExistingPath("cacerts"); + var path = BaseDirectories.config.firstMatchingPath("cacerts", Files::isDirectory); if (path == null) { return getLegacyPklHomeDir().resolve("cacerts"); } diff --git a/pkl-core/src/test/kotlin/org/pkl/core/util/BaseDirectoryTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/util/BaseDirectoryTest.kt index 00e3ee69b..5dc067b12 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/util/BaseDirectoryTest.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/util/BaseDirectoryTest.kt @@ -16,6 +16,7 @@ package org.pkl.core.util import java.io.File +import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.createDirectories import kotlin.io.path.createFile @@ -28,17 +29,17 @@ class BaseDirectoryTest { private val subject = BaseDirectories.config @Test - fun `firstExistingPath() - finds a file within the XDG-configured home`(@TempDir tempDir: Path) { + fun `firstMatchingPath() - finds a file within the XDG-configured home`(@TempDir tempDir: Path) { val xdgHome = tempDir.resolve("xdg-home").createDirectories() xdgHome.resolve("pkl/settings.pkl").createParentDirectories().createFile() val envVars = mapOf("XDG_CONFIG_HOME" to xdgHome.toString()) - assertThat(subject.firstExistingPath("settings.pkl", envVars, false)) + assertThat(subject.firstMatchingPath("settings.pkl", envVars, false, Files::exists)) .isEqualTo(xdgHome.resolve("pkl/settings.pkl")) } @Test - fun `firstExistingPath() - prefers home over dirs when both contain the file`( + fun `firstMatchingPath() - prefers home over dirs when both contain the file`( @TempDir tempDir: Path ) { val xdgHome = tempDir.resolve("xdg-home").createDirectories() @@ -48,26 +49,29 @@ class BaseDirectoryTest { val envVars = mapOf("XDG_CONFIG_HOME" to xdgHome.toString(), "XDG_CONFIG_DIRS" to dir1.toString()) - assertThat(subject.firstExistingPath("settings.pkl", envVars, false)) + assertThat(subject.firstMatchingPath("settings.pkl", envVars, false, Files::exists)) .isEqualTo(xdgHome.resolve("pkl/settings.pkl")) } @Test - fun `firstExistingPath() - falls back to dirs when home does not contain the file`( + fun `firstMatchingPath() - falls back to dirs when home does not contain the file`( @TempDir tempDir: Path ) { val xdgHome = tempDir.resolve("xdg-home").createDirectories() val dir1 = tempDir.resolve("dir1").createDirectories() - dir1.resolve("settings.pkl").createFile() + dir1.resolve("pkl/settings.pkl").let { + it.createParentDirectories() + it.createFile() + } val envVars = mapOf("XDG_CONFIG_HOME" to xdgHome.toString(), "XDG_CONFIG_DIRS" to dir1.toString()) - assertThat(subject.firstExistingPath("settings.pkl", envVars, false)) - .isEqualTo(dir1.resolve("settings.pkl")) + assertThat(subject.firstMatchingPath("settings.pkl", envVars, false, Files::exists)) + .isEqualTo(dir1.resolve("pkl/settings.pkl")) } @Test - fun `firstExistingPath() - searches multiple dirs in order`(@TempDir tempDir: Path) { + fun `firstMatchingPath() - searches multiple dirs in order`(@TempDir tempDir: Path) { val xdgHome = tempDir.resolve("xdg-home").createDirectories() val dir1 = tempDir.resolve("dir1").createDirectories() val dir2 = tempDir.resolve("dir2").createDirectories() @@ -82,11 +86,12 @@ class BaseDirectoryTest { "XDG_CONFIG_DIRS" to "$dir1${File.pathSeparator}$dir2", ) - assertThat(subject.firstExistingPath("settings.pkl", envVars, false)).isEqualTo(expected) + assertThat(subject.firstMatchingPath("settings.pkl", envVars, false, Files::exists)) + .isEqualTo(expected) } @Test - fun `firstExistingPath() - returns null when the file exists nowhere in the search hierarchy`( + fun `firstMatchingPath() - returns null when the file exists nowhere in the search hierarchy`( @TempDir tempDir: Path ) { val xdgHome = tempDir.resolve("xdg-home").createDirectories() @@ -94,11 +99,11 @@ class BaseDirectoryTest { val envVars = mapOf("XDG_CONFIG_HOME" to xdgHome.toString(), "XDG_CONFIG_DIRS" to dir1.toString()) - assertThat(subject.firstExistingPath("missing.pkl", envVars, false)).isNull() + assertThat(subject.firstMatchingPath("missing.pkl", envVars, false, Files::exists)).isNull() } @Test - fun `firstExistingPath() - XDG env var wins over the Windows env var even when isWindows is true`( + fun `firstMatchingPath() - XDG env var wins over the Windows env var even when isWindows is true`( @TempDir tempDir: Path ) { val xdgHome = tempDir.resolve("xdg-home").createDirectories() @@ -107,52 +112,52 @@ class BaseDirectoryTest { appData.resolve("pkl/settings.pkl").createParentDirectories().createFile() val envVars = mapOf("XDG_CONFIG_HOME" to xdgHome.toString(), "AppData" to appData.toString()) - assertThat(subject.firstExistingPath("settings.pkl", envVars, true)) + assertThat(subject.firstMatchingPath("settings.pkl", envVars, true, Files::exists)) .isEqualTo(xdgHome.resolve("pkl/settings.pkl")) } @Test - fun `firstExistingPath() - uses the Windows env var when the XDG env var is unset and isWindows is true`( + fun `firstMatchingPath() - uses the Windows env var when the XDG env var is unset and isWindows is true`( @TempDir tempDir: Path ) { val appData = tempDir.resolve("app-data").createDirectories() appData.resolve("pkl/settings.pkl").createParentDirectories().createFile() val envVars = mapOf("APPDATA" to appData.toString()) - assertThat(subject.firstExistingPath("settings.pkl", envVars, true)) + assertThat(subject.firstMatchingPath("settings.pkl", envVars, true, Files::exists)) .isEqualTo(appData.resolve("pkl/settings.pkl")) } @Test - fun `firstExistingPath() - ignores the Windows env var when isWindows is false`( + fun `firstMatchingPath() - ignores the Windows env var when isWindows is false`( @TempDir tempDir: Path ) { val appData = tempDir.resolve("app-data").createDirectories() appData.resolve("pkl/settings.pkl").createParentDirectories().createFile() val envVars = mapOf("APPDATA" to appData.toString()) - assertThat(subject.firstExistingPath("settings.pkl", envVars, false)).isNull() + assertThat(subject.firstMatchingPath("settings.pkl", envVars, false, Files::exists)).isNull() } @Test - fun `firstExistingPath() - appends the Windows subpath after 'pkl' when configured`( + fun `firstMatchingPath() - appends the Windows subpath after 'pkl' when configured`( @TempDir tempDir: Path ) { val localAppData = tempDir.resolve("local-app-data").createDirectories() localAppData.resolve("pkl/Cache/cache.db").createParentDirectories().createFile() val envVars = mapOf("LOCALAPPDATA" to localAppData.toString()) - assertThat(BaseDirectories.cache.firstExistingPath("cache.db", envVars, true)) + assertThat(BaseDirectories.cache.firstMatchingPath("cache.db", envVars, true, Files::exists)) .isEqualTo(localAppData.resolve("pkl/Cache/cache.db")) } @Test - fun `firstExistingPath() - returns null when falling back to defaults that do not contain the file`() { + fun `firstMatchingPath() - returns null when falling back to defaults that do not contain the file`() { // Doesn't touch the real filesystem: this subpath is not expected to exist under the real // `~/.config` or `/etc/xdg`, so the defaults are exercised without creating any real files. val subpath = "base-directory-test/definitely-does-not-exist.txt" - assertThat(subject.firstExistingPath(subpath, emptyMap(), false)).isNull() - assertThat(subject.firstExistingPath(subpath, emptyMap(), true)).isNull() + assertThat(subject.firstMatchingPath(subpath, emptyMap(), false, Files::exists)).isNull() + assertThat(subject.firstMatchingPath(subpath, emptyMap(), true, Files::exists)).isNull() } @Test @@ -220,12 +225,12 @@ class BaseDirectoryTest { } @Test - fun `firstExistingPath() - does not crash when XDG_CONFIG_DIRS contains a leading empty segment`( + fun `firstMatchingPath() - does not crash when XDG_CONFIG_DIRS contains a leading empty segment`( @TempDir tempDir: Path ) { val dir1 = tempDir.resolve("dir1").createDirectories() val envVars = mapOf("XDG_CONFIG_DIRS" to "${File.pathSeparator}$dir1") - assertThat(subject.firstExistingPath("missing.pkl", envVars, false)).isNull() + assertThat(subject.firstMatchingPath("missing.pkl", envVars, false, Files::exists)).isNull() } } diff --git a/pkl-core/src/test/kotlin/org/pkl/core/util/IoUtilsTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/util/IoUtilsTest.kt index 913e8386a..8a7076c71 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/util/IoUtilsTest.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/util/IoUtilsTest.kt @@ -1,5 +1,5 @@ /* - * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved. + * Copyright © 2024-2025 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. diff --git a/pkl-executor/src/main/java/org/pkl/executor/ExecutorOptions.java b/pkl-executor/src/main/java/org/pkl/executor/ExecutorOptions.java index 4ea002064..b9e6ba1d0 100644 --- a/pkl-executor/src/main/java/org/pkl/executor/ExecutorOptions.java +++ b/pkl-executor/src/main/java/org/pkl/executor/ExecutorOptions.java @@ -16,6 +16,7 @@ package org.pkl.executor; import java.net.URI; +import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.time.Duration; import java.util.List; @@ -27,6 +28,8 @@ import org.pkl.executor.spi.v1.ExecutorSpiOptions2; import org.pkl.executor.spi.v1.ExecutorSpiOptions3; import org.pkl.executor.spi.v1.ExecutorSpiOptions4; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Options for {@link Executor#evaluatePath}. @@ -34,6 +37,8 @@ *

To create {@code ExecutorOptions}, use its {@linkplain #builder builder}. */ public final class ExecutorOptions { + private static final Logger logger = LoggerFactory.getLogger(ExecutorOptions.class); + private final List allowedModules; private final List allowedResources; @@ -76,17 +81,26 @@ public static Path defaultModuleCacheDir() { static Path defaultModuleCacheDir( Path home, boolean isWindows, Map environmentVariables) { // Keep in sync with org.pkl.core.util.IoUtils.getSystemModuleCacheDir (pkl-executor cannot - // depend on pkl-core). On Unix prefer the XDG-style `~/.cache/pkl`; on Windows prefer - // `%LOCALAPPDATA%/pkl/cache`. Keep using a pre-existing legacy `~/.pkl/cache` so that - // already-populated caches aren't orphaned. + // depend on pkl-core). + // + // On Unix prefer the XDG-style `~/.cache/pkl`. + // On Windows prefer `%LOCALAPPDATA%/pkl/Cache`. var xdgConfig = environmentVariables.get("XDG_CACHE_HOME"); - if (xdgConfig != null) { - return Path.of(xdgConfig).resolve("pkl"); + if (xdgConfig != null && !xdgConfig.isEmpty()) { + try { + return Path.of(xdgConfig).resolve("pkl"); + } catch (InvalidPathException e) { + logger.warn("'XDG_CACHE_HOME' is an invalid path: {}", e.getMessage()); + } } if (isWindows) { var localAppData = environmentVariables.get("LOCALAPPDATA"); - if (localAppData != null) { - return Path.of(localAppData).resolve("pkl/Cache"); + if (localAppData != null && !localAppData.isEmpty()) { + try { + return Path.of(localAppData).resolve("pkl/Cache"); + } catch (InvalidPathException e) { + logger.warn("'LOCALAPPDATA' is an invalid path: {}", e.getMessage()); + } } } return home.resolve(".cache/pkl"); diff --git a/stdlib/settings.pkl b/stdlib/settings.pkl index 2208c2b04..20ce0561c 100644 --- a/stdlib/settings.pkl +++ b/stdlib/settings.pkl @@ -19,13 +19,13 @@ /// Every settings file must amend this module. /// /// Unless CLI commands and build tool plugins are explicitly configured with a settings file, -/// looks in the following locations in order of precedence: +/// they look in the following locations in order of precedence: /// /// 1. `$XDG_CONFIG_HOME/pkl/settings.pkl` /// 2. `$APPDATA/pkl/settings.pkl` (on Windows only) /// 3. `~/.config/pkl/settings.pkl` /// 4. Path `pkl/settings.pkl` within the `$XDG_CONFIG_DIRS` search path -/// (dirs separted by `:` on Unix, `;` on Windows). +/// (dirs separated by `:` on Unix, `;` on Windows). /// 5. `~/.pkl/settings.pkl` (legacy location used by Pkl 0.32 and lower) @ModuleInfo { minPklVersion = "0.33.0" } module pkl.settings