// 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) } }