From 2c45b343c2cc5f9f0771f69d89f5607de29d7a11 Mon Sep 17 00:00:00 2001 From: k3nny Date: Fri, 26 Jun 2026 01:01:41 +0200 Subject: [PATCH] feat(lsp): add Language Server Protocol server (glint lsp) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 6 + README.md | 4 +- ROADMAP.md | 2 +- cmd/glint/lsp.go | 71 +++++++ cmd/glint/main.go | 3 + internal/lsp/server.go | 332 +++++++++++++++++++++++++++++++ internal/lsp/server_test.go | 383 ++++++++++++++++++++++++++++++++++++ internal/lsp/types.go | 112 +++++++++++ 8 files changed, 911 insertions(+), 2 deletions(-) create mode 100644 cmd/glint/lsp.go create mode 100644 internal/lsp/server.go create mode 100644 internal/lsp/server_test.go create mode 100644 internal/lsp/types.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 74ee42d..260099a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project uses [Semantic Versioning](https://semver.org). +## [0.2.29] - 2026-06-26 + +### Added + +- **LSP server** (`glint lsp`) — new `internal/lsp` package and `glint lsp` subcommand that starts a Language Server Protocol server over stdin/stdout using Content-Length–framed JSON-RPC 2.0. Editors (VS Code, Neovim, Emacs, JetBrains, etc.) can connect with any generic LSP client configuration. Supported methods: `initialize`, `initialized`, `shutdown`, `exit`, `textDocument/didOpen`, `textDocument/didChange`, `textDocument/didSave`, `textDocument/didClose`. On every document open or change the server runs the full glint lint pipeline and publishes diagnostics via `textDocument/publishDiagnostics`; each diagnostic carries the rule ID as its `code` field and `"glint"` as `source`. Parse errors are surfaced as an Error diagnostic at the top of the document. Include resolution is best-effort (uses `GITLAB_TOKEN` / `GITLAB_URL` env vars; default cache dir `~/.cache/glint`). CLI flags: `--token`, `--gitlab-url`, `--cache-dir`, `--offline`. + ## [0.2.28] - 2026-06-26 ### Added diff --git a/README.md b/README.md index ad1ecfe..0a34bc4 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

License - Release + Release

> **Disclaimer:** This tool was built through iterative AI-assisted development with [Claude](https://claude.ai). It is experimental, incomplete, and not intended for production use. Coverage of GitLab CI keywords is best-effort and may lag behind GitLab's evolving spec. Use it at your own discretion — no correctness guarantees are made. Contributions and bug reports are welcome. @@ -21,6 +21,7 @@ A local tool to validate and lint `.gitlab-ci.yml` pipelines without needing a G - **Multiple output formats** — `--format text` (default, ruff-style), `json`, `sarif` (GitHub Code Scanning / GitLab SAST), `junit`, `github` (PR annotations) - **Project config** — `.glint.yml` for rule suppression, severity overrides, token/URL defaults; `# glint: ignore RULE` for per-job inline suppression - **Graph visualization** — `glint graph` prints a terminal job tree; `glint graph pipeline` renders a GitLab CI-style SVG/PNG; `--format mermaid` emits a Mermaid flowchart; `--format html` produces a self-contained HTML file with pan/zoom and a job-detail sidebar; context flags grey out skipped jobs +- **LSP server** — `glint lsp` starts a Language Server Protocol server over stdin/stdout; connect with any LSP client to get inline diagnostics (rule ID as code, error/warning severity) in VS Code, Neovim, Emacs, JetBrains, etc. See [FEATURES.md](FEATURES.md) for the complete feature reference and lint rules table, and [ROADMAP.md](ROADMAP.md) for planned improvements. @@ -52,6 +53,7 @@ Commands: check Lint a pipeline file — exits 0 (clean) or 1 (errors found) graph Visualise the pipeline as a job tree or Mermaid graph explain Print description and fix for a lint rule + lsp Start a Language Server Protocol server (stdin/stdout) ``` Run `glint --help` for all flags. See [USAGE.md](USAGE.md) for full diff --git a/ROADMAP.md b/ROADMAP.md index 5fcfbde..5384687 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -97,7 +97,7 @@ The SVG renderer and terminal tree cover the basic layout. These would bring it - [x] **GitLab CI template** — shipped v0.2.28; `templates/check.yml` is a GitLab CI/CD Catalog component with `spec:` inputs for stage, file, version, allow_failure, and extra args; also usable as a plain local/remote include - [x] **GitHub Actions action** — shipped v0.2.28; `action.yml` composite action downloads the glint Linux binary and runs `glint check`; mirror to GitHub as `k3nny/glint` to reference as `uses: k3nny/glint@v0.2.28` - [x] **Pre-commit hook** — shipped v0.2.28; `.pre-commit-hooks.yaml` defines `language: golang` hook; pre-commit builds glint from source on first run and re-runs on staged `.gitlab-ci.yml` changes -- [ ] **LSP server** — `glint lsp` mode exposing diagnostics over the Language Server Protocol; enables inline squiggles in VS Code, JetBrains, Neovim, etc. without a dedicated extension +- [x] **LSP server** — shipped v0.2.29; `glint lsp` runs a JSON-RPC 2.0 LSP server over stdin/stdout; `textDocument/didOpen`, `didChange`, `didSave`, `didClose` all publish diagnostics; rule IDs appear as the diagnostic `code`; include resolution is best-effort using env-var token and default cache dir - [ ] **VS Code extension** — thin wrapper around the LSP server with syntax highlighting for `.gitlab-ci.yml` --- diff --git a/cmd/glint/lsp.go b/cmd/glint/lsp.go new file mode 100644 index 0000000..33f35c3 --- /dev/null +++ b/cmd/glint/lsp.go @@ -0,0 +1,71 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "git.k3nny.fr/glint/internal/fetcher" + "git.k3nny.fr/glint/internal/lsp" +) + +func cmdLSP(args []string) { + fs := flag.NewFlagSet("glint lsp", flag.ExitOnError) + token := fs.String("token", "", "GitLab personal access token (overrides GITLAB_TOKEN)") + gitlabURL := fs.String("gitlab-url", "", "GitLab instance URL (overrides CI_SERVER_URL / GITLAB_URL)") + cacheDir := fs.String("cache-dir", "", "directory for caching fetched remote includes") + offline := fs.Bool("offline", false, "skip all network calls; serve only from --cache-dir") + fs.Usage = func() { + fmt.Fprintf(os.Stderr, "glint %s\n\n", version) + fmt.Fprint(os.Stderr, `Start a Language Server Protocol server for .gitlab-ci.yml files. + +Reads JSON-RPC 2.0 messages from stdin and writes responses to stdout using +the standard Content-Length framing. Connect with any LSP client (VS Code, +Neovim, Emacs, etc.). + +Usage: glint lsp [OPTIONS] + +Options: + --token + GitLab personal access token used for resolving project: and + component: includes. Defaults to GITLAB_TOKEN env var. + + --gitlab-url + GitLab instance URL for resolving remote includes. + [env: CI_SERVER_URL | GITLAB_URL] [default: https://gitlab.com] + + --cache-dir + Cache directory for fetched remote includes. Defaults to + ~/.cache/glint so subsequent opens are served from cache. + + --offline + Do not make any network calls; resolve only local includes. + Implies --cache-dir default (~/.cache/glint) when not set. + + -h, --help + Print help + +Examples: + glint lsp + glint lsp --token glpat-xxxx --cache-dir ~/.cache/glint + glint lsp --offline +`) + } + _ = fs.Parse(args) + + resolvedCacheDir := *cacheDir + if resolvedCacheDir == "" { + resolvedCacheDir = defaultCacheDir() + } + if *offline && resolvedCacheDir == "" { + resolvedCacheDir = defaultCacheDir() + } + + cfg := fetcher.AutoConfig().WithOverrides(*gitlabURL, *token, resolvedCacheDir, *offline) + + srv := lsp.New(os.Stdin, os.Stdout, cfg, version) + if err := srv.Run(); err != nil { + fmt.Fprintf(os.Stderr, "glint lsp: %v\n", err) + exit(2) + } +} diff --git a/cmd/glint/main.go b/cmd/glint/main.go index 0856ca1..a128fa3 100644 --- a/cmd/glint/main.go +++ b/cmd/glint/main.go @@ -69,6 +69,7 @@ Commands: check Lint a pipeline file — exits 0 (clean) or 1 (errors found) graph Visualise the pipeline as a job tree or Mermaid graph explain Show description and fix for a lint rule (e.g. glint explain GL007) + lsp Start a Language Server Protocol server (stdin/stdout) Options: -h, --help Print help @@ -90,6 +91,8 @@ func main() { cmdGraph(os.Args[2:]) case "explain": cmdExplain(os.Args[2:]) + case "lsp": + cmdLSP(os.Args[2:]) case "-h", "--help", "help": fmt.Fprintf(os.Stderr, "glint %s\n\n", version) fmt.Fprint(os.Stderr, globalUsage) diff --git a/internal/lsp/server.go b/internal/lsp/server.go new file mode 100644 index 0000000..edd44cf --- /dev/null +++ b/internal/lsp/server.go @@ -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) +} diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go new file mode 100644 index 0000000..fe749ff --- /dev/null +++ b/internal/lsp/server_test.go @@ -0,0 +1,383 @@ +package lsp + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "testing" + + "git.k3nny.fr/glint/internal/fetcher" +) + +// frame encodes v as a Content-Length–framed LSP message. +func frame(t *testing.T, v any) []byte { + t.Helper() + body, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + hdr := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(body)) + return append([]byte(hdr), body...) +} + +// readMsg reads one Content-Length–framed JSON object from r. +func readMsg(t *testing.T, r *bufio.Reader) map[string]json.RawMessage { + t.Helper() + var contentLength int + for { + line, err := r.ReadString('\n') + if err != nil { + t.Fatalf("reading header: %v", err) + } + line = strings.TrimRight(line, "\r\n") + if line == "" { + break + } + if strings.HasPrefix(line, "Content-Length: ") { + n, err := strconv.Atoi(strings.TrimPrefix(line, "Content-Length: ")) + if err != nil { + t.Fatalf("invalid Content-Length: %v", err) + } + contentLength = n + } + } + body := make([]byte, contentLength) + if _, err := io.ReadFull(r, body); err != nil { + t.Fatalf("reading body: %v", err) + } + var m map[string]json.RawMessage + if err := json.Unmarshal(body, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return m +} + +// newTestServer returns a Server with a captured exit code and a bufio.Reader +// wrapping the output buffer so tests can read back server messages. +func newTestServer(input []byte) (*Server, *bytes.Buffer, *int) { + var out bytes.Buffer + exitCode := -1 + srv := New(bytes.NewReader(input), &out, fetcher.GitLabConfig{}, "test") + srv.Exit = func(code int) { exitCode = code } + return srv, &out, &exitCode +} + +func TestServer_Initialize(t *testing.T) { + input := frame(t, map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": map[string]any{}, + }) + srv, out, _ := newTestServer(input) + srv.Run() //nolint:errcheck + + resp := readMsg(t, bufio.NewReader(out)) + if string(resp["id"]) != "1" { + t.Errorf("response id = %s; want 1", resp["id"]) + } + + var result InitializeResult + if err := json.Unmarshal(resp["result"], &result); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + if result.Capabilities.TextDocumentSync != 1 { + t.Errorf("textDocumentSync = %d; want 1", result.Capabilities.TextDocumentSync) + } + if result.ServerInfo == nil || result.ServerInfo.Name != "glint" { + t.Errorf("serverInfo.name = %v; want glint", result.ServerInfo) + } +} + +func TestServer_ShutdownExit(t *testing.T) { + var buf bytes.Buffer + buf.Write(frame(t, map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": map[string]any{}, + })) + buf.Write(frame(t, map[string]any{ + "jsonrpc": "2.0", "method": "initialized", "params": map[string]any{}, + })) + buf.Write(frame(t, map[string]any{ + "jsonrpc": "2.0", "id": 2, "method": "shutdown", + })) + buf.Write(frame(t, map[string]any{ + "jsonrpc": "2.0", "method": "exit", + })) + + srv, out, exitCode := newTestServer(buf.Bytes()) + srv.Run() //nolint:errcheck + + r := bufio.NewReader(out) + initResp := readMsg(t, r) + if string(initResp["id"]) != "1" { + t.Errorf("init response id = %s; want 1", initResp["id"]) + } + shutResp := readMsg(t, r) + if string(shutResp["id"]) != "2" { + t.Errorf("shutdown response id = %s; want 2", shutResp["id"]) + } + if string(shutResp["result"]) != "null" { + t.Errorf("shutdown result = %s; want null", shutResp["result"]) + } + if *exitCode != 0 { + t.Errorf("exit code = %d; want 0", *exitCode) + } +} + +func TestServer_ExitWithoutShutdown(t *testing.T) { + input := frame(t, map[string]any{ + "jsonrpc": "2.0", "method": "exit", + }) + srv, _, exitCode := newTestServer(input) + srv.Run() //nolint:errcheck + + if *exitCode != 1 { + t.Errorf("exit code = %d; want 1 (no prior shutdown)", *exitCode) + } +} + +func TestServer_MethodNotFound(t *testing.T) { + input := frame(t, map[string]any{ + "jsonrpc": "2.0", "id": 99, "method": "workspace/unknownMethod", + }) + srv, out, _ := newTestServer(input) + srv.Run() //nolint:errcheck + + resp := readMsg(t, bufio.NewReader(out)) + if resp["error"] == nil { + t.Errorf("expected error response for unknown method, got: %v", resp) + } + var rpcErr RPCError + if err := json.Unmarshal(resp["error"], &rpcErr); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if rpcErr.Code != -32601 { + t.Errorf("error code = %d; want -32601", rpcErr.Code) + } +} + +func TestServer_UnknownNotificationIgnored(t *testing.T) { + // Notifications (no id) for unknown methods must be silently ignored. + input := frame(t, map[string]any{ + "jsonrpc": "2.0", "method": "$/setTrace", "params": map[string]any{"value": "off"}, + }) + srv, out, _ := newTestServer(input) + srv.Run() //nolint:errcheck + + if out.Len() > 0 { + t.Errorf("server wrote %d bytes for unknown notification; want 0", out.Len()) + } +} + +func TestServer_DidOpen_CleanPipeline(t *testing.T) { + yaml := `stages: [build] + +build-job: + stage: build + script: echo hello +` + // Use a pseudo file:// URI that maps to the tmp path; include resolution + // will fail silently (no network, no local includes) which is fine. + uri := "file:///tmp/test.gitlab-ci.yml" + + input := frame(t, map[string]any{ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": map[string]any{ + "textDocument": map[string]any{ + "uri": uri, "languageId": "yaml", "version": 1, "text": yaml, + }, + }, + }) + srv, out, _ := newTestServer(input) + srv.Run() //nolint:errcheck + + notif := readMsg(t, bufio.NewReader(out)) + if string(notif["method"]) != `"textDocument/publishDiagnostics"` { + t.Fatalf("method = %s; want textDocument/publishDiagnostics", notif["method"]) + } + var params PublishDiagnosticsParams + if err := json.Unmarshal(notif["params"], ¶ms); err != nil { + t.Fatalf("unmarshal params: %v", err) + } + if params.URI != uri { + t.Errorf("uri = %q; want %q", params.URI, uri) + } + // A clean pipeline should produce no diagnostics (or only warnings from + // include resolution being skipped — but those are filtered since they + // originate from a different file path). + for _, d := range params.Diagnostics { + if d.Severity == 1 { + t.Errorf("unexpected error diagnostic: %s", d.Message) + } + } +} + +func TestServer_DidOpen_WithErrors(t *testing.T) { + // A pipeline with a job in an undeclared stage triggers GL004. + yaml := `stages: [build] + +bad-job: + stage: missing-stage + script: echo hi +` + uri := "file:///tmp/bad.gitlab-ci.yml" + input := frame(t, map[string]any{ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": map[string]any{ + "textDocument": map[string]any{ + "uri": uri, "languageId": "yaml", "version": 1, "text": yaml, + }, + }, + }) + srv, out, _ := newTestServer(input) + srv.Run() //nolint:errcheck + + notif := readMsg(t, bufio.NewReader(out)) + var params PublishDiagnosticsParams + if err := json.Unmarshal(notif["params"], ¶ms); err != nil { + t.Fatalf("unmarshal params: %v", err) + } + if len(params.Diagnostics) == 0 { + t.Error("expected diagnostics for pipeline with unknown stage, got none") + } + found := false + for _, d := range params.Diagnostics { + if d.Code == "GL004" { + found = true + if d.Severity != 1 { + t.Errorf("GL004 severity = %d; want 1 (Error)", d.Severity) + } + } + } + if !found { + t.Errorf("expected GL004 diagnostic, got: %v", params.Diagnostics) + } +} + +func TestServer_DidOpen_ParseError(t *testing.T) { + uri := "file:///tmp/broken.gitlab-ci.yml" + input := frame(t, map[string]any{ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": map[string]any{ + "textDocument": map[string]any{ + "uri": uri, "languageId": "yaml", "version": 1, + "text": "?", // bare ? yields empty job name → parse error + }, + }, + }) + srv, out, _ := newTestServer(input) + srv.Run() //nolint:errcheck + + notif := readMsg(t, bufio.NewReader(out)) + var params PublishDiagnosticsParams + if err := json.Unmarshal(notif["params"], ¶ms); err != nil { + t.Fatalf("unmarshal params: %v", err) + } + if len(params.Diagnostics) == 0 { + t.Fatal("expected parse-error diagnostic, got none") + } + d := params.Diagnostics[0] + if d.Severity != 1 { + t.Errorf("severity = %d; want 1 (Error)", d.Severity) + } + if !strings.Contains(d.Message, "YAML parse error") { + t.Errorf("message = %q; want YAML parse error", d.Message) + } +} + +func TestServer_DidChange(t *testing.T) { + uri := "file:///tmp/ci.gitlab-ci.yml" + var buf bytes.Buffer + // Open with clean content. + buf.Write(frame(t, map[string]any{ + "jsonrpc": "2.0", "method": "textDocument/didOpen", + "params": map[string]any{"textDocument": map[string]any{ + "uri": uri, "languageId": "yaml", "version": 1, + "text": "stages: [build]\nbuild: {stage: build, script: echo}\n", + }}, + })) + // Change to content with an error. + buf.Write(frame(t, map[string]any{ + "jsonrpc": "2.0", "method": "textDocument/didChange", + "params": map[string]any{ + "textDocument": map[string]any{"uri": uri, "version": 2}, + "contentChanges": []map[string]any{ + {"text": "stages: [build]\nbad: {stage: gone, script: hi}\n"}, + }, + }, + })) + + srv, out, _ := newTestServer(buf.Bytes()) + srv.Run() //nolint:errcheck + + r := bufio.NewReader(out) + _ = readMsg(t, r) // first publishDiagnostics (clean) + second := readMsg(t, r) + + var params PublishDiagnosticsParams + if err := json.Unmarshal(second["params"], ¶ms); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(params.Diagnostics) == 0 { + t.Error("expected diagnostics after change to broken content, got none") + } +} + +func TestServer_DidClose_ClearsdiAgnostics(t *testing.T) { + uri := "file:///tmp/toclose.gitlab-ci.yml" + var buf bytes.Buffer + buf.Write(frame(t, map[string]any{ + "jsonrpc": "2.0", "method": "textDocument/didOpen", + "params": map[string]any{"textDocument": map[string]any{ + "uri": uri, "languageId": "yaml", "version": 1, + "text": "stages: [build]\nj: {stage: build, script: echo}\n", + }}, + })) + buf.Write(frame(t, map[string]any{ + "jsonrpc": "2.0", "method": "textDocument/didClose", + "params": map[string]any{"textDocument": map[string]any{"uri": uri}}, + })) + + srv, out, _ := newTestServer(buf.Bytes()) + srv.Run() //nolint:errcheck + + r := bufio.NewReader(out) + _ = readMsg(t, r) // publishDiagnostics from didOpen + + closeNotif := readMsg(t, r) + var params PublishDiagnosticsParams + if err := json.Unmarshal(closeNotif["params"], ¶ms); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if params.URI != uri { + t.Errorf("uri = %q; want %q", params.URI, uri) + } + if len(params.Diagnostics) != 0 { + t.Errorf("expected empty diagnostics on close, got %v", params.Diagnostics) + } +} + +func TestServer_UriToPath(t *testing.T) { + tests := []struct { + uri string + want string + }{ + {"file:///tmp/ci.yml", "/tmp/ci.yml"}, + {"file:///home/user/project/.gitlab-ci.yml", "/home/user/project/.gitlab-ci.yml"}, + {"https://example.com/file.yml", ""}, + {"not-a-uri", ""}, + } + for _, tc := range tests { + got := uriToPath(tc.uri) + if got != tc.want { + t.Errorf("uriToPath(%q) = %q; want %q", tc.uri, got, tc.want) + } + } +} diff --git a/internal/lsp/types.go b/internal/lsp/types.go new file mode 100644 index 0000000..7f25845 --- /dev/null +++ b/internal/lsp/types.go @@ -0,0 +1,112 @@ +// Package lsp implements a minimal Language Server Protocol server for glint. +package lsp + +import "encoding/json" + +// Message is a JSON-RPC 2.0 message (request, response, or notification). +type Message struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *RPCError `json:"error,omitempty"` +} + +// RPCError is a JSON-RPC 2.0 error object. +type RPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// InitializeResult is the server's response to the initialize request. +type InitializeResult struct { + Capabilities ServerCapabilities `json:"capabilities"` + ServerInfo *ServerInfo `json:"serverInfo,omitempty"` +} + +// ServerCapabilities advertises what the server supports. +type ServerCapabilities struct { + // TextDocumentSync: 1 = Full (send entire document on every change). + TextDocumentSync int `json:"textDocumentSync"` +} + +// ServerInfo identifies the server to the client. +type ServerInfo struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` +} + +// TextDocumentItem is a text document opened by the client. +type TextDocumentItem struct { + URI string `json:"uri"` + LanguageID string `json:"languageId"` + Version int `json:"version"` + Text string `json:"text"` +} + +// TextDocumentIdentifier references a text document by URI. +type TextDocumentIdentifier struct { + URI string `json:"uri"` +} + +// VersionedTextDocumentIdentifier includes a version number. +type VersionedTextDocumentIdentifier struct { + URI string `json:"uri"` + Version int `json:"version"` +} + +// TextDocumentContentChangeEvent is a single content change event. +// With Full sync the Text field contains the complete new document text. +type TextDocumentContentChangeEvent struct { + Text string `json:"text"` +} + +// DidOpenTextDocumentParams is the params for textDocument/didOpen. +type DidOpenTextDocumentParams struct { + TextDocument TextDocumentItem `json:"textDocument"` +} + +// DidChangeTextDocumentParams is the params for textDocument/didChange. +type DidChangeTextDocumentParams struct { + TextDocument VersionedTextDocumentIdentifier `json:"textDocument"` + ContentChanges []TextDocumentContentChangeEvent `json:"contentChanges"` +} + +// DidSaveTextDocumentParams is the params for textDocument/didSave. +type DidSaveTextDocumentParams struct { + TextDocument TextDocumentIdentifier `json:"textDocument"` + Text *string `json:"text,omitempty"` +} + +// DidCloseTextDocumentParams is the params for textDocument/didClose. +type DidCloseTextDocumentParams struct { + TextDocument TextDocumentIdentifier `json:"textDocument"` +} + +// PublishDiagnosticsParams is the params for textDocument/publishDiagnostics. +type PublishDiagnosticsParams struct { + URI string `json:"uri"` + Diagnostics []Diagnostic `json:"diagnostics"` +} + +// Diagnostic is a lint finding expressed in LSP terms. +type Diagnostic struct { + Range Range `json:"range"` + Severity int `json:"severity"` // 1=Error, 2=Warning, 3=Information, 4=Hint + Code string `json:"code,omitempty"` + Source string `json:"source,omitempty"` + Message string `json:"message"` +} + +// Range is a zero-based line/character range within a text document. +type Range struct { + Start Position `json:"start"` + End Position `json:"end"` +} + +// Position is a zero-based line and character offset. +type Position struct { + Line int `json:"line"` + Character int `json:"character"` +}