1pub 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
29pub type Checked = Result<Vec<lanekeep_core::Violation>, String>;
31
32pub 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 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 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 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
114fn 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
130fn 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 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
178fn 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 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 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 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 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}