diff --git a/src/main/kotlin/org/pkl/lsp/PklClientOptions.kt b/src/main/kotlin/org/pkl/lsp/PklClientOptions.kt index 4f52ba6f..c309942c 100644 --- a/src/main/kotlin/org/pkl/lsp/PklClientOptions.kt +++ b/src/main/kotlin/org/pkl/lsp/PklClientOptions.kt @@ -1,5 +1,5 @@ /* - * Copyright © 2024 Apple Inc. and the Pkl project authors. All rights reserved. + * Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,6 +15,8 @@ */ package org.pkl.lsp +const val DEFAULT_PKL_AUTHORITY = "pkg.pkl-lang.org" + /** Additional options passed by a language client when initializing the LSP. */ data class PklClientOptions( /** @@ -24,6 +26,17 @@ data class PklClientOptions( /** Additional capabilities supported by this client. */ val extendedClientCapabilities: ExtendedClientCapabilities = ExtendedClientCapabilities(), + + /** + * Map of package server authorities to their documentation URL patterns. The URL pattern should + * contain placeholders: {packagePath}, {version}, {modulePath}, {path} Example: + * "https://pkl-lang.org/package-docs/{packagePath}/{version}/{modulePath}/{path}" + */ + val packageDocumentationUrls: Map = + mapOf( + DEFAULT_PKL_AUTHORITY to + "https://pkl-lang.org/package-docs/{packagePath}/{version}/{modulePath}/{path}" + ), ) { companion object { val default = PklClientOptions() diff --git a/src/main/kotlin/org/pkl/lsp/ast/Extensions.kt b/src/main/kotlin/org/pkl/lsp/ast/Extensions.kt index 9d68ce84..117ad59b 100644 --- a/src/main/kotlin/org/pkl/lsp/ast/Extensions.kt +++ b/src/main/kotlin/org/pkl/lsp/ast/Extensions.kt @@ -626,3 +626,102 @@ fun Appendable.renderParameterList( fun List.withReplaced(idx: Int, elem: T): List = toMutableList().apply { this[idx] = elem } + +fun PklNode.getDocumentationUrl(): String? { + val module = if (this is PklModule) this else enclosingModule ?: return null + val file = module.containingFile + + val packageDep = file.`package` + + val authorityUrl = + when { + packageDep == null && file.pklAuthority == Origin.STDLIB.name.lowercase() -> + DEFAULT_PKL_AUTHORITY + packageDep != null -> packageDep.packageUri.authority + else -> return null + } + + val packagePath = + when { + isInStdlib -> { + "pkl" + } + packageDep != null -> { + val packageUri = packageDep.packageUri + val authority = packageUri.authority + + authority + "/" + packageUri.path.substringBeforeLast('@').removePrefix("/") + } + else -> { + return null + } + } + + val version = + when { + packageDep == null -> module.effectivePklVersion.toString() + else -> packageDep.packageUri.version.toString() + } + + val urlPattern = + module.project.clientOptions.packageDocumentationUrls[authorityUrl] ?: return null + + // Extract the relative module path within the package + val moduleFileUri = file.uri.toString() + val modulePath = + when { + // Handle jar: URLs from packages + isInStdlib -> { + module.moduleName?.removePrefix("pkl.") ?: return null + } + moduleFileUri.startsWith("jar:") -> { + // Extract the path after the zip file reference + val afterJar = moduleFileUri.substringAfter("!/") + afterJar.removeSuffix(".pkl") + } + else -> { + // Fallback: use the module name + module.moduleName?.removeSuffix(".pkl") ?: return null + } + } + + val docPath = + when { + // For modules, use index.html + this is PklModule -> "index.html" + this is PklClass -> "$name.html" + + // For class members, use ClassName.html#memberName + else -> { + val enclosingClass = this.parentOfType() + if (enclosingClass != null) { + val className = enclosingClass.identifier?.text ?: return null + val fragmentId = + when (this) { + is PklProperty -> name + is PklMethod -> methodHeader.identifier?.text?.let { "$it()" } + is PklMethodHeader -> identifier?.text?.let { "$it()" } + else -> null + } + if (fragmentId != null) "$className.html#$fragmentId" else "$className.html" + } else { + // For top-level members, use index.html#memberName + val fragmentId = + when (this) { + is PklProperty -> name + is PklMethod -> methodHeader.identifier?.text?.let { "$it()" } + is PklMethodHeader -> identifier?.text?.let { "$it()" } + is PklTypeAlias -> identifier?.text + else -> null + } + if (fragmentId != null) "index.html#$fragmentId" else "index.html" + } + } + } + + return urlPattern + .replace("{packagePath}", packagePath.removePrefix("/")) + .replace("{version}", version) + .replace("{modulePath}", modulePath) + .replace("{path}", docPath) +} diff --git a/src/main/kotlin/org/pkl/lsp/documentation/toMarkdown.kt b/src/main/kotlin/org/pkl/lsp/documentation/toMarkdown.kt index 002a4a98..30ac8f86 100644 --- a/src/main/kotlin/org/pkl/lsp/documentation/toMarkdown.kt +++ b/src/main/kotlin/org/pkl/lsp/documentation/toMarkdown.kt @@ -229,6 +229,34 @@ private fun showDocCommentAndModule(node: PklNode?, text: String, context: PklPr appendLine() appendLine() append("in [${module.moduleName}](${module.getLocationUri(forDocs = true)})") + + val docUrl = node?.getDocumentationUrl() + if (docUrl != null) { + val domain = + try { + java.net.URI(docUrl).host + } catch (e: Exception) { + "documentation" + } + + val linkText = + when (node) { + is PklModule -> module.moduleName + is PklProperty -> node.name + is PklMethod -> node.methodHeader.identifier?.text?.let { "$it()" } ?: "method" + is PklMethodHeader -> node.identifier?.text?.let { "$it()" } ?: "method" + is PklClass -> node.identifier?.text ?: "class" + is PklTypeAlias -> node.identifier?.text ?: "type" + else -> module.moduleName + } + + appendLine() + appendLine() + append("---") + appendLine() + appendLine() + append("[`$linkText` on $domain]($docUrl)") + } } } } diff --git a/src/test/kotlin/org/pkl/lsp/HoverTest.kt b/src/test/kotlin/org/pkl/lsp/HoverTest.kt index ba0e7ef7..f8bd22f0 100644 --- a/src/test/kotlin/org/pkl/lsp/HoverTest.kt +++ b/src/test/kotlin/org/pkl/lsp/HoverTest.kt @@ -173,4 +173,104 @@ class HoverTest : LspTestBase() { val hoverText = getHoverText() assertThat(hoverText).contains("foo: UInt8") } + + @Test + fun `module with documentation URL`() { + createPklFile( + """ + import "package://pkg.pkl-lang.org/pkl-k8s/k8s@1.2.1#/k8sSchema.pkl" + + // Hover over the module reference + local schema = k8sSchema + """ + .trimIndent() + ) + val hoverText = getHoverText() + + assertThat(hoverText).contains("`k8s.k8sSchema` on pkl-lang.org") + assertThat(hoverText) + .contains( + "https://pkl-lang.org/package-docs/pkg.pkl-lang.org/pkl-k8s/k8s/1.2.1/k8sSchema/index.html" + ) + } + + @Test + fun `property with documentation URL`() { + createPklFile( + """ + amends "package://pkg.pkl-lang.org/pkl-k8s/k8s@1.2.1#/k8sSchema.pkl" + + // Hover over a property from the package module + local templates = module.resourceTemplates + """ + .trimIndent() + ) + val hoverText = getHoverText() + + assertThat(hoverText).contains("`resourceTemplates` on pkl-lang.org") + assertThat(hoverText) + .contains( + "https://pkl-lang.org/package-docs/pkg.pkl-lang.org/pkl-k8s/k8s/1.2.1/k8sSchema/index.html#resourceTemplates" + ) + } + + @Test + fun `class property with documentation URL`() { + createPklFile( + """ + import "package://pkg.pkl-lang.org/pkl-k8s/k8s@1.2.1#/api/apps/v1/Deployment.pkl" + + local deployment = new Deployment { + // Hover over a property inside the Deployment class + spec { + replicas = 1 + } + } + """ + .trimIndent() + ) + val hoverText = getHoverText() + + assertThat(hoverText).contains("`replicas` on pkl-lang.org") + assertThat(hoverText) + .contains( + "https://pkl-lang.org/package-docs/pkg.pkl-lang.org/pkl-k8s/k8s/1.2.1/api/apps/v1/Deployment/DeploymentSpec.html#replicas" + ) + } + + @Test + fun `method with documentation URL`() { + createPklFile( + """ + import "package://pkg.pkl-lang.org/pkl-k8s/k8s@1.2.1#/K8sObject.pkl" + + local result = new K8sObject {}.hasUniquePortNames() + """ + .trimIndent() + ) + val hoverText = getHoverText() + + assertThat(hoverText).contains("`hasUniquePortNames()` on pkl-lang.org") + assertThat(hoverText) + .contains( + "https://pkl-lang.org/package-docs/pkg.pkl-lang.org/pkl-k8s/k8s/1.2.1/K8sObject/index.html#hasUniquePortNames()" + ) + } + + @Test + fun `sdtlib base documentation URL`() { + createPklFile( + """ + result: String + """ + .trimIndent() + ) + val hoverText = getHoverText() + + assertThat(hoverText).contains("`String` on pkl-lang.org") + assertThat(hoverText) + .contains( + "https://pkl-lang.org/package-docs/pkl/${fakeProject.stdlib.version}/base/String.html" + ) + } }