feat(lsp): resolve symbols across workspace files

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-23 20:21:58 +02:00
parent ded9cabdf4
commit 4be16dcdf5
9 changed files with 523 additions and 32 deletions
+6 -1
View File
@@ -9,7 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- - **Workspace-wide navigation in the language server.** `gasm lsp` indexes
the `.s` files under the workspace root beyond the documents the editor
has open, so go-to-definition, find references and workspace symbol search
reach files that were never opened. An open buffer always shadows its
disk copy, and watched-file events together with a per-query freshness
check keep the index current.
### Fixed ### Fixed
+2 -1
View File
@@ -100,7 +100,8 @@ to give that syntax the tooling it deserves.
find references, rename, formatting, inlay hints, code actions, signature find references, rename, formatting, inlay hints, code actions, signature
help, document highlights, workspace symbol search, #include document help, document highlights, workspace symbol search, #include document
links and folding ranges over stdio; definition, references and rename links and folding ranges over stdio; definition, references and rename
work across every open document. work across every open document and the indexed workspace files beyond
them.
- **Comparators and audits.** `gasm diff` compares the machine code of two - **Comparators and audits.** `gasm diff` compares the machine code of two
assembly files byte-for-byte, `gasm profile` shows basic-block structure, assembly files byte-for-byte, `gasm profile` shows basic-block structure,
`gasm audit-instructions` diffs the encoder against the installed toolchain, `gasm audit-instructions` diffs the encoder against the installed toolchain,
+5 -1
View File
@@ -246,7 +246,11 @@ document store, republishes diagnostics on every change, and provides:
pseudo-registers, labels, immediates, comments, directives, textflag macros); pseudo-registers, labels, immediates, comments, directives, textflag macros);
- **navigation**: go-to-definition from a label reference to its definition, - **navigation**: go-to-definition from a label reference to its definition,
find references, document highlights of every use of the symbol under the find references, document highlights of every use of the symbol under the
cursor, rename, and workspace symbol search over the open documents; cursor, rename, and workspace symbol search over the open documents and
the indexed workspace files: the `.s` files under the workspace root that
the editor has never opened, where an open buffer shadows its disk copy
and watched-file events plus a per-query freshness check keep the index
current;
- **assists**: document formatting through the `format` package, inlay hints - **assists**: document formatting through the `format` package, inlay hints
(the frame size after the TEXT argument area), signature help (the callee's (the frame size after the TEXT argument area), signature help (the callee's
`// func` signature while the cursor is on a `CALL`), and code actions `// func` signature while the cursor is on a `CALL`), and code actions
+4 -1
View File
@@ -455,7 +455,10 @@ semantic tokens, go-to-definition, find references, rename, document
formatting, inlay hints, code actions, signature help, document highlights, formatting, inlay hints, code actions, signature help, document highlights,
workspace symbol search, #include document links, and folding ranges for workspace symbol search, #include document links, and folding ranges for
function bodies. Definition, references and rename work across every open function bodies. Definition, references and rename work across every open
document. document and the wider workspace on disk: the server indexes the `.s` files
under the workspace root that the editor has never opened, an open buffer
always shadows its disk copy, and watched-file events together with a
per-query freshness check keep the index current.
## version ## version
+25 -27
View File
@@ -127,8 +127,8 @@ func (s *Server) openASTs() []openAST {
// definition returns the location of the named label or function: a local // definition returns the location of the named label or function: a local
// label in the current document wins, then the TEXT functions of every open // label in the current document wins, then the TEXT functions of every open
// document are searched, so a `CALL ·helper(SB)` jumps to its definition in // document and indexed workspace file are searched, so a `CALL ·helper(SB)`
// another file. // jumps to its definition even when the defining file was never opened.
func (s *Server) definition(p definitionParams) []Location { func (s *Server) definition(p definitionParams) []Location {
text := s.docs[p.TextDocument.URI] text := s.docs[p.TextDocument.URI]
word, _ := wordAt(text, p.Position) word, _ := wordAt(text, p.Position)
@@ -156,15 +156,15 @@ func (s *Server) definition(p definitionParams) []Location {
} }
} }
// Function definitions across the open workspace. // Function definitions across the workspace.
for _, of := range s.openASTs() { for _, u := range s.scanUnits() {
for _, d := range of.file.Decls { for _, d := range u.file.Decls {
t, ok := d.(*ast.Text) t, ok := d.(*ast.Text)
if !ok || !realSymbol(t.Name) { if !ok || !realSymbol(t.Name) {
continue continue
} }
if t.Name.Name == name { if t.Name.Name == name {
return []Location{{URI: of.uri, Range: clientRange(s.docs[of.uri], symRange(t.Name))}} return []Location{{URI: u.uri, Range: clientRange(u.text, symRange(t.Name))}}
} }
} }
} }
@@ -172,10 +172,11 @@ func (s *Server) definition(p definitionParams) []Location {
} }
// references returns all locations where the symbol under the cursor appears // references returns all locations where the symbol under the cursor appears
// across every open document. The current document matches labels and any // across every open document and indexed workspace file. The current
// operand name, as before; other documents only match SB-qualified operand // document matches labels and any operand name, as before; every other
// references and the definition itself, because a bare name is a // source only matches SB-qualified operand references and the definition
// function-local label whose repeats in other files are unrelated. // itself, because a bare name is a function-local label whose repeats in
// other files are unrelated.
func (s *Server) references(p referenceParams) []Location { func (s *Server) references(p referenceParams) []Location {
text := s.docs[p.TextDocument.URI] text := s.docs[p.TextDocument.URI]
word, _ := wordAt(text, p.Position) word, _ := wordAt(text, p.Position)
@@ -186,22 +187,22 @@ func (s *Server) references(p referenceParams) []Location {
uri := p.TextDocument.URI uri := p.TextDocument.URI
var out []Location var out []Location
for _, of := range s.openASTs() { for _, u := range s.scanUnits() {
sameDoc := of.uri == uri sameDoc := u.uri == uri
for _, d := range of.file.Decls { for _, d := range u.file.Decls {
t, ok := d.(*ast.Text) t, ok := d.(*ast.Text)
if !ok || !realSymbol(t.Name) { if !ok || !realSymbol(t.Name) {
continue continue
} }
// Include the definition if requested. // Include the definition if requested.
if p.Context.IncludeDeclaration && t.Name.Name == name { if p.Context.IncludeDeclaration && t.Name.Name == name {
out = append(out, Location{URI: of.uri, Range: clientRange(s.docs[of.uri], symRange(t.Name))}) out = append(out, Location{URI: u.uri, Range: clientRange(u.text, symRange(t.Name))})
} }
for _, stmt := range t.Body { for _, stmt := range t.Body {
switch st := stmt.(type) { switch st := stmt.(type) {
case *ast.Label: case *ast.Label:
if sameDoc && st.Name.Text == name { if sameDoc && st.Name.Text == name {
out = append(out, Location{URI: of.uri, Range: clientRange(s.docs[of.uri], tokenRange(st.Name))}) out = append(out, Location{URI: u.uri, Range: clientRange(u.text, tokenRange(st.Name))})
} }
case *ast.Instr: case *ast.Instr:
for _, op := range st.Operands { for _, op := range st.Operands {
@@ -215,8 +216,8 @@ func (s *Server) references(p referenceParams) []Location {
// identifier, `·`/package prefix included, so a // identifier, `·`/package prefix included, so a
// rename replaces the whole spelling. // rename replaces the whole spelling.
out = append(out, Location{ out = append(out, Location{
URI: of.uri, URI: u.uri,
Range: clientRange(s.docs[of.uri], Range{ Range: clientRange(u.text, Range{
Start: Position{Line: op.Pos.Line - 1, Character: op.Pos.Column - 1}, Start: Position{Line: op.Pos.Line - 1, Character: op.Pos.Column - 1},
End: Position{Line: op.Pos.Line - 1, Character: op.Pos.Column - 1 + symIdentLen(op.Addr.Sym)}, End: Position{Line: op.Pos.Line - 1, Character: op.Pos.Column - 1 + symIdentLen(op.Addr.Sym)},
}), }),
@@ -510,19 +511,16 @@ func (s *Server) documentHighlights(p documentHighlightParams) []DocumentHighlig
return out return out
} }
// workspaceSymbols searches all open documents for symbols matching the query. // workspaceSymbols searches all open documents and indexed workspace files
// for symbols matching the query.
func (s *Server) workspaceSymbols(p workspaceSymbolParams) []WorkspaceSymbol { func (s *Server) workspaceSymbols(p workspaceSymbolParams) []WorkspaceSymbol {
if p.Query == "" { if p.Query == "" {
return nil return nil
} }
query := strings.ToLower(p.Query) query := strings.ToLower(p.Query)
var out []WorkspaceSymbol var out []WorkspaceSymbol
for uri, text := range s.docs { for _, u := range s.scanUnits() {
f, _ := parser.Parse(uriPath(uri), text) for _, d := range u.file.Decls {
if f == nil {
continue
}
for _, d := range f.Decls {
switch dd := d.(type) { switch dd := d.(type) {
case *ast.Text: case *ast.Text:
// A malformed TEXT line is kept in the tree under a "?" // A malformed TEXT line is kept in the tree under a "?"
@@ -535,7 +533,7 @@ func (s *Server) workspaceSymbols(p workspaceSymbolParams) []WorkspaceSymbol {
out = append(out, WorkspaceSymbol{ out = append(out, WorkspaceSymbol{
Name: dd.Name.Name, Name: dd.Name.Name,
Kind: symFunction, Kind: symFunction,
Location: Location{URI: uri, Range: clientRange(text, symRange(dd.Name))}, Location: Location{URI: u.uri, Range: clientRange(u.text, symRange(dd.Name))},
}) })
} }
case *ast.Globl: case *ast.Globl:
@@ -543,7 +541,7 @@ func (s *Server) workspaceSymbols(p workspaceSymbolParams) []WorkspaceSymbol {
out = append(out, WorkspaceSymbol{ out = append(out, WorkspaceSymbol{
Name: dd.Name.Name, Name: dd.Name.Name,
Kind: symConstant, Kind: symConstant,
Location: Location{URI: uri, Range: clientRange(text, symRange(dd.Name))}, Location: Location{URI: u.uri, Range: clientRange(u.text, symRange(dd.Name))},
}) })
} }
case *ast.Data: case *ast.Data:
@@ -551,7 +549,7 @@ func (s *Server) workspaceSymbols(p workspaceSymbolParams) []WorkspaceSymbol {
out = append(out, WorkspaceSymbol{ out = append(out, WorkspaceSymbol{
Name: dd.Name.Name, Name: dd.Name.Name,
Kind: symConstant, Kind: symConstant,
Location: Location{URI: uri, Range: clientRange(text, symRange(dd.Name))}, Location: Location{URI: u.uri, Range: clientRange(u.text, symRange(dd.Name))},
}) })
} }
} }
+50
View File
@@ -309,6 +309,56 @@ type ServerCapabilities struct {
DiagnosticProvider map[string]any `json:"diagnosticProvider,omitempty"` DiagnosticProvider map[string]any `json:"diagnosticProvider,omitempty"`
DocumentLinkProvider map[string]any `json:"documentLinkProvider,omitempty"` DocumentLinkProvider map[string]any `json:"documentLinkProvider,omitempty"`
FoldingRangeProvider bool `json:"foldingRangeProvider,omitempty"` FoldingRangeProvider bool `json:"foldingRangeProvider,omitempty"`
Workspace map[string]any `json:"workspace,omitempty"`
}
// --- workspace ----------------------------------------------------------------
// initializeParams carries the workspace roots the client announces.
type initializeParams struct {
RootURI string `json:"rootUri"`
WorkspaceFolders []workspaceFolder `json:"workspaceFolders"`
}
// workspaceFolder is one root folder the client opened.
type workspaceFolder struct {
URI string `json:"uri"`
Name string `json:"name"`
}
// roots returns the workspace roots as filesystem paths: the workspace
// folders when the client sends them, else the root URI.
func (p initializeParams) roots() []string {
var out []string
for _, f := range p.WorkspaceFolders {
if path := uriPath(f.URI); path != "" {
out = append(out, path)
}
}
if len(out) == 0 && p.RootURI != "" {
if path := uriPath(p.RootURI); path != "" {
out = append(out, path)
}
}
return out
}
// didChangeWatchedFilesParams announces on-disk changes the client watched.
type didChangeWatchedFilesParams struct {
Changes []fileEvent `json:"changes"`
}
// File change types (LSP FileChangeType).
const (
fileCreated = 1
fileChanged = 2
fileDeleted = 3
)
// fileEvent is one created, changed or deleted file.
type fileEvent struct {
URI string `json:"uri"`
Type int `json:"type"`
} }
// --- pull diagnostics --------------------------------------------------------- // --- pull diagnostics ---------------------------------------------------------
+16 -1
View File
@@ -24,7 +24,8 @@ type Server struct {
out io.Writer out io.Writer
mu sync.Mutex // guards writes to out mu sync.Mutex // guards writes to out
docs map[string]string docs map[string]string
version string // reported in the initialize result ("" omits it) ws workspaceIndex // the on-disk workspace beyond the open documents
version string // reported in the initialize result ("" omits it)
} }
// New returns a server reading from in and writing to out. // New returns a server reading from in and writing to out.
@@ -169,6 +170,11 @@ func (s *Server) dispatch(msg *rpcMessage) (exit bool) {
switch msg.Method { switch msg.Method {
case "initialize": case "initialize":
var ip initializeParams
if len(msg.Params) > 0 {
json.Unmarshal(msg.Params, &ip)
}
s.ws.roots = ip.roots()
info := map[string]string{"name": "gasm"} info := map[string]string{"name": "gasm"}
if s.version != "" { if s.version != "" {
info["version"] = s.version info["version"] = s.version
@@ -198,6 +204,7 @@ func (s *Server) dispatch(msg *rpcMessage) (exit bool) {
DiagnosticProvider: map[string]any{"interFileDependencies": false, "workspaceDiagnostics": false}, DiagnosticProvider: map[string]any{"interFileDependencies": false, "workspaceDiagnostics": false},
DocumentLinkProvider: map[string]any{}, DocumentLinkProvider: map[string]any{},
FoldingRangeProvider: true, FoldingRangeProvider: true,
Workspace: map[string]any{"didChangeWatchedFiles": map[string]any{}},
}, },
ServerInfo: info, ServerInfo: info,
}) })
@@ -308,6 +315,14 @@ func (s *Server) dispatch(msg *rpcMessage) (exit bool) {
json.Unmarshal(msg.Params, &p) json.Unmarshal(msg.Params, &p)
s.respond(msg.ID, s.workspaceSymbols(p)) s.respond(msg.ID, s.workspaceSymbols(p))
case "workspace/didChangeWatchedFiles":
var p didChangeWatchedFilesParams
if json.Unmarshal(msg.Params, &p) == nil {
for _, ch := range p.Changes {
s.ws.watched(uriPath(ch.URI), ch.Type == fileDeleted)
}
}
case "shutdown": case "shutdown":
s.respond(msg.ID, nil) s.respond(msg.ID, nil)
+164
View File
@@ -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()
}
+251
View File
@@ -0,0 +1,251 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package lsp
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// diskWorkspace writes one helper file on disk and returns the workspace root
// and the caller document that references ·helper and ·late by symbol.
func diskWorkspace(t *testing.T) (root, callerURI string, caller string) {
t.Helper()
root = t.TempDir()
helper := "TEXT \u00b7helper(SB), NOSPLIT, $0\n\tRET\n"
if err := os.WriteFile(filepath.Join(root, "helper_amd64.s"), []byte(helper), 0o644); err != nil {
t.Fatal(err)
}
caller = "TEXT \u00b7caller(SB), NOSPLIT, $0\n" +
"\tCALL \u00b7helper(SB)\n" +
"\tCALL \u00b7late(SB)\n" +
"\tRET\n"
callerURI = "file://" + root + "/caller_amd64.s"
return root, callerURI, caller
}
// openCaller frames the initialize/didOpen scripting around the caller doc.
func openCaller(root, callerURI, caller string) string {
return frame(1, "initialize", map[string]any{"rootUri": "file://" + root}) +
frame(nil, "initialized", map[string]any{}) +
frame(nil, "textDocument/didOpen", map[string]any{
"textDocument": map[string]any{"uri": callerURI, "languageId": "gasm", "version": 1, "text": caller},
})
}
// TestDefinitionReachesDiskFile checks that go-to-definition resolves a
// symbol to a workspace file the editor has never opened.
func TestDefinitionReachesDiskFile(t *testing.T) {
root, callerURI, caller := diskWorkspace(t)
in := openCaller(root, callerURI, caller) +
frame(2, "textDocument/definition", map[string]any{
"textDocument": map[string]any{"uri": callerURI},
"position": map[string]any{"line": 1, "character": 8}, // on helper in CALL ·helper(SB)
}) + frame(nil, "exit", nil)
msgs := run(t, in)
resp := findByID(msgs, 2)
if resp == nil {
t.Fatal("no definition response")
}
var locs []Location
if err := json.Unmarshal(mustResult(t, resp), &locs); err != nil {
t.Fatal(err)
}
wantURI := fileToURI(filepath.Join(root, "helper_amd64.s"))
if len(locs) != 1 || locs[0].URI != wantURI || locs[0].Range.Start.Line != 0 {
t.Fatalf("definition = %+v, want the TEXT in %s line 0", locs, wantURI)
}
}
// TestReferencesReachDiskFiles checks that find references collects the call
// site in the open document plus the definition in the unopened disk file.
func TestReferencesReachDiskFiles(t *testing.T) {
root, callerURI, caller := diskWorkspace(t)
in := openCaller(root, callerURI, caller) +
frame(2, "textDocument/references", map[string]any{
"textDocument": map[string]any{"uri": callerURI},
"position": map[string]any{"line": 1, "character": 8}, // on helper in CALL ·helper(SB)
"context": map[string]any{"includeDeclaration": true},
}) + frame(nil, "exit", nil)
msgs := run(t, in)
resp := findByID(msgs, 2)
if resp == nil {
t.Fatal("no references response")
}
var locs []Location
if err := json.Unmarshal(mustResult(t, resp), &locs); err != nil {
t.Fatal(err)
}
byURI := map[string]int{}
for _, l := range locs {
byURI[l.URI]++
}
if byURI[callerURI] != 1 || byURI[fileToURI(filepath.Join(root, "helper_amd64.s"))] != 1 {
t.Fatalf("references by uri = %v, want the call in the open document and the definition on disk", byURI)
}
}
// TestWorkspaceSymbolsReachesDiskFiles checks the workspace symbol search
// against a file the editor has never opened.
func TestWorkspaceSymbolsReachesDiskFiles(t *testing.T) {
root, callerURI, caller := diskWorkspace(t)
in := openCaller(root, callerURI, caller) +
frame(2, "workspace/symbol", map[string]any{"query": "helper"}) +
frame(nil, "exit", nil)
msgs := run(t, in)
resp := findByID(msgs, 2)
if resp == nil {
t.Fatal("no workspace/symbol response")
}
var syms []WorkspaceSymbol
if err := json.Unmarshal(mustResult(t, resp), &syms); err != nil {
t.Fatal(err)
}
if len(syms) != 1 || syms[0].Name != "helper" ||
syms[0].Location.URI != fileToURI(filepath.Join(root, "helper_amd64.s")) {
t.Fatalf("symbols = %+v, want helper from the disk file", syms)
}
}
// TestOpenBufferShadowsDiskFile pins the shadowing rule: when an open buffer
// carries unsaved edits, the answers come from the buffer, never from the
// stale disk copy.
func TestOpenBufferShadowsDiskFile(t *testing.T) {
root, callerURI, caller := diskWorkspace(t)
helperURI := "file://" + root + "/helper_amd64.s"
// The buffer moved the definition to line 1; the disk copy has line 0.
buffered := "\nTEXT \u00b7helper(SB), NOSPLIT, $0\n\tRET\n"
in := openCaller(root, callerURI, caller) +
frame(nil, "textDocument/didOpen", map[string]any{
"textDocument": map[string]any{"uri": helperURI, "languageId": "gasm", "version": 1, "text": buffered},
}) +
frame(2, "textDocument/definition", map[string]any{
"textDocument": map[string]any{"uri": callerURI},
"position": map[string]any{"line": 1, "character": 8},
}) +
frame(3, "textDocument/references", map[string]any{
"textDocument": map[string]any{"uri": callerURI},
"position": map[string]any{"line": 1, "character": 8},
"context": map[string]any{"includeDeclaration": true},
}) + frame(nil, "exit", nil)
msgs := run(t, in)
dresp := findByID(msgs, 2)
if dresp == nil {
t.Fatal("no definition response")
}
var locs []Location
if err := json.Unmarshal(mustResult(t, dresp), &locs); err != nil {
t.Fatal(err)
}
if len(locs) != 1 || locs[0].URI != helperURI || locs[0].Range.Start.Line != 1 {
t.Fatalf("definition = %+v, want the buffered TEXT on line 1", locs)
}
rresp := findByID(msgs, 3)
if rresp == nil {
t.Fatal("no references response")
}
locs = nil
if err := json.Unmarshal(mustResult(t, rresp), &locs); err != nil {
t.Fatal(err)
}
if len(locs) != 2 {
t.Fatalf("references = %+v, want the call and the buffered definition", locs)
}
for _, l := range locs {
if l.URI == helperURI && l.Range.Start.Line != 1 {
t.Errorf("reference in the opened file points at line %d, want the buffered line 1", l.Range.Start.Line)
}
}
}
// TestWatchedFileEventsUpdateIndex pins the one case a per-query refresh
// cannot cover: a file created after the build is unknown to the index until
// the client's watched-file event names it. The steps run through dispatch
// directly, because the file must appear on disk between two requests.
func TestWatchedFileEventsUpdateIndex(t *testing.T) {
root, callerURI, caller := diskWorkspace(t)
latePath := filepath.Join(root, "late_amd64.s")
lateURI := "file://" + latePath
var buf bytes.Buffer
srv := New(strings.NewReader(""), &buf)
request := func(id int, method string, params any) *rpcMessage {
t.Helper()
raw, err := json.Marshal(params)
if err != nil {
t.Fatal(err)
}
var msgID *json.RawMessage
if id > 0 {
msgID = rawID(t, id)
}
srv.dispatch(&rpcMessage{ID: msgID, Method: method, Params: raw})
msgs := readFrames(t, &buf)
buf.Reset()
if id > 0 {
return findByID(msgs, id)
}
return nil
}
definitionOnLate := func(id int) []Location {
t.Helper()
resp := request(id, "textDocument/definition", map[string]any{
"textDocument": map[string]any{"uri": callerURI},
"position": map[string]any{"line": 2, "character": 8}, // on late in CALL ·late(SB)
})
var locs []Location
if err := json.Unmarshal(mustResult(t, resp), &locs); err != nil {
t.Fatal(err)
}
return locs
}
request(1, "initialize", map[string]any{"rootUri": "file://" + root})
request(0, "textDocument/didOpen", map[string]any{
"textDocument": map[string]any{"uri": callerURI, "languageId": "gasm", "version": 1, "text": caller},
})
// The first request builds the index; the late file does not exist yet.
if locs := definitionOnLate(2); len(locs) != 0 {
t.Fatalf("definition before the file exists = %+v, want no result", locs)
}
if err := os.WriteFile(latePath, []byte("TEXT \u00b7late(SB), NOSPLIT, $0\n\tRET\n"), 0o644); err != nil {
t.Fatal(err)
}
request(0, "workspace/didChangeWatchedFiles", map[string]any{
"changes": []map[string]any{{"uri": lateURI, "type": fileCreated}},
})
if locs := definitionOnLate(4); len(locs) != 1 || locs[0].URI != fileToURI(latePath) {
t.Fatalf("definition after the created event = %+v, want late_amd64.s", locs)
}
if err := os.Remove(latePath); err != nil {
t.Fatal(err)
}
request(0, "workspace/didChangeWatchedFiles", map[string]any{
"changes": []map[string]any{{"uri": lateURI, "type": fileDeleted}},
})
if locs := definitionOnLate(6); len(locs) != 0 {
t.Fatalf("definition after the deleted event = %+v, want no result", locs)
}
}
// TestFileToURIRoundTrip pins the URI encoding of the disk entries: the
// constructed URI decodes back to the path it came from.
func TestFileToURIRoundTrip(t *testing.T) {
const path = "/tmp/Repositáře/kernels/helper.s"
if got := uriPath(fileToURI(path)); got != path {
t.Errorf("fileToURI round trip = %q, want %q", got, path)
}
if got := fileToURI("/plain/k.s"); got != "file:///plain/k.s" {
t.Errorf("fileToURI plain = %q", got)
}
}