252 lines
8.9 KiB
Go
252 lines
8.9 KiB
Go
// 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)
|
|
}
|
|
}
|