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