diff --git a/build-logic/src/main/kotlin/NativeImageBuild.kt b/build-logic/src/main/kotlin/NativeImageBuild.kt index ed31555b0..ff1b534c0 100644 --- a/build-logic/src/main/kotlin/NativeImageBuild.kt +++ b/build-logic/src/main/kotlin/NativeImageBuild.kt @@ -210,6 +210,11 @@ 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") + // 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/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..3a15e77ef 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..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,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..903b9c188 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/1809[#1809]). + +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 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. == Breaking Changes [small]#💔# 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..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,11 @@ 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 `user.home` property is not set. + // If so, don't bother writing repl history. + IoUtils.getReplHistoryFile()?.let { historyFile -> + variable(LineReader.HISTORY_FILE, historyFile) + } } .build() @@ -80,7 +84,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..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. */ @@ -69,7 +71,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 +148,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 +217,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) @@ -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-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..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; /** @@ -33,8 +34,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 +45,43 @@ 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)) { + DebugLogger.log("Loading settings file from " + file.normalize().toAbsolutePath()); + return load(ModuleSource.path(file)); + } + return defaultInstance; + } + + /** + * Loads the user settings file. + * + * @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) 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..2d150ef91 --- /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; + +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..aec1c0751 --- /dev/null +++ b/pkl-core/src/main/java/org/pkl/core/util/BaseDirectory.java @@ -0,0 +1,205 @@ +/* + * 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.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; + +/** + * 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. + */ +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. */ + @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. */ + @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 firstMatchingPath( + String subpath, Map envVars, boolean isWindows, Predicate predicate) { + var home = getHome(envVars, isWindows); + Path candidate; + if (home != null) { + candidate = home.resolve(subpath); + if (predicate.test(candidate)) { + return candidate; + } + } + var dirs = getDirs(envVars); + if (dirs != null) { + for (var dir : dirs) { + candidate = dir.resolve(subpath); + if (predicate.test(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/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 c784a0780..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 @@ -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.getHome(); + } + + public static Path getSystemSettingsFile() { + var path = BaseDirectories.config.firstMatchingPath("settings.pkl", Files::exists); + if (path == null) { + return getLegacyPklHomeDir().resolve("settings.pkl"); + } + return path; + } + + public static Path getSystemCaCertsDir() { + var path = BaseDirectories.config.firstMatchingPath("cacerts", Files::isDirectory); + 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..5dc067b12 --- /dev/null +++ b/pkl-core/src/test/kotlin/org/pkl/core/util/BaseDirectoryTest.kt @@ -0,0 +1,236 @@ +/* + * 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.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 `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.firstMatchingPath("settings.pkl", envVars, false, Files::exists)) + .isEqualTo(xdgHome.resolve("pkl/settings.pkl")) + } + + @Test + fun `firstMatchingPath() - 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.firstMatchingPath("settings.pkl", envVars, false, Files::exists)) + .isEqualTo(xdgHome.resolve("pkl/settings.pkl")) + } + + @Test + 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("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.firstMatchingPath("settings.pkl", envVars, false, Files::exists)) + .isEqualTo(dir1.resolve("pkl/settings.pkl")) + } + + @Test + 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() + 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.firstMatchingPath("settings.pkl", envVars, false, Files::exists)) + .isEqualTo(expected) + } + + @Test + fun `firstMatchingPath() - 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.firstMatchingPath("missing.pkl", envVars, false, Files::exists)).isNull() + } + + @Test + 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() + 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.firstMatchingPath("settings.pkl", envVars, true, Files::exists)) + .isEqualTo(xdgHome.resolve("pkl/settings.pkl")) + } + + @Test + 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.firstMatchingPath("settings.pkl", envVars, true, Files::exists)) + .isEqualTo(appData.resolve("pkl/settings.pkl")) + } + + @Test + 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.firstMatchingPath("settings.pkl", envVars, false, Files::exists)).isNull() + } + + @Test + 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.firstMatchingPath("cache.db", envVars, true, Files::exists)) + .isEqualTo(localAppData.resolve("pkl/Cache/cache.db")) + } + + @Test + 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.firstMatchingPath(subpath, emptyMap(), false, Files::exists)).isNull() + assertThat(subject.firstMatchingPath(subpath, emptyMap(), true, Files::exists)).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 `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.firstMatchingPath("missing.pkl", envVars, false, Files::exists)).isNull() + } +} 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..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,9 +16,11 @@ 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; +import java.util.Locale; import java.util.Map; import java.util.Objects; import org.jspecify.annotations.Nullable; @@ -26,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}. @@ -33,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; @@ -67,7 +73,42 @@ 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`. + var xdgConfig = environmentVariables.get("XDG_CACHE_HOME"); + 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 && !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"); + } + + 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..20ce0561c 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. +/// 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 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