feat(lsp): add Language Server Protocol server (glint lsp)
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>
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user