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