From b3908fc43d372f950d607cf133dcd7cb44d85208 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Sat, 19 Sep 2026 23:49:19 +0200 Subject: [PATCH] fix(lsp): parse-error survival, symbol ranges and UTF-16 positions Assisted-by: GLM 5.3 --- lsp/handlers.go | 234 ++++++++++++++++++++++++-------- lsp/protocol.go | 1 + lsp/server.go | 47 ++++++- lsp/server_test.go | 322 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 547 insertions(+), 57 deletions(-) diff --git a/lsp/handlers.go b/lsp/handlers.go index 5babca6..5e8ee0e 100644 --- a/lsp/handlers.go +++ b/lsp/handlers.go @@ -4,14 +4,17 @@ package lsp import ( + "cmp" "fmt" "os" "path/filepath" "regexp" "runtime" - "sort" + "slices" "strings" "unicode" + "unicode/utf16" + "unicode/utf8" "sourcedock.dev/petrbalvin/gasm-devkit/arch" "sourcedock.dev/petrbalvin/gasm-devkit/ast" @@ -67,7 +70,7 @@ func (s *Server) completion(p completionParams) []CompletionItem { items = append(items, CompletionItem{Label: name, Kind: ciModule, Detail: "local label"}) } } - sort.Slice(items, func(i, j int) bool { return items[i].Label < items[j].Label }) + slices.SortFunc(items, func(a, b CompletionItem) int { return cmp.Compare(a.Label, b.Label) }) return items } @@ -94,7 +97,7 @@ func (s *Server) hover(p hoverParams) *Hover { } return &Hover{ Contents: markupContent{Kind: "markdown", Value: md}, - Range: rng, + Range: clientRange(text, rng), } } @@ -112,7 +115,7 @@ func (s *Server) openASTs() []openAST { for uri := range s.docs { uris = append(uris, uri) } - sort.Strings(uris) + slices.Sort(uris) out := make([]openAST, 0, len(uris)) for _, uri := range uris { if f, _ := parser.Parse(uriPath(uri), s.docs[uri]); f != nil { @@ -143,11 +146,8 @@ func (s *Server) definition(p definitionParams) []Location { if lbl, ok := stmt.(*ast.Label); ok { if lbl.Name.Text == name || lbl.Name.Text == word { return []Location{{ - URI: p.TextDocument.URI, - Range: Range{ - Start: Position{Line: lbl.Name.Pos.Line - 1, Character: lbl.Name.Pos.Column - 1}, - End: Position{Line: lbl.Name.Pos.Line - 1, Character: lbl.Name.Pos.Column - 1 + len(word)}, - }, + URI: p.TextDocument.URI, + Range: clientRange(text, tokenRange(lbl.Name)), }} } } @@ -160,11 +160,11 @@ func (s *Server) definition(p definitionParams) []Location { for _, of := range s.openASTs() { for _, d := range of.file.Decls { t, ok := d.(*ast.Text) - if !ok || t.Name == nil { + if !ok || !realSymbol(t.Name) { continue } if t.Name.Name == name { - return []Location{{URI: of.uri, Range: symRange(t.Name)}} + return []Location{{URI: of.uri, Range: clientRange(s.docs[of.uri], symRange(t.Name))}} } } } @@ -190,18 +190,18 @@ func (s *Server) references(p referenceParams) []Location { sameDoc := of.uri == uri for _, d := range of.file.Decls { t, ok := d.(*ast.Text) - if !ok || t.Name == nil { + 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: symRange(t.Name)}) + out = append(out, Location{URI: of.uri, Range: clientRange(s.docs[of.uri], 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: tokenRange(st.Name)}) + out = append(out, Location{URI: of.uri, Range: clientRange(s.docs[of.uri], tokenRange(st.Name))}) } case *ast.Instr: for _, op := range st.Operands { @@ -211,12 +211,15 @@ func (s *Server) references(p referenceParams) []Location { if !sameDoc && op.Addr.Sym.Pseudo != "SB" { continue } + // The range covers the operand's verbatim + // identifier, `·`/package prefix included, so a + // rename replaces the whole spelling. out = append(out, Location{ URI: of.uri, - Range: Range{ + Range: clientRange(s.docs[of.uri], 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 + runeLen(name)}, - }, + End: Position{Line: op.Pos.Line - 1, Character: op.Pos.Column - 1 + symIdentLen(op.Addr.Sym)}, + }), }) } } @@ -265,7 +268,7 @@ func (s *Server) documentFormatting(p documentFormattingParams) []TextEdit { endLine := len(lines) - 1 endChar := 0 if endLine >= 0 { - endChar = len([]rune(lines[endLine])) + endChar = utf16Len(lines[endLine]) } return []TextEdit{{ Range: Range{Start: Position{Line: 0, Character: 0}, End: Position{Line: endLine, Character: endChar}}, @@ -289,9 +292,10 @@ func (s *Server) inlayHints(p inlayHintParams) []InlayHint { // Hint after the args size: show frame size. if t.Frame != nil && t.Frame.Imm.HasVal && t.Args != nil && t.Args.Imm.HasVal { // Place hint after the args operand using its raw text length. - col := t.Args.Pos.Column - 1 + len(t.Args.Raw) + line := t.Args.Pos.Line - 1 + col := t.Args.Pos.Column - 1 + runeLen(t.Args.Raw) out = append(out, InlayHint{ - Position: Position{Line: t.Args.Pos.Line - 1, Character: col}, + Position: Position{Line: line, Character: utf16Column(lineAt(text, line), col)}, Label: fmt.Sprintf(" frame=%d", t.Frame.Imm.Val), Kind: inlayHintTypeParameter, Tooltip: fmt.Sprintf("local frame size: %d bytes", t.Frame.Imm.Val), @@ -341,7 +345,7 @@ func (s *Server) codeActions(p codeActionParams) []CodeAction { newLines = append(newLines, lines[insertLine:]...) newText := strings.Join(newLines, "\n") endLine := len(lines) - 1 - endChar := len([]rune(lines[endLine])) + endChar := utf16Len(lines[endLine]) actions = append(actions, CodeAction{ Title: "Add RET to " + t.Name.Name, Kind: "quickfix", @@ -367,7 +371,7 @@ func (s *Server) codeActions(p codeActionParams) []CodeAction { newLines = append(newLines, lines[i+1:]...) newText := strings.Join(newLines, "\n") endLine := len(lines) - 1 - endChar := len([]rune(lines[endLine])) + endChar := utf16Len(lines[endLine]) actions = append(actions, CodeAction{ Title: "Remove unused label \"" + label + "\"", Kind: "quickfix", @@ -458,6 +462,10 @@ func (s *Server) documentHighlights(p documentHighlightParams) []DocumentHighlig if word == "" { return nil } + // Symbol names are stored without the middle dot, while the word under + // the cursor keeps it; trim once and compare the trimmed form, the way + // references and definition do. + name := strings.TrimPrefix(word, "\u00B7") f, errs := parser.Parse(uriPath(p.TextDocument.URI), text) if f == nil || len(errs) > 0 { return nil @@ -469,29 +477,29 @@ func (s *Server) documentHighlights(p documentHighlightParams) []DocumentHighlig continue } // Highlight the definition. - if t.Name.Name == word { + if t.Name != nil && t.Name.Name == name { out = append(out, DocumentHighlight{ - Range: symRange(t.Name), + Range: clientRange(text, symRange(t.Name)), Kind: highlightWrite, }) } for _, stmt := range t.Body { switch st := stmt.(type) { case *ast.Label: - if st.Name.Text == word { + if st.Name.Text == name { out = append(out, DocumentHighlight{ - Range: tokenRange(st.Name), + Range: clientRange(text, tokenRange(st.Name)), Kind: highlightWrite, }) } case *ast.Instr: for _, op := range st.Operands { - if op.Addr.Sym != nil && op.Addr.Sym.Name == word { + if op.Addr.Sym != nil && op.Addr.Sym.Name == name { out = append(out, DocumentHighlight{ - Range: Range{ + Range: clientRange(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 + runeLen(word)}, - }, + End: Position{Line: op.Pos.Line - 1, Character: op.Pos.Column - 1 + symIdentLen(op.Addr.Sym)}, + }), Kind: highlightRead, }) } @@ -517,11 +525,17 @@ func (s *Server) workspaceSymbols(p workspaceSymbolParams) []WorkspaceSymbol { for _, d := range f.Decls { switch dd := d.(type) { case *ast.Text: + // A malformed TEXT line is kept in the tree under a "?" + // placeholder; it is not a symbol, and skipping it keeps + // the answer serving the rest of a mid-edit buffer. + if !realSymbol(dd.Name) { + continue + } if strings.Contains(strings.ToLower(dd.Name.Name), query) { out = append(out, WorkspaceSymbol{ Name: dd.Name.Name, Kind: symFunction, - Location: Location{URI: uri, Range: symRange(dd.Name)}, + Location: Location{URI: uri, Range: clientRange(text, symRange(dd.Name))}, }) } case *ast.Globl: @@ -529,7 +543,7 @@ func (s *Server) workspaceSymbols(p workspaceSymbolParams) []WorkspaceSymbol { out = append(out, WorkspaceSymbol{ Name: dd.Name.Name, Kind: symConstant, - Location: Location{URI: uri, Range: symRange(dd.Name)}, + Location: Location{URI: uri, Range: clientRange(text, symRange(dd.Name))}, }) } case *ast.Data: @@ -537,7 +551,7 @@ func (s *Server) workspaceSymbols(p workspaceSymbolParams) []WorkspaceSymbol { out = append(out, WorkspaceSymbol{ Name: dd.Name.Name, Kind: symConstant, - Location: Location{URI: uri, Range: symRange(dd.Name)}, + Location: Location{URI: uri, Range: clientRange(text, symRange(dd.Name))}, }) } } @@ -557,33 +571,44 @@ func (s *Server) documentSymbols(p documentSymbolParams) []DocumentSymbol { for _, d := range f.Decls { switch dd := d.(type) { case *ast.Text: + // As in workspaceSymbols: a "?" placeholder is not a symbol, + // and the remaining declarations are still listed. + if !realSymbol(dd.Name) { + continue + } sym := DocumentSymbol{ Name: dd.Name.Name, Detail: "TEXT " + strings.Join(dd.Flags, " "), Kind: symFunction, - Range: textRange(dd), - SelectionRange: symRange(dd.Name), + Range: clientRange(text, textRange(dd)), + SelectionRange: clientRange(text, symRange(dd.Name)), } for _, st := range dd.Body { if l, ok := st.(*ast.Label); ok { sym.Children = append(sym.Children, DocumentSymbol{ Name: l.Name.Text, Kind: symVariable, - Range: tokenRange(l.Name), - SelectionRange: tokenRange(l.Name), + Range: clientRange(text, tokenRange(l.Name)), + SelectionRange: clientRange(text, tokenRange(l.Name)), }) } } out = append(out, sym) case *ast.Globl: + if dd.Name == nil { + continue + } out = append(out, DocumentSymbol{ Name: dd.Name.Name, Detail: "GLOBL", Kind: symConstant, - Range: symRange(dd.Name), SelectionRange: symRange(dd.Name), + Range: clientRange(text, symRange(dd.Name)), SelectionRange: clientRange(text, symRange(dd.Name)), }) case *ast.Data: + if dd.Name == nil { + continue + } out = append(out, DocumentSymbol{ Name: dd.Name.Name, Detail: "DATA", Kind: symConstant, - Range: symRange(dd.Name), SelectionRange: symRange(dd.Name), + Range: clientRange(text, symRange(dd.Name)), SelectionRange: clientRange(text, symRange(dd.Name)), }) } } @@ -607,10 +632,17 @@ func (s *Server) semanticTokens(p semanticTokensParams) SemanticTokens { toks := lexer.Tokenize(text) lines := groupLines(toks) + srcLines := strings.Split(text, "\n") var encoded []semTok for _, line := range lines { - encoded = append(encoded, classifyLine(line, a, labels)...) + for _, st := range classifyLine(line, a, labels) { + // Columns cross the protocol boundary in UTF-16 code units. + if st.line >= 0 && st.line < len(srcLines) { + st.char = utf16Column(srcLines[st.line], st.char) + } + encoded = append(encoded, st) + } } return SemanticTokens{Data: deltaEncode(encoded)} @@ -649,14 +681,15 @@ func classifyLine(line []token.Token, a *arch.Table, labels map[string]bool) []s typ = classifyIdent(i, first, t.Text, a, labels, isDirective, isLabel, isInstr, &mnemonicDone) case token.Colon, token.Comma, token.LParen, token.RParen, token.Plus, token.Minus, token.Star, token.Slash, token.Dollar, - token.LAngle, token.RAngle, token.LShift, token.RShift, token.Arrow, token.At: + token.LAngle, token.RAngle, token.LShift, token.RShift, token.Arrow, + token.At, token.Pipe: typ = stOperator } if typ >= 0 { out = append(out, semTok{ line: t.Pos.Line - 1, char: t.Pos.Column - 1, - length: runeLen(t.Text), + length: utf16Len(t.Text), typ: typ, }) } @@ -714,14 +747,16 @@ func deltaEncode(toks []semTok) []int { // --- shared helpers --------------------------------------------------------- -// wordAt extracts the identifier surrounding pos and its range. +// wordAt extracts the identifier surrounding pos and its range. The +// incoming character offset is UTF-16 code units, as LSP defines it, and is +// converted to the rune index the scanning works in. func wordAt(text string, pos Position) (string, Range) { lines := strings.Split(text, "\n") if pos.Line < 0 || pos.Line >= len(lines) { return "", Range{} } runes := []rune(lines[pos.Line]) - col := pos.Character + col := runeColumn(lines[pos.Line], pos.Character) if col < 0 || col > len(runes) { return "", Range{} } @@ -802,14 +837,102 @@ func firstSignificant(line []token.Token) int { func runeLen(s string) int { return len([]rune(s)) } -// symRange builds a range covering a symbol from its position and raw text. +// utf16Len returns the length of s in UTF-16 code units, the unit LSP +// positions count: an astral rune (an emoji in a comment) is two of them. +func utf16Len(s string) int { + n := 0 + for _, r := range s { + n += utf16.RuneLen(r) + } + return n +} + +// utf16Column converts a rune-based column on line into a UTF-16 code-unit +// offset. Rune columns are the lexer's convention; code units are the +// protocol's, and the two diverge once an astral rune precedes the column. +func utf16Column(line string, col int) int { + if col <= 0 { + return 0 + } + units := 0 + seen := 0 + for _, r := range line { + if seen >= col { + break + } + units += utf16.RuneLen(r) + seen++ + } + return units +} + +// runeColumn converts a UTF-16 code-unit offset on line into a rune column, +// the inverse of utf16Column, applied to positions arriving from the client. +func runeColumn(line string, units int) int { + if units <= 0 { + return 0 + } + col := 0 + u := 0 + for _, r := range line { + if u >= units { + break + } + u += utf16.RuneLen(r) + col++ + } + return col +} + +// lineAt returns the n-th zero-based line of text, or "" when out of range. +func lineAt(text string, n int) string { + if n < 0 { + return "" + } + lines := strings.Split(text, "\n") + if n >= len(lines) { + return "" + } + return lines[n] +} + +// clientRange re-encodes a rune-based range (the columns the lexer, parser +// and the helpers above produce) in the UTF-16 code units LSP mandates. +// Every range leaving the server passes through here. +func clientRange(text string, r Range) Range { + return Range{ + Start: Position{Line: r.Start.Line, Character: utf16Column(lineAt(text, r.Start.Line), r.Start.Character)}, + End: Position{Line: r.End.Line, Character: utf16Column(lineAt(text, r.End.Line), r.End.Character)}, + } +} + +// symIdentLen returns the rune length of a symbol's identifier as written: +// the verbatim spelling up to the ABI marker, offset or pseudo-register +// group, so `pkg·name(SB)` counts the package prefix and the +// middle dot. A range built from it covers the whole token a rename +// replaces; the stripped Name alone would stop one character short. +func symIdentLen(sym *ast.Symbol) int { + if i := strings.IndexAny(sym.Raw, "<+-("); i >= 0 { + return runeLen(sym.Raw[:i]) + } + return runeLen(sym.Raw) +} + +// symRange builds a range covering a symbol as written, prefix included. func symRange(sym *ast.Symbol) Range { start := Position{Line: sym.Pos.Line - 1, Character: sym.Pos.Column - 1} end := start - end.Character += runeLen(sym.Name) + end.Character += symIdentLen(sym) return Range{Start: start, End: end} } +// realSymbol reports whether sym names something a client can act on. The +// parser keeps a malformed TEXT in the tree under a "?" placeholder so the +// rest of the buffer stays servable; that placeholder is not a symbol. +func realSymbol(sym *ast.Symbol) bool { + return sym != nil && sym.Name != "?" +} + // tokenRange builds a range covering one token. func tokenRange(t token.Token) Range { return Range{ @@ -852,7 +975,7 @@ func (s *Server) diagnosticsFor(uri string) []Diagnostic { pos = pe.Pos } out = append(out, Diagnostic{ - Range: toRange(pos.Line, pos.Column, token.Position{}), + Range: clientRange(text, toRange(pos.Line, pos.Column, token.Position{})), Severity: sevError, Code: "syntax", Source: "gasm", @@ -861,7 +984,7 @@ func (s *Server) diagnosticsFor(uri string) []Diagnostic { } for _, d := range diags { out = append(out, Diagnostic{ - Range: toRange(d.Pos.Line, d.Pos.Column, d.End), + Range: clientRange(text, toRange(d.Pos.Line, d.Pos.Column, d.End)), Severity: lintSeverity(d.Severity), Code: d.Code, Source: "gasm", @@ -886,7 +1009,9 @@ func (s *Server) documentLinks(uri string) []DocumentLink { goroot := runtime.GOROOT() var out []DocumentLink - for i, line := range strings.Split(text, "\n") { + lineNo := -1 + for line := range strings.SplitSeq(text, "\n") { + lineNo++ m := includeRe.FindStringSubmatch(line) if m == nil { continue @@ -895,12 +1020,13 @@ func (s *Server) documentLinks(uri string) []DocumentLink { if target == "" { continue } - start := strings.Index(line, "\"") + // strings.Index is a byte offset; columns are runes. + start := utf8.RuneCountInString(line[:strings.Index(line, "\"")]) out = append(out, DocumentLink{ - Range: Range{ - Start: Position{Line: i, Character: start}, - End: Position{Line: i, Character: start + len(m[1]) + 2}, - }, + Range: clientRange(text, Range{ + Start: Position{Line: lineNo, Character: start}, + End: Position{Line: lineNo, Character: start + runeLen(m[1]) + 2}, + }), Target: "file://" + target, }) } diff --git a/lsp/protocol.go b/lsp/protocol.go index b5ab681..ccd0397 100644 --- a/lsp/protocol.go +++ b/lsp/protocol.go @@ -30,6 +30,7 @@ type rpcError struct { } const ( + errParse = -32700 errMethodNotFound = -32601 errInternalError = -32603 ) diff --git a/lsp/server.go b/lsp/server.go index f980272..e163bbb 100644 --- a/lsp/server.go +++ b/lsp/server.go @@ -6,6 +6,7 @@ package lsp import ( "bufio" "encoding/json" + "errors" "fmt" "io" "net/url" @@ -45,6 +46,11 @@ func (s *Server) Run() error { if err == io.EOF { return nil } + if errors.Is(err, errMalformed) { + // The message was already answered with a -32700 response; + // one malformed frame must not end the session. + continue + } if err != nil { return err } @@ -54,9 +60,20 @@ func (s *Server) Run() error { } } -// read parses one Content-Length framed JSON-RPC message. +// errMalformed marks a message that could not be framed or decoded. The +// server answers it with a -32700 parse error before returning it, so the +// read loop continues instead of propagating a process exit. +var errMalformed = errors.New("malformed JSON-RPC message") + +// read parses one Content-Length framed JSON-RPC message. A Content-Length +// value that does not parse, or a body that is not valid JSON, is answered +// with a -32700 response against the null id (as the JSON-RPC specification +// requires when the request itself cannot be decoded) and reported as +// errMalformed; the header block is consumed first so the next frame is +// read from its true start. func (s *Server) read() (*rpcMessage, error) { length := -1 + var headerErr string for { line, err := s.in.ReadString('\n') if err != nil { @@ -67,9 +84,18 @@ func (s *Server) read() (*rpcMessage, error) { break } if k, v, ok := strings.Cut(line, ":"); ok && strings.EqualFold(strings.TrimSpace(k), "Content-Length") { - length, _ = strconv.Atoi(strings.TrimSpace(v)) + n, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil || n < 0 { + headerErr = "invalid Content-Length header: " + strconv.Quote(strings.TrimSpace(v)) + continue + } + length = n } } + if headerErr != "" { + s.parseError(headerErr) + return nil, errMalformed + } if length < 0 { return nil, fmt.Errorf("missing Content-Length header") } @@ -79,7 +105,8 @@ func (s *Server) read() (*rpcMessage, error) { } var msg rpcMessage if err := json.Unmarshal(body, &msg); err != nil { - return nil, err + s.parseError("invalid JSON body: " + err.Error()) + return nil, errMalformed } return &msg, nil } @@ -101,6 +128,13 @@ func (s *Server) send(msg *rpcMessage) error { } func (s *Server) respond(id *json.RawMessage, result any) { + // JSON-RPC 2.0 requires the result member to be present even when its + // value is null (shutdown, a hover over nothing); a nil result is + // marshalled as an explicit null rather than dropped by omitempty. + // Notifications never take this path, so they grow no member. + if result == nil { + result = json.RawMessage("null") + } _ = s.send(&rpcMessage{ID: id, Result: result}) } @@ -108,6 +142,13 @@ func (s *Server) respondError(id *json.RawMessage, code int, msg string) { _ = s.send(&rpcMessage{ID: id, Error: &rpcError{Code: code, Message: msg}}) } +// parseError answers a -32700 parse error against the null id: when the +// request could not be decoded there is no id to echo back. +func (s *Server) parseError(msg string) { + id := json.RawMessage("null") + s.respondError(&id, errParse, msg) +} + func (s *Server) notify(method string, params any) { raw, _ := json.Marshal(params) _ = s.send(&rpcMessage{Method: method, Params: raw}) diff --git a/lsp/server_test.go b/lsp/server_test.go index e833792..eebcad1 100644 --- a/lsp/server_test.go +++ b/lsp/server_test.go @@ -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()