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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 12 additions & 1 deletion handler/definition.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
58 changes: 58 additions & 0 deletions handler/definition_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions handler/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading