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
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 disable in the LSP server
20    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
34/// Convert rumdl warnings to LSP diagnostics
35pub 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    // Use proper range from warning
42    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    // Create clickable link to rule documentation
53    let code_description = warning.rule_name.as_ref().and_then(|rule_name| {
54        // Create a link to the rule documentation
55        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
79/// Convert byte range to LSP range
80fn 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    // Handle end position at EOF
108    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
118/// Create a code action from a rumdl warning with fix
119pub 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        // Convert fix range (byte offsets) to LSP positions
126        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        // Test serialization
181        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        // Test deserialization
189        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); // 0-indexed
212        assert_eq!(diagnostic.range.start.character, 9); // 0-indexed
213        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        // Test with 0 line/column (should saturate to 0)
259        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(); // "World"
290
291        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        // "世界" starts at byte 6 and each character is 3 bytes
301        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); // 2 unicode characters
307    }
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        // Out of bounds range
324        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, // "World\nTest"
391                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        // Test that partial JSON can be deserialized with defaults
430        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); // Should use default
435        assert!(!config.enable_auto_fix); // Should use default
436        assert!(config.disable_rules.is_empty()); // Should use default
437    }
438}