Use XDG base directories and Known Folders on Windows (#1809)

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`

---------

Co-authored-by: Florin Ungur <florin@florinungur.com>
This commit is contained in:
Daniel Chao
2026-08-20 21:50:24 +00:00
committed by GitHub
co-authored by Florin Ungur
parent 4dd37219c0
commit 6551a59f9e
24 changed files with 799 additions and 40 deletions
@@ -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`."
)
@@ -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()
}
}