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 = "" + } } }