1use regex::Regex;
22use serde::{Deserialize, Serialize};
23use std::collections::{HashMap, HashSet};
24use std::path::{Path, PathBuf};
25use std::sync::LazyLock;
26
27use crate::lint_context::LintContext;
28use crate::utils::range_utils::byte_to_char_count;
29
30fn hex_digit_to_value(c: u8) -> Option<u8> {
36 match c {
37 b'0'..=b'9' => Some(c - b'0'),
38 b'a'..=b'f' => Some(c - b'a' + 10),
39 b'A'..=b'F' => Some(c - b'A' + 10),
40 _ => None,
41 }
42}
43
44fn url_decode(s: &str) -> String {
48 if !s.contains('%') {
50 return s.to_string();
51 }
52
53 let bytes = s.as_bytes();
54 let mut result = Vec::with_capacity(bytes.len());
55 let mut i = 0;
56
57 while i < bytes.len() {
58 if bytes[i] == b'%' && i + 2 < bytes.len() {
59 let hex1 = bytes[i + 1];
61 let hex2 = bytes[i + 2];
62 if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
63 result.push(d1 * 16 + d2);
64 i += 3;
65 continue;
66 }
67 }
68 result.push(bytes[i]);
69 i += 1;
70 }
71
72 String::from_utf8(result).unwrap_or_else(|_| s.to_string())
74}
75
76static LINK_START_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!?\[[^\]]*\]").unwrap());
86
87static URL_EXTRACT_ANGLE_BRACKET_REGEX: LazyLock<Regex> =
90 LazyLock::new(|| Regex::new(r#"\]\(\s*<([^>]+)>(#[^\)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
91
92static URL_EXTRACT_REGEX: LazyLock<Regex> =
95 LazyLock::new(|| Regex::new(r#"]\(\s*([^>)\s#]+)(#[^)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
96
97pub(crate) static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
99 LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
100
101#[inline]
103fn is_markdown_file(path: &str) -> bool {
104 crate::discovery::has_markdown_extension(std::path::Path::new(path))
105}
106
107fn strip_query_and_fragment(url: &str) -> &str {
110 let query_pos = url.find('?');
111 let fragment_pos = url.find('#');
112
113 match (query_pos, fragment_pos) {
114 (Some(q), Some(f)) => &url[..q.min(f)],
115 (Some(q), None) => &url[..q],
116 (None, Some(f)) => &url[..f],
117 (None, None) => url,
118 }
119}
120
121#[derive(Debug, Default)]
128pub struct ExtractedCrossFileLinks {
129 pub relative: Vec<CrossFileLinkIndex>,
131 pub root_relative: Vec<CrossFileLinkIndex>,
135}
136
137pub fn extract_cross_file_links(ctx: &LintContext) -> ExtractedCrossFileLinks {
145 let content = ctx.content;
146
147 if content.is_empty() || !content.contains("](") {
149 return ExtractedCrossFileLinks::default();
150 }
151
152 let mut links = ExtractedCrossFileLinks::default();
153 let lines: Vec<&str> = content.lines().collect();
154 let line_index = &ctx.line_index;
155
156 let mut processed_lines = HashSet::new();
159
160 for link in &ctx.links {
161 let line_idx = link.line - 1;
162 if line_idx >= lines.len() {
163 continue;
164 }
165
166 if !processed_lines.insert(line_idx) {
168 continue;
169 }
170
171 let line = lines[line_idx];
172 if !line.contains("](") {
173 continue;
174 }
175
176 for link_match in LINK_START_REGEX.find_iter(line) {
178 let start_pos = link_match.start();
179 let end_pos = link_match.end();
180
181 let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
183 let absolute_start_pos = line_start_byte + start_pos;
184
185 if ctx.is_in_code_span_byte(absolute_start_pos) {
187 continue;
188 }
189
190 let caps_result = URL_EXTRACT_ANGLE_BRACKET_REGEX
193 .captures_at(line, end_pos - 1)
194 .or_else(|| URL_EXTRACT_REGEX.captures_at(line, end_pos - 1));
195
196 if let Some(caps) = caps_result
197 && let Some(url_group) = caps.get(1)
198 {
199 let file_path = url_group.as_str().trim();
200
201 if let Some(rel) = file_path.strip_prefix('/') {
206 if !rel.starts_with('/')
207 && !Path::new(rel)
208 .components()
209 .any(|c| matches!(c, std::path::Component::ParentDir))
210 {
211 let stripped = strip_query_and_fragment(rel);
212 if is_markdown_file(stripped) {
213 let fragment = caps.get(2).map_or("", |m| m.as_str().trim_start_matches('#'));
214 links.root_relative.push(CrossFileLinkIndex {
215 target_path: stripped.to_string(),
216 fragment: fragment.to_string(),
217 line: link.line,
218 column: byte_to_char_count(line, url_group.start()),
219 });
220 }
221 }
222 continue;
223 }
224
225 if file_path.is_empty()
228 || PROTOCOL_DOMAIN_REGEX.is_match(file_path)
229 || file_path.starts_with("www.")
230 || file_path.starts_with('#')
231 || file_path.starts_with("{{")
232 || file_path.starts_with("{%")
233 || file_path.starts_with('~')
234 || file_path.starts_with('@')
235 || (file_path.starts_with('`') && file_path.ends_with('`'))
236 {
237 continue;
238 }
239
240 let file_path = strip_query_and_fragment(file_path);
242
243 let fragment = caps.get(2).map_or("", |m| m.as_str().trim_start_matches('#'));
245
246 if is_markdown_file(file_path) {
248 links.relative.push(CrossFileLinkIndex {
249 target_path: file_path.to_string(),
250 fragment: fragment.to_string(),
251 line: link.line,
252 column: byte_to_char_count(line, url_group.start()),
253 });
254 }
255 }
256 }
257 }
258
259 links
260}
261
262#[cfg(feature = "native")]
264const CACHE_MAGIC: &[u8; 4] = b"RWSI";
265
266#[cfg(feature = "native")]
272const CACHE_FORMAT_VERSION: u32 = 8;
273
274#[cfg(feature = "native")]
276const CACHE_FILE_NAME: &str = "workspace_index.bin";
277
278#[derive(Debug, Default, Clone, Serialize, Deserialize)]
283pub struct WorkspaceIndex {
284 files: HashMap<PathBuf, FileIndex>,
286 reverse_deps: HashMap<PathBuf, HashSet<PathBuf>>,
289 version: u64,
291}
292
293#[derive(Debug, Clone, Default, Serialize, Deserialize)]
295pub struct FileIndex {
296 pub headings: Vec<HeadingIndex>,
298 pub reference_links: Vec<ReferenceLinkIndex>,
300 pub cross_file_links: Vec<CrossFileLinkIndex>,
302 #[serde(default)]
307 pub root_relative_links: Vec<CrossFileLinkIndex>,
308 pub defined_references: HashSet<String>,
311 pub content_hash: String,
313 anchor_to_heading: HashMap<String, usize>,
316 #[serde(default)]
320 anchor_to_heading_exact: HashMap<String, usize>,
321 html_anchors: HashSet<String>,
324 #[serde(default)]
327 html_anchors_exact: HashSet<String>,
328 attribute_anchors: HashSet<String>,
332 #[serde(default)]
335 attribute_anchors_exact: HashSet<String>,
336 pub file_disabled_rules: HashSet<String>,
339 pub persistent_transitions: Vec<(usize, HashSet<String>, HashSet<String>)>,
342 pub line_disabled_rules: HashMap<usize, HashSet<String>>,
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct HeadingIndex {
349 pub text: String,
351 pub auto_anchor: String,
353 pub custom_anchor: Option<String>,
355 pub line: usize,
357 #[serde(default)]
359 pub is_setext: bool,
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct ReferenceLinkIndex {
365 pub reference_id: String,
367 pub line: usize,
369 pub column: usize,
371}
372
373#[derive(Debug, Clone, Serialize, Deserialize)]
375pub struct CrossFileLinkIndex {
376 pub target_path: String,
378 pub fragment: String,
380 pub line: usize,
382 pub column: usize,
384}
385
386#[derive(Debug, Clone, Serialize, Deserialize)]
388pub struct VulnerableAnchor {
389 pub file: PathBuf,
391 pub line: usize,
393 pub text: String,
395}
396
397impl WorkspaceIndex {
398 pub fn new() -> Self {
400 Self::default()
401 }
402
403 pub fn version(&self) -> u64 {
405 self.version
406 }
407
408 pub fn file_count(&self) -> usize {
410 self.files.len()
411 }
412
413 pub fn contains_file(&self, path: &Path) -> bool {
415 self.files.contains_key(path)
416 }
417
418 pub fn get_file(&self, path: &Path) -> Option<&FileIndex> {
420 self.files.get(path)
421 }
422
423 pub fn insert_file(&mut self, path: PathBuf, index: FileIndex) {
425 self.files.insert(path, index);
426 self.version = self.version.wrapping_add(1);
427 }
428
429 pub fn remove_file(&mut self, path: &Path) -> Option<FileIndex> {
431 self.clear_reverse_deps_for(path);
433
434 let result = self.files.remove(path);
435 if result.is_some() {
436 self.version = self.version.wrapping_add(1);
437 }
438 result
439 }
440
441 pub fn get_vulnerable_anchors(&self) -> HashMap<String, Vec<VulnerableAnchor>> {
451 let mut vulnerable: HashMap<String, Vec<VulnerableAnchor>> = HashMap::new();
452
453 for (file_path, file_index) in &self.files {
454 for heading in &file_index.headings {
455 if heading.custom_anchor.is_none() && !heading.auto_anchor.is_empty() {
457 let anchor_key = heading.auto_anchor.to_lowercase();
458 vulnerable.entry(anchor_key).or_default().push(VulnerableAnchor {
459 file: file_path.clone(),
460 line: heading.line,
461 text: heading.text.clone(),
462 });
463 }
464 }
465 }
466
467 vulnerable
468 }
469
470 pub fn all_headings(&self) -> impl Iterator<Item = (&Path, &HeadingIndex)> {
472 self.files
473 .iter()
474 .flat_map(|(path, index)| index.headings.iter().map(move |h| (path.as_path(), h)))
475 }
476
477 pub fn files(&self) -> impl Iterator<Item = (&Path, &FileIndex)> {
479 self.files.iter().map(|(p, i)| (p.as_path(), i))
480 }
481
482 pub fn files_sorted(&self) -> Vec<(&Path, &FileIndex)> {
488 let mut entries: Vec<(&Path, &FileIndex)> = self.files.iter().map(|(p, i)| (p.as_path(), i)).collect();
489 entries.sort_by(|(a, _), (b, _)| a.cmp(b));
490 entries
491 }
492
493 pub fn clear(&mut self) {
495 self.files.clear();
496 self.reverse_deps.clear();
497 self.version = self.version.wrapping_add(1);
498 }
499
500 pub fn update_file(&mut self, path: &Path, index: FileIndex) {
507 self.clear_reverse_deps_as_source(path);
510
511 for link in &index.cross_file_links {
513 let target = self.resolve_target_path(path, &link.target_path);
514 self.reverse_deps.entry(target).or_default().insert(path.to_path_buf());
515 }
516
517 self.files.insert(path.to_path_buf(), index);
518 self.version = self.version.wrapping_add(1);
519 }
520
521 pub fn get_dependents(&self, path: &Path) -> Vec<PathBuf> {
526 self.reverse_deps
527 .get(path)
528 .map(|set| set.iter().cloned().collect())
529 .unwrap_or_default()
530 }
531
532 pub fn is_file_stale(&self, path: &Path, current_hash: &str) -> bool {
536 self.files.get(path).is_none_or(|f| f.content_hash != current_hash)
537 }
538
539 pub fn retain_only(&mut self, current_files: &std::collections::HashSet<PathBuf>) -> usize {
544 let before_count = self.files.len();
545
546 let to_remove: Vec<PathBuf> = self
548 .files
549 .keys()
550 .filter(|path| !current_files.contains(*path))
551 .cloned()
552 .collect();
553
554 for path in &to_remove {
556 self.remove_file(path);
557 }
558
559 before_count - self.files.len()
560 }
561
562 #[cfg(feature = "native")]
569 pub fn save_to_cache(&self, cache_dir: &Path) -> std::io::Result<()> {
570 use std::fs;
571 use std::io::Write;
572
573 fs::create_dir_all(cache_dir)?;
575
576 let encoded = postcard::to_allocvec(self)
578 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
579
580 let mut cache_data = Vec::with_capacity(8 + encoded.len());
582 cache_data.extend_from_slice(CACHE_MAGIC);
583 cache_data.extend_from_slice(&CACHE_FORMAT_VERSION.to_le_bytes());
584 cache_data.extend_from_slice(&encoded);
585
586 let final_path = cache_dir.join(CACHE_FILE_NAME);
588 let temp_path = cache_dir.join(format!("{}.tmp.{}", CACHE_FILE_NAME, std::process::id()));
589
590 {
592 let mut file = fs::File::create(&temp_path)?;
593 file.write_all(&cache_data)?;
594 file.sync_all()?;
595 }
596
597 fs::rename(&temp_path, &final_path)?;
599
600 log::debug!(
601 "Saved workspace index to cache: {} files, {} bytes (format v{})",
602 self.files.len(),
603 cache_data.len(),
604 CACHE_FORMAT_VERSION
605 );
606
607 Ok(())
608 }
609
610 #[cfg(feature = "native")]
618 pub fn load_from_cache(cache_dir: &Path) -> Option<Self> {
619 use std::fs;
620
621 let path = cache_dir.join(CACHE_FILE_NAME);
622 let data = fs::read(&path).ok()?;
623
624 if data.len() < 8 {
626 log::warn!("Workspace index cache too small, discarding");
627 let _ = fs::remove_file(&path);
628 return None;
629 }
630
631 if &data[0..4] != CACHE_MAGIC {
633 log::warn!("Workspace index cache has invalid magic header, discarding");
634 let _ = fs::remove_file(&path);
635 return None;
636 }
637
638 let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
640 if version != CACHE_FORMAT_VERSION {
641 log::info!(
642 "Workspace index cache format version mismatch (got {version}, expected {CACHE_FORMAT_VERSION}), rebuilding"
643 );
644 let _ = fs::remove_file(&path);
645 return None;
646 }
647
648 match postcard::from_bytes::<Self>(&data[8..]) {
650 Ok(index) => {
651 log::debug!(
652 "Loaded workspace index from cache: {} files (format v{})",
653 index.files.len(),
654 version
655 );
656 Some(index)
657 }
658 Err(e) => {
659 log::warn!("Failed to deserialize workspace index cache: {e}");
660 let _ = fs::remove_file(&path);
661 None
662 }
663 }
664 }
665
666 fn clear_reverse_deps_as_source(&mut self, path: &Path) {
671 let targets: Vec<PathBuf> = match self.files.get(path) {
678 Some(index) => index
679 .cross_file_links
680 .iter()
681 .map(|link| self.resolve_target_path(path, &link.target_path))
682 .collect(),
683 None => return,
684 };
685 for target in targets {
686 if let Some(deps) = self.reverse_deps.get_mut(&target) {
687 deps.remove(path);
688 if deps.is_empty() {
689 self.reverse_deps.remove(&target);
690 }
691 }
692 }
693 }
694
695 fn clear_reverse_deps_for(&mut self, path: &Path) {
700 self.clear_reverse_deps_as_source(path);
702
703 self.reverse_deps.remove(path);
705 }
706
707 fn resolve_target_path(&self, source_file: &Path, relative_target: &str) -> PathBuf {
709 let source_dir = source_file.parent().unwrap_or(Path::new(""));
711
712 let target = source_dir.join(relative_target);
714
715 Self::normalize_path(&target)
717 }
718
719 fn normalize_path(path: &Path) -> PathBuf {
721 let mut components = Vec::new();
722
723 for component in path.components() {
724 match component {
725 std::path::Component::ParentDir => {
726 if !components.is_empty() {
728 components.pop();
729 }
730 }
731 std::path::Component::CurDir => {
732 }
734 _ => {
735 components.push(component);
736 }
737 }
738 }
739
740 components.iter().collect()
741 }
742}
743
744impl FileIndex {
745 pub fn new() -> Self {
747 Self::default()
748 }
749
750 pub fn with_hash(content_hash: String) -> Self {
752 Self {
753 content_hash,
754 ..Default::default()
755 }
756 }
757
758 pub fn add_heading(&mut self, heading: HeadingIndex) {
764 let index = self.headings.len();
765
766 self.anchor_to_heading.insert(heading.auto_anchor.to_lowercase(), index);
769 self.anchor_to_heading_exact.insert(heading.auto_anchor.clone(), index);
770
771 if let Some(ref custom) = heading.custom_anchor {
773 self.anchor_to_heading.insert(custom.to_lowercase(), index);
774 self.anchor_to_heading_exact.insert(custom.clone(), index);
775 }
776
777 self.headings.push(heading);
778 }
779
780 pub fn add_anchor_alias(&mut self, anchor: &str, heading_index: usize) {
783 if heading_index < self.headings.len() {
784 self.anchor_to_heading.insert(anchor.to_lowercase(), heading_index);
785 self.anchor_to_heading_exact.insert(anchor.to_string(), heading_index);
786 }
787 }
788
789 pub fn has_anchor(&self, anchor: &str) -> bool {
800 self.has_anchor_with_case(anchor, true)
801 }
802
803 pub fn has_anchor_with_case(&self, anchor: &str, ignore_case: bool) -> bool {
812 if self.lookup_anchor(anchor, ignore_case) {
813 return true;
814 }
815
816 if anchor.contains('%') {
818 let decoded = url_decode(anchor);
819 if decoded != anchor {
820 return self.lookup_anchor(&decoded, ignore_case);
821 }
822 }
823
824 false
825 }
826
827 fn lookup_anchor(&self, anchor: &str, ignore_case: bool) -> bool {
830 if ignore_case {
831 let lower = anchor.to_lowercase();
832 self.anchor_to_heading.contains_key(&lower)
833 || self.html_anchors.contains(&lower)
834 || self.attribute_anchors.contains(&lower)
835 } else {
836 self.anchor_to_heading_exact.contains_key(anchor)
837 || self.html_anchors_exact.contains(anchor)
838 || self.attribute_anchors_exact.contains(anchor)
839 }
840 }
841
842 pub fn add_html_anchor(&mut self, anchor: &str) {
845 if !anchor.is_empty() {
846 self.html_anchors.insert(anchor.to_lowercase());
847 self.html_anchors_exact.insert(anchor.to_string());
848 }
849 }
850
851 pub fn add_attribute_anchor(&mut self, anchor: &str) {
854 if !anchor.is_empty() {
855 self.attribute_anchors.insert(anchor.to_lowercase());
856 self.attribute_anchors_exact.insert(anchor.to_string());
857 }
858 }
859
860 pub fn get_heading_by_anchor(&self, anchor: &str) -> Option<&HeadingIndex> {
864 self.anchor_to_heading
865 .get(&anchor.to_lowercase())
866 .and_then(|&idx| self.headings.get(idx))
867 }
868
869 pub fn add_reference_link(&mut self, link: ReferenceLinkIndex) {
871 self.reference_links.push(link);
872 }
873
874 pub fn is_rule_disabled_at_line(&self, rule_name: &str, line: usize) -> bool {
879 if self.file_disabled_rules.contains("*") || self.file_disabled_rules.contains(rule_name) {
881 return true;
882 }
883
884 if let Some(rules) = self.line_disabled_rules.get(&line)
886 && (rules.contains("*") || rules.contains(rule_name))
887 {
888 return true;
889 }
890
891 if !self.persistent_transitions.is_empty() {
893 let idx = match self.persistent_transitions.binary_search_by_key(&line, |t| t.0) {
894 Ok(i) => Some(i),
895 Err(i) => {
896 if i > 0 {
897 Some(i - 1)
898 } else {
899 None
900 }
901 }
902 };
903 if let Some(i) = idx {
904 let (_, ref disabled, ref enabled) = self.persistent_transitions[i];
905 if disabled.contains("*") {
906 return !enabled.contains(rule_name);
907 }
908 return disabled.contains(rule_name);
909 }
910 }
911
912 false
913 }
914
915 pub fn add_cross_file_link(&mut self, link: CrossFileLinkIndex) {
917 let is_duplicate = self.cross_file_links.iter().any(|existing| {
920 existing.target_path == link.target_path && existing.fragment == link.fragment && existing.line == link.line
921 });
922 if !is_duplicate {
923 self.cross_file_links.push(link);
924 }
925 }
926
927 pub fn add_root_relative_link(&mut self, link: CrossFileLinkIndex) {
929 let is_duplicate = self.root_relative_links.iter().any(|existing| {
930 existing.target_path == link.target_path && existing.fragment == link.fragment && existing.line == link.line
931 });
932 if !is_duplicate {
933 self.root_relative_links.push(link);
934 }
935 }
936
937 pub fn add_defined_reference(&mut self, ref_id: String) {
939 self.defined_references.insert(ref_id);
940 }
941
942 pub fn has_defined_reference(&self, ref_id: &str) -> bool {
944 self.defined_references.contains(ref_id)
945 }
946
947 pub fn hash_matches(&self, hash: &str) -> bool {
949 self.content_hash == hash
950 }
951
952 pub fn heading_count(&self) -> usize {
954 self.headings.len()
955 }
956
957 pub fn reference_link_count(&self) -> usize {
959 self.reference_links.len()
960 }
961}
962
963#[cfg(test)]
964mod tests {
965 use super::*;
966
967 #[test]
968 fn test_workspace_index_basic() {
969 let mut index = WorkspaceIndex::new();
970 assert_eq!(index.file_count(), 0);
971 assert_eq!(index.version(), 0);
972
973 let mut file_index = FileIndex::with_hash("abc123".to_string());
974 file_index.add_heading(HeadingIndex {
975 text: "Installation".to_string(),
976 auto_anchor: "installation".to_string(),
977 custom_anchor: None,
978 line: 1,
979 is_setext: false,
980 });
981
982 index.insert_file(PathBuf::from("docs/install.md"), file_index);
983 assert_eq!(index.file_count(), 1);
984 assert_eq!(index.version(), 1);
985
986 assert!(index.contains_file(Path::new("docs/install.md")));
987 assert!(!index.contains_file(Path::new("docs/other.md")));
988 }
989
990 #[test]
991 fn test_vulnerable_anchors() {
992 let mut index = WorkspaceIndex::new();
993
994 let mut file1 = FileIndex::new();
996 file1.add_heading(HeadingIndex {
997 text: "Getting Started".to_string(),
998 auto_anchor: "getting-started".to_string(),
999 custom_anchor: None,
1000 line: 1,
1001 is_setext: false,
1002 });
1003 index.insert_file(PathBuf::from("docs/guide.md"), file1);
1004
1005 let mut file2 = FileIndex::new();
1007 file2.add_heading(HeadingIndex {
1008 text: "Installation".to_string(),
1009 auto_anchor: "installation".to_string(),
1010 custom_anchor: Some("install".to_string()),
1011 line: 1,
1012 is_setext: false,
1013 });
1014 index.insert_file(PathBuf::from("docs/install.md"), file2);
1015
1016 let vulnerable = index.get_vulnerable_anchors();
1017 assert_eq!(vulnerable.len(), 1);
1018 assert!(vulnerable.contains_key("getting-started"));
1019 assert!(!vulnerable.contains_key("installation"));
1020
1021 let anchors = vulnerable.get("getting-started").unwrap();
1022 assert_eq!(anchors.len(), 1);
1023 assert_eq!(anchors[0].file, PathBuf::from("docs/guide.md"));
1024 assert_eq!(anchors[0].text, "Getting Started");
1025 }
1026
1027 #[test]
1028 fn test_vulnerable_anchors_multiple_files_same_anchor() {
1029 let mut index = WorkspaceIndex::new();
1032
1033 let mut file1 = FileIndex::new();
1035 file1.add_heading(HeadingIndex {
1036 text: "Installation".to_string(),
1037 auto_anchor: "installation".to_string(),
1038 custom_anchor: None,
1039 line: 1,
1040 is_setext: false,
1041 });
1042 index.insert_file(PathBuf::from("docs/en/guide.md"), file1);
1043
1044 let mut file2 = FileIndex::new();
1046 file2.add_heading(HeadingIndex {
1047 text: "Installation".to_string(),
1048 auto_anchor: "installation".to_string(),
1049 custom_anchor: None,
1050 line: 5,
1051 is_setext: false,
1052 });
1053 index.insert_file(PathBuf::from("docs/fr/guide.md"), file2);
1054
1055 let mut file3 = FileIndex::new();
1057 file3.add_heading(HeadingIndex {
1058 text: "Installation".to_string(),
1059 auto_anchor: "installation".to_string(),
1060 custom_anchor: Some("install".to_string()),
1061 line: 10,
1062 is_setext: false,
1063 });
1064 index.insert_file(PathBuf::from("docs/de/guide.md"), file3);
1065
1066 let vulnerable = index.get_vulnerable_anchors();
1067 assert_eq!(vulnerable.len(), 1); assert!(vulnerable.contains_key("installation"));
1069
1070 let anchors = vulnerable.get("installation").unwrap();
1071 assert_eq!(anchors.len(), 2, "Should collect both vulnerable anchors");
1073
1074 let files: std::collections::HashSet<_> = anchors.iter().map(|a| &a.file).collect();
1076 assert!(files.contains(&PathBuf::from("docs/en/guide.md")));
1077 assert!(files.contains(&PathBuf::from("docs/fr/guide.md")));
1078 }
1079
1080 #[test]
1081 fn test_file_index_hash() {
1082 let index = FileIndex::with_hash("hash123".to_string());
1083 assert!(index.hash_matches("hash123"));
1084 assert!(!index.hash_matches("other"));
1085 }
1086
1087 #[test]
1088 fn test_version_increment() {
1089 let mut index = WorkspaceIndex::new();
1090 assert_eq!(index.version(), 0);
1091
1092 index.insert_file(PathBuf::from("a.md"), FileIndex::new());
1093 assert_eq!(index.version(), 1);
1094
1095 index.insert_file(PathBuf::from("b.md"), FileIndex::new());
1096 assert_eq!(index.version(), 2);
1097
1098 index.remove_file(Path::new("a.md"));
1099 assert_eq!(index.version(), 3);
1100
1101 index.remove_file(Path::new("nonexistent.md"));
1103 assert_eq!(index.version(), 3);
1104 }
1105
1106 #[test]
1107 fn test_files_sorted_is_path_ordered() {
1108 let mut index = WorkspaceIndex::new();
1109 for name in ["docs/zebra.md", "docs/apple.md", "docs/mango.md"] {
1111 index.update_file(Path::new(name), FileIndex::new());
1112 }
1113
1114 let paths: Vec<&Path> = index.files_sorted().into_iter().map(|(p, _)| p).collect();
1115 assert_eq!(
1116 paths,
1117 vec![
1118 Path::new("docs/apple.md"),
1119 Path::new("docs/mango.md"),
1120 Path::new("docs/zebra.md"),
1121 ],
1122 "files_sorted() must return entries ordered by path"
1123 );
1124 }
1125
1126 #[test]
1127 fn test_reverse_deps_basic() {
1128 let mut index = WorkspaceIndex::new();
1129
1130 let mut file_a = FileIndex::new();
1132 file_a.add_cross_file_link(CrossFileLinkIndex {
1133 target_path: "b.md".to_string(),
1134 fragment: "section".to_string(),
1135 line: 10,
1136 column: 5,
1137 });
1138 index.update_file(Path::new("docs/a.md"), file_a);
1139
1140 let dependents = index.get_dependents(Path::new("docs/b.md"));
1142 assert_eq!(dependents.len(), 1);
1143 assert_eq!(dependents[0], PathBuf::from("docs/a.md"));
1144
1145 let a_dependents = index.get_dependents(Path::new("docs/a.md"));
1147 assert!(a_dependents.is_empty());
1148 }
1149
1150 #[test]
1151 fn test_reverse_deps_multiple() {
1152 let mut index = WorkspaceIndex::new();
1153
1154 let mut file_a = FileIndex::new();
1156 file_a.add_cross_file_link(CrossFileLinkIndex {
1157 target_path: "../b.md".to_string(),
1158 fragment: "".to_string(),
1159 line: 1,
1160 column: 1,
1161 });
1162 index.update_file(Path::new("docs/sub/a.md"), file_a);
1163
1164 let mut file_c = FileIndex::new();
1165 file_c.add_cross_file_link(CrossFileLinkIndex {
1166 target_path: "b.md".to_string(),
1167 fragment: "".to_string(),
1168 line: 1,
1169 column: 1,
1170 });
1171 index.update_file(Path::new("docs/c.md"), file_c);
1172
1173 let dependents = index.get_dependents(Path::new("docs/b.md"));
1175 assert_eq!(dependents.len(), 2);
1176 assert!(dependents.contains(&PathBuf::from("docs/sub/a.md")));
1177 assert!(dependents.contains(&PathBuf::from("docs/c.md")));
1178 }
1179
1180 #[test]
1181 fn test_reverse_deps_update_clears_old() {
1182 let mut index = WorkspaceIndex::new();
1183
1184 let mut file_a = FileIndex::new();
1186 file_a.add_cross_file_link(CrossFileLinkIndex {
1187 target_path: "b.md".to_string(),
1188 fragment: "".to_string(),
1189 line: 1,
1190 column: 1,
1191 });
1192 index.update_file(Path::new("docs/a.md"), file_a);
1193
1194 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1196
1197 let mut file_a_updated = FileIndex::new();
1199 file_a_updated.add_cross_file_link(CrossFileLinkIndex {
1200 target_path: "c.md".to_string(),
1201 fragment: "".to_string(),
1202 line: 1,
1203 column: 1,
1204 });
1205 index.update_file(Path::new("docs/a.md"), file_a_updated);
1206
1207 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1209
1210 let c_deps = index.get_dependents(Path::new("docs/c.md"));
1212 assert_eq!(c_deps.len(), 1);
1213 assert_eq!(c_deps[0], PathBuf::from("docs/a.md"));
1214 }
1215
1216 #[test]
1217 fn test_reverse_deps_remove_file() {
1218 let mut index = WorkspaceIndex::new();
1219
1220 let mut file_a = FileIndex::new();
1222 file_a.add_cross_file_link(CrossFileLinkIndex {
1223 target_path: "b.md".to_string(),
1224 fragment: "".to_string(),
1225 line: 1,
1226 column: 1,
1227 });
1228 index.update_file(Path::new("docs/a.md"), file_a);
1229
1230 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1232
1233 index.remove_file(Path::new("docs/a.md"));
1235
1236 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1238 }
1239
1240 #[test]
1241 fn test_normalize_path() {
1242 let path = Path::new("docs/sub/../other.md");
1244 let normalized = WorkspaceIndex::normalize_path(path);
1245 assert_eq!(normalized, PathBuf::from("docs/other.md"));
1246
1247 let path2 = Path::new("docs/./other.md");
1249 let normalized2 = WorkspaceIndex::normalize_path(path2);
1250 assert_eq!(normalized2, PathBuf::from("docs/other.md"));
1251
1252 let path3 = Path::new("a/b/c/../../d.md");
1254 let normalized3 = WorkspaceIndex::normalize_path(path3);
1255 assert_eq!(normalized3, PathBuf::from("a/d.md"));
1256 }
1257
1258 #[test]
1259 fn test_clear_clears_reverse_deps() {
1260 let mut index = WorkspaceIndex::new();
1261
1262 let mut file_a = FileIndex::new();
1264 file_a.add_cross_file_link(CrossFileLinkIndex {
1265 target_path: "b.md".to_string(),
1266 fragment: "".to_string(),
1267 line: 1,
1268 column: 1,
1269 });
1270 index.update_file(Path::new("docs/a.md"), file_a);
1271
1272 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1274
1275 index.clear();
1277
1278 assert_eq!(index.file_count(), 0);
1280 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1281 }
1282
1283 #[test]
1284 fn test_is_file_stale() {
1285 let mut index = WorkspaceIndex::new();
1286
1287 assert!(index.is_file_stale(Path::new("nonexistent.md"), "hash123"));
1289
1290 let file_index = FileIndex::with_hash("hash123".to_string());
1292 index.insert_file(PathBuf::from("docs/test.md"), file_index);
1293
1294 assert!(!index.is_file_stale(Path::new("docs/test.md"), "hash123"));
1296
1297 assert!(index.is_file_stale(Path::new("docs/test.md"), "different_hash"));
1299 }
1300
1301 #[cfg(feature = "native")]
1302 #[test]
1303 fn test_cache_roundtrip() {
1304 use std::fs;
1305
1306 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_roundtrip");
1308 let _ = fs::remove_dir_all(&temp_dir);
1309 fs::create_dir_all(&temp_dir).unwrap();
1310
1311 let mut index = WorkspaceIndex::new();
1313
1314 let mut file1 = FileIndex::with_hash("abc123".to_string());
1315 file1.add_heading(HeadingIndex {
1316 text: "Test Heading".to_string(),
1317 auto_anchor: "test-heading".to_string(),
1318 custom_anchor: Some("test".to_string()),
1319 line: 1,
1320 is_setext: false,
1321 });
1322 file1.add_cross_file_link(CrossFileLinkIndex {
1323 target_path: "./other.md".to_string(),
1324 fragment: "section".to_string(),
1325 line: 5,
1326 column: 3,
1327 });
1328 index.update_file(Path::new("docs/file1.md"), file1);
1329
1330 let mut file2 = FileIndex::with_hash("def456".to_string());
1331 file2.add_heading(HeadingIndex {
1332 text: "Another Heading".to_string(),
1333 auto_anchor: "another-heading".to_string(),
1334 custom_anchor: None,
1335 line: 1,
1336 is_setext: false,
1337 });
1338 index.update_file(Path::new("docs/other.md"), file2);
1339
1340 index.save_to_cache(&temp_dir).expect("Failed to save cache");
1342
1343 assert!(temp_dir.join("workspace_index.bin").exists());
1345
1346 let loaded = WorkspaceIndex::load_from_cache(&temp_dir).expect("Failed to load cache");
1348
1349 assert_eq!(loaded.file_count(), 2);
1351 assert!(loaded.contains_file(Path::new("docs/file1.md")));
1352 assert!(loaded.contains_file(Path::new("docs/other.md")));
1353
1354 let file1_loaded = loaded.get_file(Path::new("docs/file1.md")).unwrap();
1356 assert_eq!(file1_loaded.content_hash, "abc123");
1357 assert_eq!(file1_loaded.headings.len(), 1);
1358 assert_eq!(file1_loaded.headings[0].text, "Test Heading");
1359 assert_eq!(file1_loaded.headings[0].custom_anchor, Some("test".to_string()));
1360 assert_eq!(file1_loaded.cross_file_links.len(), 1);
1361 assert_eq!(file1_loaded.cross_file_links[0].target_path, "./other.md");
1362
1363 let dependents = loaded.get_dependents(Path::new("docs/other.md"));
1365 assert_eq!(dependents.len(), 1);
1366 assert_eq!(dependents[0], PathBuf::from("docs/file1.md"));
1367
1368 let _ = fs::remove_dir_all(&temp_dir);
1370 }
1371
1372 #[cfg(feature = "native")]
1373 #[test]
1374 fn test_cache_missing_file() {
1375 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_missing");
1376 let _ = std::fs::remove_dir_all(&temp_dir);
1377
1378 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1380 assert!(result.is_none());
1381 }
1382
1383 #[cfg(feature = "native")]
1384 #[test]
1385 fn test_cache_corrupted_file() {
1386 use std::fs;
1387
1388 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_corrupted");
1389 let _ = fs::remove_dir_all(&temp_dir);
1390 fs::create_dir_all(&temp_dir).unwrap();
1391
1392 fs::write(temp_dir.join("workspace_index.bin"), b"bad").unwrap();
1394
1395 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1397 assert!(result.is_none());
1398
1399 assert!(!temp_dir.join("workspace_index.bin").exists());
1401
1402 let _ = fs::remove_dir_all(&temp_dir);
1404 }
1405
1406 #[cfg(feature = "native")]
1407 #[test]
1408 fn test_cache_invalid_magic() {
1409 use std::fs;
1410
1411 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_invalid_magic");
1412 let _ = fs::remove_dir_all(&temp_dir);
1413 fs::create_dir_all(&temp_dir).unwrap();
1414
1415 let mut data = Vec::new();
1417 data.extend_from_slice(b"XXXX"); data.extend_from_slice(&1u32.to_le_bytes()); data.extend_from_slice(&[0; 100]); fs::write(temp_dir.join("workspace_index.bin"), &data).unwrap();
1421
1422 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1424 assert!(result.is_none());
1425
1426 assert!(!temp_dir.join("workspace_index.bin").exists());
1428
1429 let _ = fs::remove_dir_all(&temp_dir);
1431 }
1432
1433 #[cfg(feature = "native")]
1434 #[test]
1435 fn test_cache_version_mismatch() {
1436 use std::fs;
1437
1438 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_version_mismatch");
1439 let _ = fs::remove_dir_all(&temp_dir);
1440 fs::create_dir_all(&temp_dir).unwrap();
1441
1442 let mut data = Vec::new();
1444 data.extend_from_slice(b"RWSI"); data.extend_from_slice(&999u32.to_le_bytes()); data.extend_from_slice(&[0; 100]); fs::write(temp_dir.join("workspace_index.bin"), &data).unwrap();
1448
1449 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1451 assert!(result.is_none());
1452
1453 assert!(!temp_dir.join("workspace_index.bin").exists());
1455
1456 let _ = fs::remove_dir_all(&temp_dir);
1458 }
1459
1460 #[cfg(feature = "native")]
1461 #[test]
1462 fn test_cache_atomic_write() {
1463 use std::fs;
1464
1465 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_atomic");
1467 let _ = fs::remove_dir_all(&temp_dir);
1468 fs::create_dir_all(&temp_dir).unwrap();
1469
1470 let index = WorkspaceIndex::new();
1471 index.save_to_cache(&temp_dir).expect("Failed to save");
1472
1473 let entries: Vec<_> = fs::read_dir(&temp_dir).unwrap().collect();
1475 assert_eq!(entries.len(), 1);
1476 assert!(temp_dir.join("workspace_index.bin").exists());
1477
1478 let _ = fs::remove_dir_all(&temp_dir);
1480 }
1481
1482 #[test]
1483 fn test_has_anchor_auto_generated() {
1484 let mut file_index = FileIndex::new();
1485 file_index.add_heading(HeadingIndex {
1486 text: "Installation Guide".to_string(),
1487 auto_anchor: "installation-guide".to_string(),
1488 custom_anchor: None,
1489 line: 1,
1490 is_setext: false,
1491 });
1492
1493 assert!(file_index.has_anchor("installation-guide"));
1495
1496 assert!(file_index.has_anchor("Installation-Guide"));
1498 assert!(file_index.has_anchor("INSTALLATION-GUIDE"));
1499
1500 assert!(!file_index.has_anchor("nonexistent"));
1502 }
1503
1504 #[test]
1505 fn test_has_anchor_custom() {
1506 let mut file_index = FileIndex::new();
1507 file_index.add_heading(HeadingIndex {
1508 text: "Installation Guide".to_string(),
1509 auto_anchor: "installation-guide".to_string(),
1510 custom_anchor: Some("install".to_string()),
1511 line: 1,
1512 is_setext: false,
1513 });
1514
1515 assert!(file_index.has_anchor("installation-guide"));
1517
1518 assert!(file_index.has_anchor("install"));
1520 assert!(file_index.has_anchor("Install")); assert!(!file_index.has_anchor("nonexistent"));
1524 }
1525
1526 #[test]
1527 fn test_get_heading_by_anchor() {
1528 let mut file_index = FileIndex::new();
1529 file_index.add_heading(HeadingIndex {
1530 text: "Installation Guide".to_string(),
1531 auto_anchor: "installation-guide".to_string(),
1532 custom_anchor: Some("install".to_string()),
1533 line: 10,
1534 is_setext: false,
1535 });
1536 file_index.add_heading(HeadingIndex {
1537 text: "Configuration".to_string(),
1538 auto_anchor: "configuration".to_string(),
1539 custom_anchor: None,
1540 line: 20,
1541 is_setext: false,
1542 });
1543
1544 let heading = file_index.get_heading_by_anchor("installation-guide");
1546 assert!(heading.is_some());
1547 assert_eq!(heading.unwrap().text, "Installation Guide");
1548 assert_eq!(heading.unwrap().line, 10);
1549
1550 let heading = file_index.get_heading_by_anchor("install");
1552 assert!(heading.is_some());
1553 assert_eq!(heading.unwrap().text, "Installation Guide");
1554
1555 let heading = file_index.get_heading_by_anchor("configuration");
1557 assert!(heading.is_some());
1558 assert_eq!(heading.unwrap().text, "Configuration");
1559 assert_eq!(heading.unwrap().line, 20);
1560
1561 assert!(file_index.get_heading_by_anchor("nonexistent").is_none());
1563 }
1564
1565 #[test]
1566 fn test_anchor_lookup_many_headings() {
1567 let mut file_index = FileIndex::new();
1569
1570 for i in 0..100 {
1572 file_index.add_heading(HeadingIndex {
1573 text: format!("Heading {i}"),
1574 auto_anchor: format!("heading-{i}"),
1575 custom_anchor: Some(format!("h{i}")),
1576 line: i + 1,
1577 is_setext: false,
1578 });
1579 }
1580
1581 for i in 0..100 {
1583 assert!(file_index.has_anchor(&format!("heading-{i}")));
1584 assert!(file_index.has_anchor(&format!("h{i}")));
1585
1586 let heading = file_index.get_heading_by_anchor(&format!("heading-{i}"));
1587 assert!(heading.is_some());
1588 assert_eq!(heading.unwrap().line, i + 1);
1589 }
1590 }
1591
1592 #[test]
1597 fn test_extract_cross_file_links_basic() {
1598 use crate::config::MarkdownFlavor;
1599
1600 let content = "# Test\n\nSee [link](./other.md) for info.\n";
1601 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1602 let links = extract_cross_file_links(&ctx).relative;
1603
1604 assert_eq!(links.len(), 1);
1605 assert_eq!(links[0].target_path, "./other.md");
1606 assert_eq!(links[0].fragment, "");
1607 assert_eq!(links[0].line, 3);
1608 assert_eq!(links[0].column, 12);
1610 }
1611
1612 #[test]
1613 fn test_extract_cross_file_links_with_fragment() {
1614 use crate::config::MarkdownFlavor;
1615
1616 let content = "Check [guide](./guide.md#install) here.\n";
1617 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1618 let links = extract_cross_file_links(&ctx).relative;
1619
1620 assert_eq!(links.len(), 1);
1621 assert_eq!(links[0].target_path, "./guide.md");
1622 assert_eq!(links[0].fragment, "install");
1623 assert_eq!(links[0].line, 1);
1624 assert_eq!(links[0].column, 15);
1626 }
1627
1628 #[test]
1629 fn test_extract_cross_file_links_multiple_on_same_line() {
1630 use crate::config::MarkdownFlavor;
1631
1632 let content = "See [a](a.md) and [b](b.md) here.\n";
1633 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1634 let links = extract_cross_file_links(&ctx).relative;
1635
1636 assert_eq!(links.len(), 2);
1637
1638 assert_eq!(links[0].target_path, "a.md");
1639 assert_eq!(links[0].line, 1);
1640 assert_eq!(links[0].column, 9);
1642
1643 assert_eq!(links[1].target_path, "b.md");
1644 assert_eq!(links[1].line, 1);
1645 assert_eq!(links[1].column, 23);
1647 }
1648
1649 #[test]
1650 fn test_extract_cross_file_links_angle_brackets() {
1651 use crate::config::MarkdownFlavor;
1652
1653 let content = "See [link](<path/with (parens).md>) here.\n";
1654 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1655 let links = extract_cross_file_links(&ctx).relative;
1656
1657 assert_eq!(links.len(), 1);
1658 assert_eq!(links[0].target_path, "path/with (parens).md");
1659 assert_eq!(links[0].line, 1);
1660 assert_eq!(links[0].column, 13);
1662 }
1663
1664 #[test]
1665 fn test_extract_cross_file_links_skips_external() {
1666 use crate::config::MarkdownFlavor;
1667
1668 let content = r#"
1669[external](https://example.com)
1670[mailto](mailto:test@example.com)
1671[local](./local.md)
1672[fragment](#section)
1673[absolute](/docs/page.md)
1674"#;
1675 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1676 let extracted = extract_cross_file_links(&ctx);
1677
1678 assert_eq!(extracted.relative.len(), 1);
1680 assert_eq!(extracted.relative[0].target_path, "./local.md");
1681 assert_eq!(extracted.root_relative.len(), 1);
1683 assert_eq!(extracted.root_relative[0].target_path, "docs/page.md");
1684 }
1685
1686 #[test]
1687 fn test_extract_cross_file_links_root_relative() {
1688 use crate::config::MarkdownFlavor;
1689
1690 let content = "[a](/guide.md#install)\n[b](/../escape.md)\n[c](//host/x.md)\n[d](/img/pic.png)\n";
1694 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1695 let extracted = extract_cross_file_links(&ctx);
1696
1697 assert!(extracted.relative.is_empty(), "no directory-relative links here");
1698 assert_eq!(
1699 extracted
1700 .root_relative
1701 .iter()
1702 .map(|l| (l.target_path.as_str(), l.fragment.as_str()))
1703 .collect::<Vec<_>>(),
1704 vec![("guide.md", "install")],
1705 "only the safe root-relative markdown link is captured"
1706 );
1707 }
1708
1709 #[test]
1710 fn test_extract_cross_file_links_skips_non_markdown() {
1711 use crate::config::MarkdownFlavor;
1712
1713 let content = r#"
1714[image](./photo.png)
1715[doc](./readme.md)
1716[pdf](./document.pdf)
1717"#;
1718 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1719 let links = extract_cross_file_links(&ctx).relative;
1720
1721 assert_eq!(links.len(), 1);
1723 assert_eq!(links[0].target_path, "./readme.md");
1724 }
1725
1726 #[test]
1727 fn test_extract_cross_file_links_skips_code_spans() {
1728 use crate::config::MarkdownFlavor;
1729
1730 let content = "Normal [link](./file.md) and `[code](./ignored.md)` here.\n";
1731 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1732 let links = extract_cross_file_links(&ctx).relative;
1733
1734 assert_eq!(links.len(), 1);
1736 assert_eq!(links[0].target_path, "./file.md");
1737 }
1738
1739 #[test]
1740 fn test_extract_cross_file_links_with_query_params() {
1741 use crate::config::MarkdownFlavor;
1742
1743 let content = "See [doc](./file.md?raw=true) here.\n";
1744 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1745 let links = extract_cross_file_links(&ctx).relative;
1746
1747 assert_eq!(links.len(), 1);
1748 assert_eq!(links[0].target_path, "./file.md");
1750 }
1751
1752 #[test]
1753 fn test_extract_cross_file_links_empty_content() {
1754 use crate::config::MarkdownFlavor;
1755
1756 let content = "";
1757 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1758 let links = extract_cross_file_links(&ctx).relative;
1759
1760 assert!(links.is_empty());
1761 }
1762
1763 #[test]
1764 fn test_extract_cross_file_links_no_links() {
1765 use crate::config::MarkdownFlavor;
1766
1767 let content = "# Just a heading\n\nSome text without links.\n";
1768 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1769 let links = extract_cross_file_links(&ctx).relative;
1770
1771 assert!(links.is_empty());
1772 }
1773
1774 #[test]
1775 fn test_extract_cross_file_links_position_accuracy_issue_234() {
1776 use crate::config::MarkdownFlavor;
1779
1780 let content = r#"# Test Document
1781
1782Here is a [broken link](nonexistent-file.md) that should trigger MD057.
1783
1784And another [link](also-missing.md) on this line.
1785"#;
1786 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1787 let links = extract_cross_file_links(&ctx).relative;
1788
1789 assert_eq!(links.len(), 2);
1790
1791 assert_eq!(links[0].target_path, "nonexistent-file.md");
1793 assert_eq!(links[0].line, 3);
1794 assert_eq!(links[0].column, 25);
1795
1796 assert_eq!(links[1].target_path, "also-missing.md");
1798 assert_eq!(links[1].line, 5);
1799 assert_eq!(links[1].column, 20);
1800 }
1801}