diff --git a/CHANGELOG.md b/CHANGELOG.md index 2339f53..be7fc30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 diff --git a/README.md b/README.md index 5151248..f4284f3 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,8 @@ to give that syntax the tooling it deserves. find references, rename, formatting, inlay hints, code actions, signature help, document highlights, workspace symbol search, #include document 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 assembly files byte-for-byte, `gasm profile` shows basic-block structure, `gasm audit-instructions` diffs the encoder against the installed toolchain, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d353abd..75f049f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -246,7 +246,11 @@ document store, republishes diagnostics on every change, and provides: pseudo-registers, labels, immediates, comments, directives, textflag macros); - **navigation**: go-to-definition from a label reference to its definition, 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 (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 diff --git a/docs/CLI.md b/docs/CLI.md index aa09713..3a6157c 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -455,7 +455,10 @@ semantic tokens, go-to-definition, find references, rename, document formatting, inlay hints, code actions, signature help, document highlights, workspace symbol search, #include document links, and folding ranges for 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 diff --git a/lsp/handlers.go b/lsp/handlers.go index 294e2ef..fefdc98 100644 --- a/lsp/handlers.go +++ b/lsp/handlers.go @@ -127,8 +127,8 @@ func (s *Server) openASTs() []openAST { // 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 -// document are searched, so a `CALL ·helper(SB)` jumps to its definition in -// another file. +// document and indexed workspace file are searched, so a `CALL ·helper(SB)` +// jumps to its definition even when the defining file was never opened. func (s *Server) definition(p definitionParams) []Location { text := s.docs[p.TextDocument.URI] word, _ := wordAt(text, p.Position) @@ -156,15 +156,15 @@ func (s *Server) definition(p definitionParams) []Location { } } - // Function definitions across the open workspace. - for _, of := range s.openASTs() { - for _, d := range of.file.Decls { + // Function definitions across the workspace. + for _, u := range s.scanUnits() { + for _, d := range u.file.Decls { t, ok := d.(*ast.Text) if !ok || !realSymbol(t.Name) { continue } 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 -// across every open document. The current document matches labels and any -// operand name, as before; other documents only match SB-qualified operand -// references and the definition itself, because a bare name is a -// function-local label whose repeats in other files are unrelated. +// across every open document and indexed workspace file. The current +// document matches labels and any operand name, as before; every other +// source only matches SB-qualified operand references and the definition +// itself, because a bare name is a function-local label whose repeats in +// other files are unrelated. func (s *Server) references(p referenceParams) []Location { text := s.docs[p.TextDocument.URI] word, _ := wordAt(text, p.Position) @@ -186,22 +187,22 @@ func (s *Server) references(p referenceParams) []Location { uri := p.TextDocument.URI var out []Location - for _, of := range s.openASTs() { - sameDoc := of.uri == uri - for _, d := range of.file.Decls { + for _, u := range s.scanUnits() { + sameDoc := u.uri == uri + for _, d := range u.file.Decls { t, ok := d.(*ast.Text) if !ok || !realSymbol(t.Name) { continue } // Include the definition if requested. 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 { switch st := stmt.(type) { case *ast.Label: 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: for _, op := range st.Operands { @@ -215,8 +216,8 @@ func (s *Server) references(p referenceParams) []Location { // identifier, `·`/package prefix included, so a // rename replaces the whole spelling. out = append(out, Location{ - URI: of.uri, - Range: clientRange(s.docs[of.uri], Range{ + URI: u.uri, + Range: clientRange(u.text, Range{ 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)}, }), @@ -510,19 +511,16 @@ func (s *Server) documentHighlights(p documentHighlightParams) []DocumentHighlig 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 { if p.Query == "" { return nil } query := strings.ToLower(p.Query) var out []WorkspaceSymbol - for uri, text := range s.docs { - f, _ := parser.Parse(uriPath(uri), text) - if f == nil { - continue - } - for _, d := range f.Decls { + for _, u := range s.scanUnits() { + for _, d := range u.file.Decls { switch dd := d.(type) { case *ast.Text: // 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{ Name: dd.Name.Name, 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: @@ -543,7 +541,7 @@ func (s *Server) workspaceSymbols(p workspaceSymbolParams) []WorkspaceSymbol { out = append(out, WorkspaceSymbol{ Name: dd.Name.Name, 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: @@ -551,7 +549,7 @@ func (s *Server) workspaceSymbols(p workspaceSymbolParams) []WorkspaceSymbol { out = append(out, WorkspaceSymbol{ Name: dd.Name.Name, Kind: symConstant, - Location: Location{URI: uri, Range: clientRange(text, symRange(dd.Name))}, + Location: Location{URI: u.uri, Range: clientRange(u.text, symRange(dd.Name))}, }) } } diff --git a/lsp/protocol.go b/lsp/protocol.go index ccd0397..1836fbf 100644 --- a/lsp/protocol.go +++ b/lsp/protocol.go @@ -309,6 +309,56 @@ type ServerCapabilities struct { DiagnosticProvider map[string]any `json:"diagnosticProvider,omitempty"` DocumentLinkProvider map[string]any `json:"documentLinkProvider,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 --------------------------------------------------------- diff --git a/lsp/server.go b/lsp/server.go index e163bbb..552fed6 100644 --- a/lsp/server.go +++ b/lsp/server.go @@ -24,7 +24,8 @@ type Server struct { out io.Writer mu sync.Mutex // guards writes to out 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. @@ -169,6 +170,11 @@ func (s *Server) dispatch(msg *rpcMessage) (exit bool) { switch msg.Method { 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"} if 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}, DocumentLinkProvider: map[string]any{}, FoldingRangeProvider: true, + Workspace: map[string]any{"didChangeWatchedFiles": map[string]any{}}, }, ServerInfo: info, }) @@ -308,6 +315,14 @@ func (s *Server) dispatch(msg *rpcMessage) (exit bool) { json.Unmarshal(msg.Params, &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": s.respond(msg.ID, nil) diff --git a/lsp/workspace.go b/lsp/workspace.go new file mode 100644 index 0000000..a8ea6ec --- /dev/null +++ b/lsp/workspace.go @@ -0,0 +1,164 @@ +// 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() +} diff --git a/lsp/workspace_test.go b/lsp/workspace_test.go new file mode 100644 index 0000000..e96ec8f --- /dev/null +++ b/lsp/workspace_test.go @@ -0,0 +1,251 @@ +// Copyright (c) 2026 Petr Balvín (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) + } +}