fix(lsp): parse-error survival, symbol ranges and UTF-16 positions

Assisted-by: GLM 5.3
This commit is contained in:
2026-09-19 23:49:19 +02:00
parent eb8b0cd316
commit b3908fc43d
4 changed files with 547 additions and 57 deletions
+180 -54
View File
@@ -4,14 +4,17 @@
package lsp package lsp
import ( import (
"cmp"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime" "runtime"
"sort" "slices"
"strings" "strings"
"unicode" "unicode"
"unicode/utf16"
"unicode/utf8"
"sourcedock.dev/petrbalvin/gasm-devkit/arch" "sourcedock.dev/petrbalvin/gasm-devkit/arch"
"sourcedock.dev/petrbalvin/gasm-devkit/ast" "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"}) 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 return items
} }
@@ -94,7 +97,7 @@ func (s *Server) hover(p hoverParams) *Hover {
} }
return &Hover{ return &Hover{
Contents: markupContent{Kind: "markdown", Value: md}, 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 { for uri := range s.docs {
uris = append(uris, uri) uris = append(uris, uri)
} }
sort.Strings(uris) slices.Sort(uris)
out := make([]openAST, 0, len(uris)) out := make([]openAST, 0, len(uris))
for _, uri := range uris { for _, uri := range uris {
if f, _ := parser.Parse(uriPath(uri), s.docs[uri]); f != nil { 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, ok := stmt.(*ast.Label); ok {
if lbl.Name.Text == name || lbl.Name.Text == word { if lbl.Name.Text == name || lbl.Name.Text == word {
return []Location{{ return []Location{{
URI: p.TextDocument.URI, URI: p.TextDocument.URI,
Range: Range{ Range: clientRange(text, tokenRange(lbl.Name)),
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)},
},
}} }}
} }
} }
@@ -160,11 +160,11 @@ func (s *Server) definition(p definitionParams) []Location {
for _, of := range s.openASTs() { for _, of := range s.openASTs() {
for _, d := range of.file.Decls { for _, d := range of.file.Decls {
t, ok := d.(*ast.Text) t, ok := d.(*ast.Text)
if !ok || t.Name == nil { if !ok || !realSymbol(t.Name) {
continue continue
} }
if t.Name.Name == name { 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 sameDoc := of.uri == uri
for _, d := range of.file.Decls { for _, d := range of.file.Decls {
t, ok := d.(*ast.Text) t, ok := d.(*ast.Text)
if !ok || t.Name == nil { if !ok || !realSymbol(t.Name) {
continue continue
} }
// Include the definition if requested. // Include the definition if requested.
if p.Context.IncludeDeclaration && t.Name.Name == name { 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 { for _, stmt := range t.Body {
switch st := stmt.(type) { switch st := stmt.(type) {
case *ast.Label: case *ast.Label:
if sameDoc && st.Name.Text == name { 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: case *ast.Instr:
for _, op := range st.Operands { for _, op := range st.Operands {
@@ -211,12 +211,15 @@ func (s *Server) references(p referenceParams) []Location {
if !sameDoc && op.Addr.Sym.Pseudo != "SB" { if !sameDoc && op.Addr.Sym.Pseudo != "SB" {
continue continue
} }
// The range covers the operand's verbatim
// identifier, `·`/package prefix included, so a
// rename replaces the whole spelling.
out = append(out, Location{ out = append(out, Location{
URI: of.uri, URI: of.uri,
Range: Range{ Range: clientRange(s.docs[of.uri], Range{
Start: Position{Line: op.Pos.Line - 1, Character: op.Pos.Column - 1}, 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 endLine := len(lines) - 1
endChar := 0 endChar := 0
if endLine >= 0 { if endLine >= 0 {
endChar = len([]rune(lines[endLine])) endChar = utf16Len(lines[endLine])
} }
return []TextEdit{{ return []TextEdit{{
Range: Range{Start: Position{Line: 0, Character: 0}, End: Position{Line: endLine, Character: endChar}}, 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. // Hint after the args size: show frame size.
if t.Frame != nil && t.Frame.Imm.HasVal && t.Args != nil && t.Args.Imm.HasVal { 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. // 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{ 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), Label: fmt.Sprintf(" frame=%d", t.Frame.Imm.Val),
Kind: inlayHintTypeParameter, Kind: inlayHintTypeParameter,
Tooltip: fmt.Sprintf("local frame size: %d bytes", t.Frame.Imm.Val), 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:]...) newLines = append(newLines, lines[insertLine:]...)
newText := strings.Join(newLines, "\n") newText := strings.Join(newLines, "\n")
endLine := len(lines) - 1 endLine := len(lines) - 1
endChar := len([]rune(lines[endLine])) endChar := utf16Len(lines[endLine])
actions = append(actions, CodeAction{ actions = append(actions, CodeAction{
Title: "Add RET to " + t.Name.Name, Title: "Add RET to " + t.Name.Name,
Kind: "quickfix", Kind: "quickfix",
@@ -367,7 +371,7 @@ func (s *Server) codeActions(p codeActionParams) []CodeAction {
newLines = append(newLines, lines[i+1:]...) newLines = append(newLines, lines[i+1:]...)
newText := strings.Join(newLines, "\n") newText := strings.Join(newLines, "\n")
endLine := len(lines) - 1 endLine := len(lines) - 1
endChar := len([]rune(lines[endLine])) endChar := utf16Len(lines[endLine])
actions = append(actions, CodeAction{ actions = append(actions, CodeAction{
Title: "Remove unused label \"" + label + "\"", Title: "Remove unused label \"" + label + "\"",
Kind: "quickfix", Kind: "quickfix",
@@ -458,6 +462,10 @@ func (s *Server) documentHighlights(p documentHighlightParams) []DocumentHighlig
if word == "" { if word == "" {
return nil 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) f, errs := parser.Parse(uriPath(p.TextDocument.URI), text)
if f == nil || len(errs) > 0 { if f == nil || len(errs) > 0 {
return nil return nil
@@ -469,29 +477,29 @@ func (s *Server) documentHighlights(p documentHighlightParams) []DocumentHighlig
continue continue
} }
// Highlight the definition. // Highlight the definition.
if t.Name.Name == word { if t.Name != nil && t.Name.Name == name {
out = append(out, DocumentHighlight{ out = append(out, DocumentHighlight{
Range: symRange(t.Name), Range: clientRange(text, symRange(t.Name)),
Kind: highlightWrite, Kind: highlightWrite,
}) })
} }
for _, stmt := range t.Body { for _, stmt := range t.Body {
switch st := stmt.(type) { switch st := stmt.(type) {
case *ast.Label: case *ast.Label:
if st.Name.Text == word { if st.Name.Text == name {
out = append(out, DocumentHighlight{ out = append(out, DocumentHighlight{
Range: tokenRange(st.Name), Range: clientRange(text, tokenRange(st.Name)),
Kind: highlightWrite, Kind: highlightWrite,
}) })
} }
case *ast.Instr: case *ast.Instr:
for _, op := range st.Operands { 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{ out = append(out, DocumentHighlight{
Range: Range{ Range: clientRange(text, Range{
Start: Position{Line: op.Pos.Line - 1, Character: op.Pos.Column - 1}, 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, Kind: highlightRead,
}) })
} }
@@ -517,11 +525,17 @@ func (s *Server) workspaceSymbols(p workspaceSymbolParams) []WorkspaceSymbol {
for _, d := range f.Decls { for _, d := range f.Decls {
switch dd := d.(type) { switch dd := d.(type) {
case *ast.Text: 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) { if strings.Contains(strings.ToLower(dd.Name.Name), query) {
out = append(out, WorkspaceSymbol{ out = append(out, WorkspaceSymbol{
Name: dd.Name.Name, Name: dd.Name.Name,
Kind: symFunction, Kind: symFunction,
Location: Location{URI: uri, Range: symRange(dd.Name)}, Location: Location{URI: uri, Range: clientRange(text, symRange(dd.Name))},
}) })
} }
case *ast.Globl: case *ast.Globl:
@@ -529,7 +543,7 @@ func (s *Server) workspaceSymbols(p workspaceSymbolParams) []WorkspaceSymbol {
out = append(out, WorkspaceSymbol{ out = append(out, WorkspaceSymbol{
Name: dd.Name.Name, Name: dd.Name.Name,
Kind: symConstant, Kind: symConstant,
Location: Location{URI: uri, Range: symRange(dd.Name)}, Location: Location{URI: uri, Range: clientRange(text, symRange(dd.Name))},
}) })
} }
case *ast.Data: case *ast.Data:
@@ -537,7 +551,7 @@ func (s *Server) workspaceSymbols(p workspaceSymbolParams) []WorkspaceSymbol {
out = append(out, WorkspaceSymbol{ out = append(out, WorkspaceSymbol{
Name: dd.Name.Name, Name: dd.Name.Name,
Kind: symConstant, 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 { for _, d := range f.Decls {
switch dd := d.(type) { switch dd := d.(type) {
case *ast.Text: 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{ sym := DocumentSymbol{
Name: dd.Name.Name, Name: dd.Name.Name,
Detail: "TEXT " + strings.Join(dd.Flags, " "), Detail: "TEXT " + strings.Join(dd.Flags, " "),
Kind: symFunction, Kind: symFunction,
Range: textRange(dd), Range: clientRange(text, textRange(dd)),
SelectionRange: symRange(dd.Name), SelectionRange: clientRange(text, symRange(dd.Name)),
} }
for _, st := range dd.Body { for _, st := range dd.Body {
if l, ok := st.(*ast.Label); ok { if l, ok := st.(*ast.Label); ok {
sym.Children = append(sym.Children, DocumentSymbol{ sym.Children = append(sym.Children, DocumentSymbol{
Name: l.Name.Text, Name: l.Name.Text,
Kind: symVariable, Kind: symVariable,
Range: tokenRange(l.Name), Range: clientRange(text, tokenRange(l.Name)),
SelectionRange: tokenRange(l.Name), SelectionRange: clientRange(text, tokenRange(l.Name)),
}) })
} }
} }
out = append(out, sym) out = append(out, sym)
case *ast.Globl: case *ast.Globl:
if dd.Name == nil {
continue
}
out = append(out, DocumentSymbol{ out = append(out, DocumentSymbol{
Name: dd.Name.Name, Detail: "GLOBL", Kind: symConstant, 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: case *ast.Data:
if dd.Name == nil {
continue
}
out = append(out, DocumentSymbol{ out = append(out, DocumentSymbol{
Name: dd.Name.Name, Detail: "DATA", Kind: symConstant, 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) toks := lexer.Tokenize(text)
lines := groupLines(toks) lines := groupLines(toks)
srcLines := strings.Split(text, "\n")
var encoded []semTok var encoded []semTok
for _, line := range lines { 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)} 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) typ = classifyIdent(i, first, t.Text, a, labels, isDirective, isLabel, isInstr, &mnemonicDone)
case token.Colon, token.Comma, token.LParen, token.RParen, case token.Colon, token.Comma, token.LParen, token.RParen,
token.Plus, token.Minus, token.Star, token.Slash, token.Dollar, 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 typ = stOperator
} }
if typ >= 0 { if typ >= 0 {
out = append(out, semTok{ out = append(out, semTok{
line: t.Pos.Line - 1, line: t.Pos.Line - 1,
char: t.Pos.Column - 1, char: t.Pos.Column - 1,
length: runeLen(t.Text), length: utf16Len(t.Text),
typ: typ, typ: typ,
}) })
} }
@@ -714,14 +747,16 @@ func deltaEncode(toks []semTok) []int {
// --- shared helpers --------------------------------------------------------- // --- 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) { func wordAt(text string, pos Position) (string, Range) {
lines := strings.Split(text, "\n") lines := strings.Split(text, "\n")
if pos.Line < 0 || pos.Line >= len(lines) { if pos.Line < 0 || pos.Line >= len(lines) {
return "", Range{} return "", Range{}
} }
runes := []rune(lines[pos.Line]) runes := []rune(lines[pos.Line])
col := pos.Character col := runeColumn(lines[pos.Line], pos.Character)
if col < 0 || col > len(runes) { if col < 0 || col > len(runes) {
return "", Range{} return "", Range{}
} }
@@ -802,14 +837,102 @@ func firstSignificant(line []token.Token) int {
func runeLen(s string) int { return len([]rune(s)) } 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<ABIInternal>(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 { func symRange(sym *ast.Symbol) Range {
start := Position{Line: sym.Pos.Line - 1, Character: sym.Pos.Column - 1} start := Position{Line: sym.Pos.Line - 1, Character: sym.Pos.Column - 1}
end := start end := start
end.Character += runeLen(sym.Name) end.Character += symIdentLen(sym)
return Range{Start: start, End: end} 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. // tokenRange builds a range covering one token.
func tokenRange(t token.Token) Range { func tokenRange(t token.Token) Range {
return Range{ return Range{
@@ -852,7 +975,7 @@ func (s *Server) diagnosticsFor(uri string) []Diagnostic {
pos = pe.Pos pos = pe.Pos
} }
out = append(out, Diagnostic{ out = append(out, Diagnostic{
Range: toRange(pos.Line, pos.Column, token.Position{}), Range: clientRange(text, toRange(pos.Line, pos.Column, token.Position{})),
Severity: sevError, Severity: sevError,
Code: "syntax", Code: "syntax",
Source: "gasm", Source: "gasm",
@@ -861,7 +984,7 @@ func (s *Server) diagnosticsFor(uri string) []Diagnostic {
} }
for _, d := range diags { for _, d := range diags {
out = append(out, Diagnostic{ 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), Severity: lintSeverity(d.Severity),
Code: d.Code, Code: d.Code,
Source: "gasm", Source: "gasm",
@@ -886,7 +1009,9 @@ func (s *Server) documentLinks(uri string) []DocumentLink {
goroot := runtime.GOROOT() goroot := runtime.GOROOT()
var out []DocumentLink 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) m := includeRe.FindStringSubmatch(line)
if m == nil { if m == nil {
continue continue
@@ -895,12 +1020,13 @@ func (s *Server) documentLinks(uri string) []DocumentLink {
if target == "" { if target == "" {
continue 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{ out = append(out, DocumentLink{
Range: Range{ Range: clientRange(text, Range{
Start: Position{Line: i, Character: start}, Start: Position{Line: lineNo, Character: start},
End: Position{Line: i, Character: start + len(m[1]) + 2}, End: Position{Line: lineNo, Character: start + runeLen(m[1]) + 2},
}, }),
Target: "file://" + target, Target: "file://" + target,
}) })
} }
+1
View File
@@ -30,6 +30,7 @@ type rpcError struct {
} }
const ( const (
errParse = -32700
errMethodNotFound = -32601 errMethodNotFound = -32601
errInternalError = -32603 errInternalError = -32603
) )
+44 -3
View File
@@ -6,6 +6,7 @@ package lsp
import ( import (
"bufio" "bufio"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/url" "net/url"
@@ -45,6 +46,11 @@ func (s *Server) Run() error {
if err == io.EOF { if err == io.EOF {
return nil 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 { if err != nil {
return err 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) { func (s *Server) read() (*rpcMessage, error) {
length := -1 length := -1
var headerErr string
for { for {
line, err := s.in.ReadString('\n') line, err := s.in.ReadString('\n')
if err != nil { if err != nil {
@@ -67,9 +84,18 @@ func (s *Server) read() (*rpcMessage, error) {
break break
} }
if k, v, ok := strings.Cut(line, ":"); ok && strings.EqualFold(strings.TrimSpace(k), "Content-Length") { 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 { if length < 0 {
return nil, fmt.Errorf("missing Content-Length header") return nil, fmt.Errorf("missing Content-Length header")
} }
@@ -79,7 +105,8 @@ func (s *Server) read() (*rpcMessage, error) {
} }
var msg rpcMessage var msg rpcMessage
if err := json.Unmarshal(body, &msg); err != nil { if err := json.Unmarshal(body, &msg); err != nil {
return nil, err s.parseError("invalid JSON body: " + err.Error())
return nil, errMalformed
} }
return &msg, nil return &msg, nil
} }
@@ -101,6 +128,13 @@ func (s *Server) send(msg *rpcMessage) error {
} }
func (s *Server) respond(id *json.RawMessage, result any) { 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}) _ = 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}}) _ = 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) { func (s *Server) notify(method string, params any) {
raw, _ := json.Marshal(params) raw, _ := json.Marshal(params)
_ = s.send(&rpcMessage{Method: method, Params: raw}) _ = s.send(&rpcMessage{Method: method, Params: raw})
+322
View File
@@ -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. // mustResult re-marshals a response result into raw JSON for typed decoding.
func mustResult(t *testing.T, m *rpcMessage) []byte { func mustResult(t *testing.T, m *rpcMessage) []byte {
t.Helper() t.Helper()