1use crate::rules::md013_line_length::MD013Config;
7use serde::{Deserialize, Serialize};
8use std::path::PathBuf;
9use tower_lsp::lsp_types::*;
10
11#[derive(Debug, Clone, PartialEq)]
13pub enum IndexState {
14 Building {
16 progress: f32,
18 files_indexed: usize,
20 total_files: usize,
22 },
23 Ready,
25 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#[derive(Debug)]
41pub enum IndexUpdate {
42 FileChanged { path: PathBuf, content: String },
44 FileDeleted { path: PathBuf },
46 FullRescan,
48 Shutdown,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub enum ConfigurationPreference {
56 #[default]
58 EditorFirst,
59 FilesystemFirst,
61 EditorOnly,
63}
64
65#[derive(Debug, Clone, Default, Serialize, Deserialize)]
70#[serde(default, rename_all = "camelCase")]
71pub struct LspRuleSettings {
72 pub line_length: Option<usize>,
74 pub disable: Option<Vec<String>>,
76 pub enable: Option<Vec<String>>,
78 #[serde(flatten)]
80 pub rules: std::collections::HashMap<String, serde_json::Value>,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
88#[serde(default, rename_all = "camelCase")]
89pub struct RumdlLspConfig {
90 pub config_path: Option<String>,
92 pub enable_linting: bool,
94 pub enable_auto_fix: bool,
96 pub enable_rules: Option<Vec<String>>,
99 pub disable_rules: Option<Vec<String>>,
101 pub configuration_preference: ConfigurationPreference,
103 pub settings: Option<LspRuleSettings>,
106 pub enable_link_completions: bool,
109 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
131pub 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 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 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
181fn 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 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 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
233pub 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
239pub(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 if let Some(fix_action) = create_fix_action(warning, uri, document_text) {
252 actions.push(fix_action);
253 }
254
255 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 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 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
280fn create_fix_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
282 if let Some(fix) = &warning.fix {
283 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
315fn create_reflow_action(
318 warning: &crate::rule::LintWarning,
319 uri: &Url,
320 document_text: &str,
321 md013_config: Option<&MD013Config>,
322) -> Option<CodeAction> {
323 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 let reflow_result =
337 crate::utils::text_reflow::reflow_paragraph_at_line_with_options(document_text, warning.line, &options)?;
338
339 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), disabled: None,
364 data: None,
365 })
366}
367
368fn extract_line_length_from_message(message: &str) -> Option<usize> {
371 let exceeds_idx = message.find("exceeds")?;
373 let after_exceeds = &message[exceeds_idx + 7..]; let num_str = after_exceeds.split_whitespace().next()?;
377
378 num_str.parse::<usize>().ok()
379}
380
381fn create_convert_to_link_action(
385 warning: &crate::rule::LintWarning,
386 uri: &Url,
387 document_text: &str,
388) -> Option<CodeAction> {
389 let fix = warning.fix.as_ref()?;
391
392 let url = extract_url_from_fix_replacement(&fix.replacement)?;
395
396 let range = byte_range_to_lsp_range(document_text, fix.range.clone())?;
398
399 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), disabled: None,
425 data: None,
426 })
427}
428
429fn extract_url_from_fix_replacement(replacement: &str) -> Option<&str> {
432 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
441fn extract_domain_for_placeholder(url: &str) -> &str {
445 if url.contains('@') && !url.contains("://") {
447 return url;
448 }
449
450 url.split("://").nth(1).and_then(|s| s.split('/').next()).unwrap_or(url)
452}
453
454fn 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 let lines: Vec<&str> = document_text.lines().collect();
461 let line_content = lines.get(warning_line)?;
462
463 if line_content.contains("rumdl-disable-line") || line_content.contains("markdownlint-disable-line") {
465 return None;
467 }
468
469 let line_end = Position {
471 line: warning_line as u32,
472 character: line_content.len() as u32,
473 };
474
475 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), disabled: None,
501 data: None,
502 })
503}
504
505#[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 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 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); assert_eq!(diagnostic.range.start.character, 9); 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 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(); 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 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); }
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 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 let text = "Hello\nWorld";
694 let text_len = text.len(); let range = byte_range_to_lsp_range(text, text_len..text_len).unwrap();
696
697 assert_eq!(range.start.line, 1);
699 assert_eq!(range.start.character, 5); 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 let text = "Hello\nWorld\n";
708 let text_len = text.len(); let range = byte_range_to_lsp_range(text, text_len..text_len).unwrap();
710
711 assert_eq!(range.start.line, 2);
713 assert_eq!(range.start.character, 0); 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]; 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 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, 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]; 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 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 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); assert!(!config.enable_auto_fix); }
895
896 #[test]
897 fn test_configuration_preference_serialization() {
898 let pref = ConfigurationPreference::EditorFirst;
900 let json = serde_json::to_string(&pref).unwrap();
901 assert_eq!(json, "\"editorFirst\"");
902
903 let pref = ConfigurationPreference::FilesystemFirst;
905 let json = serde_json::to_string(&pref).unwrap();
906 assert_eq!(json, "\"filesystemFirst\"");
907
908 let pref = ConfigurationPreference::EditorOnly;
910 let json = serde_json::to_string(&pref).unwrap();
911 assert_eq!(json, "\"editorOnly\"");
912
913 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 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 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 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 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 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 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 assert_eq!(file_edits[0].range.start.line, 4); assert_eq!(file_edits[0].range.start.character, 47); }
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 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 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 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 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 assert_eq!(actions.len(), 2);
1098
1099 assert_eq!(actions[0].title, "Fix: Trailing spaces");
1101 assert_eq!(actions[0].is_preferred, Some(true));
1102
1103 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 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 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 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 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, 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 assert_eq!(actions.len(), 3);
1207
1208 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 assert_eq!(actions[1].title, "Convert to markdown link");
1217 assert_eq!(actions[1].is_preferred, Some(false));
1218
1219 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 assert_eq!(file_edits[0].new_text, "[example.com](https://example.com)");
1227
1228 assert_eq!(actions[2].title, "Ignore MD034 for this line");
1230 }
1231
1232 #[test]
1233 fn test_md034_convert_to_link_action_email() {
1234 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, 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 assert_eq!(actions.len(), 3);
1256
1257 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 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 let text = "line1\nline2\n\n"; let range = byte_range_to_lsp_range(text, 12..13);
1307 assert!(range.is_some());
1308 let range = range.unwrap();
1309
1310 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 let text = "test\n"; let range = byte_range_to_lsp_range(text, 5..5);
1325 assert!(range.is_some());
1326 let range = range.unwrap();
1327
1328 assert_eq!(range.start.line, 1);
1330 assert_eq!(range.start.character, 0);
1331 }
1332}