From cb785cdc245a76ce3a5dad4428abb715a53d8dd8 Mon Sep 17 00:00:00 2001 From: karitham Date: Fri, 7 Aug 2026 21:13:46 +0200 Subject: [PATCH] lsp: document highlight and links --- lsp/codejump/highlight_test.go | 97 ++++++++++++++++++++++++++++++++++ lsp/codejump/reference.go | 90 ++++++++++++++++++++++--------- lsp/highlight.go | 23 ++++++++ lsp/initialize.go | 5 +- lsp/links.go | 23 ++++++++ lsp/links/links.go | 67 +++++++++++++++++++++++ lsp/links/links_test.go | 87 ++++++++++++++++++++++++++++++ lsp/server.go | 4 +- 8 files changed, 366 insertions(+), 30 deletions(-) create mode 100644 lsp/codejump/highlight_test.go create mode 100644 lsp/highlight.go create mode 100644 lsp/links.go create mode 100644 lsp/links/links.go create mode 100644 lsp/links/links_test.go diff --git a/lsp/codejump/highlight_test.go b/lsp/codejump/highlight_test.go new file mode 100644 index 0000000..e9b9ca4 --- /dev/null +++ b/lsp/codejump/highlight_test.go @@ -0,0 +1,97 @@ +package codejump + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.lsp.dev/protocol" + + "github.com/karitham/thrift-ls/lsp/cache" +) + +// TestHighlightSameFile pins document highlighting: the identifier at the +// cursor highlights its same-file references only. +func TestHighlightSameFile(t *testing.T) { + tests := []struct { + name string + files []*cache.FileChange + pos protocol.Position // cursor position in the first file + wantLines []uint32 // highlighted lines in the first file + }{ + { + name: "type name highlights definition and usages", + files: []*cache.FileChange{ + {URI: "file:///tmp/main.thrift", Version: 0, Content: []byte(`struct Gundam { + 1: required string Name +} + +struct StrikeRouge { + 1: required Gundam pack + 2: optional Gundam beamSaber +}`), From: cache.FileChangeTypeDidOpen}, + }, + pos: protocol.Position{Line: 0, Character: 7}, + wantLines: []uint32{0, 5, 6}, + }, + { + name: "usage highlights the definition too", + files: []*cache.FileChange{ + {URI: "file:///tmp/main.thrift", Version: 0, Content: []byte(`struct Gundam { + 1: required string Name +} + +struct StrikeRouge { + 1: required Gundam pack +}`), From: cache.FileChangeTypeDidOpen}, + }, + pos: protocol.Position{Line: 5, Character: 14}, + wantLines: []uint32{0, 5}, + }, + { + name: "cross-file references are excluded", + files: []*cache.FileChange{ + {URI: "file:///tmp/gundam.thrift", Version: 0, Content: []byte(`struct Gundam { + 1: required string Name +}`), From: cache.FileChangeTypeDidOpen}, + {URI: "file:///tmp/main.thrift", Version: 0, Content: []byte(`include "gundam.thrift" + +struct StrikeRouge { + 1: required Gundam pack +}`), From: cache.FileChangeTypeDidOpen}, + }, + pos: protocol.Position{Line: 0, Character: 7}, + wantLines: []uint32{0}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ss := cache.BuildSnapshotForTest(tt.files) + + highlights, err := Highlight(t.Context(), ss, tt.files[0].URI, tt.pos) + require.NoError(t, err) + + lines := make([]uint32, len(highlights)) + for i, h := range highlights { + lines[i] = h.Range.Start.Line + assert.Equal(t, protocol.DocumentHighlightKindText, h.Kind) + } + + assert.Equal(t, tt.wantLines, lines) + }) + } +} + +// TestHighlightUnresolvableType pins the minimal result for an identifier +// without references: only the cursor word itself is highlighted. +func TestHighlightUnresolvableType(t *testing.T) { + ss := cache.BuildSnapshotForTest([]*cache.FileChange{ + {URI: "file:///tmp/main.thrift", Version: 0, Content: []byte("struct Gundam {\n\t1: required UnknownType pack\n}"), From: cache.FileChangeTypeDidOpen}, + }) + + highlights, err := Highlight(t.Context(), ss, "file:///tmp/main.thrift", protocol.Position{Line: 1, Character: 20}) + require.NoError(t, err) + require.Len(t, highlights, 1) + assert.Equal(t, uint32(1), highlights[0].Range.Start.Line) +} diff --git a/lsp/codejump/reference.go b/lsp/codejump/reference.go index 9c2685a..f65462a 100644 --- a/lsp/codejump/reference.go +++ b/lsp/codejump/reference.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "sort" "strings" "go.lsp.dev/protocol" @@ -25,45 +26,84 @@ var validReferenceDefinitionType = map[DefinitionKind]struct{}{ // Reference returns the locations of all references to the definition under // the cursor: type definitions, constant values, enum values, and services. func Reference(ctx context.Context, ss *cache.Snapshot, file uri.URI, pos protocol.Position) (res []protocol.Location, err error) { - res = make([]protocol.Location, 0) + pf, target, err := resolveTarget(ctx, ss, file, pos) + if err != nil { + return nil, err + } + + refs, err := searchReferences(ctx, ss, file, pf, target) + if err != nil { + return nil, err + } + + return hits(refs), nil +} +// Highlight returns the references of the identifier at pos within the +// same file, for document highlighting. The identifier itself is always +// included, so the cursor word stays highlighted. +func Highlight(ctx context.Context, ss *cache.Snapshot, file uri.URI, pos protocol.Position) ([]protocol.DocumentHighlight, error) { pf, target, err := resolveTarget(ctx, ss, file, pos) if err != nil { - return res, err + return nil, err } - switch target.kind { - case TargetTypeName: - refs, err := searchTypeNameReferences(ctx, ss, file, pf, target) - if err != nil { - return nil, err - } + refs, err := searchReferences(ctx, ss, file, pf, target) + if err != nil { + return nil, err + } - return hits(refs), nil - case TargetConstValue: - refs, err := searchConstValueReferences(ctx, ss, file, pf, target) - if err != nil { - return nil, err + out := make([]protocol.DocumentHighlight, 0, len(refs)+1) + seen := map[protocol.Range]bool{} + + add := func(r protocol.Range) { + if seen[r] { + return } - return hits(refs), nil - case TargetService: - refs, err := searchServiceReferences(ctx, ss, file, target.identifier().Text) - if err != nil { - return nil, err + seen[r] = true + out = append(out, protocol.DocumentHighlight{Range: r, Kind: protocol.DocumentHighlightKindText}) + } + + // The identifier at the cursor is always highlighted; the reference + // search already includes it when the cursor sits on a usage, so the + // set dedups. + if id := target.identifier(); id != nil { + add(nodeRange(pf.AST(), id)) + } + + for _, r := range refs { + if r.loc.URI == file { + add(r.loc.Range) } + } - return hits(refs), nil - case TargetDefinition: - refs, err := searchDefinitionReferences(ctx, ss, file, pf, target) - if err != nil { - return nil, err + sort.Slice(out, func(i, j int) bool { + a, b := out[i].Range.Start, out[j].Range.Start + if a.Line != b.Line { + return a.Line < b.Line } - return hits(refs), nil + return a.Character < b.Character + }) + + return out, nil +} + +// searchReferences dispatches to the reference search for the target kind. +func searchReferences(ctx context.Context, ss *cache.Snapshot, file uri.URI, pf *cache.ParsedFile, target *target) ([]referenceHit, error) { + switch target.kind { + case TargetTypeName: + return searchTypeNameReferences(ctx, ss, file, pf, target) + case TargetConstValue: + return searchConstValueReferences(ctx, ss, file, pf, target) + case TargetService: + return searchServiceReferences(ctx, ss, file, target.identifier().Text) + case TargetDefinition: + return searchDefinitionReferences(ctx, ss, file, pf, target) } - return res, err + return nil, nil } // searchDefinitionReferences handles references from a definition name: diff --git a/lsp/highlight.go b/lsp/highlight.go new file mode 100644 index 0000000..f8e022d --- /dev/null +++ b/lsp/highlight.go @@ -0,0 +1,23 @@ +package lsp + +import ( + "context" + + "go.lsp.dev/protocol" + + "github.com/karitham/thrift-ls/lsp/codejump" +) + +func (s *Server) documentHighlight(ctx context.Context, params *protocol.DocumentHighlightParams) ([]protocol.DocumentHighlight, error) { + file := params.TextDocument.URI + + view, err := s.session.ViewOf(file) + if err != nil { + return nil, err + } + + ss, release := view.Snapshot() + defer release() + + return codejump.Highlight(ctx, ss, file, params.Position) +} diff --git a/lsp/initialize.go b/lsp/initialize.go index ef93fc8..05cc8ca 100644 --- a/lsp/initialize.go +++ b/lsp/initialize.go @@ -169,7 +169,7 @@ func initializeResult() *protocol.InitializeResult { WorkDoneProgress: new(true), }, }, - DocumentHighlightProvider: protocol.Boolean(false), + DocumentHighlightProvider: protocol.Boolean(true), DocumentSymbolProvider: &protocol.DocumentSymbolOptions{ WorkDoneProgressOptions: protocol.WorkDoneProgressOptions{ WorkDoneProgress: new(true), @@ -186,8 +186,7 @@ func initializeResult() *protocol.InitializeResult { }, DocumentLinkProvider: &protocol.DocumentLinkOptions{ ResolveProvider: new(false), - }, - ColorProvider: protocol.Boolean(false), + }, ColorProvider: protocol.Boolean(false), FoldingRangeProvider: protocol.Boolean(true), WorkspaceSymbolProvider: &protocol.WorkspaceSymbolOptions{ WorkDoneProgressOptions: protocol.WorkDoneProgressOptions{ diff --git a/lsp/links.go b/lsp/links.go new file mode 100644 index 0000000..d15a3e9 --- /dev/null +++ b/lsp/links.go @@ -0,0 +1,23 @@ +package lsp + +import ( + "context" + + "go.lsp.dev/protocol" + + "github.com/karitham/thrift-ls/lsp/links" +) + +func (s *Server) documentLink(ctx context.Context, params *protocol.DocumentLinkParams) ([]protocol.DocumentLink, error) { + file := params.TextDocument.URI + + view, err := s.session.ViewOf(file) + if err != nil { + return nil, err + } + + ss, release := view.Snapshot() + defer release() + + return links.Links(ctx, ss, file), nil +} diff --git a/lsp/links/links.go b/lsp/links/links.go new file mode 100644 index 0000000..a7f21df --- /dev/null +++ b/lsp/links/links.go @@ -0,0 +1,67 @@ +// Package links computes document links: include paths resolving to their +// target files. Pure over the snapshot: parsing and file I/O happen in the +// caller. +package links + +import ( + "context" + "strings" + + "go.lsp.dev/protocol" + "go.lsp.dev/uri" + + "github.com/karitham/thrift-ls/lsp/cache" + "github.com/karitham/thrift-ls/syntax" +) + +// Links returns the document links of a file, one per include and +// cpp_include, targeting the resolved file. +func Links(ctx context.Context, ss *cache.Snapshot, file uri.URI) []protocol.DocumentLink { + pf, err := ss.Parse(ctx, file) + if err != nil || pf.AST() == nil { + return nil + } + + doc := pf.AST() + resolver := ss.Resolver() + + var out []protocol.DocumentLink + + add := func(path *syntax.Token) { + if path == nil { + return + } + + text := strings.Trim(path.Text, "\"'") + if text == "" { + return + } + + target := resolver.ResolveInclude(file, text) + out = append(out, protocol.DocumentLink{ + Range: tokenRange(doc, path), + Target: &target, + }) + } + + for _, inc := range doc.Includes() { + add(inc.Path) + } + + for _, inc := range doc.CPPIncludes() { + add(inc.Path) + } + + return out +} + +// tokenRange converts a token's span into a protocol range. +func tokenRange(doc *syntax.Document, tok *syntax.Token) protocol.Range { + start := doc.TokenPosition(doc.TokenIndex(tok)) + end := doc.TokenEndPosition(doc.TokenIndex(tok)) + + return protocol.Range{ + Start: protocol.Position{Line: uint32(start.Line - 1), Character: uint32(start.Col - 1)}, + End: protocol.Position{Line: uint32(end.Line - 1), Character: uint32(end.Col - 1)}, + } +} diff --git a/lsp/links/links_test.go b/lsp/links/links_test.go new file mode 100644 index 0000000..a3f343f --- /dev/null +++ b/lsp/links/links_test.go @@ -0,0 +1,87 @@ +package links + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.lsp.dev/uri" + + "github.com/karitham/thrift-ls/lsp/cache" +) + +// buildSnapshot parses src as the file at URI and returns the snapshot. +func buildSnapshot(t *testing.T, file uri.URI, src string) *cache.Snapshot { + t.Helper() + + ss := cache.BuildSnapshotForTest([]*cache.FileChange{ + {URI: file, Version: 0, Content: []byte(src), From: cache.FileChangeTypeDidOpen}, + }) + + return ss +} + +func TestLinks(t *testing.T) { + tests := []struct { + name string + src string + want []struct { + line uint32 + target uri.URI + } + }{ + { + name: "include and cpp_include links", + src: `include "base.thrift" +cpp_include "types.h" + +struct S {}`, + want: []struct { + line uint32 + target uri.URI + }{ + {0, "file:///tmp/base.thrift"}, + {1, "file:///tmp/types.h"}, + }, + }, + { + name: "no includes yields no links", + src: "struct S {}", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ss := buildSnapshot(t, "file:///tmp/main.thrift", tt.src) + + got := Links(t.Context(), ss, "file:///tmp/main.thrift") + + if tt.want == nil { + assert.Empty(t, got) + + return + } + + require.Len(t, got, len(tt.want)) + for i, want := range tt.want { + assert.Equal(t, want.line, got[i].Range.Start.Line) + require.NotNil(t, got[i].Target) + assert.Equal(t, want.target, *got[i].Target) + } + }) + } +} + +// TestLinksRange pins the link range to the include string literal. +func TestLinksRange(t *testing.T) { + ss := buildSnapshot(t, "file:///tmp/main.thrift", "include \"base.thrift\"\n") + + got := Links(t.Context(), ss, "file:///tmp/main.thrift") + require.Len(t, got, 1) + + assert.Equal(t, uint32(0), got[0].Range.Start.Line) + assert.Equal(t, uint32(8), got[0].Range.Start.Character) + assert.Equal(t, uint32(0), got[0].Range.End.Line) + assert.Equal(t, uint32(21), got[0].Range.End.Character) +} diff --git a/lsp/server.go b/lsp/server.go index 4927f60..462557c 100644 --- a/lsp/server.go +++ b/lsp/server.go @@ -164,11 +164,11 @@ func (s *Server) DocumentColor(ctx context.Context, params *protocol.DocumentCol } func (s *Server) DocumentHighlight(ctx context.Context, params *protocol.DocumentHighlightParams) (result []protocol.DocumentHighlight, err error) { - return []protocol.DocumentHighlight{}, nil + return s.documentHighlight(ctx, params) } func (s *Server) DocumentLink(ctx context.Context, params *protocol.DocumentLinkParams) (result []protocol.DocumentLink, err error) { - return []protocol.DocumentLink{}, nil + return s.documentLink(ctx, params) } func (s *Server) DocumentLinkResolve(ctx context.Context, params *protocol.DocumentLink) (result *protocol.DocumentLink, err error) { -- 2.51.2