1use std::collections::BTreeMap;
18use std::path::{Path, PathBuf};
19
20use lanekeep_core::{Severity, Violation};
21use serde_json::{Value, json};
22
23mod severity {
25 pub(super) const ERROR: u8 = 1;
26 pub(super) const WARNING: u8 = 2;
27}
28
29#[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#[must_use]
57pub fn to_range(violation: &Violation) -> Value {
58 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#[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 _ => severity::WARNING,
80 },
81 "source": "lanekeep",
82 "code": violation.rule_id.to_string(),
83 "message": format!("{}\n{}", violation.message, violation.remediation),
87 })
88}
89
90#[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#[must_use]
112pub fn path_from_uri(uri: &str) -> Option<PathBuf> {
113 let rest = uri.strip_prefix("file://")?;
114
115 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#[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
142fn 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
164fn 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 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 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 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}