From 81e609b1b0ff9b043744be08d0d93a1d7daf3cd1 Mon Sep 17 00:00:00 2001 From: karitham Date: Sat, 8 Aug 2026 00:10:40 +0200 Subject: [PATCH] completion: scope qualified type completion to the include MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "songs.|" in a type position completed as a qualified VALUE: the trailing dot (which the lexer drops) forced CtxFieldValue, so the list showed enum values and consts instead of the include's types. The type provider already scoped dotted prefixes to the include, but never got the slot. ResolveContext now recognizes the dropped dot (identifier token ending one byte before the cursor) and routes it by the token before the identifier: field modifiers, id colons, const/typedef keywords, and map/list/set openers are type slots — the dotted prefix stays so the provider suggests the include's types; everything else stays a qualified value. Dotted identifiers mid-typing ("songs.A") classify the same way. The type completion now covers field types, const types, and service return types, and keeps the dot: labels keep the include qualifier, the insert is the bare type, and the edit starts at the cursor. --- lsp/source/completion_qualified_type_test.go | 89 ++++++++++++++++++++ lsp/source/context.go | 52 ++++++++++++ lsp/source/token_completion.go | 25 ++++-- 3 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 lsp/source/completion_qualified_type_test.go diff --git a/lsp/source/completion_qualified_type_test.go b/lsp/source/completion_qualified_type_test.go new file mode 100644 index 0000000..3c42a73 --- /dev/null +++ b/lsp/source/completion_qualified_type_test.go @@ -0,0 +1,89 @@ +package source + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/karitham/thrift-ls/lsp/cache" +) + +// TestCompletionQualifiedType covers "songs.|" in type positions: the dot +// scopes the completion to the include's types (never enum values or +// consts), the label keeps the include qualifier, and the inserted name is +// the bare type — the edit range starts at the cursor so the dot stays. +func TestCompletionQualifiedType(t *testing.T) { + songs := `enum Song { + FUWA_FUWA_TIME = 1 +} + +struct Album { + 1: required string title +} + +const i32 TEMPO = 120` + + tests := []struct { + name string + content string + marker string + }{ + { + name: "field type", + content: "include \"songs.thrift\"\nstruct Club {\n\t1: required songs.\n}", + marker: "1: required songs.", + }, + { + name: "const type", + content: "include \"songs.thrift\"\nconst songs. favorite = Album{title = \"x\"}", + marker: "const songs.", + }, + { + name: "function return type", + content: "include \"songs.thrift\"\nservice Club {\n\tsongs. Play()\n}", + marker: "\tsongs.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ss := buildSnapshot(t, nil, + &cache.FileChange{URI: "file:///tmp/songs.thrift", Version: 0, Content: []byte(songs), From: cache.FileChangeTypeDidOpen}, + &cache.FileChange{URI: "file:///tmp/club.thrift", Version: 0, Content: []byte(tt.content), From: cache.FileChangeTypeDidOpen}, + ) + + pos := lspPosOf(t, tt.content, tt.marker) + + labels, rng, _ := completionLabels(t, ss, "file:///tmp/club.thrift", pos) + + assert.Contains(t, labels, "songs.Album", "labels: %v", labels) + assert.Contains(t, labels, "songs.Song", "labels: %v", labels) + assert.NotContains(t, labels, "Song.FUWA_FUWA_TIME", "value candidates must not leak into a type slot") + assert.NotContains(t, labels, "TEMPO", "const candidates must not leak into a type slot") + + // The edit range starts at the cursor: the dot is not replaced. + assert.Equal(t, pos.Character, rng.Start.Character) + }) + } +} + +// TestCompletionQualifiedTypeFilter pins that typing after the dot filters +// the include's types: "songs.A" narrows to Album. +func TestCompletionQualifiedTypeFilter(t *testing.T) { + content := "include \"songs.thrift\"\nstruct Club {\n\t1: required songs.A\n}" + + ss := buildSnapshot(t, nil, + &cache.FileChange{URI: "file:///tmp/songs.thrift", Version: 0, Content: []byte("enum Song {\n\tFUWA_FUWA_TIME = 1\n}\n\nstruct Album {\n\t1: required string title\n}\n"), From: cache.FileChangeTypeDidOpen}, + &cache.FileChange{URI: "file:///tmp/club.thrift", Version: 0, Content: []byte(content), From: cache.FileChangeTypeDidOpen}, + ) + + pos := lspPosOf(t, content, "songs.A") + + labels, rng, _ := completionLabels(t, ss, "file:///tmp/club.thrift", pos) + + assert.Equal(t, []string{"songs.Album"}, labels, "labels: %v", labels) + + // The edit replaces the whole typed prefix, keeping the qualifier. + assert.Equal(t, uint32(13), rng.Start.Character) // 's' of songs.A + assert.Equal(t, pos.Character, rng.End.Character) +} diff --git a/lsp/source/context.go b/lsp/source/context.go index 8f62c83..d816794 100644 --- a/lsp/source/context.go +++ b/lsp/source/context.go @@ -148,6 +148,26 @@ func ResolveContext(doc *syntax.Document, pos syntax.Position) Context { c.Kind = CtxType return c + case syntax.TokenIdentifier: + // "user.|" — the lexer drops a trailing dot, so the + // identifier token ends one byte before the cursor and the + // byte between them is the dot. The slot of the identifier + // decides: a type position keeps the type kind with the + // dotted prefix (the type provider scopes to the include), + // anything else is a qualified value. + if prev.Offset+len(prev.Text) == c.Offset-1 { + if typeSlotAfterIdent(doc, prevIdx) { + c.Kind = CtxType + c.Prefix = prev.Text + } else { + c.Kind = CtxFieldValue + c.Prefix = "" + } + + c.EditStart = c.Offset + + return c + } } } @@ -163,6 +183,15 @@ func ResolveContext(doc *syntax.Document, pos syntax.Position) Context { return c case syntax.TokenIdentifier: + // "songs.A" — a dotted identifier in a type slot: the type + // provider scopes to the include and the edit replaces the + // whole qualified prefix. + if strings.Contains(at.Text, ".") && typeSlotAfterIdent(doc, prevReal(doc.Tokens, atIdx-1)) { + c.Kind = CtxType + + return c + } + // "ZeonForces.|" — an identifier ending in a dot is a // qualified value position; the lexer may split the dot off // the token, so the cursor lands right at its end. @@ -295,6 +324,29 @@ func isThrowsGroup(doc *syntax.Document, opener int) bool { return prevIdx >= 0 && doc.Tokens[prevIdx].Kind == syntax.TokenThrows } +// typeSlotAfterIdent reports whether the identifier at idx (ending in a +// dropped dot) sits in a type position, decided by the token before it: +// after a field modifier or id colon, a const or typedef keyword, a +// map/list/set opener, or a service function return. +func typeSlotAfterIdent(doc *syntax.Document, idx int) bool { + prev := prevReal(doc.Tokens, idx-1) + if prev < 0 { + return false + } + + switch doc.Tokens[prev].Kind { + case syntax.TokenRequired, syntax.TokenOptional, syntax.TokenColon, + syntax.TokenConst, syntax.TokenTypedef, syntax.TokenLt: + return true + case syntax.TokenLBrace: + kw, ok := braceBodyKind(doc, prev) + + return ok && kw == syntax.TokenService + } + + return false +} + // memberKind maps a container keyword to its member slot. func memberKind(kw syntax.TokenKind) ContextKind { switch kw { diff --git a/lsp/source/token_completion.go b/lsp/source/token_completion.go index de17642..15744cf 100644 --- a/lsp/source/token_completion.go +++ b/lsp/source/token_completion.go @@ -75,19 +75,28 @@ func (c *TokenCompletion) Completion(ctx context.Context, ss *cache.Snapshot, cm cc := ResolveContext(parsedFile.AST(), pos) - // A trailing dot (enum-qualified value position, e.g. "ZeonForces.|") - // means the user is about to type the member: filter on everything - // after the dots, insert after them, and strip the qualifier from - // inserted names so the result is "ZeonForces.ZAKU_I", not - // "ZeonForces.ZeonForces.ZAKU_I". The lexer drops a trailing dot, so - // detect it from the raw content, not the token stream. + // A trailing dot (qualified position, e.g. "ZeonForces.|" for a value + // or "songs.|" for a type) means the user is about to type the member: + // filter on everything after the dots, insert after them, and strip + // the qualifier from inserted names so the result is "ZeonForces.ZAKU_I" + // or "songs.Album", not a doubled qualifier. The lexer drops a trailing + // dot, so detect it from the raw content, not the token stream. In a + // type slot the dot keeps its slot — the type provider scopes to the + // include — while any other slot becomes a qualified value position. qualified := strings.HasSuffix(cc.Prefix, ".") if !qualified && cc.Offset > 0 { if content, err := cmp.Fh.Content(); err == nil && cc.Offset <= len(content) && content[cc.Offset-1] == '.' { qualified = true - cc.Kind = CtxFieldValue - cc.Prefix = "" + + if cc.Kind == CtxType { + // Keep the dotted prefix so the type provider resolves + // the include name and suggests its types. + cc.Prefix += "." + } else { + cc.Kind = CtxFieldValue + cc.Prefix = "" + } } } -- 2.51.2