Something went wrong. Try again.
A Thrift language server, formatter, and linter.
lsp linter helix vim vscode thrift
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464package store
import ( "context" "log/slog" "slices" "strings" "sync" "sync/atomic"
"go.lsp.dev/uri"
"github.com/karitham/thrift-ls/syntax")
// viewEntry is one file's store record: its content handle and the parse// derived from it. Entries are replaced wholesale when content changes, so// a live entry never goes stale.type viewEntry struct { fh FileHandle parsed *ParsedFile // nil until parsed}
// View is one workspace folder's file store: parsed files, the include// graph between them, and the include configuration that applies to them.//// Concurrency: entries and edges are guarded by mu; reads share immutable// values, writes replace entries wholesale. gen bumps on every Update or// Evict; asynchronous work compares its captured generation against// View.IsCurrent to drop superseded results.type View struct { // folder is the tree root: a workspace folder, or the opened file's // directory in single-file mode. folder uri.URI
fs FileSource
includePaths []string
mu sync.RWMutex entries map[uri.URI]*viewEntry includes map[uri.URI][]uri.URI // sorted direct include edges includers map[uri.URI][]uri.URI // reverse edges
gen atomic.Uint64}
type generationContextKey struct{}
// WithGeneration marks ctx as analysis work for generation. Parses performed// with the context are not cached after the view advances past that generation.func WithGeneration(ctx context.Context, generation uint64) context.Context { return context.WithValue(ctx, generationContextKey{}, generation)}
func generationOf(ctx context.Context) (uint64, bool) { generation, ok := ctx.Value(generationContextKey{}).(uint64)
return generation, ok}
func NewView(folder uri.URI, fs FileSource, includePaths []string) *View { return &View{ folder: folder, fs: fs, includePaths: slices.Clone(includePaths), entries: make(map[uri.URI]*viewEntry), includes: make(map[uri.URI][]uri.URI), includers: make(map[uri.URI][]uri.URI), }}
func (v *View) ContainsFile(uri uri.URI) bool { // folder: file:///workdir/ // file: file:///workdir/file.idl folder := v.folder.Path() file := uri.Path()
if !strings.HasPrefix(file, folder) { return false }
folder = strings.TrimSuffix(folder, "/") file = strings.TrimPrefix(file, folder)
return strings.HasPrefix(file, "/")}
// Folder returns the workspace folder the view covers.func (v *View) Folder() uri.URI { return v.folder}
// WalkFiles enumerates the view's file source under root: the disk in// production, the in-memory tree in tests.func (v *View) WalkFiles(ctx context.Context, root uri.URI, fn func(uri.URI) error) error { return v.fs.WalkFiles(ctx, root, fn)}
// ReadFile returns the current content of uri: the editor overlay for open// files, the disk content otherwise.func (v *View) ReadFile(ctx context.Context, u uri.URI) (FileHandle, error) { slog.Debug("view read file", "uri", u)
return v.fs.ReadFile(ctx, u)}
// Parse returns the cached parse of uri, parsing it on first use. A parse// failure (unreadable file) is not cached; syntax errors are carried on the// ParsedFile itself.func (v *View) Parse(ctx context.Context, u uri.URI) (*ParsedFile, error) { if pf := v.parsed(u); pf != nil { return pf, nil }
generation, guarded := generationOf(ctx) parseGeneration := v.gen.Load()
fh, err := v.ReadFile(ctx, u) if err != nil { return nil, err }
pf, err := Parse(fh) if err != nil { slog.Debug("view parse failed", "err", err)
return nil, err }
var includes []uri.URI if pf.AST() != nil { includes = resolveIncludes(ctx, u, pf.AST().Includes(), v.Resolver().ResolveInclude) }
v.mu.Lock() if (!guarded || generation == parseGeneration) && v.gen.Load() == parseGeneration { v.removeEdgesLocked(u) v.entries[u] = &viewEntry{fh: fh, parsed: pf}
for _, inc := range includes { v.includes[u] = append(v.includes[u], inc) v.includers[inc] = append(v.includers[inc], u) } } v.mu.Unlock()
return pf, nil}
// Resolver returns a resolver for the view's include paths.func (v *View) Resolver() *Resolver { return newResolver(v.includePaths, v.fs)}
// parsed returns the cached parse of uri, or nil.func (v *View) parsed(u uri.URI) *ParsedFile { v.mu.RLock() defer v.mu.RUnlock()
e, ok := v.entries[u] if !ok || e.parsed == nil { return nil }
return e.parsed}
// setEntry stores the file's entry and replaces its include edges.func (v *View) setEntry(u uri.URI, e *viewEntry, includes []uri.URI) { v.mu.Lock() defer v.mu.Unlock()
v.removeEdgesLocked(u) v.entries[u] = e
for _, inc := range includes { v.includes[u] = append(v.includes[u], inc)
// A self-include records its own reverse edge, so the file is its // own dependent. v.includers[inc] = append(v.includers[inc], u) }}
// removeEdgesLocked drops u's out-edges and their reverse entries.// Callers must hold mu.func (v *View) removeEdgesLocked(u uri.URI) { for _, inc := range v.includes[u] { includers := v.includers[inc] for i, candidate := range includers { if candidate == u { last := len(includers) - 1 includers[i] = includers[last] v.includers[inc] = includers[:last]
break } }
if len(v.includers[inc]) == 0 { delete(v.includers, inc) } }
delete(v.includes, u)}
// Includes returns the files file includes directly, sorted ascending by// URI.func (v *View) Includes(file uri.URI) []uri.URI { v.mu.RLock() defer v.mu.RUnlock()
return slices.Clone(v.includes[file])}
// Includers returns the files that include file directly, sorted ascending// by URI.func (v *View) Includers(file uri.URI) []uri.URI { v.mu.RLock() defer v.mu.RUnlock()
out := slices.Clone(v.includers[file]) slices.Sort(out)
return out}
// Dependents returns every file that directly or transitively includes// file, including file itself when a cycle leads back to it. The result is// sorted ascending by URI.func (v *View) Dependents(file uri.URI) []uri.URI { v.mu.RLock() defer v.mu.RUnlock()
deps := make([]uri.URI, 0) seen := make(map[uri.URI]struct{}) queue := []uri.URI{file}
for head := 0; head < len(queue); head++ { u := queue[head]
for _, dependent := range v.includers[u] { if _, ok := seen[dependent]; ok { continue }
seen[dependent] = struct{}{} deps = append(deps, dependent) queue = append(queue, dependent) } }
slices.Sort(deps)
return deps}
// TokensForFile returns the identifier tokens of file and its transitively// included files. Only already-parsed files contribute; nothing is forced.func (v *View) TokensForFile(file 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 := v.parsed(f); pf != nil { for token := range pf.Tokens() { tokens[token] = struct{}{} } }
for _, inc := range v.Includes(f) { collect(inc) } }
collect(file)
return tokens}
// FileKnown reports whether the view tracks uri.func (v *View) FileKnown(u uri.URI) bool { v.mu.RLock() defer v.mu.RUnlock()
_, ok := v.entries[u]
return ok}
// KnownFiles returns the tracked file URIs of the view, sorted for// deterministic iteration.func (v *View) KnownFiles() []uri.URI { v.mu.RLock() defer v.mu.RUnlock()
files := make([]uri.URI, 0, len(v.entries)) for file := range v.entries { files = append(files, file) }
slices.Sort(files)
return files}
// Generation returns the view's change counter. It increments on every// FileChange; asynchronous work captures it and re-checks before publishing.func (v *View) Generation() uint64 { return v.gen.Load()}
// Evict removes files from the view and advances its generation. Advancing the// generation also invalidates asynchronous work that was started for the// evicted entries.func (v *View) Evict(files ...uri.URI) { uris := make([]uri.URI, 0, len(files)) uris = append(uris, files...)
slices.Sort(uris) uris = slices.Compact(uris)
v.mu.Lock() for _, file := range uris { v.removeEdgesLocked(file) for _, includer := range v.includers[file] { includes := v.includes[includer] for i, include := range slices.Backward(includes) { if include != file { continue }
includes = append(includes[:i], includes[i+1:]...) } if len(includes) == 0 { delete(v.includes, includer) } else { v.includes[includer] = includes } } delete(v.includers, file) delete(v.entries, file) } v.gen.Add(1) v.mu.Unlock()}
// IsCurrent reports whether gen is still the view's latest generation.// Used by asynchronous work to drop results that a newer change superseded.func (v *View) IsCurrent(gen uint64) bool { return v.Generation() == gen}
// ChangeResult reports what a batch of changes did: the new generation and// the affected URIs (changed files plus their transitive dependents).type ChangeResult struct { // Gen is the generation this change produced. Compare it against // View.IsCurrent before publishing derived results. Gen uint64
// Affected holds the changed URIs first, in order, then their // transitive dependents, deduped. Edges were refreshed from an eager // re-parse of the changed files, so it reflects the change. Affected []uri.URI}
// Update applies changes to the view: it invalidates the changed files'// entries and re-parses them, so their include edges are fresh before any// request observes the change. It returns what changed; publishing derived// results (diagnostics) is the caller's policy.func (v *View) Update(ctx context.Context, changes ...*FileChange) ChangeResult { uris := make([]uri.URI, 0, len(changes)) for _, change := range changes { uris = append(uris, change.URI) }
slices.Sort(uris) uris = slices.Compact(uris)
v.mu.Lock() for _, u := range uris { v.removeEdgesLocked(u)
// Invalidate the old entry unconditionally, and track the file even // if the parse below fails, so routing and KnownFiles see it. v.entries[u] = &viewEntry{} } generation := v.gen.Add(1) v.mu.Unlock()
// Parse is lazy and cached, so requests racing ahead of this loop // simply parse on demand. for _, u := range uris { if _, err := v.Parse(ctx, u); err != nil { slog.Warn("parse error", "err", err) } }
return ChangeResult{ Affected: v.affected(uris), Gen: generation, }}
// affected returns uris plus the transitive dependents of each, deduped,// computed after the eager re-parse so the edges reflect the change.// Changes come first, in order; dependents follow, sorted by URI.func (v *View) affected(uris []uri.URI) []uri.URI { affected := slices.Clone(uris) seen := make(map[uri.URI]struct{}, len(uris)) for _, u := range affected { seen[u] = struct{}{} }
for _, u := range uris { for _, dep := range v.Dependents(u) { if _, ok := seen[dep]; ok { continue }
seen[dep] = struct{}{} affected = append(affected, dep) } }
return affected}
// resolveIncludes dedupes include statements by path text (first wins) and// resolves each to a URI, sorted ascending.func resolveIncludes(ctx context.Context, file uri.URI, includes []*syntax.Include, resolve func(context.Context, uri.URI, string) uri.URI) []uri.URI { seen := make(map[string]struct{}, len(includes)) uris := make([]uri.URI, 0, len(includes))
for _, inc := range includes { if inc.Path == nil { continue }
text := strings.Trim(inc.Path.Text, "\"'") if _, ok := seen[text]; ok { continue }
seen[text] = struct{}{}
uris = append(uris, resolve(ctx, file, text)) }
slices.Sort(uris)
return uris}