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