Skip to main content

lanekeep_server/
lsp.rs

1//! The Language Server Protocol surface.
2//!
3//! Diagnostics, published on open and on save. Not on every keystroke: a check reads files
4//! from disk, and the buffer an editor holds mid-edit is not on disk yet. Publishing against
5//! stale bytes would put squiggles under the wrong characters, which is worse than a short
6//! delay — §12 already says the warm cache is what makes one-shot fast, and the same cache
7//! makes a save-triggered re-check fast enough to feel immediate.
8//!
9//! # Positions
10//!
11//! **LSP counts lines and characters from zero. lanekeep counts from one.** Every diagnostic
12//! crosses that boundary, and getting it wrong shifts every squiggle up a line and left a
13//! column — visible, but easy to mistake for a rule reporting the wrong node. [`to_range`]
14//! is the one place the conversion happens, and it is tested at line and column 1 where the
15//! subtraction would underflow.
16
17use std::collections::BTreeMap;
18use std::path::{Path, PathBuf};
19
20use lanekeep_core::{Severity, Violation};
21use serde_json::{Value, json};
22
23/// LSP severity numbers, which are not lanekeep's.
24mod severity {
25    pub(super) const ERROR: u8 = 1;
26    pub(super) const WARNING: u8 = 2;
27}
28
29/// What the server tells the client it can do.
30///
31/// `textDocumentSync: 1` is full-document sync. The server does not use the text the client
32/// sends — it re-reads from disk — but declaring `none` would stop some clients sending the
33/// open and save notifications that trigger a check at all.
34#[must_use]
35pub fn capabilities() -> Value {
36    json!({
37        "capabilities": {
38            "textDocumentSync": {
39                "openClose": true,
40                "change": 1,
41                "save": { "includeText": false },
42            },
43        },
44        "serverInfo": {
45            "name": "lanekeep",
46            "version": env!("CARGO_PKG_VERSION"),
47        },
48    })
49}
50
51/// Convert a violation's one-based position into LSP's zero-based range.
52///
53/// The range is the single character at the position. lanekeep reports a point, not a span:
54/// a violation's location is where to look, and inventing an end column from a node's width
55/// would be a different claim than the one the rule made.
56#[must_use]
57pub fn to_range(violation: &Violation) -> Value {
58    // Saturating, because line or column 1 is the common case and `1 - 1` is the answer,
59    // while a hypothetical 0 must not wrap to `u32::MAX` and put the squiggle off-screen.
60    let line = violation.location.position.line.saturating_sub(1);
61    let character = violation.location.position.column.saturating_sub(1);
62
63    json!({
64        "start": { "line": line, "character": character },
65        "end": { "line": line, "character": character + 1 },
66    })
67}
68
69/// Convert a violation into an LSP diagnostic.
70#[must_use]
71pub fn to_diagnostic(violation: &Violation) -> Value {
72    json!({
73        "range": to_range(violation),
74        "severity": match violation.severity {
75            Severity::Error => severity::ERROR,
76            // Anything not an error is advice. `off` never reaches here — a disabled rule
77            // does not run — and mapping it to a warning rather than dropping it would be a
78            // diagnostic nobody asked for.
79            _ => severity::WARNING,
80        },
81        "source": "lanekeep",
82        "code": violation.rule_id.to_string(),
83        // Both lines, because an editor shows one hover and the remediation is the half that
84        // says what to do. Splitting them across `message` and a related-information entry
85        // hides the actionable half behind a click.
86        "message": format!("{}\n{}", violation.message, violation.remediation),
87    })
88}
89
90/// Group violations by the file they belong to, as absolute paths.
91///
92/// Every file the client has open needs a `publishDiagnostics`, including the ones with
93/// nothing wrong — an empty list is how a diagnostic gets cleared, and skipping it leaves
94/// yesterday's squiggle on a line the author already fixed.
95#[must_use]
96pub fn by_file(root: &Path, violations: &[Violation]) -> BTreeMap<PathBuf, Vec<Value>> {
97    let mut grouped: BTreeMap<PathBuf, Vec<Value>> = BTreeMap::new();
98    for violation in violations {
99        grouped
100            .entry(root.join(violation.location.file.as_str()))
101            .or_default()
102            .push(to_diagnostic(violation));
103    }
104    grouped
105}
106
107/// The path a `file://` URI refers to.
108///
109/// Percent-decoded, because a path with a space arrives as `%20` and comparing the encoded
110/// form against a path from disk would never match.
111#[must_use]
112pub fn path_from_uri(uri: &str) -> Option<PathBuf> {
113    let rest = uri.strip_prefix("file://")?;
114
115    // `file:///a/b` on Unix leaves `/a/b`; a Windows URI leaves `/C:/a/b`, where the leading
116    // slash is part of the URI and not of the path.
117    let rest = if rest.len() > 2
118        && rest.starts_with('/')
119        && rest.as_bytes()[2] == b':'
120        && rest.as_bytes()[1].is_ascii_alphabetic()
121    {
122        &rest[1..]
123    } else {
124        rest
125    };
126
127    Some(PathBuf::from(percent_decode(rest)))
128}
129
130/// The `file://` URI for a path.
131#[must_use]
132pub fn uri_from_path(path: &Path) -> String {
133    let text = path.to_string_lossy().replace('\\', "/");
134    let text = if text.starts_with('/') {
135        text
136    } else {
137        format!("/{text}")
138    };
139    format!("file://{}", percent_encode(&text))
140}
141
142/// Decode `%XX` escapes. Anything malformed is left as written rather than dropped.
143fn percent_decode(text: &str) -> String {
144    let bytes = text.as_bytes();
145    let mut out = Vec::with_capacity(bytes.len());
146    let mut index = 0;
147
148    while index < bytes.len() {
149        if bytes[index] == b'%' && index + 2 < bytes.len() {
150            let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).ok();
151            if let Some(byte) = hex.and_then(|hex| u8::from_str_radix(hex, 16).ok()) {
152                out.push(byte);
153                index += 3;
154                continue;
155            }
156        }
157        out.push(bytes[index]);
158        index += 1;
159    }
160
161    String::from_utf8_lossy(&out).into_owned()
162}
163
164/// Encode the characters a URI cannot carry literally. `/` stays, being the separator.
165fn percent_encode(text: &str) -> String {
166    let mut out = String::with_capacity(text.len());
167    for byte in text.bytes() {
168        match byte {
169            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
170                out.push(byte as char);
171            }
172            other => {
173                use std::fmt::Write as _;
174                let _ = write!(out, "%{other:02X}");
175            }
176        }
177    }
178    out
179}
180
181#[cfg(test)]
182mod tests {
183    use lanekeep_core::{FilePath, Location, Position, RuleId};
184
185    use super::*;
186
187    fn violation(line: u32, column: u32, severity: Severity) -> Violation {
188        Violation {
189            rule_id: "local/example".parse::<RuleId>().expect("valid"),
190            location: Location::new(FilePath::new("src/a.ts"), Position::new(line, column)),
191            message: "something".to_owned(),
192            remediation: "do this".to_owned(),
193            severity,
194            fix: None,
195        }
196    }
197
198    #[test]
199    fn positions_convert_from_one_based_to_zero_based() {
200        let range = to_range(&violation(9, 5, Severity::Error));
201        assert_eq!(range["start"]["line"], 8);
202        assert_eq!(range["start"]["character"], 4);
203    }
204
205    #[test]
206    fn the_first_line_and_column_do_not_underflow() {
207        // `1 - 1` is 0, which is right. What must not happen is a wrap to u32::MAX, which
208        // would put the squiggle somewhere no editor will ever show.
209        let range = to_range(&violation(1, 1, Severity::Error));
210        assert_eq!(range["start"]["line"], 0);
211        assert_eq!(range["start"]["character"], 0);
212        assert_eq!(range["end"]["character"], 1);
213    }
214
215    #[test]
216    fn severity_maps_to_the_lsp_numbers() {
217        assert_eq!(
218            to_diagnostic(&violation(1, 1, Severity::Error))["severity"],
219            severity::ERROR
220        );
221        assert_eq!(
222            to_diagnostic(&violation(1, 1, Severity::Warn))["severity"],
223            severity::WARNING
224        );
225    }
226
227    #[test]
228    fn a_diagnostic_carries_the_rule_id_and_both_lines() {
229        let diagnostic = to_diagnostic(&violation(1, 1, Severity::Error));
230        assert_eq!(diagnostic["code"], "local/example");
231        assert_eq!(diagnostic["source"], "lanekeep");
232        let message = diagnostic["message"].as_str().expect("a string");
233        assert!(message.contains("something"), "{message}");
234        assert!(message.contains("do this"), "{message}");
235    }
236
237    #[test]
238    fn violations_group_by_file_as_absolute_paths() {
239        let root = Path::new("/project");
240        let grouped = by_file(
241            root,
242            &[
243                violation(1, 1, Severity::Error),
244                violation(2, 1, Severity::Warn),
245            ],
246        );
247        assert_eq!(grouped.len(), 1);
248        assert_eq!(
249            grouped.keys().next().expect("one file"),
250            Path::new("/project/src/a.ts")
251        );
252        assert_eq!(grouped.values().next().expect("one file").len(), 2);
253    }
254
255    #[test]
256    fn a_uri_round_trips_through_a_path() {
257        for path in ["/project/src/a.ts", "/project/with space/b.ts"] {
258            let uri = uri_from_path(Path::new(path));
259            assert_eq!(
260                path_from_uri(&uri).as_deref(),
261                Some(Path::new(path)),
262                "{uri}"
263            );
264        }
265    }
266
267    #[test]
268    fn a_space_is_percent_encoded_and_decoded() {
269        // An editor sends `%20`; comparing that against a path read from disk never matches.
270        assert_eq!(uri_from_path(Path::new("/a b/c.ts")), "file:///a%20b/c.ts");
271        assert_eq!(
272            path_from_uri("file:///a%20b/c.ts").as_deref(),
273            Some(Path::new("/a b/c.ts"))
274        );
275    }
276
277    #[test]
278    fn a_windows_uri_drops_the_slash_before_the_drive_letter() {
279        assert_eq!(
280            path_from_uri("file:///C:/project/a.ts").as_deref(),
281            Some(Path::new("C:/project/a.ts"))
282        );
283    }
284
285    #[test]
286    fn a_malformed_escape_is_left_alone_rather_than_dropped() {
287        // Better a path that fails to match than a path silently missing characters.
288        assert_eq!(
289            path_from_uri("file:///a%zz/b.ts").as_deref(),
290            Some(Path::new("/a%zz/b.ts"))
291        );
292    }
293
294    #[test]
295    fn a_non_file_uri_is_refused() {
296        assert!(path_from_uri("untitled:Untitled-1").is_none());
297        assert!(path_from_uri("https://example.com/a.ts").is_none());
298    }
299
300    #[test]
301    fn capabilities_announce_open_and_save() {
302        let announced = capabilities();
303        let sync = &announced["capabilities"]["textDocumentSync"];
304        assert_eq!(sync["openClose"], true);
305        assert!(sync["save"].is_object());
306        assert_eq!(announced["serverInfo"]["name"], "lanekeep");
307    }
308}