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