Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **Document links** (`textDocument/documentLink`) — `import QtQuick`, `import QtQuick.Controls`, and relative `import "./components"` statements are now clickable. Named modules jump to the `qmldir` discovered at startup; relative imports jump to the target directory's `qmldir` when present, otherwise to the directory itself. Dotted module names fall back to parent modules when the exact name isn't registered.

## [1.6.0] - 2026-04-16

### Added
Expand Down
122 changes: 122 additions & 0 deletions handler/documentlinks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package handler

import (
"context"
"os"
"path/filepath"
"strings"

"github.com/odvcencio/gotreesitter"
"github.com/owenrumney/go-lsp/lsp"
)

// DocumentLink returns one clickable link per `import` statement in the
// document. Named modules (e.g. `import QtQuick`) resolve to the qmldir file
// discovered at startup. String-literal imports (e.g. `import "./components"`)
// resolve relative to the current document, preferring a qmldir inside the
// target directory when one exists.
func (h *Handler) DocumentLink(_ context.Context, params *lsp.DocumentLinkParams) ([]lsp.DocumentLink, error) {
uri := params.TextDocument.URI
doc, ok := h.getDocument(uri)
if !ok || h.parser == nil {
return nil, nil
}
tree := h.parser.GetTree(uri)
if tree == nil {
return nil, nil
}
root := tree.RootNode()
if root == nil {
return nil, nil
}

lang := h.parser.Language()
content := []byte(doc)
docDir := filepath.Dir(uriToPath(uri))

var links []lsp.DocumentLink
walkTree(root, func(n *gotreesitter.Node) bool {
if n.Type(lang) != "ui_import" {
return true
}
if link, ok := importLink(n, lang, content, docDir); ok {
links = append(links, link)
}
return false
})
return links, nil
}

// importLink builds a DocumentLink for a single ui_import node. Returns
// ok=false when the import has no resolvable target.
func importLink(n *gotreesitter.Node, lang *gotreesitter.Language, content []byte, docDir string) (lsp.DocumentLink, bool) {
source := n.ChildByFieldName("source", lang)
if source == nil {
return lsp.DocumentLink{}, false
}
rng := nodeRange(content, source)
text := string(content[source.StartByte():source.EndByte()])

target, tooltip, ok := resolveImportTarget(source.Type(lang), text, docDir)
if !ok {
return lsp.DocumentLink{}, false
}
targetURI := lsp.DocumentURI(pathToURI(target))
return lsp.DocumentLink{
Range: rng,
Target: &targetURI,
Tooltip: tooltip,
}, true
}

// resolveImportTarget returns the absolute filesystem path the import should
// navigate to, along with a human-readable tooltip. sourceType is the
// tree-sitter node type of the `source` field: "string" for quoted paths,
// anything else is treated as a qualified module id.
func resolveImportTarget(sourceType, text, docDir string) (target, tooltip string, ok bool) {
if sourceType == "string" {
// Strip the surrounding quotes — tree-sitter gives us the whole literal
// including delimiters.
rel := strings.Trim(text, "\"'`")
if rel == "" {
return "", "", false
}
abs := rel
if !filepath.IsAbs(abs) && docDir != "" {
abs = filepath.Join(docDir, rel)
}
abs = filepath.Clean(abs)
info, err := os.Stat(abs)
if err != nil {
return "", "", false
}
if info.IsDir() {
// Prefer the qmldir inside the directory so the editor opens it
// rather than trying to render a folder.
if qd := filepath.Join(abs, "qmldir"); fileExists(qd) {
return qd, "Open qmldir for " + rel, true
}
return abs, "Open directory " + rel, true
}
return abs, "Open " + rel, true
}
// Qualified module id — may be dotted ("QtQuick.Controls"). Try the full
// name first, then successively strip trailing segments.
name := text
for name != "" {
if dir := LookupModuleQMLDir(name); dir != "" {
return dir, "Open qmldir for " + name, true
}
idx := strings.LastIndex(name, ".")
if idx < 0 {
break
}
name = name[:idx]
}
return "", "", false
}

func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
143 changes: 143 additions & 0 deletions handler/documentlinks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package handler

import (
"context"
"net/url"
"os"
"path/filepath"
"strings"
"testing"

"github.com/owenrumney/go-lsp/lsp"
)

func TestDocumentLinkQualifiedModule(t *testing.T) {
// Point a fake module name at a real qmldir path so resolution succeeds
// regardless of what Qt installations are present on the machine.
tmp := t.TempDir()
qmldir := filepath.Join(tmp, "qmldir")
if err := os.WriteFile(qmldir, []byte("module FakeMod\n"), 0o644); err != nil {
t.Fatalf("write qmldir: %v", err)
}
recordModuleQMLDir("FakeMod", qmldir)

doc := "import FakeMod\n\nRectangle {}\n"
h := newTestHandler(t, "test://mod.qml", doc)

links, err := h.DocumentLink(context.Background(), &lsp.DocumentLinkParams{
TextDocument: lsp.TextDocumentIdentifier{URI: "test://mod.qml"},
})
if err != nil {
t.Fatalf("DocumentLink: %v", err)
}
if len(links) != 1 {
t.Fatalf("expected one link, got %d", len(links))
}
if links[0].Target == nil {
t.Fatal("link target was nil")
}
if !strings.Contains(string(*links[0].Target), "qmldir") {
t.Errorf("target %q should reference qmldir", *links[0].Target)
}
// Range should cover the module name on the first line, not the `import`
// keyword or the whole line.
if links[0].Range.Start.Line != 0 || links[0].Range.Start.Character != 7 {
t.Errorf("range start = %+v, want line 0 char 7", links[0].Range.Start)
}
if links[0].Range.End.Character != 14 {
t.Errorf("range end char = %d, want 14 (end of 'FakeMod')", links[0].Range.End.Character)
}
}

