fix(lsp): recover from handler panics and decode client uris
This commit is contained in:
+34
-13
@@ -22,12 +22,13 @@ import (
|
|||||||
"sourcedock.dev/petrbalvin/gasm-devkit/token"
|
"sourcedock.dev/petrbalvin/gasm-devkit/token"
|
||||||
)
|
)
|
||||||
|
|
||||||
// textflagMacros are the flag names defined by textflag.h; they are highlighted
|
// textflagMacros are the flag names defined by runtime/textflag.h; they are
|
||||||
// as macros and offered as completions after a TEXT/GLOBL directive.
|
// highlighted as macros and offered as completions after a TEXT/GLOBL
|
||||||
|
// directive.
|
||||||
var textflagMacros = map[string]bool{
|
var textflagMacros = map[string]bool{
|
||||||
"NOPROFILE": true, "DUPOK": true, "NOSPLIT": true, "RODATA": true,
|
"NOPROF": true, "DUPOK": true, "NOSPLIT": true, "RODATA": true,
|
||||||
"NOPTR": true, "WRAPPER": true, "NEEDCTXT": true, "TOPFRAME": true,
|
"NOPTR": true, "WRAPPER": true, "NEEDCTXT": true, "TLSBSS": true,
|
||||||
"LEAF": true, "ABI0": true, "REFLECTDATA": true,
|
"NOFRAME": true, "REFLECTMETHOD": true, "TOPFRAME": true, "ABIWRAPPER": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// completion builds the completion list for a document.
|
// completion builds the completion list for a document.
|
||||||
@@ -81,13 +82,13 @@ func (s *Server) hover(p hoverParams) *Hover {
|
|||||||
|
|
||||||
var md string
|
var md string
|
||||||
if in, ok := a.Lookup(word); ok {
|
if in, ok := a.Lookup(word); ok {
|
||||||
md = "**" + in.Name + "** — " + in.Summary
|
md = "**" + in.Name + "**: " + in.Summary
|
||||||
} else if r, ok := a.Register(word); ok {
|
} else if r, ok := a.Register(word); ok {
|
||||||
md = "**" + r.Name + "** — " + r.Class.String() + " register. " + r.Desc
|
md = "**" + r.Name + "**: " + r.Class.String() + " register. " + r.Desc
|
||||||
} else if desc, ok := arch.PseudoRegDesc(word); ok {
|
} else if desc, ok := arch.PseudoRegDesc(word); ok {
|
||||||
md = "**" + strings.ToUpper(word) + "** — pseudo-register. " + desc
|
md = "**" + strings.ToUpper(word) + "**: pseudo-register. " + desc
|
||||||
} else if textflagMacros[strings.ToUpper(word)] {
|
} else if textflagMacros[strings.ToUpper(word)] {
|
||||||
md = "**" + strings.ToUpper(word) + "** — textflag.h flag"
|
md = "**" + strings.ToUpper(word) + "**: textflag.h flag"
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -271,7 +272,9 @@ func (s *Server) codeActions(p codeActionParams) []CodeAction {
|
|||||||
for _, diag := range p.Context.Diagnostics {
|
for _, diag := range p.Context.Diagnostics {
|
||||||
switch diag.Code {
|
switch diag.Code {
|
||||||
case "missing-ret":
|
case "missing-ret":
|
||||||
// Offer to add RET at the end of the function.
|
// Offer to add RET at the end of the flagged function only: the
|
||||||
|
// diagnostic's range covers the TEXT keyword, so a line match
|
||||||
|
// picks the function the diagnostic belongs to.
|
||||||
f, _ := parser.Parse(uriPath(p.TextDocument.URI), text)
|
f, _ := parser.Parse(uriPath(p.TextDocument.URI), text)
|
||||||
if f == nil {
|
if f == nil {
|
||||||
continue
|
continue
|
||||||
@@ -281,6 +284,9 @@ func (s *Server) codeActions(p codeActionParams) []CodeAction {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if t.Keyword.Pos.Line-1 != int(diag.Range.Start.Line) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if len(t.Body) == 0 {
|
if len(t.Body) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -298,7 +304,7 @@ func (s *Server) codeActions(p codeActionParams) []CodeAction {
|
|||||||
endLine := len(lines) - 1
|
endLine := len(lines) - 1
|
||||||
endChar := len([]rune(lines[endLine]))
|
endChar := len([]rune(lines[endLine]))
|
||||||
actions = append(actions, CodeAction{
|
actions = append(actions, CodeAction{
|
||||||
Title: "Add RET",
|
Title: "Add RET to " + t.Name.Name,
|
||||||
Kind: "quickfix",
|
Kind: "quickfix",
|
||||||
Edit: &WorkspaceEdit{
|
Edit: &WorkspaceEdit{
|
||||||
Changes: map[string][]TextEdit{p.TextDocument.URI: {
|
Changes: map[string][]TextEdit{p.TextDocument.URI: {
|
||||||
@@ -792,13 +798,28 @@ func textRange(t *ast.Text) Range {
|
|||||||
|
|
||||||
// diagnosticsFor computes the LSP diagnostics of one document; the push
|
// diagnosticsFor computes the LSP diagnostics of one document; the push
|
||||||
// (publishDiagnostics) and pull (textDocument/diagnostic) paths share it.
|
// (publishDiagnostics) and pull (textDocument/diagnostic) paths share it.
|
||||||
|
// Parse errors surface as error-severity diagnostics so a malformed line is
|
||||||
|
// visible in the editor instead of only breaking derived features.
|
||||||
func (s *Server) diagnosticsFor(uri string) []Diagnostic {
|
func (s *Server) diagnosticsFor(uri string) []Diagnostic {
|
||||||
text := s.docs[uri]
|
text := s.docs[uri]
|
||||||
f, _ := parser.Parse(uriPath(uri), text)
|
f, errs := parser.Parse(uriPath(uri), text)
|
||||||
cfg := lint.Config{Arch: arch.FromFilename(uriPath(uri))}
|
cfg := lint.Config{Arch: arch.FromFilename(uriPath(uri))}
|
||||||
diags := lint.File(f, cfg)
|
diags := lint.File(f, cfg)
|
||||||
|
|
||||||
out := make([]Diagnostic, 0, len(diags))
|
out := make([]Diagnostic, 0, len(diags)+len(errs))
|
||||||
|
for _, e := range errs {
|
||||||
|
pos := token.Position{Line: 1, Column: 1}
|
||||||
|
if pe, ok := e.(parser.Error); ok && pe.Pos.IsValid() {
|
||||||
|
pos = pe.Pos
|
||||||
|
}
|
||||||
|
out = append(out, Diagnostic{
|
||||||
|
Range: toRange(pos.Line, pos.Column, token.Position{}),
|
||||||
|
Severity: sevError,
|
||||||
|
Code: "syntax",
|
||||||
|
Source: "gasm",
|
||||||
|
Message: e.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
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: toRange(d.Pos.Line, d.Pos.Column, d.End),
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,7 @@
|
|||||||
// Package lsp implements a Language Server Protocol server for GAsm. It
|
// Package lsp implements a Language Server Protocol server for GAsm. It
|
||||||
// speaks JSON-RPC 2.0 over any io.Reader/io.Writer pair (normally standard
|
// speaks JSON-RPC 2.0 over any io.Reader/io.Writer pair (normally standard
|
||||||
// input/output) and provides completion, hover documentation, document
|
// input/output) and provides completion, hover documentation, document
|
||||||
// symbols, diagnostics and semantic-token highlighting — all backed by the
|
// symbols, diagnostics and semantic-token highlighting, all backed by the
|
||||||
// pure-Go lexer, parser, arch and lint packages. It is the vendor-neutral
|
// pure-Go lexer, parser, arch and lint packages. It is the vendor-neutral
|
||||||
// integration point: any LSP-capable editor can use it with no editor-specific
|
// integration point: any LSP-capable editor can use it with no editor-specific
|
||||||
// plugin code.
|
// plugin code.
|
||||||
@@ -31,6 +31,7 @@ type rpcError struct {
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
errMethodNotFound = -32601
|
errMethodNotFound = -32601
|
||||||
|
errInternalError = -32603
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- LSP positions and ranges ----------------------------------------------
|
// --- LSP positions and ranges ----------------------------------------------
|
||||||
|
|||||||
+31
-6
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -18,10 +19,11 @@ import (
|
|||||||
|
|
||||||
// Server is a GAsm language server bound to a byte stream.
|
// Server is a GAsm language server bound to a byte stream.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
in *bufio.Reader
|
in *bufio.Reader
|
||||||
out io.Writer
|
out io.Writer
|
||||||
mu sync.Mutex // guards writes to out
|
mu sync.Mutex // guards writes to out
|
||||||
docs map[string]string
|
docs map[string]string
|
||||||
|
version string // reported in the initialize result ("" omits it)
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a server reading from in and writing to out.
|
// New returns a server reading from in and writing to out.
|
||||||
@@ -33,6 +35,9 @@ func New(in io.Reader, out io.Writer) *Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetVersion records the server version reported in the initialize result.
|
||||||
|
func (s *Server) SetVersion(v string) { s.version = v }
|
||||||
|
|
||||||
// Run serves requests until the input is exhausted or an exit is requested.
|
// Run serves requests until the input is exhausted or an exit is requested.
|
||||||
func (s *Server) Run() error {
|
func (s *Server) Run() error {
|
||||||
for {
|
for {
|
||||||
@@ -109,9 +114,24 @@ func (s *Server) notify(method string, params any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// dispatch routes one message. It returns true when the server should stop.
|
// dispatch routes one message. It returns true when the server should stop.
|
||||||
|
// A panic in any handler is recovered and answered as an internal error:
|
||||||
|
// handlers parse live editor buffers, so malformed input must never take
|
||||||
|
// the whole server down.
|
||||||
func (s *Server) dispatch(msg *rpcMessage) (exit bool) {
|
func (s *Server) dispatch(msg *rpcMessage) (exit bool) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
if msg != nil && msg.ID != nil {
|
||||||
|
s.respondError(msg.ID, errInternalError, fmt.Sprintf("internal error: %v", r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
switch msg.Method {
|
switch msg.Method {
|
||||||
case "initialize":
|
case "initialize":
|
||||||
|
info := map[string]string{"name": "gasm"}
|
||||||
|
if s.version != "" {
|
||||||
|
info["version"] = s.version
|
||||||
|
}
|
||||||
s.respond(msg.ID, initializeResult{
|
s.respond(msg.ID, initializeResult{
|
||||||
Capabilities: ServerCapabilities{
|
Capabilities: ServerCapabilities{
|
||||||
TextDocumentSync: 1, // full sync
|
TextDocumentSync: 1, // full sync
|
||||||
@@ -138,7 +158,7 @@ func (s *Server) dispatch(msg *rpcMessage) (exit bool) {
|
|||||||
DocumentLinkProvider: map[string]any{},
|
DocumentLinkProvider: map[string]any{},
|
||||||
FoldingRangeProvider: true,
|
FoldingRangeProvider: true,
|
||||||
},
|
},
|
||||||
ServerInfo: map[string]string{"name": "gasm", "version": "0.31.1"},
|
ServerInfo: info,
|
||||||
})
|
})
|
||||||
|
|
||||||
case "initialized", "textDocument/didSave":
|
case "initialized", "textDocument/didSave":
|
||||||
@@ -292,9 +312,14 @@ func lintSeverity(s lint.Severity) int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// uriPath strips a file:// scheme and returns the path component.
|
// uriPath strips a file:// scheme and percent-decodes the path component.
|
||||||
|
// LSP clients percent-encode URIs, so a raw slice would break every on-disk
|
||||||
|
// lookup for paths containing spaces or non-ASCII characters.
|
||||||
func uriPath(uri string) string {
|
func uriPath(uri string) string {
|
||||||
if rest, ok := strings.CutPrefix(uri, "file://"); ok {
|
if rest, ok := strings.CutPrefix(uri, "file://"); ok {
|
||||||
|
if decoded, err := url.PathUnescape(rest); err == nil {
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
return rest
|
return rest
|
||||||
}
|
}
|
||||||
return uri
|
return uri
|
||||||
|
|||||||
+123
-3
@@ -381,7 +381,7 @@ func TestCodeActions(t *testing.T) {
|
|||||||
frame(10, "textDocument/codeAction", map[string]any{
|
frame(10, "textDocument/codeAction", map[string]any{
|
||||||
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
"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}},
|
"range": map[string]any{"start": map[string]any{"line": 0, "character": 0}, "end": map[string]any{"line": 2, "character": 0}},
|
||||||
"context": map[string]any{"diagnostics": []map[string]any{{"code": "missing-ret", "range": map[string]any{"start": map[string]any{"line": 0, "character": 0}, "end": map[string]any{"line": 0, "character": 4}}}}},
|
"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}}}}},
|
||||||
}) + frame(nil, "exit", nil)
|
}) + frame(nil, "exit", nil)
|
||||||
msgs := run(t, in)
|
msgs := run(t, in)
|
||||||
resp := findByID(msgs, 10)
|
resp := findByID(msgs, 10)
|
||||||
@@ -395,8 +395,8 @@ func TestCodeActions(t *testing.T) {
|
|||||||
if len(actions) == 0 {
|
if len(actions) == 0 {
|
||||||
t.Fatal("want at least 1 code action for missing-ret")
|
t.Fatal("want at least 1 code action for missing-ret")
|
||||||
}
|
}
|
||||||
if actions[0].Title != "Add RET" {
|
if actions[0].Title != "Add RET to foo" {
|
||||||
t.Errorf("action title = %q, want Add RET", actions[0].Title)
|
t.Errorf("action title = %q, want Add RET to foo", actions[0].Title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,3 +571,123 @@ func TestFoldingRanges(t *testing.T) {
|
|||||||
t.Errorf("folding kind = %q, want region", ranges[0].Kind)
|
t.Errorf("folding kind = %q, want region", ranges[0].Kind)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user