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