func TestDocumentLinkDottedModuleFallsBack(t *testing.T) {
// `import QtQuick.Controls` when only QtQuick is registered should still
// resolve, by stripping the trailing segment.
tmp := t.TempDir()
qmldir := filepath.Join(tmp, "qmldir")
if err := os.WriteFile(qmldir, []byte("module FallbackMod\n"), 0o644); err != nil {
t.Fatalf("write qmldir: %v", err)
}
recordModuleQMLDir("FallbackMod", qmldir)

doc := "import FallbackMod.Sub\n\nRectangle {}\n"
h := newTestHandler(t, "test://fallback.qml", doc)

links, err := h.DocumentLink(context.Background(), &lsp.DocumentLinkParams{
TextDocument: lsp.TextDocumentIdentifier{URI: "test://fallback.qml"},
})
if err != nil {
t.Fatalf("DocumentLink: %v", err)
}
if len(links) != 1 {
t.Fatalf("expected one link, got %d", len(links))
}
if links[0].Target == nil || !strings.Contains(string(*links[0].Target), "qmldir") {
t.Errorf("target %v should reference qmldir", links[0].Target)
}
}

func TestDocumentLinkStringImportRelativeDir(t *testing.T) {
tmp := t.TempDir()
comp := filepath.Join(tmp, "components")
if err := os.MkdirAll(comp, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(comp, "qmldir"), []byte("module local\n"), 0o644); err != nil {
t.Fatalf("write qmldir: %v", err)
}

mainPath := filepath.Join(tmp, "main.qml")
if err := os.WriteFile(mainPath, []byte(""), 0o644); err != nil {
t.Fatalf("write main: %v", err)
}
uri := lsp.DocumentURI((&url.URL{Scheme: "file", Path: filepath.ToSlash(mainPath)}).String())

doc := "import \"./components\"\n\nRectangle {}\n"
h := newTestHandler(t, uri, doc)

links, err := h.DocumentLink(context.Background(), &lsp.DocumentLinkParams{
TextDocument: lsp.TextDocumentIdentifier{URI: uri},
})
if err != nil {
t.Fatalf("DocumentLink: %v", err)
}
if len(links) != 1 {
t.Fatalf("expected one link, got %d", len(links))
}
if links[0].Target == nil {
t.Fatal("link target was nil")
}
if !strings.HasSuffix(string(*links[0].Target), "components/qmldir") {
t.Errorf("target %q should end with components/qmldir", *links[0].Target)
}
}

func TestDocumentLinkUnknownModuleDropped(t *testing.T) {
doc := "import ThisDefinitelyDoesNotExistAnywhere\n\nRectangle {}\n"
h := newTestHandler(t, "test://unknown.qml", doc)

links, err := h.DocumentLink(context.Background(), &lsp.DocumentLinkParams{
TextDocument: lsp.TextDocumentIdentifier{URI: "test://unknown.qml"},
})
if err != nil {
t.Fatalf("DocumentLink: %v", err)
}
if len(links) != 0 {
t.Errorf("expected no links for unresolvable module, got %d", len(links))
}
}

func TestResolveImportTargetStringPrefersQMLDir(t *testing.T) {
tmp := t.TempDir()
if err := os.WriteFile(filepath.Join(tmp, "qmldir"), []byte(""), 0o644); err != nil {
t.Fatalf("write qmldir: %v", err)
}

target, _, ok := resolveImportTarget("string", "\".\"", tmp)
if !ok {
t.Fatal("expected resolution to succeed")
}
if filepath.Base(target) != "qmldir" {
t.Errorf("target = %q, want qmldir", target)
}
}
1 change: 1 addition & 0 deletions handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ func (h *Handler) Initialize(_ context.Context, params *lsp.InitializeParams) (*
WorkspaceSymbolProvider: boolPtr(true),
DocumentFormattingProvider: boolPtr(true),
DocumentRangeFormattingProvider: boolPtr(true),
DocumentLinkProvider: &lsp.DocumentLinkOptions{},
},
ServerInfo: &lsp.ServerInfo{
Name: "qml-language-server",
Expand Down
30 changes: 30 additions & 0 deletions handler/qmltypes_discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,34 @@ func appendUnique(paths []string, p string) []string {
type discoveredModule struct {
moduleName string
qmltypesPath string
qmldirPath string
}

// moduleQMLDirs maps module name to the absolute path of the qmldir file that
// declared it. Populated by DiscoverAndRegisterQMLTypes and used by document
// links to resolve `import Foo` targets. First writer wins so Qt6 takes
// precedence over Qt5 when both are installed.
var (
moduleDirsMu sync.RWMutex
moduleQMLDirs = map[string]string{}
)

func recordModuleQMLDir(name, path string) {
if name == "" || path == "" {
return
}
moduleDirsMu.Lock()
if _, ok := moduleQMLDirs[name]; !ok {
moduleQMLDirs[name] = path
}
moduleDirsMu.Unlock()
}

// LookupModuleQMLDir returns the qmldir path registered for a module, or "".
func LookupModuleQMLDir(name string) string {
moduleDirsMu.RLock()
defer moduleDirsMu.RUnlock()
return moduleQMLDirs[name]
}

// qmlImportPaths returns directories to scan for QML modules.
Expand Down Expand Up @@ -149,7 +177,9 @@ func discoverModules(root string) []discoveredModule {
modules = append(modules, discoveredModule{
moduleName: qmld.Name,
qmltypesPath: typesPath,
qmldirPath: path,
})
recordModuleQMLDir(qmld.Name, path)
return nil
})
return modules
Expand Down
Loading