Skip to main content

rumdl_lib/lsp/
types.rs

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