2026-07-06 09:49:50 +02:00
|
|
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|
|
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
|
|
package lsp
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bufio"
|
|
|
|
|
"bytes"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const cleanDoc = "#include \"textflag.h\"\n" +
|
|
|
|
|
"TEXT ·foo(SB), NOSPLIT, $0\n" +
|
|
|
|
|
"\tMOVQ AX, CX\n" +
|
|
|
|
|
"loop:\n" +
|
|
|
|
|
"\tJMP loop\n" +
|
|
|
|
|
"\tRET\n"
|
|
|
|
|
|
|
|
|
|
const badDoc = "#include \"textflag.h\"\n" +
|
|
|
|
|
"TEXT ·foo(SB), NOSPLIT, $0\n" +
|
|
|
|
|
"\tNOSUCHINSTR AX, BX\n" +
|
|
|
|
|
"\tJMP missing\n"
|
|
|
|
|
|
|
|
|
|
// frame renders one Content-Length framed JSON-RPC message.
|
|
|
|
|
func frame(id any, method string, params any) string {
|
|
|
|
|
msg := map[string]any{"jsonrpc": "2.0"}
|
|
|
|
|
if id != nil {
|
|
|
|
|
msg["id"] = id
|
|
|
|
|
}
|
|
|
|
|
if method != "" {
|
|
|
|
|
msg["method"] = method
|
|
|
|
|
}
|
|
|
|
|
if params != nil {
|
|
|
|
|
msg["params"] = params
|
|
|
|
|
}
|
|
|
|
|
body, _ := json.Marshal(msg)
|
|
|
|
|
return fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(body), body)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// run feeds input to a server and returns every parsed output message.
|
|
|
|
|
func run(t *testing.T, input string) []rpcMessage {
|
|
|
|
|
t.Helper()
|
|
|
|
|
var out bytes.Buffer
|
|
|
|
|
srv := New(strings.NewReader(input), &out)
|
|
|
|
|
if err := srv.Run(); err != nil {
|
|
|
|
|
t.Fatalf("server run: %v", err)
|
|
|
|
|
}
|
|
|
|
|
return readFrames(t, &out)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// readFrames parses all framed messages from a buffer.
|
|
|
|
|
func readFrames(t *testing.T, r io.Reader) []rpcMessage {
|
|
|
|
|
t.Helper()
|
|
|
|
|
br := bufio.NewReader(r)
|
|
|
|
|
var msgs []rpcMessage
|
|
|
|
|
for {
|
|
|
|
|
length := -1
|
|
|
|
|
for {
|
|
|
|
|
line, err := br.ReadString('\n')
|
|
|
|
|
if err == io.EOF {
|
|
|
|
|
return msgs
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("read header: %v", err)
|
|
|
|
|
}
|
|
|
|
|
line = strings.TrimRight(line, "\r\n")
|
|
|
|
|
if line == "" {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
if k, v, ok := strings.Cut(line, ":"); ok && strings.EqualFold(strings.TrimSpace(k), "Content-Length") {
|
|
|
|
|
length, _ = strconv.Atoi(strings.TrimSpace(v))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if length < 0 {
|
|
|
|
|
return msgs
|
|
|
|
|
}
|
|
|
|
|
body := make([]byte, length)
|
|
|
|
|
if _, err := io.ReadFull(br, body); err != nil {
|
|
|
|
|
t.Fatalf("read body: %v", err)
|
|
|
|
|
}
|
|
|
|
|
var m rpcMessage
|
|
|
|
|
if err := json.Unmarshal(body, &m); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
msgs = append(msgs, m)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// session builds a standard scripting of messages around a document.
|
|
|
|
|
func session(uri, text string) string {
|
|
|
|
|
var b strings.Builder
|
|
|
|
|
b.WriteString(frame(1, "initialize", map[string]any{"rootUri": ""}))
|
|
|
|
|
b.WriteString(frame(nil, "initialized", map[string]any{}))
|
|
|
|
|
b.WriteString(frame(nil, "textDocument/didOpen", map[string]any{
|
|
|
|
|
"textDocument": map[string]any{
|
|
|
|
|
"uri": uri, "languageId": "gasm", "version": 1, "text": text,
|
|
|
|
|
},
|
|
|
|
|
}))
|
|
|
|
|
return b.String()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func findByID(msgs []rpcMessage, n int) *rpcMessage {
|
|
|
|
|
for i := range msgs {
|
|
|
|
|
if msgs[i].ID != nil {
|
|
|
|
|
var id int
|
|
|
|
|
if json.Unmarshal(*msgs[i].ID, &id) == nil && id == n {
|
|
|
|
|
return &msgs[i]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func findMethod(msgs []rpcMessage, method string) *rpcMessage {
|
|
|
|
|
for i := range msgs {
|
|
|
|
|
if msgs[i].Method == method {
|
|
|
|
|
return &msgs[i]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestInitialize(t *testing.T) {
|
|
|
|
|
msgs := run(t, session("file:///f_amd64.s", cleanDoc)+frame(nil, "exit", nil))
|
|
|
|
|
resp := findByID(msgs, 1)
|
|
|
|
|
if resp == nil {
|
|
|
|
|
t.Fatal("no initialize response")
|
|
|
|
|
}
|
|
|
|
|
var res initializeResult
|
|
|
|
|
if err := json.Unmarshal(mustResult(t, resp), &res); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if !res.Capabilities.HoverProvider || res.Capabilities.SemanticTokensProvider == nil {
|
|
|
|
|
t.Fatalf("unexpected capabilities: %+v", res.Capabilities)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestDiagnosticsClean(t *testing.T) {
|
|
|
|
|
msgs := run(t, session("file:///f_amd64.s", cleanDoc)+frame(nil, "exit", nil))
|
|
|
|
|
pub := findMethod(msgs, "textDocument/publishDiagnostics")
|
|
|
|
|
if pub == nil {
|
|
|
|
|
t.Fatal("no publishDiagnostics notification")
|
|
|
|
|
}
|
|
|
|
|
var p publishDiagnosticsParams
|
|
|
|
|
json.Unmarshal(pub.Params, &p)
|
|
|
|
|
if len(p.Diagnostics) != 0 {
|
|
|
|
|
t.Fatalf("clean doc should have no diagnostics, got %+v", p.Diagnostics)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestDiagnosticsErrors(t *testing.T) {
|
|
|
|
|
msgs := run(t, session("file:///f_amd64.s", badDoc)+frame(nil, "exit", nil))
|
|
|
|
|
pub := findMethod(msgs, "textDocument/publishDiagnostics")
|
|
|
|
|
if pub == nil {
|
|
|
|
|
t.Fatal("no publishDiagnostics notification")
|
|
|
|
|
}
|
|
|
|
|
var p publishDiagnosticsParams
|
|
|
|
|
json.Unmarshal(pub.Params, &p)
|
|
|
|
|
codes := map[string]bool{}
|
|
|
|
|
for _, d := range p.Diagnostics {
|
|
|
|
|
codes[d.Code] = true
|
|
|
|
|
}
|
|
|
|
|
if !codes["unknown-instruction"] || !codes["undefined-label"] {
|
|
|
|
|
t.Fatalf("expected unknown-instruction and undefined-label, got %+v", p.Diagnostics)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCompletion(t *testing.T) {
|
|
|
|
|
in := session("file:///f_amd64.s", cleanDoc) +
|
|
|
|
|
frame(2, "textDocument/completion", map[string]any{
|
|
|
|
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
|
|
|
|
"position": map[string]any{"line": 2, "character": 1},
|
|
|
|
|
}) + frame(nil, "exit", nil)
|
|
|
|
|
msgs := run(t, in)
|
|
|
|
|
resp := findByID(msgs, 2)
|
|
|
|
|
if resp == nil {
|
|
|
|
|
t.Fatal("no completion response")
|
|
|
|
|
}
|
|
|
|
|
var items []CompletionItem
|
|
|
|
|
if err := json.Unmarshal(mustResult(t, resp), &items); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
labels := map[string]bool{}
|
|
|
|
|
for _, it := range items {
|
|
|
|
|
labels[it.Label] = true
|
|
|
|
|
}
|
|
|
|
|
for _, want := range []string{"MOVQ", "AX", "TEXT", "NOSPLIT", "loop"} {
|
|
|
|
|
if !labels[want] {
|
|
|
|
|
t.Errorf("completion missing %q", want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHover(t *testing.T) {
|
|
|
|
|
in := session("file:///f_amd64.s", cleanDoc) +
|
|
|
|
|
frame(3, "textDocument/hover", map[string]any{
|
|
|
|
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
|
|
|
|
"position": map[string]any{"line": 2, "character": 2}, // on MOVQ
|
|
|
|
|
}) + frame(nil, "exit", nil)
|
|
|
|
|
msgs := run(t, in)
|
|
|
|
|
resp := findByID(msgs, 3)
|
|
|
|
|
if resp == nil {
|
|
|
|
|
t.Fatal("no hover response")
|
|
|
|
|
}
|
|
|
|
|
var h Hover
|
|
|
|
|
if err := json.Unmarshal(mustResult(t, resp), &h); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(h.Contents.Value, "MOVQ") {
|
|
|
|
|
t.Fatalf("hover = %q, want MOVQ docs", h.Contents.Value)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestDocumentSymbols(t *testing.T) {
|
|
|
|
|
in := session("file:///f_amd64.s", cleanDoc) +
|
|
|
|
|
frame(4, "textDocument/documentSymbol", map[string]any{
|
|
|
|
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
|
|
|
|
}) + frame(nil, "exit", nil)
|
|
|
|
|
msgs := run(t, in)
|
|
|
|
|
resp := findByID(msgs, 4)
|
|
|
|
|
if resp == nil {
|
|
|
|
|
t.Fatal("no documentSymbol response")
|
|
|
|
|
}
|
|
|
|
|
var syms []DocumentSymbol
|
|
|
|
|
if err := json.Unmarshal(mustResult(t, resp), &syms); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(syms) == 0 || syms[0].Name != "foo" {
|
|
|
|
|
t.Fatalf("symbols = %+v, want function foo", syms)
|
|
|
|
|
}
|
|
|
|
|
found := false
|
|
|
|
|
for _, c := range syms[0].Children {
|
|
|
|
|
if c.Name == "loop" {
|
|
|
|
|
found = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !found {
|
|
|
|
|
t.Errorf("function foo should contain label loop: %+v", syms[0].Children)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSemanticTokens(t *testing.T) {
|
|
|
|
|
in := session("file:///f_amd64.s", cleanDoc) +
|
|
|
|
|
frame(5, "textDocument/semanticTokens/full", map[string]any{
|
|
|
|
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
|
|
|
|
}) + frame(nil, "exit", nil)
|
|
|
|
|
msgs := run(t, in)
|
|
|
|
|
resp := findByID(msgs, 5)
|
|
|
|
|
if resp == nil {
|
|
|
|
|
t.Fatal("no semanticTokens response")
|
|
|
|
|
}
|
|
|
|
|
var st SemanticTokens
|
|
|
|
|
if err := json.Unmarshal(mustResult(t, resp), &st); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(st.Data) == 0 || len(st.Data)%5 != 0 {
|
|
|
|
|
t.Fatalf("semantic tokens data invalid: len=%d", len(st.Data))
|
|
|
|
|
}
|
|
|
|
|
// There must be at least one "function" (mnemonic) and one "comment"-free
|
|
|
|
|
// keyword token; sanity-check that a MOVQ-classified function token exists.
|
|
|
|
|
seenFunction := false
|
|
|
|
|
for i := 3; i < len(st.Data); i += 5 {
|
|
|
|
|
if st.Data[i] == stFunction {
|
|
|
|
|
seenFunction = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !seenFunction {
|
|
|
|
|
t.Error("expected at least one function (mnemonic) semantic token")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-21 00:18:45 +02:00
|
|
|
func TestReferences(t *testing.T) {
|
|
|
|
|
in := session("file:///f_amd64.s", cleanDoc) +
|
|
|
|
|
frame(6, "textDocument/references", map[string]any{
|
|
|
|
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
|
|
|
|
"position": map[string]any{"line": 4, "character": 5}, // on "loop" in JMP loop
|
|
|
|
|
"context": map[string]any{"includeDeclaration": true},
|
|
|
|
|
}) + frame(nil, "exit", nil)
|
|
|
|
|
msgs := run(t, in)
|
|
|
|
|
resp := findByID(msgs, 6)
|
|
|
|
|
if resp == nil {
|
|
|
|
|
t.Fatal("no references response")
|
|
|
|
|
}
|
|
|
|
|
var locs []Location
|
|
|
|
|
if err := json.Unmarshal(mustResult(t, resp), &locs); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(locs) < 2 {
|
|
|
|
|
t.Fatalf("want at least 2 references (definition + use), got %d", len(locs))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRename(t *testing.T) {
|
|
|
|
|
in := session("file:///f_amd64.s", cleanDoc) +
|
|
|
|
|
frame(7, "textDocument/rename", map[string]any{
|
|
|
|
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
|
|
|
|
"position": map[string]any{"line": 3, "character": 0}, // on "loop:" label
|
|
|
|
|
"newName": "newloop",
|
|
|
|
|
}) + frame(nil, "exit", nil)
|
|
|
|
|
msgs := run(t, in)
|
|
|
|
|
resp := findByID(msgs, 7)
|
|
|
|
|
if resp == nil {
|
|
|
|
|
t.Fatal("no rename response")
|
|
|
|
|
}
|
|
|
|
|
var edit WorkspaceEdit
|
|
|
|
|
if err := json.Unmarshal(mustResult(t, resp), &edit); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
edits := edit.Changes["file:///f_amd64.s"]
|
|
|
|
|
if len(edits) < 2 {
|
|
|
|
|
t.Fatalf("want at least 2 edits (definition + use), got %d", len(edits))
|
|
|
|
|
}
|
|
|
|
|
for _, e := range edits {
|
|
|
|
|
if e.NewText != "newloop" {
|
|
|
|
|
t.Errorf("edit newText = %q, want newloop", e.NewText)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestFormatting(t *testing.T) {
|
|
|
|
|
in := session("file:///f_amd64.s", cleanDoc) +
|
|
|
|
|
frame(8, "textDocument/formatting", map[string]any{
|
|
|
|
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
|
|
|
|
"options": map[string]any{"tabSize": 4, "insertSpaces": false},
|
|
|
|
|
}) + frame(nil, "exit", nil)
|
|
|
|
|
msgs := run(t, in)
|
|
|
|
|
resp := findByID(msgs, 8)
|
|
|
|
|
if resp == nil {
|
|
|
|
|
t.Fatal("no formatting response")
|
|
|
|
|
}
|
|
|
|
|
var edits []TextEdit
|
|
|
|
|
if err := json.Unmarshal(mustResult(t, resp), &edits); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
// Formatting should return at most one whole-document edit.
|
|
|
|
|
if len(edits) > 1 {
|
|
|
|
|
t.Fatalf("formatting returned %d edits, want 0 or 1", len(edits))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestInlayHints(t *testing.T) {
|
|
|
|
|
docWithFrame := "#include \"textflag.h\"\n" +
|
|
|
|
|
"TEXT ·foo(SB), NOSPLIT, $16-24\n" +
|
|
|
|
|
"\tMOVQ AX, CX\n" +
|
|
|
|
|
"\tRET\n"
|
|
|
|
|
in := session("file:///f_amd64.s", docWithFrame) +
|
|
|
|
|
frame(9, "textDocument/inlayHint", map[string]any{
|
|
|
|
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
|
|
|
|
"range": map[string]any{
|
|
|
|
|
"start": map[string]any{"line": 0, "character": 0},
|
|
|
|
|
"end": map[string]any{"line": 3, "character": 0},
|
|
|
|
|
},
|
|
|
|
|
}) + frame(nil, "exit", nil)
|
|
|
|
|
msgs := run(t, in)
|
|
|
|
|
resp := findByID(msgs, 9)
|
|
|
|
|
if resp == nil {
|
|
|
|
|
t.Fatal("no inlayHint response")
|
|
|
|
|
}
|
|
|
|
|
var hints []InlayHint
|
|
|
|
|
if err := json.Unmarshal(mustResult(t, resp), &hints); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(hints) == 0 {
|
|
|
|
|
t.Fatal("want at least 1 inlay hint for frame size")
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(hints[0].Label, "frame=") {
|
|
|
|
|
t.Errorf("hint label = %q, want frame hint", hints[0].Label)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-21 01:01:58 +02:00
|
|
|
func TestCodeActions(t *testing.T) {
|
|
|
|
|
badDoc := "#include \"textflag.h\"\n" +
|
|
|
|
|
"TEXT ·foo(SB), NOSPLIT, $0\n" +
|
|
|
|
|
"\tMOVQ AX, CX\n"
|
|
|
|
|
in := session("file:///f_amd64.s", badDoc) +
|
|
|
|
|
frame(10, "textDocument/codeAction", map[string]any{
|
|
|
|
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
|
|
|
|
"range": map[string]any{"start": map[string]any{"line": 0, "character": 0}, "end": map[string]any{"line": 2, "character": 0}},
|
2026-09-14 18:22:00 +02:00
|
|
|
"context": map[string]any{"diagnostics": []map[string]any{{"code": "missing-ret", "range": map[string]any{"start": map[string]any{"line": 1, "character": 0}, "end": map[string]any{"line": 1, "character": 4}}}}},
|
2026-08-21 01:01:58 +02:00
|
|
|
}) + frame(nil, "exit", nil)
|
|
|
|
|
msgs := run(t, in)
|
|
|
|
|
resp := findByID(msgs, 10)
|
|
|
|
|
if resp == nil {
|
|
|
|
|
t.Fatal("no codeAction response")
|
|
|
|
|
}
|
|
|
|
|
var actions []CodeAction
|
|
|
|
|
if err := json.Unmarshal(mustResult(t, resp), &actions); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(actions) == 0 {
|
|
|
|
|
t.Fatal("want at least 1 code action for missing-ret")
|
|
|
|
|
}
|
2026-09-14 18:22:00 +02:00
|
|
|
if actions[0].Title != "Add RET to foo" {
|
|
|
|
|
t.Errorf("action title = %q, want Add RET to foo", actions[0].Title)
|
2026-08-21 01:01:58 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSignatureHelp(t *testing.T) {
|
|
|
|
|
doc := "#include \"textflag.h\"\n" +
|
|
|
|
|
"TEXT ·add(SB), NOSPLIT, $0-24\n" +
|
|
|
|
|
"\tMOVQ AX, CX\n" +
|
|
|
|
|
"\tRET\n" +
|
|
|
|
|
"TEXT ·caller(SB), NOSPLIT, $0\n" +
|
|
|
|
|
"\tCALL ·add(SB)\n" +
|
|
|
|
|
"\tRET\n"
|
|
|
|
|
in := session("file:///f_amd64.s", doc) +
|
|
|
|
|
frame(11, "textDocument/signatureHelp", map[string]any{
|
|
|
|
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
|
|
|
|
"position": map[string]any{"line": 5, "character": 6},
|
|
|
|
|
}) + frame(nil, "exit", nil)
|
|
|
|
|
msgs := run(t, in)
|
|
|
|
|
resp := findByID(msgs, 11)
|
|
|
|
|
if resp == nil {
|
|
|
|
|
t.Fatal("no signatureHelp response")
|
|
|
|
|
}
|
|
|
|
|
var sh SignatureHelp
|
|
|
|
|
if err := json.Unmarshal(mustResult(t, resp), &sh); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(sh.Signatures) == 0 {
|
|
|
|
|
t.Fatal("want at least 1 signature")
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(sh.Signatures[0].Label, "add") {
|
|
|
|
|
t.Errorf("signature label = %q, want add", sh.Signatures[0].Label)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestDocumentHighlights(t *testing.T) {
|
|
|
|
|
in := session("file:///f_amd64.s", cleanDoc) +
|
|
|
|
|
frame(12, "textDocument/documentHighlight", map[string]any{
|
|
|
|
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
|
|
|
|
"position": map[string]any{"line": 4, "character": 5},
|
|
|
|
|
}) + frame(nil, "exit", nil)
|
|
|
|
|
msgs := run(t, in)
|
|
|
|
|
resp := findByID(msgs, 12)
|
|
|
|
|
if resp == nil {
|
|
|
|
|
t.Fatal("no documentHighlight response")
|
|
|
|
|
}
|
|
|
|
|
var highlights []DocumentHighlight
|
|
|
|
|
if err := json.Unmarshal(mustResult(t, resp), &highlights); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(highlights) < 2 {
|
|
|
|
|
t.Fatalf("want at least 2 highlights (definition + use), got %d", len(highlights))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-21 01:15:05 +02:00
|
|
|
func TestWorkspaceSymbols(t *testing.T) {
|
|
|
|
|
in := session("file:///f_amd64.s", cleanDoc) +
|
|
|
|
|
frame(13, "workspace/symbol", map[string]any{
|
|
|
|
|
"query": "foo",
|
|
|
|
|
}) + frame(nil, "exit", nil)
|
|
|
|
|
msgs := run(t, in)
|
|
|
|
|
resp := findByID(msgs, 13)
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
found := false
|
|
|
|
|
for _, s := range syms {
|
|
|
|
|
if s.Name == "foo" {
|
|
|
|
|
found = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !found {
|
|
|
|
|
t.Fatalf("want symbol foo in results, got %+v", syms)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 09:49:50 +02:00
|
|
|
func TestMethodNotFound(t *testing.T) {
|
|
|
|
|
in := frame(9, "bogus/method", map[string]any{}) + frame(nil, "exit", nil)
|
|
|
|
|
msgs := run(t, in)
|
|
|
|
|
resp := findByID(msgs, 9)
|
|
|
|
|
if resp == nil || resp.Error == nil || resp.Error.Code != errMethodNotFound {
|
|
|
|
|
t.Fatalf("expected method-not-found error, got %+v", resp)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 23:49:19 +02:00
|
|
|
// TestMalformedContentLengthAnsweredAndRecovered pins the framing contract:
|
|
|
|
|
// an unparseable Content-Length is answered with a -32700 parse error against
|
|
|
|
|
// the null id, and the server keeps serving the next well-formed request
|
|
|
|
|
// instead of exiting the process.
|
|
|
|
|
func TestMalformedContentLengthAnsweredAndRecovered(t *testing.T) {
|
|
|
|
|
in := "Content-Length: 12x\r\n\r\n" +
|
|
|
|
|
frame(1, "initialize", map[string]any{}) + frame(nil, "exit", nil)
|
|
|
|
|
msgs := run(t, in)
|
|
|
|
|
if n := countParseErrors(msgs); n != 1 {
|
|
|
|
|
t.Fatalf("want exactly one -32700 response, got %d", n)
|
|
|
|
|
}
|
|
|
|
|
if findByID(msgs, 1) == nil {
|
|
|
|
|
t.Fatal("server did not serve the request after the malformed header")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestMalformedJSONBodyAnsweredAndRecovered pins the same contract for a
|
|
|
|
|
// body that does not decode as JSON-RPC.
|
|
|
|
|
func TestMalformedJSONBodyAnsweredAndRecovered(t *testing.T) {
|
|
|
|
|
body := "{not json"
|
|
|
|
|
in := fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(body), body) +
|
|
|
|
|
frame(1, "initialize", map[string]any{}) + frame(nil, "exit", nil)
|
|
|
|
|
msgs := run(t, in)
|
|
|
|
|
if n := countParseErrors(msgs); n != 1 {
|
|
|
|
|
t.Fatalf("want exactly one -32700 response, got %d", n)
|
|
|
|
|
}
|
|
|
|
|
if findByID(msgs, 1) == nil {
|
|
|
|
|
t.Fatal("server did not serve the request after the malformed body")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func countParseErrors(msgs []rpcMessage) int {
|
|
|
|
|
n := 0
|
|
|
|
|
for i := range msgs {
|
|
|
|
|
if msgs[i].Error != nil && msgs[i].Error.Code == errParse {
|
|
|
|
|
n++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return n
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestNullResultPresent pins the response envelope: JSON-RPC 2.0 requires a
|
|
|
|
|
// result member even when its value is null (shutdown, hover over nothing),
|
|
|
|
|
// while notifications must not grow one.
|
|
|
|
|
func TestNullResultPresent(t *testing.T) {
|
|
|
|
|
var out bytes.Buffer
|
|
|
|
|
in := frame(1, "shutdown", nil) + frame(nil, "exit", nil)
|
|
|
|
|
srv := New(strings.NewReader(in), &out)
|
|
|
|
|
if err := srv.Run(); err != nil {
|
|
|
|
|
t.Fatalf("server run: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(out.String(), `"result":null`) {
|
|
|
|
|
t.Fatalf("shutdown response must serialise an explicit null result, got %q", out.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestHoverNullResultPresent checks the same envelope on a handler whose
|
|
|
|
|
// result is genuinely null: hover over a position with no symbol.
|
|
|
|
|
func TestHoverNullResultPresent(t *testing.T) {
|
|
|
|
|
var out bytes.Buffer
|
|
|
|
|
in := session("file:///f_amd64.s", cleanDoc) +
|
|
|
|
|
frame(3, "textDocument/hover", map[string]any{
|
|
|
|
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
|
|
|
|
"position": map[string]any{"line": 0, "character": 0},
|
|
|
|
|
}) + frame(nil, "exit", nil)
|
|
|
|
|
srv := New(strings.NewReader(in), &out)
|
|
|
|
|
if err := srv.Run(); err != nil {
|
|
|
|
|
t.Fatalf("server run: %v", err)
|
|
|
|
|
}
|
|
|
|
|
raw := out.String()
|
|
|
|
|
if !strings.Contains(raw, `"result":null`) {
|
|
|
|
|
t.Fatalf("null hover result must serialise as an explicit null, got %q", raw)
|
|
|
|
|
}
|
|
|
|
|
// The publishDiagnostics notification must not carry a result member.
|
|
|
|
|
for _, m := range readFrames(t, strings.NewReader(raw)) {
|
|
|
|
|
if m.Method == "textDocument/publishDiagnostics" && m.Result != nil {
|
|
|
|
|
t.Fatalf("notification grew a result member: %+v", m)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// applyEdits applies text edits whose character columns are rune columns
|
|
|
|
|
// (the middle dot makes byte and rune offsets diverge).
|
|
|
|
|
func applyEdits(text string, edits []TextEdit) string {
|
|
|
|
|
lines := strings.Split(text, "\n")
|
|
|
|
|
for _, e := range edits {
|
|
|
|
|
s, en := e.Range.Start, e.Range.End
|
|
|
|
|
if s.Line != en.Line || s.Line >= len(lines) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
runes := []rune(lines[s.Line])
|
|
|
|
|
if s.Character > len(runes) || en.Character > len(runes) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
lines[s.Line] = string(runes[:s.Character]) + e.NewText + string(runes[en.Character:])
|
|
|
|
|
}
|
|
|
|
|
return strings.Join(lines, "\n")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestRenameMiddleDotCoversFullToken pins the reference ranges: a symbol
|
|
|
|
|
// reference carries the `·` prefix, so every edit must span the whole raw
|
|
|
|
|
// token or the rename corrupts the source (`·helpe` + `r` shapes).
|
|
|
|
|
func TestRenameMiddleDotCoversFullToken(t *testing.T) {
|
|
|
|
|
doc := "#include \"textflag.h\"\n" +
|
|
|
|
|
"TEXT ·helper(SB), NOSPLIT, $0\n" +
|
|
|
|
|
"\tRET\n" +
|
|
|
|
|
"TEXT ·caller(SB), NOSPLIT, $0\n" +
|
|
|
|
|
"\tCALL ·helper(SB)\n" +
|
|
|
|
|
"\tRET\n"
|
|
|
|
|
in := session("file:///f_amd64.s", doc) +
|
|
|
|
|
frame(7, "textDocument/rename", map[string]any{
|
|
|
|
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
|
|
|
|
"position": map[string]any{"line": 4, "character": 7}, // on ·helper in CALL
|
|
|
|
|
"newName": "renamed",
|
|
|
|
|
}) + frame(nil, "exit", nil)
|
|
|
|
|
msgs := run(t, in)
|
|
|
|
|
resp := findByID(msgs, 7)
|
|
|
|
|
if resp == nil {
|
|
|
|
|
t.Fatal("no rename response")
|
|
|
|
|
}
|
|
|
|
|
var edit WorkspaceEdit
|
|
|
|
|
if err := json.Unmarshal(mustResult(t, resp), &edit); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
edits := edit.Changes["file:///f_amd64.s"]
|
|
|
|
|
if len(edits) != 2 {
|
|
|
|
|
t.Fatalf("want 2 edits (definition + call), got %d", len(edits))
|
|
|
|
|
}
|
|
|
|
|
for _, e := range edits {
|
|
|
|
|
if got := e.Range.End.Character - e.Range.Start.Character; got != runeLen("·helper") {
|
|
|
|
|
t.Errorf("edit range covers %d characters, want the full %d of ·helper", got, runeLen("·helper"))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
got := applyEdits(doc, edits)
|
|
|
|
|
if strings.Contains(got, "·helper") || strings.Contains(got, "·renamed") {
|
|
|
|
|
t.Fatalf("rename left a partial spelling behind: %q", got)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(got, "TEXT renamed(SB)") || !strings.Contains(got, "CALL renamed(SB)") {
|
|
|
|
|
t.Fatalf("rename did not rewrite both sites: %q", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestDocumentHighlightsMiddleDot pins the trim the handler was missing:
|
|
|
|
|
// the word under the cursor keeps its `·` prefix while symbol names are
|
|
|
|
|
// stored without it, so the untrimmed comparison returned nothing.
|
|
|
|
|
func TestDocumentHighlightsMiddleDot(t *testing.T) {
|
|
|
|
|
doc := "#include \"textflag.h\"\n" +
|
|
|
|
|
"TEXT ·helper(SB), NOSPLIT, $0\n" +
|
|
|
|
|
"\tRET\n" +
|
|
|
|
|
"TEXT ·caller(SB), NOSPLIT, $0\n" +
|
|
|
|
|
"\tCALL ·helper(SB)\n" +
|
|
|
|
|
"\tRET\n"
|
|
|
|
|
in := session("file:///f_amd64.s", doc) +
|
|
|
|
|
frame(12, "textDocument/documentHighlight", map[string]any{
|
|
|
|
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
|
|
|
|
"position": map[string]any{"line": 4, "character": 7},
|
|
|
|
|
}) + frame(nil, "exit", nil)
|
|
|
|
|
msgs := run(t, in)
|
|
|
|
|
resp := findByID(msgs, 12)
|
|
|
|
|
if resp == nil {
|
|
|
|
|
t.Fatal("no documentHighlight response")
|
|
|
|
|
}
|
|
|
|
|
var highlights []DocumentHighlight
|
|
|
|
|
if err := json.Unmarshal(mustResult(t, resp), &highlights); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(highlights) != 2 {
|
|
|
|
|
t.Fatalf("want the definition and the call highlighted, got %d", len(highlights))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestSymbolsSkipMalformedTextPlaceholder checks both symbol listings
|
|
|
|
|
// against a mid-edit buffer whose first TEXT line has no symbol yet: the
|
|
|
|
|
// placeholder is skipped and the remaining symbols are still served.
|
|
|
|
|
func TestSymbolsSkipMalformedTextPlaceholder(t *testing.T) {
|
|
|
|
|
doc := "TEXT $0\n" +
|
|
|
|
|
"\tMOVQ AX, BX\n" +
|
|
|
|
|
"TEXT ·ok(SB), NOSPLIT, $0\n" +
|
|
|
|
|
"\tRET\n"
|
|
|
|
|
in := session("file:///f_amd64.s", doc) +
|
|
|
|
|
frame(4, "textDocument/documentSymbol", map[string]any{
|
|
|
|
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
|
|
|
|
}) +
|
|
|
|
|
frame(13, "workspace/symbol", map[string]any{"query": "ok"}) +
|
|
|
|
|
frame(nil, "exit", nil)
|
|
|
|
|
msgs := run(t, in)
|
|
|
|
|
|
|
|
|
|
dresp := findByID(msgs, 4)
|
|
|
|
|
if dresp == nil || dresp.Error != nil {
|
|
|
|
|
t.Fatalf("documentSymbol failed on a mid-edit buffer: %+v", dresp)
|
|
|
|
|
}
|
|
|
|
|
var syms []DocumentSymbol
|
|
|
|
|
if err := json.Unmarshal(mustResult(t, dresp), &syms); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(syms) != 1 || syms[0].Name != "ok" {
|
|
|
|
|
t.Fatalf("want only the ·ok function, got %+v", syms)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
wresp := findByID(msgs, 13)
|
|
|
|
|
if wresp == nil || wresp.Error != nil {
|
|
|
|
|
t.Fatalf("workspace/symbol failed on a mid-edit buffer: %+v", wresp)
|
|
|
|
|
}
|
|
|
|
|
var wsyms []WorkspaceSymbol
|
|
|
|
|
if err := json.Unmarshal(mustResult(t, wresp), &wsyms); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(wsyms) != 1 || wsyms[0].Name != "ok" {
|
|
|
|
|
t.Fatalf("want only the ·ok symbol, got %+v", wsyms)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestUTF16PositionEncoding exercises the boundary conversions directly:
|
|
|
|
|
// one astral rune is one rune but two UTF-16 code units.
|
|
|
|
|
func TestUTF16PositionEncoding(t *testing.T) {
|
|
|
|
|
const line = "\U0001F389ab" // emoji, then two ASCII letters
|
|
|
|
|
if got := utf16Len(line); got != 4 {
|
|
|
|
|
t.Errorf("utf16Len = %d, want 4", got)
|
|
|
|
|
}
|
|
|
|
|
if got := utf16Column(line, 3); got != 4 {
|
|
|
|
|
t.Errorf("utf16Column(col 3) = %d, want 4", got)
|
|
|
|
|
}
|
|
|
|
|
if got := runeColumn(line, 4); got != 3 {
|
|
|
|
|
t.Errorf("runeColumn(units 4) = %d, want 3", got)
|
|
|
|
|
}
|
|
|
|
|
rng := clientRange("x\n"+line, Range{
|
|
|
|
|
Start: Position{Line: 1, Character: 1},
|
|
|
|
|
End: Position{Line: 1, Character: 3},
|
|
|
|
|
})
|
|
|
|
|
if rng.Start.Character != 2 || rng.End.Character != 4 {
|
|
|
|
|
t.Errorf("clientRange = %+v, want 2..4 UTF-16 units", rng)
|
|
|
|
|
}
|
|
|
|
|
// wordAt takes UTF-16 units from the client and lands on the right rune.
|
|
|
|
|
w, wr := wordAt("loop: // \U0001F389 x\n", Position{Line: 0, Character: 12})
|
|
|
|
|
if w != "x" {
|
|
|
|
|
t.Fatalf("wordAt = %q, want x", w)
|
|
|
|
|
}
|
|
|
|
|
if wr.Start.Character != 11 || wr.End.Character != 12 {
|
|
|
|
|
t.Errorf("wordAt range = %+v, want 11..12 (rune columns are returned unconverted)", wr)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestSemanticTokensAstralCommentLength checks that an astral rune in a
|
|
|
|
|
// comment is counted as two UTF-16 code units in the token length.
|
|
|
|
|
func TestSemanticTokensAstralCommentLength(t *testing.T) {
|
|
|
|
|
comment := "// \U0001F389 ok"
|
|
|
|
|
doc := "#include \"textflag.h\"\nTEXT ·f(SB), NOSPLIT, $0\n\tRET " + comment + "\n"
|
|
|
|
|
in := session("file:///f_amd64.s", doc) +
|
|
|
|
|
frame(5, "textDocument/semanticTokens/full", map[string]any{
|
|
|
|
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
|
|
|
|
}) + frame(nil, "exit", nil)
|
|
|
|
|
msgs := run(t, in)
|
|
|
|
|
resp := findByID(msgs, 5)
|
|
|
|
|
if resp == nil {
|
|
|
|
|
t.Fatal("no semanticTokens response")
|
|
|
|
|
}
|
|
|
|
|
var st SemanticTokens
|
|
|
|
|
if err := json.Unmarshal(mustResult(t, resp), &st); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
// Decode the delta stream: the RET line carries the comment token.
|
|
|
|
|
line, char := 0, 0
|
|
|
|
|
found := false
|
|
|
|
|
for i := 0; i+4 < len(st.Data); i += 5 {
|
|
|
|
|
if st.Data[i] != 0 {
|
|
|
|
|
line += st.Data[i]
|
|
|
|
|
char = st.Data[i+1]
|
|
|
|
|
} else {
|
|
|
|
|
char += st.Data[i+1]
|
|
|
|
|
}
|
|
|
|
|
if line == 2 && st.Data[i+3] == stComment {
|
|
|
|
|
found = true
|
|
|
|
|
if st.Data[i+2] != utf16Len(comment) {
|
|
|
|
|
t.Errorf("comment token length = %d, want %d UTF-16 code units", st.Data[i+2], utf16Len(comment))
|
|
|
|
|
}
|
|
|
|
|
if want := utf16Len("\tRET "); char != want {
|
|
|
|
|
t.Errorf("comment token char = %d, want %d UTF-16 code units", char, want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !found {
|
|
|
|
|
t.Fatalf("no comment token on the RET line: %v", st.Data)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestSemanticTokensPipeOperator checks that the `|` joining TEXT flags is
|
|
|
|
|
// classified as an operator instead of falling out of the switch unstyled.
|
|
|
|
|
func TestSemanticTokensPipeOperator(t *testing.T) {
|
|
|
|
|
doc := "#include \"textflag.h\"\nTEXT ·f(SB), NOSPLIT|DUPOK, $0\n\tRET\n"
|
|
|
|
|
in := session("file:///f_amd64.s", doc) +
|
|
|
|
|
frame(5, "textDocument/semanticTokens/full", map[string]any{
|
|
|
|
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
|
|
|
|
}) + frame(nil, "exit", nil)
|
|
|
|
|
msgs := run(t, in)
|
|
|
|
|
resp := findByID(msgs, 5)
|
|
|
|
|
if resp == nil {
|
|
|
|
|
t.Fatal("no semanticTokens response")
|
|
|
|
|
}
|
|
|
|
|
var st SemanticTokens
|
|
|
|
|
if err := json.Unmarshal(mustResult(t, resp), &st); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
// Decode the delta stream and look for a one-character operator token
|
|
|
|
|
// on the TEXT line.
|
|
|
|
|
line, char := 0, 0
|
|
|
|
|
found := false
|
|
|
|
|
for i := 0; i+4 < len(st.Data); i += 5 {
|
|
|
|
|
if st.Data[i] != 0 {
|
|
|
|
|
line += st.Data[i]
|
|
|
|
|
char = st.Data[i+1]
|
|
|
|
|
} else {
|
|
|
|
|
char += st.Data[i+1]
|
|
|
|
|
}
|
|
|
|
|
if line == 1 && st.Data[i+2] == 1 && st.Data[i+3] == stOperator {
|
|
|
|
|
found = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !found {
|
|
|
|
|
t.Fatalf("no operator token found for the | in the flag list: %v", st.Data)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 09:49:50 +02:00
|
|
|
// mustResult re-marshals a response result into raw JSON for typed decoding.
|
|
|
|
|
func mustResult(t *testing.T, m *rpcMessage) []byte {
|
|
|
|
|
t.Helper()
|
|
|
|
|
b, err := json.Marshal(m.Result)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
return b
|
|
|
|
|
}
|
2026-08-30 21:30:22 +02:00
|
|
|
|
|
|
|
|
func TestPullDiagnostics(t *testing.T) {
|
|
|
|
|
in := session("file:///f_amd64.s", badDoc) +
|
|
|
|
|
frame(20, "textDocument/diagnostic", map[string]any{
|
|
|
|
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
|
|
|
|
}) + frame(nil, "exit", nil)
|
|
|
|
|
msgs := run(t, in)
|
|
|
|
|
resp := findByID(msgs, 20)
|
|
|
|
|
if resp == nil {
|
|
|
|
|
t.Fatal("no diagnostic response")
|
|
|
|
|
}
|
|
|
|
|
var report documentDiagnosticReport
|
|
|
|
|
if err := json.Unmarshal(mustResult(t, resp), &report); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if report.Kind != "full" {
|
|
|
|
|
t.Errorf("report kind = %q, want full", report.Kind)
|
|
|
|
|
}
|
|
|
|
|
found := false
|
|
|
|
|
for _, d := range report.Items {
|
|
|
|
|
if d.Code == "unknown-instruction" {
|
|
|
|
|
found = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !found {
|
|
|
|
|
t.Errorf("want an unknown-instruction diagnostic, got %+v", report.Items)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestDocumentLinks(t *testing.T) {
|
|
|
|
|
in := session("file:///f_amd64.s", cleanDoc) +
|
|
|
|
|
frame(21, "textDocument/documentLink", map[string]any{
|
|
|
|
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
|
|
|
|
}) + frame(nil, "exit", nil)
|
|
|
|
|
msgs := run(t, in)
|
|
|
|
|
resp := findByID(msgs, 21)
|
|
|
|
|
if resp == nil {
|
|
|
|
|
t.Fatal("no documentLink response")
|
|
|
|
|
}
|
|
|
|
|
var links []DocumentLink
|
|
|
|
|
if err := json.Unmarshal(mustResult(t, resp), &links); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(links) != 1 {
|
|
|
|
|
t.Fatalf("want 1 document link, got %d", len(links))
|
|
|
|
|
}
|
|
|
|
|
if !strings.HasSuffix(links[0].Target, "pkg/include/textflag.h") {
|
|
|
|
|
t.Errorf("link target = %q, want the GOROOT pkg/include header", links[0].Target)
|
|
|
|
|
}
|
|
|
|
|
if links[0].Range.Start.Line != 0 || links[0].Range.Start.Character != 9 {
|
|
|
|
|
t.Errorf("link range start = %+v, want line 0 character 9", links[0].Range.Start)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestFoldingRanges(t *testing.T) {
|
|
|
|
|
in := session("file:///f_amd64.s", cleanDoc) +
|
|
|
|
|
frame(22, "textDocument/foldingRange", map[string]any{
|
|
|
|
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
|
|
|
|
}) + frame(nil, "exit", nil)
|
|
|
|
|
msgs := run(t, in)
|
|
|
|
|
resp := findByID(msgs, 22)
|
|
|
|
|
if resp == nil {
|
|
|
|
|
t.Fatal("no foldingRange response")
|
|
|
|
|
}
|
|
|
|
|
var ranges []FoldingRange
|
|
|
|
|
if err := json.Unmarshal(mustResult(t, resp), &ranges); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(ranges) != 1 {
|
|
|
|
|
t.Fatalf("want 1 folding range, got %d", len(ranges))
|
|
|
|
|
}
|
|
|
|
|
if ranges[0].StartLine != 1 || ranges[0].EndLine != 5 {
|
|
|
|
|
t.Errorf("folding range = %d-%d, want 1-5", ranges[0].StartLine, ranges[0].EndLine)
|
|
|
|
|
}
|
|
|
|
|
if ranges[0].Kind != "region" {
|
|
|
|
|
t.Errorf("folding kind = %q, want region", ranges[0].Kind)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-14 18:22:00 +02:00
|
|
|
|
|
|
|
|
// TestInitializeVersion checks the version reported in the initialize
|
|
|
|
|
// result when the caller stamps one.
|
|
|
|
|
func TestInitializeVersion(t *testing.T) {
|
|
|
|
|
var out bytes.Buffer
|
|
|
|
|
srv := New(strings.NewReader(frame(1, "initialize", map[string]any{})+frame(nil, "exit", nil)), &out)
|
|
|
|
|
srv.SetVersion("9.9.9")
|
|
|
|
|
if err := srv.Run(); err != nil {
|
|
|
|
|
t.Fatalf("server run: %v", err)
|
|
|
|
|
}
|
|
|
|
|
msgs := readFrames(t, &out)
|
|
|
|
|
resp := findByID(msgs, 1)
|
|
|
|
|
if resp == nil {
|
|
|
|
|
t.Fatal("no initialize response")
|
|
|
|
|
}
|
|
|
|
|
var res initializeResult
|
|
|
|
|
if err := json.Unmarshal(mustResult(t, resp), &res); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if res.ServerInfo["version"] != "9.9.9" {
|
|
|
|
|
t.Errorf("serverInfo = %v, want version 9.9.9", res.ServerInfo)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestSyntaxDiagnosticsPublished checks that parse errors reach the editor
|
|
|
|
|
// as error-severity diagnostics with the syntax code.
|
|
|
|
|
func TestSyntaxDiagnosticsPublished(t *testing.T) {
|
|
|
|
|
msgs := run(t, session("file:///f_amd64.s", "TEXT $\n")+frame(nil, "exit", nil))
|
|
|
|
|
pub := findMethod(msgs, "textDocument/publishDiagnostics")
|
|
|
|
|
if pub == nil {
|
|
|
|
|
t.Fatal("no publishDiagnostics notification")
|
|
|
|
|
}
|
|
|
|
|
var p publishDiagnosticsParams
|
|
|
|
|
json.Unmarshal(pub.Params, &p)
|
|
|
|
|
found := false
|
|
|
|
|
for _, d := range p.Diagnostics {
|
|
|
|
|
if d.Code == "syntax" && d.Severity == sevError {
|
|
|
|
|
found = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !found {
|
|
|
|
|
t.Fatalf("expected a syntax error diagnostic, got %+v", p.Diagnostics)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestDispatchRecoversFromPanic exercises the per-message recover: a panic
|
|
|
|
|
// inside a handler is answered as an internal error instead of taking the
|
|
|
|
|
// server down.
|
|
|
|
|
func TestDispatchRecoversFromPanic(t *testing.T) {
|
|
|
|
|
var out bytes.Buffer
|
|
|
|
|
srv := New(strings.NewReader(""), &out)
|
|
|
|
|
srv.docs = nil // force a nil-map write inside didOpen
|
|
|
|
|
raw := json.RawMessage(`{"textDocument":{"uri":"file:///x.s","text":"RET"}}`)
|
|
|
|
|
srv.dispatch(&rpcMessage{ID: rawID(t, 7), Method: "textDocument/didOpen", Params: raw})
|
|
|
|
|
msgs := readFrames(t, &out)
|
|
|
|
|
resp := findByID(msgs, 7)
|
|
|
|
|
if resp == nil {
|
|
|
|
|
t.Fatal("no error response after panic")
|
|
|
|
|
}
|
|
|
|
|
if resp.Error == nil || resp.Error.Code != errInternalError {
|
|
|
|
|
t.Fatalf("error = %+v, want internal error", resp.Error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func rawID(t *testing.T, n int) *json.RawMessage {
|
|
|
|
|
t.Helper()
|
|
|
|
|
b, err := json.Marshal(n)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
raw := json.RawMessage(b)
|
|
|
|
|
return &raw
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestURIDecoding pins the percent-decoding of file URIs: clients encode
|
|
|
|
|
// non-ASCII paths, and the decoded form is what resolves on disk.
|
|
|
|
|
func TestURIDecoding(t *testing.T) {
|
|
|
|
|
got := uriPath("file:///home/petrbalvin/Repozit%C3%A1%C5%99e/k.s")
|
|
|
|
|
if got != "/home/petrbalvin/Repozitáře/k.s" {
|
|
|
|
|
t.Errorf("uriPath = %q", got)
|
|
|
|
|
}
|
|
|
|
|
if got := uriPath("/plain/path.s"); got != "/plain/path.s" {
|
|
|
|
|
t.Errorf("uriPath plain = %q", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestCodeActionsTargetsFlaggedFunctionOnly checks that a missing-ret
|
|
|
|
|
// diagnostic offers an edit for the flagged function only, even when the
|
|
|
|
|
// file defines several functions.
|
|
|
|
|
func TestCodeActionsTargetsFlaggedFunctionOnly(t *testing.T) {
|
|
|
|
|
doc := "TEXT \u00b7first(SB), NOSPLIT, $0\n" +
|
|
|
|
|
"\tMOVQ AX, CX\n" +
|
|
|
|
|
"\tRET\n" +
|
|
|
|
|
"TEXT \u00b7second(SB), NOSPLIT, $0\n" +
|
|
|
|
|
"\tMOVQ AX, CX\n"
|
|
|
|
|
in := session("file:///f_amd64.s", doc) +
|
|
|
|
|
frame(11, "textDocument/codeAction", map[string]any{
|
|
|
|
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
|
|
|
|
"range": map[string]any{"start": map[string]any{"line": 0, "character": 0}, "end": map[string]any{"line": 4, "character": 0}},
|
|
|
|
|
"context": map[string]any{"diagnostics": []map[string]any{{"code": "missing-ret", "range": map[string]any{"start": map[string]any{"line": 3, "character": 0}, "end": map[string]any{"line": 3, "character": 4}}}}},
|
|
|
|
|
}) + frame(nil, "exit", nil)
|
|
|
|
|
msgs := run(t, in)
|
|
|
|
|
resp := findByID(msgs, 11)
|
|
|
|
|
if resp == nil {
|
|
|
|
|
t.Fatal("no codeAction response")
|
|
|
|
|
}
|
|
|
|
|
var actions []CodeAction
|
|
|
|
|
if err := json.Unmarshal(mustResult(t, resp), &actions); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if len(actions) != 1 {
|
|
|
|
|
t.Fatalf("actions = %d, want 1", len(actions))
|
|
|
|
|
}
|
|
|
|
|
if actions[0].Title != "Add RET to second" {
|
|
|
|
|
t.Errorf("action title = %q, want Add RET to second", actions[0].Title)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(actions[0].Edit.Changes["file:///f_amd64.s"][0].NewText, "\tRET\nTEXT \u00b7second") {
|
|
|
|
|
t.Errorf("edit does not insert RET at the end of second:\n%s", actions[0].Edit.Changes["file:///f_amd64.s"][0].NewText)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-14 18:50:55 +02:00
|
|
|
|
|
|
|
|
// TestCrossFileDefinitionAndReferences opens two documents: docA calls
|
|
|
|
|
// ·helper(SB), docB defines it. Definition must jump to docB and references
|
|
|
|
|
// must collect the call site in docA plus the definition in docB.
|
|
|
|
|
func TestCrossFileDefinitionAndReferences(t *testing.T) {
|
|
|
|
|
docA := "TEXT \u00b7caller(SB), NOSPLIT, $0\n" +
|
|
|
|
|
"\tCALL \u00b7helper(SB)\n" +
|
|
|
|
|
"\tRET\n"
|
|
|
|
|
docB := "TEXT \u00b7helper(SB), NOSPLIT, $0\n" +
|
|
|
|
|
"\tRET\n"
|
|
|
|
|
in := frame(1, "initialize", map[string]any{}) +
|
|
|
|
|
frame(nil, "initialized", map[string]any{}) +
|
|
|
|
|
frame(nil, "textDocument/didOpen", map[string]any{
|
|
|
|
|
"textDocument": map[string]any{"uri": "file:///a_amd64.s", "languageId": "gasm", "version": 1, "text": docA},
|
|
|
|
|
}) +
|
|
|
|
|
frame(nil, "textDocument/didOpen", map[string]any{
|
|
|
|
|
"textDocument": map[string]any{"uri": "file:///b_amd64.s", "languageId": "gasm", "version": 1, "text": docB},
|
|
|
|
|
}) +
|
|
|
|
|
frame(2, "textDocument/definition", map[string]any{
|
|
|
|
|
"textDocument": map[string]any{"uri": "file:///a_amd64.s"},
|
|
|
|
|
"position": map[string]any{"line": 1, "character": 8}, // on helper in CALL ·helper(SB)
|
|
|
|
|
}) +
|
|
|
|
|
frame(3, "textDocument/references", map[string]any{
|
|
|
|
|
"textDocument": map[string]any{"uri": "file:///b_amd64.s"},
|
|
|
|
|
"position": map[string]any{"line": 0, "character": 7}, // on helper in TEXT ·helper(SB)
|
|
|
|
|
"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 != "file:///b_amd64.s" || locs[0].Range.Start.Line != 0 {
|
|
|
|
|
t.Fatalf("definition = %+v, want the TEXT in b_amd64.s line 0", 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 definition in b_amd64.s and the call in a_amd64.s", locs)
|
|
|
|
|
}
|
|
|
|
|
byURI := map[string]int{}
|
|
|
|
|
for _, l := range locs {
|
|
|
|
|
byURI[l.URI]++
|
|
|
|
|
}
|
|
|
|
|
if byURI["file:///a_amd64.s"] != 1 || byURI["file:///b_amd64.s"] != 1 {
|
|
|
|
|
t.Errorf("references by uri = %v, want one in each file", byURI)
|
|
|
|
|
}
|
|
|
|
|
}
|