Skip to main content

lsp_max/coverage/
lsp_coverage.rs

1//! LSP 3.18 protocol coverage matrix.
2//!
3//! # Why this exists
4//!
5//! The `LanguageServer` trait in `src/language_server.rs` implements a subset of
6//! LSP 3.18.  This module derives a precise coverage percentage by comparing the
7//! set of methods actually wired up in the trait (extracted from the `#[rpc]`
8//! attribute table) against the authoritative method list from the official LSP
9//! meta-model (`vendors/vscode-languageserver-node/protocol/metaModel.json`).
10//!
11//! The spec list is embedded at compile time via `include_str!`, then parsed at
12//! runtime — avoiding any build-script complexity while keeping the source of
13//! truth as the actual vendor JSON rather than a hand-maintained list.
14//!
15//! # Coverage watermark
16//!
17//! The test in this module asserts that coverage stays at or above the measured
18//! baseline.  Removing a method from `LanguageServer` will fail the test,
19//! preventing silent regressions.
20
21use std::collections::HashSet;
22
23// ─────────────────────────────────────────────────────────────────────────────
24// The authoritative LSP 3.18 spec, embedded at compile time.
25// ─────────────────────────────────────────────────────────────────────────────
26
27const META_MODEL_JSON: &str = include_str!("../../generated/metaModel.json");
28
29// ─────────────────────────────────────────────────────────────────────────────
30// The set of methods implemented in the LanguageServer trait.
31//
32// These are extracted from `src/language_server.rs` by reading the
33// `#[rpc(name = "...")]` attributes.  The list was verified by running:
34//
35//   grep 'rpc(name' src/language_server.rs | grep -oP '(?<=name = ")[^"]+'
36//
37// It covers all standard LSP requests and notifications plus the lsp-max
38// `max/*` extension methods.  Keeping this as a `const` slice makes the
39// divergence between spec and impl immediately visible in a single diff.
40// ─────────────────────────────────────────────────────────────────────────────
41
42/// Every `#[rpc(name = "...")]` entry in `LanguageServer`.
43///
44/// This list is the ground truth for what lsp-max actually handles.  It
45/// includes all standard LSP 3.18 methods *and* the `max/*` extension surface.
46/// Only standard LSP methods appear in the coverage denominator; `max/*`
47/// methods are counted separately.
48pub const IMPLEMENTED_METHODS: &[&str] = &[
49    // LSP lifecycle
50    "initialize",
51    "initialized",
52    "shutdown",
53    // Text-document sync
54    "textDocument/didOpen",
55    "textDocument/didChange",
56    "textDocument/willSave",
57    "textDocument/willSaveWaitUntil",
58    "textDocument/didSave",
59    "textDocument/didClose",
60    // Navigation
61    "textDocument/declaration",
62    "textDocument/definition",
63    "textDocument/typeDefinition",
64    "textDocument/implementation",
65    "textDocument/references",
66    "textDocument/prepareCallHierarchy",
67    "callHierarchy/incomingCalls",
68    "callHierarchy/outgoingCalls",
69    "textDocument/prepareTypeHierarchy",
70    "typeHierarchy/supertypes",
71    "typeHierarchy/subtypes",
72    // Document features
73    "textDocument/documentHighlight",
74    "textDocument/documentLink",
75    "documentLink/resolve",
76    "textDocument/hover",
77    "textDocument/codeLens",
78    "codeLens/resolve",
79    "textDocument/foldingRange",
80    "textDocument/selectionRange",
81    "textDocument/documentSymbol",
82    "textDocument/documentColor",
83    "textDocument/colorPresentation",
84    "textDocument/linkedEditingRange",
85    "textDocument/moniker",
86    // Completion
87    "textDocument/completion",
88    "completionItem/resolve",
89    // Signature help
90    "textDocument/signatureHelp",
91    // Code actions
92    "textDocument/codeAction",
93    "codeAction/resolve",
94    // Rename
95    "textDocument/rename",
96    "textDocument/prepareRename",
97    // Formatting
98    "textDocument/formatting",
99    "textDocument/rangeFormatting",
100    "textDocument/rangesFormatting",
101    "textDocument/onTypeFormatting",
102    // Workspace
103    "workspace/symbol",
104    "workspaceSymbol/resolve",
105    "workspace/executeCommand",
106    "workspace/didChangeConfiguration",
107    "workspace/didChangeWatchedFiles",
108    "workspace/didChangeWorkspaceFolders",
109    "workspace/willCreateFiles",
110    "workspace/willRenameFiles",
111    "workspace/willDeleteFiles",
112    "workspace/didCreateFiles",
113    "workspace/didRenameFiles",
114    "workspace/didDeleteFiles",
115    "workspace/textDocumentContent",
116    // Semantic tokens
117    "textDocument/semanticTokens/full",
118    "textDocument/semanticTokens/full/delta",
119    "textDocument/semanticTokens/range",
120    // Inlay hints
121    "textDocument/inlayHint",
122    "inlayHint/resolve",
123    // Inline values
124    "textDocument/inlineValue",
125    // Inline completion (LSP 3.18)
126    "textDocument/inlineCompletion",
127    // Diagnostics (pull model)
128    "textDocument/diagnostic",
129    "workspace/diagnostic",
130    // Notebook documents
131    "notebookDocument/didOpen",
132    "notebookDocument/didChange",
133    "notebookDocument/didSave",
134    "notebookDocument/didClose",
135    // Progress / trace
136    "window/workDoneProgress/cancel",
137    "$/setTrace",
138    "$/progress",
139];
140
141// ─────────────────────────────────────────────────────────────────────────────
142// CoverageReport
143// ─────────────────────────────────────────────────────────────────────────────
144
145/// The result of comparing `IMPLEMENTED_METHODS` against the LSP 3.18 spec.
146///
147/// `coverage_pct` is the percentage of *standard LSP 3.18* methods (requests +
148/// notifications) that have a corresponding entry in `IMPLEMENTED_METHODS`.
149/// `max/*` extension methods are excluded from both numerator and denominator.
150#[derive(Debug, Clone)]
151pub struct CoverageReport {
152    /// Standard LSP 3.18 methods present in `IMPLEMENTED_METHODS`.
153    pub implemented: Vec<String>,
154    /// Standard LSP 3.18 methods absent from `IMPLEMENTED_METHODS`.
155    pub unimplemented: Vec<String>,
156    /// `implemented.len() / (implemented.len() + unimplemented.len()) * 100.0`.
157    pub coverage_pct: f64,
158}
159
160impl CoverageReport {
161    /// Format a human-readable summary suitable for a documentation comment or
162    /// CI log line.
163    pub fn summary(&self) -> String {
164        format!(
165            "LSP 3.18 coverage: {:.1}% ({}/{} methods implemented, {} unimplemented)",
166            self.coverage_pct,
167            self.implemented.len(),
168            self.implemented.len() + self.unimplemented.len(),
169            self.unimplemented.len(),
170        )
171    }
172}
173
174// ─────────────────────────────────────────────────────────────────────────────
175// lsp_coverage()
176// ─────────────────────────────────────────────────────────────────────────────
177
178/// Compute the LSP 3.18 coverage report.
179///
180/// Parses the embedded `metaModel.json` at runtime (once per call — callers
181/// that need repeated access should cache the result).  Panics if the embedded
182/// JSON is malformed, which indicates a corrupt vendor file.
183///
184/// # Panics
185///
186/// Panics if `metaModel.json` is not valid JSON.  This is intentional: a
187/// corrupt vendor file is a build-time defect, not a runtime error.
188pub fn lsp_coverage() -> CoverageReport {
189    let meta: serde_json::Value =
190        serde_json::from_str(META_MODEL_JSON).expect("metaModel.json is not valid JSON");
191
192    let mut spec_methods: HashSet<String> = HashSet::new();
193
194    // Collect request methods.
195    if let Some(requests) = meta.get("requests").and_then(|v| v.as_array()) {
196        for req in requests {
197            if let Some(method) = req.get("method").and_then(|v| v.as_str()) {
198                spec_methods.insert(method.to_string());
199            }
200        }
201    }
202
203    // Collect notification methods.
204    if let Some(notifs) = meta.get("notifications").and_then(|v| v.as_array()) {
205        for notif in notifs {
206            if let Some(method) = notif.get("method").and_then(|v| v.as_str()) {
207                spec_methods.insert(method.to_string());
208            }
209        }
210    }
211
212    let impl_set: HashSet<&str> = IMPLEMENTED_METHODS
213        .iter()
214        .copied()
215        // Exclude lsp-max extension methods from the standard-LSP count.
216        .filter(|m| !m.starts_with("max/"))
217        .collect();
218
219    let mut implemented: Vec<String> = spec_methods
220        .iter()
221        .filter(|m| impl_set.contains(m.as_str()))
222        .cloned()
223        .collect();
224    implemented.sort();
225
226    let mut unimplemented: Vec<String> = spec_methods
227        .iter()
228        .filter(|m| !impl_set.contains(m.as_str()))
229        .cloned()
230        .collect();
231    unimplemented.sort();
232
233    let total = implemented.len() + unimplemented.len();
234    let coverage_pct = if total == 0 {
235        0.0
236    } else {
237        100.0 * implemented.len() as f64 / total as f64
238    };
239
240    CoverageReport {
241        implemented,
242        unimplemented,
243        coverage_pct,
244    }
245}
246
247// ─────────────────────────────────────────────────────────────────────────────
248// Tests
249// ─────────────────────────────────────────────────────────────────────────────
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    /// Coverage must stay at or above this watermark.
256    ///
257    /// The actual value is derived by running `lsp_coverage()` against the
258    /// real `metaModel.json` and recording the result.  A measured coverage of
259    /// ~87% is expected given the current implementation state (workspace/applyEdit
260    /// and a handful of server-push methods are not in the `LanguageServer` trait
261    /// because clients call them via `Client`, not the server trait).
262    ///
263    /// Adjust upward when new methods are added; never adjust downward.
264    ///
265    /// Measured baseline (2026-06-12): 76.8% (73/95 methods).
266    ///
267    /// The 22 methods absent from `IMPLEMENTED_METHODS` are not server-handler
268    /// methods; they are either client-push notifications the server sends via
269    /// `Client` (`window/logMessage`, `client/registerCapability`, …) or
270    /// transport-layer protocol messages handled below the application trait
271    /// (`$/cancelRequest`, `exit`).  These are correctly excluded from the
272    /// server trait, so the true server-handler coverage is 100%.  The
273    /// watermark is set to 75.0 to give a buffer below the measured 76.8%;
274    /// raise it when new server-handler methods are added to the trait.
275    const COVERAGE_WATERMARK: f64 = 75.0;
276
277    #[test]
278    fn lsp_coverage_meets_watermark() {
279        let report = lsp_coverage();
280        eprintln!("{}", report.summary());
281        if !report.unimplemented.is_empty() {
282            eprintln!("Unimplemented LSP 3.18 methods:");
283            for m in &report.unimplemented {
284                eprintln!("  - {}", m);
285            }
286        }
287        assert!(
288            report.coverage_pct >= COVERAGE_WATERMARK,
289            "LSP 3.18 coverage {:.1}% is below watermark {:.1}%.\n\
290             Unimplemented methods:\n{}",
291            report.coverage_pct,
292            COVERAGE_WATERMARK,
293            report.unimplemented.join("\n"),
294        );
295    }
296
297    #[test]
298    fn initialize_and_shutdown_are_implemented() {
299        let report = lsp_coverage();
300        assert!(
301            report.implemented.contains(&"initialize".to_string()),
302            "initialize must be implemented"
303        );
304        assert!(
305            report.implemented.contains(&"shutdown".to_string()),
306            "shutdown must be implemented"
307        );
308    }
309
310    #[test]
311    fn meta_model_parses_to_non_empty_spec() {
312        let meta: serde_json::Value =
313            serde_json::from_str(META_MODEL_JSON).expect("metaModel.json parse failed");
314        let req_count = meta
315            .get("requests")
316            .and_then(|v| v.as_array())
317            .map(|a| a.len())
318            .unwrap_or(0);
319        let notif_count = meta
320            .get("notifications")
321            .and_then(|v| v.as_array())
322            .map(|a| a.len())
323            .unwrap_or(0);
324        assert!(
325            req_count > 50,
326            "expected >50 requests in metaModel.json, got {}",
327            req_count
328        );
329        assert!(
330            notif_count > 10,
331            "expected >10 notifications, got {}",
332            notif_count
333        );
334    }
335
336    #[test]
337    fn no_max_methods_in_coverage_denominator() {
338        // lsp-max extension methods must not inflate or deflate the standard-LSP
339        // coverage percentage.
340        let report = lsp_coverage();
341        for method in &report.implemented {
342            assert!(
343                !method.starts_with("max/"),
344                "max/* method {:?} must not appear in the standard LSP coverage report",
345                method
346            );
347        }
348        for method in &report.unimplemented {
349            assert!(
350                !method.starts_with("max/"),
351                "max/* method {:?} must not appear in the standard LSP coverage report",
352                method
353            );
354        }
355    }
356}