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"
|
|
|
|
|
"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,
|
|
|
|
|
DocumentSymbolProvider: true,
|
|
|
|
|
SemanticTokensProvider: map[string]any{
|
|
|
|
|
"legend": SemanticTokensLegend{
|
|
|
|
|
TokenTypes: semanticTokenTypes,
|
|
|
|
|
TokenModifiers: []string{},
|
|
|
|
|
},
|
|
|
|
|
"full": true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
ServerInfo: map[string]string{"name": "gasm", "version": "0.1.0"},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
|
|
|
|
case "textDocument/semanticTokens/full":
|
|
|
|
|
var p semanticTokensParams
|
|
|
|
|
json.Unmarshal(msg.Params, &p)
|
|
|
|
|
s.respond(msg.ID, s.semanticTokens(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
|
|
|
|
|
}
|