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 pub link_completion_content_roots: Vec<String>,
117}
118
119impl Default for RumdlLspConfig {
120 fn default() -> Self {
121 Self {
122 config_path: None,
123 enable_linting: true,
124 enable_auto_fix: false,
125 enable_rules: None,
126 disable_rules: None,
127 configuration_preference: ConfigurationPreference::default(),
128 settings: None,
129 enable_link_completions: true,
130 enable_link_navigation: true,
131 link_completion_content_roots: Vec::new(),
132 }
133 }
134}
135
136pub fn warning_to_diagnostic(warning: &crate::rule::LintWarning) -> Diagnostic {
138 let start_position = Position {
139 line: (warning.line.saturating_sub(1)) as u32,
140 character: (warning.column.saturating_sub(1)) as u32,
141 };
142
143 let end_position = Position {
145 line: (warning.end_line.saturating_sub(1)) as u32,
146 character: (warning.end_column.saturating_sub(1)) as u32,
147 };
148
149 let severity = match warning.severity {
150 crate::rule::Severity::Error => DiagnosticSeverity::ERROR,
151 crate::rule::Severity::Warning => DiagnosticSeverity::WARNING,
152 crate::rule::Severity::Info => DiagnosticSeverity::INFORMATION,
153 };
154
155 let code_description = warning.rule_name.as_ref().and_then(|rule_name| {
158 let is_rumdl_rule = rule_name.len() > 2
159 && rule_name[..2].eq_ignore_ascii_case("MD")
160 && rule_name[2..].chars().all(|c| c.is_ascii_digit());
161 if is_rumdl_rule {
162 Url::parse(&format!("https://rumdl.dev/{}/", rule_name.to_lowercase()))
163 .ok()
164 .map(|href| CodeDescription { href })
165 } else {
166 None
167 }
168 });
169
170 Diagnostic {
171 range: Range {
172 start: start_position,
173 end: end_position,
174 },
175 severity: Some(severity),
176 code: warning.rule_name.as_ref().map(|s| NumberOrString::String(s.clone())),
177 source: Some("rumdl".to_string()),
178 message: warning.message.clone(),
179 related_information: None,
180 tags: None,
181 code_description,
182 data: None,
183 }
184}
185
186fn byte_range_to_lsp_range(text: &str, byte_range: std::ops::Range<usize>) -> Option<Range> {
194 let mut line = 0u32;
195 let mut character = 0u32;
196 let mut byte_pos = 0;
197
198 let mut start_pos = None;
199 let mut end_pos = None;
200
201 for ch in text.chars() {
202 if byte_pos == byte_range.start {
203 start_pos = Some(Position { line, character });
204 }
205 if byte_pos == byte_range.end {
206 end_pos = Some(Position { line, character });
207 break;
208 }
209
210 if ch == '\n' {
211 line += 1;
212 character = 0;
213 } else {
214 character += ch.len_utf16() as u32;
215 }
216
217 byte_pos += ch.len_utf8();
218 }
219
220 if start_pos.is_none() && byte_pos >= byte_range.start {
223 start_pos = Some(Position { line, character });
224 }
225 if end_pos.is_none() && byte_pos >= byte_range.end {
226 end_pos = Some(Position { line, character });
227 }
228
229 match (start_pos, end_pos) {
230 (Some(start), Some(end)) => Some(Range { start, end }),
231 _ => {
232 log::warn!(
235 "Failed to convert byte range {:?} to LSP range for text of length {}",
236 byte_range,
237 text.len()
238 );
239 None
240 }
241 }
242}
243
244pub fn warning_to_code_actions(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Vec<CodeAction> {
247 warning_to_code_actions_with_md013_config(warning, uri, document_text, None)
248}
249
250pub(crate) fn warning_to_code_actions_with_md013_config(
254 warning: &crate::rule::LintWarning,
255 uri: &Url,
256 document_text: &str,
257 md013_config: Option<&MD013Config>,
258) -> Vec<CodeAction> {
259 let mut actions = Vec::new();
260
261 if let Some(fix_action) = create_fix_action(warning, uri, document_text) {
263 actions.push(fix_action);
264 }
265
266 if warning.rule_name.as_deref() == Some("MD013")
269 && warning.fix.is_none()
270 && let Some(reflow_action) = create_reflow_action(warning, uri, document_text, md013_config)
271 {
272 actions.push(reflow_action);
273 }
274
275 if warning.rule_name.as_deref() == Some("MD034")
278 && let Some(convert_action) = create_convert_to_link_action(warning, uri, document_text)
279 {
280 actions.push(convert_action);
281 }
282
283 if let Some(ignore_line_action) = create_ignore_line_action(warning, uri, document_text) {
285 actions.push(ignore_line_action);
286 }
287
288 actions
289}
290
291fn create_fix_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
293 if let Some(fix) = &warning.fix {
294 let primary = TextEdit {
299 range: byte_range_to_lsp_range(document_text, fix.range.clone())?,
300 new_text: fix.replacement.clone(),
301 };
302
303 let mut edits = Vec::with_capacity(1 + fix.additional_edits.len());
304 edits.push(primary);
305 for extra in &fix.additional_edits {
306 edits.push(TextEdit {
307 range: byte_range_to_lsp_range(document_text, extra.range.clone())?,
308 new_text: extra.replacement.clone(),
309 });
310 }
311
312 let mut changes = std::collections::HashMap::new();
313 changes.insert(uri.clone(), edits);
314
315 let workspace_edit = WorkspaceEdit {
316 changes: Some(changes),
317 document_changes: None,
318 change_annotations: None,
319 };
320
321 Some(CodeAction {
322 title: format!("Fix: {}", warning.message),
323 kind: Some(CodeActionKind::QUICKFIX),
324 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
325 edit: Some(workspace_edit),
326 command: None,
327 is_preferred: Some(true),
328 disabled: None,
329 data: None,
330 })
331 } else {
332 None
333 }
334}
335
336fn create_reflow_action(
339 warning: &crate::rule::LintWarning,
340 uri: &Url,
341 document_text: &str,
342 md013_config: Option<&MD013Config>,
343) -> Option<CodeAction> {
344 let options = if let Some(config) = md013_config {
347 config.to_reflow_options()
348 } else {
349 let line_length = extract_line_length_from_message(&warning.message).unwrap_or(80);
350 crate::utils::text_reflow::ReflowOptions {
351 line_length,
352 ..Default::default()
353 }
354 };
355
356 let reflow_result =
358 crate::utils::text_reflow::reflow_paragraph_at_line_with_options(document_text, warning.line, &options)?;
359
360 let range = byte_range_to_lsp_range(document_text, reflow_result.start_byte..reflow_result.end_byte)?;
362
363 let edit = TextEdit {
364 range,
365 new_text: reflow_result.reflowed_text,
366 };
367
368 let mut changes = std::collections::HashMap::new();
369 changes.insert(uri.clone(), vec![edit]);
370
371 let workspace_edit = WorkspaceEdit {
372 changes: Some(changes),
373 document_changes: None,
374 change_annotations: None,
375 };
376
377 Some(CodeAction {
378 title: "Reflow paragraph".to_string(),
379 kind: Some(CodeActionKind::QUICKFIX),
380 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
381 edit: Some(workspace_edit),
382 command: None,
383 is_preferred: Some(false), disabled: None,
385 data: None,
386 })
387}
388
389fn extract_line_length_from_message(message: &str) -> Option<usize> {
392 let exceeds_idx = message.find("exceeds")?;
394 let after_exceeds = &message[exceeds_idx + 7..]; let num_str = after_exceeds.split_whitespace().next()?;
398
399 num_str.parse::<usize>().ok()
400}
401
402fn create_convert_to_link_action(
406 warning: &crate::rule::LintWarning,
407 uri: &Url,
408 document_text: &str,
409) -> Option<CodeAction> {
410 let fix = warning.fix.as_ref()?;
412
413 let url = extract_url_from_fix_replacement(&fix.replacement)?;
416
417 let range = byte_range_to_lsp_range(document_text, fix.range.clone())?;
419
420 let link_text = extract_domain_for_placeholder(url);
425 let new_text = format!("[{link_text}]({url})");
426
427 let edit = TextEdit { range, new_text };
428
429 let mut changes = std::collections::HashMap::new();
430 changes.insert(uri.clone(), vec![edit]);
431
432 let workspace_edit = WorkspaceEdit {
433 changes: Some(changes),
434 document_changes: None,
435 change_annotations: None,
436 };
437
438 Some(CodeAction {
439 title: "Convert to markdown link".to_string(),
440 kind: Some(CodeActionKind::QUICKFIX),
441 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
442 edit: Some(workspace_edit),
443 command: None,
444 is_preferred: Some(false), disabled: None,
446 data: None,
447 })
448}
449
450fn extract_url_from_fix_replacement(replacement: &str) -> Option<&str> {
453 let trimmed = replacement.trim();
455 if trimmed.starts_with('<') && trimmed.ends_with('>') {
456 Some(&trimmed[1..trimmed.len() - 1])
457 } else {
458 None
459 }
460}
461
462fn extract_domain_for_placeholder(url: &str) -> &str {
466 if url.contains('@') && !url.contains("://") {
468 return url;
469 }
470
471 url.split("://").nth(1).and_then(|s| s.split('/').next()).unwrap_or(url)
473}
474
475fn create_ignore_line_action(warning: &crate::rule::LintWarning, uri: &Url, document_text: &str) -> Option<CodeAction> {
477 let rule_id = warning.rule_name.as_ref()?;
478 let warning_line = warning.line.saturating_sub(1);
479
480 let lines: Vec<&str> = document_text.lines().collect();
482 let line_content = lines.get(warning_line)?;
483
484 if line_content.contains("rumdl-disable-line") || line_content.contains("markdownlint-disable-line") {
486 return None;
488 }
489
490 let line_end = Position {
492 line: warning_line as u32,
493 character: line_content.len() as u32,
494 };
495
496 let comment = format!(" <!-- rumdl-disable-line {rule_id} -->");
498
499 let edit = TextEdit {
500 range: Range {
501 start: line_end,
502 end: line_end,
503 },
504 new_text: comment,
505 };
506
507 let mut changes = std::collections::HashMap::new();
508 changes.insert(uri.clone(), vec![edit]);
509
510 Some(CodeAction {
511 title: format!("Ignore {rule_id} for this line"),
512 kind: Some(CodeActionKind::QUICKFIX),
513 diagnostics: Some(vec![warning_to_diagnostic(warning)]),
514 edit: Some(WorkspaceEdit {
515 changes: Some(changes),
516 document_changes: None,
517 change_annotations: None,
518 }),
519 command: None,
520 is_preferred: Some(false), disabled: None,
522 data: None,
523 })
524}
525
526#[deprecated(since = "0.0.167", note = "Use warning_to_code_actions instead")]
529pub fn warning_to_code_action(
530 warning: &crate::rule::LintWarning,
531 uri: &Url,
532 document_text: &str,
533) -> Option<CodeAction> {
534 warning_to_code_actions(warning, uri, document_text)
535 .into_iter()
536 .find(|action| action.is_preferred == Some(true))
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542 use crate::rule::{Fix, LintWarning, Severity};
543
544 #[test]
545 fn test_rumdl_lsp_config_default() {
546 let config = RumdlLspConfig::default();
547 assert_eq!(config.config_path, None);
548 assert!(config.enable_linting);
549 assert!(!config.enable_auto_fix);
550 }
551
552 #[test]
553 fn test_rumdl_lsp_config_serialization() {
554 let config = RumdlLspConfig {
555 config_path: Some("/path/to/config.toml".to_string()),
556 enable_linting: false,
557 enable_auto_fix: true,
558 enable_rules: None,
559 disable_rules: None,
560 configuration_preference: ConfigurationPreference::EditorFirst,
561 settings: None,
562 enable_link_completions: true,
563 enable_link_navigation: true,
564 link_completion_content_roots: Vec::new(),
565 };
566
567 let json = serde_json::to_string(&config).unwrap();
569 assert!(json.contains("\"configPath\":\"/path/to/config.toml\""));
570 assert!(json.contains("\"enableLinting\":false"));
571 assert!(json.contains("\"enableAutoFix\":true"));
572
573 let deserialized: RumdlLspConfig = serde_json::from_str(&json).unwrap();
575 assert_eq!(deserialized.config_path, config.config_path);
576 assert_eq!(deserialized.enable_linting, config.enable_linting);
577 assert_eq!(deserialized.enable_auto_fix, config.enable_auto_fix);
578 }
579
580 #[test]
581 fn test_warning_to_diagnostic_basic() {
582 let warning = LintWarning {
583 line: 5,
584 column: 10,
585 end_line: 5,
586 end_column: 15,
587 rule_name: Some("MD001".to_string()),
588 message: "Test warning message".to_string(),
589 severity: Severity::Warning,
590 fix: None,
591 };
592
593 let diagnostic = warning_to_diagnostic(&warning);
594
595 assert_eq!(diagnostic.range.start.line, 4); assert_eq!(diagnostic.range.start.character, 9); assert_eq!(diagnostic.range.end.line, 4);
598 assert_eq!(diagnostic.range.end.character, 14);
599 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::WARNING));
600 assert_eq!(diagnostic.source, Some("rumdl".to_string()));
601 assert_eq!(diagnostic.message, "Test warning message");
602 assert_eq!(diagnostic.code, Some(NumberOrString::String("MD001".to_string())));
603 }
604
605 #[test]
606 fn test_warning_to_diagnostic_error_severity() {
607 let warning = LintWarning {
608 line: 1,
609 column: 1,
610 end_line: 1,
611 end_column: 5,
612 rule_name: Some("MD002".to_string()),
613 message: "Error message".to_string(),
614 severity: Severity::Error,
615 fix: None,
616 };
617
618 let diagnostic = warning_to_diagnostic(&warning);
619 assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::ERROR));
620 }
621
622 #[test]
623 fn test_warning_to_diagnostic_no_rule_name() {
624 let warning = LintWarning {
625 line: 1,
626 column: 1,
627 end_line: 1,
628 end_column: 5,
629 rule_name: None,
630 message: "Generic warning".to_string(),
631 severity: Severity::Warning,
632 fix: None,
633 };
634
635 let diagnostic = warning_to_diagnostic(&warning);
636 assert_eq!(diagnostic.code, None);
637 assert!(diagnostic.code_description.is_none());
638 }
639
640 #[test]
641 fn test_warning_to_diagnostic_edge_cases() {
642 let warning = LintWarning {
644 line: 0,
645 column: 0,
646 end_line: 0,
647 end_column: 0,
648 rule_name: Some("MD001".to_string()),
649 message: "Edge case".to_string(),
650 severity: Severity::Warning,
651 fix: None,
652 };
653
654 let diagnostic = warning_to_diagnostic(&warning);
655 assert_eq!(diagnostic.range.start.line, 0);
656 assert_eq!(diagnostic.range.start.character, 0);
657 }
658
659 #[test]
660 fn test_byte_range_to_lsp_range_simple() {
661 let text = "Hello\nWorld";
662 let range = byte_range_to_lsp_range(text, 0..5).unwrap();
663
664 assert_eq!(range.start.line, 0);
665 assert_eq!(range.start.character, 0);
666 assert_eq!(range.end.line, 0);
667 assert_eq!(range.end.character, 5);
668 }
669
670 #[test]
671 fn test_byte_range_to_lsp_range_multiline() {
672 let text = "Hello\nWorld\nTest";
673 let range = byte_range_to_lsp_range(text, 6..11).unwrap(); assert_eq!(range.start.line, 1);
676 assert_eq!(range.start.character, 0);
677 assert_eq!(range.end.line, 1);
678 assert_eq!(range.end.character, 5);
679 }
680
681 #[test]
682 fn test_byte_range_to_lsp_range_unicode() {
683 let text = "Hello 世界\nTest";
684 let range = byte_range_to_lsp_range(text, 6..12).unwrap();
686
687 assert_eq!(range.start.line, 0);
688 assert_eq!(range.start.character, 6);
689 assert_eq!(range.end.line, 0);
690 assert_eq!(range.end.character, 8); }
692
693 #[test]
694 fn test_byte_range_to_lsp_range_non_bmp_counts_as_surrogate_pair() {
695 let text = "a🎉b"; let range = byte_range_to_lsp_range(text, 5..6).unwrap();
706 assert_eq!(range.start.line, 0);
707 assert_eq!(range.start.character, 3);
709 assert_eq!(range.end.line, 0);
710 assert_eq!(range.end.character, 4);
711 }
712
713 #[test]
714 fn test_byte_range_to_lsp_range_eof() {
715 let text = "Hello";
716 let range = byte_range_to_lsp_range(text, 0..5).unwrap();
717
718 assert_eq!(range.start.line, 0);
719 assert_eq!(range.start.character, 0);
720 assert_eq!(range.end.line, 0);
721 assert_eq!(range.end.character, 5);
722 }
723
724 #[test]
725 fn test_byte_range_to_lsp_range_invalid() {
726 let text = "Hello";
727 let range = byte_range_to_lsp_range(text, 10..15);
729 assert!(range.is_none());
730 }
731
732 #[test]
733 fn test_byte_range_to_lsp_range_insertion_at_eof() {
734 let text = "Hello\nWorld";
736 let text_len = text.len(); let range = byte_range_to_lsp_range(text, text_len..text_len).unwrap();
738
739 assert_eq!(range.start.line, 1);
741 assert_eq!(range.start.character, 5); assert_eq!(range.end.line, 1);
743 assert_eq!(range.end.character, 5);
744 }
745
746 #[test]
747 fn test_byte_range_to_lsp_range_insertion_at_eof_with_trailing_newline() {
748 let text = "Hello\nWorld\n";
750 let text_len = text.len(); let range = byte_range_to_lsp_range(text, text_len..text_len).unwrap();
752
753 assert_eq!(range.start.line, 2);
755 assert_eq!(range.start.character, 0); assert_eq!(range.end.line, 2);
757 assert_eq!(range.end.character, 0);
758 }
759
760 #[test]
761 fn test_warning_to_code_action_with_fix() {
762 let warning = LintWarning {
763 line: 1,
764 column: 1,
765 end_line: 1,
766 end_column: 5,
767 rule_name: Some("MD001".to_string()),
768 message: "Missing space".to_string(),
769 severity: Severity::Warning,
770 fix: Some(Fix::new(0..5, "Fixed".to_string())),
771 };
772
773 let uri = Url::parse("file:///test.md").unwrap();
774 let document_text = "Hello World";
775
776 let actions = warning_to_code_actions(&warning, &uri, document_text);
777 assert!(!actions.is_empty());
778 let action = &actions[0]; assert_eq!(action.title, "Fix: Missing space");
781 assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
782 assert_eq!(action.is_preferred, Some(true));
783
784 let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
785 let edits = &changes[&uri];
786 assert_eq!(edits.len(), 1);
787 assert_eq!(edits[0].new_text, "Fixed");
788 }
789
790 #[test]
791 fn test_warning_to_code_action_no_fix() {
792 let warning = LintWarning {
793 line: 1,
794 column: 1,
795 end_line: 1,
796 end_column: 5,
797 rule_name: Some("MD001".to_string()),
798 message: "No fix available".to_string(),
799 severity: Severity::Warning,
800 fix: None,
801 };
802
803 let uri = Url::parse("file:///test.md").unwrap();
804 let document_text = "Hello World";
805
806 let actions = warning_to_code_actions(&warning, &uri, document_text);
807 assert!(actions.iter().all(|a| a.is_preferred != Some(true)));
809 }
810
811 #[test]
812 fn test_warning_to_code_actions_md013_blockquote_reflow_action() {
813 let warning = LintWarning {
814 line: 2,
815 column: 1,
816 end_line: 2,
817 end_column: 100,
818 rule_name: Some("MD013".to_string()),
819 message: "Line length 95 exceeds 40 characters".to_string(),
820 severity: Severity::Warning,
821 fix: None,
822 };
823
824 let uri = Url::parse("file:///test.md").unwrap();
825 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";
826
827 let actions = warning_to_code_actions(&warning, &uri, document_text);
828 let reflow_action = actions
829 .iter()
830 .find(|action| action.title == "Reflow paragraph")
831 .expect("Expected manual reflow action for MD013");
832
833 let changes = reflow_action
834 .edit
835 .as_ref()
836 .and_then(|edit| edit.changes.as_ref())
837 .expect("Expected edits for reflow action");
838 let file_edits = changes.get(&uri).expect("Expected edits for URI");
839 assert_eq!(file_edits.len(), 1);
840 assert!(
841 file_edits[0]
842 .new_text
843 .lines()
844 .next()
845 .is_some_and(|line| line.starts_with("> ")),
846 "Expected blockquote prefix in reflow output"
847 );
848 }
849
850 #[test]
851 fn test_warning_to_code_action_multiline_fix() {
852 let warning = LintWarning {
853 line: 2,
854 column: 1,
855 end_line: 3,
856 end_column: 5,
857 rule_name: Some("MD001".to_string()),
858 message: "Multiline fix".to_string(),
859 severity: Severity::Warning,
860 fix: Some(Fix::new(6..16, "Fixed\nContent".to_string())),
861 };
862
863 let uri = Url::parse("file:///test.md").unwrap();
864 let document_text = "Hello\nWorld\nTest Line";
865
866 let actions = warning_to_code_actions(&warning, &uri, document_text);
867 assert!(!actions.is_empty());
868 let action = &actions[0]; let changes = action.edit.as_ref().unwrap().changes.as_ref().unwrap();
871 let edits = &changes[&uri];
872 assert_eq!(edits[0].new_text, "Fixed\nContent");
873 assert_eq!(edits[0].range.start.line, 1);
874 assert_eq!(edits[0].range.start.character, 0);
875 }
876
877 #[test]
878 fn test_warning_to_code_action_atomic_with_additional_edits() {
879 let document_text = "See [docs](https://example.com) for details.\n";
885 let primary_start = document_text.find("[docs](https://example.com)").unwrap();
886 let primary_end = document_text.find(" for details").unwrap();
887 let appended = "\n[docs]: https://example.com\n".to_string();
888
889 let warning = LintWarning {
890 line: 1,
891 column: primary_start + 1,
892 end_line: 1,
893 end_column: primary_end + 1,
894 rule_name: Some("MD054".to_string()),
895 message: "Inconsistent link style".to_string(),
896 severity: Severity::Warning,
897 fix: Some(Fix::with_additional_edits(
898 primary_start..primary_end,
899 "[docs]".to_string(),
900 vec![Fix::new(document_text.len()..document_text.len(), appended.clone())],
901 )),
902 };
903
904 let uri = Url::parse("file:///test.md").unwrap();
905 let actions = warning_to_code_actions(&warning, &uri, document_text);
906
907 let fix_action = actions
908 .iter()
909 .find(|a| a.is_preferred == Some(true))
910 .expect("expected a preferred fix code action for MD054 ref-emit warning");
911 assert_eq!(fix_action.kind, Some(CodeActionKind::QUICKFIX));
912
913 let edits = fix_action
914 .edit
915 .as_ref()
916 .and_then(|w| w.changes.as_ref())
917 .and_then(|c| c.get(&uri))
918 .expect("WorkspaceEdit should carry edits keyed by the document URI");
919
920 assert_eq!(
921 edits.len(),
922 2,
923 "atomic fix must surface primary + 1 additional edit as TWO TextEdits, got {edits:?}"
924 );
925 assert_eq!(edits[0].new_text, "[docs]");
926 assert_eq!(edits[1].new_text, appended);
927
928 assert_eq!(edits[1].range.start, edits[1].range.end);
930 }
931
932 #[test]
933 fn test_code_description_url_generation() {
934 let warning = LintWarning {
935 line: 1,
936 column: 1,
937 end_line: 1,
938 end_column: 5,
939 rule_name: Some("MD013".to_string()),
940 message: "Line too long".to_string(),
941 severity: Severity::Warning,
942 fix: None,
943 };
944
945 let diagnostic = warning_to_diagnostic(&warning);
946 assert!(diagnostic.code_description.is_some());
947
948 let url = diagnostic.code_description.unwrap().href;
949 assert_eq!(url.as_str(), "https://rumdl.dev/md013/");
950 }
951
952 #[test]
953 fn test_no_url_for_code_block_tool_warnings() {
954 for tool_name in &["jq", "tombi", "shellcheck", "prettier", "code-block-tools"] {
957 let warning = LintWarning {
958 line: 1,
959 column: 1,
960 end_line: 1,
961 end_column: 10,
962 rule_name: Some(tool_name.to_string()),
963 message: "some tool warning".to_string(),
964 severity: Severity::Warning,
965 fix: None,
966 };
967
968 let diagnostic = warning_to_diagnostic(&warning);
969 assert!(
970 diagnostic.code_description.is_none(),
971 "Expected no URL for tool name '{tool_name}', but got one",
972 );
973 }
974 }
975
976 #[test]
977 fn test_lsp_config_partial_deserialization() {
978 let json = r#"{"enableLinting": false}"#;
980 let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
981
982 assert!(!config.enable_linting);
983 assert_eq!(config.config_path, None); assert!(!config.enable_auto_fix); }
986
987 #[test]
988 fn test_configuration_preference_serialization() {
989 let pref = ConfigurationPreference::EditorFirst;
991 let json = serde_json::to_string(&pref).unwrap();
992 assert_eq!(json, "\"editorFirst\"");
993
994 let pref = ConfigurationPreference::FilesystemFirst;
996 let json = serde_json::to_string(&pref).unwrap();
997 assert_eq!(json, "\"filesystemFirst\"");
998
999 let pref = ConfigurationPreference::EditorOnly;
1001 let json = serde_json::to_string(&pref).unwrap();
1002 assert_eq!(json, "\"editorOnly\"");
1003
1004 let pref: ConfigurationPreference = serde_json::from_str("\"filesystemFirst\"").unwrap();
1006 assert_eq!(pref, ConfigurationPreference::FilesystemFirst);
1007 }
1008
1009 #[test]
1010 fn test_lsp_rule_settings_deserialization() {
1011 let json = r#"{
1013 "lineLength": 120,
1014 "disable": ["MD001", "MD002"],
1015 "enable": ["MD013"]
1016 }"#;
1017 let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
1018
1019 assert_eq!(settings.line_length, Some(120));
1020 assert_eq!(settings.disable, Some(vec!["MD001".to_string(), "MD002".to_string()]));
1021 assert_eq!(settings.enable, Some(vec!["MD013".to_string()]));
1022 }
1023
1024 #[test]
1025 fn test_lsp_rule_settings_with_per_rule_config() {
1026 let json = r#"{
1028 "lineLength": 80,
1029 "MD013": {
1030 "lineLength": 120,
1031 "codeBlocks": false
1032 },
1033 "MD024": {
1034 "siblingsOnly": true
1035 }
1036 }"#;
1037 let settings: LspRuleSettings = serde_json::from_str(json).unwrap();
1038
1039 assert_eq!(settings.line_length, Some(80));
1040
1041 let md013 = settings.rules.get("MD013").unwrap();
1043 assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
1044 assert_eq!(md013.get("codeBlocks").unwrap().as_bool(), Some(false));
1045
1046 let md024 = settings.rules.get("MD024").unwrap();
1048 assert_eq!(md024.get("siblingsOnly").unwrap().as_bool(), Some(true));
1049 }
1050
1051 #[test]
1052 fn test_full_lsp_config_with_settings() {
1053 let json = r#"{
1055 "configPath": "/path/to/config",
1056 "enableLinting": true,
1057 "enableAutoFix": false,
1058 "configurationPreference": "editorFirst",
1059 "settings": {
1060 "lineLength": 100,
1061 "disable": ["MD033"],
1062 "MD013": {
1063 "lineLength": 120,
1064 "tables": false
1065 }
1066 }
1067 }"#;
1068 let config: RumdlLspConfig = serde_json::from_str(json).unwrap();
1069
1070 assert_eq!(config.config_path, Some("/path/to/config".to_string()));
1071 assert!(config.enable_linting);
1072 assert!(!config.enable_auto_fix);
1073 assert_eq!(config.configuration_preference, ConfigurationPreference::EditorFirst);
1074
1075 let settings = config.settings.unwrap();
1076 assert_eq!(settings.line_length, Some(100));
1077 assert_eq!(settings.disable, Some(vec!["MD033".to_string()]));
1078
1079 let md013 = settings.rules.get("MD013").unwrap();
1080 assert_eq!(md013.get("lineLength").unwrap().as_u64(), Some(120));
1081 assert_eq!(md013.get("tables").unwrap().as_bool(), Some(false));
1082 }
1083
1084 #[test]
1085 fn test_create_ignore_line_action_uses_rumdl_syntax() {
1086 let warning = LintWarning {
1087 line: 5,
1088 column: 1,
1089 end_line: 5,
1090 end_column: 50,
1091 rule_name: Some("MD013".to_string()),
1092 message: "Line too long".to_string(),
1093 severity: Severity::Warning,
1094 fix: None,
1095 };
1096
1097 let document = "Line 1\nLine 2\nLine 3\nLine 4\nThis is a very long line that exceeds the limit\nLine 6";
1098 let uri = Url::parse("file:///test.md").unwrap();
1099
1100 let action = create_ignore_line_action(&warning, &uri, document).unwrap();
1101
1102 assert_eq!(action.title, "Ignore MD013 for this line");
1103 assert_eq!(action.is_preferred, Some(false));
1104 assert!(action.edit.is_some());
1105
1106 let edit = action.edit.unwrap();
1108 let changes = edit.changes.unwrap();
1109 let file_edits = changes.get(&uri).unwrap();
1110
1111 assert_eq!(file_edits.len(), 1);
1112 assert!(file_edits[0].new_text.contains("rumdl-disable-line MD013"));
1113 assert!(!file_edits[0].new_text.contains("markdownlint"));
1114
1115 assert_eq!(file_edits[0].range.start.line, 4); assert_eq!(file_edits[0].range.start.character, 47); }
1119
1120 #[test]
1121 fn test_create_ignore_line_action_no_duplicate() {
1122 let warning = LintWarning {
1123 line: 1,
1124 column: 1,
1125 end_line: 1,
1126 end_column: 50,
1127 rule_name: Some("MD013".to_string()),
1128 message: "Line too long".to_string(),
1129 severity: Severity::Warning,
1130 fix: None,
1131 };
1132
1133 let document = "This is a line <!-- rumdl-disable-line MD013 -->";
1135 let uri = Url::parse("file:///test.md").unwrap();
1136
1137 let action = create_ignore_line_action(&warning, &uri, document);
1138
1139 assert!(action.is_none());
1141 }
1142
1143 #[test]
1144 fn test_create_ignore_line_action_detects_markdownlint_syntax() {
1145 let warning = LintWarning {
1146 line: 1,
1147 column: 1,
1148 end_line: 1,
1149 end_column: 50,
1150 rule_name: Some("MD013".to_string()),
1151 message: "Line too long".to_string(),
1152 severity: Severity::Warning,
1153 fix: None,
1154 };
1155
1156 let document = "This is a line <!-- markdownlint-disable-line MD013 -->";
1158 let uri = Url::parse("file:///test.md").unwrap();
1159
1160 let action = create_ignore_line_action(&warning, &uri, document);
1161
1162 assert!(action.is_none());
1164 }
1165
1166 #[test]
1167 fn test_warning_to_code_actions_with_fix() {
1168 let warning = LintWarning {
1169 line: 1,
1170 column: 1,
1171 end_line: 1,
1172 end_column: 5,
1173 rule_name: Some("MD009".to_string()),
1174 message: "Trailing spaces".to_string(),
1175 severity: Severity::Warning,
1176 fix: Some(Fix::new(0..5, "Fixed".to_string())),
1177 };
1178
1179 let uri = Url::parse("file:///test.md").unwrap();
1180 let document_text = "Hello \nWorld";
1181
1182 let actions = warning_to_code_actions(&warning, &uri, document_text);
1183
1184 assert_eq!(actions.len(), 2);
1186
1187 assert_eq!(actions[0].title, "Fix: Trailing spaces");
1189 assert_eq!(actions[0].is_preferred, Some(true));
1190
1191 assert_eq!(actions[1].title, "Ignore MD009 for this line");
1193 assert_eq!(actions[1].is_preferred, Some(false));
1194 }
1195
1196 #[test]
1197 fn test_warning_to_code_actions_no_fix() {
1198 let warning = LintWarning {
1199 line: 1,
1200 column: 1,
1201 end_line: 1,
1202 end_column: 10,
1203 rule_name: Some("MD033".to_string()),
1204 message: "Inline HTML".to_string(),
1205 severity: Severity::Warning,
1206 fix: None,
1207 };
1208
1209 let uri = Url::parse("file:///test.md").unwrap();
1210 let document_text = "<div>HTML</div>";
1211
1212 let actions = warning_to_code_actions(&warning, &uri, document_text);
1213
1214 assert_eq!(actions.len(), 1);
1216 assert_eq!(actions[0].title, "Ignore MD033 for this line");
1217 assert_eq!(actions[0].is_preferred, Some(false));
1218 }
1219
1220 #[test]
1221 fn test_warning_to_code_actions_no_rule_name() {
1222 let warning = LintWarning {
1223 line: 1,
1224 column: 1,
1225 end_line: 1,
1226 end_column: 5,
1227 rule_name: None,
1228 message: "Generic warning".to_string(),
1229 severity: Severity::Warning,
1230 fix: None,
1231 };
1232
1233 let uri = Url::parse("file:///test.md").unwrap();
1234 let document_text = "Hello World";
1235
1236 let actions = warning_to_code_actions(&warning, &uri, document_text);
1237
1238 assert_eq!(actions.len(), 0);
1240 }
1241
1242 #[test]
1243 fn test_legacy_warning_to_code_action_compatibility() {
1244 let warning = LintWarning {
1245 line: 1,
1246 column: 1,
1247 end_line: 1,
1248 end_column: 5,
1249 rule_name: Some("MD001".to_string()),
1250 message: "Test".to_string(),
1251 severity: Severity::Warning,
1252 fix: Some(Fix::new(0..5, "Fixed".to_string())),
1253 };
1254
1255 let uri = Url::parse("file:///test.md").unwrap();
1256 let document_text = "Hello World";
1257
1258 #[allow(deprecated)]
1259 let action = warning_to_code_action(&warning, &uri, document_text);
1260
1261 assert!(action.is_some());
1263 let action = action.unwrap();
1264 assert_eq!(action.title, "Fix: Test");
1265 assert_eq!(action.is_preferred, Some(true));
1266 }
1267
1268 #[test]
1269 fn test_md034_convert_to_link_action() {
1270 let warning = LintWarning {
1272 line: 1,
1273 column: 1,
1274 end_line: 1,
1275 end_column: 25,
1276 rule_name: Some("MD034".to_string()),
1277 message: "URL without angle brackets or link formatting: 'https://example.com'".to_string(),
1278 severity: Severity::Warning,
1279 fix: Some(Fix::new(0..20, "<https://example.com>".to_string())),
1280 };
1281
1282 let uri = Url::parse("file:///test.md").unwrap();
1283 let document_text = "https://example.com is a test URL";
1284
1285 let actions = warning_to_code_actions(&warning, &uri, document_text);
1286
1287 assert_eq!(actions.len(), 3);
1289
1290 assert_eq!(
1292 actions[0].title,
1293 "Fix: URL without angle brackets or link formatting: 'https://example.com'"
1294 );
1295 assert_eq!(actions[0].is_preferred, Some(true));
1296
1297 assert_eq!(actions[1].title, "Convert to markdown link");
1299 assert_eq!(actions[1].is_preferred, Some(false));
1300
1301 let edit = actions[1].edit.as_ref().unwrap();
1303 let changes = edit.changes.as_ref().unwrap();
1304 let file_edits = changes.get(&uri).unwrap();
1305 assert_eq!(file_edits.len(), 1);
1306
1307 assert_eq!(file_edits[0].new_text, "[example.com](https://example.com)");
1309
1310 assert_eq!(actions[2].title, "Ignore MD034 for this line");
1312 }
1313
1314 #[test]
1315 fn test_md034_convert_to_link_action_email() {
1316 let warning = LintWarning {
1318 line: 1,
1319 column: 1,
1320 end_line: 1,
1321 end_column: 20,
1322 rule_name: Some("MD034".to_string()),
1323 message: "Email address without angle brackets or link formatting: 'user@example.com'".to_string(),
1324 severity: Severity::Warning,
1325 fix: Some(Fix::new(0..16, "<user@example.com>".to_string())),
1326 };
1327
1328 let uri = Url::parse("file:///test.md").unwrap();
1329 let document_text = "user@example.com is my email";
1330
1331 let actions = warning_to_code_actions(&warning, &uri, document_text);
1332
1333 assert_eq!(actions.len(), 3);
1335
1336 assert_eq!(actions[1].title, "Convert to markdown link");
1338
1339 let edit = actions[1].edit.as_ref().unwrap();
1340 let changes = edit.changes.as_ref().unwrap();
1341 let file_edits = changes.get(&uri).unwrap();
1342
1343 assert_eq!(file_edits[0].new_text, "[user@example.com](user@example.com)");
1345 }
1346
1347 #[test]
1348 fn test_extract_url_from_fix_replacement() {
1349 assert_eq!(
1350 extract_url_from_fix_replacement("<https://example.com>"),
1351 Some("https://example.com")
1352 );
1353 assert_eq!(
1354 extract_url_from_fix_replacement("<user@example.com>"),
1355 Some("user@example.com")
1356 );
1357 assert_eq!(extract_url_from_fix_replacement("https://example.com"), None);
1358 assert_eq!(extract_url_from_fix_replacement("<>"), Some(""));
1359 }
1360
1361 #[test]
1362 fn test_extract_domain_for_placeholder() {
1363 assert_eq!(extract_domain_for_placeholder("https://example.com"), "example.com");
1364 assert_eq!(
1365 extract_domain_for_placeholder("https://example.com/path/to/page"),
1366 "example.com"
1367 );
1368 assert_eq!(
1369 extract_domain_for_placeholder("http://sub.example.com:8080/"),
1370 "sub.example.com:8080"
1371 );
1372 assert_eq!(extract_domain_for_placeholder("user@example.com"), "user@example.com");
1373 assert_eq!(
1374 extract_domain_for_placeholder("ftp://files.example.com"),
1375 "files.example.com"
1376 );
1377 }
1378
1379 #[test]
1380 fn test_byte_range_to_lsp_range_trailing_newlines() {
1381 let text = "line1\nline2\n\n"; let range = byte_range_to_lsp_range(text, 12..13);
1386 assert!(range.is_some());
1387 let range = range.unwrap();
1388
1389 assert_eq!(range.start.line, 2);
1392 assert_eq!(range.start.character, 0);
1393 assert_eq!(range.end.line, 3);
1394 assert_eq!(range.end.character, 0);
1395 }
1396
1397 #[test]
1398 fn test_byte_range_to_lsp_range_at_eof() {
1399 let text = "test\n"; let range = byte_range_to_lsp_range(text, 5..5);
1404 assert!(range.is_some());
1405 let range = range.unwrap();
1406
1407 assert_eq!(range.start.line, 1);
1409 assert_eq!(range.start.character, 0);
1410 }
1411}