diff --git a/src/main/kotlin/org/pkl/lsp/Project.kt b/src/main/kotlin/org/pkl/lsp/Project.kt index 882bb5d7..4c03dc71 100644 --- a/src/main/kotlin/org/pkl/lsp/Project.kt +++ b/src/main/kotlin/org/pkl/lsp/Project.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. @@ -55,6 +55,8 @@ class Project(private val server: PklLspServer) { val pklParser: PklParser by lazy { PklParser(this) } + val modulepathResolver: ModulepathResolver by lazy { ModulepathResolver(this) } + fun initialize(): CompletableFuture<*> { return CompletableFuture.allOf(*myComponents.map { it.initialize() }.toTypedArray()) } diff --git a/src/main/kotlin/org/pkl/lsp/ast/PklModuleUri.kt b/src/main/kotlin/org/pkl/lsp/ast/PklModuleUri.kt index 26ed77ec..4d2f5703 100644 --- a/src/main/kotlin/org/pkl/lsp/ast/PklModuleUri.kt +++ b/src/main/kotlin/org/pkl/lsp/ast/PklModuleUri.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. @@ -18,11 +18,7 @@ package org.pkl.lsp.ast import io.github.treesitter.jtreesitter.Node import java.nio.file.Path import org.pkl.lsp.* -import org.pkl.lsp.FsFile -import org.pkl.lsp.HttpsFile -import org.pkl.lsp.JarFile import org.pkl.lsp.LspUtil.firstInstanceOf -import org.pkl.lsp.VirtualFile import org.pkl.lsp.packages.dto.PackageUri import org.pkl.lsp.packages.dto.PklProject import org.pkl.lsp.util.CachedValue @@ -125,6 +121,7 @@ class PklModuleUriImpl(project: Project, override val parent: PklNode, override ?.resolve(targetUri.fragment) vfile?.getModule()?.get() } + "modulepath" -> project.modulepathResolver.resolve(targetUri.path, context) // targetUri is a relative URI null -> { when { diff --git a/src/main/kotlin/org/pkl/lsp/completion/ModuleUriCompletionProvider.kt b/src/main/kotlin/org/pkl/lsp/completion/ModuleUriCompletionProvider.kt index e36de613..f3c87236 100644 --- a/src/main/kotlin/org/pkl/lsp/completion/ModuleUriCompletionProvider.kt +++ b/src/main/kotlin/org/pkl/lsp/completion/ModuleUriCompletionProvider.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. @@ -37,6 +37,7 @@ class ModuleUriCompletionProvider(project: Project, private val packageUriOnly: private const val FILE_SCHEME = "file:///" private const val HTTPS_SCHEME = "https://" private const val PACKAGE_SCHEME = "package://" + private const val MODULEPATH_SCHEME = "modulepath:/" private val SCHEME_ELEMENTS = listOf( @@ -44,6 +45,7 @@ class ModuleUriCompletionProvider(project: Project, private val packageUriOnly: CompletionItem(FILE_SCHEME), CompletionItem(HTTPS_SCHEME), CompletionItem(PACKAGE_SCHEME), + CompletionItem(MODULEPATH_SCHEME), ) private val GLOBBABLE_SCHEME_ELEMENTS = @@ -183,6 +185,21 @@ class ModuleUriCompletionProvider(project: Project, private val packageUriOnly: } } } + targetUri.startsWith(MODULEPATH_SCHEME) -> { + val context = sourceModule.virtualFile.pklProject + val roots = + project.modulepathResolver.paths(context).mapNotNull(project.virtualFileManager::get) + completeHierarchicalUri( + roots, + ".", + targetUri.substring(12), + collector, + isGlobImport = isGlobImport, + isAbsoluteUri = true, + stringCharsNode = stringChars, + targetUri = targetUri, + ) + } targetUri.startsWith("@") && !packageUriOnly -> { val dependencies = sourceModule.dependencies(null) ?: return if (!targetUri.contains('/')) { diff --git a/src/main/kotlin/org/pkl/lsp/packages/dto/PklProject.kt b/src/main/kotlin/org/pkl/lsp/packages/dto/PklProject.kt index 371c4754..abaf2ecd 100644 --- a/src/main/kotlin/org/pkl/lsp/packages/dto/PklProject.kt +++ b/src/main/kotlin/org/pkl/lsp/packages/dto/PklProject.kt @@ -1,5 +1,5 @@ /* - * Copyright © 2024 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. @@ -49,7 +49,11 @@ data class PklProject(val metadata: DerivedProjectMetadata, val projectDeps: Pro val evaluatorSettings: EvaluatorSettings?, ) - @Serializable data class EvaluatorSettings(val moduleCacheDir: String? = null) + @Serializable + data class EvaluatorSettings( + val moduleCacheDir: String? = null, + val modulePath: List? = null, + ) @Serializable data class ProjectDeps( diff --git a/src/main/kotlin/org/pkl/lsp/services/ModulepathResolver.kt b/src/main/kotlin/org/pkl/lsp/services/ModulepathResolver.kt new file mode 100644 index 00000000..2195fb89 --- /dev/null +++ b/src/main/kotlin/org/pkl/lsp/services/ModulepathResolver.kt @@ -0,0 +1,49 @@ +/* + * 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.lsp.services + +import java.nio.file.Files +import java.nio.file.Path +import org.pkl.lsp.Component +import org.pkl.lsp.Project +import org.pkl.lsp.ast.PklModule +import org.pkl.lsp.packages.dto.PklProject + +class ModulepathResolver(project: Project) : Component(project) { + fun resolve(path: String, context: PklProject?): PklModule? { + val path = path.trimStart('/') + val file = + project.settingsManager.settings.modulepath + .map { it.resolve(path) } + .firstOrNull(Files::exists) + ?: context + ?.metadata + ?.evaluatorSettings + ?.modulePath + ?.map { context.projectDir.resolve(it, path) } + ?.firstOrNull(Files::exists) + ?: return null + return project.virtualFileManager.get(file)?.getModule()?.get() + } + + fun paths(context: PklProject?): List { + val fromSettings = project.settingsManager.settings.modulepath + val fromProject = + context?.metadata?.evaluatorSettings?.modulePath?.map(context.projectDir::resolve) + ?: return fromSettings + return fromSettings + fromProject + } +} diff --git a/src/main/kotlin/org/pkl/lsp/services/SettingsManager.kt b/src/main/kotlin/org/pkl/lsp/services/SettingsManager.kt index 57d81618..bcaa4b83 100644 --- a/src/main/kotlin/org/pkl/lsp/services/SettingsManager.kt +++ b/src/main/kotlin/org/pkl/lsp/services/SettingsManager.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. @@ -17,6 +17,7 @@ package org.pkl.lsp.services import com.google.gson.JsonElement import com.google.gson.JsonPrimitive +import java.nio.file.Files import java.nio.file.Path import java.util.concurrent.CompletableFuture import kotlin.io.path.isExecutable @@ -30,6 +31,7 @@ import org.pkl.lsp.messages.ActionableNotification data class WorkspaceSettings( var pklCliPath: Path? = null, var grammarVersion: GrammarVersion? = null, + var modulepath: List = listOf(), ) class SettingsManager(project: Project) : Component(project) { @@ -77,6 +79,24 @@ class SettingsManager(project: Project) : Component(project) { } } + private fun resolveModulepath(value: JsonElement): List { + if (value.isJsonNull) return listOf() + if (!value.isJsonArray) { + logger.warn("Got non-array value for configuration: pkl.modulepath. Value: $value") + return listOf() + } + return buildList { + for (path in value.asJsonArray) { + val decodedPath = decodeString(path, "pkl.modulepath") + if (path != null) { + val entry = Path.of(path.asString) + if (!Files.exists(entry)) logger.warn("Entry in pkl.modulepath does not exist: $entry") + add(entry) + } + } + } + } + private fun loadSettings(): CompletableFuture { logger.log("Fetching configuration") val params = @@ -90,14 +110,21 @@ class SettingsManager(project: Project) : Component(project) { scopeUri = "Pkl" section = "pkl.formatter.grammarVersion" }, + ConfigurationItem().apply { + scopeUri = "Pkl" + section = "pkl.modulepath" + }, ) ) return project.languageClient .configuration(params) - .thenApply { (cliPath, grammarVersion) -> - logger.log("Got configuration: cliPath = $cliPath, grammarVersion = $grammarVersion") + .thenApply { (cliPath, grammarVersion, modulepath) -> + logger.log( + "Got configuration: cliPath = $cliPath, grammarVersion = $grammarVersion, modulepath = $modulepath" + ) settings.pklCliPath = resolvePklCliPath(cliPath as JsonElement) settings.grammarVersion = resolveGrammarVersion(grammarVersion as JsonElement) + settings.modulepath = resolveModulepath(modulepath as JsonElement) } .exceptionally { logger.error("Failed to fetch settings: ${it.cause}") } .whenComplete { _, _ -> logger.log("Settings changed to $settings") } diff --git a/src/main/kotlin/org/pkl/lsp/type/InferResourceType.kt b/src/main/kotlin/org/pkl/lsp/type/InferResourceType.kt index a5d8041b..e1d8d2d1 100644 --- a/src/main/kotlin/org/pkl/lsp/type/InferResourceType.kt +++ b/src/main/kotlin/org/pkl/lsp/type/InferResourceType.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. @@ -48,6 +48,7 @@ fun inferResourceType( stringChars.startsWith("file:", ignoreCase = true) -> base.resourceType stringChars.startsWith("http:", ignoreCase = true) -> base.resourceType stringChars.startsWith("https:", ignoreCase = true) -> base.resourceType + stringChars.startsWith("modulepath:", ignoreCase = true) -> base.resourceType !stringChars.contains(":") -> base.resourceType else -> // bail out diff --git a/src/test/kotlin/org/pkl/lsp/CompletionFeatureTest.kt b/src/test/kotlin/org/pkl/lsp/CompletionFeatureTest.kt index 02589ae3..85cc4024 100644 --- a/src/test/kotlin/org/pkl/lsp/CompletionFeatureTest.kt +++ b/src/test/kotlin/org/pkl/lsp/CompletionFeatureTest.kt @@ -1,5 +1,5 @@ /* - * Copyright © 2025 Apple Inc. and the Pkl project authors. All rights reserved. + * Copyright © 2025-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. @@ -24,7 +24,7 @@ class CompletionFeatureTest : LspTestBase() { createPklFile("amends \"\"") val completions = getCompletions() assertThat(completions.map { it.label }) - .isEqualTo(listOf("pkl:", "file:///", "https://", "package://", "main.pkl")) + .isEqualTo(listOf("pkl:", "file:///", "https://", "package://", "modulepath:/", "main.pkl")) } @Test @@ -32,7 +32,7 @@ class CompletionFeatureTest : LspTestBase() { createPklFile("import \"\"") val completions = getCompletions() assertThat(completions.map { it.label }) - .isEqualTo(listOf("pkl:", "file:///", "https://", "package://", "main.pkl")) + .isEqualTo(listOf("pkl:", "file:///", "https://", "package://", "modulepath:/", "main.pkl")) } @Test @@ -40,6 +40,15 @@ class CompletionFeatureTest : LspTestBase() { createPklFile("res = import(\"\")") val completions = getCompletions() assertThat(completions.map { it.label }) - .isEqualTo(listOf("pkl:", "file:///", "https://", "package://", "main.pkl")) + .isEqualTo(listOf("pkl:", "file:///", "https://", "package://", "modulepath:/", "main.pkl")) + } + + @Test + fun `complete modulepath import`() { + fakeProject.settingsManager.settings.modulepath = listOf(testProjectDir.resolve("lib")) + createPklFile("lib/target.pkl", "") + createPklFile("import \"modulepath:/\"") + val completions = getCompletions() + assertThat(completions.map { it.label }).isEqualTo(listOf("target.pkl")) } } diff --git a/src/test/kotlin/org/pkl/lsp/GoToDefinitionTest.kt b/src/test/kotlin/org/pkl/lsp/GoToDefinitionTest.kt index 72b53c87..7f0e3690 100644 --- a/src/test/kotlin/org/pkl/lsp/GoToDefinitionTest.kt +++ b/src/test/kotlin/org/pkl/lsp/GoToDefinitionTest.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. @@ -62,7 +62,7 @@ class GoToDefinitionTest : LspTestBase() { createPklFile( """ function foo() = 42 - + result = foo() """ .trimIndent() @@ -94,7 +94,7 @@ class GoToDefinitionTest : LspTestBase() { createPklFile( """ class Person - + person: Person """ .trimIndent() @@ -132,7 +132,7 @@ class GoToDefinitionTest : LspTestBase() { createPklFile( """ import "lib.pkl" - + result = lib.foo """ .trimIndent() @@ -218,4 +218,138 @@ class GoToDefinitionTest : LspTestBase() { assertThat(resolved[0]).isInstanceOf(PklClassProperty::class.java) assertThat((resolved[0] as PklClassProperty).name).isEqualTo("name") } + + @Test + fun `resolve modulepath import`() { + fakeProject.settingsManager.settings.modulepath = listOf(testProjectDir.resolve("lib")) + createPklFile("lib/target.pkl", "") + createPklFile( + """ + import "modulepath:/target.pkl" + """ + .trimIndent() + ) + val resolved = goToDefinition() + assertThat(resolved).hasSize(1) + assertThat(resolved[0]).isInstanceOf(PklModule::class.java) + val resolvedFile = resolved.first().containingFile + assertThat(resolvedFile.uri.path).endsWith("/lib/target.pkl") + } + + @Test + fun `resolve field in modulepath`() { + fakeProject.settingsManager.settings.modulepath = listOf(testProjectDir.resolve("lib")) + createPklFile( + "lib/Target.pkl", + """ + module Target + + field: String + """ + .trimIndent(), + ) + createPklFile( + """ + import "modulepath:/Target.pkl" + + target: Target = new { + field = "field" + } + """ + .trimIndent() + ) + val resolved = goToDefinition() + assertThat(resolved).hasSize(1) + assertThat(resolved[0]).isInstanceOf(PklClassProperty::class.java) + assertThat((resolved[0] as PklClassProperty).name).isEqualTo("field") + val resolvedFile = resolved.first().containingFile + assertThat(resolvedFile.uri.path).endsWith("/lib/Target.pkl") + } + + @Test + fun `resolve relative field in modulepath`() { + fakeProject.settingsManager.settings.modulepath = listOf(testProjectDir.resolve("lib")) + createPklFile( + "lib/Target.pkl", + """ + module Target + + field: String + """ + .trimIndent(), + ) + createPklFile( + "lib/Stopover.pkl", + """ + module Stopover + + import "./Target.pkl" + + target: Target + """ + .trimIndent(), + ) + createPklFile( + """ + import "modulepath:/Stopover.pkl" + + stopover: Stopover = new { + target { + field = "field" + } + } + """ + .trimIndent() + ) + val resolved = goToDefinition() + assertThat(resolved).hasSize(1) + assertThat(resolved[0]).isInstanceOf(PklClassProperty::class.java) + assertThat((resolved[0] as PklClassProperty).name).isEqualTo("field") + val resolvedFile = resolved.first().containingFile + assertThat(resolvedFile.uri.path).endsWith("/lib/Target.pkl") + } + + @Test + fun `resolve modulepath field in modulepath`() { + fakeProject.settingsManager.settings.modulepath = + listOf(testProjectDir.resolve("nested"), testProjectDir.resolve("lib")) + createPklFile( + "nested/Target.pkl", + """ + module Target + + field: String + """ + .trimIndent(), + ) + createPklFile( + "lib/Stopover.pkl", + """ + module Stopover + + import "modulepath:/Target.pkl" + + target: Target + """ + .trimIndent(), + ) + createPklFile( + """ + import "modulepath:/Stopover.pkl" + + stopover: Stopover = new { + target { + field = "field" + } + } + """ + .trimIndent() + ) + val resolved = goToDefinition() + assertThat(resolved).hasSize(1) + assertThat(resolved[0]).isInstanceOf(PklClassProperty::class.java) + assertThat((resolved[0] as PklClassProperty).name).isEqualTo("field") + val resolvedFile = resolved.first().containingFile + assertThat(resolvedFile.uri.path).endsWith("/nested/Target.pkl") + } } diff --git a/src/test/kotlin/org/pkl/lsp/LspTestBase.kt b/src/test/kotlin/org/pkl/lsp/LspTestBase.kt index 5563730c..e0e8429c 100644 --- a/src/test/kotlin/org/pkl/lsp/LspTestBase.kt +++ b/src/test/kotlin/org/pkl/lsp/LspTestBase.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. @@ -18,6 +18,7 @@ package org.pkl.lsp import java.net.URI import java.nio.file.Files import java.nio.file.Path +import kotlin.io.path.createParentDirectories import kotlin.io.path.name import kotlin.io.path.readText import kotlin.io.path.writeText @@ -121,7 +122,11 @@ abstract class LspTestBase { val caret = contents.indexOf("") val effectiveContents = if (caret == -1) contents else contents.replaceRange(caret, caret + 7, "") - val file = testProjectDir.resolve(name).also { it.writeText(effectiveContents) } + val file = + testProjectDir + .resolve(name) + .also { it.createParentDirectories() } + .also { it.writeText(effectiveContents) } // need to trigger this so the LSP knows about this file. server.textDocumentService.didOpen( DidOpenTextDocumentParams(file.toTextDocument(effectiveContents)) diff --git a/src/test/kotlin/org/pkl/lsp/SyncProjectsTest.kt b/src/test/kotlin/org/pkl/lsp/SyncProjectsTest.kt index 9d35b6ff..eaa6b841 100644 --- a/src/test/kotlin/org/pkl/lsp/SyncProjectsTest.kt +++ b/src/test/kotlin/org/pkl/lsp/SyncProjectsTest.kt @@ -1,5 +1,5 @@ /* - * Copyright © 2024 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. @@ -20,6 +20,7 @@ import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Test +import org.pkl.lsp.ast.PklModuleImpl import org.pkl.lsp.services.PklWorkspaceState class SyncProjectsTest : LspTestBase() { @@ -34,12 +35,18 @@ class SyncProjectsTest : LspTestBase() { "PklProject", """ amends "pkl:Project" - + dependencies { ["appEnvCluster"] { uri = "package://pkg.pkl-lang.org/pkl-pantry/k8s.contrib.appEnvCluster@1.0.2" } } + + evaluatorSettings { + modulePath { + "lib" + } + } """ .trimIndent(), ) @@ -84,6 +91,8 @@ class SyncProjectsTest : LspTestBase() { "package://pkg.pkl-lang.org/pkl-pantry/k8s.contrib.appEnvCluster@1", ) assertThat(project.declaredDependencies).containsOnlyKeys("appEnvCluster") + assertThat(project.evaluatorSettings?.modulePath).hasSize(1) + assertThat(project.evaluatorSettings?.modulePath!![0]).isEqualTo("lib") } @Test @@ -124,4 +133,20 @@ class SyncProjectsTest : LspTestBase() { val resolvedFile = resolved.first().containingFile assertThat(resolvedFile).isInstanceOf(JarFile::class.java) } + + @Test + fun `resolve modulepath import`() { + createPklFile("lib/target.pkl", "") + createPklFile( + """ + import "modulepath:/target.pkl" + """ + .trimIndent() + ) + val resolved = goToDefinition() + assertThat(resolved).hasSize(1) + assertThat(resolved[0]).isInstanceOf(PklModuleImpl::class.java) + val resolvedFile = resolved.first().containingFile + assertThat(resolvedFile.uri.path).endsWith("/lib/target.pkl") + } }