diff --git a/lsp/cache/cache.go b/lsp/cache/cache.go index 4c71da1..f80662f 100644 --- a/lsp/cache/cache.go +++ b/lsp/cache/cache.go @@ -1,30 +1,14 @@ package cache -import ( - "strconv" - "sync/atomic" -) - type Cache struct { - id string - IncludePaths []string *memoizedFS } -var cacheIndex int64 - func New(includePaths []string) *Cache { - index := atomic.AddInt64(&cacheIndex, 1) - - c := &Cache{ - id: strconv.FormatInt(index, 10), + return &Cache{ IncludePaths: includePaths, memoizedFS: &memoizedFS{filesByID: map[FileID][]*DiskFile{}}, } - - return c } - -func (c *Cache) ID() string { return c.id } diff --git a/lsp/cache/context.go b/lsp/cache/context.go index 68befb9..03a9806 100644 --- a/lsp/cache/context.go +++ b/lsp/cache/context.go @@ -9,15 +9,35 @@ import ( "github.com/karitham/thrift-ls/syntax" ) -// Context tracks, per file, its transitive include dependencies. It owns the +// IncludeDeps tracks, per file, its transitive include dependencies. It owns the // IncludeGraph; callers never touch the underlying graph directly. -type Context struct { +type IncludeDeps struct { graph *IncludeGraph } -// NewContext returns an empty context. -func NewContext() *Context { - return &Context{graph: NewIncludeGraph()} +// NewIncludeDeps returns an empty dependency set. +func NewIncludeDeps() *IncludeDeps { + return &IncludeDeps{graph: NewIncludeGraph()} +} + +// Includes returns the files file includes directly, in include order. +func (c *IncludeDeps) Includes(file uri.URI) []uri.URI { + node := c.graph.Get(file) + if node == nil { + return nil + } + + return node.OutDegree() +} + +// Includers returns the files that include file directly, in graph order. +func (c *IncludeDeps) Includers(file uri.URI) []uri.URI { + node := c.graph.Get(file) + if node == nil { + return nil + } + + return node.InDegree() } // Register replaces file's include edges, resolving them via resolve the same @@ -26,7 +46,7 @@ func NewContext() *Context { // // Duplicate includes resolve once; unknown include paths fall back to a // relative path (see resolver.Resolve) and never crash. -func (c *Context) Register(file uri.URI, includes []*syntax.Include, resolve func(uri.URI, string) uri.URI) []uri.URI { +func (c *IncludeDeps) Register(file uri.URI, includes []*syntax.Include, resolve func(uri.URI, string) uri.URI) []uri.URI { oldDeps := c.Dependents(file) c.graph.Set(file, dedupeIncludes(includes), resolve) @@ -37,7 +57,7 @@ func (c *Context) Register(file uri.URI, includes []*syntax.Include, resolve fun // Dependents returns every file that directly or transitively includes file, // including file itself when it transitively includes itself. The result is // sorted ascending by URI and cycle-safe. -func (c *Context) Dependents(file uri.URI) []uri.URI { +func (c *IncludeDeps) Dependents(file uri.URI) []uri.URI { deps := make([]uri.URI, 0) seen := make(map[uri.URI]struct{}) @@ -69,7 +89,7 @@ func (c *Context) Dependents(file uri.URI) []uri.URI { } // Forget removes file's edges and returns its former dependents. -func (c *Context) Forget(file uri.URI) []uri.URI { +func (c *IncludeDeps) Forget(file uri.URI) []uri.URI { deps := c.Dependents(file) c.graph.Remove(file) @@ -78,8 +98,8 @@ func (c *Context) Forget(file uri.URI) []uri.URI { } // Clone returns a deep copy, for snapshot copy-on-write. -func (c *Context) Clone() *Context { - return &Context{graph: c.graph.Clone()} +func (c *IncludeDeps) Clone() *IncludeDeps { + return &IncludeDeps{graph: c.graph.Clone()} } // dedupeIncludes drops include statements with the same path text, keeping diff --git a/lsp/cache/context_test.go b/lsp/cache/context_test.go index 1a5da94..dea7d12 100644 --- a/lsp/cache/context_test.go +++ b/lsp/cache/context_test.go @@ -21,10 +21,10 @@ const ( // buildTestContext registers the given include edges, where the map value is // the list of files the key file includes, and returns the context. -func buildTestContext(t *testing.T, edges map[string][]string) *Context { +func buildTestContext(t *testing.T, edges map[string][]string) *IncludeDeps { t.Helper() - c := NewContext() + c := NewIncludeDeps() for file, includes := range edges { inc := make([]*syntax.Include, 0, len(includes)) @@ -190,7 +190,7 @@ func Test_Context_Forget(t *testing.T) { } func Test_Context_DuplicateIncludes(t *testing.T) { - c := NewContext() + c := NewIncludeDeps() duplicates := []*syntax.Include{ {Path: &syntax.Token{Text: "federation.gundam.thrift"}}, @@ -208,7 +208,7 @@ func Test_Context_DuplicateIncludes(t *testing.T) { } func Test_Context_UnknownInclude(t *testing.T) { - c := NewContext() + c := NewIncludeDeps() // A resolve func returning an unresolvable URI must not crash Register; // the unknown target still records its dependent. diff --git a/lsp/cache/cow.go b/lsp/cache/cow.go new file mode 100644 index 0000000..a4049b0 --- /dev/null +++ b/lsp/cache/cow.go @@ -0,0 +1,89 @@ +package cache + +import ( + "maps" + "sync" + + "go.lsp.dev/uri" +) + +// cowMap is a copy-on-write map. Snapshots share the underlying map +// (Clone is O(1)); the first write after a clone copies the map, so +// cloning per keystroke is cheap while old snapshots stay immutable. +type cowMap[K comparable, V any] struct { + mu sync.RWMutex + m map[K]V + shared bool +} + +func newCowMap[K comparable, V any]() *cowMap[K, V] { + return &cowMap[K, V]{m: make(map[K]V)} +} + +func (m *cowMap[K, V]) Get(key K) (V, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + + v, ok := m.m[key] + + return v, ok +} + +func (m *cowMap[K, V]) Set(key K, val V) { + m.mu.Lock() + defer m.mu.Unlock() + + m.copyOnWrite() + + m.m[key] = val +} + +func (m *cowMap[K, V]) Forget(key K) { + m.mu.Lock() + defer m.mu.Unlock() + + m.copyOnWrite() + + delete(m.m, key) +} + +// Clone returns a map sharing the same entries. The clone and the original +// both become copy-on-write. +func (m *cowMap[K, V]) Clone() *cowMap[K, V] { + m.mu.Lock() + defer m.mu.Unlock() + + m.shared = true + + return &cowMap[K, V]{m: m.m, shared: true} +} + +// copyOnWrite detaches the map from a shared parent before the first write. +// Callers must hold mu. +func (m *cowMap[K, V]) copyOnWrite() { + if !m.shared { + return + } + + m2 := make(map[K]V, len(m.m)+1) + maps.Copy(m2, m.m) + + m.m = m2 + m.shared = false +} + +// ParseCaches maps URIs to parsed files. +type ParseCaches = cowMap[uri.URI, *ParsedFile] + +// NewParseCaches returns an empty parse cache. +func NewParseCaches() *ParseCaches { + return newCowMap[uri.URI, *ParsedFile]() +} + +// FilesMap holds the files of a snapshot. +type FilesMap = cowMap[uri.URI, FileHandle] + +// NewFilesMap returns an empty files map. +func NewFilesMap() *FilesMap { + return newCowMap[uri.URI, FileHandle]() +} diff --git a/lsp/cache/cow_test.go b/lsp/cache/cow_test.go index f67c35e..c56a485 100644 --- a/lsp/cache/cow_test.go +++ b/lsp/cache/cow_test.go @@ -53,7 +53,7 @@ func TestSnapshotCloneIsolation(t *testing.T) { assert.NoError(t, err) assert.Len(t, clonePf.AST().Structs()[0].Fields, 2, "clone parses the new content") - origPf := ss.parsedCache.Get("file:///tmp/federation.gundam.thrift") + origPf, _ := ss.parsedCache.Get("file:///tmp/federation.gundam.thrift") assert.NotNil(t, origPf, "original snapshot keeps its parsed file") assert.Len(t, origPf.AST().Structs()[0].Fields, 1, "original snapshot is unaffected by clone writes") diff --git a/lsp/cache/file.go b/lsp/cache/file.go index fc40c46..584d13c 100644 --- a/lsp/cache/file.go +++ b/lsp/cache/file.go @@ -1,12 +1,7 @@ package cache import ( - "bytes" "context" - "crypto/sha256" - "fmt" - "maps" - "sync" "time" "go.lsp.dev/protocol" @@ -27,53 +22,11 @@ type FileID struct { // Like os.Stat, it reads through symbolic links. func GetFileID(filename string) (FileID, time.Time, error) { return getFileID(filename) } -type Hash [sha256.Size]byte - -// HashOf returns the hash of some data. -func HashOf(data []byte) Hash { - return Hash(sha256.Sum256(data)) -} - -// Hashf returns the hash of a printf-formatted string. -func Hashf(format string, args ...any) Hash { - // Although this looks alloc-heavy, it is faster than using - // Fprintf on sha256.New() because the allocations don't escape. - return HashOf(fmt.Appendf(nil, format, args...)) -} - -// String returns the digest as a string of hex digits. -func (h Hash) String() string { - return fmt.Sprintf("%64x", [sha256.Size]byte(h)) -} - -// Less returns true if the given hash is less than the other. -func (h Hash) Less(other Hash) bool { - return bytes.Compare(h[:], other[:]) < 0 -} - -// XORWith updates *h to *h XOR h2. -func (h *Hash) XORWith(h2 Hash) { - // Small enough that we don't need crypto/subtle.XORBytes. - for i := range h { - h[i] ^= h2[i] - } -} - -// FileIdentity uniquely identifies a file at a version from a FileSystem. -type FileIdentity struct { - URI uri.URI - Hash Hash // digest of file contents -} - -func (id FileIdentity) String() string { - return fmt.Sprintf("%s%s", id.URI, id.Hash) -} - -// A FileHandle represents the URI, content, hash, and optional -// version of a file tracked by the LSP session. +// A FileHandle represents the URI, content, and optional version of a file +// tracked by the LSP session. // -// File content may be provided by the file system (for Saved files) -// or from an overlay, for open files with unsaved edits. +// File content may be provided by the file system or from an overlay for an +// open file with unsaved edits. // A FileHandle may record an attempt to read a non-existent file, // in which case Content returns an error. type FileHandle interface { @@ -82,12 +35,6 @@ type FileHandle interface { // may be more than one URI that resolve to the same FileHandle. Which one is // this? URI() uri.URI - // FileIdentity returns a FileIdentity for the file, even if there was an - // error reading it. - FileIdentity() FileIdentity - // Saved reports whether the file has the same content on disk: - // it is false for files open on an editor with unsaved edits. - Saved() bool // Version returns the file version, as defined by the LSP client. // For on-disk file handles, Version returns 0. Version() int32 @@ -103,87 +50,12 @@ type FileSource interface { ReadFile(ctx context.Context, uri uri.URI) (FileHandle, error) } -// FilesMap holds files on disk and overlay files. Snapshots share the -// underlying maps (Clone is O(1)); the first write after a clone copies -// copy-on-write. -type FilesMap struct { - mu sync.RWMutex - files map[uri.URI]FileHandle - overlays map[uri.URI]*Overlay - shared bool -} - -func (m *FilesMap) Get(key uri.URI) (FileHandle, bool) { - m.mu.RLock() - defer m.mu.RUnlock() - - fh, ok := m.files[key] - - return fh, ok -} - -func (m *FilesMap) Set(key uri.URI, file FileHandle) { - m.mu.Lock() - defer m.mu.Unlock() - - m.copyOnWrite() - - m.files[key] = file - if o, ok := file.(*Overlay); ok { - m.overlays[key] = o - } -} - -func (m *FilesMap) Forget(key uri.URI) { - m.mu.Lock() - defer m.mu.Unlock() - - m.copyOnWrite() - - delete(m.files, key) - delete(m.overlays, key) -} - -// Clone returns a view sharing the same entries. The clone and the original -// both become copy-on-write. -func (m *FilesMap) Clone() *FilesMap { - m.mu.Lock() - defer m.mu.Unlock() - - m.shared = true - - return &FilesMap{ - files: m.files, - overlays: m.overlays, - shared: true, - } -} - -// copyOnWrite detaches the maps from a shared parent before the first write. -// Callers must hold mu. -func (m *FilesMap) copyOnWrite() { - if !m.shared { - return - } - - files := make(map[uri.URI]FileHandle, len(m.files)+1) - maps.Copy(files, m.files) - - overlays := make(map[uri.URI]*Overlay, len(m.overlays)+1) - maps.Copy(overlays, m.overlays) - - m.files = files - m.overlays = overlays - m.shared = false -} - type FileChangeType string const ( FileChangeTypeInitialize FileChangeType = "Initialize" FileChangeTypeDidOpen FileChangeType = "DidOpen" FileChangeTypeDidChange FileChangeType = "DidChange" - FileChangeTypeDidSave FileChangeType = "DidSave" FileChangeTypeDidClose FileChangeType = "DidClose" ) @@ -194,8 +66,7 @@ type FileChange struct { From FileChangeType } -func (f *FileChange) FullContent(base []byte) []byte { - // only support full change now +func (f *FileChange) FullContent() []byte { return f.Content } diff --git a/lsp/cache/fs_memoized.go b/lsp/cache/fs_memoized.go index 4d79706..c3c5b7f 100644 --- a/lsp/cache/fs_memoized.go +++ b/lsp/cache/fs_memoized.go @@ -25,20 +25,11 @@ type DiskFile struct { uri uri.URI modTime time.Time content []byte - hash Hash err error } func (h *DiskFile) URI() uri.URI { return h.uri } -func (h *DiskFile) FileIdentity() FileIdentity { - return FileIdentity{ - URI: h.uri, - Hash: h.hash, - } -} - -func (h *DiskFile) Saved() bool { return true } func (h *DiskFile) Version() int32 { return 0 } func (h *DiskFile) Content() ([]byte, error) { return h.content, h.err } @@ -136,7 +127,6 @@ func readFile(ctx context.Context, uri uri.URI, mtime time.Time) (*DiskFile, err modTime: mtime, uri: uri, content: content, - hash: HashOf(content), err: err, }, nil } diff --git a/lsp/cache/fs_overlay.go b/lsp/cache/fs_overlay.go index fad216a..3556d9c 100644 --- a/lsp/cache/fs_overlay.go +++ b/lsp/cache/fs_overlay.go @@ -24,19 +24,6 @@ func NewOverlayFS(delegate FileSource) *overlayFS { } } -// Overlays returns a new unordered array of overlays. -func (fs *overlayFS) Overlays() []*Overlay { - fs.mu.Lock() - defer fs.mu.Unlock() - - overlays := make([]*Overlay, 0, len(fs.overlays)) - for _, overlay := range fs.overlays { - overlays = append(overlays, overlay) - } - - return overlays -} - func (fs *overlayFS) ReadFile(ctx context.Context, uri uri.URI) (FileHandle, error) { slog.Debug("reading uri", "uri", uri) fs.mu.Lock() @@ -52,7 +39,7 @@ func (fs *overlayFS) ReadFile(ctx context.Context, uri uri.URI) (FileHandle, err // Update applies changes to the overlay set. DidClose changes remove the // overlay; all other types create or replace it. -func (fs *overlayFS) Update(ctx context.Context, changes []*FileChange) error { +func (fs *overlayFS) Update(_ context.Context, changes []*FileChange) error { for _, change := range changes { if change.From == FileChangeTypeDidClose { fs.Forget(change.URI) @@ -60,21 +47,7 @@ func (fs *overlayFS) Update(ctx context.Context, changes []*FileChange) error { continue } - var base []byte - - if change.From == FileChangeTypeDidChange { - fh, err := fs.ReadFile(ctx, change.URI) - if err != nil { - return err - } - - base, err = fh.Content() - if err != nil { - return err - } - } - - overlay := NewOverlay(change.URI, change.FullContent(base), int32(change.Version)) + overlay := NewOverlay(change.URI, change.FullContent(), int32(change.Version)) slog.Debug("new overlay content", "content", string(overlay.content), "uri", change.URI) @@ -109,12 +82,7 @@ func (fs *overlayFS) Forget(uri uri.URI) { type Overlay struct { uri uri.URI content []byte - hash Hash version int32 - - // saved is true if a file matches the state on disk, - // and therefore does not need to be part of the overlay sent to go/packages. - saved bool } func NewOverlay(uri uri.URI, content []byte, version int32) *Overlay { @@ -122,19 +90,10 @@ func NewOverlay(uri uri.URI, content []byte, version int32) *Overlay { uri: uri, content: content, version: version, - hash: HashOf(content), } } func (o *Overlay) URI() uri.URI { return o.uri } -func (o *Overlay) FileIdentity() FileIdentity { - return FileIdentity{ - URI: o.uri, - Hash: o.hash, - } -} - func (o *Overlay) Content() ([]byte, error) { return o.content, nil } func (o *Overlay) Version() int32 { return o.version } -func (o *Overlay) Saved() bool { return o.saved } diff --git a/lsp/cache/graph.go b/lsp/cache/graph.go index ff83cb1..ad27ca0 100644 --- a/lsp/cache/graph.go +++ b/lsp/cache/graph.go @@ -1,7 +1,6 @@ package cache import ( - "log/slog" "sort" "strings" "sync" @@ -221,9 +220,3 @@ func (g *IncludeGraph) removeWithoutLock(file uri.URI) { delete(g.mapper, file) } } - -func (g *IncludeGraph) Debug() { - for file, node := range g.mapper { - slog.Debug("graph file node", "file", file, "node", node) - } -} diff --git a/lsp/cache/graph_test.go b/lsp/cache/graph_test.go index 6f5d1ea..e40066a 100644 --- a/lsp/cache/graph_test.go +++ b/lsp/cache/graph_test.go @@ -168,10 +168,8 @@ func Test_SnapshotParseIncludeCycles(t *testing.T) { ss := BuildSnapshotForTest(files) // both directions of the mutual cycle are recorded - node := ss.Graph().Get(char) - assert.NotNil(t, node) - assert.Equal(t, []uri.URI{amuro}, node.OutDegree()) - assert.Equal(t, []uri.URI{amuro}, node.InDegree()) + assert.Equal(t, []uri.URI{amuro}, ss.Includes(char)) + assert.Equal(t, []uri.URI{amuro}, ss.Includers(char)) // dependents terminate on the cycle and include both files assert.Equal(t, []uri.URI{amuro, char}, ss.Dependents(char)) diff --git a/lsp/cache/index.go b/lsp/cache/index.go new file mode 100644 index 0000000..dd14566 --- /dev/null +++ b/lsp/cache/index.go @@ -0,0 +1,220 @@ +package cache + +import ( + "github.com/karitham/thrift-ls/syntax" +) + +// RefKind classifies a name reference by the grammar slot it sits in. The +// slot decides what the reference can legally point at: an exception is +// only referenced from signatures, an enum value only from value positions. +type RefKind uint8 + +const ( + // RefFieldType is a type reference in a field-ish position: struct, + // union, and exception fields, typedef targets, and const types. + RefFieldType RefKind = iota + 1 + // RefSignatureType is a type reference in a service signature: a + // function return type, argument, or throws member. + RefSignatureType + // RefConstValue is an identifier in a constant value position: a field + // default or a const value, possibly qualified ("Color.RED"). + RefConstValue + // RefServiceExtends is a service extends reference. + RefServiceExtends +) + +// Reference is one name occurrence that resolves to a definition somewhere: +// in this file or in an included one. It is a raw fact — the name text as +// written, uninterpreted. Qualifier parsing ("shared.User" vs +// "shared.thrift.User") and definition matching live in the source layer, +// which knows the include graph. +type Reference struct { + Kind RefKind + + // Name is the reference text as written: "User", "shared.User", or + // "shared.thrift.User". + Name string + + // Node carries the reference's position: *syntax.Identifier for type + // and service references, *syntax.ConstValue for value references. + // Ranges come from the owning file's AST and mapper. + Node syntax.Node +} + +// FileIndex is the per-file semantic index: the file's definitions, enum +// values, name references, and annotation names, extracted in a single AST +// walk and cached with the parse. A re-parse replaces the whole +// ParsedFile, so the index never goes stale. +// +// The index answers "what does this file contain". Cross-file questions — +// "where is this name defined", "who references it" — belong to +// source.Index, which composes FileIndexes over the include graph. +type FileIndex struct { + defs map[string]syntax.Node + enumValues map[string]*syntax.Identifier + refs []Reference + annotations map[string]struct{} +} + +// Defs returns the file's top-level definitions indexed by name: structs, +// unions, exceptions, enums, services, consts, and typedefs. The node's +// concrete type identifies the definition kind. +func (x *FileIndex) Defs() map[string]syntax.Node { + return x.defs +} + +// EnumValues returns the file's enum value names indexed by name. +func (x *FileIndex) EnumValues() map[string]*syntax.Identifier { + return x.enumValues +} + +// References returns every name reference in the file, in document order: +// field and signature type references, constant value identifiers, and +// service extends references. +func (x *FileIndex) References() []Reference { + return x.refs +} + +// buildIndex extracts the FileIndex of ast in one walk. +func buildIndex(ast *syntax.Document) *FileIndex { + x := &FileIndex{ + defs: make(map[string]syntax.Node), + enumValues: make(map[string]*syntax.Identifier), + annotations: make(map[string]struct{}), + } + if ast == nil { + return x + } + + collect := &indexWalker{x: x} + + for _, n := range ast.Nodes { + collect.visit(n) + } + + return x +} + +// indexWalker accumulates the index of one document in a single walk. +type indexWalker struct { + x *FileIndex +} + +// visit walks one top-level node: definitions and enum values bind names, +// references are recorded with the slot classification of their position. +func (w *indexWalker) visit(n syntax.Node) { + switch v := n.(type) { + case *syntax.Struct: + w.x.defs[v.Name.Text] = v + w.note(v.Annotations) + + for _, f := range v.Fields { + w.field(f, RefFieldType) + } + case *syntax.Enum: + w.x.defs[v.Name.Text] = v + w.note(v.Annotations) + + for _, ev := range v.Values { + w.x.enumValues[ev.Name.Text] = ev.Name + w.note(ev.Annotations) + } + case *syntax.Service: + w.x.defs[v.Name.Text] = v + w.note(v.Annotations) + + if v.Extends != nil { + w.x.refs = append(w.x.refs, Reference{ + Kind: RefServiceExtends, + Name: v.Extends.Text, + Node: v.Extends, + }) + } + + for _, fn := range v.Functions { + w.note(fn.Annotations) + w.typ(fn.Type, RefSignatureType) + + for _, a := range fn.Args { + w.field(a, RefSignatureType) + } + + if fn.Throws != nil { + for _, f := range fn.Throws.Fields { + w.field(f, RefSignatureType) + } + } + } + case *syntax.Const: + w.x.defs[v.Name.Text] = v + w.typ(v.Type, RefFieldType) + w.value(v.Value) + case *syntax.Typedef: + w.x.defs[v.Name.Text] = v + w.note(v.Annotations) + w.typ(v.Type, RefFieldType) + case *syntax.Namespace: + w.note(v.Annotations) + } +} + +// field records a field's type and default value references, plus its +// annotations. +func (w *indexWalker) field(f *syntax.Field, kind RefKind) { + w.note(f.Annotations) + w.typ(f.Type, kind) + w.value(f.Value) +} + +// typ records a type reference: the type name itself and any container +// element types, all in the same slot kind. Annotations on container +// types and the type name itself are also collected. +func (w *indexWalker) typ(ft *syntax.FieldType, kind RefKind) { + if ft == nil { + return + } + + w.note(ft.Annotations) + + if ft.Kind == syntax.TypeIdent && ft.Ident != nil { + w.x.refs = append(w.x.refs, Reference{Kind: kind, Name: ft.Ident.Text, Node: ft.Ident}) + } + + w.typ(ft.KeyType, kind) + w.typ(ft.ValueType, kind) +} + +// value records an identifier in a value position: a field default, a +// const value, or a nested list/map element, recursively. The boolean +// literals true/false are value identifiers syntactically but not +// references; every other identifier (including qualified forms like +// "Color.RED" or "shared.Color.RED") is. +func (w *indexWalker) value(v *syntax.ConstValue) { + if v == nil { + return + } + + if v.Kind == syntax.ValueIdent && v.Text != "true" && v.Text != "false" { + w.x.refs = append(w.x.refs, Reference{Kind: RefConstValue, Name: v.Text, Node: v}) + } + + for _, item := range v.List { + w.value(item) + } + + for _, entry := range v.Map { + w.value(entry.Key) + w.value(entry.Value) + } +} + +// note records every annotation name of the annotations node. +func (w *indexWalker) note(a *syntax.Annotations) { + if a == nil { + return + } + + for _, item := range a.Items { + w.x.annotations[item.Name.Text] = struct{}{} + } +} diff --git a/lsp/cache/index_test.go b/lsp/cache/index_test.go new file mode 100644 index 0000000..c281194 --- /dev/null +++ b/lsp/cache/index_test.go @@ -0,0 +1,180 @@ +package cache + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/karitham/thrift-ls/syntax" +) + +func TestFileIndex_Defs(t *testing.T) { + idx := buildIndex(parse(t, ` + struct Foo {} + union Bar {} + exception Err {} + enum Color { RED } + service Svc {} + const i32 C = 0 + typedef i32 Age + `)) + + defs := idx.Defs() + assert.NotNil(t, defs["Foo"]) + assert.NotNil(t, defs["Bar"]) + assert.NotNil(t, defs["Err"]) + assert.NotNil(t, defs["Color"]) + assert.NotNil(t, defs["Svc"]) + assert.NotNil(t, defs["C"]) + assert.NotNil(t, defs["Age"]) +} + +func TestFileIndex_EnumValues(t *testing.T) { + idx := buildIndex(parse(t, ` + enum Color { RED = 0, GREEN = 2, BLUE = 3 } + `)) + + all := idx.EnumValues() + assert.NotNil(t, all["RED"]) + assert.NotNil(t, all["GREEN"]) + assert.NotNil(t, all["BLUE"]) + assert.Equal(t, "RED", all["RED"].Text) +} + +func TestFileIndex_References(t *testing.T) { + idx := buildIndex(parse(t, ` + include "shared.thrift" + struct Foo { + 1: i32 id, + 2: shared.Bar bar, + 3: list items, + } + const i32 C = shared.Max + + typedef map QuxMap + + service Svc { + RpcResult do(1: Arg arg) throws (1: Err err); + } + `)) + + refs := idx.References() + + // field type references: shared.Bar (qualified), Baz, list has Baz element + has := func(name string, kind RefKind) bool { + for _, r := range refs { + if r.Name == name && r.Kind == kind { + return true + } + } + + return false + } + + require.True(t, has("shared.Bar", RefFieldType), "field type") + require.True(t, has("Baz", RefFieldType), "container element type") + require.True(t, has("shared.Max", RefConstValue), "const value reference") + require.True(t, has("Qux", RefFieldType), "typedef target") + + require.True(t, has("RpcResult", RefSignatureType), "return type") + require.True(t, has("Arg", RefSignatureType), "argument type") + require.True(t, has("Err", RefSignatureType), "throws type") +} + +func TestFileIndex_ServiceExtends(t *testing.T) { + idx := buildIndex(parse(t, ` + service Base {} + service Derived extends Base {} + `)) + + has := false + for _, r := range idx.References() { + if r.Kind == RefServiceExtends && r.Name == "Base" { + has = true + } + } + + require.True(t, has) +} + +func TestFileIndex_ExcludesTrueFalse(t *testing.T) { + idx := buildIndex(parse(t, ` + const bool C = true + const bool D = false + `)) + + for _, r := range idx.References() { + require.NotEqual(t, "true", r.Name) + require.NotEqual(t, "false", r.Name) + } +} + +func TestFileIndex_Annotations(t *testing.T) { + // Each sub-test uses an isolated document so parser interactions + // (struct-level annotations before annotated fields, etc.) do not + // mask results. + t.Run("definition", func(t *testing.T) { + idx := buildIndex(parse(t, `struct Foo (ann = "x") {}`)) + require.Contains(t, idx.annotations, "ann") + }) + t.Run("field type", func(t *testing.T) { + idx := buildIndex(parse(t, `struct Foo { + 1: i32 (bar = "y") id, + }`)) + require.Contains(t, idx.annotations, "bar") + }) + t.Run("enum", func(t *testing.T) { + idx := buildIndex(parse(t, `enum Color (colorAnn = "z") { RED }`)) + require.Contains(t, idx.annotations, "colorAnn") + }) + t.Run("service", func(t *testing.T) { + idx := buildIndex(parse(t, `service Svc (svcAnn) { + void f(1: i32 x); + }`)) + require.Contains(t, idx.annotations, "svcAnn") + }) + t.Run("namespace", func(t *testing.T) { + idx := buildIndex(parse(t, `namespace java com.example (langAnn)`)) + require.Contains(t, idx.annotations, "langAnn") + }) + t.Run("typedef", func(t *testing.T) { + idx := buildIndex(parse(t, `typedef i32 (typeAnn) Age`)) + require.Contains(t, idx.annotations, "typeAnn") + }) +} + +func TestFileIndex_SingleWalkConsistency(t *testing.T) { + content := ` + struct Foo { + 1: Bar bar, + } + enum Color { RED = 0 } + service Svc extends Base { + void f(1: i32 x) throws (1: Err e); + } + ` + p := mustParse(content) + pf := &ParsedFile{ast: p} + + // Definitions and EnumValues via the index must match the old + // accessors — the index walker produces identical output. + assert.NotNil(t, pf.Definitions()["Foo"]) + assert.NotNil(t, pf.Definitions()["Color"]) + assert.NotNil(t, pf.Definitions()["Svc"]) + + assert.NotNil(t, pf.EnumValues()["RED"]) +} + +func mustParse(src string) *syntax.Document { + ast, _ := syntax.Parse([]byte(src)) + + return ast +} + +func parse(t *testing.T, src string) *syntax.Document { + t.Helper() + ast, _ := syntax.Parse([]byte(src)) + + return ast +} diff --git a/lsp/cache/invalidation_test.go b/lsp/cache/invalidation_test.go index ba76cb7..66345b1 100644 --- a/lsp/cache/invalidation_test.go +++ b/lsp/cache/invalidation_test.go @@ -67,7 +67,7 @@ func newViewHarness(t *testing.T, files []*FileChange) *viewHarness { t.Fatal(err) } - view := NewView("test", "file:///tmp", fs, nil) + view := NewView("file:///tmp", fs, nil) ss, release := view.Snapshot() defer release() @@ -194,14 +194,16 @@ struct Gundam { ss := h.snapshot(t) for _, file := range tt.wantDropped { - assert.Nil(t, ss.parsedCache.Get(file), "parse cache for %s should be dropped", file) + pf, _ := ss.parsedCache.Get(file) + assert.Nil(t, pf, "parse cache for %s should be dropped", file) _, ok := ss.files.Get(file) assert.False(t, ok, "file handle for %s should be dropped", file) } for _, file := range tt.wantKept { - assert.NotNil(t, ss.parsedCache.Get(file), "parse cache for %s should survive", file) + pf, _ := ss.parsedCache.Get(file) + assert.NotNil(t, pf, "parse cache for %s should survive", file) } }) } diff --git a/lsp/cache/parse.go b/lsp/cache/parse.go index 3fac1aa..df5d352 100644 --- a/lsp/cache/parse.go +++ b/lsp/cache/parse.go @@ -3,7 +3,6 @@ package cache import ( "fmt" "log/slog" - "maps" "sync" "go.lsp.dev/uri" @@ -12,104 +11,6 @@ import ( "github.com/karitham/thrift-ls/syntax" ) -// ParseCaches maps URIs to parsed files. Snapshots share the underlying map -// (Clone is O(1)); the first write after a clone copies the map -// copy-on-write, so cloning per keystroke is cheap while old snapshots stay -// immutable. -type ParseCaches struct { - mu sync.RWMutex - caches map[uri.URI]*ParsedFile - shared bool -} - -func NewParseCaches() *ParseCaches { - return &ParseCaches{ - caches: make(map[uri.URI]*ParsedFile), - } -} - -func (c *ParseCaches) Set(filePath uri.URI, res *ParsedFile) { - c.mu.Lock() - defer c.mu.Unlock() - - c.copyOnWrite() - - c.caches[filePath] = res -} - -func (c *ParseCaches) Get(filePath uri.URI) *ParsedFile { - c.mu.RLock() - defer c.mu.RUnlock() - - return c.caches[filePath] -} - -func (c *ParseCaches) Forget(filePath uri.URI) { - c.mu.Lock() - defer c.mu.Unlock() - - c.copyOnWrite() - - delete(c.caches, filePath) -} - -// Clone returns a view sharing the same entries. The clone and the original -// both become copy-on-write. -func (c *ParseCaches) Clone() *ParseCaches { - c.mu.Lock() - defer c.mu.Unlock() - - c.shared = true - - return &ParseCaches{caches: c.caches, shared: true} -} - -// copyOnWrite detaches caches from a shared parent before the first write. -// Callers must hold mu. -func (c *ParseCaches) copyOnWrite() { - if !c.shared { - return - } - - caches := make(map[uri.URI]*ParsedFile, len(c.caches)+1) - maps.Copy(caches, c.caches) - - c.caches = caches - c.shared = false -} - -// TokensForFile returns tokens for the given file and its transitively -// included files. Each file's token set is computed once per parse and -// reused, so typing does not re-walk the include closure's ASTs. -func (c *ParseCaches) TokensForFile(file uri.URI, getIncludes func(uri.URI) []uri.URI) map[string]struct{} { - tokens := make(map[string]struct{}) - visited := make(map[uri.URI]bool) - - var collect func(f uri.URI) - - collect = func(f uri.URI) { - if visited[f] { - return - } - - visited[f] = true - - if pf := c.Get(f); pf != nil { - for token := range pf.Tokens() { - tokens[token] = struct{}{} - } - } - - for _, inc := range getIncludes(f) { - collect(inc) - } - } - - collect(file) - - return tokens -} - // collectTokens collects every identifier name in the document: definition // names, field names, and identifier references. func collectTokens(ast *syntax.Document, tokens map[string]struct{}) { @@ -216,13 +117,26 @@ type ParsedFile struct { tokensOnce sync.Once tokens map[string]struct{} - // defs and enumValues index the file's definitions, computed lazily - // once per parse. A re-parse replaces the whole ParsedFile, so the - // caches never go stale. - defsOnce sync.Once - defs map[string]syntax.Node - enumOnce sync.Once - enumValues map[string]*syntax.Identifier + // index is the file's semantic index, computed lazily once per parse. + // A single walk of the AST collects definitions, enum values, name + // references, and annotation names. + indexOnce sync.Once + index *FileIndex +} + +// Index returns the file's semantic index: definitions, enum values, name +// references, and annotation names from a single AST walk. +func (p *ParsedFile) Index() *FileIndex { + p.indexOnce.Do(func() { + p.index = buildIndex(p.ast) + }) + + return p.index +} + +// URI returns the URI of the parsed file. +func (p *ParsedFile) URI() uri.URI { + return p.fh.URI() } func (p *ParsedFile) Mapper() *mapper.Mapper { @@ -257,49 +171,12 @@ func (p *ParsedFile) Tokens() map[string]struct{} { // structs, unions, exceptions, enums, services, consts, and typedefs. The // node's concrete type identifies the definition kind. func (p *ParsedFile) Definitions() map[string]syntax.Node { - p.defsOnce.Do(func() { - p.defs = map[string]syntax.Node{} - - if p.ast == nil { - return - } - - for _, n := range p.ast.Nodes { - switch v := n.(type) { - case *syntax.Struct: - p.defs[v.Name.Text] = v - case *syntax.Enum: - p.defs[v.Name.Text] = v - case *syntax.Service: - p.defs[v.Name.Text] = v - case *syntax.Const: - p.defs[v.Name.Text] = v - case *syntax.Typedef: - p.defs[v.Name.Text] = v - } - } - }) - - return p.defs + return p.Index().Defs() } // EnumValues returns the file's enum value names indexed by name. func (p *ParsedFile) EnumValues() map[string]*syntax.Identifier { - p.enumOnce.Do(func() { - p.enumValues = map[string]*syntax.Identifier{} - - if p.ast == nil { - return - } - - for _, enum := range p.ast.Enums() { - for _, value := range enum.Values { - p.enumValues[value.Name.Text] = value.Name - } - } - }) - - return p.enumValues + return p.Index().EnumValues() } func (p *ParsedFile) AggregatedError() error { @@ -329,7 +206,7 @@ func Parse(fh FileHandle) (*ParsedFile, error) { slog.Debug("parse failed", "errs", errs) } - pf.mapper = mapper.NewMapper(fh.URI(), content) + pf.mapper = mapper.NewMapper(content) return pf, nil } diff --git a/lsp/cache/parse_test.go b/lsp/cache/parse_test.go index a63be6c..d51ee04 100644 --- a/lsp/cache/parse_test.go +++ b/lsp/cache/parse_test.go @@ -33,7 +33,6 @@ struct Xtruct3 } `), version: 0, - saved: false, }, }, assertion: assert.NoError, @@ -55,7 +54,6 @@ struct Xtruct3 } `), version: 0, - saved: false, }, }, assertion: assert.NoError, diff --git a/lsp/cache/resolver_test.go b/lsp/cache/resolver_test.go index 364805a..29fad7a 100644 --- a/lsp/cache/resolver_test.go +++ b/lsp/cache/resolver_test.go @@ -31,7 +31,7 @@ func TestResolver(t *testing.T) { c := New(nil) fs := NewOverlayFS(c) - view := NewView("test", uri.File(tmpDir), fs, nil) + view := NewView(uri.File(tmpDir), fs, nil) includePaths := []string{sharedDir} ss := NewSnapshot(view, includePaths) diff --git a/lsp/cache/session.go b/lsp/cache/session.go index 117f600..6a8c7b5 100644 --- a/lsp/cache/session.go +++ b/lsp/cache/session.go @@ -3,15 +3,12 @@ package cache import ( "context" "fmt" - "math/rand" "sync" "go.lsp.dev/uri" ) type Session struct { - id int64 - // cache is shared global cache *Cache @@ -26,7 +23,6 @@ type Session struct { func NewSession(cache *Cache) *Session { sess := &Session{ - id: rand.Int63(), cache: cache, views: make([]*View, 0), viewMap: make(map[uri.URI]*View), @@ -48,7 +44,7 @@ func (s *Session) AddView(folder uri.URI) *View { } } - view := NewView(folder.Path(), folder, s.overlayFS, s.cache.IncludePaths) + view := NewView(folder, s.overlayFS, s.cache.IncludePaths) s.views = append(s.views, view) return view @@ -113,6 +109,10 @@ func (s *Session) ViewOf(fileURI uri.URI) (*View, error) { } } + // Fallback: the file is not inside any view's folder (an include + // outside the root, or a stray URI). The first view is the session's + // default; silently treating it as no error mirrors single-root + // server deployments where every file belongs to the one view. return s.views[0], nil } diff --git a/lsp/cache/snapshot.go b/lsp/cache/snapshot.go index 78a0c49..a87e68c 100644 --- a/lsp/cache/snapshot.go +++ b/lsp/cache/snapshot.go @@ -5,7 +5,6 @@ import ( "context" "io/fs" "log/slog" - "math/rand" "strings" "sync" "time" @@ -141,15 +140,13 @@ func getIncludeNameFromPath(path string) string { } type Snapshot struct { - id int64 - view *View refCount sync.WaitGroup files *FilesMap - context *Context + context *IncludeDeps parsedCache *ParseCaches includePaths []string @@ -157,16 +154,12 @@ type Snapshot struct { func NewSnapshot(view *View, includePaths []string) *Snapshot { snapshot := &Snapshot{ - id: rand.Int63(), view: view, - refCount: sync.WaitGroup{}, - context: NewContext(), - parsedCache: NewParseCaches(), - files: &FilesMap{ - files: make(map[uri.URI]FileHandle), - overlays: make(map[uri.URI]*Overlay), - }, + refCount: sync.WaitGroup{}, + context: NewIncludeDeps(), + parsedCache: NewParseCaches(), + files: NewFilesMap(), includePaths: includePaths, } @@ -179,8 +172,14 @@ func (s *Snapshot) Acquire() func() { return s.refCount.Done } -func (s *Snapshot) Graph() *IncludeGraph { - return s.context.graph +// Includes returns the files file includes directly, in include order. +func (s *Snapshot) Includes(file uri.URI) []uri.URI { + return s.context.Includes(file) +} + +// Includers returns the files that include file directly, in graph order. +func (s *Snapshot) Includers(file uri.URI) []uri.URI { + return s.context.Includers(file) } // Dependents returns the transitive dependents of uri: every file that @@ -236,7 +235,7 @@ func (s *Snapshot) ForgetFile(uri uri.URI) { } func (s *Snapshot) Parse(ctx context.Context, uri uri.URI) (*ParsedFile, error) { - if parsedFile := s.parsedCache.Get(uri); parsedFile != nil { + if parsedFile, ok := s.parsedCache.Get(uri); ok { return parsedFile, nil } @@ -245,10 +244,6 @@ func (s *Snapshot) Parse(ctx context.Context, uri uri.URI) (*ParsedFile, error) return nil, err } - // DEBUG - // content, _ := fh.Content() - // slog.Debug("parse content", "content", string(content)) - pf, err := Parse(fh) if err != nil { slog.Debug("snapshot parse failed", "err", err) @@ -266,28 +261,41 @@ func (s *Snapshot) Parse(ctx context.Context, uri uri.URI) (*ParsedFile, error) } // TokensForFile returns the identifier tokens of file and its transitively -// included files. +// included files. Each file's token set is computed once per parse and +// reused, so typing does not re-walk the include closure's ASTs. func (s *Snapshot) TokensForFile(file uri.URI) map[string]struct{} { - return s.parsedCache.TokensForFile(file, func(f uri.URI) []uri.URI { - node := s.context.graph.Get(f) - if node == nil { - return nil + tokens := make(map[string]struct{}) + visited := make(map[uri.URI]bool) + + var collect func(f uri.URI) + + collect = func(f uri.URI) { + if visited[f] { + return } - return node.OutDegree() - }) + visited[f] = true + + if pf, ok := s.parsedCache.Get(f); ok { + for token := range pf.Tokens() { + tokens[token] = struct{}{} + } + } + + for _, inc := range s.Includes(f) { + collect(inc) + } + } + + collect(file) + + return tokens } func (s *Snapshot) clone() (*Snapshot, func()) { snap := &Snapshot{ - id: rand.Int63(), - view: s.view, - // TODO(jpf): file change 没有更新,导致读到旧的缓存 - files: s.files.Clone(), - // files: &FilesMap{ - // files: make(map[uri.URI]FileHandle), - // overlays: make(map[uri.URI]*Overlay), - // }, + view: s.view, + files: s.files.Clone(), context: s.context.Clone(), parsedCache: s.parsedCache.Clone(), includePaths: s.includePaths, @@ -307,7 +315,7 @@ func BuildSnapshotForTestWithPaths(includePaths []string, files []*FileChange) * fs := NewOverlayFS(c) _ = fs.Update(context.TODO(), files) - view := NewView("test", "file:///tmp", fs, includePaths) + view := NewView("file:///tmp", fs, includePaths) ss := NewSnapshot(view, includePaths) for _, f := range files { diff --git a/lsp/cache/view.go b/lsp/cache/view.go index 185d4e4..b9f0768 100644 --- a/lsp/cache/view.go +++ b/lsp/cache/view.go @@ -3,7 +3,6 @@ package cache import ( "context" "log/slog" - "math/rand" "slices" "strings" "sync" @@ -12,11 +11,6 @@ import ( ) type View struct { - id int64 - - // name is the user-specified name of this view. - name string - // TODO(jpf): view 的设计并不合理 // workspace folder folder uri.URI @@ -36,10 +30,8 @@ type View struct { snapshotRelease func() } -func NewView(name string, folder uri.URI, fs FileSource, includePaths []string) *View { +func NewView(folder uri.URI, fs FileSource, includePaths []string) *View { view := &View{ - id: rand.Int63(), - name: name, folder: folder, fs: fs, knownFiles: make(map[uri.URI]bool), diff --git a/lsp/codejump.go b/lsp/codejump.go index a96caea..b62693f 100644 --- a/lsp/codejump.go +++ b/lsp/codejump.go @@ -10,19 +10,19 @@ import ( ) func (s *Server) definition(ctx context.Context, params *protocol.DefinitionParams) (result []protocol.Location, err error) { - return withSnapshot(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot) ([]protocol.Location, error) { + return withSnapshot(s.session, params.TextDocument.URI, func(ss *cache.Snapshot) ([]protocol.Location, error) { return source.Definition(ctx, ss, params.TextDocument.URI, params.Position) }) } func (s *Server) references(ctx context.Context, params *protocol.ReferenceParams) (result []protocol.Location, err error) { - return withSnapshot(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot) ([]protocol.Location, error) { + return withSnapshot(s.session, params.TextDocument.URI, func(ss *cache.Snapshot) ([]protocol.Location, error) { return source.Reference(ctx, ss, params.TextDocument.URI, params.Position) }) } func (s *Server) typeDefinition(ctx context.Context, params *protocol.TypeDefinitionParams) (result []protocol.Location, err error) { - return withSnapshot(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot) ([]protocol.Location, error) { + return withSnapshot(s.session, params.TextDocument.URI, func(ss *cache.Snapshot) ([]protocol.Location, error) { return source.TypeDefinition(ctx, ss, params.TextDocument.URI, params.Position) }) } diff --git a/lsp/folding.go b/lsp/folding.go index d94f2dc..2ad1cd2 100644 --- a/lsp/folding.go +++ b/lsp/folding.go @@ -10,7 +10,7 @@ import ( ) func (s *Server) foldingRanges(ctx context.Context, params *protocol.FoldingRangeParams) ([]protocol.FoldingRange, error) { - return withSnapshot(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot) ([]protocol.FoldingRange, error) { + return withSnapshot(s.session, params.TextDocument.URI, func(ss *cache.Snapshot) ([]protocol.FoldingRange, error) { return source.Ranges(ctx, ss, params.TextDocument.URI), nil }) } diff --git a/lsp/format_range_server_test.go b/lsp/format_range_server_test.go index 6f18cf3..49b8976 100644 --- a/lsp/format_range_server_test.go +++ b/lsp/format_range_server_test.go @@ -40,7 +40,7 @@ struct C { 3: i64 c } // applyEdits applies the edits to the given text via the mapper. applyEdits := func(text string, edits []protocol.TextEdit) string { - got, err := mapper.NewMapper(fileURI, []byte(text)).ApplyEdits(edits) + got, err := mapper.NewMapper([]byte(text)).ApplyEdits(edits) require.NoError(t, err) return string(got) diff --git a/lsp/highlight.go b/lsp/highlight.go index f0dd3ca..daf5417 100644 --- a/lsp/highlight.go +++ b/lsp/highlight.go @@ -10,7 +10,7 @@ import ( ) func (s *Server) documentHighlight(ctx context.Context, params *protocol.DocumentHighlightParams) ([]protocol.DocumentHighlight, error) { - return withSnapshot(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot) ([]protocol.DocumentHighlight, error) { + return withSnapshot(s.session, params.TextDocument.URI, func(ss *cache.Snapshot) ([]protocol.DocumentHighlight, error) { return source.Highlight(ctx, ss, params.TextDocument.URI, params.Position) }) } diff --git a/lsp/hover.go b/lsp/hover.go index b78078e..8693f51 100644 --- a/lsp/hover.go +++ b/lsp/hover.go @@ -11,7 +11,7 @@ import ( ) func (s *Server) hover(ctx context.Context, params *protocol.HoverParams) (*protocol.Hover, error) { - return withSnapshot(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot) (*protocol.Hover, error) { + return withSnapshot(s.session, params.TextDocument.URI, func(ss *cache.Snapshot) (*protocol.Hover, error) { content, err := source.Hover(ctx, ss, params.TextDocument.URI, params.Position) if err != nil { return nil, err diff --git a/lsp/impl.go b/lsp/impl.go index b57e7b5..eb78311 100644 --- a/lsp/impl.go +++ b/lsp/impl.go @@ -7,12 +7,12 @@ import ( "path" "strings" - "go.lsp.dev/protocol" "go.lsp.dev/uri" + "go.lsp.dev/protocol" + "github.com/karitham/thrift-ls/lsp/cache" "github.com/karitham/thrift-ls/lsp/source" - "github.com/karitham/thrift-ls/lsp/types" ) func (s *Server) didOpen(ctx context.Context, params *protocol.DidOpenTextDocumentParams) error { @@ -192,8 +192,7 @@ func (s *Server) diagnose(ctx context.Context, ss *cache.Snapshot, affected []ur func (s *Server) completion(ctx context.Context, params *protocol.CompletionParams) (*protocol.CompletionList, error) { return withFile(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot, fh cache.FileHandle) (*protocol.CompletionList, error) { items, rng, truncated, err := source.DefaultTokenCompletion.Completion(ctx, ss, &source.CompletionRequest{ - TriggerKind: 0, - Pos: types.Position{ + Pos: protocol.Position{ Line: params.Position.Line, Character: params.Position.Character, }, diff --git a/lsp/initialize.go b/lsp/initialize.go index 2368004..51bb5ac 100644 --- a/lsp/initialize.go +++ b/lsp/initialize.go @@ -15,7 +15,7 @@ import ( "github.com/karitham/thrift-ls/lsp/source" ) -func (s *Server) initialize(ctx context.Context, params *protocol.InitializeParams) (result *protocol.InitializeResult, err error) { +func (s *Server) initialize(params *protocol.InitializeParams) (result *protocol.InitializeResult, err error) { // Prefer WorkspaceFolders; fall back to the deprecated RootURI/RootPath // fields for older clients. folders := make([]uri.URI, 0, 1) diff --git a/lsp/links.go b/lsp/links.go index 202ee35..329333b 100644 --- a/lsp/links.go +++ b/lsp/links.go @@ -10,7 +10,7 @@ import ( ) func (s *Server) documentLink(ctx context.Context, params *protocol.DocumentLinkParams) ([]protocol.DocumentLink, error) { - return withSnapshot(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot) ([]protocol.DocumentLink, error) { + return withSnapshot(s.session, params.TextDocument.URI, func(ss *cache.Snapshot) ([]protocol.DocumentLink, error) { return source.Links(ctx, ss, params.TextDocument.URI), nil }) } diff --git a/lsp/mapper/apply.go b/lsp/mapper/apply.go index 9f0d049..abf401d 100644 --- a/lsp/mapper/apply.go +++ b/lsp/mapper/apply.go @@ -5,8 +5,6 @@ import ( "sort" "go.lsp.dev/protocol" - - "github.com/karitham/thrift-ls/lsp/types" ) // ApplyEdits returns the mapped content with the edits applied. Edits must @@ -54,7 +52,7 @@ func (m *Mapper) ApplyEdits(edits []protocol.TextEdit) ([]byte, error) { // offsetAt resolves an LSP (UTF-16) position to a byte offset in the mapped // content. func (m *Mapper) offsetAt(pos protocol.Position) (int, error) { - p, err := m.LSPPosToParserPosition(types.Position{Line: pos.Line, Character: pos.Character}) + p, err := m.LSPPosToParserPosition(protocol.Position{Line: pos.Line, Character: pos.Character}) return p.Offset, err } diff --git a/lsp/mapper/mapper.go b/lsp/mapper/mapper.go index 25dfd91..a09f8b1 100644 --- a/lsp/mapper/mapper.go +++ b/lsp/mapper/mapper.go @@ -8,14 +8,12 @@ import ( "sync" "unicode/utf8" - "go.lsp.dev/uri" + "go.lsp.dev/protocol" - "github.com/karitham/thrift-ls/lsp/types" "github.com/karitham/thrift-ls/syntax" ) type Mapper struct { - fileURI uri.URI content []byte lineInit sync.Once @@ -24,9 +22,8 @@ type Mapper struct { } // NewMapper ... -func NewMapper(fileURI uri.URI, content []byte) *Mapper { +func NewMapper(content []byte) *Mapper { return &Mapper{ - fileURI: fileURI, content: content, } } @@ -51,12 +48,12 @@ func (m *Mapper) initLineStart() { // GetLSPEndPosition returns the position immediately after the last // character of the document: the last line (0-based) at the UTF-16 length // of its content. A document ending with a newline has an empty last line. -func (m *Mapper) GetLSPEndPosition() types.Position { +func (m *Mapper) GetLSPEndPosition() protocol.Position { m.initLineStart() lastLineStart := m.lineStart[len(m.lineStart)-1] lastLine := m.content[lastLineStart:] - return types.Position{ + return protocol.Position{ Line: uint32(len(m.lineStart) - 1), Character: uint32(utf16Count(lastLine)), } @@ -64,23 +61,23 @@ func (m *Mapper) GetLSPEndPosition() types.Position { // OffsetToLSPPosition converts a byte offset in the mapped content to an LSP // position (0-based line, UTF-16 code-unit column). -func (m *Mapper) OffsetToLSPPosition(offset int) (types.Position, error) { +func (m *Mapper) OffsetToLSPPosition(offset int) (protocol.Position, error) { m.initLineStart() if offset < 0 || offset > len(m.content) { - return types.Position{}, fmt.Errorf("invalid offset: %d, total content: %d", offset, len(m.content)) + return protocol.Position{}, fmt.Errorf("invalid offset: %d, total content: %d", offset, len(m.content)) } line := max(sort.Search(len(m.lineStart), func(i int) bool { return m.lineStart[i] > offset })-1, 0) - return types.Position{ + return protocol.Position{ Line: uint32(line), Character: uint32(utf16Count(m.content[m.lineStart[line]:offset])), }, nil } // convert from utf16-based to rune-based position -func (m *Mapper) LSPPosToParserPosition(pos types.Position) (syntax.Position, error) { +func (m *Mapper) LSPPosToParserPosition(pos protocol.Position) (syntax.Position, error) { m.initLineStart() line := int(pos.Line) + 1 @@ -159,12 +156,6 @@ func (m *Mapper) LSPPosToParserPosition(pos types.Position) (syntax.Position, er return syntax.InvalidPosition, errors.New("invalid position character") } - /* - if offset >= m.lineStart[pos.Line+1] { - return syntax.InvalidPosition, errors.New("invalid position character") - } - */ - return syntax.Position{ Line: line, Col: runeLen + 1, diff --git a/lsp/mapper/mapper_fuzz_test.go b/lsp/mapper/mapper_fuzz_test.go index 912d682..f85756a 100644 --- a/lsp/mapper/mapper_fuzz_test.go +++ b/lsp/mapper/mapper_fuzz_test.go @@ -3,8 +3,6 @@ package mapper import ( "testing" "unicode/utf8" - - "go.lsp.dev/uri" ) // FuzzOffsetRoundTrip checks that byte offsets round-trip through @@ -38,7 +36,7 @@ func FuzzOffsetRoundTrip(f *testing.F) { return } - m := NewMapper(uri.File("/tmp/fuzz.thrift"), content) + m := NewMapper(content) pos, err := m.OffsetToLSPPosition(offset) if err != nil { diff --git a/lsp/mapper/mapper_test.go b/lsp/mapper/mapper_test.go index 9610553..437e7cd 100644 --- a/lsp/mapper/mapper_test.go +++ b/lsp/mapper/mapper_test.go @@ -4,20 +4,18 @@ import ( "testing" "github.com/stretchr/testify/assert" - "go.lsp.dev/uri" + "go.lsp.dev/protocol" - "github.com/karitham/thrift-ls/lsp/types" "github.com/karitham/thrift-ls/syntax" ) func TestMapper_LSPPosToParserPosition(t *testing.T) { type fields struct { - fileURI uri.URI content []byte } type args struct { - pos types.Position + pos protocol.Position } content := `struct demo { @@ -38,11 +36,10 @@ func TestMapper_LSPPosToParserPosition(t *testing.T) { { name: "ascii", fields: fields{ - fileURI: "test/test.thrift", content: []byte(content), }, args: args{ - pos: types.Position{ + pos: protocol.Position{ Line: 1, Character: 5, }, @@ -57,11 +54,10 @@ func TestMapper_LSPPosToParserPosition(t *testing.T) { { name: "ascii line exceeded", fields: fields{ - fileURI: "test/test.thrift", content: []byte(content), }, args: args{ - pos: types.Position{ + pos: protocol.Position{ Line: 3, Character: 5, }, @@ -72,11 +68,10 @@ func TestMapper_LSPPosToParserPosition(t *testing.T) { { name: "ascii character exceeded", fields: fields{ - fileURI: "test/test.thrift", content: []byte(content), }, args: args{ - pos: types.Position{ + pos: protocol.Position{ Line: 1, Character: 28, }, @@ -87,11 +82,10 @@ func TestMapper_LSPPosToParserPosition(t *testing.T) { { name: "ascii character no exceeded end of file", fields: fields{ - fileURI: "test/test.thrift", content: []byte(content), }, args: args{ - pos: types.Position{ + pos: protocol.Position{ Line: 2, Character: 1, }, @@ -106,11 +100,10 @@ func TestMapper_LSPPosToParserPosition(t *testing.T) { { name: "ascii character exceeded end of file", fields: fields{ - fileURI: "test/test.thrift", content: []byte(content), }, args: args{ - pos: types.Position{ + pos: protocol.Position{ Line: 2, Character: 2, }, @@ -121,11 +114,10 @@ func TestMapper_LSPPosToParserPosition(t *testing.T) { { name: "rune", fields: fields{ - fileURI: "test/test.thrift", content: []byte(runeContent), }, args: args{ - pos: types.Position{ + pos: protocol.Position{ Line: 0, Character: 12, }, @@ -140,11 +132,10 @@ func TestMapper_LSPPosToParserPosition(t *testing.T) { { name: "rune line exceeded", fields: fields{ - fileURI: "test/test.thrift", content: []byte(content), }, args: args{ - pos: types.Position{ + pos: protocol.Position{ Line: 2, Character: 12, }, @@ -155,11 +146,10 @@ func TestMapper_LSPPosToParserPosition(t *testing.T) { { name: "rune character exceeded", fields: fields{ - fileURI: "test/test.thrift", content: []byte(content), }, args: args{ - pos: types.Position{ + pos: protocol.Position{ Line: 0, Character: 15, }, @@ -172,7 +162,6 @@ func TestMapper_LSPPosToParserPosition(t *testing.T) { tt := tests[i] t.Run(tt.name, func(t *testing.T) { m := &Mapper{ - fileURI: tt.fields.fileURI, content: tt.fields.content, } got, err := m.LSPPosToParserPosition(tt.args.pos) @@ -218,32 +207,32 @@ func TestGetLSPEndPosition(t *testing.T) { tests := []struct { name string content string - want types.Position + want protocol.Position }{ { name: "single line", content: "struct Gundam {}", - want: types.Position{Line: 0, Character: 16}, + want: protocol.Position{Line: 0, Character: 16}, }, { name: "multiline without trailing newline", content: "enum ZeonForces {\n ZAKU_I\n}", - want: types.Position{Line: 2, Character: 1}, + want: protocol.Position{Line: 2, Character: 1}, }, { name: "trailing newline has an empty last line", content: "struct Gundam {\n}\n", - want: types.Position{Line: 2, Character: 0}, + want: protocol.Position{Line: 2, Character: 0}, }, { name: "non-ascii on the last line", content: `const string s = "モビルスーツ"`, - want: types.Position{Line: 0, Character: 25}, + want: protocol.Position{Line: 0, Character: 25}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - m := NewMapper("file:///tmp/test.thrift", []byte(tt.content)) + m := NewMapper([]byte(tt.content)) assert.Equal(t, tt.want, m.GetLSPEndPosition()) }) } diff --git a/lsp/rename.go b/lsp/rename.go index cea8ccf..e03e6ea 100644 --- a/lsp/rename.go +++ b/lsp/rename.go @@ -10,13 +10,13 @@ import ( ) func (s *Server) prepareRename(ctx context.Context, params *protocol.PrepareRenameParams) (*protocol.Range, error) { - return withSnapshot(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot) (*protocol.Range, error) { + return withSnapshot(s.session, params.TextDocument.URI, func(ss *cache.Snapshot) (*protocol.Range, error) { return source.PrepareRename(ctx, ss, params.TextDocument.URI, params.Position) }) } func (s *Server) rename(ctx context.Context, params *protocol.RenameParams) (*protocol.WorkspaceEdit, error) { - return withSnapshot(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot) (*protocol.WorkspaceEdit, error) { + return withSnapshot(s.session, params.TextDocument.URI, func(ss *cache.Snapshot) (*protocol.WorkspaceEdit, error) { return source.Rename(ctx, ss, params.TextDocument.URI, params.Position, params.NewName) }) } diff --git a/lsp/semantic.go b/lsp/semantic.go index ed2ef1a..3e97a12 100644 --- a/lsp/semantic.go +++ b/lsp/semantic.go @@ -10,7 +10,7 @@ import ( ) func (s *Server) semanticTokensFull(ctx context.Context, params *protocol.SemanticTokensParams) (*protocol.SemanticTokens, error) { - return withSnapshot(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot) (*protocol.SemanticTokens, error) { + return withSnapshot(s.session, params.TextDocument.URI, func(ss *cache.Snapshot) (*protocol.SemanticTokens, error) { data, err := source.Tokens(ctx, ss, params.TextDocument.URI) if err != nil { return nil, err diff --git a/lsp/server.go b/lsp/server.go index 39dc7e1..ebca344 100644 --- a/lsp/server.go +++ b/lsp/server.go @@ -27,11 +27,9 @@ type Server struct { // initializationOptions and didChangeConfiguration are overlaid on it. base options.Patch - // opts is the effective configuration (base with workspace settings - // applied) and formatOpts its resolved formatter options. Both are - // guarded by optsMu because settings can change between requests. + // formatOpts is the effective formatter configuration. It is guarded by + // optsMu because settings can change between requests. optsMu sync.RWMutex - opts options.Patch formatOpts formatter.Options // folders are the workspace folders from the initialize request; the @@ -57,7 +55,6 @@ func NewServer(c *cache.Cache, client protocol.Client, base options.Patch) *Serv client: client, base: base, } - s.opts = base s.formatOpts, _ = base.Formatter() return s @@ -75,7 +72,6 @@ func (s *Server) setWorkspaceSettings(overlay options.Patch) { } s.optsMu.Lock() - s.opts = merged s.formatOpts = fopts s.optsMu.Unlock() @@ -94,7 +90,7 @@ func (s *Server) Initialize(ctx context.Context, params *protocol.InitializePara slog.Debug("Initialize called") defer slog.Debug("Initialize finished") - return s.initialize(ctx, params) + return s.initialize(params) } func (s *Server) Initialized(ctx context.Context, params *protocol.InitializedParams) (err error) { diff --git a/lsp/snapshot.go b/lsp/snapshot.go index e675017..6ac7b3e 100644 --- a/lsp/snapshot.go +++ b/lsp/snapshot.go @@ -11,7 +11,7 @@ import ( // withSnapshot resolves file's view, acquires its snapshot, and runs fn // while the snapshot is held. Every request handler funnels through this // helper so the acquire/release discipline lives in one place. -func withSnapshot[T any](ctx context.Context, session *cache.Session, file uri.URI, fn func(*cache.Snapshot) (T, error)) (T, error) { +func withSnapshot[T any](session *cache.Session, file uri.URI, fn func(*cache.Snapshot) (T, error)) (T, error) { view, err := session.ViewOf(file) if err != nil { var zero T @@ -27,7 +27,7 @@ func withSnapshot[T any](ctx context.Context, session *cache.Session, file uri.U // withFile is withSnapshot plus the file handle for file. func withFile[T any](ctx context.Context, session *cache.Session, file uri.URI, fn func(*cache.Snapshot, cache.FileHandle) (T, error)) (T, error) { - return withSnapshot(ctx, session, file, func(ss *cache.Snapshot) (T, error) { + return withSnapshot(session, file, func(ss *cache.Snapshot) (T, error) { fh, err := ss.ReadFile(ctx, file) if err != nil { var zero T diff --git a/lsp/source/completion_test.go b/lsp/source/completion_test.go index 34a211a..1a44c2a 100644 --- a/lsp/source/completion_test.go +++ b/lsp/source/completion_test.go @@ -6,8 +6,9 @@ import ( "github.com/stretchr/testify/assert" "go.lsp.dev/uri" + "go.lsp.dev/protocol" + "github.com/karitham/thrift-ls/lsp/cache" - "github.com/karitham/thrift-ls/lsp/types" ) // buildSnapshot builds a snapshot from file contents with optional include @@ -18,7 +19,7 @@ func buildSnapshot(t *testing.T, includePaths []string, files ...*cache.FileChan c := cache.New(nil) fs := cache.NewOverlayFS(c) _ = fs.Update(t.Context(), files) - view := cache.NewView("test", uri.File("/tmp"), fs, includePaths) + view := cache.NewView(uri.File("/tmp"), fs, includePaths) return cache.NewSnapshot(view, includePaths) } @@ -34,7 +35,7 @@ func TestCompletionEndToEnd(t *testing.T) { cmp := &CompletionRequest{ Fh: fh, - Pos: types.Position{Line: 5, Character: 16}, // after "Us" in "1: required Us" + Pos: protocol.Position{Line: 5, Character: 16}, // after "Us" in "1: required Us" } items, _, _, err := DefaultTokenCompletion.Completion(t.Context(), ss, cmp) assert.NoError(t, err) diff --git a/lsp/source/cycle_detect_test.go b/lsp/source/cycle_detect_test.go index 36ad4da..0162b4f 100644 --- a/lsp/source/cycle_detect_test.go +++ b/lsp/source/cycle_detect_test.go @@ -290,7 +290,7 @@ func buildSnapshotForTest(t *testing.T, files []*cache.FileChange) *cache.Snapsh fs := cache.NewOverlayFS(c) _ = fs.Update(t.Context(), files) - view := cache.NewView("test", "file:///tmp", fs, nil) + view := cache.NewView("file:///tmp", fs, nil) ss := cache.NewSnapshot(view, nil) return ss diff --git a/lsp/source/definition.go b/lsp/source/definition.go index 2cc1985..bd00524 100644 --- a/lsp/source/definition.go +++ b/lsp/source/definition.go @@ -2,7 +2,6 @@ package source import ( "context" - "log/slog" "go.lsp.dev/protocol" "go.lsp.dev/uri" @@ -23,139 +22,25 @@ func Definition(ctx context.Context, ss *cache.Snapshot, file uri.URI, pos proto switch target.kind { case TargetTypeName: - return typeNameDefinition(ctx, ss, file, pf, target) + return typeNameDefinition(ctx, NewIndex(ss), pf, target) case TargetConstValue: - return constValueDefinition(ctx, ss, file, pf, target) + return constValueDefinition(ctx, NewIndex(ss), pf, target) case TargetService: - return serviceDefinition(ctx, ss, file, pf, target) + return serviceDefinition(ctx, NewIndex(ss), pf, target) } return res, err } -// FindTypeDefinition resolves a type reference to its definition: an -// exception, struct, enum, union, or typedef, possibly in an included file. -func FindTypeDefinition(ctx context.Context, ss *cache.Snapshot, file uri.URI, ast *syntax.Document, ft *syntax.FieldType) (uri.URI, *syntax.Identifier, DefinitionKind, error) { - name := typeReferenceName(ft) - if name == "" || IsBasicType(name) { - return "", nil, DefinitionNone, nil - } - - _, identifier := parseIdent(file, ast.Includes(), name) - for _, astFile := range definitionFiles(ctx, ss, file, ast, name) { - dstPf, err := parseDefinitionFile(ctx, ss, astFile) - if err != nil { - return astFile, nil, DefinitionNone, err - } - - switch v := dstPf.Definitions()[identifier].(type) { - case *syntax.Struct: - switch v.Kind { - case syntax.UnionDecl: - return astFile, v.Name, DefinitionUnion, nil - case syntax.ExceptionDecl: - return astFile, v.Name, DefinitionException, nil - } - - return astFile, v.Name, DefinitionStruct, nil - case *syntax.Enum: - return astFile, v.Name, DefinitionEnum, nil - case *syntax.Typedef: - return astFile, v.Name, DefinitionTypedef, nil - } - } - - return file, nil, DefinitionNone, nil -} - -// FindConstValueDefinition resolves a constant value identifier to its -// definition: an enum value or a const, possibly in an included file. -func FindConstValueDefinition(ctx context.Context, ss *cache.Snapshot, file uri.URI, ast *syntax.Document, value *syntax.ConstValue) (uri.URI, *syntax.Identifier, error) { - if value == nil || value.Kind != syntax.ValueIdent { - return "", nil, nil - } - - name := value.Text - if name == "true" || name == "false" { - return "", nil, nil - } - - _, identifier := parseIdent(file, ast.Includes(), name) - identifier = bareName(identifier) - - for _, astFile := range definitionFiles(ctx, ss, file, ast, name) { - dstPf, err := parseDefinitionFile(ctx, ss, astFile) - if err != nil { - return astFile, nil, err - } - - if id := dstPf.EnumValues()[identifier]; id != nil { - return astFile, id, nil - } - - if cst, ok := dstPf.Definitions()[identifier].(*syntax.Const); ok { - return astFile, cst.Name, nil - } - } - - return file, nil, nil -} - -// FindServiceDefinition resolves a service name or extends reference to the -// service definition. -func FindServiceDefinition(ctx context.Context, ss *cache.Snapshot, file uri.URI, ast *syntax.Document, ident *syntax.Identifier) (uri.URI, *syntax.Identifier, error) { - if ident == nil { - return "", nil, nil - } - - _, identifier := parseIdent(file, ast.Includes(), ident.Text) - for _, astFile := range definitionFiles(ctx, ss, file, ast, ident.Text) { - dstPf, err := parseDefinitionFile(ctx, ss, astFile) - if err != nil { - return astFile, nil, err - } - - if svc, ok := dstPf.Definitions()[identifier].(*syntax.Service); ok { - return astFile, svc.Name, nil - } - } - - return file, nil, nil -} - -// parseDefinitionFile parses the definition file, tolerating parse errors -// in the target file (the definitions may still be found in the partial -// AST). It returns the parsed file so callers can use its indexes. -func parseDefinitionFile(ctx context.Context, ss *cache.Snapshot, file uri.URI) (*cache.ParsedFile, error) { - pf, err := ss.Parse(ctx, file) - if err != nil { - return nil, err - } - - if len(pf.Errors()) > 0 { - slog.Error("parse error", "errs", pf.Errors()) - } - - if pf.AST() == nil { - return nil, errNoAST - } - - return pf, nil -} - -func typeNameDefinition(ctx context.Context, ss *cache.Snapshot, file uri.URI, pf *cache.ParsedFile, target *target) ([]protocol.Location, error) { +func typeNameDefinition(ctx context.Context, ix *Index, pf *cache.ParsedFile, target *target) ([]protocol.Location, error) { ft := target.parent.(*syntax.FieldType) - astFile, id, _, err := FindTypeDefinition(ctx, ss, file, pf.AST(), ft) - if err != nil { + def, err := ix.ResolveType(ctx, pf, ft) + if err != nil || def == nil { return nil, err } - if id == nil { - return nil, nil - } - - loc, err := jumpInFile(ctx, ss, astFile, id) + loc, err := jumpInFile(ctx, ix.ss, def.File, def.Name) if err != nil { return nil, err } @@ -163,17 +48,13 @@ func typeNameDefinition(ctx context.Context, ss *cache.Snapshot, file uri.URI, p return []protocol.Location{loc}, nil } -func constValueDefinition(ctx context.Context, ss *cache.Snapshot, file uri.URI, pf *cache.ParsedFile, target *target) ([]protocol.Location, error) { - astFile, id, err := FindConstValueDefinition(ctx, ss, file, pf.AST(), target.node.(*syntax.ConstValue)) - if err != nil { +func constValueDefinition(ctx context.Context, ix *Index, pf *cache.ParsedFile, target *target) ([]protocol.Location, error) { + def, err := ix.ResolveValue(ctx, pf, target.node.(*syntax.ConstValue)) + if err != nil || def == nil { return nil, err } - if id == nil { - return nil, nil - } - - loc, err := jumpInFile(ctx, ss, astFile, id) + loc, err := jumpInFile(ctx, ix.ss, def.File, def.Name) if err != nil { return nil, err } @@ -181,17 +62,13 @@ func constValueDefinition(ctx context.Context, ss *cache.Snapshot, file uri.URI, return []protocol.Location{loc}, nil } -func serviceDefinition(ctx context.Context, ss *cache.Snapshot, file uri.URI, pf *cache.ParsedFile, target *target) ([]protocol.Location, error) { - astFile, id, err := FindServiceDefinition(ctx, ss, file, pf.AST(), target.identifier()) - if err != nil { +func serviceDefinition(ctx context.Context, ix *Index, pf *cache.ParsedFile, target *target) ([]protocol.Location, error) { + def, err := ix.ResolveService(ctx, pf, target.identifier()) + if err != nil || def == nil { return nil, err } - if id == nil { - return nil, nil - } - - loc, err := jumpInFile(ctx, ss, astFile, id) + loc, err := jumpInFile(ctx, ix.ss, def.File, def.Name) if err != nil { return nil, err } diff --git a/lsp/source/enum_value_action.go b/lsp/source/enum_value_action.go index f6e28e5..a58070b 100644 --- a/lsp/source/enum_value_action.go +++ b/lsp/source/enum_value_action.go @@ -53,7 +53,7 @@ func MakeEnumValuesExplicitAction(ctx context.Context, ss *cache.Snapshot, fh ca // enumAt returns the enum declaration containing the selection start, or // nil when it lies outside every enum. func enumAt(pf *cache.ParsedFile, rng protocol.Range) *syntax.Enum { - pos, err := pf.Mapper().LSPPosToParserPosition(lspPosition(rng.Start)) + pos, err := pf.Mapper().LSPPosToParserPosition(rng.Start) if err != nil { return nil } diff --git a/lsp/source/enum_value_action_test.go b/lsp/source/enum_value_action_test.go index 0d077da..4454e86 100644 --- a/lsp/source/enum_value_action_test.go +++ b/lsp/source/enum_value_action_test.go @@ -112,7 +112,7 @@ enum B { edits := act.Edit.Changes["file:///tmp/user.thrift"] - got, err := mapper.NewMapper("file:///tmp/user.thrift", []byte(tt.content)).ApplyEdits(edits) + got, err := mapper.NewMapper([]byte(tt.content)).ApplyEdits(edits) require.NoError(t, err) assert.Equal(t, tt.want, string(got)) }) diff --git a/lsp/source/enum_value_check.go b/lsp/source/enum_value_check.go index ea67796..a6eb972 100644 --- a/lsp/source/enum_value_check.go +++ b/lsp/source/enum_value_check.go @@ -118,7 +118,7 @@ func enumImplicitValues(enum *syntax.Enum) []enumImplicitValue { continue } - out = append(out, enumImplicitValue{member: mv.member, value: mv.value, known: mv.known}) + out = append(out, enumImplicitValue(mv)) } return out diff --git a/lsp/source/folding.go b/lsp/source/folding.go index 04af479..16e610c 100644 --- a/lsp/source/folding.go +++ b/lsp/source/folding.go @@ -45,7 +45,7 @@ func Ranges(ctx context.Context, ss *cache.Snapshot, file uri.URI) []protocol.Fo } } - if ann := nodeAnnotations(doc, doc.Nodes); len(ann) > 0 { + if ann := nodeAnnotations(doc.Nodes); len(ann) > 0 { for _, a := range ann { if r, ok := spanRange(pf, a.TokStart(), a.TokEnd()); ok { ranges = append(ranges, r) @@ -106,7 +106,7 @@ func bracedRange(pf *cache.ParsedFile, n syntax.Node) (protocol.FoldingRange, bo // nodeAnnotations collects the annotations of every top-level node, in // source order. -func nodeAnnotations(doc *syntax.Document, nodes []syntax.Node) []*syntax.Annotations { +func nodeAnnotations(nodes []syntax.Node) []*syntax.Annotations { var anns []*syntax.Annotations for _, n := range nodes { diff --git a/lsp/source/folding_test.go b/lsp/source/folding_test.go index 29df153..b905351 100644 --- a/lsp/source/folding_test.go +++ b/lsp/source/folding_test.go @@ -28,7 +28,7 @@ func foldingRanges(t *testing.T, src string) []protocol.FoldingRange { file := uri.File(filepath.Join(dir, "test.thrift")) require.NoError(t, os.WriteFile(filepath.Join(dir, "test.thrift"), []byte(src), 0o644)) - view := cache.NewView("test", uri.File(dir), cache.NewOverlayFS(cache.New(nil)), nil) + view := cache.NewView(uri.File(dir), cache.NewOverlayFS(cache.New(nil)), nil) view.FileChange(t.Context(), []*cache.FileChange{{ URI: file, Version: 0, diff --git a/lsp/source/format.go b/lsp/source/format.go index e7d6fcc..f28adf3 100644 --- a/lsp/source/format.go +++ b/lsp/source/format.go @@ -9,7 +9,6 @@ import ( "github.com/karitham/thrift-ls/formatter" "github.com/karitham/thrift-ls/lsp/cache" "github.com/karitham/thrift-ls/lsp/mapper" - "github.com/karitham/thrift-ls/lsp/types" ) // Format returns the whole-document formatting of fh's content. @@ -44,7 +43,7 @@ func FormatDocument(ctx context.Context, ss *cache.Snapshot, fh cache.FileHandle return nil, nil } - mp := mapper.NewMapper(fh.URI(), content) + mp := mapper.NewMapper(content) endPos := mp.GetLSPEndPosition() return &protocol.TextEdit{ @@ -82,14 +81,14 @@ func FormatRange(ctx context.Context, ss *cache.Snapshot, fh cache.FileHandle, o return nil, nil } - mp := mapper.NewMapper(fh.URI(), content) + mp := mapper.NewMapper(content) - start, err := mp.LSPPosToParserPosition(lspPosition(rng.Start)) + start, err := mp.LSPPosToParserPosition(rng.Start) if err != nil { return nil, err } - end, err := mp.LSPPosToParserPosition(lspPosition(rng.End)) + end, err := mp.LSPPosToParserPosition(rng.End) if err != nil { return nil, err } @@ -118,8 +117,8 @@ func FormatRange(ctx context.Context, ss *cache.Snapshot, fh cache.FileHandle, o result = append(result, protocol.TextEdit{ Range: protocol.Range{ - Start: protocolPosition(startPos), - End: protocolPosition(endPos), + Start: startPos, + End: endPos, }, NewText: be.text, }) @@ -128,22 +127,6 @@ func FormatRange(ctx context.Context, ss *cache.Snapshot, fh cache.FileHandle, o return result, nil } -// lspPosition converts a protocol position to the internal position type. -func lspPosition(p protocol.Position) types.Position { - return types.Position{ - Line: uint32(p.Line), - Character: uint32(p.Character), - } -} - -// protocolPosition converts an internal position to a protocol position. -func protocolPosition(p types.Position) protocol.Position { - return protocol.Position{ - Line: p.Line, - Character: p.Character, - } -} - // blockEdit replaces content[start:end] with text. Every block edit is // bounded by blank lines or file edges, so it splices safely. type blockEdit struct { diff --git a/lsp/source/hits.go b/lsp/source/hits.go deleted file mode 100644 index f18acb0..0000000 --- a/lsp/source/hits.go +++ /dev/null @@ -1,21 +0,0 @@ -package source - -import ( - "go.lsp.dev/protocol" -) - -// referenceHit is a matched reference with the text of the matched -// identifier, so rename can preserve include qualifiers. -type referenceHit struct { - loc protocol.Location - text string -} - -func hits(hits []referenceHit) []protocol.Location { - out := make([]protocol.Location, 0, len(hits)) - for _, h := range hits { - out = append(out, h.loc) - } - - return out -} diff --git a/lsp/source/hover.go b/lsp/source/hover.go index 17cff8a..5c55dc3 100644 --- a/lsp/source/hover.go +++ b/lsp/source/hover.go @@ -19,13 +19,15 @@ func Hover(ctx context.Context, ss *cache.Snapshot, file uri.URI, pos protocol.P return res, err } + ix := NewIndex(ss) + switch target.kind { case TargetTypeName: - return hoverDefinition(ctx, ss, file, pf, target) + return hoverDefinition(ctx, ix, pf, target) case TargetConstValue: - return hoverConstValue(ctx, ss, file, pf, target) + return hoverConstValue(ctx, ix, pf, target) case TargetService: - return hoverService(ctx, ss, file, pf, target) + return hoverService(ctx, ix, pf, target) } return res, err @@ -36,63 +38,52 @@ func formatNode(doc *syntax.Document, node syntax.Node) (string, error) { return formatter.FormatNode(doc, node, formatter.DefaultOptions()) } -func hoverService(ctx context.Context, ss *cache.Snapshot, file uri.URI, pf *cache.ParsedFile, target *target) (string, error) { - astFile, id, err := FindServiceDefinition(ctx, ss, file, pf.AST(), target.identifier()) - if err != nil || id == nil { - return "", err - } - - dstPf, err := parseDefinitionFile(ctx, ss, astFile) - if err != nil { +func hoverService(ctx context.Context, ix *Index, pf *cache.ParsedFile, target *target) (string, error) { + def, err := ix.ResolveService(ctx, pf, target.identifier()) + if err != nil || def == nil { return "", err } - svc, _ := dstPf.Definitions()[id.Text].(*syntax.Service) + svc, _ := def.Node.(*syntax.Service) if svc == nil { return "", nil } - return formatNode(dstPf.AST(), svc) + return formatNode(def.Parsed.AST(), svc) } -func hoverDefinition(ctx context.Context, ss *cache.Snapshot, file uri.URI, pf *cache.ParsedFile, target *target) (string, error) { +func hoverDefinition(ctx context.Context, ix *Index, pf *cache.ParsedFile, target *target) (string, error) { ft := target.parent.(*syntax.FieldType) - astFile, id, kind, err := FindTypeDefinition(ctx, ss, file, pf.AST(), ft) - if err != nil || id == nil { - return "", err - } - - dstPf, err := parseDefinitionFile(ctx, ss, astFile) - if err != nil { + def, err := ix.ResolveType(ctx, pf, ft) + if err != nil || def == nil { return "", err } - node, ok := dstPf.Definitions()[id.Text] - if !ok || !definitionMatches(node, kind) { + if !definitionMatches(def.Node, def.Kind) { return "", nil } - return formatNode(dstPf.AST(), node) + return formatNode(def.Parsed.AST(), def.Node) } -func hoverConstValue(ctx context.Context, ss *cache.Snapshot, file uri.URI, pf *cache.ParsedFile, target *target) (string, error) { - astFile, id, err := FindConstValueDefinition(ctx, ss, file, pf.AST(), target.node.(*syntax.ConstValue)) - if err != nil || id == nil { +func hoverConstValue(ctx context.Context, ix *Index, pf *cache.ParsedFile, target *target) (string, error) { + def, err := ix.ResolveValue(ctx, pf, target.node.(*syntax.ConstValue)) + if err != nil || def == nil { return "", err } - dstPf, err := parseDefinitionFile(ctx, ss, astFile) - if err != nil { - return "", err - } + if def.Kind == DefinitionEnumValue { + dst := enumOfValue(def.Parsed, def.Name.Text) + if dst == nil { + return "", nil + } - if dstEnum := enumOfValue(dstPf, id.Text); dstEnum != nil { - return formatNode(dstPf.AST(), dstEnum) + return formatNode(def.Parsed.AST(), dst) } - if dstConst, ok := dstPf.Definitions()[id.Text].(*syntax.Const); ok { - return formatNode(dstPf.AST(), dstConst) + if dstConst, ok := def.Node.(*syntax.Const); ok { + return formatNode(def.Parsed.AST(), dstConst) } return "", nil diff --git a/lsp/source/include_action.go b/lsp/source/include_action.go index 7133b73..868b477 100644 --- a/lsp/source/include_action.go +++ b/lsp/source/include_action.go @@ -3,11 +3,8 @@ package source import ( "context" "fmt" - "io/fs" "path" "path/filepath" - "sort" - "strings" "go.lsp.dev/protocol" "go.lsp.dev/uri" @@ -110,12 +107,12 @@ func MakeAddMissingIncludeAction(ctx context.Context, ss *cache.Snapshot, fh cac return nil, nil } - defFile, ok := findTypeInFolder(ctx, ss, fh.URI(), name) - if !ok || defFile == fh.URI() { + def, err := NewIndex(ss).FindInWorkspace(ctx, name) + if err != nil || def == nil || def.File == fh.URI() { return nil, nil } - incPath, err := filepath.Rel(path.Dir(fh.URI().Path()), defFile.Path()) + incPath, err := filepath.Rel(path.Dir(fh.URI().Path()), def.File.Path()) if err != nil { return nil, nil } @@ -180,45 +177,3 @@ func missingTypeAt(ctx context.Context, ss *cache.Snapshot, fh cache.FileHandle, return typeReferenceName(ft) } - -// findTypeInFolder searches every thrift file under the workspace folder -// (excluding file) for a definition of name, returning the first match in -// lexical order. -func findTypeInFolder(ctx context.Context, ss *cache.Snapshot, file uri.URI, name string) (uri.URI, bool) { - root := ss.View().Folder().Path() - if root == "" { - return "", false - } - - var files []string - - err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { - if err != nil { - return nil - } - - if !d.IsDir() && strings.HasSuffix(d.Name(), ".thrift") && uri.File(p) != file { - files = append(files, p) - } - - return nil - }) - if err != nil { - return "", false - } - - sort.Strings(files) - - for _, p := range files { - pf, err := ss.Parse(ctx, uri.File(p)) - if err != nil || pf.AST() == nil { - continue - } - - if _, ok := pf.Definitions()[name]; ok { - return uri.File(p), true - } - } - - return "", false -} diff --git a/lsp/source/include_action_test.go b/lsp/source/include_action_test.go index 21c87e7..d9343cb 100644 --- a/lsp/source/include_action_test.go +++ b/lsp/source/include_action_test.go @@ -23,7 +23,7 @@ func buildFolderSnapshotForTest(t *testing.T, folder string, files []*cache.File fs := cache.NewOverlayFS(c) _ = fs.Update(t.Context(), files) - view := cache.NewView("test", uri.File(folder), fs, nil) + view := cache.NewView(uri.File(folder), fs, nil) return cache.NewSnapshot(view, nil) } @@ -65,7 +65,7 @@ func Test_MakeRemoveUnusedIncludeAction(t *testing.T) { assert.Equal(t, protocol.CodeActionKindQuickFix, *act.Kind) edits := act.Edit.Changes[uri.File(filePath)] - got, err := mapper.NewMapper(uri.File(filePath), []byte("include \"shared.thrift\"\nstruct S { 1: i32 a }\n")).ApplyEdits(edits) + got, err := mapper.NewMapper([]byte("include \"shared.thrift\"\nstruct S { 1: i32 a }\n")).ApplyEdits(edits) require.NoError(t, err) assert.Equal(t, "struct S { 1: i32 a }\n", string(got)) } @@ -122,7 +122,7 @@ func Test_MakeAddMissingIncludeAction(t *testing.T) { assert.Equal(t, `Add include "shared.thrift"`, act.Title) edits := act.Edit.Changes[uri.File(filePath)] - got, err := mapper.NewMapper(uri.File(filePath), []byte("struct S {\n 1: User u,\n}\n")).ApplyEdits(edits) + got, err := mapper.NewMapper([]byte("struct S {\n 1: User u,\n}\n")).ApplyEdits(edits) require.NoError(t, err) assert.Equal(t, "include \"shared.thrift\"\nstruct S {\n 1: User u,\n}\n", string(got)) } @@ -155,7 +155,7 @@ func Test_MakeAddMissingIncludeAction_InsertAfterExistingIncludes(t *testing.T) require.NotNil(t, act) edits := act.Edit.Changes[uri.File(filePath)] - got, err := mapper.NewMapper(uri.File(filePath), []byte("include \"base.thrift\"\n\nstruct S {\n 1: User u,\n}\n")).ApplyEdits(edits) + got, err := mapper.NewMapper([]byte("include \"base.thrift\"\n\nstruct S {\n 1: User u,\n}\n")).ApplyEdits(edits) require.NoError(t, err) assert.Equal(t, "include \"base.thrift\"\ninclude \"shared.thrift\"\n\nstruct S {\n 1: User u,\n}\n", string(got)) } diff --git a/lsp/source/index.go b/lsp/source/index.go new file mode 100644 index 0000000..16015b9 --- /dev/null +++ b/lsp/source/index.go @@ -0,0 +1,442 @@ +package source + +import ( + "context" + "fmt" + "io/fs" + "log/slog" + "path/filepath" + "sort" + "strings" + + "go.lsp.dev/protocol" + "go.lsp.dev/uri" + + "github.com/karitham/thrift-ls/lsp/cache" + "github.com/karitham/thrift-ls/syntax" +) + +// Index answers cross-file semantic queries over one snapshot: definition +// resolution and reference search. It composes per-file +// cache.FileIndexes over the include graph. +// +// An Index is cheap — construct one per request with NewIndex. +type Index struct { + ss *cache.Snapshot +} + +// NewIndex returns an Index for the snapshot. +func NewIndex(ss *cache.Snapshot) *Index { + return &Index{ss: ss} +} + +// parseDefinitionFile parses the definition file, tolerating parse errors +// in the target file (the definitions may still be found in the partial +// AST). It returns the parsed file so callers can use its indexes. +func parseDefinitionFile(ctx context.Context, ss *cache.Snapshot, file uri.URI) (*cache.ParsedFile, error) { + pf, err := ss.Parse(ctx, file) + if err != nil { + return nil, err + } + + if len(pf.Errors()) > 0 { + slog.Error("parse error", "errs", pf.Errors()) + } + + if pf.AST() == nil { + return nil, errNoAST + } + + return pf, nil +} + +// Resolved is a resolved definition: the target file, the parsed +// document, the definition identifier (jump target), and its kind. +type Resolved struct { + File uri.URI + Parsed *cache.ParsedFile + + // Name is the definition's identifier node, whose range is the jump + // target. + Name *syntax.Identifier + + // Node is the definition itself: *syntax.Struct, *syntax.Enum, etc. + // For an enum value, Node is the *syntax.Identifier (same as Name). + Node syntax.Node + + Kind DefinitionKind +} + +// ResolveType resolves a type reference to its definition, or returns nil +// when unresolved (base types, unresolvable name). parseDefinitionFile +// errors are propagated. +func (x *Index) ResolveType(ctx context.Context, from *cache.ParsedFile, ft *syntax.FieldType) (*Resolved, error) { + name := typeReferenceName(ft) + if name == "" || IsBasicType(name) { + return nil, nil + } + + _, identifier := parseIdent(from.URI(), from.AST().Includes(), name) + for _, astFile := range definitionFiles(ctx, x.ss, from.URI(), from.AST(), name) { + dst, err := parseDefinitionFile(ctx, x.ss, astFile) + if err != nil { + return nil, err + } + + switch v := dst.Definitions()[identifier].(type) { + case *syntax.Struct: + return defStruct(dst, v), nil + case *syntax.Enum: + return defFrom(dst, v.Name, v, DefinitionEnum), nil + case *syntax.Typedef: + return defFrom(dst, v.Name, v, DefinitionTypedef), nil + } + } + + return nil, nil +} + +// ResolveValue resolves a const-value identifier to its definition +// (an enum value or a const), or returns nil when unresolved. +func (x *Index) ResolveValue(ctx context.Context, from *cache.ParsedFile, v *syntax.ConstValue) (*Resolved, error) { + if v == nil || v.Kind != syntax.ValueIdent { + return nil, nil + } + + if v.Text == "true" || v.Text == "false" { + return nil, nil + } + + _, identifier := parseIdent(from.URI(), from.AST().Includes(), v.Text) + identifier = bareName(identifier) + + for _, astFile := range definitionFiles(ctx, x.ss, from.URI(), from.AST(), v.Text) { + dst, err := parseDefinitionFile(ctx, x.ss, astFile) + if err != nil { + return nil, err + } + + if id := dst.EnumValues()[identifier]; id != nil { + return defFrom(dst, id, id, DefinitionEnumValue), nil + } + + if cst, ok := dst.Definitions()[identifier].(*syntax.Const); ok { + return defFrom(dst, cst.Name, cst, DefinitionConst), nil + } + } + + return nil, nil +} + +// ResolveService resolves a service name or extends reference, or returns +// nil when unresolved. +func (x *Index) ResolveService(ctx context.Context, from *cache.ParsedFile, ident *syntax.Identifier) (*Resolved, error) { + if ident == nil { + return nil, nil + } + + _, identifier := parseIdent(from.URI(), from.AST().Includes(), ident.Text) + for _, astFile := range definitionFiles(ctx, x.ss, from.URI(), from.AST(), ident.Text) { + dst, err := parseDefinitionFile(ctx, x.ss, astFile) + if err != nil { + return nil, err + } + + if svc, ok := dst.Definitions()[identifier].(*syntax.Service); ok { + return defFrom(dst, svc.Name, svc, DefinitionService), nil + } + } + + return nil, nil +} + +// Hit is one reference occurrence of a name, with the qualifying text +// preserved so a rename can rewrite includes correctly. +type Hit struct { + File uri.URI + Range protocol.Range + Text string // as written: "User", "shared.User", "shared.thrift.User" +} + +// References returns every occurrence of name in file and in every file +// that transitively includes it, restricted to the given reference kinds. +// The definition site is not included (no self-referencing hit). +func (x *Index) References(ctx context.Context, file uri.URI, name string, kinds ...cache.RefKind) ([]Hit, error) { + files := x.searchFiles(file) + + var out []Hit + seen := map[uri.URI]bool{} + + for _, f := range files { + if seen[f] { + continue + } + + seen[f] = true + + pf, err := x.ss.Parse(ctx, f) + if err != nil || pf.AST() == nil { + continue + } + + out = append(out, x.matches(pf, name, kinds)...) + } + + return out, nil +} + +// QualifiedValues returns value-position references whose qualifier is +// enumName: "Song.FUWA_FUWA_TIME" or "songs.Song.FUWA_FUWA_TIME", each +// hit covering only the enum segment so a rename rewrites the qualifier +// while keeping the member name. +func (x *Index) QualifiedValues(ctx context.Context, file uri.URI, enumName string) ([]Hit, error) { + files := x.searchFiles(file) + + var out []Hit + seen := map[uri.URI]bool{} + + for _, f := range files { + if seen[f] { + continue + } + + seen[f] = true + + pf, err := x.ss.Parse(ctx, f) + if err != nil || pf.AST() == nil { + continue + } + + for _, r := range pf.Index().References() { + if r.Kind != cache.RefConstValue { + continue + } + + seg, off, ok := enumSegment(r.Name, enumName) + if !ok { + continue + } + + start, _ := pf.AST().Range(r.Node) + + segStart := toLSPPosition(pf, syntax.Position{ + Line: start.Line, Col: start.Col, Offset: start.Offset + off, + }) + segEnd := toLSPPosition(pf, syntax.Position{ + Line: start.Line, Col: start.Col, Offset: start.Offset + off + len(seg), + }) + + out = append(out, Hit{ + File: f, + Range: protocol.Range{Start: segStart, End: segEnd}, + Text: seg, + }) + } + } + + return out, nil +} + +// ReferencingFiles returns every file that directly includes file, +// in graph order. +func (x *Index) ReferencingFiles(file uri.URI) []uri.URI { + return x.ss.Includers(file) +} + +// FindInWorkspace returns the definition of name in any known file of the +// workspace, falling back to a directory walk when the workspace has not +// been indexed yet (e.g. a quick-fix on the first didOpen). +func (x *Index) FindInWorkspace(ctx context.Context, name string) (*Resolved, error) { + view := x.ss.View() + if view == nil { + return nil, nil + } + + for _, f := range view.KnownFiles() { + pf, err := x.ss.Parse(ctx, f) + if err != nil || pf.AST() == nil { + continue + } + + if n, ok := pf.Definitions()[name]; ok { + return defFromNode(pf, n), nil + } + } + + // Fallback to the old directory walk when KnownFiles is empty. + root := view.Folder().Path() + if root == "" { + return nil, nil + } + + var files []string + + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + + if !d.IsDir() && strings.HasSuffix(d.Name(), ".thrift") { + files = append(files, p) + } + + return nil + }) + if err != nil { + return nil, nil + } + + sort.Strings(files) + for _, p := range files { + pf, err := x.ss.Parse(ctx, uri.File(p)) + if err != nil || pf.AST() == nil { + continue + } + + if n, ok := pf.Definitions()[name]; ok { + return defFromNode(pf, n), nil + } + } + + return nil, nil +} + +// refKindsFor returns the reference slots a definition kind can appear in. +// +// An exception is only thrown (signatures), never used as a field type. +// Enum values and consts live in value positions. Services are +// extends-only. Every other type can appear in both field and signature +// slots. +func refKindsFor(k DefinitionKind) []cache.RefKind { + switch k { + case DefinitionException: + return []cache.RefKind{cache.RefSignatureType} + case DefinitionEnumValue, DefinitionConst: + return []cache.RefKind{cache.RefConstValue} + case DefinitionService: + return []cache.RefKind{cache.RefServiceExtends} + case DefinitionStruct, DefinitionUnion, DefinitionEnum, DefinitionTypedef: + return []cache.RefKind{cache.RefFieldType, cache.RefSignatureType} + } + + return nil +} + +// --- helpers --- + +// searchFiles returns the file itself followed by its direct includers, +// deduplicated. This matches the existing reference-search file ordering. +func (x *Index) searchFiles(file uri.URI) []uri.URI { + files := []uri.URI{file} + + for _, dep := range x.ReferencingFiles(file) { + if dep != file { + files = append(files, dep) + } + } + + return files +} + +// matches returns hits for references in pf whose kind is in kinds and +// whose bare name equals bareName(name). +func (x *Index) matches(pf *cache.ParsedFile, name string, kinds []cache.RefKind) []Hit { + kindSet := make(map[cache.RefKind]bool, len(kinds)) + for _, k := range kinds { + kindSet[k] = true + } + + haveKindSet := len(kinds) > 0 + + var out []Hit + + for _, r := range pf.Index().References() { + if haveKindSet && !kindSet[r.Kind] { + continue + } + + if bareName(r.Name) != bareName(name) { + continue + } + + out = append(out, Hit{ + File: pf.URI(), + Range: nodeRange(pf, r.Node), + Text: r.Name, + }) + } + + return out +} + +// enumSegment splits a value identifier on dots and returns the segment +// that equals enumName, its byte offset, and ok=true. If the identifier is +// not qualified with enumName, ok=false. +func enumSegment(text, enumName string) (seg string, off int, ok bool) { + items := strings.Split(text, ".") + + for i, item := range items { + if item == enumName { + off := 0 + for _, p := range items[:i] { + off += len(p) + 1 + } + + return item, off, true + } + } + + return "", 0, false +} + +// defStruct maps a struct/union/exception definition. +func defStruct(pf *cache.ParsedFile, v *syntax.Struct) *Resolved { + return &Resolved{ + File: pf.URI(), + Parsed: pf, + Name: v.Name, + Node: v, + Kind: structKind(v.Kind), + } +} + +func structKind(k syntax.TokenKind) DefinitionKind { + switch k { + case syntax.UnionDecl: + return DefinitionUnion + case syntax.ExceptionDecl: + return DefinitionException + } + + return DefinitionStruct +} + +func defFrom(pf *cache.ParsedFile, name *syntax.Identifier, node syntax.Node, kind DefinitionKind) *Resolved { + return &Resolved{File: pf.URI(), Parsed: pf, Name: name, Node: node, Kind: kind} +} + +// defFromNode builds a Resolved from any top-level definition node. Use +// when the concrete type and Kind are not known statically (e.g. +// FindInWorkspace). +func defFromNode(pf *cache.ParsedFile, n syntax.Node) *Resolved { + switch v := n.(type) { + case *syntax.Struct: + return defStruct(pf, v) + case *syntax.Enum: + return defFrom(pf, v.Name, v, DefinitionEnum) + case *syntax.Typedef: + return defFrom(pf, v.Name, v, DefinitionTypedef) + case *syntax.Const: + return defFrom(pf, v.Name, v, DefinitionConst) + case *syntax.Service: + return defFrom(pf, v.Name, v, DefinitionService) + case *syntax.Identifier: + // Enum value names are Identifiers in the defs map? No — enum + // values live in EnumValues(), not Definitions(). This case is + // unreachable from FindInWorkspace (which queries Definitions), + // but kept for completeness. + return defFrom(pf, v, v, DefinitionEnumValue) + } + + panic(fmt.Sprintf("unexpected definition node type %T", n)) +} diff --git a/lsp/source/index_test.go b/lsp/source/index_test.go new file mode 100644 index 0000000..406cee5 --- /dev/null +++ b/lsp/source/index_test.go @@ -0,0 +1,187 @@ +package source + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.lsp.dev/uri" + + "github.com/karitham/thrift-ls/lsp/cache" + "github.com/karitham/thrift-ls/syntax" +) + +var ctx = context.Background() + +func TestIndex_ResolveType_SameFile(t *testing.T) { + ss := snap(t, "/t.thrift", "struct Foo {}\ntypedef i32 Age") + from := parseOne(t, ss, fu("/t.thrift")) + + def, err := NewIndex(ss).ResolveType(ctx, from, ft("Foo")) + require.NoError(t, err) + require.NotNil(t, def) + assert.Equal(t, fu("/t.thrift"), def.File) + assert.Equal(t, DefinitionStruct, def.Kind) + + def2, err := NewIndex(ss).ResolveType(ctx, from, ft("Age")) + require.NoError(t, err) + require.NotNil(t, def2) + assert.Equal(t, DefinitionTypedef, def2.Kind) + + def3, err := NewIndex(ss).ResolveType(ctx, from, ft("i32")) + require.NoError(t, err) + assert.Nil(t, def3) +} + +func TestIndex_ResolveType_IncludeChain(t *testing.T) { + ss := crossSnap(t, "/a.thrift", `include "b.thrift" +struct Foo { 1: b.Bar bar, }`, "/b.thrift", "struct Bar {}") + a := parseOne(t, ss, fu("/a.thrift")) + + def, err := NewIndex(ss).ResolveType(ctx, a, ft("b.Bar")) + require.NoError(t, err) + require.NotNil(t, def) + assert.Equal(t, fu("/b.thrift"), def.File) + assert.Equal(t, "Bar", def.Name.Text) + assert.Equal(t, DefinitionStruct, def.Kind) +} + +func TestIndex_ResolveValue(t *testing.T) { + ss := crossSnap(t, "/a.thrift", `include "b.thrift" +const i32 C = b.MAX`, "/b.thrift", "const i32 MAX = 10\nenum Color { RED }") + a := parseOne(t, ss, fu("/a.thrift")) + + def, err := NewIndex(ss).ResolveValue(ctx, a, cv("b.MAX")) + require.NoError(t, err) + require.NotNil(t, def) + assert.Equal(t, fu("/b.thrift"), def.File) + assert.Equal(t, DefinitionConst, def.Kind) + + // RED is defined in b.thrift, resolved through the include chain. + def2, err := NewIndex(ss).ResolveValue(ctx, a, cv("RED")) + require.NoError(t, err) + require.NotNil(t, def2) + assert.Equal(t, fu("/b.thrift"), def2.File) + assert.Equal(t, DefinitionEnumValue, def2.Kind) + + def3, err := NewIndex(ss).ResolveValue(ctx, a, cv("true")) + require.NoError(t, err) + assert.Nil(t, def3) +} + +func TestIndex_ResolveService(t *testing.T) { + ss := snap(t, "/t.thrift", "service Base {}") + a := parseOne(t, ss, fu("/t.thrift")) + def, err := NewIndex(ss).ResolveService(ctx, a, &syntax.Identifier{Text: "Base"}) + require.NoError(t, err) + require.NotNil(t, def) + assert.Equal(t, DefinitionService, def.Kind) +} + +func TestIndex_References_Type(t *testing.T) { + ss := snap(t, "/t.thrift", "struct User {}\nstruct Foo { 1: User user, 2: list users, }\nservice Svc { User get(1: i32 id); }") + _ = parseOne(t, ss, fu("/t.thrift")) + + hits, err := NewIndex(ss).References(ctx, fu("/t.thrift"), "User", cache.RefFieldType, cache.RefSignatureType) + require.NoError(t, err) + require.Len(t, hits, 3) +} + +func TestIndex_References_ExceptionRule(t *testing.T) { + ss := snap(t, "/t.thrift", "exception Bad {}\nstruct Foo { 1: Bad bad, }\nservice Svc { void f() throws (1: Bad e); }") + _ = parseOne(t, ss, fu("/t.thrift")) + + hits, err := NewIndex(ss).References(ctx, fu("/t.thrift"), "Bad", cache.RefSignatureType) + require.NoError(t, err) + require.Len(t, hits, 1) + assert.Equal(t, "Bad", hits[0].Text) +} + +func TestIndex_References_ConstValue(t *testing.T) { + ss := snap(t, "/t.thrift", "const i32 MAX = 10\nstruct Foo { 1: i32 id = MAX, }") + _ = parseOne(t, ss, fu("/t.thrift")) + + hits, err := NewIndex(ss).References(ctx, fu("/t.thrift"), "MAX", cache.RefConstValue) + require.NoError(t, err) + require.Len(t, hits, 1) +} + +func TestIndex_QualifiedValues(t *testing.T) { + ss := snap(t, "/t.thrift", "enum Color { RED = 0, BLUE = 1 }\nstruct Foo { 1: i32 id = Color.RED, }\nconst i32 C = Color.BLUE") + _ = parseOne(t, ss, fu("/t.thrift")) + + hits, err := NewIndex(ss).QualifiedValues(ctx, fu("/t.thrift"), "Color") + require.NoError(t, err) + require.Len(t, hits, 2) + for _, h := range hits { + assert.Equal(t, "Color", h.Text) + } +} + +func TestIndex_ReferencingFiles(t *testing.T) { + ss := crossSnap(t, "/a.thrift", `include "b.thrift"`, "/b.thrift", "") + _ = parseOne(t, ss, fu("/a.thrift")) + files := NewIndex(ss).ReferencingFiles(fu("/b.thrift")) + require.Len(t, files, 1) + assert.Equal(t, fu("/a.thrift"), files[0]) +} + +func TestIndex_FindInWorkspace(t *testing.T) { + ss := crossSnap(t, "/a.thrift", "struct User {}", "/b.thrift", "struct Account {}") + _ = parseOne(t, ss, fu("/a.thrift")) + _ = parseOne(t, ss, fu("/b.thrift")) + + def, err := NewIndex(ss).FindInWorkspace(ctx, "Account") + require.NoError(t, err) + require.NotNil(t, def) + assert.Equal(t, fu("/b.thrift"), def.File) + assert.Equal(t, DefinitionStruct, def.Kind) +} + +func TestRefKindsFor(t *testing.T) { + assert.Equal(t, []cache.RefKind{cache.RefSignatureType}, refKindsFor(DefinitionException)) + assert.Equal(t, []cache.RefKind{cache.RefFieldType, cache.RefSignatureType}, refKindsFor(DefinitionStruct)) + assert.Equal(t, []cache.RefKind{cache.RefServiceExtends}, refKindsFor(DefinitionService)) + assert.Equal(t, []cache.RefKind{cache.RefConstValue}, refKindsFor(DefinitionConst)) +} + +// --- helpers --- + +func snap(t *testing.T, file, content string) *cache.Snapshot { + t.Helper() + return cache.BuildSnapshotForTest([]*cache.FileChange{{ + URI: fu(file), Version: 0, Content: []byte(content), From: cache.FileChangeTypeDidOpen, + }}) +} + +// crossSnap builds a snapshot with two files, parsed in dependency order +// (includes first), so the include graph resolves correctly. +func crossSnap(t *testing.T, fa, ca, fb, cb string) *cache.Snapshot { + t.Helper() + ss := cache.BuildSnapshotForTest([]*cache.FileChange{ + {URI: fu(fb), Version: 0, Content: []byte(cb), From: cache.FileChangeTypeDidOpen}, + {URI: fu(fa), Version: 0, Content: []byte(ca), From: cache.FileChangeTypeDidOpen}, + }) + return ss +} + +func fu(p string) uri.URI { u, _ := uri.Parse("file://" + p); return u } + +func parseOne(t *testing.T, ss *cache.Snapshot, u uri.URI) *cache.ParsedFile { + t.Helper() + pf, err := ss.Parse(context.Background(), u) + require.NoError(t, err) + return pf +} + +func ft(name string) *syntax.FieldType { + return &syntax.FieldType{Kind: syntax.TypeIdent, Ident: &syntax.Identifier{Text: name}} +} + +func cv(s string) *syntax.ConstValue { + if s == "true" || s == "false" { + return &syntax.ConstValue{Kind: syntax.ValueInt, Text: s} + } + return &syntax.ConstValue{Kind: syntax.ValueIdent, Text: s} +} diff --git a/lsp/source/provider.go b/lsp/source/provider.go index 3b82110..52b6614 100644 --- a/lsp/source/provider.go +++ b/lsp/source/provider.go @@ -16,8 +16,6 @@ import ( // filtering, sorting, the edit range, and the item cap stay in the shared // pipeline (TokenCompletion.Completion). type Provider interface { - Kind() ContextKind - // Candidates returns unfiltered candidates for the slot. The current // context carries the prefix and the document. Candidates(ctx context.Context, ss *cache.Snapshot, file uri.URI, c Context) []Candidate @@ -55,32 +53,24 @@ func providersFor(kind ContextKind) []Provider { type includeProvider struct{} -func (includeProvider) Kind() ContextKind { return CtxIncludePath } - func (includeProvider) Candidates(_ context.Context, ss *cache.Snapshot, file uri.URI, c Context) []Candidate { return ListDirAndFiles(filepath.Dir(file.FsPath()), ss.Resolver().IncludePaths(), c.Prefix) } type typeProvider struct{} -func (typeProvider) Kind() ContextKind { return CtxType } - func (typeProvider) Candidates(ctx context.Context, ss *cache.Snapshot, file uri.URI, c Context) []Candidate { return typeCandidates(ctx, ss, file, c) } type valueProvider struct{} -func (valueProvider) Kind() ContextKind { return CtxFieldValue } - func (valueProvider) Candidates(ctx context.Context, ss *cache.Snapshot, file uri.URI, c Context) []Candidate { return valueCandidates(ctx, ss, file, c.Doc) } type keywordProvider struct{} -func (keywordProvider) Kind() ContextKind { return CtxKeyword } - // Candidates returns the keyword snippets and every identifier token known // to the file (and its includes). func (keywordProvider) Candidates(_ context.Context, ss *cache.Snapshot, file uri.URI, _ Context) []Candidate { @@ -99,8 +89,6 @@ func (keywordProvider) Candidates(_ context.Context, ss *cache.Snapshot, file ur type fieldNameProvider struct{} -func (fieldNameProvider) Kind() ContextKind { return CtxFieldName } - // Candidates returns the field modifiers and every identifier token, so a // field name position suggests required/optional and names from the // codebase — never value candidates. @@ -119,8 +107,6 @@ func (fieldNameProvider) Candidates(_ context.Context, ss *cache.Snapshot, file type annotationKeyProvider struct{} -func (annotationKeyProvider) Kind() ContextKind { return CtxAnnotationKey } - // Candidates collects the annotation names used in the file and its // transitively included files. func (annotationKeyProvider) Candidates(ctx context.Context, ss *cache.Snapshot, file uri.URI, c Context) []Candidate { @@ -173,10 +159,6 @@ func annotationKeys(doc *syntax.Document) map[string]struct{} { add(td.Annotations) } - for _, cst := range doc.Consts() { - _ = cst // consts carry no annotations - } - for _, st := range doc.Structs() { add(st.Annotations) @@ -206,8 +188,6 @@ func annotationKeys(doc *syntax.Document) map[string]struct{} { type serviceExtendsProvider struct{} -func (serviceExtendsProvider) Kind() ContextKind { return CtxServiceExtends } - // Candidates returns the service names from the file and its includes. func (serviceExtendsProvider) Candidates(ctx context.Context, ss *cache.Snapshot, file uri.URI, c Context) []Candidate { names := make(map[string]struct{}) diff --git a/lsp/source/reference.go b/lsp/source/reference.go index 1cb8b04..32ca54e 100644 --- a/lsp/source/reference.go +++ b/lsp/source/reference.go @@ -3,7 +3,6 @@ package source import ( "context" "fmt" - "log/slog" "sort" "strings" @@ -14,40 +13,42 @@ import ( "github.com/karitham/thrift-ls/syntax" ) -var validReferenceDefinitionType = map[DefinitionKind]struct{}{ - DefinitionStruct: {}, - DefinitionUnion: {}, - DefinitionEnum: {}, - DefinitionException: {}, - DefinitionTypedef: {}, +// highlightKind maps a reference kind to the highlight type. +var highlightKind = map[cache.RefKind]protocol.DocumentHighlightKind{ + cache.RefFieldType: protocol.DocumentHighlightKindText, + cache.RefSignatureType: protocol.DocumentHighlightKindText, + cache.RefConstValue: protocol.DocumentHighlightKindRead, + cache.RefServiceExtends: protocol.DocumentHighlightKindText, } -// 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) { - pf, target, err := resolveTarget(ctx, ss, file, pos) +// Reference returns every usage of the symbol at pos, including usage +// in files that include the definition. +func Reference(ctx context.Context, ss *cache.Snapshot, file uri.URI, pos protocol.Position) ([]protocol.Location, error) { + refs, err := searchReferences(ctx, ss, file, pos) if err != nil { return nil, err } - refs, err := searchReferences(ctx, ss, file, pf, target) - if err != nil { - return nil, err + locs := make([]protocol.Location, 0, len(refs)) + for _, r := range refs { + if r.loc.URI == "" { + continue + } + + locs = append(locs, r.loc) } - return hits(refs), nil + return locs, 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. +// Highlight returns the document highlight ranges for the symbol at pos. 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 nil, err } - refs, err := searchReferences(ctx, ss, file, pf, target) + refs, err := searchReferences(ctx, ss, file, pos) if err != nil { return nil, err } @@ -55,25 +56,27 @@ func Highlight(ctx context.Context, ss *cache.Snapshot, file uri.URI, pos protoc out := make([]protocol.DocumentHighlight, 0, len(refs)+1) seen := map[protocol.Range]bool{} - add := func(r protocol.Range) { + add := func(r protocol.Range, kind protocol.DocumentHighlightKind) { if seen[r] { return } seen[r] = true - out = append(out, protocol.DocumentHighlight{Range: r, Kind: protocol.DocumentHighlightKindText}) + out = append(out, protocol.DocumentHighlight{Range: r, Kind: kind}) } - // 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, id)) + add(nodeRange(pf, id), protocol.DocumentHighlightKindText) } for _, r := range refs { if r.loc.URI == file { - add(r.loc.Range) + kind, ok := highlightKind[r.kind] + if !ok { + kind = protocol.DocumentHighlightKindText + } + + add(r.loc.Range, kind) } } @@ -90,501 +93,260 @@ func Highlight(ctx context.Context, ss *cache.Snapshot, file uri.URI, pos protoc } // 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) { +func searchReferences(ctx context.Context, ss *cache.Snapshot, file uri.URI, pos protocol.Position) ([]indexHit, error) { + pf, target, err := resolveTarget(ctx, ss, file, pos) + if err != nil { + return nil, err + } + + ix := NewIndex(ss) + switch target.kind { case TargetTypeName: - return searchTypeNameReferences(ctx, ss, file, pf, target) + return searchTypeNameRefs(ctx, ix, ss, pf, target) case TargetConstValue: - return searchConstValueReferences(ctx, ss, file, pf, target) + return searchConstValueRefs(ctx, ix, ss, pf, target) case TargetService: - return searchServiceReferences(ctx, ss, file, target.identifier().Text) + return searchServiceRefs(ctx, ix, ss, file, target.identifier().Text) case TargetDefinition: - return searchDefinitionReferences(ctx, ss, file, pf, target) + return searchDefRefs(ctx, ix, ss, file, pf, target) } return nil, nil } -// searchDefinitionReferences handles references from a definition name: -// const and enum value names reference constant usages; struct, union, -// enum, exception, and typedef names reference type usages. -func searchDefinitionReferences(ctx context.Context, ss *cache.Snapshot, file uri.URI, pf *cache.ParsedFile, target *target) (res []referenceHit, err error) { - id := target.identifier() - if id == nil { - return res, err - } - - parent := target.parent - switch parent.(type) { - case *syntax.Const: - typeName := fmt.Sprintf("%s.%s", includeNameOf(file), id.Text) - - return searchConstValueIdentifierReferences(ctx, ss, file, typeName) - case *syntax.EnumValue: - enum, ok := grandparent(target.path).(*syntax.Enum) - if !ok { - return res, err - } - - typeName := fmt.Sprintf("%s.%s.%s", includeNameOf(file), enum.Name.Text, id.Text) - - return searchConstValueIdentifierReferences(ctx, ss, file, typeName) - case *syntax.Service: - svcName := id.Text - if strings.Contains(svcName, ".") { - include, _ := parseIdent(file, pf.AST().Includes(), svcName) - - resolver := ss.Resolver() - if path := resolver.GetIncludePath(pf.AST(), include); path != "" { - file = resolver.ResolveInclude(file, path) - } - } else { - svcName = fmt.Sprintf("%s.%s", includeNameOf(file), svcName) - } - - return searchServiceReferences(ctx, ss, file, svcName) - } - - kind, ok := definitionKindOf(parent) - if !ok { - return res, err - } - - if _, ok := validReferenceDefinitionType[kind]; !ok { - return res, err - } - - typeName := fmt.Sprintf("%s.%s", includeNameOf(file), id.Text) - - typeRefs, err := searchIdentifierReferences(ctx, ss, file, typeName, kind) - if err != nil { - return res, err - } - - res = append(res, typeRefs...) - - // Enum renames also touch value positions: identifiers like - // songs.Song.FUWA_FUWA_TIME reference the enum by name. - if kind == DefinitionEnum { - valueRefs, err := searchEnumQualifiedValueReferences(ctx, ss, file, id.Text) - if err != nil { - return res, err - } - - res = append(res, valueRefs...) - } - - return res, err -} - -func grandparent(path []syntax.Node) syntax.Node { - if len(path) < 3 { - return nil - } - - return path[len(path)-3] -} - -// definitionKindOf maps a definition node to its kind. -func definitionKindOf(n syntax.Node) (DefinitionKind, bool) { - switch v := n.(type) { - case *syntax.Struct: - switch v.Kind { - case syntax.StructDecl: - return DefinitionStruct, true - case syntax.UnionDecl: - return DefinitionUnion, true - case syntax.ExceptionDecl: - return DefinitionException, true - } - case *syntax.Enum: - return DefinitionEnum, true - case *syntax.Typedef: - return DefinitionTypedef, true - case *syntax.Const: - return DefinitionConst, true - case *syntax.Service: - return DefinitionService, true - } - - return DefinitionNone, false -} - -func searchTypeNameReferences(ctx context.Context, ss *cache.Snapshot, file uri.URI, pf *cache.ParsedFile, target *target) (res []referenceHit, err error) { - res = make([]referenceHit, 0) +// searchTypeNameRefs resolves the type reference and finds all usages. +func searchTypeNameRefs(ctx context.Context, ix *Index, ss *cache.Snapshot, pf *cache.ParsedFile, target *target) ([]indexHit, error) { ft := target.parent.(*syntax.FieldType) - typeName := typeReferenceName(ft) if typeName == "" || IsBasicType(typeName) { - return res, err + return nil, nil } - // Search the type definition. - definitionFile, identifierNode, definitionType, err := FindTypeDefinition(ctx, ss, file, pf.AST(), ft) + def, err := ix.ResolveType(ctx, pf, ft) if err != nil { - return res, err + return nil, err } - if identifierNode == nil { - return res, err + if def == nil { + return nil, nil } - loc, err := jumpInFile(ctx, ss, definitionFile, identifierNode) + loc, err := jumpInFile(ctx, ss, def.File, def.Name) if err != nil { - return res, err + return nil, err } - res = append(res, referenceHit{loc: loc, text: identifierNode.Text}) + hits := []indexHit{{loc: loc, text: def.Name.Text, kind: cache.RefFieldType}} - // Search usages of the type name. - locations, err := searchIdentifierReferences(ctx, ss, definitionFile, typeName, definitionType) + kinds := refKindsFor(def.Kind) + refs, err := ix.References(ctx, def.File, typeName, kinds...) if err != nil { - return res, err + return nil, err } - res = append(res, locations...) + for _, h := range refs { + hits = append(hits, indexHit{loc: protocol.Location{URI: h.File, Range: h.Range}, text: h.Text, kind: cache.RefFieldType}) + } - return res, err + return hits, nil } -func searchServiceReferences(ctx context.Context, ss *cache.Snapshot, file uri.URI, svcName string) (res []referenceHit, err error) { - slog.Debug("searching service references", "file", file, "svcName", svcName) +// searchConstValueRefs resolves a const-value or enum-value reference. +func searchConstValueRefs(ctx context.Context, ix *Index, ss *cache.Snapshot, pf *cache.ParsedFile, target *target) ([]indexHit, error) { + value := target.node.(*syntax.ConstValue) - locations, err := searchServiceDefinitionReferences(ctx, ss, file, strings.TrimPrefix(svcName, fmt.Sprintf("%s.", includeNameOf(file)))) + def, err := ix.ResolveValue(ctx, pf, value) if err != nil { return nil, err } - res = append(res, locations...) - - for _, referenceFile := range referenceFiles(ss, file) { - // References in including files may use the bare name or the - // include-qualified name; the match function accepts both. - locations, err := searchServiceDefinitionReferences(ctx, ss, referenceFile, svcName) - if err != nil { - return nil, err - } - - res = append(res, locations...) - } - - return res, err -} - -// referenceFiles returns the files that include the given file, per the -// include graph. -func referenceFiles(ss *cache.Snapshot, file uri.URI) []uri.URI { - includeNode := ss.Graph().Get(file) - if includeNode == nil { - return nil + if def == nil { + return nil, nil } - if len(includeNode.InDegree()) == 0 && len(includeNode.OutDegree()) == 0 { - ss.Graph().Debug() + loc, err := jumpInFile(ctx, ss, def.File, def.Name) + if err != nil { + return nil, err } - return includeNode.InDegree() -} + hits := []indexHit{{loc: loc, text: def.Name.Text, kind: cache.RefConstValue}} -func searchServiceDefinitionReferences(ctx context.Context, ss *cache.Snapshot, file uri.URI, svcName string) (res []referenceHit, err error) { - pf, err := ss.Parse(ctx, file) + refs, err := ix.References(ctx, def.File, value.Text, cache.RefConstValue) if err != nil { - return res, err + return nil, err } - if pf.AST() == nil { - return res, err + for _, h := range refs { + hits = append(hits, indexHit{loc: protocol.Location{URI: h.File, Range: h.Range}, text: h.Text, kind: cache.RefConstValue}) } - for _, svc := range pf.AST().Services() { - if svc.Extends == nil { - continue - } - - // Accept both the bare name and the include-qualified literal. - if bareName(svc.Extends.Text) != bareName(svcName) { - continue - } - - res = append(res, referenceHit{loc: jump(file, pf, svc.Extends), text: svc.Extends.Text}) - } - - return res, err + return hits, nil } -func searchIdentifierReferences(ctx context.Context, ss *cache.Snapshot, file uri.URI, typeName string, definitionType DefinitionKind) (res []referenceHit, err error) { - slog.Debug("searching identifier references", "file", file, "typeName", typeName) - - locations, err := searchDefinitionIdentifierReferences(ctx, ss, file, - strings.TrimPrefix(typeName, fmt.Sprintf("%s.", includeNameOf(file))), definitionType) - if err != nil { +// searchServiceRefs finds the includes and extends referencing a service. +func searchServiceRefs(ctx context.Context, ix *Index, ss *cache.Snapshot, file uri.URI, svcName string) ([]indexHit, error) { + pf, err := ss.Parse(ctx, file) + if err != nil || pf.AST() == nil { return nil, err } - res = append(res, locations...) - - for _, referenceFile := range referenceFiles(ss, file) { - // References in including files may use the bare name or the - // include-qualified name; the match function accepts both. - locations, err := searchDefinitionIdentifierReferences(ctx, ss, referenceFile, typeName, definitionType) - if err != nil { - return nil, err - } - - res = append(res, locations...) + def, err := ix.ResolveService(ctx, pf, &syntax.Identifier{Text: svcName}) + if err != nil || def == nil { + return nil, nil } - return res, err -} - -// searchDefinitionIdentifierReferences finds type references matching -// typeName in one file: field types, function return types, arguments, -// throws, typedef types, and const types. -func searchDefinitionIdentifierReferences(ctx context.Context, ss *cache.Snapshot, file uri.URI, typeName string, definitionType DefinitionKind) (res []referenceHit, err error) { - pf, err := ss.Parse(ctx, file) + refs, err := ix.References(ctx, def.File, svcName, cache.RefServiceExtends) if err != nil { - return res, err + return nil, err } - if pf.AST() == nil { - return res, err + hits := make([]indexHit, 0, len(refs)) + for _, h := range refs { + hits = append(hits, indexHit{loc: protocol.Location{URI: h.File, Range: h.Range}, text: h.Text, kind: cache.RefServiceExtends}) } - jumpFieldType := func(ft *syntax.FieldType) { - if ft == nil { - return - } - - // Accept every reference form of the same definition: bare - // ("Test"), include-name-qualified ("user.Test"), and - // file-base-qualified ("user.thrift.Test"). - if bareName(typeReferenceName(ft)) != bareName(typeName) { - return - } + return hits, nil +} - res = append(res, referenceHit{loc: jump(file, pf, ft.Ident), text: ft.Ident.Text}) +// searchDefRefs handles references from a definition name: struct, union, +// exception, enum, typedef, const, enum value, and service names. +func searchDefRefs(ctx context.Context, ix *Index, ss *cache.Snapshot, file uri.URI, pf *cache.ParsedFile, target *target) ([]indexHit, error) { + id := target.identifier() + if id == nil { + return nil, nil } - var searchFieldType func(ft *syntax.FieldType) - - searchFieldType = func(ft *syntax.FieldType) { - if ft == nil { - return - } - - if ft.KeyType != nil { - searchFieldType(ft.KeyType) - } + parent := target.parent + switch parent.(type) { + case *syntax.Const: + typeName := fmt.Sprintf("%s.%s", includeNameOf(file), id.Text) - if ft.ValueType != nil { - searchFieldType(ft.ValueType) + return valueRefHits(ctx, ix, file, typeName) + case *syntax.EnumValue: + enum, ok := grandparent(target.path).(*syntax.Enum) + if !ok { + return nil, nil } - jumpFieldType(ft) - } - jumpField := func(field *syntax.Field) { - searchFieldType(field.Type) - } - processStructLike := func(fields []*syntax.Field) { - for _, field := range fields { - jumpField(field) - } - } + typeName := fmt.Sprintf("%s.%s.%s", includeNameOf(file), enum.Name.Text, id.Text) - for _, svc := range pf.AST().Services() { - for _, fn := range svc.Functions { - searchFieldType(fn.Type) - processStructLike(fn.Args) + return valueRefHits(ctx, ix, file, typeName) + case *syntax.Service: + svcName := id.Text + if strings.Contains(svcName, ".") { + include, _ := parseIdent(file, pf.AST().Includes(), svcName) - if fn.Throws != nil { - processStructLike(fn.Throws.Fields) + resolver := ss.Resolver() + if path := resolver.GetIncludePath(pf.AST(), include); path != "" { + file = resolver.ResolveInclude(file, path) } + } else { + svcName = fmt.Sprintf("%s.%s", includeNameOf(file), svcName) } - } - - if definitionType == DefinitionException { - return res, err - } - - for _, st := range pf.AST().Structs() { - processStructLike(st.Fields) - } - - for _, st := range pf.AST().Unions() { - processStructLike(st.Fields) - } - - for _, st := range pf.AST().Exceptions() { - processStructLike(st.Fields) - } - - for _, typedef := range pf.AST().Typedefs() { - searchFieldType(typedef.Type) - } - for _, cst := range pf.AST().Consts() { - searchFieldType(cst.Type) + return searchServiceRefs(ctx, ix, ss, file, svcName) } - return res, err -} - -func searchConstValueReferences(ctx context.Context, ss *cache.Snapshot, file uri.URI, pf *cache.ParsedFile, target *target) (res []referenceHit, err error) { - res = make([]referenceHit, 0) - value := target.node.(*syntax.ConstValue) - - definitionFile, identifierNode, err := FindConstValueDefinition(ctx, ss, file, pf.AST(), value) - if err != nil { - return res, err - } - - if identifierNode == nil { - return res, err - } - - loc, err := jumpInFile(ctx, ss, definitionFile, identifierNode) - if err != nil { - return res, err + kind, ok := definitionKindOf(parent) + if !ok { + return nil, nil } - res = append(res, referenceHit{loc: loc, text: identifierNode.Text}) - - locations, err := searchConstValueIdentifierReferences(ctx, ss, definitionFile, value.Text) - if err != nil { - return res, err + if _, ok := validReferenceDefinitionType[kind]; !ok { + return nil, nil } - res = append(res, locations...) - - return res, err -} + typeName := fmt.Sprintf("%s.%s", includeNameOf(file), id.Text) -// searchConstValueIdentifierReferences finds usages of a const or enum -// value name: field default values and const values. -func searchConstValueIdentifierReferences(ctx context.Context, ss *cache.Snapshot, file uri.URI, valueName string) (res []referenceHit, err error) { - locations, err := searchConstValueIdentifierReference(ctx, ss, file, strings.TrimPrefix(valueName, fmt.Sprintf("%s.", includeNameOf(file)))) + typeRefs, err := ix.References(ctx, file, typeName, refKindsFor(kind)...) if err != nil { return nil, err } - res = append(res, locations...) + hits := make([]indexHit, 0, len(typeRefs)) + for _, h := range typeRefs { + hits = append(hits, indexHit{loc: protocol.Location{URI: h.File, Range: h.Range}, text: h.Text, kind: cache.RefFieldType}) + } - for _, referenceFile := range referenceFiles(ss, file) { - // References in including files may use the bare name or the - // include-qualified name; the match function accepts both. - locations, err := searchConstValueIdentifierReference(ctx, ss, referenceFile, valueName) + // Enum renames also touch value positions qualified with the enum name. + if kind == DefinitionEnum { + valRefs, err := ix.QualifiedValues(ctx, file, id.Text) if err != nil { return nil, err } - res = append(res, locations...) - } - - return res, err -} - -func searchConstValueIdentifierReference(ctx context.Context, ss *cache.Snapshot, file uri.URI, valueName string) (res []referenceHit, err error) { - pf, err := ss.Parse(ctx, file) - if err != nil { - return res, err - } - - if pf.AST() == nil { - return res, err - } - - walkValueIdentifiers(pf.AST(), func(v *syntax.ConstValue) { - if v.Kind == syntax.ValueIdent && bareName(v.Text) == bareName(valueName) { - res = append(res, referenceHit{loc: jump(file, pf, v), text: v.Text}) + for _, h := range valRefs { + hits = append(hits, indexHit{loc: protocol.Location{URI: h.File, Range: h.Range}, text: h.Text, kind: cache.RefConstValue}) } - }) + } - return res, err + return hits, nil } -// searchEnumQualifiedValueReferences finds references to an enum in value -// positions: field defaults and const values qualified with the enum name, -// e.g. songs.Song.FUWA_FUWA_TIME or Song.FUWA_FUWA_TIME. Each hit covers -// only the enum segment of the identifier, so the rename rewrites the -// qualifier and keeps the value. -func searchEnumQualifiedValueReferences(ctx context.Context, ss *cache.Snapshot, file uri.URI, enumName string) (res []referenceHit, err error) { - locations, err := searchEnumQualifiedValueReference(ctx, ss, file, enumName) +// valueRefHits wraps value-kind reference lookups for consts and enum +// values. +func valueRefHits(ctx context.Context, ix *Index, file uri.URI, name string) ([]indexHit, error) { + refs, err := ix.References(ctx, file, name, cache.RefConstValue) if err != nil { return nil, err } - res = append(res, locations...) - - for _, referenceFile := range referenceFiles(ss, file) { - locations, err := searchEnumQualifiedValueReference(ctx, ss, referenceFile, enumName) - if err != nil { - return nil, err - } - - res = append(res, locations...) + hits := make([]indexHit, 0, len(refs)) + for _, h := range refs { + hits = append(hits, indexHit{loc: protocol.Location{URI: h.File, Range: h.Range}, text: h.Text, kind: cache.RefConstValue}) } - return res, err + return hits, nil } -func searchEnumQualifiedValueReference(ctx context.Context, ss *cache.Snapshot, file uri.URI, enumName string) (res []referenceHit, err error) { - pf, err := ss.Parse(ctx, file) - if err != nil || pf.AST() == nil { - return res, err +func grandparent(path []syntax.Node) syntax.Node { + if len(path) < 3 { + return nil } - // qualifier returns the enum segment of a value identifier and its - // byte offset within the identifier, when the identifier is - // . or .. with the enum name. - qualifier := func(text string) (seg string, off int, ok bool) { - items := strings.Split(text, ".") - if len(items) == 2 && items[0] == enumName { - return items[0], 0, true - } + return path[len(path)-3] +} - if len(items) == 3 && items[1] == enumName { - return items[1], len(items[0]) + 1, true +// definitionKindOf maps a definition node to its kind. +func definitionKindOf(n syntax.Node) (DefinitionKind, bool) { + switch v := n.(type) { + case *syntax.Struct: + switch v.Kind { + case syntax.StructDecl: + return DefinitionStruct, true + case syntax.UnionDecl: + return DefinitionUnion, true + case syntax.ExceptionDecl: + return DefinitionException, true } - - return "", 0, false + case *syntax.Enum: + return DefinitionEnum, true + case *syntax.Typedef: + return DefinitionTypedef, true + case *syntax.Const: + return DefinitionConst, true + case *syntax.Service: + return DefinitionService, true } - walkValueIdentifiers(pf.AST(), func(v *syntax.ConstValue) { - if v.Kind != syntax.ValueIdent { - return - } - - seg, off, ok := qualifier(v.Text) - if !ok { - return - } - - start, _ := pf.AST().Range(v) - segStart := toLSPPosition(pf, syntax.Position{Line: start.Line, Col: start.Col, Offset: start.Offset + off}) - segEnd := toLSPPosition(pf, syntax.Position{Line: start.Line, Col: start.Col, Offset: start.Offset + off + len(seg)}) - - res = append(res, referenceHit{ - loc: protocol.Location{URI: file, Range: protocol.Range{Start: segStart, End: segEnd}}, - text: seg, - }) - }) - - return res, err + return DefinitionNone, false } -// walkValueIdentifiers visits every constant value in a value position: -// field defaults, const values, and service argument and throws defaults. -// Positions without a default are skipped. -func walkValueIdentifiers(doc *syntax.Document, fn func(v *syntax.ConstValue)) { - doc.WalkFieldLists(func(fields []*syntax.Field, _ syntax.FieldListKind) { - for _, field := range fields { - if field.Value != nil { - fn(field.Value) - } - } - }) +// indexHit is a referenceSearch result: location, text as written, and +// reference kind — the replacement for referenceHit. +type indexHit struct { + loc protocol.Location + text string + kind cache.RefKind +} - for _, cst := range doc.Consts() { - fn(cst.Value) - } +// validReferenceDefinitionType lists definition kinds that can have type +// references (i.e. everything except services and consts). +var validReferenceDefinitionType = map[DefinitionKind]struct{}{ + DefinitionStruct: {}, + DefinitionUnion: {}, + DefinitionEnum: {}, + DefinitionException: {}, + DefinitionTypedef: {}, } diff --git a/lsp/source/rename.go b/lsp/source/rename.go index 8b3e5ae..d51f041 100644 --- a/lsp/source/rename.go +++ b/lsp/source/rename.go @@ -50,7 +50,7 @@ func Rename(ctx context.Context, ss *cache.Snapshot, file uri.URI, pos protocol. return res, err } - var refs []referenceHit + var refs []indexHit switch target.kind { case TargetTypeName: @@ -59,20 +59,13 @@ func Rename(ctx context.Context, ss *cache.Snapshot, file uri.URI, pos protocol. return nil, fmt.Errorf("rename not supported for basic types") } - refs, err = searchTypeNameReferences(ctx, ss, file, pf, target) + refs, err = searchTypeNameRefs(ctx, NewIndex(ss), ss, pf, target) if err != nil { return nil, err } case TargetConstValue: - value := target.node.(*syntax.ConstValue) - if _, id, err := FindConstValueDefinition(ctx, ss, file, pf.AST(), value); err != nil { - return nil, err - } else if id == nil { - return nil, fmt.Errorf("definition not found") - } - - refs, err = searchConstValueReferences(ctx, ss, file, pf, target) + refs, err = searchConstValueRefs(ctx, NewIndex(ss), ss, pf, target) if err != nil { return nil, err } @@ -90,13 +83,13 @@ func Rename(ctx context.Context, ss *cache.Snapshot, file uri.URI, pos protocol. } } - refs, err = searchServiceReferences(ctx, ss, file, svcName) + refs, err = searchServiceRefs(ctx, NewIndex(ss), ss, file, svcName) if err != nil { return nil, err } case TargetDefinition: - refs, err = searchDefinitionReferences(ctx, ss, file, pf, target) + refs, err = searchDefRefs(ctx, NewIndex(ss), ss, file, pf, target) if err != nil { return nil, err } @@ -106,12 +99,11 @@ func Rename(ctx context.Context, ss *cache.Snapshot, file uri.URI, pos protocol. } // The definition under the cursor itself. - refs = append(refs, referenceHit{ + refs = append(refs, indexHit{ loc: protocol.Location{ URI: file, Range: nodeRange(pf, target.node), }, - text: "", }) return convertHitsToWorkspaceEdit(refs, newName), nil @@ -120,7 +112,7 @@ func Rename(ctx context.Context, ss *cache.Snapshot, file uri.URI, pos protocol. // convertHitsToWorkspaceEdit groups the edits by file. A reference whose // text has an include qualifier (user.Test) keeps the qualifier: the new // text becomes user.newtext. -func convertHitsToWorkspaceEdit(refs []referenceHit, newName string) *protocol.WorkspaceEdit { +func convertHitsToWorkspaceEdit(refs []indexHit, newName string) *protocol.WorkspaceEdit { changes := make(map[uri.URI][]protocol.TextEdit) for i := range refs { diff --git a/lsp/source/semantic_analysis.go b/lsp/source/semantic_analysis.go index b319df6..853c16b 100644 --- a/lsp/source/semantic_analysis.go +++ b/lsp/source/semantic_analysis.go @@ -48,24 +48,24 @@ func (s *SemanticAnalysis) diagnostic(ctx context.Context, ss *cache.Snapshot, c slog.Debug("parse failed", "err", err) } - res := s.checkDefinitionExist(ctx, ss, changeFile, pf) + res := s.checkDefinitionExist(ctx, ss, pf) return res, nil } // checkDefinitionExist reports field types, const values, and return types // that reference undefined definitions. -func (s *SemanticAnalysis) checkDefinitionExist(ctx context.Context, ss *cache.Snapshot, file uri.URI, pf *cache.ParsedFile) []protocol.Diagnostic { +func (s *SemanticAnalysis) checkDefinitionExist(ctx context.Context, ss *cache.Snapshot, pf *cache.ParsedFile) []protocol.Diagnostic { ret := make([]protocol.Diagnostic, 0) processStructLike := func(fields []*syntax.Field) { for i := range fields { field := fields[i] - items := s.checkTypeExist(ctx, ss, file, pf, field.Type) + items := s.checkTypeExist(ctx, ss, pf, field.Type) ret = append(ret, items...) if field.Value != nil { - items := s.checkConstValueExist(ctx, ss, file, pf, field.Value) + items := s.checkConstValueExist(ctx, ss, pf, field.Value) ret = append(ret, items...) dig := s.checkConstValueMatchType(pf, field) @@ -81,13 +81,13 @@ func (s *SemanticAnalysis) checkDefinitionExist(ctx context.Context, ss *cache.S }) for _, cst := range pf.AST().Consts() { - items := s.checkConstValueExist(ctx, ss, file, pf, cst.Value) + items := s.checkConstValueExist(ctx, ss, pf, cst.Value) ret = append(ret, items...) } for _, svc := range pf.AST().Services() { for _, fn := range svc.Functions { - items := s.checkTypeExist(ctx, ss, file, pf, fn.Type) + items := s.checkTypeExist(ctx, ss, pf, fn.Type) ret = append(ret, items...) } } @@ -96,7 +96,7 @@ func (s *SemanticAnalysis) checkDefinitionExist(ctx context.Context, ss *cache.S } func (s *SemanticAnalysis) checkConstValueExist(ctx context.Context, ss *cache.Snapshot, - file uri.URI, pf *cache.ParsedFile, cst *syntax.ConstValue, + pf *cache.ParsedFile, cst *syntax.ConstValue, ) (res []protocol.Diagnostic) { if cst == nil || cst.Kind != syntax.ValueIdent { return res @@ -106,8 +106,8 @@ func (s *SemanticAnalysis) checkConstValueExist(ctx context.Context, ss *cache.S return res } - _, id, err := FindConstValueDefinition(ctx, ss, file, pf.AST(), cst) - if err != nil || id == nil { + def, err := NewIndex(ss).ResolveValue(ctx, pf, cst) + if err != nil || def == nil { res = append(res, protocol.Diagnostic{ Range: nodeRange(pf, cst), Severity: protocol.DiagnosticSeverityError, @@ -226,7 +226,7 @@ func typeName(ft *syntax.FieldType) string { } func (s *SemanticAnalysis) checkTypeExist(ctx context.Context, ss *cache.Snapshot, - file uri.URI, pf *cache.ParsedFile, ft *syntax.FieldType, + pf *cache.ParsedFile, ft *syntax.FieldType, ) (res []protocol.Diagnostic) { if ft == nil { return res @@ -234,12 +234,12 @@ func (s *SemanticAnalysis) checkTypeExist(ctx context.Context, ss *cache.Snapsho switch ft.Kind { case syntax.TypeMap, syntax.TypeList, syntax.TypeSet: - return s.checkContainerTypeExist(ctx, ss, file, pf, ft) + return s.checkContainerTypeExist(ctx, ss, pf, ft) case syntax.TypeBase: return nil case syntax.TypeIdent: - _, id, _, err := FindTypeDefinition(ctx, ss, file, pf.AST(), ft) - if err != nil || id == nil { + def, err := NewIndex(ss).ResolveType(ctx, pf, ft) + if err != nil || def == nil { res = append(res, protocol.Diagnostic{ Range: nodeRange(pf, ft.Ident), Severity: protocol.DiagnosticSeverityError, @@ -254,20 +254,20 @@ func (s *SemanticAnalysis) checkTypeExist(ctx context.Context, ss *cache.Snapsho } func (s *SemanticAnalysis) checkContainerTypeExist(ctx context.Context, - ss *cache.Snapshot, file uri.URI, pf *cache.ParsedFile, ft *syntax.FieldType, + ss *cache.Snapshot, pf *cache.ParsedFile, ft *syntax.FieldType, ) (res []protocol.Diagnostic) { if ft.KeyType != nil { - res = append(res, s.checkTypeExist(ctx, ss, file, pf, ft.KeyType)...) + res = append(res, s.checkTypeExist(ctx, ss, pf, ft.KeyType)...) if ft.Kind == syntax.TypeMap { - if dig := s.checkMapKeyScalar(ctx, ss, file, pf, ft.KeyType); dig != nil { + if dig := s.checkMapKeyScalar(ctx, ss, pf, ft.KeyType); dig != nil { res = append(res, *dig) } } } if ft.ValueType != nil { - res = append(res, s.checkTypeExist(ctx, ss, file, pf, ft.ValueType)...) + res = append(res, s.checkTypeExist(ctx, ss, pf, ft.ValueType)...) } return res @@ -276,8 +276,8 @@ func (s *SemanticAnalysis) checkContainerTypeExist(ctx context.Context, // checkMapKeyScalar returns an error when the map key type is not scalar: // thrift requires map keys to be a base type or an enum. Structs, unions, // exceptions, and containers cannot be keys; typedefs are followed. -func (s *SemanticAnalysis) checkMapKeyScalar(ctx context.Context, ss *cache.Snapshot, file uri.URI, pf *cache.ParsedFile, key *syntax.FieldType) *protocol.Diagnostic { - kind := s.mapKeyKind(ctx, ss, file, pf.AST(), key, 0) +func (s *SemanticAnalysis) checkMapKeyScalar(ctx context.Context, ss *cache.Snapshot, pf *cache.ParsedFile, key *syntax.FieldType) *protocol.Diagnostic { + kind := s.mapKeyKind(ctx, ss, pf, key, 0) if kind == "" { return nil } @@ -294,7 +294,7 @@ func (s *SemanticAnalysis) checkMapKeyScalar(ctx context.Context, ss *cache.Snap // mapKeyKind reports why key is not a scalar map key: the container kind, // or the definition kind for struct-like types. "" means scalar: a base // type, an enum, or a typedef chain ending there. -func (s *SemanticAnalysis) mapKeyKind(ctx context.Context, ss *cache.Snapshot, file uri.URI, ast *syntax.Document, key *syntax.FieldType, depth int) string { +func (s *SemanticAnalysis) mapKeyKind(ctx context.Context, ss *cache.Snapshot, pf *cache.ParsedFile, key *syntax.FieldType, depth int) string { if key == nil { return "" } @@ -314,28 +314,23 @@ func (s *SemanticAnalysis) mapKeyKind(ctx context.Context, ss *cache.Snapshot, f return "" } - dstFile, id, kind, err := FindTypeDefinition(ctx, ss, file, ast, key) - if err != nil || id == nil { + def, err := NewIndex(ss).ResolveType(ctx, pf, key) + if err != nil || def == nil { return "" } - switch kind { + switch def.Kind { case DefinitionEnum: return "" case DefinitionStruct, DefinitionUnion, DefinitionException: - return kindLabel(kind) + return kindLabel(def.Kind) case DefinitionTypedef: - dstPf, err := parseDefinitionFile(ctx, ss, dstFile) - if err != nil { - return "" - } - - td, ok := dstPf.Definitions()[id.Text].(*syntax.Typedef) + td, ok := def.Node.(*syntax.Typedef) if !ok { return "" } - return s.mapKeyKind(ctx, ss, dstFile, dstPf.AST(), td.Type, depth+1) + return s.mapKeyKind(ctx, ss, def.Parsed, td.Type, depth+1) } } diff --git a/lsp/source/semantic_based_completion.go b/lsp/source/semantic_based_completion.go index 8346c2f..deb5917 100644 --- a/lsp/source/semantic_based_completion.go +++ b/lsp/source/semantic_based_completion.go @@ -23,7 +23,6 @@ func BuildCompletionItem(candidate Candidate) *CompletionItem { InsertTextFormat: candidate.format, Kind: protocol.CompletionItemKindText, Deprecated: false, - Score: 90, Documentation: "", } } diff --git a/lsp/source/semantic_completion.go b/lsp/source/semantic_completion.go index 49b6bd2..3750b00 100644 --- a/lsp/source/semantic_completion.go +++ b/lsp/source/semantic_completion.go @@ -198,12 +198,7 @@ func includedFiles(ss *cache.Snapshot, file uri.URI) []uri.URI { visited[f] = true - node := ss.Graph().Get(f) - if node == nil { - return - } - - for _, inc := range node.OutDegree() { + for _, inc := range ss.Includes(f) { out = append(out, inc) visit(inc) } diff --git a/lsp/source/slot_completion_test.go b/lsp/source/slot_completion_test.go index a6725d3..de75e4f 100644 --- a/lsp/source/slot_completion_test.go +++ b/lsp/source/slot_completion_test.go @@ -11,13 +11,12 @@ import ( "go.lsp.dev/uri" "github.com/karitham/thrift-ls/lsp/cache" - "github.com/karitham/thrift-ls/lsp/types" "github.com/karitham/thrift-ls/syntax" ) // lspPosOf returns the LSP position (0-based line, UTF-16 character) // immediately after the first occurrence of marker in content. -func lspPosOf(t *testing.T, content, marker string) types.Position { +func lspPosOf(t *testing.T, content, marker string) protocol.Position { t.Helper() idx := strings.Index(content, marker) @@ -28,7 +27,7 @@ func lspPosOf(t *testing.T, content, marker string) types.Position { lineStart := strings.LastIndex(before, "\n") + 1 - return types.Position{ + return protocol.Position{ Line: uint32(line), Character: uint32(utf16Len([]byte(before[lineStart:])) + utf16Len([]byte(marker))), } @@ -50,7 +49,7 @@ func utf16Len(b []byte) int { // completionLabels runs the completion entry point at an LSP position and // returns the item labels, the edit range, and the truncated flag. -func completionLabels(t *testing.T, ss *cache.Snapshot, file string, pos types.Position) ([]string, protocol.Range, bool) { +func completionLabels(t *testing.T, ss *cache.Snapshot, file string, pos protocol.Position) ([]string, protocol.Range, bool) { t.Helper() fh, err := ss.ReadFile(t.Context(), uri.URI(file)) @@ -71,7 +70,7 @@ func completionLabels(t *testing.T, ss *cache.Snapshot, file string, pos types.P } // completionItems runs the entry point and returns raw items. -func completionItems(t *testing.T, ss *cache.Snapshot, file string, pos types.Position) ([]*CompletionItem, protocol.Range, bool) { +func completionItems(t *testing.T, ss *cache.Snapshot, file string, pos protocol.Position) ([]*CompletionItem, protocol.Range, bool) { t.Helper() fh, err := ss.ReadFile(t.Context(), uri.URI(file)) @@ -279,7 +278,7 @@ func TestCompletionKeywordFallback(t *testing.T) { &cache.FileChange{URI: "file:///tmp/empty.thrift", Version: 0, Content: []byte(""), From: cache.FileChangeTypeDidOpen}, ) - labels, _, truncated := completionLabels(t, ss, "file:///tmp/empty.thrift", types.Position{Line: 0, Character: 0}) + labels, _, truncated := completionLabels(t, ss, "file:///tmp/empty.thrift", protocol.Position{Line: 0, Character: 0}) assert.Contains(t, labels, "include") assert.True(t, truncated, "keyword fallback exceeds the cap") } @@ -340,7 +339,7 @@ func TestCompletionNoPrefixUnderflow(t *testing.T) { &cache.FileChange{URI: "file:///tmp/underflow.thrift", Version: 0, Content: []byte("const X=1"), From: cache.FileChangeTypeDidOpen}, ) - _, rng, _ := completionLabels(t, ss, "file:///tmp/underflow.thrift", types.Position{Line: 0, Character: 9}) + _, rng, _ := completionLabels(t, ss, "file:///tmp/underflow.thrift", protocol.Position{Line: 0, Character: 9}) assert.LessOrEqual(t, rng.Start.Character, uint32(9), "edit range must not wrap") } diff --git a/lsp/source/target.go b/lsp/source/target.go index 6416c3b..650a742 100644 --- a/lsp/source/target.go +++ b/lsp/source/target.go @@ -8,7 +8,6 @@ import ( "go.lsp.dev/uri" "github.com/karitham/thrift-ls/lsp/cache" - "github.com/karitham/thrift-ls/lsp/types" "github.com/karitham/thrift-ls/syntax" ) @@ -51,7 +50,7 @@ func resolveTarget(ctx context.Context, ss *cache.Snapshot, file uri.URI, pos pr return nil, nil, errNoAST } - astPos, err := pf.Mapper().LSPPosToParserPosition(types.Position{Line: pos.Line, Character: pos.Character}) + astPos, err := pf.Mapper().LSPPosToParserPosition(protocol.Position{Line: pos.Line, Character: pos.Character}) if err != nil { return nil, nil, err } @@ -143,7 +142,7 @@ func toLSPPosition(pf *cache.ParsedFile, pos syntax.Position) protocol.Position return protocol.Position{Line: uint32(pos.Line - 1), Character: uint32(pos.Col - 1)} } - return protocolPosition(p) + return p } // toLSPRange converts a parser span to an LSP range with UTF-16 columns. diff --git a/lsp/source/type_definition.go b/lsp/source/type_definition.go index b0706a8..f7a84b8 100644 --- a/lsp/source/type_definition.go +++ b/lsp/source/type_definition.go @@ -24,27 +24,13 @@ func TypeDefinition(ctx context.Context, ss *cache.Snapshot, file uri.URI, pos p switch target.kind { case TargetTypeName: - return typeNameDefinition(ctx, ss, file, pf, target) + return typeNameDefinition(ctx, NewIndex(ss), pf, target) case TargetConstValue: // The type definition of a constant value is the value's own // definition: the enum value or const it references. - astFile, id, err := FindConstValueDefinition(ctx, ss, file, pf.AST(), target.node.(*syntax.ConstValue)) - if err != nil { - return nil, err - } - - if id == nil { - return nil, nil - } - - loc, err := jumpInFile(ctx, ss, astFile, id) - if err != nil { - return nil, err - } - - return []protocol.Location{loc}, nil + return constValueDefinition(ctx, NewIndex(ss), pf, target) case TargetDefinition: - return declarationTypeDefinition(ctx, ss, file, pf, target) + return declarationTypeDefinition(ctx, NewIndex(ss), pf, target) } return res, err @@ -52,7 +38,7 @@ func TypeDefinition(ctx context.Context, ss *cache.Snapshot, file uri.URI, pos p // declarationTypeDefinition jumps to the definition of the declared type of // a field, typedef, function, or const under the cursor. -func declarationTypeDefinition(ctx context.Context, ss *cache.Snapshot, file uri.URI, pf *cache.ParsedFile, target *target) ([]protocol.Location, error) { +func declarationTypeDefinition(ctx context.Context, ix *Index, pf *cache.ParsedFile, target *target) ([]protocol.Location, error) { var ft *syntax.FieldType switch parent := target.parent.(type) { @@ -70,16 +56,12 @@ func declarationTypeDefinition(ctx context.Context, ss *cache.Snapshot, file uri return nil, nil } - astFile, id, _, err := FindTypeDefinition(ctx, ss, file, pf.AST(), ft) - if err != nil { + def, err := ix.ResolveType(ctx, pf, ft) + if err != nil || def == nil { return nil, err } - if id == nil { - return nil, nil - } - - loc, err := jumpInFile(ctx, ss, astFile, id) + loc, err := jumpInFile(ctx, ix.ss, def.File, def.Name) if err != nil { return nil, err } diff --git a/lsp/source/types.go b/lsp/source/types.go index 5940b2f..cf3ec4a 100644 --- a/lsp/source/types.go +++ b/lsp/source/types.go @@ -4,13 +4,11 @@ import ( "go.lsp.dev/protocol" "github.com/karitham/thrift-ls/lsp/cache" - "github.com/karitham/thrift-ls/lsp/types" ) type CompletionRequest struct { - TriggerKind int - Pos types.Position - Fh cache.FileHandle + Pos protocol.Position + Fh cache.FileHandle } type CompletionItem struct { @@ -29,8 +27,6 @@ type CompletionItem struct { Kind protocol.CompletionItemKind Deprecated bool - Score int - // Documentation holds document text for this completion Documentation string } diff --git a/lsp/source/unused_include_check.go b/lsp/source/unused_include_check.go index ccc5e18..7bbfe5b 100644 --- a/lsp/source/unused_include_check.go +++ b/lsp/source/unused_include_check.go @@ -87,9 +87,9 @@ func unusedIncludeDiagnostics(ctx context.Context, ss *cache.Snapshot, file uri. } // usedIncludes marks every include that at least one reference in the -// document resolves into. Resolution goes through the definition finders, -// which handle both qualified ("base.Type") and unqualified names that -// resolve through the include chain. +// document resolves into. Resolution goes through the per-file reference +// index, which handles both qualified ("base.Type") and unqualified names +// that resolve through the include chain. func usedIncludes(ctx context.Context, ss *cache.Snapshot, file uri.URI, pf *cache.ParsedFile) map[*syntax.Include]bool { resolver := ss.Resolver() includeByFile := make(map[uri.URI]*syntax.Include) @@ -102,14 +102,15 @@ func usedIncludes(ctx context.Context, ss *cache.Snapshot, file uri.URI, pf *cac used := make(map[*syntax.Include]bool) seen := make(map[string]bool) + ix := NewIndex(ss) - for _, name := range referencedNames(pf.AST()) { - if seen[name] { + for _, ref := range pf.Index().References() { + if seen[ref.Name] { continue } - seen[name] = true + seen[ref.Name] = true - if dst, ok := resolveReferenceFile(ctx, ss, file, pf.AST(), name); ok { + if dst, ok := resolveReferenceFile(ctx, ix, pf, ref); ok { if inc, ok := includeByFile[dst]; ok { used[inc] = true } @@ -119,118 +120,41 @@ func usedIncludes(ctx context.Context, ss *cache.Snapshot, file uri.URI, pf *cac return used } -// resolveReferenceFile returns the file a reference name resolves to, or +// resolveReferenceFile returns the file the reference resolves to, or // false when it resolves nowhere or into the current file. Type, const -// value, and service references are all considered; each finder resolves -// include-qualified names itself. -func resolveReferenceFile(ctx context.Context, ss *cache.Snapshot, file uri.URI, ast *syntax.Document, name string) (uri.URI, bool) { - ft := &syntax.FieldType{Kind: syntax.TypeIdent, Ident: &syntax.Identifier{Text: name}} - if dst, id, _, err := FindTypeDefinition(ctx, ss, file, ast, ft); err == nil && id != nil && dst != file { - return dst, true - } - - cv := &syntax.ConstValue{Kind: syntax.ValueIdent, Text: name} - if dst, id, err := FindConstValueDefinition(ctx, ss, file, ast, cv); err == nil && id != nil && dst != file { - return dst, true - } - - id := &syntax.Identifier{Text: name} - if dst, found, err := FindServiceDefinition(ctx, ss, file, ast, id); err == nil && found != nil && dst != file { - return dst, true - } - - return "", false -} - -// referencedNames collects every identifier used in a reference position: -// field, argument, throws, return, typedef, and const types; const value -// identifiers; and service extends. -func referencedNames(doc *syntax.Document) []string { - var names []string - - addType := func(t *syntax.FieldType) { - walkTypeIdents(t, func(text string) { names = append(names, text) }) - } - addValue := func(v *syntax.ConstValue) { - walkValueIdents(v, func(text string) { names = append(names, text) }) - } - - doc.WalkFieldLists(func(fields []*syntax.Field, _ syntax.FieldListKind) { - for _, f := range fields { - addType(f.Type) - addValue(f.Value) +// value, and service references resolve through their own finder. +func resolveReferenceFile(ctx context.Context, ix *Index, pf *cache.ParsedFile, ref cache.Reference) (uri.URI, bool) { + var def *Resolved + var err error + + switch ref.Kind { + case cache.RefFieldType, cache.RefSignatureType: + id, ok := ref.Node.(*syntax.Identifier) + if !ok { + return "", false } - }) - - for _, td := range doc.Typedefs() { - addType(td.Type) - } - - for _, cs := range doc.Consts() { - addType(cs.Type) - addValue(cs.Value) - } - for _, svc := range doc.Services() { - if svc.Extends != nil { - names = append(names, svc.Extends.Text) + ft := &syntax.FieldType{Kind: syntax.TypeIdent, Ident: id} + def, err = ix.ResolveType(ctx, pf, ft) + case cache.RefConstValue: + cv, ok := ref.Node.(*syntax.ConstValue) + if !ok { + return "", false } - for _, fn := range svc.Functions { - addType(fn.Type) - - for _, arg := range fn.Args { - addType(arg.Type) - } - - if fn.Throws != nil { - for _, f := range fn.Throws.Fields { - addType(f.Type) - addValue(f.Value) - } - } + def, err = ix.ResolveValue(ctx, pf, cv) + case cache.RefServiceExtends: + id, ok := ref.Node.(*syntax.Identifier) + if !ok { + return "", false } - } - - return names -} -// walkTypeIdents calls f with every identifier of a type reference, -// including nested container types. -func walkTypeIdents(t *syntax.FieldType, f func(string)) { - if t == nil { - return + def, err = ix.ResolveService(ctx, pf, id) } - switch t.Kind { - case syntax.TypeIdent: - if t.Ident != nil { - f(t.Ident.Text) - } - case syntax.TypeMap, syntax.TypeList, syntax.TypeSet: - walkTypeIdents(t.KeyType, f) - walkTypeIdents(t.ValueType, f) - } -} - -// walkValueIdents calls f with every identifier of a constant value, -// descending into maps and lists. -func walkValueIdents(v *syntax.ConstValue, f func(string)) { - if v == nil { - return + if err != nil || def == nil || def.File == pf.URI() { + return "", false } - switch v.Kind { - case syntax.ValueIdent: - f(v.Text) - case syntax.ValueList: - for _, item := range v.List { - walkValueIdents(item, f) - } - case syntax.ValueMap: - for _, entry := range v.Map { - walkValueIdents(entry.Key, f) - walkValueIdents(entry.Value, f) - } - } + return def.File, true } diff --git a/lsp/symbols.go b/lsp/symbols.go index 1438b70..588d866 100644 --- a/lsp/symbols.go +++ b/lsp/symbols.go @@ -10,7 +10,7 @@ import ( ) func (s *Server) documentSymbol(ctx context.Context, params *protocol.DocumentSymbolParams) (result protocol.DocumentSymbolSlice, err error) { - return withSnapshot(ctx, s.session, params.TextDocument.URI, func(ss *cache.Snapshot) (protocol.DocumentSymbolSlice, error) { + return withSnapshot(s.session, params.TextDocument.URI, func(ss *cache.Snapshot) (protocol.DocumentSymbolSlice, error) { syms := source.DocumentSymbols(ctx, ss, params.TextDocument.URI) result := make(protocol.DocumentSymbolSlice, 0, len(syms)) diff --git a/lsp/types/position.go b/lsp/types/position.go deleted file mode 100644 index d1d0d0d..0000000 --- a/lsp/types/position.go +++ /dev/null @@ -1,6 +0,0 @@ -package types - -type Position struct { - Line uint32 - Character uint32 -}