2026-07-06 09:49:50 +02:00
|
|
|
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|
|
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
|
|
|
|
|
|
package lsp
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bufio"
|
|
|
|
|
"encoding/json"
|
2026-09-19 23:49:19 +02:00
|
|
|
"errors"
|
2026-07-06 09:49:50 +02:00
|
|
|
"fmt"
|
|
|
|
|
"io"
|
2026-09-14 18:22:00 +02:00
|
|
|
"net/url"
|
2026-07-06 09:49:50 +02:00
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
|
|
|
|
|
"sourcedock.dev/petrbalvin/gasm-devkit/lint"
|
|
|
|
|
"sourcedock.dev/petrbalvin/gasm-devkit/token"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Server is a GAsm language server bound to a byte stream.
|
|
|
|
|
type Server struct {
|
2026-09-14 18:22:00 +02:00
|
|
|
in *bufio.Reader
|
|
|
|
|
out io.Writer
|
|
|
|
|
mu sync.Mutex // guards writes to out
|
|
|
|
|
docs map[string]string
|
|
|
|
|
version string // reported in the initialize result ("" omits it)
|
2026-07-06 09:49:50 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-14 18:22:00 +02:00
|
|
|
// SetVersion records the server version reported in the initialize result.
|
|
|
|
|
func (s *Server) SetVersion(v string) { s.version = v }
|
|
|
|
|
|
2026-07-06 09:49:50 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
2026-09-19 23:49:19 +02:00
|
|
|
if errors.Is(err, errMalformed) {
|
|
|
|
|
// The message was already answered with a -32700 response;
|
|
|
|
|
// one malformed frame must not end the session.
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-07-06 09:49:50 +02:00
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if exit := s.dispatch(msg); exit {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 23:49:19 +02:00
|
|
|
// 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.
|
2026-07-06 09:49:50 +02:00
|
|
|
func (s *Server) read() (*rpcMessage, error) {
|
|
|
|
|
length := -1
|
2026-09-19 23:49:19 +02:00
|
|
|
var headerErr string
|
2026-07-06 09:49:50 +02:00
|
|
|
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") {
|
2026-09-19 23:49:19 +02:00
|
|
|
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
|
2026-07-06 09:49:50 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-09-19 23:49:19 +02:00
|
|
|
if headerErr != "" {
|
|
|
|
|
s.parseError(headerErr)
|
|
|
|
|
return nil, errMalformed
|
|
|
|
|
}
|
2026-07-06 09:49:50 +02:00
|
|
|
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 {
|
2026-09-19 23:49:19 +02:00
|
|
|
s.parseError("invalid JSON body: " + err.Error())
|
|
|
|
|
return nil, errMalformed
|
2026-07-06 09:49:50 +02:00
|
|
|
}
|
|
|
|
|
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) {
|
2026-09-19 23:49:19 +02:00
|
|
|
// 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")
|
|
|
|
|
}
|
2026-07-06 09:49:50 +02:00
|
|
|
_ = 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}})
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-19 23:49:19 +02:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 09:49:50 +02:00
|
|
|
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.
|
2026-09-14 18:22:00 +02:00
|
|
|
// 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.
|
2026-07-06 09:49:50 +02:00
|
|
|
func (s *Server) dispatch(msg *rpcMessage) (exit bool) {
|
2026-09-14 18:22:00 +02:00
|
|
|
defer func() {
|
|
|
|
|
if r := recover(); r != nil {
|
|
|
|
|
if msg != nil && msg.ID != nil {
|
|
|
|
|
s.respondError(msg.ID, errInternalError, fmt.Sprintf("internal error: %v", r))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
2026-07-06 09:49:50 +02:00
|
|
|
switch msg.Method {
|
|
|
|
|
case "initialize":
|
2026-09-14 18:22:00 +02:00
|
|
|
info := map[string]string{"name": "gasm"}
|
|
|
|
|
if s.version != "" {
|
|
|
|
|
info["version"] = s.version
|
|
|
|
|
}
|
2026-07-06 09:49:50 +02:00
|
|
|
s.respond(msg.ID, initializeResult{
|
|
|
|
|
Capabilities: ServerCapabilities{
|
2026-08-20 23:27:48 +02:00
|
|
|
TextDocumentSync: 1, // full sync
|
|
|
|
|
CompletionProvider: map[string]any{},
|
|
|
|
|
HoverProvider: true,
|
|
|
|
|
DefinitionProvider: true,
|
|
|
|
|
ReferencesProvider: true,
|
|
|
|
|
RenameProvider: true,
|
|
|
|
|
DocumentSymbolProvider: true,
|
|
|
|
|
DocumentFormattingProvider: true,
|
2026-07-06 09:49:50 +02:00
|
|
|
SemanticTokensProvider: map[string]any{
|
|
|
|
|
"legend": SemanticTokensLegend{
|
|
|
|
|
TokenTypes: semanticTokenTypes,
|
|
|
|
|
TokenModifiers: []string{},
|
|
|
|
|
},
|
|
|
|
|
"full": true,
|
|
|
|
|
},
|
2026-08-21 01:01:58 +02:00
|
|
|
InlayHintProvider: true,
|
|
|
|
|
DocumentHighlightProvider: true,
|
|
|
|
|
CodeActionProvider: true,
|
|
|
|
|
SignatureHelpProvider: map[string]any{"triggerCharacters": []string{"("}},
|
2026-08-21 01:15:05 +02:00
|
|
|
WorkspaceSymbolProvider: true,
|
2026-08-30 21:30:22 +02:00
|
|
|
DiagnosticProvider: map[string]any{"interFileDependencies": false, "workspaceDiagnostics": false},
|
|
|
|
|
DocumentLinkProvider: map[string]any{},
|
|
|
|
|
FoldingRangeProvider: true,
|
2026-07-06 09:49:50 +02:00
|
|
|
},
|
2026-09-14 18:22:00 +02:00
|
|
|
ServerInfo: info,
|
2026-07-06 09:49:50 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
2026-08-05 11:08:00 +02:00
|
|
|
case "textDocument/definition":
|
|
|
|
|
var p definitionParams
|
|
|
|
|
json.Unmarshal(msg.Params, &p)
|
|
|
|
|
s.respond(msg.ID, s.definition(p))
|
|
|
|
|
|
2026-07-06 09:49:50 +02:00
|
|
|
case "textDocument/documentSymbol":
|
|
|
|
|
var p documentSymbolParams
|
|
|
|
|
json.Unmarshal(msg.Params, &p)
|
|
|
|
|
s.respond(msg.ID, s.documentSymbols(p))
|
|
|
|
|
|
2026-08-20 23:27:48 +02:00
|
|
|
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))
|
|
|
|
|
|
2026-08-21 01:01:58 +02:00
|
|
|
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))
|
|
|
|
|
|
2026-07-06 09:49:50 +02:00
|
|
|
case "textDocument/semanticTokens/full":
|
|
|
|
|
var p semanticTokensParams
|
|
|
|
|
json.Unmarshal(msg.Params, &p)
|
|
|
|
|
s.respond(msg.ID, s.semanticTokens(p))
|
|
|
|
|
|
2026-08-30 21:30:22 +02:00
|
|
|
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))
|
2026-08-21 01:15:05 +02:00
|
|
|
case "workspace/symbol":
|
|
|
|
|
var p workspaceSymbolParams
|
|
|
|
|
json.Unmarshal(msg.Params, &p)
|
|
|
|
|
s.respond(msg.ID, s.workspaceSymbols(p))
|
|
|
|
|
|
2026-07-06 09:49:50 +02:00
|
|
|
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) {
|
2026-08-30 21:30:22 +02:00
|
|
|
s.notify("textDocument/publishDiagnostics", publishDiagnosticsParams{URI: uri, Diagnostics: s.diagnosticsFor(uri)})
|
2026-07-06 09:49:50 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-14 18:22:00 +02:00
|
|
|
// 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.
|
2026-07-06 09:49:50 +02:00
|
|
|
func uriPath(uri string) string {
|
|
|
|
|
if rest, ok := strings.CutPrefix(uri, "file://"); ok {
|
2026-09-14 18:22:00 +02:00
|
|
|
if decoded, err := url.PathUnescape(rest); err == nil {
|
|
|
|
|
return decoded
|
|
|
|
|
}
|
2026-07-06 09:49:50 +02:00
|
|
|
return rest
|
|
|
|
|
}
|
|
|
|
|
return uri
|
|
|
|
|
}
|