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