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