1use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8use tower_lsp::lsp_types::*;
9
10#[derive(Debug, Clone, PartialEq)]
12pub enum IndexState {
13 Building {
15 progress: f32,
17 files_indexed: usize,
19 total_files: usize,
21 },
22 Ready,
24 Error(String),
26}
27
28impl Default for IndexState {
29 fn default() -> Self {
30 Self::Building {
31 progress: 0.0,
32 files_indexed: 0,
33 total_files: 0,
34 }
35 }
36}
37
38#[derive(Debug)]
40pub enum IndexUpdate {
41 FileChanged { path: PathBuf, content: String },
43 FileDeleted { path: PathBuf },
45 FullRescan,
47 Shutdown,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
53#[serde(rename_all = "camelCase")]
54pub enum ConfigurationPreference {
55 #[default]
57 EditorFirst,
58 FilesystemFirst,
60 EditorOnly,
62}
63
64#[derive(Debug, Clone, Default, Serialize, Deserialize)]
69#[serde(default, rename_all = "camelCase")]
70pub struct LspRuleSettings {
71 pub line_length: Option<usize>,
73 pub disable: Option<Vec<String>>,
75 pub enable: Option<Vec<String>>,
77 #[serde(flatten)]
79 pub rules: std::collections::HashMap<String, serde_json::Value>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
87#[serde(default, rename_all = "camelCase")]
88pub struct RumdlLspConfig {
89 pub config_path: Option<String>,
91 pub enable_linting: bool,
93 pub enable_auto_fix: bool,
95 pub enable_rules: Option<Vec<String>>,
98 pub disable_rules: Option<Vec<String>>,
100 pub configuration_preference: ConfigurationPreference,
102 pub settings: Option<LspRuleSettings>,
105}
106
107impl Default for RumdlLspConfig {
108 fn default() -> Self {
109 Self {
110 config_path: None,
111 enable_linting: true,
112 enable_auto_fix: false,
113 enable_rules: None,
114 disable_rules: None,
115 configuration_preference: ConfigurationPreference::default(),
116 settings: None,
117 }
118 }
119}
120
121pub fn warning_to_diagnostic(warning: &crate::rule::LintWarning) -> Diagnostic {
123 let start_position = Position {
124 line: (warning.line.saturating_sub(1)) as u32,
125 character: (warning.column.saturating_sub(1)) as u32,
126 };
127
128 let end_position = Position {
130 line: (warning.end_line.saturating_sub(1)) as u32,
131 character: (warning.end_column.saturating_sub(1)) as u32,
132 };
133
134 let severity = match warning.severity {
135 crate::rule::Severity::Error => DiagnosticSeverity::ERROR,
136 crate::rule::Severity::Warning => DiagnosticSeverity::WARNING,
137 };
138
139 let code_description = warning.rule_name.as_ref().and_then(|rule_name| {
141 Url::parse(&format!(
143 "https://github.com/rvben/rumdl/blob/main/docs/{}.md",
144 rule_name.to_lowercase()
145 ))
146 .ok()
147 .map(|href| CodeDescription { href })
148 });
149
150 Diagnostic {
151 range: Range {
152 start: start_position,
153 end: end_position,
154 },
155 severity: Some(severity),
156 code: warning.rule_name.as_ref().map(|s| NumberOrString::String(s.clone())),
157 source: Some("rumdl".to_string()),
158 message: warning.message.clone(),
159 related_information: None,
160 tags: None,
161 code_description,
162 data: None,
163 }
164}
165
166fn byte_range_to_lsp_range(text: &str, byte_range: std::ops::Range<usize>) -> Option<Range> {
168 let mut line = 0u32;
169 let mut character = 0u32;
170 let mut byte_pos = 0;
171
172 let mut start_pos = None;
173 let mut end_pos = None;
174
175 for ch in text.chars() {
176 if byte_pos == byte_range.start {
177 start_pos = Some(Position { line, character });
178 }
179 if byte_pos == byte_range.end {
180 end_pos = Some(Position { line, character });
181 break;
182 }
183
184 if ch == '\n' {
185 line += 1;
186 character = 0;
187 } else {
188 character += 1;
189 }
190
191 byte_pos += ch.len_utf8();
192 }
193
194 if start_pos.is_none() && byte_pos >= byte_range.start {
197 start_pos = Some(Position { line, character });
198 }
199 if end_pos.is_none() && byte_pos >= byte_range.end {
200 end_pos = Some(Position { line, character });
201 }
202
203 match (start_pos, end_pos) {
204 (Some(start), Some(end)) => Some(Range { start, end }),
205 _ => {
206 log::warn!(
209 "Failed to convert byte range {:?} to LSP range for text of length {}",
210 byte_range,
211 text.len()
212 );
213 None
214 }
215 }
216}
217
218pub fn warning_to_code_actions(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Vec<CodeAction> {
221 let mut actions = Vec::new();
222
223 if let Some(fix_action) = create_fix_action(warning, uri, document_text) {
225 actions.push(fix_action);
226 }
227
228 if warning.rule_name.as_deref() == Some("MD013")
231 && warning.fix.is_none()
232 && let Some(reflow_action) = create_reflow_action(warning, uri, document_text)
233 {
234 actions.push(reflow_action);
235 }
236
237 if warning.rule_name.as_deref() == Some("MD034")
240 && let Some(convert_action) = create_convert_to_link_action(warning, uri, document_text)
241 {
242 actions.push(convert_action);
243 }
244
245 if let Some(ignore_line_action) = create_ignore_line_action(warning, uri, document_text) {
247 actions.push(ignore_line_action);
248 }
249
250 actions
251}
252
253fn create_fix_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
255 if let Some(fix) = &warning.fix {
256 let range = byte_range_to_lsp_range(document_text, fix.range.clone())?;
258
259 let edit = TextEdit {
260 range,
261 new_text: fix.replacement.clone(),
262 };
263
264 let mut changes = std::collections::HashMap::new();
265 changes.insert(uri.clone(), vec![edit]);
266
267 let workspace_edit = WorkspaceEdit {
268 changes: Some(changes),
269 document_changes: None,
270 change_annotations: None,
271 };
272
273 Some(CodeAction {
274 title: format!("Fix: {}", warning.message),
275 kind: Some(CodeActionKind::QUICKFIX),
276 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
277 edit: Some(workspace_edit),
278 command: None,
279 is_preferred: Some(true),
280 disabled: None,
281 data: None,
282 })
283 } else {
284 None
285 }
286}
287
288fn create_reflow_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
291 let line_length = extract_line_length_from_message(&warning.message).unwrap_or(80);
293
294 let reflow_result = crate::utils::text_reflow::reflow_paragraph_at_line(document_text, warning.line, line_length)?;
296
297 let range = byte_range_to_lsp_range(document_text, reflow_result.start_byte..reflow_result.end_byte)?;
299
300 let edit = TextEdit {
301 range,
302 new_text: reflow_result.reflowed_text,
303 };
304
305 let mut changes = std::collections::HashMap::new();
306 changes.insert(uri.clone(), vec![edit]);
307
308 let workspace_edit = WorkspaceEdit {
309 changes: Some(changes),
310 document_changes: None,
311 change_annotations: None,
312 };
313
314 Some(CodeAction {
315 title: "Reflow paragraph".to_string(),
316 kind: Some(CodeActionKind::QUICKFIX),
317 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
318 edit: Some(workspace_edit),
319 command: None,
320 is_preferred: Some(false), disabled: None,
322 data: None,
323 })
324}
325
326fn extract_line_length_from_message(message: &str) -> Option<usize> {
329 let exceeds_idx = message.find("exceeds")?;
331 let after_exceeds = &message[exceeds_idx + 7..]; let num_str = after_exceeds.split_whitespace().next()?;
335
336 num_str.parse::<usize>().ok()
337}
338
339fn create_convert_to_link_action(
343 warning: &crate::rule::LintWarning,
344 uri: &Url,
345 document_text: &str,
346) -> Option<CodeAction> {
347 let fix = warning.fix.as_ref()?;
349
350 let url = extract_url_from_fix_replacement(&fix.replacement)?;
353
354 let range = byte_range_to_lsp_range(document_text, fix.range.clone())?;
356
357 let link_text = extract_domain_for_placeholder(url);
362 let new_text = format!("[{link_text}]({url})");
363
364 let edit = TextEdit { range, new_text };
365
366 let mut changes = std::collections::HashMap::new();
367 changes.insert(uri.clone(), vec![edit]);
368
369 let workspace_edit = WorkspaceEdit {
370 changes: Some(changes),
371 document_changes: None,
372 change_annotations: None,
373 };
374
375 Some(CodeAction {
376 title: "Convert to markdown link".to_string(),
377 kind: Some(CodeActionKind::QUICKFIX),
378 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
379 edit: Some(workspace_edit),
380 command: None,
381 is_preferred: Some(false), disabled: None,
383 data: None,
384 })
385}
386
387fn extract_url_from_fix_replacement(replacement: &str) -> Option<&str> {
390 let trimmed = replacement.trim();
392 if trimmed.starts_with('<') && trimmed.ends_with('>') {
393 Some(&trimmed[1..trimmed.len() - 1])
394 } else {
395 None
396 }
397}
398
399fn extract_domain_for_placeholder(url: &str) -> &str {
403 if url.contains('@') && !url.contains("://") {
405 return url;
406 }
407
408 url.split("://").nth(1).and_then(|s| s.split('/').next()).unwrap_or(url)
410}
411
412fn create_ignore_line_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
414 let rule_id = warning.rule_name.as_ref()?;
415 let warning_line = warning.line.saturating_sub(1);
416
417 let lines: Vec<&str> = document_text.lines().collect();
419 let line_content = lines.get(warning_line)?;
420
421 if line_content.contains("rumdl-disable-line") || line_content.contains("markdownlint-disable-line") {
423 return None;
425 }
426
427 let line_end = Position {
429 line: warning_line as u32,
430 character: line_content.len() as u32,
431 };
432
433 let comment = format!(" <!-- rumdl-disable-line {rule_id} -->");
435
436 let edit = TextEdit {
437 range: Range {
438 start: line_end,
439 end: line_end,
440 },
441 new_text: comment,
442 };
443
444 let mut changes = std::collections::HashMap::new();
445 changes.insert(uri.clone(), vec![edit]);
446
447 Some(CodeAction {
448 title: format!("Ignore {rule_id} for this line"),
449 kind: Some(CodeActionKind::QUICKFIX),
450 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
451 edit: Some(WorkspaceEdit {
452 changes: Some(changes),
453 document_changes: None,
454 change_annotations: None,
455 }),
456 command: None,
457 is_preferred: Some(false), disabled: None,
459 data: None,
460 })
461}
462
463#[deprecated(since = "0.0.167", note = "Use warning_to_code_actions instead")]
466pub fn warning_to_code_action(
467 warning: &crate::rule::LintWarning,
468 uri: &Url,
469 document_text: &str,
470) -> Option<CodeAction> {
471 warning_to_code_actions(warning, uri, document_text)
472 .into_iter()
473 .find(|action| action.is_preferred == Some(true))
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479 use crate::rule::{Fix, LintWarning, Severity};
480
481 #[test]
482 fn test_rumdl_lsp_config_default() {
483 let config = RumdlLspConfig::default();
484 assert_eq!(config.config_path, None);
485 assert!(config.enable_linting);
486 assert!(!config.enable_auto_fix);
487 }
488
489 #[test]
490 fn test_rumdl_lsp_config_serialization() {
491 let config = RumdlLspConfig {
492 config_path: Some("/path/to/config.toml".to_string()),
493 enable_linting: false,
494 enable_auto_fix: true,
495 enable_rules: None,
496 disable_rules: None,
497 configuration_preference: ConfigurationPreference::EditorFirst,
498 settings: None,
499 };
500
501 let json = serde_json::to_string(&config).unwrap();
503 assert!(json.contains("\"configPath\":\"/path/to/config.toml\""));
504 assert!(json.contains("\"enableLinting\":false"));
505 assert!(json.contains("\"enableAutoFix\":true"));
506
507 let deserialized: RumdlLspConfig = serde_json::from_str(&json).unwrap();
509 assert_eq!(deserialized.config_path, config.config_path);
510 assert_eq!(deserialized.enable_linting, config.enable_linting);
511 assert_eq!(deserialized.enable_auto_fix, config.enable_auto_fix);
512 }
513
514 #[test]
515 fn test_warning_to_diagnostic_basic() {
516 let warning = LintWarning {
517 line: 5,
518 column: 10,
519 end_line: 5,
520 end_column: 15,
521 rule_name: Some("MD001".to_string()),
522 message: "Test warning message".to_string(),
523 severity: Severity::Warning,
524 fix: None,
525 };
526
527 let diagnostic = warning_to_diagnostic(&warning);
528
529 assert_eq!(diagnostic.range.start.line, 4); assert_eq!(diagnostic.range.start.character, 9); assert_eq!(diagnostic.range.end.line, 4);
532 assert_eq!(diagnostic.range.end.character, 14);
533 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
534 assert_eq!(diagnostic.source, Some("rumdl".to_string()));
535 assert_eq!(diagnostic.message, "Test warning message");
536 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
537 }
538
539 #[test]
540 fn test_warning_to_diagnostic_error_severity() {
541 let warning = LintWarning {
542 line: 1,
543 column: 1,
544 end_line: 1,
545 end_column: 5,
546 rule_name: Some("MD002".to_string()),
547 message: "Error message".to_string(),
548 severity: Severity::Error,
549 fix: None,
550 };
551
552 let diagnostic = warning_to_diagnostic(&warning);
553 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::ERROR));
554 }
555
556 #[test]
557 fn test_warning_to_diagnostic_no_rule_name() {
558 let warning = LintWarning {
559 line: 1,
560 column: 1,
561 end_line: 1,
562 end_column: 5,
563 rule_name: None,
564 message: "Generic warning".to_string(),
565 severity: Severity::Warning,
566 fix: None,
567 };
568
569 let diagnostic = warning_to_diagnostic(&warning);
570 assert_eq!(diagnostic.code, None);
571 assert!(diagnostic.code_description.is_none());
572 }
573
574 #[test]
575 fn test_warning_to_diagnostic_edge_cases() {
576 let warning = LintWarning {
578 line: 0,
579 column: 0,
580 end_line: 0,
581 end_column: 0,
582 rule_name: Some("MD001".to_string()),
583 message: "Edge case".to_string(),
584 severity: Severity::Warning,
585 fix: None,
586 };
587
588 let diagnostic = warning_to_diagnostic(&warning);
589 assert_eq!(diagnostic.range.start.line, 0);
590 assert_eq!(diagnostic.range.start.character, 0);
591 }
592
593 #[test]
594 fn test_byte_range_to_lsp_range_simple() {
595 let text = "Hello\nWorld";
596 let range = byte_range_to_lsp_range(text, 0..5).unwrap();
597
598 assert_eq!(range.start.line, 0);
599 assert_eq!(range.start.character, 0);
600 assert_eq!(range.end.line, 0);
601 assert_eq!(range.end.character, 5);
602 }
603
604 #[test]
605 fn test_byte_range_to_lsp_range_multiline() {
606 let text = "Hello\nWorld\nTest";
607 let range = byte_range_to_lsp_range(text, 6..11).unwrap(); assert_eq!(range.start.line, 1);
610 assert_eq!(range.start.character, 0);
611 assert_eq!(range.end.line, 1);
612 assert_eq!(range.end.character, 5);
613 }
614
615 #[test]
616 fn test_byte_range_to_lsp_range_unicode() {
617 let text = "Hello 世界\nTest";
618 let range = byte_range_to_lsp_range(text, 6..12).unwrap();
620
621 assert_eq!(range.start.line, 0);
622 assert_eq!(range.start.character, 6);
623 assert_eq!(range.end.line, 0);
624 assert_eq!(range.end.character, 8); }
626
627 #[test]
628 fn test_byte_range_to_lsp_range_eof() {
629 let text = "Hello";
630 let range = byte_range_to_lsp_range(text, 0..5).unwrap();
631
632 assert_eq!(range.start.line, 0);
633 assert_eq!(range.start.character, 0);
634 assert_eq!(range.end.line, 0);
635 assert_eq!(range.end.character, 5);
636 }
637
638 #[test]
639 fn test_byte_range_to_lsp_range_invalid() {
640 let text = "Hello";
641 let range = byte_range_to_lsp_range(text, 10..15);
643 assert!(range.is_none());
644 }
645
646 #[test]
647 fn test_byte_range_to_lsp_range_insertion_at_eof() {
648 let text = "Hello\nWorld";
650 let text_len = text.len(); let range = byte_range_to_lsp_range(text, text_len..text_len).unwrap();
652
653 assert_eq!(range.start.line, 1);
655 assert_eq!(range.start.character, 5); assert_eq!(range.end.line, 1);
657 assert_eq!(range.end.character, 5);
658 }
659
660 #[test]
661 fn test_byte_range_to_lsp_range_insertion_at_eof_with_trailing_newline() {
662 let text = "Hello\nWorld\n";
664 let text_len = text.len(); let range = byte_range_to_lsp_range(text, text_len..text_len).unwrap();
666
667 assert_eq!(range.start.line, 2);
669 assert_eq!(range.start.character, 0); assert_eq!(range.end.line, 2);
671 assert_eq!(range.end.character, 0);
672 }
673
674 #[test]
675 fn test_warning_to_code_action_with_fix() {
676 let warning = LintWarning {
677 line: 1,
678 column: 1,
679 end_line: 1,
680 end_column: 5,
681 rule_name: Some("MD001".to_string()),
682 message: "Missing space".to_string(),
683 severity: Severity::Warning,
684 fix: Some(Fix {
685 range: 0..5,
686 replacement: "Fixed".to_string(),
687 }),
688 };
689
690 let uri = Url::parse("file:///test.md").unwrap();
691 let document_text = "Hello World";
692
693 let actions = warning_to_code_actions(&warning, &uri, document_text);
694 assert!(!actions.is_empty());
695 let action = &actions[0]; assert_eq!(action.title, "Fix: Missing space");
698 assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
699 assert_eq!(action.is_preferred, Some(true));
700
701 let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
702 let edits = &changes[&uri];
703 assert_eq!(edits.len(), 1);
704 assert_eq!(edits[0].new_text, "Fixed");
705 }
706
707 #[test]
708 fn test_warning_to_code_action_no_fix() {
709 let warning = LintWarning {
710 line: 1,
711 column: 1,
712 end_line: 1,
713 end_column: 5,
714 rule_name: Some("MD001".to_string()),
715 message: "No fix available".to_string(),
716 severity: Severity::Warning,
717 fix: None,
718 };
719
720 let uri = Url::parse("file:///test.md").unwrap();
721 let document_text = "Hello World";
722
723 let actions = warning_to_code_actions(&warning, &uri, document_text);
724 assert!(actions.iter().all(|a| a.is_preferred != Some(true)));
726 }
727
728 #[test]
729 fn test_warning_to_code_action_multiline_fix() {
730 let warning = LintWarning {
731 line: 2,
732 column: 1,
733 end_line: 3,
734 end_column: 5,
735 rule_name: Some("MD001".to_string()),
736 message: "Multiline fix".to_string(),
737 severity: Severity::Warning,
738 fix: Some(Fix {
739 range: 6..16, replacement: "Fixed\nContent".to_string(),
741 }),
742 };
743
744 let uri = Url::parse("file:///test.md").unwrap();
745 let document_text = "Hello\nWorld\nTest Line";
746
747 let actions = warning_to_code_actions(&warning, &uri, document_text);
748 assert!(!actions.is_empty());
749 let action = &actions[0]; let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
752 let edits = &changes[&uri];
753 assert_eq!(edits[0].new_text, "Fixed\nContent");
754 assert_eq!(edits[0].range.start.line, 1);
755 assert_eq!(edits[0].range.start.character, 0);
756 }
757
758 #[test]
759 fn test_code_description_url_generation() {
760 let warning = LintWarning {
761 line: 1,
762 column: 1,
763 end_line: 1,
764 end_column: 5,
765 rule_name: Some("MD013".to_string()),
766 message: "Line too long".to_string(),
767 severity: Severity::Warning,
768 fix: None,
769 };
770
771 let diagnostic = warning_to_diagnostic(&warning);
772 assert!(diagnostic.code_description.is_some());
773
774 let url = diagnostic.code_description.unwrap().href;
775 assert_eq!(url.as_str(), "https://github.com/rvben/rumdl/blob/main/docs/md013.md");
776 }
777
778 #[test]
779 fn test_lsp_config_partial_deserialization() {
780 let json = r#"{"enableLinting": false}"#;
782 let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
783
784 assert!(!config.enable_linting);
785 assert_eq!(config.config_path, None); assert!(!config.enable_auto_fix); }
788
789 #[test]
790 fn test_configuration_preference_serialization() {
791 let pref = ConfigurationPreference::EditorFirst;
793 let json = serde_json::to_string(&pref).unwrap();
794 assert_eq!(json, "\"editorFirst\"");
795
796 let pref = ConfigurationPreference::FilesystemFirst;
798 let json = serde_json::to_string(&pref).unwrap();
799 assert_eq!(json, "\"filesystemFirst\"");
800
801 let pref = ConfigurationPreference::EditorOnly;
803 let json = serde_json::to_string(&pref).unwrap();
804 assert_eq!(json, "\"editorOnly\"");
805
806 let pref: ConfigurationPreference = serde_json::from_str("\"filesystemFirst\"").unwrap();
808 assert_eq!(pref, ConfigurationPreference::FilesystemFirst);
809 }
810
811 #[test]
812 fn test_lsp_rule_settings_deserialization() {
813 let json = r#"{
815 "lineLength": 120,
816 "disable": ["MD001", "MD002"],
817 "enable": ["MD013"]
818 }"#;
819 let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
820
821 assert_eq!(settings.line_length, Some(120));
822 assert_eq!(settings.disable, Some(vec!["MD001".to_string(), "MD002".to_string()]));
823 assert_eq!(settings.enable, Some(vec!["MD013".to_string()]));
824 }
825
826 #[test]
827 fn test_lsp_rule_settings_with_per_rule_config() {
828 let json = r#"{
830 "lineLength": 80,
831 "MD013": {
832 "lineLength": 120,
833 "codeBlocks": false
834 },
835 "MD024": {
836 "siblingsOnly": true
837 }
838 }"#;
839 let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
840
841 assert_eq!(settings.line_length, Some(80));
842
843 let md013 = settings.rules.get("MD013").unwrap();
845 assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
846 assert_eq!(md013.get("codeBlocks").unwrap().as_bool(), Some(false));
847
848 let md024 = settings.rules.get("MD024").unwrap();
850 assert_eq!(md024.get("siblingsOnly").unwrap().as_bool(), Some(true));
851 }
852
853 #[test]
854 fn test_full_lsp_config_with_settings() {
855 let json = r#"{
857 "configPath": "/path/to/config",
858 "enableLinting": true,
859 "enableAutoFix": false,
860 "configurationPreference": "editorFirst",
861 "settings": {
862 "lineLength": 100,
863 "disable": ["MD033"],
864 "MD013": {
865 "lineLength": 120,
866 "tables": false
867 }
868 }
869 }"#;
870 let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
871
872 assert_eq!(config.config_path, Some("/path/to/config".to_string()));
873 assert!(config.enable_linting);
874 assert!(!config.enable_auto_fix);
875 assert_eq!(config.configuration_preference, ConfigurationPreference::EditorFirst);
876
877 let settings = config.settings.unwrap();
878 assert_eq!(settings.line_length, Some(100));
879 assert_eq!(settings.disable, Some(vec!["MD033".to_string()]));
880
881 let md013 = settings.rules.get("MD013").unwrap();
882 assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
883 assert_eq!(md013.get("tables").unwrap().as_bool(), Some(false));
884 }
885
886 #[test]
887 fn test_create_ignore_line_action_uses_rumdl_syntax() {
888 let warning = LintWarning {
889 line: 5,
890 column: 1,
891 end_line: 5,
892 end_column: 50,
893 rule_name: Some("MD013".to_string()),
894 message: "Line too long".to_string(),
895 severity: Severity::Warning,
896 fix: None,
897 };
898
899 let document = "Line 1\nLine 2\nLine 3\nLine 4\nThis is a very long line that exceeds the limit\nLine 6";
900 let uri = Url::parse("file:///test.md").unwrap();
901
902 let action = create_ignore_line_action(&warning, &uri, document).unwrap();
903
904 assert_eq!(action.title, "Ignore MD013 for this line");
905 assert_eq!(action.is_preferred, Some(false));
906 assert!(action.edit.is_some());
907
908 let edit = action.edit.unwrap();
910 let changes = edit.changes.unwrap();
911 let file_edits = changes.get(&uri).unwrap();
912
913 assert_eq!(file_edits.len(), 1);
914 assert!(file_edits[0].new_text.contains("rumdl-disable-line MD013"));
915 assert!(!file_edits[0].new_text.contains("markdownlint"));
916
917 assert_eq!(file_edits[0].range.start.line, 4); assert_eq!(file_edits[0].range.start.character, 47); }
921
922 #[test]
923 fn test_create_ignore_line_action_no_duplicate() {
924 let warning = LintWarning {
925 line: 1,
926 column: 1,
927 end_line: 1,
928 end_column: 50,
929 rule_name: Some("MD013".to_string()),
930 message: "Line too long".to_string(),
931 severity: Severity::Warning,
932 fix: None,
933 };
934
935 let document = "This is a line <!-- rumdl-disable-line MD013 -->";
937 let uri = Url::parse("file:///test.md").unwrap();
938
939 let action = create_ignore_line_action(&warning, &uri, document);
940
941 assert!(action.is_none());
943 }
944
945 #[test]
946 fn test_create_ignore_line_action_detects_markdownlint_syntax() {
947 let warning = LintWarning {
948 line: 1,
949 column: 1,
950 end_line: 1,
951 end_column: 50,
952 rule_name: Some("MD013".to_string()),
953 message: "Line too long".to_string(),
954 severity: Severity::Warning,
955 fix: None,
956 };
957
958 let document = "This is a line <!-- markdownlint-disable-line MD013 -->";
960 let uri = Url::parse("file:///test.md").unwrap();
961
962 let action = create_ignore_line_action(&warning, &uri, document);
963
964 assert!(action.is_none());
966 }
967
968 #[test]
969 fn test_warning_to_code_actions_with_fix() {
970 let warning = LintWarning {
971 line: 1,
972 column: 1,
973 end_line: 1,
974 end_column: 5,
975 rule_name: Some("MD009".to_string()),
976 message: "Trailing spaces".to_string(),
977 severity: Severity::Warning,
978 fix: Some(Fix {
979 range: 0..5,
980 replacement: "Fixed".to_string(),
981 }),
982 };
983
984 let uri = Url::parse("file:///test.md").unwrap();
985 let document_text = "Hello \nWorld";
986
987 let actions = warning_to_code_actions(&warning, &uri, document_text);
988
989 assert_eq!(actions.len(), 2);
991
992 assert_eq!(actions[0].title, "Fix: Trailing spaces");
994 assert_eq!(actions[0].is_preferred, Some(true));
995
996 assert_eq!(actions[1].title, "Ignore MD009 for this line");
998 assert_eq!(actions[1].is_preferred, Some(false));
999 }
1000
1001 #[test]
1002 fn test_warning_to_code_actions_no_fix() {
1003 let warning = LintWarning {
1004 line: 1,
1005 column: 1,
1006 end_line: 1,
1007 end_column: 10,
1008 rule_name: Some("MD033".to_string()),
1009 message: "Inline HTML".to_string(),
1010 severity: Severity::Warning,
1011 fix: None,
1012 };
1013
1014 let uri = Url::parse("file:///test.md").unwrap();
1015 let document_text = "<div>HTML</div>";
1016
1017 let actions = warning_to_code_actions(&warning, &uri, document_text);
1018
1019 assert_eq!(actions.len(), 1);
1021 assert_eq!(actions[0].title, "Ignore MD033 for this line");
1022 assert_eq!(actions[0].is_preferred, Some(false));
1023 }
1024
1025 #[test]
1026 fn test_warning_to_code_actions_no_rule_name() {
1027 let warning = LintWarning {
1028 line: 1,
1029 column: 1,
1030 end_line: 1,
1031 end_column: 5,
1032 rule_name: None,
1033 message: "Generic warning".to_string(),
1034 severity: Severity::Warning,
1035 fix: None,
1036 };
1037
1038 let uri = Url::parse("file:///test.md").unwrap();
1039 let document_text = "Hello World";
1040
1041 let actions = warning_to_code_actions(&warning, &uri, document_text);
1042
1043 assert_eq!(actions.len(), 0);
1045 }
1046
1047 #[test]
1048 fn test_legacy_warning_to_code_action_compatibility() {
1049 let warning = LintWarning {
1050 line: 1,
1051 column: 1,
1052 end_line: 1,
1053 end_column: 5,
1054 rule_name: Some("MD001".to_string()),
1055 message: "Test".to_string(),
1056 severity: Severity::Warning,
1057 fix: Some(Fix {
1058 range: 0..5,
1059 replacement: "Fixed".to_string(),
1060 }),
1061 };
1062
1063 let uri = Url::parse("file:///test.md").unwrap();
1064 let document_text = "Hello World";
1065
1066 #[allow(deprecated)]
1067 let action = warning_to_code_action(&warning, &uri, document_text);
1068
1069 assert!(action.is_some());
1071 let action = action.unwrap();
1072 assert_eq!(action.title, "Fix: Test");
1073 assert_eq!(action.is_preferred, Some(true));
1074 }
1075
1076 #[test]
1077 fn test_md034_convert_to_link_action() {
1078 let warning = LintWarning {
1080 line: 1,
1081 column: 1,
1082 end_line: 1,
1083 end_column: 25,
1084 rule_name: Some("MD034".to_string()),
1085 message: "URL without angle brackets or link formatting: 'https://example.com'".to_string(),
1086 severity: Severity::Warning,
1087 fix: Some(Fix {
1088 range: 0..20, replacement: "<https://example.com>".to_string(),
1090 }),
1091 };
1092
1093 let uri = Url::parse("file:///test.md").unwrap();
1094 let document_text = "https://example.com is a test URL";
1095
1096 let actions = warning_to_code_actions(&warning, &uri, document_text);
1097
1098 assert_eq!(actions.len(), 3);
1100
1101 assert_eq!(
1103 actions[0].title,
1104 "Fix: URL without angle brackets or link formatting: 'https://example.com'"
1105 );
1106 assert_eq!(actions[0].is_preferred, Some(true));
1107
1108 assert_eq!(actions[1].title, "Convert to markdown link");
1110 assert_eq!(actions[1].is_preferred, Some(false));
1111
1112 let edit = actions[1].edit.as_ref().unwrap();
1114 let changes = edit.changes.as_ref().unwrap();
1115 let file_edits = changes.get(&uri).unwrap();
1116 assert_eq!(file_edits.len(), 1);
1117
1118 assert_eq!(file_edits[0].new_text, "[example.com](https://example.com)");
1120
1121 assert_eq!(actions[2].title, "Ignore MD034 for this line");
1123 }
1124
1125 #[test]
1126 fn test_md034_convert_to_link_action_email() {
1127 let warning = LintWarning {
1129 line: 1,
1130 column: 1,
1131 end_line: 1,
1132 end_column: 20,
1133 rule_name: Some("MD034".to_string()),
1134 message: "Email address without angle brackets or link formatting: 'user@example.com'".to_string(),
1135 severity: Severity::Warning,
1136 fix: Some(Fix {
1137 range: 0..16, replacement: "<user@example.com>".to_string(),
1139 }),
1140 };
1141
1142 let uri = Url::parse("file:///test.md").unwrap();
1143 let document_text = "user@example.com is my email";
1144
1145 let actions = warning_to_code_actions(&warning, &uri, document_text);
1146
1147 assert_eq!(actions.len(), 3);
1149
1150 assert_eq!(actions[1].title, "Convert to markdown link");
1152
1153 let edit = actions[1].edit.as_ref().unwrap();
1154 let changes = edit.changes.as_ref().unwrap();
1155 let file_edits = changes.get(&uri).unwrap();
1156
1157 assert_eq!(file_edits[0].new_text, "[user@example.com](user@example.com)");
1159 }
1160
1161 #[test]
1162 fn test_extract_url_from_fix_replacement() {
1163 assert_eq!(
1164 extract_url_from_fix_replacement("<https://example.com>"),
1165 Some("https://example.com")
1166 );
1167 assert_eq!(
1168 extract_url_from_fix_replacement("<user@example.com>"),
1169 Some("user@example.com")
1170 );
1171 assert_eq!(extract_url_from_fix_replacement("https://example.com"), None);
1172 assert_eq!(extract_url_from_fix_replacement("<>"), Some(""));
1173 }
1174
1175 #[test]
1176 fn test_extract_domain_for_placeholder() {
1177 assert_eq!(extract_domain_for_placeholder("https://example.com"), "example.com");
1178 assert_eq!(
1179 extract_domain_for_placeholder("https://example.com/path/to/page"),
1180 "example.com"
1181 );
1182 assert_eq!(
1183 extract_domain_for_placeholder("http://sub.example.com:8080/"),
1184 "sub.example.com:8080"
1185 );
1186 assert_eq!(extract_domain_for_placeholder("user@example.com"), "user@example.com");
1187 assert_eq!(
1188 extract_domain_for_placeholder("ftp://files.example.com"),
1189 "files.example.com"
1190 );
1191 }
1192
1193 #[test]
1194 fn test_byte_range_to_lsp_range_trailing_newlines() {
1195 let text = "line1\nline2\n\n"; let range = byte_range_to_lsp_range(text, 12..13);
1200 assert!(range.is_some());
1201 let range = range.unwrap();
1202
1203 assert_eq!(range.start.line, 2);
1206 assert_eq!(range.start.character, 0);
1207 assert_eq!(range.end.line, 3);
1208 assert_eq!(range.end.character, 0);
1209 }
1210
1211 #[test]
1212 fn test_byte_range_to_lsp_range_at_eof() {
1213 let text = "test\n"; let range = byte_range_to_lsp_range(text, 5..5);
1218 assert!(range.is_some());
1219 let range = range.unwrap();
1220
1221 assert_eq!(range.start.line, 1);
1223 assert_eq!(range.start.character, 0);
1224 }
1225}