Skip to main content

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