Files
gasm-sdk/lsp/server.go
T

304 lines
7.8 KiB
Go
Raw Normal View History

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package lsp
import (
"bufio"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"sync"
"sourcedock.dev/petrbalvin/gasm-devkit/arch"
"sourcedock.dev/petrbalvin/gasm-devkit/lint"
"sourcedock.dev/petrbalvin/gasm-devkit/parser"
"sourcedock.dev/petrbalvin/gasm-devkit/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
}
// 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),
}
}
// 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 err != nil {
return err
}
if exit := s.dispatch(msg); exit {
return nil
}
}
}
// read parses one Content-Length framed JSON-RPC message.
func (s *Server) read() (*rpcMessage, error) {
length := -1
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") {
length, _ = strconv.Atoi(strings.TrimSpace(v))
}
}
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 {
return nil, err
}
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) {
_ = 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}})
}
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.
func (s *Server) dispatch(msg *rpcMessage) (exit bool) {
switch msg.Method {
case "initialize":
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{"("}},
2026-08-21 01:15:05 +02:00
WorkspaceSymbolProvider: true,
},
ServerInfo: map[string]string{"name": "gasm", "version": "0.31.1"},
})
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))
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))
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))
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) {
text := s.docs[uri]
f, _ := parser.Parse(uri, text)
cfg := lint.Config{Arch: arch.FromFilename(uriPath(uri))}
diags := lint.File(f, cfg)
out := make([]Diagnostic, 0, len(diags))
for _, d := range diags {
out = append(out, Diagnostic{
Range: toRange(d.Pos.Line, d.Pos.Column, d.End),
Severity: lintSeverity(d.Severity),
Code: d.Code,
Source: "gasm",
Message: d.Message,
})
}
s.notify("textDocument/publishDiagnostics", publishDiagnosticsParams{URI: uri, Diagnostics: out})
}
// 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 returns the path component.
func uriPath(uri string) string {
if rest, ok := strings.CutPrefix(uri, "file://"); ok {
return rest
}
return uri
}