// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: BSD-3-Clause package lsp import ( "bufio" "encoding/json" "errors" "fmt" "io" "net/url" "strconv" "strings" "sync" "sourcedock.dev/petrbalvin/gasm-sdk/lint" "sourcedock.dev/petrbalvin/gasm-sdk/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 ws workspaceIndex // the on-disk workspace beyond the open documents 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 errors.Is(err, errMalformed) { // The message was already answered with a -32700 response; // one malformed frame must not end the session. continue } if err != nil { return err } if exit := s.dispatch(msg); exit { return nil } } } // 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. func (s *Server) read() (*rpcMessage, error) { length := -1 var headerErr string 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") { 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 } } if headerErr != "" { s.parseError(headerErr) return nil, errMalformed } 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 { s.parseError("invalid JSON body: " + err.Error()) return nil, errMalformed } 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) { // 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") } _ = 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}}) } // 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) } 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": var ip initializeParams if len(msg.Params) > 0 { json.Unmarshal(msg.Params, &ip) } s.ws.roots = ip.roots() 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, Workspace: map[string]any{"didChangeWatchedFiles": map[string]any{}}, }, 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 "workspace/didChangeWatchedFiles": var p didChangeWatchedFilesParams if json.Unmarshal(msg.Params, &p) == nil { for _, ch := range p.Changes { s.ws.watched(uriPath(ch.URI), ch.Type == fileDeleted) } } 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 }