Files
gasm-sdk/lsp/server.go
2026-09-26 11:08:43 +02:00

383 lines
11 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package lsp
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"strconv"
"strings"
"sync"
"sourcedock.dev/petrbalvin/gasm-sdk/lint"
"sourcedock.dev/petrbalvin/gasm-sdk/token"
)
// Server is a GAsm language server bound to a byte stream.
type Server struct {
in *bufio.Reader
out io.Writer
mu sync.Mutex // guards writes to out
docs map[string]string
ws workspaceIndex // the on-disk workspace beyond the open documents
version string // reported in the initialize result ("" omits it)
}
// New returns a server reading from in and writing to out.
func New(in io.Reader, out io.Writer) *Server {
return &Server{
in: bufio.NewReader(in),
out: out,
docs: make(map[string]string),
}
}
// 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.
func (s *Server) Run() error {
for {
msg, err := s.read()
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
}
if exit := s.dispatch(msg); exit {
return nil
}
}
}
// 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 {
return nil, err
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
break
}
if k, v, ok := strings.Cut(line, ":"); ok && strings.EqualFold(strings.TrimSpace(k), "Content-Length") {
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")
}
body := make([]byte, length)
if _, err := io.ReadFull(s.in, body); err != nil {
return nil, err
}
var msg rpcMessage
if err := json.Unmarshal(body, &msg); err != nil {
s.parseError("invalid JSON body: " + err.Error())
return nil, errMalformed
}
return &msg, nil
}
// send marshals and writes one framed message.
func (s *Server) send(msg *rpcMessage) error {
msg.JSONRPC = "2.0"
body, err := json.Marshal(msg)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
if _, err := fmt.Fprintf(s.out, "Content-Length: %d\r\n\r\n", len(body)); err != nil {
return err
}
_, err = s.out.Write(body)
return err
}
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})
}
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})
}
// 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) {
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 {
case "initialize":
var ip initializeParams
if len(msg.Params) > 0 {
json.Unmarshal(msg.Params, &ip)
}
s.ws.roots = ip.roots()
info := map[string]string{"name": "gasm"}
if s.version != "" {
info["version"] = s.version
}
s.respond(msg.ID, initializeResult{
Capabilities: ServerCapabilities{
TextDocumentSync: 1, // full sync
CompletionProvider: map[string]any{},
HoverProvider: true,
DefinitionProvider: true,
ReferencesProvider: true,
RenameProvider: true,
DocumentSymbolProvider: true,
DocumentFormattingProvider: true,
SemanticTokensProvider: map[string]any{
"legend": SemanticTokensLegend{
TokenTypes: semanticTokenTypes,
TokenModifiers: []string{},
},
"full": true,
},
InlayHintProvider: true,
DocumentHighlightProvider: true,
CodeActionProvider: true,
SignatureHelpProvider: map[string]any{"triggerCharacters": []string{"("}},
WorkspaceSymbolProvider: true,
DiagnosticProvider: map[string]any{"interFileDependencies": false, "workspaceDiagnostics": false},
DocumentLinkProvider: map[string]any{},
FoldingRangeProvider: true,
Workspace: map[string]any{"didChangeWatchedFiles": map[string]any{}},
},
ServerInfo: info,
})
case "initialized", "textDocument/didSave":
// Notifications with nothing to do.
case "textDocument/didOpen":
var p didOpenParams
if json.Unmarshal(msg.Params, &p) == nil {
s.docs[p.TextDocument.URI] = p.TextDocument.Text
s.publish(p.TextDocument.URI)
}
case "textDocument/didChange":
var p didChangeParams
if json.Unmarshal(msg.Params, &p) == nil && len(p.ContentChanges) > 0 {
// Full sync: the last change carries the whole document.
text := p.ContentChanges[len(p.ContentChanges)-1].Text
s.docs[p.TextDocument.URI] = text
s.publish(p.TextDocument.URI)
}
case "textDocument/didClose":
var p didCloseParams
if json.Unmarshal(msg.Params, &p) == nil {
delete(s.docs, p.TextDocument.URI)
// Clear diagnostics for the closed document.
s.notify("textDocument/publishDiagnostics", publishDiagnosticsParams{
URI: p.TextDocument.URI, Diagnostics: []Diagnostic{},
})
}
case "textDocument/completion":
var p completionParams
json.Unmarshal(msg.Params, &p)
s.respond(msg.ID, s.completion(p))
case "textDocument/hover":
var p hoverParams
json.Unmarshal(msg.Params, &p)
s.respond(msg.ID, s.hover(p))
case "textDocument/definition":
var p definitionParams
json.Unmarshal(msg.Params, &p)
s.respond(msg.ID, s.definition(p))
case "textDocument/documentSymbol":
var p documentSymbolParams
json.Unmarshal(msg.Params, &p)
s.respond(msg.ID, s.documentSymbols(p))
case "textDocument/references":
var p referenceParams
json.Unmarshal(msg.Params, &p)
s.respond(msg.ID, s.references(p))
case "textDocument/rename":
var p renameParams
json.Unmarshal(msg.Params, &p)
s.respond(msg.ID, s.rename(p))
case "textDocument/formatting":
var p documentFormattingParams
json.Unmarshal(msg.Params, &p)
s.respond(msg.ID, s.documentFormatting(p))
case "textDocument/inlayHint":
var p inlayHintParams
json.Unmarshal(msg.Params, &p)
s.respond(msg.ID, s.inlayHints(p))
case "textDocument/codeAction":
var p codeActionParams
json.Unmarshal(msg.Params, &p)
s.respond(msg.ID, s.codeActions(p))
case "textDocument/signatureHelp":
var p signatureHelpParams
json.Unmarshal(msg.Params, &p)
s.respond(msg.ID, s.signatureHelp(p))
case "textDocument/documentHighlight":
var p documentHighlightParams
json.Unmarshal(msg.Params, &p)
s.respond(msg.ID, s.documentHighlights(p))
case "textDocument/semanticTokens/full":
var p semanticTokensParams
json.Unmarshal(msg.Params, &p)
s.respond(msg.ID, s.semanticTokens(p))
case "textDocument/diagnostic":
var p diagnosticParams
json.Unmarshal(msg.Params, &p)
s.respond(msg.ID, documentDiagnosticReport{Kind: "full", Items: s.diagnosticsFor(p.TextDocument.URI)})
case "textDocument/documentLink":
var p documentLinkParams
json.Unmarshal(msg.Params, &p)
s.respond(msg.ID, s.documentLinks(p.TextDocument.URI))
case "textDocument/foldingRange":
var p foldingRangeParams
json.Unmarshal(msg.Params, &p)
s.respond(msg.ID, s.foldingRanges(p.TextDocument.URI))
case "workspace/symbol":
var p workspaceSymbolParams
json.Unmarshal(msg.Params, &p)
s.respond(msg.ID, s.workspaceSymbols(p))
case "workspace/didChangeWatchedFiles":
var p didChangeWatchedFilesParams
if json.Unmarshal(msg.Params, &p) == nil {
for _, ch := range p.Changes {
s.ws.watched(uriPath(ch.URI), ch.Type == fileDeleted)
}
}
case "shutdown":
s.respond(msg.ID, nil)
case "exit":
return true
default:
if msg.ID != nil {
s.respondError(msg.ID, errMethodNotFound, "method not supported: "+msg.Method)
}
}
return false
}
// publish parses and lints a document and pushes the diagnostics to the client.
func (s *Server) publish(uri string) {
s.notify("textDocument/publishDiagnostics", publishDiagnosticsParams{URI: uri, Diagnostics: s.diagnosticsFor(uri)})
}
// toRange converts one-based line/column plus an optional end position into an
// LSP range (zero-based).
func toRange(line, col int, end token.Position) Range {
start := Position{Line: line - 1, Character: col - 1}
finish := start
if end.IsValid() {
finish = Position{Line: end.Line - 1, Character: end.Column - 1}
} else {
finish.Character = start.Character + 1
}
return Range{Start: start, End: finish}
}
func lintSeverity(s lint.Severity) int {
switch s {
case lint.Error:
return sevError
case lint.Warning:
return sevWarning
case lint.Information:
return sevInformation
default:
return sevHint
}
}
// 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 {
if rest, ok := strings.CutPrefix(uri, "file://"); ok {
if decoded, err := url.PathUnescape(rest); err == nil {
return decoded
}
return rest
}
return uri
}