mirror of
https://github.com/apple/pkl.git
synced 2026-09-13 13:21:47 +02:00
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:
co-authored by
Florin Ungur
parent
4dd37219c0
commit
6551a59f9e
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user