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
- **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.
Expand Down
70 changes: 69 additions & 1 deletion handler/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -380,6 +384,70 @@ func qmlPropertyCompletions() []lsp.CompletionItem {
return completionItemsByCategory("property", "anchor")
}

// idMemberCompletions returns type-specific completions when the cursor sits
// just after an `<id>.` and `<id>` 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 `<id>.` case.
if start > 0 && text[start-1] == '.' {
return ""
}
return text[start:end]
}

func qmlKeywords() []lsp.CompletionItem {
return completionItemsByCategory("keyword")
}
103 changes: 103 additions & 0 deletions handler/idindex.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
115 changes: 115 additions & 0 deletions handler/idindex_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading