1use serde::{Deserialize, Serialize};
7use tower_lsp::lsp_types::*;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(default)]
12pub struct RumdlLspConfig {
13 pub config_path: Option<String>,
15 pub enable_linting: bool,
17 pub enable_auto_fix: bool,
19 pub enable_rules: Option<Vec<String>>,
22 pub disable_rules: Option<Vec<String>>,
24}
25
26impl Default for RumdlLspConfig {
27 fn default() -> Self {
28 Self {
29 config_path: None,
30 enable_linting: true,
31 enable_auto_fix: false,
32 enable_rules: None,
33 disable_rules: None,
34 }
35 }
36}
37
38pub fn warning_to_diagnostic(warning: &crate::rule::LintWarning) -> Diagnostic {
40 let start_position = Position {
41 line: (warning.line.saturating_sub(1)) as u32,
42 character: (warning.column.saturating_sub(1)) as u32,
43 };
44
45 let end_position = Position {
47 line: (warning.end_line.saturating_sub(1)) as u32,
48 character: (warning.end_column.saturating_sub(1)) as u32,
49 };
50
51 let severity = match warning.severity {
52 crate::rule::Severity::Error => DiagnosticSeverity::ERROR,
53 crate::rule::Severity::Warning => DiagnosticSeverity::WARNING,
54 };
55
56 let code_description = warning.rule_name.as_ref().and_then(|rule_name| {
58 Url::parse(&format!(
60 "https://github.com/rvben/rumdl/blob/main/docs/{}.md",
61 rule_name.to_lowercase()
62 ))
63 .ok()
64 .map(|href| CodeDescription { href })
65 });
66
67 Diagnostic {
68 range: Range {
69 start: start_position,
70 end: end_position,
71 },
72 severity: Some(severity),
73 code: warning.rule_name.map(|s| NumberOrString::String(s.to_string())),
74 source: Some("rumdl".to_string()),
75 message: warning.message.clone(),
76 related_information: None,
77 tags: None,
78 code_description,
79 data: None,
80 }
81}
82
83fn byte_range_to_lsp_range(text: &str, byte_range: std::ops::Range<usize>) -> Option<Range> {
85 let mut line = 0u32;
86 let mut character = 0u32;
87 let mut byte_pos = 0;
88
89 let mut start_pos = None;
90 let mut end_pos = None;
91
92 for ch in text.chars() {
93 if byte_pos == byte_range.start {
94 start_pos = Some(Position { line, character });
95 }
96 if byte_pos == byte_range.end {
97 end_pos = Some(Position { line, character });
98 break;
99 }
100
101 if ch == '\n' {
102 line += 1;
103 character = 0;
104 } else {
105 character += 1;
106 }
107
108 byte_pos += ch.len_utf8();
109 }
110
111 if byte_pos == byte_range.end && end_pos.is_none() {
113 end_pos = Some(Position { line, character });
114 }
115
116 match (start_pos, end_pos) {
117 (Some(start), Some(end)) => Some(Range { start, end }),
118 _ => None,
119 }
120}
121
122pub fn warning_to_code_action(
124 warning: &crate::rule::LintWarning,
125 uri: &Url,
126 document_text: &str,
127) -> Option<CodeAction> {
128 if let Some(fix) = &warning.fix {
129 let range = byte_range_to_lsp_range(document_text, fix.range.clone())?;
131
132 let edit = TextEdit {
133 range,
134 new_text: fix.replacement.clone(),
135 };
136
137 let mut changes = std::collections::HashMap::new();
138 changes.insert(uri.clone(), vec![edit]);
139
140 let workspace_edit = WorkspaceEdit {
141 changes: Some(changes),
142 document_changes: None,
143 change_annotations: None,
144 };
145
146 Some(CodeAction {
147 title: format!("Fix: {}", warning.message),
148 kind: Some(CodeActionKind::QUICKFIX),
149 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
150 edit: Some(workspace_edit),
151 command: None,
152 is_preferred: Some(true),
153 disabled: None,
154 data: None,
155 })
156 } else {
157 None
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use crate::rule::{Fix, LintWarning, Severity};
165
166 #[test]
167 fn test_rumdl_lsp_config_default() {
168 let config = RumdlLspConfig::default();
169 assert_eq!(config.config_path, None);
170 assert!(config.enable_linting);
171 assert!(!config.enable_auto_fix);
172 }
173
174 #[test]
175 fn test_rumdl_lsp_config_serialization() {
176 let config = RumdlLspConfig {
177 config_path: Some("/path/to/config.toml".to_string()),
178 enable_linting: false,
179 enable_auto_fix: true,
180 enable_rules: None,
181 disable_rules: None,
182 };
183
184 let json = serde_json::to_string(&config).unwrap();
186 assert!(json.contains("\"config_path\":\"/path/to/config.toml\""));
187 assert!(json.contains("\"enable_linting\":false"));
188 assert!(json.contains("\"enable_auto_fix\":true"));
189
190 let deserialized: RumdlLspConfig = serde_json::from_str(&json).unwrap();
192 assert_eq!(deserialized.config_path, config.config_path);
193 assert_eq!(deserialized.enable_linting, config.enable_linting);
194 assert_eq!(deserialized.enable_auto_fix, config.enable_auto_fix);
195 }
196
197 #[test]
198 fn test_warning_to_diagnostic_basic() {
199 let warning = LintWarning {
200 line: 5,
201 column: 10,
202 end_line: 5,
203 end_column: 15,
204 rule_name: Some("MD001"),
205 message: "Test warning message".to_string(),
206 severity: Severity::Warning,
207 fix: None,
208 };
209
210 let diagnostic = warning_to_diagnostic(&warning);
211
212 assert_eq!(diagnostic.range.start.line, 4); assert_eq!(diagnostic.range.start.character, 9); assert_eq!(diagnostic.range.end.line, 4);
215 assert_eq!(diagnostic.range.end.character, 14);
216 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
217 assert_eq!(diagnostic.source, Some("rumdl".to_string()));
218 assert_eq!(diagnostic.message, "Test warning message");
219 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
220 }
221
222 #[test]
223 fn test_warning_to_diagnostic_error_severity() {
224 let warning = LintWarning {
225 line: 1,
226 column: 1,
227 end_line: 1,
228 end_column: 5,
229 rule_name: Some("MD002"),
230 message: "Error message".to_string(),
231 severity: Severity::Error,
232 fix: None,
233 };
234
235 let diagnostic = warning_to_diagnostic(&warning);
236 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::ERROR));
237 }
238
239 #[test]
240 fn test_warning_to_diagnostic_no_rule_name() {
241 let warning = LintWarning {
242 line: 1,
243 column: 1,
244 end_line: 1,
245 end_column: 5,
246 rule_name: None,
247 message: "Generic warning".to_string(),
248 severity: Severity::Warning,
249 fix: None,
250 };
251
252 let diagnostic = warning_to_diagnostic(&warning);
253 assert_eq!(diagnostic.code, None);
254 assert!(diagnostic.code_description.is_none());
255 }
256
257 #[test]
258 fn test_warning_to_diagnostic_edge_cases() {
259 let warning = LintWarning {
261 line: 0,
262 column: 0,
263 end_line: 0,
264 end_column: 0,
265 rule_name: Some("MD001"),
266 message: "Edge case".to_string(),
267 severity: Severity::Warning,
268 fix: None,
269 };
270
271 let diagnostic = warning_to_diagnostic(&warning);
272 assert_eq!(diagnostic.range.start.line, 0);
273 assert_eq!(diagnostic.range.start.character, 0);
274 }
275
276 #[test]
277 fn test_byte_range_to_lsp_range_simple() {
278 let text = "Hello\nWorld";
279 let range = byte_range_to_lsp_range(text, 0..5).unwrap();
280
281 assert_eq!(range.start.line, 0);
282 assert_eq!(range.start.character, 0);
283 assert_eq!(range.end.line, 0);
284 assert_eq!(range.end.character, 5);
285 }
286
287 #[test]
288 fn test_byte_range_to_lsp_range_multiline() {
289 let text = "Hello\nWorld\nTest";
290 let range = byte_range_to_lsp_range(text, 6..11).unwrap(); assert_eq!(range.start.line, 1);
293 assert_eq!(range.start.character, 0);
294 assert_eq!(range.end.line, 1);
295 assert_eq!(range.end.character, 5);
296 }
297
298 #[test]
299 fn test_byte_range_to_lsp_range_unicode() {
300 let text = "Hello 世界\nTest";
301 let range = byte_range_to_lsp_range(text, 6..12).unwrap();
303
304 assert_eq!(range.start.line, 0);
305 assert_eq!(range.start.character, 6);
306 assert_eq!(range.end.line, 0);
307 assert_eq!(range.end.character, 8); }
309
310 #[test]
311 fn test_byte_range_to_lsp_range_eof() {
312 let text = "Hello";
313 let range = byte_range_to_lsp_range(text, 0..5).unwrap();
314
315 assert_eq!(range.start.line, 0);
316 assert_eq!(range.start.character, 0);
317 assert_eq!(range.end.line, 0);
318 assert_eq!(range.end.character, 5);
319 }
320
321 #[test]
322 fn test_byte_range_to_lsp_range_invalid() {
323 let text = "Hello";
324 let range = byte_range_to_lsp_range(text, 10..15);
326 assert!(range.is_none());
327 }
328
329 #[test]
330 fn test_warning_to_code_action_with_fix() {
331 let warning = LintWarning {
332 line: 1,
333 column: 1,
334 end_line: 1,
335 end_column: 5,
336 rule_name: Some("MD001"),
337 message: "Missing space".to_string(),
338 severity: Severity::Warning,
339 fix: Some(Fix {
340 range: 0..5,
341 replacement: "Fixed".to_string(),
342 }),
343 };
344
345 let uri = Url::parse("file:///test.md").unwrap();
346 let document_text = "Hello World";
347
348 let action = warning_to_code_action(&warning, &uri, document_text).unwrap();
349
350 assert_eq!(action.title, "Fix: Missing space");
351 assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
352 assert_eq!(action.is_preferred, Some(true));
353
354 let changes = action.edit.unwrap().changes.unwrap();
355 let edits = &changes[&uri];
356 assert_eq!(edits.len(), 1);
357 assert_eq!(edits[0].new_text, "Fixed");
358 }
359
360 #[test]
361 fn test_warning_to_code_action_no_fix() {
362 let warning = LintWarning {
363 line: 1,
364 column: 1,
365 end_line: 1,
366 end_column: 5,
367 rule_name: Some("MD001"),
368 message: "No fix available".to_string(),
369 severity: Severity::Warning,
370 fix: None,
371 };
372
373 let uri = Url::parse("file:///test.md").unwrap();
374 let document_text = "Hello World";
375
376 let action = warning_to_code_action(&warning, &uri, document_text);
377 assert!(action.is_none());
378 }
379
380 #[test]
381 fn test_warning_to_code_action_multiline_fix() {
382 let warning = LintWarning {
383 line: 2,
384 column: 1,
385 end_line: 3,
386 end_column: 5,
387 rule_name: Some("MD001"),
388 message: "Multiline fix".to_string(),
389 severity: Severity::Warning,
390 fix: Some(Fix {
391 range: 6..16, replacement: "Fixed\nContent".to_string(),
393 }),
394 };
395
396 let uri = Url::parse("file:///test.md").unwrap();
397 let document_text = "Hello\nWorld\nTest Line";
398
399 let action = warning_to_code_action(&warning, &uri, document_text).unwrap();
400
401 let changes = action.edit.unwrap().changes.unwrap();
402 let edits = &changes[&uri];
403 assert_eq!(edits[0].new_text, "Fixed\nContent");
404 assert_eq!(edits[0].range.start.line, 1);
405 assert_eq!(edits[0].range.start.character, 0);
406 }
407
408 #[test]
409 fn test_code_description_url_generation() {
410 let warning = LintWarning {
411 line: 1,
412 column: 1,
413 end_line: 1,
414 end_column: 5,
415 rule_name: Some("MD013"),
416 message: "Line too long".to_string(),
417 severity: Severity::Warning,
418 fix: None,
419 };
420
421 let diagnostic = warning_to_diagnostic(&warning);
422 assert!(diagnostic.code_description.is_some());
423
424 let url = diagnostic.code_description.unwrap().href;
425 assert_eq!(url.as_str(), "https://github.com/rvben/rumdl/blob/main/docs/md013.md");
426 }
427
428 #[test]
429 fn test_lsp_config_partial_deserialization() {
430 let json = r#"{"enable_linting": false}"#;
432 let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
433
434 assert!(!config.enable_linting);
435 assert_eq!(config.config_path, None); assert!(!config.enable_auto_fix); }
438}