Skip to main content

lanekeep_server/
lib.rs

1//! The LSP and MCP servers, over stdio.
2//!
3//! §12 specifies `lanekeep server`, "launched by editors/agent hosts". Both protocols are
4//! JSON-RPC 2.0 over stdio, so [`jsonrpc`] is shared and each protocol contributes only its
5//! framing and its method set.
6//!
7//! # No executor
8//!
9//! `deny.toml` denies `tokio` outright, which rules out every async LSP crate. That is the
10//! right constraint here rather than an obstacle worked around: a server that reads a
11//! message, answers it, and reads the next has nothing to schedule, and §13's "minimal
12//! dependency surface" is easier to hold with no runtime at all.
13//!
14//! The cost is that a long check blocks the next message. For a tool whose warm run is tens
15//! of milliseconds that is not a real cost, and the alternative buys concurrency this server
16//! has no use for.
17
18pub mod jsonrpc;
19pub mod lsp;
20pub mod mcp;
21
22use std::io::{BufRead, Write};
23use std::path::{Path, PathBuf};
24
25use serde_json::{Value, json};
26
27use crate::jsonrpc::{Framing, Incoming, Outgoing, codes};
28
29/// What a check produced, in the only shape the server needs.
30pub type Checked = Result<Vec<lanekeep_core::Violation>, String>;
31
32/// Run the LSP server against `input` and `output` until the client disconnects.
33///
34/// `check` is supplied rather than built here so the loop can be driven in a test without a
35/// project on disk. The binary passes the real engine.
36///
37/// # Errors
38///
39/// Propagates an I/O failure on the transport. A failing *check* is not one: it becomes a
40/// diagnostic-free publish and a log line, because an editor session should survive a rule
41/// that throws.
42pub fn serve_lsp(
43    input: &mut impl BufRead,
44    output: &mut impl Write,
45    root: &Path,
46    mut check: impl FnMut() -> Checked,
47) -> std::io::Result<()> {
48    let mut open: Vec<PathBuf> = Vec::new();
49    let mut shutting_down = false;
50
51    while let Some(raw) = jsonrpc::read(input, Framing::Headers)? {
52        let Ok(message) = serde_json::from_str::<Incoming>(&raw) else {
53            // A client that sent one bad frame has not stopped being a client.
54            jsonrpc::write(
55                output,
56                Framing::Headers,
57                &Outgoing::error(None, codes::PARSE_ERROR, "not a JSON-RPC message"),
58            )?;
59            continue;
60        };
61
62        match message.method.as_str() {
63            "initialize" => {
64                reply(output, &message, Ok(lsp::capabilities()))?;
65            }
66
67            "initialized" => {}
68
69            "textDocument/didOpen" | "textDocument/didSave" => {
70                if let Some(path) = document_path(&message.params)
71                    && !open.contains(&path)
72                {
73                    open.push(path);
74                }
75                publish(output, root, &open, &mut check)?;
76            }
77
78            "textDocument/didClose" => {
79                // Diagnostics for a closed document are the client's to forget, and a server
80                // that kept publishing them would grow its list without bound.
81                if let Some(path) = document_path(&message.params) {
82                    open.retain(|candidate| candidate != &path);
83                }
84            }
85
86            "shutdown" => {
87                shutting_down = true;
88                reply(output, &message, Ok(Value::Null))?;
89            }
90
91            "exit" => break,
92
93            other => {
94                // Only a request gets told; a notification nobody knows is not an error the
95                // client can act on, and answering one is itself a protocol violation.
96                if message.expects_reply() {
97                    reply(
98                        output,
99                        &message,
100                        Err((codes::METHOD_NOT_FOUND, format!("no method `{other}`"))),
101                    )?;
102                }
103            }
104        }
105
106        if shutting_down && message.method == "exit" {
107            break;
108        }
109    }
110
111    Ok(())
112}
113
114/// Answer a request, and say nothing to a notification.
115fn reply(
116    output: &mut impl Write,
117    message: &Incoming,
118    outcome: Result<Value, (i32, String)>,
119) -> std::io::Result<()> {
120    if !message.expects_reply() {
121        return Ok(());
122    }
123    let response = match outcome {
124        Ok(result) => Outgoing::result(message.id.clone(), result),
125        Err((code, text)) => Outgoing::error(message.id.clone(), code, text),
126    };
127    jsonrpc::write(output, Framing::Headers, &response)
128}
129
130/// Re-check and publish diagnostics for every open document.
131///
132/// Every open document, not only the one that changed: a cross-file rule can move a
133/// violation from the file being edited to one that was not, and publishing only the edited
134/// file would leave that one stale.
135fn publish(
136    output: &mut impl Write,
137    root: &Path,
138    open: &[PathBuf],
139    check: &mut impl FnMut() -> Checked,
140) -> std::io::Result<()> {
141    let violations = match check() {
142        Ok(violations) => violations,
143        Err(error) => {
144            // Say so once and clear the squiggles, rather than leaving diagnostics from a
145            // run that no longer describes the code.
146            jsonrpc::write(
147                output,
148                Framing::Headers,
149                &Outgoing::notification(
150                    "window/logMessage",
151                    json!({ "type": 1, "message": format!("lanekeep: {error}") }),
152                ),
153            )?;
154            Vec::new()
155        }
156    };
157
158    let grouped = lsp::by_file(root, &violations);
159
160    for path in open {
161        let diagnostics = grouped.get(path).cloned().unwrap_or_default();
162        jsonrpc::write(
163            output,
164            Framing::Headers,
165            &Outgoing::notification(
166                "textDocument/publishDiagnostics",
167                json!({
168                    "uri": lsp::uri_from_path(path),
169                    "diagnostics": diagnostics,
170                }),
171            ),
172        )?;
173    }
174
175    Ok(())
176}
177
178/// The path a `textDocument` parameter refers to.
179fn document_path(params: &Value) -> Option<PathBuf> {
180    lsp::path_from_uri(params["textDocument"]["uri"].as_str()?)
181}
182
183#[cfg(test)]
184mod tests {
185    use lanekeep_core::{FilePath, Location, Position, RuleId, Severity, Violation};
186
187    use super::*;
188
189    fn framed(messages: &[Value]) -> String {
190        use std::fmt::Write as _;
191
192        let mut out = String::new();
193        for message in messages {
194            let body = message.to_string();
195            let _ = write!(out, "Content-Length: {}\r\n\r\n{body}", body.len());
196        }
197        out
198    }
199
200    /// Every message the server wrote back, parsed.
201    fn exchange(messages: &[Value], check: impl FnMut() -> Checked) -> Vec<Value> {
202        let wire = framed(messages);
203        let mut input = std::io::BufReader::new(wire.as_bytes());
204        let mut output = Vec::new();
205        serve_lsp(&mut input, &mut output, Path::new("/project"), check).expect("serves");
206
207        let text = String::from_utf8(output).expect("utf-8");
208        let mut cursor = std::io::BufReader::new(text.as_bytes());
209        let mut out = Vec::new();
210        while let Ok(Some(raw)) = jsonrpc::read(&mut cursor, Framing::Headers) {
211            out.push(serde_json::from_str(&raw).expect("parses"));
212        }
213        out
214    }
215
216    fn a_violation() -> Violation {
217        Violation {
218            rule_id: "local/example".parse::<RuleId>().expect("valid"),
219            location: Location::new(FilePath::new("src/a.ts"), Position::new(3, 5)),
220            message: "something".to_owned(),
221            remediation: "do this".to_owned(),
222            severity: Severity::Error,
223            fix: None,
224        }
225    }
226
227    fn open(uri: &str) -> Value {
228        json!({
229            "method": "textDocument/didOpen",
230            "params": { "textDocument": { "uri": uri } }
231        })
232    }
233
234    #[test]
235    fn initialize_is_answered_with_capabilities() {
236        let replies = exchange(
237            &[json!({ "id": 1, "method": "initialize", "params": {} })],
238            || Ok(Vec::new()),
239        );
240        assert_eq!(replies.len(), 1);
241        assert_eq!(replies[0]["id"], 1);
242        assert_eq!(replies[0]["result"]["serverInfo"]["name"], "lanekeep");
243    }
244
245    #[test]
246    fn opening_a_document_publishes_its_diagnostics() {
247        let replies = exchange(&[open("file:///project/src/a.ts")], || {
248            Ok(vec![a_violation()])
249        });
250
251        let published = replies
252            .iter()
253            .find(|m| m["method"] == "textDocument/publishDiagnostics")
254            .expect("published");
255        assert_eq!(published["params"]["uri"], "file:///project/src/a.ts");
256
257        let diagnostics = published["params"]["diagnostics"]
258            .as_array()
259            .expect("an array");
260        assert_eq!(diagnostics.len(), 1);
261        assert_eq!(diagnostics[0]["range"]["start"]["line"], 2);
262        assert_eq!(diagnostics[0]["code"], "local/example");
263    }
264
265    #[test]
266    fn a_clean_file_is_published_with_an_empty_list() {
267        // The only way to clear a squiggle the author already fixed. Skipping the publish
268        // leaves the old diagnostic on screen forever.
269        let replies = exchange(&[open("file:///project/src/a.ts")], || Ok(Vec::new()));
270        let published = replies
271            .iter()
272            .find(|m| m["method"] == "textDocument/publishDiagnostics")
273            .expect("published");
274        assert_eq!(
275            published["params"]["diagnostics"]
276                .as_array()
277                .expect("an array")
278                .len(),
279            0
280        );
281    }
282
283    #[test]
284    fn every_open_document_is_republished_when_one_changes() {
285        // A cross-file rule can move a violation into a file nobody touched. Publishing only
286        // the edited one leaves that file's diagnostics describing an older corpus.
287        let replies = exchange(
288            &[
289                open("file:///project/src/a.ts"),
290                open("file:///project/src/b.ts"),
291            ],
292            || Ok(Vec::new()),
293        );
294
295        let published: Vec<&Value> = replies
296            .iter()
297            .filter(|m| m["method"] == "textDocument/publishDiagnostics")
298            .collect();
299        // One for the first open, two for the second.
300        assert_eq!(published.len(), 3);
301        assert_eq!(published[2]["params"]["uri"], "file:///project/src/b.ts");
302    }
303
304    #[test]
305    fn closing_a_document_stops_publishing_for_it() {
306        let replies = exchange(
307            &[
308                open("file:///project/src/a.ts"),
309                json!({
310                    "method": "textDocument/didClose",
311                    "params": { "textDocument": { "uri": "file:///project/src/a.ts" } }
312                }),
313                open("file:///project/src/b.ts"),
314            ],
315            || Ok(Vec::new()),
316        );
317
318        let uris: Vec<&str> = replies
319            .iter()
320            .filter(|m| m["method"] == "textDocument/publishDiagnostics")
321            .filter_map(|m| m["params"]["uri"].as_str())
322            .collect();
323        assert!(
324            !uris[1..].contains(&"file:///project/src/a.ts"),
325            "a closed document was still published: {uris:?}"
326        );
327    }
328
329    #[test]
330    fn a_failing_check_logs_and_clears_rather_than_ending_the_session() {
331        let replies = exchange(&[open("file:///project/src/a.ts")], || {
332            Err("rule threw".to_owned())
333        });
334
335        assert!(
336            replies.iter().any(|m| m["method"] == "window/logMessage"
337                && m["params"]["message"]
338                    .as_str()
339                    .is_some_and(|text| text.contains("rule threw"))),
340            "the failure should be logged: {replies:?}"
341        );
342        assert!(
343            replies
344                .iter()
345                .any(|m| m["method"] == "textDocument/publishDiagnostics"),
346            "and diagnostics still published"
347        );
348    }
349
350    #[test]
351    fn an_unknown_request_is_refused_and_an_unknown_notification_is_not() {
352        let replies = exchange(
353            &[
354                json!({ "id": 7, "method": "textDocument/formatting" }),
355                json!({ "method": "$/setTrace", "params": {} }),
356            ],
357            || Ok(Vec::new()),
358        );
359        assert_eq!(
360            replies.len(),
361            1,
362            "only the request is answered: {replies:?}"
363        );
364        assert_eq!(replies[0]["error"]["code"], codes::METHOD_NOT_FOUND);
365    }
366
367    #[test]
368    fn a_malformed_message_is_answered_and_the_session_continues() {
369        let body = "not json at all";
370        let wire = format!(
371            "Content-Length: {}\r\n\r\n{body}{}",
372            body.len(),
373            framed(&[json!({ "id": 1, "method": "initialize" })])
374        );
375        let mut input = std::io::BufReader::new(wire.as_bytes());
376        let mut output = Vec::new();
377        serve_lsp(&mut input, &mut output, Path::new("/project"), || {
378            Ok(Vec::new())
379        })
380        .expect("serves");
381
382        let text = String::from_utf8(output).expect("utf-8");
383        assert!(text.contains("-32700"), "a parse error is reported: {text}");
384        assert!(
385            text.contains("lanekeep"),
386            "and initialize is still answered: {text}"
387        );
388    }
389
390    #[test]
391    fn exit_ends_the_loop() {
392        let replies = exchange(
393            &[
394                json!({ "id": 1, "method": "shutdown" }),
395                json!({ "method": "exit" }),
396                json!({ "id": 2, "method": "initialize" }),
397            ],
398            || Ok(Vec::new()),
399        );
400        assert_eq!(
401            replies.len(),
402            1,
403            "nothing after exit is served: {replies:?}"
404        );
405        assert_eq!(replies[0]["id"], 1);
406    }
407}