diff --git a/CHANGELOG.md b/CHANGELOG.md index cb58de9..4a11df0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/handler/documentlinks.go b/handler/documentlinks.go new file mode 100644 index 0000000..7b95059 --- /dev/null +++ b/handler/documentlinks.go @@ -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() +} diff --git a/handler/documentlinks_test.go b/handler/documentlinks_test.go new file mode 100644 index 0000000..269c481 --- /dev/null +++ b/handler/documentlinks_test.go @@ -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) + } +} diff --git a/handler/handler.go b/handler/handler.go index 25aaa50..9bae166 100644 --- a/handler/handler.go +++ b/handler/handler.go @@ -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", diff --git a/handler/qmltypes_discovery.go b/handler/qmltypes_discovery.go index 6a013e9..4fc2da8 100644 --- a/handler/qmltypes_discovery.go +++ b/handler/qmltypes_discovery.go @@ -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. @@ -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