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}
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
126pub 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 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 let code_description = warning.rule_name.as_ref().and_then(|rule_name| {
147 Url::parse(&format!("https://rumdl.dev/{}/", rule_name.to_lowercase()))
149 .ok()
150 .map(|href| CodeDescription { href })
151 });
152
153 Diagnostic {
154 range: Range {
155 start: start_position,
156 end: end_position,
157 },
158 severity: Some(severity),
159 code: warning.rule_name.as_ref().map(|s| NumberOrString::String(s.clone())),
160 source: Some("rumdl".to_string()),
161 message: warning.message.clone(),
162 related_information: None,
163 tags: None,
164 code_description,
165 data: None,
166 }
167}
168
169fn byte_range_to_lsp_range(text: &str, byte_range: std::ops::Range<usize>) -> Option<Range> {
171 let mut line = 0u32;
172 let mut character = 0u32;
173 let mut byte_pos = 0;
174
175 let mut start_pos = None;
176 let mut end_pos = None;
177
178 for ch in text.chars() {
179 if byte_pos == byte_range.start {
180 start_pos = Some(Position { line, character });
181 }
182 if byte_pos == byte_range.end {
183 end_pos = Some(Position { line, character });
184 break;
185 }
186
187 if ch == '\n' {
188 line += 1;
189 character = 0;
190 } else {
191 character += 1;
192 }
193
194 byte_pos += ch.len_utf8();
195 }
196
197 if start_pos.is_none() && byte_pos >= byte_range.start {
200 start_pos = Some(Position { line, character });
201 }
202 if end_pos.is_none() && byte_pos >= byte_range.end {
203 end_pos = Some(Position { line, character });
204 }
205
206 match (start_pos, end_pos) {
207 (Some(start), Some(end)) => Some(Range { start, end }),
208 _ => {
209 log::warn!(
212 "Failed to convert byte range {:?} to LSP range for text of length {}",
213 byte_range,
214 text.len()
215 );
216 None
217 }
218 }
219}
220
221pub fn warning_to_code_actions(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Vec<CodeAction> {
224 warning_to_code_actions_with_md013_config(warning, uri, document_text, None)
225}
226
227pub(crate) fn warning_to_code_actions_with_md013_config(
231 warning: &crate::rule::LintWarning,
232 uri: &Url,
233 document_text: &str,
234 md013_config: Option<&MD013Config>,
235) -> Vec<CodeAction> {
236 let mut actions = Vec::new();
237
238 if let Some(fix_action) = create_fix_action(warning, uri, document_text) {
240 actions.push(fix_action);
241 }
242
243 if warning.rule_name.as_deref() == Some("MD013")
246 && warning.fix.is_none()
247 && let Some(reflow_action) = create_reflow_action(warning, uri, document_text, md013_config)
248 {
249 actions.push(reflow_action);
250 }
251
252 if warning.rule_name.as_deref() == Some("MD034")
255 && let Some(convert_action) = create_convert_to_link_action(warning, uri, document_text)
256 {
257 actions.push(convert_action);
258 }
259
260 if let Some(ignore_line_action) = create_ignore_line_action(warning, uri, document_text) {
262 actions.push(ignore_line_action);
263 }
264
265 actions
266}
267
268fn create_fix_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
270 if let Some(fix) = &warning.fix {
271 let range = byte_range_to_lsp_range(document_text, fix.range.clone())?;
273
274 let edit = TextEdit {
275 range,
276 new_text: fix.replacement.clone(),
277 };
278
279 let mut changes = std::collections::HashMap::new();
280 changes.insert(uri.clone(), vec![edit]);
281
282 let workspace_edit = WorkspaceEdit {
283 changes: Some(changes),
284 document_changes: None,
285 change_annotations: None,
286 };
287
288 Some(CodeAction {
289 title: format!("Fix: {}", warning.message),
290 kind: Some(CodeActionKind::QUICKFIX),
291 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
292 edit: Some(workspace_edit),
293 command: None,
294 is_preferred: Some(true),
295 disabled: None,
296 data: None,
297 })
298 } else {
299 None
300 }
301}
302
303fn create_reflow_action(
306 warning: &crate::rule::LintWarning,
307 uri: &Url,
308 document_text: &str,
309 md013_config: Option<&MD013Config>,
310) -> Option<CodeAction> {
311 let options = if let Some(config) = md013_config {
314 config.to_reflow_options()
315 } else {
316 let line_length = extract_line_length_from_message(&warning.message).unwrap_or(80);
317 crate::utils::text_reflow::ReflowOptions {
318 line_length,
319 ..Default::default()
320 }
321 };
322
323 let reflow_result =
325 crate::utils::text_reflow::reflow_paragraph_at_line_with_options(document_text, warning.line, &options)?;
326
327 let range = byte_range_to_lsp_range(document_text, reflow_result.start_byte..reflow_result.end_byte)?;
329
330 let edit = TextEdit {
331 range,
332 new_text: reflow_result.reflowed_text,
333 };
334
335 let mut changes = std::collections::HashMap::new();
336 changes.insert(uri.clone(), vec![edit]);
337
338 let workspace_edit = WorkspaceEdit {
339 changes: Some(changes),
340 document_changes: None,
341 change_annotations: None,
342 };
343
344 Some(CodeAction {
345 title: "Reflow paragraph".to_string(),
346 kind: Some(CodeActionKind::QUICKFIX),
347 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
348 edit: Some(workspace_edit),
349 command: None,
350 is_preferred: Some(false), disabled: None,
352 data: None,
353 })
354}
355
356fn extract_line_length_from_message(message: &str) -> Option<usize> {
359 let exceeds_idx = message.find("exceeds")?;
361 let after_exceeds = &message[exceeds_idx + 7..]; let num_str = after_exceeds.split_whitespace().next()?;
365
366 num_str.parse::<usize>().ok()
367}
368
369fn create_convert_to_link_action(
373 warning: &crate::rule::LintWarning,
374 uri: &Url,
375 document_text: &str,
376) -> Option<CodeAction> {
377 let fix = warning.fix.as_ref()?;
379
380 let url = extract_url_from_fix_replacement(&fix.replacement)?;
383
384 let range = byte_range_to_lsp_range(document_text, fix.range.clone())?;
386
387 let link_text = extract_domain_for_placeholder(url);
392 let new_text = format!("[{link_text}]({url})");
393
394 let edit = TextEdit { range, new_text };
395
396 let mut changes = std::collections::HashMap::new();
397 changes.insert(uri.clone(), vec![edit]);
398
399 let workspace_edit = WorkspaceEdit {
400 changes: Some(changes),
401 document_changes: None,
402 change_annotations: None,
403 };
404
405 Some(CodeAction {
406 title: "Convert to markdown link".to_string(),
407 kind: Some(CodeActionKind::QUICKFIX),
408 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
409 edit: Some(workspace_edit),
410 command: None,
411 is_preferred: Some(false), disabled: None,
413 data: None,
414 })
415}
416
417fn extract_url_from_fix_replacement(replacement: &str) -> Option<&str> {
420 let trimmed = replacement.trim();
422 if trimmed.starts_with('<') && trimmed.ends_with('>') {
423 Some(&trimmed[1..trimmed.len() - 1])
424 } else {
425 None
426 }
427}
428
429fn extract_domain_for_placeholder(url: &str) -> &str {
433 if url.contains('@') && !url.contains("://") {
435 return url;
436 }
437
438 url.split("://").nth(1).and_then(|s| s.split('/').next()).unwrap_or(url)
440}
441
442fn create_ignore_line_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
444 let rule_id = warning.rule_name.as_ref()?;
445 let warning_line = warning.line.saturating_sub(1);
446
447 let lines: Vec<&str> = document_text.lines().collect();
449 let line_content = lines.get(warning_line)?;
450
451 if line_content.contains("rumdl-disable-line") || line_content.contains("markdownlint-disable-line") {
453 return None;
455 }
456
457 let line_end = Position {
459 line: warning_line as u32,
460 character: line_content.len() as u32,
461 };
462
463 let comment = format!(" <!-- rumdl-disable-line {rule_id} -->");
465
466 let edit = TextEdit {
467 range: Range {
468 start: line_end,
469 end: line_end,
470 },
471 new_text: comment,
472 };
473
474 let mut changes = std::collections::HashMap::new();
475 changes.insert(uri.clone(), vec![edit]);
476
477 Some(CodeAction {
478 title: format!("Ignore {rule_id} for this line"),
479 kind: Some(CodeActionKind::QUICKFIX),
480 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
481 edit: Some(WorkspaceEdit {
482 changes: Some(changes),
483 document_changes: None,
484 change_annotations: None,
485 }),
486 command: None,
487 is_preferred: Some(false), disabled: None,
489 data: None,
490 })
491}
492
493#[deprecated(since = "0.0.167", note = "Use warning_to_code_actions instead")]
496pub fn warning_to_code_action(
497 warning: &crate::rule::LintWarning,
498 uri: &Url,
499 document_text: &str,
500) -> Option<CodeAction> {
501 warning_to_code_actions(warning, uri, document_text)
502 .into_iter()
503 .find(|action| action.is_preferred == Some(true))
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use crate::rule::{Fix, LintWarning, Severity};
510
511 #[test]
512 fn test_rumdl_lsp_config_default() {
513 let config = RumdlLspConfig::default();
514 assert_eq!(config.config_path, None);
515 assert!(config.enable_linting);
516 assert!(!config.enable_auto_fix);
517 }
518
519 #[test]
520 fn test_rumdl_lsp_config_serialization() {
521 let config = RumdlLspConfig {
522 config_path: Some("/path/to/config.toml".to_string()),
523 enable_linting: false,
524 enable_auto_fix: true,
525 enable_rules: None,
526 disable_rules: None,
527 configuration_preference: ConfigurationPreference::EditorFirst,
528 settings: None,
529 enable_link_completions: true,
530 };
531
532 let json = serde_json::to_string(&config).unwrap();
534 assert!(json.contains("\"configPath\":\"/path/to/config.toml\""));
535 assert!(json.contains("\"enableLinting\":false"));
536 assert!(json.contains("\"enableAutoFix\":true"));
537
538 let deserialized: RumdlLspConfig = serde_json::from_str(&json).unwrap();
540 assert_eq!(deserialized.config_path, config.config_path);
541 assert_eq!(deserialized.enable_linting, config.enable_linting);
542 assert_eq!(deserialized.enable_auto_fix, config.enable_auto_fix);
543 }
544
545 #[test]
546 fn test_warning_to_diagnostic_basic() {
547 let warning = LintWarning {
548 line: 5,
549 column: 10,
550 end_line: 5,
551 end_column: 15,
552 rule_name: Some("MD001".to_string()),
553 message: "Test warning message".to_string(),
554 severity: Severity::Warning,
555 fix: None,
556 };
557
558 let diagnostic = warning_to_diagnostic(&warning);
559
560 assert_eq!(diagnostic.range.start.line, 4); assert_eq!(diagnostic.range.start.character, 9); assert_eq!(diagnostic.range.end.line, 4);
563 assert_eq!(diagnostic.range.end.character, 14);
564 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
565 assert_eq!(diagnostic.source, Some("rumdl".to_string()));
566 assert_eq!(diagnostic.message, "Test warning message");
567 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
568 }
569
570 #[test]
571 fn test_warning_to_diagnostic_error_severity() {
572 let warning = LintWarning {
573 line: 1,
574 column: 1,
575 end_line: 1,
576 end_column: 5,
577 rule_name: Some("MD002".to_string()),
578 message: "Error message".to_string(),
579 severity: Severity::Error,
580 fix: None,
581 };
582
583 let diagnostic = warning_to_diagnostic(&warning);
584 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::ERROR));
585 }
586
587 #[test]
588 fn test_warning_to_diagnostic_no_rule_name() {
589 let warning = LintWarning {
590 line: 1,
591 column: 1,
592 end_line: 1,
593 end_column: 5,
594 rule_name: None,
595 message: "Generic warning".to_string(),
596 severity: Severity::Warning,
597 fix: None,
598 };
599
600 let diagnostic = warning_to_diagnostic(&warning);
601 assert_eq!(diagnostic.code, None);
602 assert!(diagnostic.code_description.is_none());
603 }
604
605 #[test]
606 fn test_warning_to_diagnostic_edge_cases() {
607 let warning = LintWarning {
609 line: 0,
610 column: 0,
611 end_line: 0,
612 end_column: 0,
613 rule_name: Some("MD001".to_string()),
614 message: "Edge case".to_string(),
615 severity: Severity::Warning,
616 fix: None,
617 };
618
619 let diagnostic = warning_to_diagnostic(&warning);
620 assert_eq!(diagnostic.range.start.line, 0);
621 assert_eq!(diagnostic.range.start.character, 0);
622 }
623
624 #[test]
625 fn test_byte_range_to_lsp_range_simple() {
626 let text = "Hello\nWorld";
627 let range = byte_range_to_lsp_range(text, 0..5).unwrap();
628
629 assert_eq!(range.start.line, 0);
630 assert_eq!(range.start.character, 0);
631 assert_eq!(range.end.line, 0);
632 assert_eq!(range.end.character, 5);
633 }
634
635 #[test]
636 fn test_byte_range_to_lsp_range_multiline() {
637 let text = "Hello\nWorld\nTest";
638 let range = byte_range_to_lsp_range(text, 6..11).unwrap(); assert_eq!(range.start.line, 1);
641 assert_eq!(range.start.character, 0);
642 assert_eq!(range.end.line, 1);
643 assert_eq!(range.end.character, 5);
644 }
645
646 #[test]
647 fn test_byte_range_to_lsp_range_unicode() {
648 let text = "Hello 世界\nTest";
649 let range = byte_range_to_lsp_range(text, 6..12).unwrap();
651
652 assert_eq!(range.start.line, 0);
653 assert_eq!(range.start.character, 6);
654 assert_eq!(range.end.line, 0);
655 assert_eq!(range.end.character, 8); }
657
658 #[test]
659 fn test_byte_range_to_lsp_range_eof() {
660 let text = "Hello";
661 let range = byte_range_to_lsp_range(text, 0..5).unwrap();
662
663 assert_eq!(range.start.line, 0);
664 assert_eq!(range.start.character, 0);
665 assert_eq!(range.end.line, 0);
666 assert_eq!(range.end.character, 5);
667 }
668
669 #[test]
670 fn test_byte_range_to_lsp_range_invalid() {
671 let text = "Hello";
672 let range = byte_range_to_lsp_range(text, 10..15);
674 assert!(range.is_none());
675 }
676
677 #[test]
678 fn test_byte_range_to_lsp_range_insertion_at_eof() {
679 let text = "Hello\nWorld";
681 let text_len = text.len(); let range = byte_range_to_lsp_range(text, text_len..text_len).unwrap();
683
684 assert_eq!(range.start.line, 1);
686 assert_eq!(range.start.character, 5); assert_eq!(range.end.line, 1);
688 assert_eq!(range.end.character, 5);
689 }
690
691 #[test]
692 fn test_byte_range_to_lsp_range_insertion_at_eof_with_trailing_newline() {
693 let text = "Hello\nWorld\n";
695 let text_len = text.len(); let range = byte_range_to_lsp_range(text, text_len..text_len).unwrap();
697
698 assert_eq!(range.start.line, 2);
700 assert_eq!(range.start.character, 0); assert_eq!(range.end.line, 2);
702 assert_eq!(range.end.character, 0);
703 }
704
705 #[test]
706 fn test_warning_to_code_action_with_fix() {
707 let warning = LintWarning {
708 line: 1,
709 column: 1,
710 end_line: 1,
711 end_column: 5,
712 rule_name: Some("MD001".to_string()),
713 message: "Missing space".to_string(),
714 severity: Severity::Warning,
715 fix: Some(Fix {
716 range: 0..5,
717 replacement: "Fixed".to_string(),
718 }),
719 };
720
721 let uri = Url::parse("file:///test.md").unwrap();
722 let document_text = "Hello World";
723
724 let actions = warning_to_code_actions(&warning, &uri, document_text);
725 assert!(!actions.is_empty());
726 let action = &actions[0]; assert_eq!(action.title, "Fix: Missing space");
729 assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
730 assert_eq!(action.is_preferred, Some(true));
731
732 let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
733 let edits = &changes[&uri];
734 assert_eq!(edits.len(), 1);
735 assert_eq!(edits[0].new_text, "Fixed");
736 }
737
738 #[test]
739 fn test_warning_to_code_action_no_fix() {
740 let warning = LintWarning {
741 line: 1,
742 column: 1,
743 end_line: 1,
744 end_column: 5,
745 rule_name: Some("MD001".to_string()),
746 message: "No fix available".to_string(),
747 severity: Severity::Warning,
748 fix: None,
749 };
750
751 let uri = Url::parse("file:///test.md").unwrap();
752 let document_text = "Hello World";
753
754 let actions = warning_to_code_actions(&warning, &uri, document_text);
755 assert!(actions.iter().all(|a| a.is_preferred != Some(true)));
757 }
758
759 #[test]
760 fn test_warning_to_code_actions_md013_blockquote_reflow_action() {
761 let warning = LintWarning {
762 line: 2,
763 column: 1,
764 end_line: 2,
765 end_column: 100,
766 rule_name: Some("MD013".to_string()),
767 message: "Line length 95 exceeds 40 characters".to_string(),
768 severity: Severity::Warning,
769 fix: None,
770 };
771
772 let uri = Url::parse("file:///test.md").unwrap();
773 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";
774
775 let actions = warning_to_code_actions(&warning, &uri, document_text);
776 let reflow_action = actions
777 .iter()
778 .find(|action| action.title == "Reflow paragraph")
779 .expect("Expected manual reflow action for MD013");
780
781 let changes = reflow_action
782 .edit
783 .as_ref()
784 .and_then(|edit| edit.changes.as_ref())
785 .expect("Expected edits for reflow action");
786 let file_edits = changes.get(&uri).expect("Expected edits for URI");
787 assert_eq!(file_edits.len(), 1);
788 assert!(
789 file_edits[0]
790 .new_text
791 .lines()
792 .next()
793 .is_some_and(|line| line.starts_with("> ")),
794 "Expected blockquote prefix in reflow output"
795 );
796 }
797
798 #[test]
799 fn test_warning_to_code_action_multiline_fix() {
800 let warning = LintWarning {
801 line: 2,
802 column: 1,
803 end_line: 3,
804 end_column: 5,
805 rule_name: Some("MD001".to_string()),
806 message: "Multiline fix".to_string(),
807 severity: Severity::Warning,
808 fix: Some(Fix {
809 range: 6..16, replacement: "Fixed\nContent".to_string(),
811 }),
812 };
813
814 let uri = Url::parse("file:///test.md").unwrap();
815 let document_text = "Hello\nWorld\nTest Line";
816
817 let actions = warning_to_code_actions(&warning, &uri, document_text);
818 assert!(!actions.is_empty());
819 let action = &actions[0]; let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
822 let edits = &changes[&uri];
823 assert_eq!(edits[0].new_text, "Fixed\nContent");
824 assert_eq!(edits[0].range.start.line, 1);
825 assert_eq!(edits[0].range.start.character, 0);
826 }
827
828 #[test]
829 fn test_code_description_url_generation() {
830 let warning = LintWarning {
831 line: 1,
832 column: 1,
833 end_line: 1,
834 end_column: 5,
835 rule_name: Some("MD013".to_string()),
836 message: "Line too long".to_string(),
837 severity: Severity::Warning,
838 fix: None,
839 };
840
841 let diagnostic = warning_to_diagnostic(&warning);
842 assert!(diagnostic.code_description.is_some());
843
844 let url = diagnostic.code_description.unwrap().href;
845 assert_eq!(url.as_str(), "https://rumdl.dev/md013/");
846 }
847
848 #[test]
849 fn test_lsp_config_partial_deserialization() {
850 let json = r#"{"enableLinting": false}"#;
852 let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
853
854 assert!(!config.enable_linting);
855 assert_eq!(config.config_path, None); assert!(!config.enable_auto_fix); }
858
859 #[test]
860 fn test_configuration_preference_serialization() {
861 let pref = ConfigurationPreference::EditorFirst;
863 let json = serde_json::to_string(&pref).unwrap();
864 assert_eq!(json, "\"editorFirst\"");
865
866 let pref = ConfigurationPreference::FilesystemFirst;
868 let json = serde_json::to_string(&pref).unwrap();
869 assert_eq!(json, "\"filesystemFirst\"");
870
871 let pref = ConfigurationPreference::EditorOnly;
873 let json = serde_json::to_string(&pref).unwrap();
874 assert_eq!(json, "\"editorOnly\"");
875
876 let pref: ConfigurationPreference = serde_json::from_str("\"filesystemFirst\"").unwrap();
878 assert_eq!(pref, ConfigurationPreference::FilesystemFirst);
879 }
880
881 #[test]
882 fn test_lsp_rule_settings_deserialization() {
883 let json = r#"{
885 "lineLength": 120,
886 "disable": ["MD001", "MD002"],
887 "enable": ["MD013"]
888 }"#;
889 let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
890
891 assert_eq!(settings.line_length, Some(120));
892 assert_eq!(settings.disable, Some(vec!["MD001".to_string(), "MD002".to_string()]));
893 assert_eq!(settings.enable, Some(vec!["MD013".to_string()]));
894 }
895
896 #[test]
897 fn test_lsp_rule_settings_with_per_rule_config() {
898 let json = r#"{
900 "lineLength": 80,
901 "MD013": {
902 "lineLength": 120,
903 "codeBlocks": false
904 },
905 "MD024": {
906 "siblingsOnly": true
907 }
908 }"#;
909 let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
910
911 assert_eq!(settings.line_length, Some(80));
912
913 let md013 = settings.rules.get("MD013").unwrap();
915 assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
916 assert_eq!(md013.get("codeBlocks").unwrap().as_bool(), Some(false));
917
918 let md024 = settings.rules.get("MD024").unwrap();
920 assert_eq!(md024.get("siblingsOnly").unwrap().as_bool(), Some(true));
921 }
922
923 #[test]
924 fn test_full_lsp_config_with_settings() {
925 let json = r#"{
927 "configPath": "/path/to/config",
928 "enableLinting": true,
929 "enableAutoFix": false,
930 "configurationPreference": "editorFirst",
931 "settings": {
932 "lineLength": 100,
933 "disable": ["MD033"],
934 "MD013": {
935 "lineLength": 120,
936 "tables": false
937 }
938 }
939 }"#;
940 let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
941
942 assert_eq!(config.config_path, Some("/path/to/config".to_string()));
943 assert!(config.enable_linting);
944 assert!(!config.enable_auto_fix);
945 assert_eq!(config.configuration_preference, ConfigurationPreference::EditorFirst);
946
947 let settings = config.settings.unwrap();
948 assert_eq!(settings.line_length, Some(100));
949 assert_eq!(settings.disable, Some(vec!["MD033".to_string()]));
950
951 let md013 = settings.rules.get("MD013").unwrap();
952 assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
953 assert_eq!(md013.get("tables").unwrap().as_bool(), Some(false));
954 }
955
956 #[test]
957 fn test_create_ignore_line_action_uses_rumdl_syntax() {
958 let warning = LintWarning {
959 line: 5,
960 column: 1,
961 end_line: 5,
962 end_column: 50,
963 rule_name: Some("MD013".to_string()),
964 message: "Line too long".to_string(),
965 severity: Severity::Warning,
966 fix: None,
967 };
968
969 let document = "Line 1\nLine 2\nLine 3\nLine 4\nThis is a very long line that exceeds the limit\nLine 6";
970 let uri = Url::parse("file:///test.md").unwrap();
971
972 let action = create_ignore_line_action(&warning, &uri, document).unwrap();
973
974 assert_eq!(action.title, "Ignore MD013 for this line");
975 assert_eq!(action.is_preferred, Some(false));
976 assert!(action.edit.is_some());
977
978 let edit = action.edit.unwrap();
980 let changes = edit.changes.unwrap();
981 let file_edits = changes.get(&uri).unwrap();
982
983 assert_eq!(file_edits.len(), 1);
984 assert!(file_edits[0].new_text.contains("rumdl-disable-line MD013"));
985 assert!(!file_edits[0].new_text.contains("markdownlint"));
986
987 assert_eq!(file_edits[0].range.start.line, 4); assert_eq!(file_edits[0].range.start.character, 47); }
991
992 #[test]
993 fn test_create_ignore_line_action_no_duplicate() {
994 let warning = LintWarning {
995 line: 1,
996 column: 1,
997 end_line: 1,
998 end_column: 50,
999 rule_name: Some("MD013".to_string()),
1000 message: "Line too long".to_string(),
1001 severity: Severity::Warning,
1002 fix: None,
1003 };
1004
1005 let document = "This is a line <!-- rumdl-disable-line MD013 -->";
1007 let uri = Url::parse("file:///test.md").unwrap();
1008
1009 let action = create_ignore_line_action(&warning, &uri, document);
1010
1011 assert!(action.is_none());
1013 }
1014
1015 #[test]
1016 fn test_create_ignore_line_action_detects_markdownlint_syntax() {
1017 let warning = LintWarning {
1018 line: 1,
1019 column: 1,
1020 end_line: 1,
1021 end_column: 50,
1022 rule_name: Some("MD013".to_string()),
1023 message: "Line too long".to_string(),
1024 severity: Severity::Warning,
1025 fix: None,
1026 };
1027
1028 let document = "This is a line <!-- markdownlint-disable-line MD013 -->";
1030 let uri = Url::parse("file:///test.md").unwrap();
1031
1032 let action = create_ignore_line_action(&warning, &uri, document);
1033
1034 assert!(action.is_none());
1036 }
1037
1038 #[test]
1039 fn test_warning_to_code_actions_with_fix() {
1040 let warning = LintWarning {
1041 line: 1,
1042 column: 1,
1043 end_line: 1,
1044 end_column: 5,
1045 rule_name: Some("MD009".to_string()),
1046 message: "Trailing spaces".to_string(),
1047 severity: Severity::Warning,
1048 fix: Some(Fix {
1049 range: 0..5,
1050 replacement: "Fixed".to_string(),
1051 }),
1052 };
1053
1054 let uri = Url::parse("file:///test.md").unwrap();
1055 let document_text = "Hello \nWorld";
1056
1057 let actions = warning_to_code_actions(&warning, &uri, document_text);
1058
1059 assert_eq!(actions.len(), 2);
1061
1062 assert_eq!(actions[0].title, "Fix: Trailing spaces");
1064 assert_eq!(actions[0].is_preferred, Some(true));
1065
1066 assert_eq!(actions[1].title, "Ignore MD009 for this line");
1068 assert_eq!(actions[1].is_preferred, Some(false));
1069 }
1070
1071 #[test]
1072 fn test_warning_to_code_actions_no_fix() {
1073 let warning = LintWarning {
1074 line: 1,
1075 column: 1,
1076 end_line: 1,
1077 end_column: 10,
1078 rule_name: Some("MD033".to_string()),
1079 message: "Inline HTML".to_string(),
1080 severity: Severity::Warning,
1081 fix: None,
1082 };
1083
1084 let uri = Url::parse("file:///test.md").unwrap();
1085 let document_text = "<div>HTML</div>";
1086
1087 let actions = warning_to_code_actions(&warning, &uri, document_text);
1088
1089 assert_eq!(actions.len(), 1);
1091 assert_eq!(actions[0].title, "Ignore MD033 for this line");
1092 assert_eq!(actions[0].is_preferred, Some(false));
1093 }
1094
1095 #[test]
1096 fn test_warning_to_code_actions_no_rule_name() {
1097 let warning = LintWarning {
1098 line: 1,
1099 column: 1,
1100 end_line: 1,
1101 end_column: 5,
1102 rule_name: None,
1103 message: "Generic warning".to_string(),
1104 severity: Severity::Warning,
1105 fix: None,
1106 };
1107
1108 let uri = Url::parse("file:///test.md").unwrap();
1109 let document_text = "Hello World";
1110
1111 let actions = warning_to_code_actions(&warning, &uri, document_text);
1112
1113 assert_eq!(actions.len(), 0);
1115 }
1116
1117 #[test]
1118 fn test_legacy_warning_to_code_action_compatibility() {
1119 let warning = LintWarning {
1120 line: 1,
1121 column: 1,
1122 end_line: 1,
1123 end_column: 5,
1124 rule_name: Some("MD001".to_string()),
1125 message: "Test".to_string(),
1126 severity: Severity::Warning,
1127 fix: Some(Fix {
1128 range: 0..5,
1129 replacement: "Fixed".to_string(),
1130 }),
1131 };
1132
1133 let uri = Url::parse("file:///test.md").unwrap();
1134 let document_text = "Hello World";
1135
1136 #[allow(deprecated)]
1137 let action = warning_to_code_action(&warning, &uri, document_text);
1138
1139 assert!(action.is_some());
1141 let action = action.unwrap();
1142 assert_eq!(action.title, "Fix: Test");
1143 assert_eq!(action.is_preferred, Some(true));
1144 }
1145
1146 #[test]
1147 fn test_md034_convert_to_link_action() {
1148 let warning = LintWarning {
1150 line: 1,
1151 column: 1,
1152 end_line: 1,
1153 end_column: 25,
1154 rule_name: Some("MD034".to_string()),
1155 message: "URL without angle brackets or link formatting: 'https://example.com'".to_string(),
1156 severity: Severity::Warning,
1157 fix: Some(Fix {
1158 range: 0..20, replacement: "<https://example.com>".to_string(),
1160 }),
1161 };
1162
1163 let uri = Url::parse("file:///test.md").unwrap();
1164 let document_text = "https://example.com is a test URL";
1165
1166 let actions = warning_to_code_actions(&warning, &uri, document_text);
1167
1168 assert_eq!(actions.len(), 3);
1170
1171 assert_eq!(
1173 actions[0].title,
1174 "Fix: URL without angle brackets or link formatting: 'https://example.com'"
1175 );
1176 assert_eq!(actions[0].is_preferred, Some(true));
1177
1178 assert_eq!(actions[1].title, "Convert to markdown link");
1180 assert_eq!(actions[1].is_preferred, Some(false));
1181
1182 let edit = actions[1].edit.as_ref().unwrap();
1184 let changes = edit.changes.as_ref().unwrap();
1185 let file_edits = changes.get(&uri).unwrap();
1186 assert_eq!(file_edits.len(), 1);
1187
1188 assert_eq!(file_edits[0].new_text, "[example.com](https://example.com)");
1190
1191 assert_eq!(actions[2].title, "Ignore MD034 for this line");
1193 }
1194
1195 #[test]
1196 fn test_md034_convert_to_link_action_email() {
1197 let warning = LintWarning {
1199 line: 1,
1200 column: 1,
1201 end_line: 1,
1202 end_column: 20,
1203 rule_name: Some("MD034".to_string()),
1204 message: "Email address without angle brackets or link formatting: 'user@example.com'".to_string(),
1205 severity: Severity::Warning,
1206 fix: Some(Fix {
1207 range: 0..16, replacement: "<user@example.com>".to_string(),
1209 }),
1210 };
1211
1212 let uri = Url::parse("file:///test.md").unwrap();
1213 let document_text = "user@example.com is my email";
1214
1215 let actions = warning_to_code_actions(&warning, &uri, document_text);
1216
1217 assert_eq!(actions.len(), 3);
1219
1220 assert_eq!(actions[1].title, "Convert to markdown link");
1222
1223 let edit = actions[1].edit.as_ref().unwrap();
1224 let changes = edit.changes.as_ref().unwrap();
1225 let file_edits = changes.get(&uri).unwrap();
1226
1227 assert_eq!(file_edits[0].new_text, "[user@example.com](user@example.com)");
1229 }
1230
1231 #[test]
1232 fn test_extract_url_from_fix_replacement() {
1233 assert_eq!(
1234 extract_url_from_fix_replacement("<https://example.com>"),
1235 Some("https://example.com")
1236 );
1237 assert_eq!(
1238 extract_url_from_fix_replacement("<user@example.com>"),
1239 Some("user@example.com")
1240 );
1241 assert_eq!(extract_url_from_fix_replacement("https://example.com"), None);
1242 assert_eq!(extract_url_from_fix_replacement("<>"), Some(""));
1243 }
1244
1245 #[test]
1246 fn test_extract_domain_for_placeholder() {
1247 assert_eq!(extract_domain_for_placeholder("https://example.com"), "example.com");
1248 assert_eq!(
1249 extract_domain_for_placeholder("https://example.com/path/to/page"),
1250 "example.com"
1251 );
1252 assert_eq!(
1253 extract_domain_for_placeholder("http://sub.example.com:8080/"),
1254 "sub.example.com:8080"
1255 );
1256 assert_eq!(extract_domain_for_placeholder("user@example.com"), "user@example.com");
1257 assert_eq!(
1258 extract_domain_for_placeholder("ftp://files.example.com"),
1259 "files.example.com"
1260 );
1261 }
1262
1263 #[test]
1264 fn test_byte_range_to_lsp_range_trailing_newlines() {
1265 let text = "line1\nline2\n\n"; let range = byte_range_to_lsp_range(text, 12..13);
1270 assert!(range.is_some());
1271 let range = range.unwrap();
1272
1273 assert_eq!(range.start.line, 2);
1276 assert_eq!(range.start.character, 0);
1277 assert_eq!(range.end.line, 3);
1278 assert_eq!(range.end.character, 0);
1279 }
1280
1281 #[test]
1282 fn test_byte_range_to_lsp_range_at_eof() {
1283 let text = "test\n"; let range = byte_range_to_lsp_range(text, 5..5);
1288 assert!(range.is_some());
1289 let range = range.unwrap();
1290
1291 assert_eq!(range.start.line, 1);
1293 assert_eq!(range.start.character, 0);
1294 }
1295}