fix(lsp): parse-error survival, symbol ranges and UTF-16 positions
Assisted-by: GLM 5.3
This commit is contained in:
@@ -484,6 +484,328 @@ func TestMethodNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// mustResult re-marshals a response result into raw JSON for typed decoding.
|
||||
func mustResult(t *testing.T, m *rpcMessage) []byte {
|
||||
t.Helper()
|
||||
|
||||
Reference in New Issue
Block a user