From 0f7dbf30a2e744be0a2a14e30bbea9566fb0cee8 Mon Sep 17 00:00:00 2001 From: Matthew Cushing Date: Thu, 16 Apr 2026 21:50:45 -0600 Subject: [PATCH] Type inference on `.` completions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing `root.` where `id: root` resolves to a specific type now offers that type's properties (including inherited ones via the prototype chain) instead of the generic property list. Implementation: * buildIDTypeIndex walks ui_binding nodes named "id" and records the id → enclosing type. It prefers a ui_object_definition ancestor and falls back to a textual brace-balance scan when the parser error- recovers — which happens in the common case where `.` has no member after it yet. * The ContextProperty branch of Completion consults the index when the identifier before `.` matches an id, and returns typePropertyCompletions in that case. Unknown identifiers fall back to generic properties so existing behavior is unchanged. --- CHANGELOG.md | 3 ++ handler/completion.go | 70 +++++++++++++++++++++++- handler/idindex.go | 103 +++++++++++++++++++++++++++++++++++ handler/idindex_test.go | 115 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 handler/idindex.go create mode 100644 handler/idindex_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 223022e..efd8bed 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 +- **Type inference for id member expressions** — typing `root.` where `id: root` resolves to a known type now completes with that type's properties (and everything it inherits via the prototype chain) rather than the generic property list. Works even while the file is mid-edit and error-recovering, via a textual fallback that finds the enclosing type when tree-sitter can't wrap it in a `ui_object_definition`. + ### 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. diff --git a/handler/completion.go b/handler/completion.go index d2b9769..8168f8f 100644 --- a/handler/completion.go +++ b/handler/completion.go @@ -41,7 +41,11 @@ func (h *Handler) Completion(_ context.Context, params *lsp.CompletionParams) (* items = append(items, getCompletionTypes()...) items = append(items, h.workspaceCompletions()...) case ContextProperty: - items = append(items, qmlPropertyCompletions()...) + if typeItems := h.idMemberCompletions(params.TextDocument.URI, lineText, char); typeItems != nil { + items = append(items, typeItems...) + } else { + items = append(items, qmlPropertyCompletions()...) + } case ContextId: items = append(items, qmlKeywords()...) case ContextAfterColon: @@ -380,6 +384,70 @@ func qmlPropertyCompletions() []lsp.CompletionItem { return completionItemsByCategory("property", "anchor") } +// idMemberCompletions returns type-specific completions when the cursor sits +// just after an `.` and `` resolves to an id binding in the document. +// Returns nil when the identifier before `.` isn't a known id, so the caller +// can fall back to generic property completions. +func (h *Handler) idMemberCompletions(uri lsp.DocumentURI, lineText string, char int) []lsp.CompletionItem { + if h.parser == nil { + return nil + } + ident := identifierBeforeDot(lineText, char) + if ident == "" { + return nil + } + tree := h.parser.GetTree(uri) + if tree == nil { + return nil + } + root := tree.RootNode() + if root == nil { + return nil + } + doc, ok := h.getDocument(uri) + if !ok { + return nil + } + index := buildIDTypeIndex(root, h.parser.Language(), []byte(doc)) + typeName, found := index[ident] + if !found { + return nil + } + return typePropertyCompletions(typeName) +} + +// identifierBeforeDot returns the identifier immediately preceding the `.` +// that triggered this completion. Walks backward from `pos`, skipping +// whitespace, then past a single `.`, then collects the identifier-like run. +// Returns "" if the pattern doesn't match (e.g. multi-level `foo.bar.`). +func identifierBeforeDot(text string, pos int) string { + if pos > len(text) { + pos = len(text) + } + i := pos - 1 + for i >= 0 && isSpaceByte(text[i]) { + i-- + } + if i < 0 || text[i] != '.' { + return "" + } + i-- + end := i + 1 + for i >= 0 && isIdentChar(text[i]) { + i-- + } + start := i + 1 + if start >= end { + return "" + } + // Reject multi-dot chains (e.g. `anchors.fill.`) — this helper only + // handles the single-level `.` case. + if start > 0 && text[start-1] == '.' { + return "" + } + return text[start:end] +} + func qmlKeywords() []lsp.CompletionItem { return completionItemsByCategory("keyword") } diff --git a/handler/idindex.go b/handler/idindex.go new file mode 100644 index 0000000..07cb675 --- /dev/null +++ b/handler/idindex.go @@ -0,0 +1,103 @@ +package handler + +import ( + "github.com/odvcencio/gotreesitter" +) + +// buildIDTypeIndex walks the tree and returns a map from `id` name to the +// enclosing object's type name. QML ids are file-scoped, so a single map per +// document is sufficient for property resolution on member expressions like +// `root.width`. +// +// Duplicate ids (which QML forbids) resolve first-writer-wins — whichever the +// walk sees first. This is consistent with how the existing id → location +// lookup in definition.go behaves. +func buildIDTypeIndex(root *gotreesitter.Node, lang *gotreesitter.Language, content []byte) map[string]string { + index := map[string]string{} + walkTree(root, func(n *gotreesitter.Node) bool { + if n.Type(lang) != "ui_binding" { + return true + } + if bindingName(n, lang, content) != "id" { + return true + } + idName := bindingValueIdentifier(n, lang, content) + if idName == "" { + return true + } + typeName := resolveEnclosingType(n, lang, content) + if typeName == "" { + return true + } + if _, exists := index[idName]; !exists { + index[idName] = typeName + } + return true + }) + return index +} + +// resolveEnclosingType finds the type name of the object that contains the +// given binding. Prefers the parse tree (ui_object_definition ancestor); on +// partial parses — which happen while the user is mid-edit and the file is +// temporarily invalid — falls back to a textual brace-balance scan. +func resolveEnclosingType(b *gotreesitter.Node, lang *gotreesitter.Language, content []byte) string { + for anc := b.Parent(); anc != nil; anc = anc.Parent() { + if anc.Type(lang) != "ui_object_definition" { + continue + } + if name := objectDefinitionTypeName(anc, lang, content); name != "" { + return name + } + } + return enclosingTypeFromText(content, b.StartByte()) +} + +// objectDefinitionTypeName returns the type name of a ui_object_definition +// node (e.g. "Rectangle" for `Rectangle { ... }`). Handles the dotted form +// `QtQuick.Window` by returning the last segment. +func objectDefinitionTypeName(obj *gotreesitter.Node, lang *gotreesitter.Language, content []byte) string { + for i := 0; i < obj.ChildCount(); i++ { + c := obj.Child(i) + if c == nil { + continue + } + t := c.Type(lang) + if t == "identifier" || t == "nested_identifier" { + return lastDottedSegment(string(content[c.StartByte():c.EndByte()])) + } + } + return "" +} + +func bindingName(b *gotreesitter.Node, lang *gotreesitter.Language, content []byte) string { + for i := 0; i < b.ChildCount(); i++ { + c := b.Child(i) + if c == nil { + continue + } + if c.Type(lang) == "identifier" { + return string(content[c.StartByte():c.EndByte()]) + } + } + return "" +} + +func bindingValueIdentifier(b *gotreesitter.Node, lang *gotreesitter.Language, content []byte) string { + for i := 0; i < b.ChildCount(); i++ { + c := b.Child(i) + if c == nil || c.Type(lang) != "expression_statement" { + continue + } + for j := 0; j < c.ChildCount(); j++ { + cc := c.Child(j) + if cc == nil { + continue + } + if cc.Type(lang) == "identifier" { + return string(content[cc.StartByte():cc.EndByte()]) + } + } + } + return "" +} diff --git a/handler/idindex_test.go b/handler/idindex_test.go new file mode 100644 index 0000000..fbe1ee2 --- /dev/null +++ b/handler/idindex_test.go @@ -0,0 +1,115 @@ +package handler + +import ( + "context" + "testing" + + "github.com/owenrumney/go-lsp/lsp" +) + +func TestBuildIDTypeIndexFindsNestedIds(t *testing.T) { + doc := "import QtQuick\n\nRectangle {\n id: root\n Text {\n id: label\n text: \"hi\"\n }\n}\n" + h := newTestHandler(t, "test://idx.qml", doc) + tree := h.parser.GetTree("test://idx.qml") + if tree == nil { + t.Fatal("no tree") + } + index := buildIDTypeIndex(tree.RootNode(), h.parser.Language(), []byte(doc)) + if got := index["root"]; got != "Rectangle" { + t.Errorf("root type = %q, want Rectangle", got) + } + if got := index["label"]; got != "Text" { + t.Errorf("label type = %q, want Text", got) + } +} + +func TestBuildIDTypeIndexIgnoresNonIdBindings(t *testing.T) { + doc := "import QtQuick\n\nRectangle {\n width: 100\n color: \"red\"\n}\n" + h := newTestHandler(t, "test://nid.qml", doc) + tree := h.parser.GetTree("test://nid.qml") + index := buildIDTypeIndex(tree.RootNode(), h.parser.Language(), []byte(doc)) + if len(index) != 0 { + t.Errorf("expected empty index, got %v", index) + } +} + +func TestIdentifierBeforeDot(t *testing.T) { + cases := []struct { + text string + pos int + want string + }{ + {" root.", 9, "root"}, + {"foo = root.", 11, "root"}, + {"anchors.fill.", 13, ""}, // multi-dot chain: rejected + {" .", 5, ""}, // nothing before dot + {" root", 8, ""}, // no dot at cursor + } + for _, tc := range cases { + if got := identifierBeforeDot(tc.text, tc.pos); got != tc.want { + t.Errorf("identifierBeforeDot(%q, %d) = %q, want %q", tc.text, tc.pos, got, tc.want) + } + } +} + +func TestCompletionAfterIdDotReturnsTypeProperties(t *testing.T) { + // Rectangle has `radius` as a type-specific property (via static + // typeProperties). Pre-populate the typeProperties map so the test is + // independent of whatever Qt modules happen to be installed. + typeProperties["TestRectFoo"] = []QMLSymbol{ + {Label: "uniqueTestProp", Category: "property"}, + } + baseTypes["TestRectFoo"] = nil + defer delete(typeProperties, "TestRectFoo") + defer delete(baseTypes, "TestRectFoo") + + doc := "import QtQuick\n\nTestRectFoo {\n id: root\n width: root.\n}\n" + h := newTestHandler(t, "test://dot.qml", doc) + + list, err := h.Completion(context.Background(), &lsp.CompletionParams{ + TextDocumentPositionParams: lsp.TextDocumentPositionParams{ + TextDocument: lsp.TextDocumentIdentifier{URI: "test://dot.qml"}, + Position: lsp.Position{Line: 4, Character: 16}, // just after `root.` + }, + }) + if err != nil { + t.Fatalf("Completion: %v", err) + } + found := false + for _, item := range list.Items { + if item.Label == "uniqueTestProp" { + found = true + break + } + } + if !found { + t.Errorf("expected uniqueTestProp from TestRectFoo in completions, got %d items", len(list.Items)) + } +} + +func TestCompletionAfterUnknownDotFallsBack(t *testing.T) { + // `foo.` where foo isn't an id should fall back to generic property + // completions (e.g. `width`, `height`). + doc := "import QtQuick\n\nRectangle {\n width: foo.\n}\n" + h := newTestHandler(t, "test://unk.qml", doc) + + list, err := h.Completion(context.Background(), &lsp.CompletionParams{ + TextDocumentPositionParams: lsp.TextDocumentPositionParams{ + TextDocument: lsp.TextDocumentIdentifier{URI: "test://unk.qml"}, + Position: lsp.Position{Line: 3, Character: 15}, // line 3 = ` width: foo.` + }, + }) + if err != nil { + t.Fatalf("Completion: %v", err) + } + foundWidth := false + for _, item := range list.Items { + if item.Label == "width" { + foundWidth = true + break + } + } + if !foundWidth { + t.Errorf("expected generic `width` in fallback completions, got %d items", len(list.Items)) + } +}