From e30b005dc1894eb7601f3cc610b5db0e9d27b190 Mon Sep 17 00:00:00 2001 From: Matthew Cushing Date: Thu, 16 Apr 2026 21:43:46 -0600 Subject: [PATCH] Cross-file go-to-definition for workspace components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit go-to-definition on a user-defined QML component (e.g. `MyButton` where `MyButton.qml` exists in the workspace) now returns the URI of the .qml file that defines it. Previously only built-in Qt types navigated to their import line and workspace components silently dropped through since their registry entry has an empty Module. Adds a small name→URI lookup populated by the workspace scanner so definition.go can resolve cross-file targets without coupling to the Handler. --- CHANGELOG.md | 1 + handler/definition.go | 13 ++++++++- handler/definition_test.go | 58 ++++++++++++++++++++++++++++++++++++++ handler/workspace.go | 25 ++++++++++++++++ 4 files changed, 96 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a11df0..223022e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. +- **Cross-file go-to-definition for workspace components** — `gd` on a user-defined component like `MyButton` now jumps to `MyButton.qml` in the workspace rather than staying in the current file. Built-in Qt types still navigate to the originating `import` line. ## [1.6.0] - 2026-04-16 diff --git a/handler/definition.go b/handler/definition.go index e733953..6820da6 100644 --- a/handler/definition.go +++ b/handler/definition.go @@ -132,7 +132,18 @@ func findIdDeclarations(idName string, root *gotreesitter.Node, lang *gotreesitt func findComponentDefinition(node *gotreesitter.Node, lang *gotreesitter.Language, content []byte, root *gotreesitter.Node, uri lsp.DocumentURI) []lsp.Location { name := string(content[node.StartByte():node.EndByte()]) sym, ok := lookupSymbol(name) - if !ok || sym.Module == "" { + if !ok { + return nil + } + // Workspace components resolve to their .qml file. Use the start of the + // file as the target range — editors navigate to the top of the file. + if sym.Category == "workspace" { + if target := LookupWorkspaceURI(name); target != "" { + return []lsp.Location{{URI: target, Range: lsp.Range{}}} + } + return nil + } + if sym.Module == "" { return nil } return findImportForModule(root, lang, content, sym.Module, uri) diff --git a/handler/definition_test.go b/handler/definition_test.go index 7f148fa..524e7ce 100644 --- a/handler/definition_test.go +++ b/handler/definition_test.go @@ -70,6 +70,64 @@ func TestDefinitionOnUnknownReturnsEmpty(t *testing.T) { } } +func TestDefinitionJumpsToWorkspaceComponent(t *testing.T) { + // Simulate the workspace index having indexed MyWidget.qml by registering + // the component URI directly. The registry is shared package state, so + // tests must register unique names to avoid cross-test leakage. + targetURI := lsp.DocumentURI("file:///tmp/workspace/MyTestWidgetXYZ.qml") + registerSymbols(QMLSymbol{ + Label: "MyTestWidgetXYZ", + Category: "workspace", + }) + recordWorkspaceURI("MyTestWidgetXYZ", targetURI) + + doc := "import QtQuick\n\nMyTestWidgetXYZ {}\n" + h := newTestHandler(t, "test://use.qml", doc) + + locs, err := h.Definition(context.Background(), &lsp.DefinitionParams{ + TextDocumentPositionParams: lsp.TextDocumentPositionParams{ + TextDocument: lsp.TextDocumentIdentifier{URI: "test://use.qml"}, + Position: lsp.Position{Line: 2, Character: 5}, // inside MyTestWidgetXYZ + }, + }) + if err != nil { + t.Fatalf("Definition: %v", err) + } + if len(locs) != 1 { + t.Fatalf("expected one location, got %d", len(locs)) + } + if locs[0].URI != targetURI { + t.Errorf("URI = %q, want %q", locs[0].URI, targetURI) + } +} + +func TestDefinitionWorkspaceWithoutURIReturnsEmpty(t *testing.T) { + // A workspace-category symbol with no recorded URI (shouldn't happen in + // practice but guards against a stale registry entry) must not crash + // or return a zero-URI location. + registerSymbols(QMLSymbol{ + Label: "OrphanedWidgetABC", + Category: "workspace", + }) + // Deliberately do not call recordWorkspaceURI. + + doc := "import QtQuick\n\nOrphanedWidgetABC {}\n" + h := newTestHandler(t, "test://orphan.qml", doc) + + locs, err := h.Definition(context.Background(), &lsp.DefinitionParams{ + TextDocumentPositionParams: lsp.TextDocumentPositionParams{ + TextDocument: lsp.TextDocumentIdentifier{URI: "test://orphan.qml"}, + Position: lsp.Position{Line: 2, Character: 5}, + }, + }) + if err != nil { + t.Fatalf("Definition: %v", err) + } + if len(locs) != 0 { + t.Errorf("expected no locations for orphaned workspace symbol, got %d", len(locs)) + } +} + func TestExtractIdFromBinding(t *testing.T) { cases := []struct { in string diff --git a/handler/workspace.go b/handler/workspace.go index a9b0d66..e6ebb6c 100644 --- a/handler/workspace.go +++ b/handler/workspace.go @@ -98,6 +98,31 @@ func publishWorkspaceSymbol(c workspaceComponent) { Description: "Defined in `" + c.Path + "`.", Category: "workspace", }) + recordWorkspaceURI(c.Name, c.URI) +} + +// workspaceURIs maps component name → file URI so go-to-definition can jump +// cross-file without coupling definition.go to the Handler. Populated every +// time a workspace component is published. +var ( + workspaceURIsMu sync.RWMutex + workspaceURIs = map[string]lsp.DocumentURI{} +) + +func recordWorkspaceURI(name string, uri lsp.DocumentURI) { + if name == "" || uri == "" { + return + } + workspaceURIsMu.Lock() + workspaceURIs[name] = uri + workspaceURIsMu.Unlock() +} + +// LookupWorkspaceURI returns the file URI for a workspace component, or "". +func LookupWorkspaceURI(name string) lsp.DocumentURI { + workspaceURIsMu.RLock() + defer workspaceURIsMu.RUnlock() + return workspaceURIs[name] } // registerURI indexes a single QML document URI, used when a file is opened