// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: BSD-3-Clause package lsp import ( "bufio" "encoding/json" "fmt" "io" "net/url" "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 { 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) } // 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 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. // 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": 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, }, 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 "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 }