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