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