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