feat(lsp): resolve symbols across workspace files
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"io/fs"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sourcedock.dev/petrbalvin/gasm-devkit/ast"
|
||||
"sourcedock.dev/petrbalvin/gasm-devkit/parser"
|
||||
)
|
||||
|
||||
// workspaceIndex indexes the .s files under the workspace roots that the
|
||||
// client has not opened. Every workspace-scoped answer (definition,
|
||||
// references, workspace symbols) reads it after the open documents, so a
|
||||
// symbol resolves to and collects references from files the editor has never
|
||||
// shown. An open buffer always shadows its disk copy: the entry stays in the
|
||||
// index but is skipped at query time, because the buffer may hold unsaved
|
||||
// edits the disk has never seen.
|
||||
//
|
||||
// Freshness does not rest on the client's watched-file events alone: every
|
||||
// query re-stats the indexed files and re-reads the ones whose stat identity
|
||||
// changed. Watched-file events remain necessary for files created after the
|
||||
// build, which a stat of the known set cannot discover. The index builds on
|
||||
// first use, since the roots arrive with initialize. The server serves one
|
||||
// message at a time, so the index needs no lock.
|
||||
type workspaceIndex struct {
|
||||
roots []string
|
||||
built bool
|
||||
files map[string]*workspaceFile // keyed by absolute path
|
||||
}
|
||||
|
||||
// workspaceFile is one indexed on-disk file: its content, the parsed tree and
|
||||
// the stat identity the freshness check compares.
|
||||
type workspaceFile struct {
|
||||
text string
|
||||
modTime time.Time
|
||||
size int64
|
||||
file *ast.File
|
||||
}
|
||||
|
||||
// ensure builds the index on first use and refreshes it on every query
|
||||
// afterwards: a stat per file, a re-read only for the ones that changed.
|
||||
func (w *workspaceIndex) ensure() {
|
||||
if !w.built {
|
||||
w.built = true
|
||||
w.files = make(map[string]*workspaceFile)
|
||||
for _, root := range w.roots {
|
||||
w.walk(root)
|
||||
}
|
||||
return
|
||||
}
|
||||
for path := range w.files {
|
||||
w.load(path)
|
||||
}
|
||||
}
|
||||
|
||||
// walk adds every .s file below root, skipping the directories the go tool
|
||||
// itself ignores (hidden and underscore-prefixed names, testdata): the index
|
||||
// covers the buildable surface, not the whole tree.
|
||||
func (w *workspaceIndex) walk(root string) {
|
||||
filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil // an unreadable entry never breaks the scan
|
||||
}
|
||||
if d.IsDir() {
|
||||
if path != root && skipDir(d.Name()) {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(path, ".s") {
|
||||
w.load(path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// skipDir reports whether a directory is outside the buildable surface.
|
||||
func skipDir(name string) bool {
|
||||
return name == "testdata" || strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_")
|
||||
}
|
||||
|
||||
// load reads one path into the index, or evicts it when it has disappeared.
|
||||
// A file whose stat identity still matches is left untouched.
|
||||
func (w *workspaceIndex) load(path string) {
|
||||
st, err := os.Stat(path)
|
||||
if err != nil || st.IsDir() {
|
||||
delete(w.files, path)
|
||||
return
|
||||
}
|
||||
if ef, ok := w.files[path]; ok && ef.size == st.Size() && ef.modTime.Equal(st.ModTime()) {
|
||||
return
|
||||
}
|
||||
text, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
delete(w.files, path)
|
||||
return
|
||||
}
|
||||
// Parsing is tolerant: a file with syntax errors still contributes its
|
||||
// usable declarations, exactly as an open buffer mid-edit does.
|
||||
body := string(text)
|
||||
f, _ := parser.Parse(path, body)
|
||||
w.files[path] = &workspaceFile{text: body, modTime: st.ModTime(), size: st.Size(), file: f}
|
||||
}
|
||||
|
||||
// watched applies one client watched-file event: the named file is re-read
|
||||
// (created or changed) or evicted (deleted). Events for anything but .s
|
||||
// files are ignored, and an event arriving before the first build no-ops:
|
||||
// the build then reads the disk state that already includes it.
|
||||
func (w *workspaceIndex) watched(path string, deleted bool) {
|
||||
if !w.built || !strings.HasSuffix(path, ".s") {
|
||||
return
|
||||
}
|
||||
if deleted {
|
||||
delete(w.files, path)
|
||||
return
|
||||
}
|
||||
w.load(path)
|
||||
}
|
||||
|
||||
// scanUnit is one parseable source the workspace answers draw from: an open
|
||||
// buffer or an indexed disk file.
|
||||
type scanUnit struct {
|
||||
uri string
|
||||
text string
|
||||
file *ast.File
|
||||
}
|
||||
|
||||
// scanUnits returns every source the workspace answers cover, in URI order
|
||||
// for deterministic results: the open documents, then the indexed disk files
|
||||
// that no open buffer shadows.
|
||||
func (s *Server) scanUnits() []scanUnit {
|
||||
open := make(map[string]bool, len(s.docs))
|
||||
for uri := range s.docs {
|
||||
open[uriPath(uri)] = true
|
||||
}
|
||||
units := make([]scanUnit, 0, len(s.docs))
|
||||
for _, of := range s.openASTs() {
|
||||
units = append(units, scanUnit{uri: of.uri, text: s.docs[of.uri], file: of.file})
|
||||
}
|
||||
s.ws.ensure()
|
||||
for path, ef := range s.ws.files {
|
||||
if ef.file == nil || open[path] {
|
||||
continue
|
||||
}
|
||||
units = append(units, scanUnit{uri: fileToURI(path), text: ef.text, file: ef.file})
|
||||
}
|
||||
slices.SortFunc(units, func(a, b scanUnit) int { return cmp.Compare(a.uri, b.uri) })
|
||||
return units
|
||||
}
|
||||
|
||||
// fileToURI renders an absolute path as a file URI, percent-encoding the
|
||||
// characters the URI rules reserve.
|
||||
func fileToURI(path string) string {
|
||||
return (&url.URL{Scheme: "file", Path: path}).String()
|
||||
}
|
||||
Reference in New Issue
Block a user