165 lines
5.1 KiB
Go
165 lines
5.1 KiB
Go
// 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()
|
||
|
|
}
|