2c45b343c2
New internal/lsp package implements a minimal JSON-RPC 2.0 LSP server over stdin/stdout with Content-Length framing. Supported lifecycle: initialize → initialized → shutdown → exit. Document sync: Full (sends complete text on every change). Handles textDocument/didOpen, didChange, didSave, didClose; publishes textDocument/publishDiagnostics after every change. Rule IDs surface as the diagnostic `code` field with `"glint"` as source. Parse errors produce an Error diagnostic at the top of the document. Include resolution is best-effort (GITLAB_TOKEN env var; ~/.cache/glint default cache). CLI: glint lsp [--token] [--gitlab-url] [--cache-dir] [--offline]. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
333 lines
8.8 KiB
Go
333 lines
8.8 KiB
Go
package lsp
|
||
|
||
import (
|
||
"bufio"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"git.k3nny.fr/glint/internal/fetcher"
|
||
"git.k3nny.fr/glint/internal/linter"
|
||
"git.k3nny.fr/glint/internal/model"
|
||
"git.k3nny.fr/glint/internal/resolver"
|
||
)
|
||
|
||
// Server is a minimal Language Server Protocol server that publishes glint
|
||
// diagnostics for .gitlab-ci.yml files opened in an editor.
|
||
//
|
||
// Transport: JSON-RPC 2.0 over stdin/stdout with Content-Length framing.
|
||
// Sync mode: Full — the client sends the complete document text on every change.
|
||
type Server struct {
|
||
in *bufio.Reader
|
||
out io.Writer
|
||
cfg fetcher.GitLabConfig
|
||
version string
|
||
docs map[string]string // uri → current document text
|
||
|
||
// Exit is called with the process exit code when the LSP client sends the
|
||
// "exit" notification. Defaults to os.Exit; replace in tests.
|
||
Exit func(int)
|
||
|
||
shutdownReceived bool
|
||
}
|
||
|
||
// New creates a Server reading from r and writing to w.
|
||
func New(r io.Reader, w io.Writer, cfg fetcher.GitLabConfig, version string) *Server {
|
||
return &Server{
|
||
in: bufio.NewReader(r),
|
||
out: w,
|
||
cfg: cfg,
|
||
version: version,
|
||
docs: make(map[string]string),
|
||
Exit: os.Exit,
|
||
}
|
||
}
|
||
|
||
// Run processes LSP messages until the connection closes or a fatal error occurs.
|
||
// It returns nil on a clean EOF (client disconnected) and a non-nil error for
|
||
// unrecoverable protocol failures.
|
||
func (s *Server) Run() error {
|
||
for {
|
||
msg, err := s.readMessage()
|
||
if err == io.EOF {
|
||
return nil
|
||
}
|
||
if err != nil {
|
||
return fmt.Errorf("reading LSP message: %w", err)
|
||
}
|
||
if err := s.dispatch(msg); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
|
||
// readMessage reads one Content-Length–framed JSON-RPC message from the stream.
|
||
func (s *Server) readMessage() (*Message, error) {
|
||
var contentLength int
|
||
for {
|
||
line, err := s.in.ReadString('\n')
|
||
if err != nil {
|
||
if err == io.EOF && line == "" {
|
||
return nil, io.EOF
|
||
}
|
||
return nil, err
|
||
}
|
||
line = strings.TrimRight(line, "\r\n")
|
||
if line == "" {
|
||
break // blank line separates headers from body
|
||
}
|
||
if strings.HasPrefix(line, "Content-Length: ") {
|
||
n, parseErr := strconv.Atoi(strings.TrimPrefix(line, "Content-Length: "))
|
||
if parseErr != nil {
|
||
return nil, fmt.Errorf("invalid Content-Length: %w", parseErr)
|
||
}
|
||
contentLength = n
|
||
}
|
||
}
|
||
if contentLength == 0 {
|
||
return nil, fmt.Errorf("missing or zero Content-Length header")
|
||
}
|
||
|
||
body := make([]byte, contentLength)
|
||
if _, err := io.ReadFull(s.in, body); err != nil {
|
||
return nil, fmt.Errorf("reading message body: %w", err)
|
||
}
|
||
|
||
var msg Message
|
||
if err := json.Unmarshal(body, &msg); err != nil {
|
||
return nil, fmt.Errorf("unmarshalling message: %w", err)
|
||
}
|
||
return &msg, nil
|
||
}
|
||
|
||
// writeMessage encodes v as JSON and sends it with a Content-Length header.
|
||
func (s *Server) writeMessage(v any) error {
|
||
body, err := json.Marshal(v)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(body))
|
||
if _, err := io.WriteString(s.out, header); err != nil {
|
||
return err
|
||
}
|
||
_, err = s.out.Write(body)
|
||
return err
|
||
}
|
||
|
||
func (s *Server) respond(id json.RawMessage, result any) error {
|
||
raw, err := json.Marshal(result)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return s.writeMessage(struct {
|
||
JSONRPC string `json:"jsonrpc"`
|
||
ID json.RawMessage `json:"id"`
|
||
Result json.RawMessage `json:"result"`
|
||
}{"2.0", id, raw})
|
||
}
|
||
|
||
func (s *Server) respondError(id json.RawMessage, code int, message string) error {
|
||
return s.writeMessage(struct {
|
||
JSONRPC string `json:"jsonrpc"`
|
||
ID json.RawMessage `json:"id"`
|
||
Error RPCError `json:"error"`
|
||
}{"2.0", id, RPCError{Code: code, Message: message}})
|
||
}
|
||
|
||
func (s *Server) notify(method string, params any) error {
|
||
raw, err := json.Marshal(params)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return s.writeMessage(struct {
|
||
JSONRPC string `json:"jsonrpc"`
|
||
Method string `json:"method"`
|
||
Params json.RawMessage `json:"params"`
|
||
}{"2.0", method, raw})
|
||
}
|
||
|
||
// isRequest reports whether msg is a JSON-RPC request (has a non-null id).
|
||
func isRequest(msg *Message) bool {
|
||
return len(msg.ID) > 0 && string(msg.ID) != "null"
|
||
}
|
||
|
||
func (s *Server) dispatch(msg *Message) error {
|
||
switch msg.Method {
|
||
case "initialize":
|
||
return s.handleInitialize(msg)
|
||
case "initialized":
|
||
return nil // notification; no response required
|
||
case "shutdown":
|
||
s.shutdownReceived = true
|
||
if isRequest(msg) {
|
||
return s.respond(msg.ID, nil)
|
||
}
|
||
return nil
|
||
case "exit":
|
||
code := 1
|
||
if s.shutdownReceived {
|
||
code = 0
|
||
}
|
||
s.Exit(code)
|
||
return nil
|
||
case "textDocument/didOpen":
|
||
return s.handleDidOpen(msg)
|
||
case "textDocument/didChange":
|
||
return s.handleDidChange(msg)
|
||
case "textDocument/didSave":
|
||
return s.handleDidSave(msg)
|
||
case "textDocument/didClose":
|
||
return s.handleDidClose(msg)
|
||
default:
|
||
if isRequest(msg) {
|
||
return s.respondError(msg.ID, -32601, "method not found: "+msg.Method)
|
||
}
|
||
return nil
|
||
}
|
||
}
|
||
|
||
func (s *Server) handleInitialize(msg *Message) error {
|
||
return s.respond(msg.ID, InitializeResult{
|
||
Capabilities: ServerCapabilities{TextDocumentSync: 1},
|
||
ServerInfo: &ServerInfo{Name: "glint", Version: s.version},
|
||
})
|
||
}
|
||
|
||
func (s *Server) handleDidOpen(msg *Message) error {
|
||
var p DidOpenTextDocumentParams
|
||
if err := json.Unmarshal(msg.Params, &p); err != nil {
|
||
return nil // ignore malformed notifications
|
||
}
|
||
s.docs[p.TextDocument.URI] = p.TextDocument.Text
|
||
return s.lintAndPublish(p.TextDocument.URI, p.TextDocument.Text)
|
||
}
|
||
|
||
func (s *Server) handleDidChange(msg *Message) error {
|
||
var p DidChangeTextDocumentParams
|
||
if err := json.Unmarshal(msg.Params, &p); err != nil {
|
||
return nil
|
||
}
|
||
if len(p.ContentChanges) == 0 {
|
||
return nil
|
||
}
|
||
// Full sync: the last change event holds the complete new text.
|
||
text := p.ContentChanges[len(p.ContentChanges)-1].Text
|
||
s.docs[p.TextDocument.URI] = text
|
||
return s.lintAndPublish(p.TextDocument.URI, text)
|
||
}
|
||
|
||
func (s *Server) handleDidSave(msg *Message) error {
|
||
var p DidSaveTextDocumentParams
|
||
if err := json.Unmarshal(msg.Params, &p); err != nil {
|
||
return nil
|
||
}
|
||
text := s.docs[p.TextDocument.URI]
|
||
if p.Text != nil {
|
||
text = *p.Text
|
||
s.docs[p.TextDocument.URI] = text
|
||
}
|
||
if text == "" {
|
||
return nil
|
||
}
|
||
return s.lintAndPublish(p.TextDocument.URI, text)
|
||
}
|
||
|
||
func (s *Server) handleDidClose(msg *Message) error {
|
||
var p DidCloseTextDocumentParams
|
||
if err := json.Unmarshal(msg.Params, &p); err != nil {
|
||
return nil
|
||
}
|
||
delete(s.docs, p.TextDocument.URI)
|
||
// Clear diagnostics so the editor doesn't show stale squiggles.
|
||
return s.notify("textDocument/publishDiagnostics", PublishDiagnosticsParams{
|
||
URI: p.TextDocument.URI,
|
||
Diagnostics: []Diagnostic{},
|
||
})
|
||
}
|
||
|
||
func (s *Server) lintAndPublish(uri, text string) error {
|
||
diags := s.lintDocument(uri, text)
|
||
return s.notify("textDocument/publishDiagnostics", PublishDiagnosticsParams{
|
||
URI: uri,
|
||
Diagnostics: diags,
|
||
})
|
||
}
|
||
|
||
// lintDocument parses text and runs all lint rules, returning LSP Diagnostics.
|
||
// Findings that originate from included files (not the root document) are
|
||
// excluded; their URIs are not tracked so line numbers would be incorrect.
|
||
func (s *Server) lintDocument(uri, text string) []Diagnostic {
|
||
path := uriToPath(uri)
|
||
if path == "" {
|
||
return []Diagnostic{}
|
||
}
|
||
rootDir := filepath.Dir(filepath.Clean(path))
|
||
|
||
pipeline, err := model.ParseBytes([]byte(text))
|
||
if err != nil {
|
||
return []Diagnostic{{
|
||
Range: Range{Start: Position{}, End: Position{}},
|
||
Severity: 1,
|
||
Source: "glint",
|
||
Message: "YAML parse error: " + err.Error(),
|
||
}}
|
||
}
|
||
pipeline.SourceFile = path
|
||
pipeline.SetJobOrigin(path)
|
||
|
||
// Include resolution is best-effort: network failures produce warnings that
|
||
// are intentionally discarded here. The linter operates on whatever was
|
||
// successfully resolved.
|
||
_, _ = resolver.ResolveIncludes(pipeline, s.cfg, rootDir)
|
||
_, _ = resolver.Resolve(pipeline)
|
||
|
||
findings := linter.Lint(pipeline, nil)
|
||
|
||
diags := make([]Diagnostic, 0, len(findings))
|
||
for _, f := range findings {
|
||
// Skip findings from included files — their line numbers reference
|
||
// a different document URI that the server has not opened.
|
||
if f.File != path && f.File != "" {
|
||
continue
|
||
}
|
||
line := 0
|
||
if f.Line > 0 {
|
||
line = f.Line - 1 // glint uses 1-based lines; LSP uses 0-based
|
||
}
|
||
sev := 1 // DiagnosticSeverity: Error
|
||
if f.Severity == linter.Warning {
|
||
sev = 2 // DiagnosticSeverity: Warning
|
||
}
|
||
msg := f.Message
|
||
if f.Job != "" {
|
||
msg = fmt.Sprintf("job %q: %s", f.Job, f.Message)
|
||
}
|
||
diags = append(diags, Diagnostic{
|
||
Range: Range{
|
||
Start: Position{Line: line},
|
||
End: Position{Line: line},
|
||
},
|
||
Severity: sev,
|
||
Code: f.Rule,
|
||
Source: "glint",
|
||
Message: msg,
|
||
})
|
||
}
|
||
return diags
|
||
}
|
||
|
||
// uriToPath converts a file:// URI to a local filesystem path.
|
||
// Returns an empty string for non-file URIs or on parse error.
|
||
func uriToPath(uri string) string {
|
||
u, err := url.Parse(uri)
|
||
if err != nil || u.Scheme != "file" {
|
||
return ""
|
||
}
|
||
return filepath.FromSlash(u.Path)
|
||
}
|