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