Skip to main content

rumdl_lib/lsp/
types.rs

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