Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/main/kotlin/org/pkl/lsp/PklClientOptions.kt
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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(
/**
Expand All @@ -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<String, String> =
mapOf(
DEFAULT_PKL_AUTHORITY to
"https://pkl-lang.org/package-docs/{packagePath}/{version}/{modulePath}/{path}"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't need users to provide any placeholders here. The relative path within a docsite can be derived by the module/class/method/property name.

It's always going to be:

pkgName.replace(".", "/") + "/" + version + "/" + moduleName.replace(".", "/") + ("index" | "classname") + ".html" + fragment?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The LSP doesn't really know where a package's documentation might exist. It might make sense to just accept a list of docsite URLs, where pkl-lsp tries each one to see if documentation exists. But, if we do this, we'd need some way to cache this so that we aren't hammering the documentation site needlessly.

Not all packages from pkg.pkl-lang.org exist in pkl-lang.org/package-docs. For example, packages published by users can be fetched from there, but we only publish package docs for the packages that we maintain ourselves.

Copy link
Contributor Author

@gordonbondon gordonbondon Sep 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about we have a config with package/module prefixes mapped to doc site url prefix?

Something like:

pkg.pkl-lang.org: pkl-lang.org/package-docs
pkg.pkl-lang.org/pkl-go: example.com/docs/
pkg.pkl-lang.org/pkl-go/pkl.golang: another.example.com/my-doc-root/

Default will be:

pkg.pkl-lang.org: pkl-lang.org/package-docs

Trying multiple urls and caching them for each hover seems like an overhead.

Looking at how other extensions handle this:

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For future improvement, how about having a url shema for docs similar to source code schema in package description? https://pkl-lang.org/package-docs/pkl/current/Project/Package.html#sourceCodeUrlScheme

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

godoc is a central docsite (all public go modules are available on godoc).
Currently, we don't have the concept of a central pkldoc site, but this is something that we've talked about hosting in the past.
If we have that, then we only need one setting for "this is where the docsite lives".

How about we have a config with package/module prefixes mapped to doc site url prefix?

For the pkg.pkl-lang.org stuff, we kind of need a "negative match" on packages that start with pkg.pkl-lang.org/github.com, rather than a prefix match.

For future improvement, how about having a url shema for docs similar to source code schema in package description? https://pkl-lang.org/package-docs/pkl/current/Project/Package.html#sourceCodeUrlScheme

The package metadata actually does have a field for documentation: https://pkl-lang.org/package-docs/pkl/current/Project/Package.html#documentation. This field will appear in the package metadata if set.
Perhaps, for now, we can just use that field in lieu of first checking a docsite.

),
) {
companion object {
val default = PklClientOptions()
Expand Down
99 changes: 99 additions & 0 deletions src/main/kotlin/org/pkl/lsp/ast/Extensions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -626,3 +626,102 @@ fun Appendable.renderParameterList(

fun <T> List<T>.withReplaced(idx: Int, elem: T): List<T> =
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("/")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make org.pkl.lsp.packages.dto.PackageUri#basePath public, and use that instead of packageUri.path.substringBeforeLast('@')

}
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<PklClass>()
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"
}
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


return urlPattern
.replace("{packagePath}", packagePath.removePrefix("/"))
.replace("{version}", version)
.replace("{modulePath}", modulePath)
.replace("{path}", docPath)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above re: it doesn't really make sense to have placeholders

}
28 changes: 28 additions & 0 deletions src/main/kotlin/org/pkl/lsp/documentation/toMarkdown.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be a misconfiguration error if this happens; seems like we should catch this at configuration time.

}

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)")
}
}
}
}
100 changes: 100 additions & 0 deletions src/test/kotlin/org/pkl/lsp/HoverTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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/[email protected]#/k8sSchema.pkl"

// Hover over the module reference
local schema = k8s<caret>Schema
"""
.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/[email protected]#/k8sSchema.pkl"

// Hover over a property from the package module
local templates = module.resourceTem<caret>plates
"""
.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/[email protected]#/api/apps/v1/Deployment.pkl"

local deployment = new Deployment {
// Hover over a property inside the Deployment class
spec {
repli<caret>cas = 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/[email protected]#/K8sObject.pkl"

local result = new K8sObject {}.hasUnique<caret>PortNames()
"""
.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: Str<caret>ing
"""
.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"
)
}
}