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 std::path::PathBuf;
8use tower_lsp::lsp_types::*;
9
10/// State of the workspace index
11#[derive(Debug, Clone, PartialEq)]
12pub enum IndexState {
13    /// Index is being built
14    Building {
15        /// Progress percentage (0-100)
16        progress: f32,
17        /// Number of files indexed so far
18        files_indexed: usize,
19        /// Total number of files to index
20        total_files: usize,
21    },
22    /// Index is ready for use
23    Ready,
24    /// Index encountered an error
25    Error(String),
26}
27
28impl Default for IndexState {
29    fn default() -> Self {
30        Self::Building {
31            progress: 0.0,
32            files_indexed: 0,
33            total_files: 0,
34        }
35    }
36}
37
38/// Messages sent to the background index worker
39#[derive(Debug)]
40pub enum IndexUpdate {
41    /// A file was changed (content included for debouncing)
42    FileChanged { path: PathBuf, content: String },
43    /// A file was deleted
44    FileDeleted { path: PathBuf },
45    /// Request a full workspace rescan
46    FullRescan,
47    /// Shutdown the worker
48    Shutdown,
49}
50
51/// Controls the order in which configuration sources are merged
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
53#[serde(rename_all = "camelCase")]
54pub enum ConfigurationPreference {
55    /// Editor settings take priority over config files (default)
56    #[default]
57    EditorFirst,
58    /// Config files take priority over editor settings
59    FilesystemFirst,
60    /// Ignore config files, use only editor settings
61    EditorOnly,
62}
63
64/// Per-rule settings that can be passed via LSP initialization options
65///
66/// This struct mirrors the rule-specific settings from Config, allowing
67/// editors to configure rules without needing a config file.
68#[derive(Debug, Clone, Default, Serialize, Deserialize)]
69#[serde(default, rename_all = "camelCase")]
70pub struct LspRuleSettings {
71    /// Global line length for rules that use it
72    pub line_length: Option<usize>,
73    /// Rules to disable
74    pub disable: Option<Vec<String>>,
75    /// Rules to enable
76    pub enable: Option<Vec<String>>,
77    /// Per-rule configuration (e.g., "MD013": { "lineLength": 120 })
78    #[serde(flatten)]
79    pub rules: std::collections::HashMap<String, serde_json::Value>,
80}
81
82/// Configuration for the rumdl LSP server (from initialization options)
83///
84/// Uses camelCase for all fields per LSP specification.
85/// Follows Ruff's LSP configuration pattern for consistency.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87#[serde(default, rename_all = "camelCase")]
88pub struct RumdlLspConfig {
89    /// Path to rumdl configuration file
90    pub config_path: Option<String>,
91    /// Enable/disable real-time linting
92    pub enable_linting: bool,
93    /// Enable/disable auto-fixing on save
94    pub enable_auto_fix: bool,
95    /// Rules to enable (overrides config file)
96    /// If specified, only these rules will be active
97    pub enable_rules: Option<Vec<String>>,
98    /// Rules to disable (overrides config file)
99    pub disable_rules: Option<Vec<String>>,
100    /// Controls priority between editor settings and config files
101    pub configuration_preference: ConfigurationPreference,
102    /// Rule-specific settings passed from the editor
103    /// This allows configuring rules like MD013.lineLength directly from editor settings
104    pub settings: Option<LspRuleSettings>,
105}
106
107impl Default for RumdlLspConfig {
108    fn default() -> Self {
109        Self {
110            config_path: None,
111            enable_linting: true,
112            enable_auto_fix: false,
113            enable_rules: None,
114            disable_rules: None,
115            configuration_preference: ConfigurationPreference::default(),
116            settings: None,
117        }
118    }
119}
120
121/// Convert rumdl warnings to LSP diagnostics
122pub fn warning_to_diagnostic(warning: &crate::rule::LintWarning) -> Diagnostic {
123    let start_position = Position {
124        line: (warning.line.saturating_sub(1)) as u32,
125        character: (warning.column.saturating_sub(1)) as u32,
126    };
127
128    // Use proper range from warning
129    let end_position = Position {
130        line: (warning.end_line.saturating_sub(1)) as u32,
131        character: (warning.end_column.saturating_sub(1)) as u32,
132    };
133
134    let severity = match warning.severity {
135        crate::rule::Severity::Error => DiagnosticSeverity::ERROR,
136        crate::rule::Severity::Warning => DiagnosticSeverity::WARNING,
137        crate::rule::Severity::Info => DiagnosticSeverity::INFORMATION,
138    };
139
140    // Create clickable link to rule documentation
141    let code_description = warning.rule_name.as_ref().and_then(|rule_name| {
142        // Create a link to the rule documentation
143        Url::parse(&format!(
144            "https://github.com/rvben/rumdl/blob/main/docs/{}.md",
145            rule_name.to_lowercase()
146        ))
147        .ok()
148        .map(|href| CodeDescription { href })
149    });
150
151    Diagnostic {
152        range: Range {
153            start: start_position,
154            end: end_position,
155        },
156        severity: Some(severity),
157        code: warning.rule_name.as_ref().map(|s| NumberOrString::String(s.clone())),
158        source: Some("rumdl".to_string()),
159        message: warning.message.clone(),
160        related_information: None,
161        tags: None,
162        code_description,
163        data: None,
164    }
165}
166
167/// Convert byte range to LSP range
168fn byte_range_to_lsp_range(text: &str, byte_range: std::ops::Range<usize>) -> Option<Range> {
169    let mut line = 0u32;
170    let mut character = 0u32;
171    let mut byte_pos = 0;
172
173    let mut start_pos = None;
174    let mut end_pos = None;
175
176    for ch in text.chars() {
177        if byte_pos == byte_range.start {
178            start_pos = Some(Position { line, character });
179        }
180        if byte_pos == byte_range.end {
181            end_pos = Some(Position { line, character });
182            break;
183        }
184
185        if ch == '\n' {
186            line += 1;
187            character = 0;
188        } else {
189            character += 1;
190        }
191
192        byte_pos += ch.len_utf8();
193    }
194
195    // Handle positions at or beyond EOF
196    // This is crucial for fixes that delete trailing content (like MD012 EOF blanks)
197    if start_pos.is_none() && byte_pos >= byte_range.start {
198        start_pos = Some(Position { line, character });
199    }
200    if end_pos.is_none() && byte_pos >= byte_range.end {
201        end_pos = Some(Position { line, character });
202    }
203
204    match (start_pos, end_pos) {
205        (Some(start), Some(end)) => Some(Range { start, end }),
206        _ => {
207            // If we still don't have valid positions, log for debugging
208            // This shouldn't happen with proper fix ranges
209            log::warn!(
210                "Failed to convert byte range {:?} to LSP range for text of length {}",
211                byte_range,
212                text.len()
213            );
214            None
215        }
216    }
217}
218
219/// Create code actions from a rumdl warning
220/// Returns a vector of available actions: fix action (if available) and ignore actions
221pub fn warning_to_code_actions(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Vec<CodeAction> {
222    let mut actions = Vec::new();
223
224    // Add fix action if available (marked as preferred)
225    if let Some(fix_action) = create_fix_action(warning, uri, document_text) {
226        actions.push(fix_action);
227    }
228
229    // Add manual reflow action for MD013 when no fix is available
230    // This allows users to manually reflow paragraphs without enabling reflow globally
231    if warning.rule_name.as_deref() == Some("MD013")
232        && warning.fix.is_none()
233        && let Some(reflow_action) = create_reflow_action(warning, uri, document_text)
234    {
235        actions.push(reflow_action);
236    }
237
238    // Add convert-to-markdown-link action for MD034 (bare URLs)
239    // This provides an alternative to the default angle bracket fix
240    if warning.rule_name.as_deref() == Some("MD034")
241        && let Some(convert_action) = create_convert_to_link_action(warning, uri, document_text)
242    {
243        actions.push(convert_action);
244    }
245
246    // Add ignore-line action
247    if let Some(ignore_line_action) = create_ignore_line_action(warning, uri, document_text) {
248        actions.push(ignore_line_action);
249    }
250
251    actions
252}
253
254/// Create a fix code action from a rumdl warning with fix
255fn create_fix_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
256    if let Some(fix) = &warning.fix {
257        // Convert fix range (byte offsets) to LSP positions
258        let range = byte_range_to_lsp_range(document_text, fix.range.clone())?;
259
260        let edit = TextEdit {
261            range,
262            new_text: fix.replacement.clone(),
263        };
264
265        let mut changes = std::collections::HashMap::new();
266        changes.insert(uri.clone(), vec![edit]);
267
268        let workspace_edit = WorkspaceEdit {
269            changes: Some(changes),
270            document_changes: None,
271            change_annotations: None,
272        };
273
274        Some(CodeAction {
275            title: format!("Fix: {}", warning.message),
276            kind: Some(CodeActionKind::QUICKFIX),
277            diagnostics: Some(vec![warning_to_diagnostic(warning)]),
278            edit: Some(workspace_edit),
279            command: None,
280            is_preferred: Some(true),
281            disabled: None,
282            data: None,
283        })
284    } else {
285        None
286    }
287}
288
289/// Create a manual reflow code action for MD013 line length warnings
290/// This allows users to manually reflow paragraphs even when reflow is disabled in config
291fn create_reflow_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
292    // Extract line length limit from message (format: "Line length X exceeds Y characters")
293    let line_length = extract_line_length_from_message(&warning.message).unwrap_or(80);
294
295    // Use the reflow helper to find and reflow the paragraph
296    let reflow_result = crate::utils::text_reflow::reflow_paragraph_at_line(document_text, warning.line, line_length)?;
297
298    // Convert byte offsets to LSP range
299    let range = byte_range_to_lsp_range(document_text, reflow_result.start_byte..reflow_result.end_byte)?;
300
301    let edit = TextEdit {
302        range,
303        new_text: reflow_result.reflowed_text,
304    };
305
306    let mut changes = std::collections::HashMap::new();
307    changes.insert(uri.clone(), vec![edit]);
308
309    let workspace_edit = WorkspaceEdit {
310        changes: Some(changes),
311        document_changes: None,
312        change_annotations: None,
313    };
314
315    Some(CodeAction {
316        title: "Reflow paragraph".to_string(),
317        kind: Some(CodeActionKind::QUICKFIX),
318        diagnostics: Some(vec![warning_to_diagnostic(warning)]),
319        edit: Some(workspace_edit),
320        command: None,
321        is_preferred: Some(false), // Not preferred - manual action only
322        disabled: None,
323        data: None,
324    })
325}
326
327/// Extract line length limit from MD013 warning message
328/// Message format: "Line length X exceeds Y characters"
329fn extract_line_length_from_message(message: &str) -> Option<usize> {
330    // Find "exceeds" in the message
331    let exceeds_idx = message.find("exceeds")?;
332    let after_exceeds = &message[exceeds_idx + 7..]; // Skip "exceeds"
333
334    // Find the number after "exceeds"
335    let num_str = after_exceeds.split_whitespace().next()?;
336
337    num_str.parse::<usize>().ok()
338}
339
340/// Create a "convert to markdown link" action for MD034 bare URL warnings
341/// This provides an alternative to the default angle bracket fix, allowing users
342/// to create proper markdown links with descriptive text
343fn create_convert_to_link_action(
344    warning: &crate::rule::LintWarning,
345    uri: &Url,
346    document_text: &str,
347) -> Option<CodeAction> {
348    // Get the fix from the warning
349    let fix = warning.fix.as_ref()?;
350
351    // Extract the URL from the fix replacement (format: "<https://example.com>" or "<user@example.com>")
352    // The MD034 fix wraps URLs in angle brackets
353    let url = extract_url_from_fix_replacement(&fix.replacement)?;
354
355    // Convert byte offsets to LSP range
356    let range = byte_range_to_lsp_range(document_text, fix.range.clone())?;
357
358    // Create markdown link with the domain as link text
359    // The user can then edit the link text manually
360    // Note: LSP WorkspaceEdit doesn't support snippet placeholders like ${1:text}
361    // so we just use the domain as default text that user can select and replace
362    let link_text = extract_domain_for_placeholder(url);
363    let new_text = format!("[{link_text}]({url})");
364
365    let edit = TextEdit { range, new_text };
366
367    let mut changes = std::collections::HashMap::new();
368    changes.insert(uri.clone(), vec![edit]);
369
370    let workspace_edit = WorkspaceEdit {
371        changes: Some(changes),
372        document_changes: None,
373        change_annotations: None,
374    };
375
376    Some(CodeAction {
377        title: "Convert to markdown link".to_string(),
378        kind: Some(CodeActionKind::QUICKFIX),
379        diagnostics: Some(vec![warning_to_diagnostic(warning)]),
380        edit: Some(workspace_edit),
381        command: None,
382        is_preferred: Some(false), // Not preferred - user explicitly chooses this
383        disabled: None,
384        data: None,
385    })
386}
387
388/// Extract URL/email from MD034 fix replacement
389/// MD034 fix format: "<https://example.com>" or "<user@example.com>"
390fn extract_url_from_fix_replacement(replacement: &str) -> Option<&str> {
391    // Remove angle brackets that MD034's fix adds
392    let trimmed = replacement.trim();
393    if trimmed.starts_with('<') && trimmed.ends_with('>') {
394        Some(&trimmed[1..trimmed.len() - 1])
395    } else {
396        None
397    }
398}
399
400/// Extract a smart placeholder from a URL for the link text
401/// For "https://example.com/path" returns "example.com"
402/// For "user@example.com" returns "user@example.com"
403fn extract_domain_for_placeholder(url: &str) -> &str {
404    // For email addresses, use the whole email
405    if url.contains('@') && !url.contains("://") {
406        return url;
407    }
408
409    // For URLs, extract the domain
410    url.split("://").nth(1).and_then(|s| s.split('/').next()).unwrap_or(url)
411}
412
413/// Create an ignore-line code action that adds a rumdl-disable-line comment
414fn create_ignore_line_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
415    let rule_id = warning.rule_name.as_ref()?;
416    let warning_line = warning.line.saturating_sub(1);
417
418    // Find the end of the line where the warning occurs
419    let lines: Vec<&str> = document_text.lines().collect();
420    let line_content = lines.get(warning_line)?;
421
422    // Check if this line already has a rumdl-disable-line comment
423    if line_content.contains("rumdl-disable-line") || line_content.contains("markdownlint-disable-line") {
424        // Don't offer the action if the line already has a disable comment
425        return None;
426    }
427
428    // Calculate position at end of line
429    let line_end = Position {
430        line: warning_line as u32,
431        character: line_content.len() as u32,
432    };
433
434    // Use rumdl-disable-line syntax
435    let comment = format!(" <!-- rumdl-disable-line {rule_id} -->");
436
437    let edit = TextEdit {
438        range: Range {
439            start: line_end,
440            end: line_end,
441        },
442        new_text: comment,
443    };
444
445    let mut changes = std::collections::HashMap::new();
446    changes.insert(uri.clone(), vec![edit]);
447
448    Some(CodeAction {
449        title: format!("Ignore {rule_id} for this line"),
450        kind: Some(CodeActionKind::QUICKFIX),
451        diagnostics: Some(vec![warning_to_diagnostic(warning)]),
452        edit: Some(WorkspaceEdit {
453            changes: Some(changes),
454            document_changes: None,
455            change_annotations: None,
456        }),
457        command: None,
458        is_preferred: Some(false), // Fix action is preferred
459        disabled: None,
460        data: None,
461    })
462}
463
464/// Legacy function for backwards compatibility
465/// Use `warning_to_code_actions` instead
466#[deprecated(since = "0.0.167", note = "Use warning_to_code_actions instead")]
467pub fn warning_to_code_action(
468    warning: &crate::rule::LintWarning,
469    uri: &Url,
470    document_text: &str,
471) -> Option<CodeAction> {
472    warning_to_code_actions(warning, uri, document_text)
473        .into_iter()
474        .find(|action| action.is_preferred == Some(true))
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use crate::rule::{Fix, LintWarning, Severity};
481
482    #[test]
483    fn test_rumdl_lsp_config_default() {
484        let config = RumdlLspConfig::default();
485        assert_eq!(config.config_path, None);
486        assert!(config.enable_linting);
487        assert!(!config.enable_auto_fix);
488    }
489
490    #[test]
491    fn test_rumdl_lsp_config_serialization() {
492        let config = RumdlLspConfig {
493            config_path: Some("/path/to/config.toml".to_string()),
494            enable_linting: false,
495            enable_auto_fix: true,
496            enable_rules: None,
497            disable_rules: None,
498            configuration_preference: ConfigurationPreference::EditorFirst,
499            settings: None,
500        };
501
502        // Test serialization (uses camelCase)
503        let json = serde_json::to_string(&config).unwrap();
504        assert!(json.contains("\"configPath\":\"/path/to/config.toml\""));
505        assert!(json.contains("\"enableLinting\":false"));
506        assert!(json.contains("\"enableAutoFix\":true"));
507
508        // Test deserialization
509        let deserialized: RumdlLspConfig = serde_json::from_str(&json).unwrap();
510        assert_eq!(deserialized.config_path, config.config_path);
511        assert_eq!(deserialized.enable_linting, config.enable_linting);
512        assert_eq!(deserialized.enable_auto_fix, config.enable_auto_fix);
513    }
514
515    #[test]
516    fn test_warning_to_diagnostic_basic() {
517        let warning = LintWarning {
518            line: 5,
519            column: 10,
520            end_line: 5,
521            end_column: 15,
522            rule_name: Some("MD001".to_string()),
523            message: "Test warning message".to_string(),
524            severity: Severity::Warning,
525            fix: None,
526        };
527
528        let diagnostic = warning_to_diagnostic(&warning);
529
530        assert_eq!(diagnostic.range.start.line, 4); // 0-indexed
531        assert_eq!(diagnostic.range.start.character, 9); // 0-indexed
532        assert_eq!(diagnostic.range.end.line, 4);
533        assert_eq!(diagnostic.range.end.character, 14);
534        assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
535        assert_eq!(diagnostic.source, Some("rumdl".to_string()));
536        assert_eq!(diagnostic.message, "Test warning message");
537        assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
538    }
539
540    #[test]
541    fn test_warning_to_diagnostic_error_severity() {
542        let warning = LintWarning {
543            line: 1,
544            column: 1,
545            end_line: 1,
546            end_column: 5,
547            rule_name: Some("MD002".to_string()),
548            message: "Error message".to_string(),
549            severity: Severity::Error,
550            fix: None,
551        };
552
553        let diagnostic = warning_to_diagnostic(&warning);
554        assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::ERROR));
555    }
556
557    #[test]
558    fn test_warning_to_diagnostic_no_rule_name() {
559        let warning = LintWarning {
560            line: 1,
561            column: 1,
562            end_line: 1,
563            end_column: 5,
564            rule_name: None,
565            message: "Generic warning".to_string(),
566            severity: Severity::Warning,
567            fix: None,
568        };
569
570        let diagnostic = warning_to_diagnostic(&warning);
571        assert_eq!(diagnostic.code, None);
572        assert!(diagnostic.code_description.is_none());
573    }
574
575    #[test]
576    fn test_warning_to_diagnostic_edge_cases() {
577        // Test with 0 line/column (should saturate to 0)
578        let warning = LintWarning {
579            line: 0,
580            column: 0,
581            end_line: 0,
582            end_column: 0,
583            rule_name: Some("MD001".to_string()),
584            message: "Edge case".to_string(),
585            severity: Severity::Warning,
586            fix: None,
587        };
588
589        let diagnostic = warning_to_diagnostic(&warning);
590        assert_eq!(diagnostic.range.start.line, 0);
591        assert_eq!(diagnostic.range.start.character, 0);
592    }
593
594    #[test]
595    fn test_byte_range_to_lsp_range_simple() {
596        let text = "Hello\nWorld";
597        let range = byte_range_to_lsp_range(text, 0..5).unwrap();
598
599        assert_eq!(range.start.line, 0);
600        assert_eq!(range.start.character, 0);
601        assert_eq!(range.end.line, 0);
602        assert_eq!(range.end.character, 5);
603    }
604
605    #[test]
606    fn test_byte_range_to_lsp_range_multiline() {
607        let text = "Hello\nWorld\nTest";
608        let range = byte_range_to_lsp_range(text, 6..11).unwrap(); // "World"
609
610        assert_eq!(range.start.line, 1);
611        assert_eq!(range.start.character, 0);
612        assert_eq!(range.end.line, 1);
613        assert_eq!(range.end.character, 5);
614    }
615
616    #[test]
617    fn test_byte_range_to_lsp_range_unicode() {
618        let text = "Hello 世界\nTest";
619        // "世界" starts at byte 6 and each character is 3 bytes
620        let range = byte_range_to_lsp_range(text, 6..12).unwrap();
621
622        assert_eq!(range.start.line, 0);
623        assert_eq!(range.start.character, 6);
624        assert_eq!(range.end.line, 0);
625        assert_eq!(range.end.character, 8); // 2 unicode characters
626    }
627
628    #[test]
629    fn test_byte_range_to_lsp_range_eof() {
630        let text = "Hello";
631        let range = byte_range_to_lsp_range(text, 0..5).unwrap();
632
633        assert_eq!(range.start.line, 0);
634        assert_eq!(range.start.character, 0);
635        assert_eq!(range.end.line, 0);
636        assert_eq!(range.end.character, 5);
637    }
638
639    #[test]
640    fn test_byte_range_to_lsp_range_invalid() {
641        let text = "Hello";
642        // Out of bounds range
643        let range = byte_range_to_lsp_range(text, 10..15);
644        assert!(range.is_none());
645    }
646
647    #[test]
648    fn test_byte_range_to_lsp_range_insertion_at_eof() {
649        // Test insertion point at EOF (like MD047 adds trailing newline)
650        let text = "Hello\nWorld";
651        let text_len = text.len(); // 11 bytes
652        let range = byte_range_to_lsp_range(text, text_len..text_len).unwrap();
653
654        // Should create a zero-width range at EOF position
655        assert_eq!(range.start.line, 1);
656        assert_eq!(range.start.character, 5); // After "World"
657        assert_eq!(range.end.line, 1);
658        assert_eq!(range.end.character, 5);
659    }
660
661    #[test]
662    fn test_byte_range_to_lsp_range_insertion_at_eof_with_trailing_newline() {
663        // Test when file already ends with newline
664        let text = "Hello\nWorld\n";
665        let text_len = text.len(); // 12 bytes
666        let range = byte_range_to_lsp_range(text, text_len..text_len).unwrap();
667
668        // Should create a zero-width range at EOF (after the newline)
669        assert_eq!(range.start.line, 2);
670        assert_eq!(range.start.character, 0); // Beginning of line after newline
671        assert_eq!(range.end.line, 2);
672        assert_eq!(range.end.character, 0);
673    }
674
675    #[test]
676    fn test_warning_to_code_action_with_fix() {
677        let warning = LintWarning {
678            line: 1,
679            column: 1,
680            end_line: 1,
681            end_column: 5,
682            rule_name: Some("MD001".to_string()),
683            message: "Missing space".to_string(),
684            severity: Severity::Warning,
685            fix: Some(Fix {
686                range: 0..5,
687                replacement: "Fixed".to_string(),
688            }),
689        };
690
691        let uri = Url::parse("file:///test.md").unwrap();
692        let document_text = "Hello World";
693
694        let actions = warning_to_code_actions(&warning, &uri, document_text);
695        assert!(!actions.is_empty());
696        let action = &actions[0]; // First action is the fix
697
698        assert_eq!(action.title, "Fix: Missing space");
699        assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
700        assert_eq!(action.is_preferred, Some(true));
701
702        let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
703        let edits = &changes[&uri];
704        assert_eq!(edits.len(), 1);
705        assert_eq!(edits[0].new_text, "Fixed");
706    }
707
708    #[test]
709    fn test_warning_to_code_action_no_fix() {
710        let warning = LintWarning {
711            line: 1,
712            column: 1,
713            end_line: 1,
714            end_column: 5,
715            rule_name: Some("MD001".to_string()),
716            message: "No fix available".to_string(),
717            severity: Severity::Warning,
718            fix: None,
719        };
720
721        let uri = Url::parse("file:///test.md").unwrap();
722        let document_text = "Hello World";
723
724        let actions = warning_to_code_actions(&warning, &uri, document_text);
725        // Should have ignore actions but no fix action (fix actions have is_preferred = true)
726        assert!(actions.iter().all(|a| a.is_preferred != Some(true)));
727    }
728
729    #[test]
730    fn test_warning_to_code_action_multiline_fix() {
731        let warning = LintWarning {
732            line: 2,
733            column: 1,
734            end_line: 3,
735            end_column: 5,
736            rule_name: Some("MD001".to_string()),
737            message: "Multiline fix".to_string(),
738            severity: Severity::Warning,
739            fix: Some(Fix {
740                range: 6..16, // "World\nTest"
741                replacement: "Fixed\nContent".to_string(),
742            }),
743        };
744
745        let uri = Url::parse("file:///test.md").unwrap();
746        let document_text = "Hello\nWorld\nTest Line";
747
748        let actions = warning_to_code_actions(&warning, &uri, document_text);
749        assert!(!actions.is_empty());
750        let action = &actions[0]; // First action is the fix
751
752        let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
753        let edits = &changes[&uri];
754        assert_eq!(edits[0].new_text, "Fixed\nContent");
755        assert_eq!(edits[0].range.start.line, 1);
756        assert_eq!(edits[0].range.start.character, 0);
757    }
758
759    #[test]
760    fn test_code_description_url_generation() {
761        let warning = LintWarning {
762            line: 1,
763            column: 1,
764            end_line: 1,
765            end_column: 5,
766            rule_name: Some("MD013".to_string()),
767            message: "Line too long".to_string(),
768            severity: Severity::Warning,
769            fix: None,
770        };
771
772        let diagnostic = warning_to_diagnostic(&warning);
773        assert!(diagnostic.code_description.is_some());
774
775        let url = diagnostic.code_description.unwrap().href;
776        assert_eq!(url.as_str(), "https://github.com/rvben/rumdl/blob/main/docs/md013.md");
777    }
778
779    #[test]
780    fn test_lsp_config_partial_deserialization() {
781        // Test that partial JSON can be deserialized with defaults (uses camelCase per LSP spec)
782        let json = r#"{"enableLinting": false}"#;
783        let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
784
785        assert!(!config.enable_linting);
786        assert_eq!(config.config_path, None); // Should use default
787        assert!(!config.enable_auto_fix); // Should use default
788    }
789
790    #[test]
791    fn test_configuration_preference_serialization() {
792        // Test EditorFirst (default)
793        let pref = ConfigurationPreference::EditorFirst;
794        let json = serde_json::to_string(&pref).unwrap();
795        assert_eq!(json, "\"editorFirst\"");
796
797        // Test FilesystemFirst
798        let pref = ConfigurationPreference::FilesystemFirst;
799        let json = serde_json::to_string(&pref).unwrap();
800        assert_eq!(json, "\"filesystemFirst\"");
801
802        // Test EditorOnly
803        let pref = ConfigurationPreference::EditorOnly;
804        let json = serde_json::to_string(&pref).unwrap();
805        assert_eq!(json, "\"editorOnly\"");
806
807        // Test deserialization
808        let pref: ConfigurationPreference = serde_json::from_str("\"filesystemFirst\"").unwrap();
809        assert_eq!(pref, ConfigurationPreference::FilesystemFirst);
810    }
811
812    #[test]
813    fn test_lsp_rule_settings_deserialization() {
814        // Test basic settings
815        let json = r#"{
816            "lineLength": 120,
817            "disable": ["MD001", "MD002"],
818            "enable": ["MD013"]
819        }"#;
820        let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
821
822        assert_eq!(settings.line_length, Some(120));
823        assert_eq!(settings.disable, Some(vec!["MD001".to_string(), "MD002".to_string()]));
824        assert_eq!(settings.enable, Some(vec!["MD013".to_string()]));
825    }
826
827    #[test]
828    fn test_lsp_rule_settings_with_per_rule_config() {
829        // Test per-rule configuration via flattened HashMap
830        let json = r#"{
831            "lineLength": 80,
832            "MD013": {
833                "lineLength": 120,
834                "codeBlocks": false
835            },
836            "MD024": {
837                "siblingsOnly": true
838            }
839        }"#;
840        let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
841
842        assert_eq!(settings.line_length, Some(80));
843
844        // Check MD013 config
845        let md013 = settings.rules.get("MD013").unwrap();
846        assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
847        assert_eq!(md013.get("codeBlocks").unwrap().as_bool(), Some(false));
848
849        // Check MD024 config
850        let md024 = settings.rules.get("MD024").unwrap();
851        assert_eq!(md024.get("siblingsOnly").unwrap().as_bool(), Some(true));
852    }
853
854    #[test]
855    fn test_full_lsp_config_with_settings() {
856        // Test complete LSP config with all new fields (camelCase per LSP spec)
857        let json = r#"{
858            "configPath": "/path/to/config",
859            "enableLinting": true,
860            "enableAutoFix": false,
861            "configurationPreference": "editorFirst",
862            "settings": {
863                "lineLength": 100,
864                "disable": ["MD033"],
865                "MD013": {
866                    "lineLength": 120,
867                    "tables": false
868                }
869            }
870        }"#;
871        let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
872
873        assert_eq!(config.config_path, Some("/path/to/config".to_string()));
874        assert!(config.enable_linting);
875        assert!(!config.enable_auto_fix);
876        assert_eq!(config.configuration_preference, ConfigurationPreference::EditorFirst);
877
878        let settings = config.settings.unwrap();
879        assert_eq!(settings.line_length, Some(100));
880        assert_eq!(settings.disable, Some(vec!["MD033".to_string()]));
881
882        let md013 = settings.rules.get("MD013").unwrap();
883        assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
884        assert_eq!(md013.get("tables").unwrap().as_bool(), Some(false));
885    }
886
887    #[test]
888    fn test_create_ignore_line_action_uses_rumdl_syntax() {
889        let warning = LintWarning {
890            line: 5,
891            column: 1,
892            end_line: 5,
893            end_column: 50,
894            rule_name: Some("MD013".to_string()),
895            message: "Line too long".to_string(),
896            severity: Severity::Warning,
897            fix: None,
898        };
899
900        let document = "Line 1\nLine 2\nLine 3\nLine 4\nThis is a very long line that exceeds the limit\nLine 6";
901        let uri = Url::parse("file:///test.md").unwrap();
902
903        let action = create_ignore_line_action(&warning, &uri, document).unwrap();
904
905        assert_eq!(action.title, "Ignore MD013 for this line");
906        assert_eq!(action.is_preferred, Some(false));
907        assert!(action.edit.is_some());
908
909        // Verify the edit adds the rumdl-disable-line comment
910        let edit = action.edit.unwrap();
911        let changes = edit.changes.unwrap();
912        let file_edits = changes.get(&uri).unwrap();
913
914        assert_eq!(file_edits.len(), 1);
915        assert!(file_edits[0].new_text.contains("rumdl-disable-line MD013"));
916        assert!(!file_edits[0].new_text.contains("markdownlint"));
917
918        // Verify position is at end of line
919        assert_eq!(file_edits[0].range.start.line, 4); // 0-indexed line 5
920        assert_eq!(file_edits[0].range.start.character, 47); // End of "This is a very long line that exceeds the limit"
921    }
922
923    #[test]
924    fn test_create_ignore_line_action_no_duplicate() {
925        let warning = LintWarning {
926            line: 1,
927            column: 1,
928            end_line: 1,
929            end_column: 50,
930            rule_name: Some("MD013".to_string()),
931            message: "Line too long".to_string(),
932            severity: Severity::Warning,
933            fix: None,
934        };
935
936        // Line already has a disable comment
937        let document = "This is a line <!-- rumdl-disable-line MD013 -->";
938        let uri = Url::parse("file:///test.md").unwrap();
939
940        let action = create_ignore_line_action(&warning, &uri, document);
941
942        // Should not offer the action if comment already exists
943        assert!(action.is_none());
944    }
945
946    #[test]
947    fn test_create_ignore_line_action_detects_markdownlint_syntax() {
948        let warning = LintWarning {
949            line: 1,
950            column: 1,
951            end_line: 1,
952            end_column: 50,
953            rule_name: Some("MD013".to_string()),
954            message: "Line too long".to_string(),
955            severity: Severity::Warning,
956            fix: None,
957        };
958
959        // Line has markdownlint-disable-line comment
960        let document = "This is a line <!-- markdownlint-disable-line MD013 -->";
961        let uri = Url::parse("file:///test.md").unwrap();
962
963        let action = create_ignore_line_action(&warning, &uri, document);
964
965        // Should not offer the action if markdownlint comment exists
966        assert!(action.is_none());
967    }
968
969    #[test]
970    fn test_warning_to_code_actions_with_fix() {
971        let warning = LintWarning {
972            line: 1,
973            column: 1,
974            end_line: 1,
975            end_column: 5,
976            rule_name: Some("MD009".to_string()),
977            message: "Trailing spaces".to_string(),
978            severity: Severity::Warning,
979            fix: Some(Fix {
980                range: 0..5,
981                replacement: "Fixed".to_string(),
982            }),
983        };
984
985        let uri = Url::parse("file:///test.md").unwrap();
986        let document_text = "Hello   \nWorld";
987
988        let actions = warning_to_code_actions(&warning, &uri, document_text);
989
990        // Should have 2 actions: fix and ignore-line
991        assert_eq!(actions.len(), 2);
992
993        // First action should be fix (preferred)
994        assert_eq!(actions[0].title, "Fix: Trailing spaces");
995        assert_eq!(actions[0].is_preferred, Some(true));
996
997        // Second action should be ignore-line
998        assert_eq!(actions[1].title, "Ignore MD009 for this line");
999        assert_eq!(actions[1].is_preferred, Some(false));
1000    }
1001
1002    #[test]
1003    fn test_warning_to_code_actions_no_fix() {
1004        let warning = LintWarning {
1005            line: 1,
1006            column: 1,
1007            end_line: 1,
1008            end_column: 10,
1009            rule_name: Some("MD033".to_string()),
1010            message: "Inline HTML".to_string(),
1011            severity: Severity::Warning,
1012            fix: None,
1013        };
1014
1015        let uri = Url::parse("file:///test.md").unwrap();
1016        let document_text = "<div>HTML</div>";
1017
1018        let actions = warning_to_code_actions(&warning, &uri, document_text);
1019
1020        // Should have 1 action: ignore-line only (no fix available)
1021        assert_eq!(actions.len(), 1);
1022        assert_eq!(actions[0].title, "Ignore MD033 for this line");
1023        assert_eq!(actions[0].is_preferred, Some(false));
1024    }
1025
1026    #[test]
1027    fn test_warning_to_code_actions_no_rule_name() {
1028        let warning = LintWarning {
1029            line: 1,
1030            column: 1,
1031            end_line: 1,
1032            end_column: 5,
1033            rule_name: None,
1034            message: "Generic warning".to_string(),
1035            severity: Severity::Warning,
1036            fix: None,
1037        };
1038
1039        let uri = Url::parse("file:///test.md").unwrap();
1040        let document_text = "Hello World";
1041
1042        let actions = warning_to_code_actions(&warning, &uri, document_text);
1043
1044        // Should have no actions (no rule name means can't create ignore comment)
1045        assert_eq!(actions.len(), 0);
1046    }
1047
1048    #[test]
1049    fn test_legacy_warning_to_code_action_compatibility() {
1050        let warning = LintWarning {
1051            line: 1,
1052            column: 1,
1053            end_line: 1,
1054            end_column: 5,
1055            rule_name: Some("MD001".to_string()),
1056            message: "Test".to_string(),
1057            severity: Severity::Warning,
1058            fix: Some(Fix {
1059                range: 0..5,
1060                replacement: "Fixed".to_string(),
1061            }),
1062        };
1063
1064        let uri = Url::parse("file:///test.md").unwrap();
1065        let document_text = "Hello World";
1066
1067        #[allow(deprecated)]
1068        let action = warning_to_code_action(&warning, &uri, document_text);
1069
1070        // Should return the preferred (fix) action
1071        assert!(action.is_some());
1072        let action = action.unwrap();
1073        assert_eq!(action.title, "Fix: Test");
1074        assert_eq!(action.is_preferred, Some(true));
1075    }
1076
1077    #[test]
1078    fn test_md034_convert_to_link_action() {
1079        // Test the "convert to markdown link" action for MD034 bare URLs
1080        let warning = LintWarning {
1081            line: 1,
1082            column: 1,
1083            end_line: 1,
1084            end_column: 25,
1085            rule_name: Some("MD034".to_string()),
1086            message: "URL without angle brackets or link formatting: 'https://example.com'".to_string(),
1087            severity: Severity::Warning,
1088            fix: Some(Fix {
1089                range: 0..20, // "https://example.com"
1090                replacement: "<https://example.com>".to_string(),
1091            }),
1092        };
1093
1094        let uri = Url::parse("file:///test.md").unwrap();
1095        let document_text = "https://example.com is a test URL";
1096
1097        let actions = warning_to_code_actions(&warning, &uri, document_text);
1098
1099        // Should have 3 actions: fix (angle brackets), convert to link, and ignore
1100        assert_eq!(actions.len(), 3);
1101
1102        // First action should be the fix (angle brackets) - preferred
1103        assert_eq!(
1104            actions[0].title,
1105            "Fix: URL without angle brackets or link formatting: 'https://example.com'"
1106        );
1107        assert_eq!(actions[0].is_preferred, Some(true));
1108
1109        // Second action should be convert to link - not preferred
1110        assert_eq!(actions[1].title, "Convert to markdown link");
1111        assert_eq!(actions[1].is_preferred, Some(false));
1112
1113        // Check that the convert action creates a proper markdown link
1114        let edit = actions[1].edit.as_ref().unwrap();
1115        let changes = edit.changes.as_ref().unwrap();
1116        let file_edits = changes.get(&uri).unwrap();
1117        assert_eq!(file_edits.len(), 1);
1118
1119        // The replacement should be: [example.com](https://example.com)
1120        assert_eq!(file_edits[0].new_text, "[example.com](https://example.com)");
1121
1122        // Third action should be ignore
1123        assert_eq!(actions[2].title, "Ignore MD034 for this line");
1124    }
1125
1126    #[test]
1127    fn test_md034_convert_to_link_action_email() {
1128        // Test the "convert to markdown link" action for MD034 bare emails
1129        let warning = LintWarning {
1130            line: 1,
1131            column: 1,
1132            end_line: 1,
1133            end_column: 20,
1134            rule_name: Some("MD034".to_string()),
1135            message: "Email address without angle brackets or link formatting: 'user@example.com'".to_string(),
1136            severity: Severity::Warning,
1137            fix: Some(Fix {
1138                range: 0..16, // "user@example.com"
1139                replacement: "<user@example.com>".to_string(),
1140            }),
1141        };
1142
1143        let uri = Url::parse("file:///test.md").unwrap();
1144        let document_text = "user@example.com is my email";
1145
1146        let actions = warning_to_code_actions(&warning, &uri, document_text);
1147
1148        // Should have 3 actions
1149        assert_eq!(actions.len(), 3);
1150
1151        // Check convert to link action
1152        assert_eq!(actions[1].title, "Convert to markdown link");
1153
1154        let edit = actions[1].edit.as_ref().unwrap();
1155        let changes = edit.changes.as_ref().unwrap();
1156        let file_edits = changes.get(&uri).unwrap();
1157
1158        // For emails, use the whole email as link text
1159        assert_eq!(file_edits[0].new_text, "[user@example.com](user@example.com)");
1160    }
1161
1162    #[test]
1163    fn test_extract_url_from_fix_replacement() {
1164        assert_eq!(
1165            extract_url_from_fix_replacement("<https://example.com>"),
1166            Some("https://example.com")
1167        );
1168        assert_eq!(
1169            extract_url_from_fix_replacement("<user@example.com>"),
1170            Some("user@example.com")
1171        );
1172        assert_eq!(extract_url_from_fix_replacement("https://example.com"), None);
1173        assert_eq!(extract_url_from_fix_replacement("<>"), Some(""));
1174    }
1175
1176    #[test]
1177    fn test_extract_domain_for_placeholder() {
1178        assert_eq!(extract_domain_for_placeholder("https://example.com"), "example.com");
1179        assert_eq!(
1180            extract_domain_for_placeholder("https://example.com/path/to/page"),
1181            "example.com"
1182        );
1183        assert_eq!(
1184            extract_domain_for_placeholder("http://sub.example.com:8080/"),
1185            "sub.example.com:8080"
1186        );
1187        assert_eq!(extract_domain_for_placeholder("user@example.com"), "user@example.com");
1188        assert_eq!(
1189            extract_domain_for_placeholder("ftp://files.example.com"),
1190            "files.example.com"
1191        );
1192    }
1193
1194    #[test]
1195    fn test_byte_range_to_lsp_range_trailing_newlines() {
1196        // Test converting byte ranges for MD012 trailing blank line fixes
1197        let text = "line1\nline2\n\n"; // 13 bytes: "line1\n" (6) + "line2\n" (6) + "\n" (1)
1198
1199        // Remove the last blank line (byte 12..13)
1200        let range = byte_range_to_lsp_range(text, 12..13);
1201        assert!(range.is_some());
1202        let range = range.unwrap();
1203
1204        // Should be on line 2 (0-indexed), at position 0 for start
1205        // End should be on line 3 (after the newline at byte 12)
1206        assert_eq!(range.start.line, 2);
1207        assert_eq!(range.start.character, 0);
1208        assert_eq!(range.end.line, 3);
1209        assert_eq!(range.end.character, 0);
1210    }
1211
1212    #[test]
1213    fn test_byte_range_to_lsp_range_at_eof() {
1214        // Test a range that starts at EOF (empty range)
1215        let text = "test\n"; // 5 bytes
1216
1217        // Try to convert a range starting at EOF (should handle gracefully)
1218        let range = byte_range_to_lsp_range(text, 5..5);
1219        assert!(range.is_some());
1220        let range = range.unwrap();
1221
1222        // Should be at line 1 (after newline), position 0
1223        assert_eq!(range.start.line, 1);
1224        assert_eq!(range.start.character, 0);
1225    }
1226}