rumdl_lib/lsp/
types.rs

1//! LSP type definitions and utilities for rumdl
2//!
3//! This module contains LSP-specific types and utilities for rumdl,
4//! following the Language Server Protocol specification.
5
6use serde::{Deserialize, Serialize};
7use tower_lsp::lsp_types::*;
8
9/// Configuration for the rumdl LSP server (from initialization options)
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(default)]
12pub struct RumdlLspConfig {
13    /// Path to rumdl configuration file
14    pub config_path: Option<String>,
15    /// Enable/disable real-time linting
16    pub enable_linting: bool,
17    /// Enable/disable auto-fixing on save
18    pub enable_auto_fix: bool,
19    /// Rules to enable (overrides config file)
20    /// If specified, only these rules will be active
21    pub enable_rules: Option<Vec<String>>,
22    /// Rules to disable (overrides config file)
23    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
38/// Convert rumdl warnings to LSP diagnostics
39pub 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    // Use proper range from warning
46    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    // Create clickable link to rule documentation
57    let code_description = warning.rule_name.as_ref().and_then(|rule_name| {
58        // Create a link to the rule documentation
59        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
83/// Convert byte range to LSP range
84fn 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    // Handle end position at EOF
112    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
122/// Create a code action from a rumdl warning with fix
123pub 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        // Convert fix range (byte offsets) to LSP positions
130        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        // Test serialization
185        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        // Test deserialization
191        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); // 0-indexed
213        assert_eq!(diagnostic.range.start.character, 9); // 0-indexed
214        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        // Test with 0 line/column (should saturate to 0)
260        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(); // "World"
291
292        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        // "世界" starts at byte 6 and each character is 3 bytes
302        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); // 2 unicode characters
308    }
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        // Out of bounds range
325        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, // "World\nTest"
392                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        // Test that partial JSON can be deserialized with defaults
431        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); // Should use default
436        assert!(!config.enable_auto_fix); // Should use default
437    }
438}