rumdl_lib/lsp/
types.rs

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