// Copyright (c) 2026 Petr BalvĂ­n (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() }