// Copyright (c) 2026 Petr BalvĂ­n (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)) 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/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 }