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;
28
29fn hex_digit_to_value(c: u8) -> Option<u8> {
35 match c {
36 b'0'..=b'9' => Some(c - b'0'),
37 b'a'..=b'f' => Some(c - b'a' + 10),
38 b'A'..=b'F' => Some(c - b'A' + 10),
39 _ => None,
40 }
41}
42
43fn url_decode(s: &str) -> String {
47 if !s.contains('%') {
49 return s.to_string();
50 }
51
52 let bytes = s.as_bytes();
53 let mut result = Vec::with_capacity(bytes.len());
54 let mut i = 0;
55
56 while i < bytes.len() {
57 if bytes[i] == b'%' && i + 2 < bytes.len() {
58 let hex1 = bytes[i + 1];
60 let hex2 = bytes[i + 2];
61 if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
62 result.push(d1 * 16 + d2);
63 i += 3;
64 continue;
65 }
66 }
67 result.push(bytes[i]);
68 i += 1;
69 }
70
71 String::from_utf8(result).unwrap_or_else(|_| s.to_string())
73}
74
75static LINK_START_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!?\[[^\]]*\]").unwrap());
85
86static URL_EXTRACT_ANGLE_BRACKET_REGEX: LazyLock<Regex> =
89 LazyLock::new(|| Regex::new(r#"\]\(\s*<([^>]+)>(#[^\)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
90
91static URL_EXTRACT_REGEX: LazyLock<Regex> =
94 LazyLock::new(|| Regex::new(r#"]\(\s*([^>)\s#]+)(#[^)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
95
96pub(crate) static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
98 LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
99
100#[inline]
102fn is_markdown_file(path: &str) -> bool {
103 crate::discovery::has_markdown_extension(std::path::Path::new(path))
104}
105
106fn strip_query_and_fragment(url: &str) -> &str {
109 let query_pos = url.find('?');
110 let fragment_pos = url.find('#');
111
112 match (query_pos, fragment_pos) {
113 (Some(q), Some(f)) => &url[..q.min(f)],
114 (Some(q), None) => &url[..q],
115 (None, Some(f)) => &url[..f],
116 (None, None) => url,
117 }
118}
119
120#[derive(Debug, Default)]
127pub struct ExtractedCrossFileLinks {
128 pub relative: Vec<CrossFileLinkIndex>,
130 pub root_relative: Vec<CrossFileLinkIndex>,
134}
135
136pub fn extract_cross_file_links(ctx: &LintContext) -> ExtractedCrossFileLinks {
144 let content = ctx.content;
145
146 if content.is_empty() || !content.contains("](") {
148 return ExtractedCrossFileLinks::default();
149 }
150
151 let mut links = ExtractedCrossFileLinks::default();
152 let lines: Vec<&str> = content.lines().collect();
153 let line_index = &ctx.line_index;
154
155 let mut processed_lines = HashSet::new();
158
159 for link in &ctx.links {
160 let line_idx = link.line - 1;
161 if line_idx >= lines.len() {
162 continue;
163 }
164
165 if !processed_lines.insert(line_idx) {
167 continue;
168 }
169
170 let line = lines[line_idx];
171 if !line.contains("](") {
172 continue;
173 }
174
175 for link_match in LINK_START_REGEX.find_iter(line) {
177 let start_pos = link_match.start();
178 let end_pos = link_match.end();
179
180 let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
182 let absolute_start_pos = line_start_byte + start_pos;
183
184 if ctx.is_in_code_span_byte(absolute_start_pos) {
186 continue;
187 }
188
189 let caps_result = URL_EXTRACT_ANGLE_BRACKET_REGEX
192 .captures_at(line, end_pos - 1)
193 .or_else(|| URL_EXTRACT_REGEX.captures_at(line, end_pos - 1));
194
195 if let Some(caps) = caps_result
196 && let Some(url_group) = caps.get(1)
197 {
198 let file_path = url_group.as_str().trim();
199
200 if let Some(rel) = file_path.strip_prefix('/') {
205 if !rel.starts_with('/')
206 && !Path::new(rel)
207 .components()
208 .any(|c| matches!(c, std::path::Component::ParentDir))
209 {
210 let stripped = strip_query_and_fragment(rel);
211 if is_markdown_file(stripped) {
212 let fragment = caps.get(2).map_or("", |m| m.as_str().trim_start_matches('#'));
213 links.root_relative.push(CrossFileLinkIndex {
214 target_path: stripped.to_string(),
215 fragment: fragment.to_string(),
216 line: link.line,
217 column: url_group.start() + 1,
218 });
219 }
220 }
221 continue;
222 }
223
224 if file_path.is_empty()
227 || PROTOCOL_DOMAIN_REGEX.is_match(file_path)
228 || file_path.starts_with("www.")
229 || file_path.starts_with('#')
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('`') && file_path.ends_with('`'))
235 {
236 continue;
237 }
238
239 let file_path = strip_query_and_fragment(file_path);
241
242 let fragment = caps.get(2).map_or("", |m| m.as_str().trim_start_matches('#'));
244
245 if is_markdown_file(file_path) {
247 links.relative.push(CrossFileLinkIndex {
248 target_path: file_path.to_string(),
249 fragment: fragment.to_string(),
250 line: link.line,
251 column: url_group.start() + 1,
252 });
253 }
254 }
255 }
256 }
257
258 links
259}
260
261#[cfg(feature = "native")]
263const CACHE_MAGIC: &[u8; 4] = b"RWSI";
264
265#[cfg(feature = "native")]
271const CACHE_FORMAT_VERSION: u32 = 8;
272
273#[cfg(feature = "native")]
275const CACHE_FILE_NAME: &str = "workspace_index.bin";
276
277#[derive(Debug, Default, Clone, Serialize, Deserialize)]
282pub struct WorkspaceIndex {
283 files: HashMap<PathBuf, FileIndex>,
285 reverse_deps: HashMap<PathBuf, HashSet<PathBuf>>,
288 version: u64,
290}
291
292#[derive(Debug, Clone, Default, Serialize, Deserialize)]
294pub struct FileIndex {
295 pub headings: Vec<HeadingIndex>,
297 pub reference_links: Vec<ReferenceLinkIndex>,
299 pub cross_file_links: Vec<CrossFileLinkIndex>,
301 #[serde(default)]
306 pub root_relative_links: Vec<CrossFileLinkIndex>,
307 pub defined_references: HashSet<String>,
310 pub content_hash: String,
312 anchor_to_heading: HashMap<String, usize>,
315 #[serde(default)]
319 anchor_to_heading_exact: HashMap<String, usize>,
320 html_anchors: HashSet<String>,
323 #[serde(default)]
326 html_anchors_exact: HashSet<String>,
327 attribute_anchors: HashSet<String>,
331 #[serde(default)]
334 attribute_anchors_exact: HashSet<String>,
335 pub file_disabled_rules: HashSet<String>,
338 pub persistent_transitions: Vec<(usize, HashSet<String>, HashSet<String>)>,
341 pub line_disabled_rules: HashMap<usize, HashSet<String>>,
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct HeadingIndex {
348 pub text: String,
350 pub auto_anchor: String,
352 pub custom_anchor: Option<String>,
354 pub line: usize,
356 #[serde(default)]
358 pub is_setext: bool,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct ReferenceLinkIndex {
364 pub reference_id: String,
366 pub line: usize,
368 pub column: usize,
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize)]
374pub struct CrossFileLinkIndex {
375 pub target_path: String,
377 pub fragment: String,
379 pub line: usize,
381 pub column: usize,
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize)]
387pub struct VulnerableAnchor {
388 pub file: PathBuf,
390 pub line: usize,
392 pub text: String,
394}
395
396impl WorkspaceIndex {
397 pub fn new() -> Self {
399 Self::default()
400 }
401
402 pub fn version(&self) -> u64 {
404 self.version
405 }
406
407 pub fn file_count(&self) -> usize {
409 self.files.len()
410 }
411
412 pub fn contains_file(&self, path: &Path) -> bool {
414 self.files.contains_key(path)
415 }
416
417 pub fn get_file(&self, path: &Path) -> Option<&FileIndex> {
419 self.files.get(path)
420 }
421
422 pub fn insert_file(&mut self, path: PathBuf, index: FileIndex) {
424 self.files.insert(path, index);
425 self.version = self.version.wrapping_add(1);
426 }
427
428 pub fn remove_file(&mut self, path: &Path) -> Option<FileIndex> {
430 self.clear_reverse_deps_for(path);
432
433 let result = self.files.remove(path);
434 if result.is_some() {
435 self.version = self.version.wrapping_add(1);
436 }
437 result
438 }
439
440 pub fn get_vulnerable_anchors(&self) -> HashMap<String, Vec<VulnerableAnchor>> {
450 let mut vulnerable: HashMap<String, Vec<VulnerableAnchor>> = HashMap::new();
451
452 for (file_path, file_index) in &self.files {
453 for heading in &file_index.headings {
454 if heading.custom_anchor.is_none() && !heading.auto_anchor.is_empty() {
456 let anchor_key = heading.auto_anchor.to_lowercase();
457 vulnerable.entry(anchor_key).or_default().push(VulnerableAnchor {
458 file: file_path.clone(),
459 line: heading.line,
460 text: heading.text.clone(),
461 });
462 }
463 }
464 }
465
466 vulnerable
467 }
468
469 pub fn all_headings(&self) -> impl Iterator<Item = (&Path, &HeadingIndex)> {
471 self.files
472 .iter()
473 .flat_map(|(path, index)| index.headings.iter().map(move |h| (path.as_path(), h)))
474 }
475
476 pub fn files(&self) -> impl Iterator<Item = (&Path, &FileIndex)> {
478 self.files.iter().map(|(p, i)| (p.as_path(), i))
479 }
480
481 pub fn files_sorted(&self) -> Vec<(&Path, &FileIndex)> {
487 let mut entries: Vec<(&Path, &FileIndex)> = self.files.iter().map(|(p, i)| (p.as_path(), i)).collect();
488 entries.sort_by(|(a, _), (b, _)| a.cmp(b));
489 entries
490 }
491
492 pub fn clear(&mut self) {
494 self.files.clear();
495 self.reverse_deps.clear();
496 self.version = self.version.wrapping_add(1);
497 }
498
499 pub fn update_file(&mut self, path: &Path, index: FileIndex) {
506 self.clear_reverse_deps_as_source(path);
509
510 for link in &index.cross_file_links {
512 let target = self.resolve_target_path(path, &link.target_path);
513 self.reverse_deps.entry(target).or_default().insert(path.to_path_buf());
514 }
515
516 self.files.insert(path.to_path_buf(), index);
517 self.version = self.version.wrapping_add(1);
518 }
519
520 pub fn get_dependents(&self, path: &Path) -> Vec<PathBuf> {
525 self.reverse_deps
526 .get(path)
527 .map(|set| set.iter().cloned().collect())
528 .unwrap_or_default()
529 }
530
531 pub fn is_file_stale(&self, path: &Path, current_hash: &str) -> bool {
535 self.files.get(path).is_none_or(|f| f.content_hash != current_hash)
536 }
537
538 pub fn retain_only(&mut self, current_files: &std::collections::HashSet<PathBuf>) -> usize {
543 let before_count = self.files.len();
544
545 let to_remove: Vec<PathBuf> = self
547 .files
548 .keys()
549 .filter(|path| !current_files.contains(*path))
550 .cloned()
551 .collect();
552
553 for path in &to_remove {
555 self.remove_file(path);
556 }
557
558 before_count - self.files.len()
559 }
560
561 #[cfg(feature = "native")]
568 pub fn save_to_cache(&self, cache_dir: &Path) -> std::io::Result<()> {
569 use std::fs;
570 use std::io::Write;
571
572 fs::create_dir_all(cache_dir)?;
574
575 let encoded = postcard::to_allocvec(self)
577 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
578
579 let mut cache_data = Vec::with_capacity(8 + encoded.len());
581 cache_data.extend_from_slice(CACHE_MAGIC);
582 cache_data.extend_from_slice(&CACHE_FORMAT_VERSION.to_le_bytes());
583 cache_data.extend_from_slice(&encoded);
584
585 let final_path = cache_dir.join(CACHE_FILE_NAME);
587 let temp_path = cache_dir.join(format!("{}.tmp.{}", CACHE_FILE_NAME, std::process::id()));
588
589 {
591 let mut file = fs::File::create(&temp_path)?;
592 file.write_all(&cache_data)?;
593 file.sync_all()?;
594 }
595
596 fs::rename(&temp_path, &final_path)?;
598
599 log::debug!(
600 "Saved workspace index to cache: {} files, {} bytes (format v{})",
601 self.files.len(),
602 cache_data.len(),
603 CACHE_FORMAT_VERSION
604 );
605
606 Ok(())
607 }
608
609 #[cfg(feature = "native")]
617 pub fn load_from_cache(cache_dir: &Path) -> Option<Self> {
618 use std::fs;
619
620 let path = cache_dir.join(CACHE_FILE_NAME);
621 let data = fs::read(&path).ok()?;
622
623 if data.len() < 8 {
625 log::warn!("Workspace index cache too small, discarding");
626 let _ = fs::remove_file(&path);
627 return None;
628 }
629
630 if &data[0..4] != CACHE_MAGIC {
632 log::warn!("Workspace index cache has invalid magic header, discarding");
633 let _ = fs::remove_file(&path);
634 return None;
635 }
636
637 let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
639 if version != CACHE_FORMAT_VERSION {
640 log::info!(
641 "Workspace index cache format version mismatch (got {version}, expected {CACHE_FORMAT_VERSION}), rebuilding"
642 );
643 let _ = fs::remove_file(&path);
644 return None;
645 }
646
647 match postcard::from_bytes::<Self>(&data[8..]) {
649 Ok(index) => {
650 log::debug!(
651 "Loaded workspace index from cache: {} files (format v{})",
652 index.files.len(),
653 version
654 );
655 Some(index)
656 }
657 Err(e) => {
658 log::warn!("Failed to deserialize workspace index cache: {e}");
659 let _ = fs::remove_file(&path);
660 None
661 }
662 }
663 }
664
665 fn clear_reverse_deps_as_source(&mut self, path: &Path) {
670 let targets: Vec<PathBuf> = match self.files.get(path) {
677 Some(index) => index
678 .cross_file_links
679 .iter()
680 .map(|link| self.resolve_target_path(path, &link.target_path))
681 .collect(),
682 None => return,
683 };
684 for target in targets {
685 if let Some(deps) = self.reverse_deps.get_mut(&target) {
686 deps.remove(path);
687 if deps.is_empty() {
688 self.reverse_deps.remove(&target);
689 }
690 }
691 }
692 }
693
694 fn clear_reverse_deps_for(&mut self, path: &Path) {
699 self.clear_reverse_deps_as_source(path);
701
702 self.reverse_deps.remove(path);
704 }
705
706 fn resolve_target_path(&self, source_file: &Path, relative_target: &str) -> PathBuf {
708 let source_dir = source_file.parent().unwrap_or(Path::new(""));
710
711 let target = source_dir.join(relative_target);
713
714 Self::normalize_path(&target)
716 }
717
718 fn normalize_path(path: &Path) -> PathBuf {
720 let mut components = Vec::new();
721
722 for component in path.components() {
723 match component {
724 std::path::Component::ParentDir => {
725 if !components.is_empty() {
727 components.pop();
728 }
729 }
730 std::path::Component::CurDir => {
731 }
733 _ => {
734 components.push(component);
735 }
736 }
737 }
738
739 components.iter().collect()
740 }
741}
742
743impl FileIndex {
744 pub fn new() -> Self {
746 Self::default()
747 }
748
749 pub fn with_hash(content_hash: String) -> Self {
751 Self {
752 content_hash,
753 ..Default::default()
754 }
755 }
756
757 pub fn add_heading(&mut self, heading: HeadingIndex) {
763 let index = self.headings.len();
764
765 self.anchor_to_heading.insert(heading.auto_anchor.to_lowercase(), index);
768 self.anchor_to_heading_exact.insert(heading.auto_anchor.clone(), index);
769
770 if let Some(ref custom) = heading.custom_anchor {
772 self.anchor_to_heading.insert(custom.to_lowercase(), index);
773 self.anchor_to_heading_exact.insert(custom.clone(), index);
774 }
775
776 self.headings.push(heading);
777 }
778
779 pub fn add_anchor_alias(&mut self, anchor: &str, heading_index: usize) {
782 if heading_index < self.headings.len() {
783 self.anchor_to_heading.insert(anchor.to_lowercase(), heading_index);
784 self.anchor_to_heading_exact.insert(anchor.to_string(), heading_index);
785 }
786 }
787
788 pub fn has_anchor(&self, anchor: &str) -> bool {
799 self.has_anchor_with_case(anchor, true)
800 }
801
802 pub fn has_anchor_with_case(&self, anchor: &str, ignore_case: bool) -> bool {
811 if self.lookup_anchor(anchor, ignore_case) {
812 return true;
813 }
814
815 if anchor.contains('%') {
817 let decoded = url_decode(anchor);
818 if decoded != anchor {
819 return self.lookup_anchor(&decoded, ignore_case);
820 }
821 }
822
823 false
824 }
825
826 fn lookup_anchor(&self, anchor: &str, ignore_case: bool) -> bool {
829 if ignore_case {
830 let lower = anchor.to_lowercase();
831 self.anchor_to_heading.contains_key(&lower)
832 || self.html_anchors.contains(&lower)
833 || self.attribute_anchors.contains(&lower)
834 } else {
835 self.anchor_to_heading_exact.contains_key(anchor)
836 || self.html_anchors_exact.contains(anchor)
837 || self.attribute_anchors_exact.contains(anchor)
838 }
839 }
840
841 pub fn add_html_anchor(&mut self, anchor: &str) {
844 if !anchor.is_empty() {
845 self.html_anchors.insert(anchor.to_lowercase());
846 self.html_anchors_exact.insert(anchor.to_string());
847 }
848 }
849
850 pub fn add_attribute_anchor(&mut self, anchor: &str) {
853 if !anchor.is_empty() {
854 self.attribute_anchors.insert(anchor.to_lowercase());
855 self.attribute_anchors_exact.insert(anchor.to_string());
856 }
857 }
858
859 pub fn get_heading_by_anchor(&self, anchor: &str) -> Option<&HeadingIndex> {
863 self.anchor_to_heading
864 .get(&anchor.to_lowercase())
865 .and_then(|&idx| self.headings.get(idx))
866 }
867
868 pub fn add_reference_link(&mut self, link: ReferenceLinkIndex) {
870 self.reference_links.push(link);
871 }
872
873 pub fn is_rule_disabled_at_line(&self, rule_name: &str, line: usize) -> bool {
878 if self.file_disabled_rules.contains("*") || self.file_disabled_rules.contains(rule_name) {
880 return true;
881 }
882
883 if let Some(rules) = self.line_disabled_rules.get(&line)
885 && (rules.contains("*") || rules.contains(rule_name))
886 {
887 return true;
888 }
889
890 if !self.persistent_transitions.is_empty() {
892 let idx = match self.persistent_transitions.binary_search_by_key(&line, |t| t.0) {
893 Ok(i) => Some(i),
894 Err(i) => {
895 if i > 0 {
896 Some(i - 1)
897 } else {
898 None
899 }
900 }
901 };
902 if let Some(i) = idx {
903 let (_, ref disabled, ref enabled) = self.persistent_transitions[i];
904 if disabled.contains("*") {
905 return !enabled.contains(rule_name);
906 }
907 return disabled.contains(rule_name);
908 }
909 }
910
911 false
912 }
913
914 pub fn add_cross_file_link(&mut self, link: CrossFileLinkIndex) {
916 let is_duplicate = self.cross_file_links.iter().any(|existing| {
919 existing.target_path == link.target_path && existing.fragment == link.fragment && existing.line == link.line
920 });
921 if !is_duplicate {
922 self.cross_file_links.push(link);
923 }
924 }
925
926 pub fn add_root_relative_link(&mut self, link: CrossFileLinkIndex) {
928 let is_duplicate = self.root_relative_links.iter().any(|existing| {
929 existing.target_path == link.target_path && existing.fragment == link.fragment && existing.line == link.line
930 });
931 if !is_duplicate {
932 self.root_relative_links.push(link);
933 }
934 }
935
936 pub fn add_defined_reference(&mut self, ref_id: String) {
938 self.defined_references.insert(ref_id);
939 }
940
941 pub fn has_defined_reference(&self, ref_id: &str) -> bool {
943 self.defined_references.contains(ref_id)
944 }
945
946 pub fn hash_matches(&self, hash: &str) -> bool {
948 self.content_hash == hash
949 }
950
951 pub fn heading_count(&self) -> usize {
953 self.headings.len()
954 }
955
956 pub fn reference_link_count(&self) -> usize {
958 self.reference_links.len()
959 }
960}
961
962#[cfg(test)]
963mod tests {
964 use super::*;
965
966 #[test]
967 fn test_workspace_index_basic() {
968 let mut index = WorkspaceIndex::new();
969 assert_eq!(index.file_count(), 0);
970 assert_eq!(index.version(), 0);
971
972 let mut file_index = FileIndex::with_hash("abc123".to_string());
973 file_index.add_heading(HeadingIndex {
974 text: "Installation".to_string(),
975 auto_anchor: "installation".to_string(),
976 custom_anchor: None,
977 line: 1,
978 is_setext: false,
979 });
980
981 index.insert_file(PathBuf::from("docs/install.md"), file_index);
982 assert_eq!(index.file_count(), 1);
983 assert_eq!(index.version(), 1);
984
985 assert!(index.contains_file(Path::new("docs/install.md")));
986 assert!(!index.contains_file(Path::new("docs/other.md")));
987 }
988
989 #[test]
990 fn test_vulnerable_anchors() {
991 let mut index = WorkspaceIndex::new();
992
993 let mut file1 = FileIndex::new();
995 file1.add_heading(HeadingIndex {
996 text: "Getting Started".to_string(),
997 auto_anchor: "getting-started".to_string(),
998 custom_anchor: None,
999 line: 1,
1000 is_setext: false,
1001 });
1002 index.insert_file(PathBuf::from("docs/guide.md"), file1);
1003
1004 let mut file2 = FileIndex::new();
1006 file2.add_heading(HeadingIndex {
1007 text: "Installation".to_string(),
1008 auto_anchor: "installation".to_string(),
1009 custom_anchor: Some("install".to_string()),
1010 line: 1,
1011 is_setext: false,
1012 });
1013 index.insert_file(PathBuf::from("docs/install.md"), file2);
1014
1015 let vulnerable = index.get_vulnerable_anchors();
1016 assert_eq!(vulnerable.len(), 1);
1017 assert!(vulnerable.contains_key("getting-started"));
1018 assert!(!vulnerable.contains_key("installation"));
1019
1020 let anchors = vulnerable.get("getting-started").unwrap();
1021 assert_eq!(anchors.len(), 1);
1022 assert_eq!(anchors[0].file, PathBuf::from("docs/guide.md"));
1023 assert_eq!(anchors[0].text, "Getting Started");
1024 }
1025
1026 #[test]
1027 fn test_vulnerable_anchors_multiple_files_same_anchor() {
1028 let mut index = WorkspaceIndex::new();
1031
1032 let mut file1 = FileIndex::new();
1034 file1.add_heading(HeadingIndex {
1035 text: "Installation".to_string(),
1036 auto_anchor: "installation".to_string(),
1037 custom_anchor: None,
1038 line: 1,
1039 is_setext: false,
1040 });
1041 index.insert_file(PathBuf::from("docs/en/guide.md"), file1);
1042
1043 let mut file2 = FileIndex::new();
1045 file2.add_heading(HeadingIndex {
1046 text: "Installation".to_string(),
1047 auto_anchor: "installation".to_string(),
1048 custom_anchor: None,
1049 line: 5,
1050 is_setext: false,
1051 });
1052 index.insert_file(PathBuf::from("docs/fr/guide.md"), file2);
1053
1054 let mut file3 = FileIndex::new();
1056 file3.add_heading(HeadingIndex {
1057 text: "Installation".to_string(),
1058 auto_anchor: "installation".to_string(),
1059 custom_anchor: Some("install".to_string()),
1060 line: 10,
1061 is_setext: false,
1062 });
1063 index.insert_file(PathBuf::from("docs/de/guide.md"), file3);
1064
1065 let vulnerable = index.get_vulnerable_anchors();
1066 assert_eq!(vulnerable.len(), 1); assert!(vulnerable.contains_key("installation"));
1068
1069 let anchors = vulnerable.get("installation").unwrap();
1070 assert_eq!(anchors.len(), 2, "Should collect both vulnerable anchors");
1072
1073 let files: std::collections::HashSet<_> = anchors.iter().map(|a| &a.file).collect();
1075 assert!(files.contains(&PathBuf::from("docs/en/guide.md")));
1076 assert!(files.contains(&PathBuf::from("docs/fr/guide.md")));
1077 }
1078
1079 #[test]
1080 fn test_file_index_hash() {
1081 let index = FileIndex::with_hash("hash123".to_string());
1082 assert!(index.hash_matches("hash123"));
1083 assert!(!index.hash_matches("other"));
1084 }
1085
1086 #[test]
1087 fn test_version_increment() {
1088 let mut index = WorkspaceIndex::new();
1089 assert_eq!(index.version(), 0);
1090
1091 index.insert_file(PathBuf::from("a.md"), FileIndex::new());
1092 assert_eq!(index.version(), 1);
1093
1094 index.insert_file(PathBuf::from("b.md"), FileIndex::new());
1095 assert_eq!(index.version(), 2);
1096
1097 index.remove_file(Path::new("a.md"));
1098 assert_eq!(index.version(), 3);
1099
1100 index.remove_file(Path::new("nonexistent.md"));
1102 assert_eq!(index.version(), 3);
1103 }
1104
1105 #[test]
1106 fn test_files_sorted_is_path_ordered() {
1107 let mut index = WorkspaceIndex::new();
1108 for name in ["docs/zebra.md", "docs/apple.md", "docs/mango.md"] {
1110 index.update_file(Path::new(name), FileIndex::new());
1111 }
1112
1113 let paths: Vec<&Path> = index.files_sorted().into_iter().map(|(p, _)| p).collect();
1114 assert_eq!(
1115 paths,
1116 vec![
1117 Path::new("docs/apple.md"),
1118 Path::new("docs/mango.md"),
1119 Path::new("docs/zebra.md"),
1120 ],
1121 "files_sorted() must return entries ordered by path"
1122 );
1123 }
1124
1125 #[test]
1126 fn test_reverse_deps_basic() {
1127 let mut index = WorkspaceIndex::new();
1128
1129 let mut file_a = FileIndex::new();
1131 file_a.add_cross_file_link(CrossFileLinkIndex {
1132 target_path: "b.md".to_string(),
1133 fragment: "section".to_string(),
1134 line: 10,
1135 column: 5,
1136 });
1137 index.update_file(Path::new("docs/a.md"), file_a);
1138
1139 let dependents = index.get_dependents(Path::new("docs/b.md"));
1141 assert_eq!(dependents.len(), 1);
1142 assert_eq!(dependents[0], PathBuf::from("docs/a.md"));
1143
1144 let a_dependents = index.get_dependents(Path::new("docs/a.md"));
1146 assert!(a_dependents.is_empty());
1147 }
1148
1149 #[test]
1150 fn test_reverse_deps_multiple() {
1151 let mut index = WorkspaceIndex::new();
1152
1153 let mut file_a = FileIndex::new();
1155 file_a.add_cross_file_link(CrossFileLinkIndex {
1156 target_path: "../b.md".to_string(),
1157 fragment: "".to_string(),
1158 line: 1,
1159 column: 1,
1160 });
1161 index.update_file(Path::new("docs/sub/a.md"), file_a);
1162
1163 let mut file_c = FileIndex::new();
1164 file_c.add_cross_file_link(CrossFileLinkIndex {
1165 target_path: "b.md".to_string(),
1166 fragment: "".to_string(),
1167 line: 1,
1168 column: 1,
1169 });
1170 index.update_file(Path::new("docs/c.md"), file_c);
1171
1172 let dependents = index.get_dependents(Path::new("docs/b.md"));
1174 assert_eq!(dependents.len(), 2);
1175 assert!(dependents.contains(&PathBuf::from("docs/sub/a.md")));
1176 assert!(dependents.contains(&PathBuf::from("docs/c.md")));
1177 }
1178
1179 #[test]
1180 fn test_reverse_deps_update_clears_old() {
1181 let mut index = WorkspaceIndex::new();
1182
1183 let mut file_a = FileIndex::new();
1185 file_a.add_cross_file_link(CrossFileLinkIndex {
1186 target_path: "b.md".to_string(),
1187 fragment: "".to_string(),
1188 line: 1,
1189 column: 1,
1190 });
1191 index.update_file(Path::new("docs/a.md"), file_a);
1192
1193 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1195
1196 let mut file_a_updated = FileIndex::new();
1198 file_a_updated.add_cross_file_link(CrossFileLinkIndex {
1199 target_path: "c.md".to_string(),
1200 fragment: "".to_string(),
1201 line: 1,
1202 column: 1,
1203 });
1204 index.update_file(Path::new("docs/a.md"), file_a_updated);
1205
1206 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1208
1209 let c_deps = index.get_dependents(Path::new("docs/c.md"));
1211 assert_eq!(c_deps.len(), 1);
1212 assert_eq!(c_deps[0], PathBuf::from("docs/a.md"));
1213 }
1214
1215 #[test]
1216 fn test_reverse_deps_remove_file() {
1217 let mut index = WorkspaceIndex::new();
1218
1219 let mut file_a = FileIndex::new();
1221 file_a.add_cross_file_link(CrossFileLinkIndex {
1222 target_path: "b.md".to_string(),
1223 fragment: "".to_string(),
1224 line: 1,
1225 column: 1,
1226 });
1227 index.update_file(Path::new("docs/a.md"), file_a);
1228
1229 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1231
1232 index.remove_file(Path::new("docs/a.md"));
1234
1235 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1237 }
1238
1239 #[test]
1240 fn test_normalize_path() {
1241 let path = Path::new("docs/sub/../other.md");
1243 let normalized = WorkspaceIndex::normalize_path(path);
1244 assert_eq!(normalized, PathBuf::from("docs/other.md"));
1245
1246 let path2 = Path::new("docs/./other.md");
1248 let normalized2 = WorkspaceIndex::normalize_path(path2);
1249 assert_eq!(normalized2, PathBuf::from("docs/other.md"));
1250
1251 let path3 = Path::new("a/b/c/../../d.md");
1253 let normalized3 = WorkspaceIndex::normalize_path(path3);
1254 assert_eq!(normalized3, PathBuf::from("a/d.md"));
1255 }
1256
1257 #[test]
1258 fn test_clear_clears_reverse_deps() {
1259 let mut index = WorkspaceIndex::new();
1260
1261 let mut file_a = FileIndex::new();
1263 file_a.add_cross_file_link(CrossFileLinkIndex {
1264 target_path: "b.md".to_string(),
1265 fragment: "".to_string(),
1266 line: 1,
1267 column: 1,
1268 });
1269 index.update_file(Path::new("docs/a.md"), file_a);
1270
1271 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1273
1274 index.clear();
1276
1277 assert_eq!(index.file_count(), 0);
1279 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1280 }
1281
1282 #[test]
1283 fn test_is_file_stale() {
1284 let mut index = WorkspaceIndex::new();
1285
1286 assert!(index.is_file_stale(Path::new("nonexistent.md"), "hash123"));
1288
1289 let file_index = FileIndex::with_hash("hash123".to_string());
1291 index.insert_file(PathBuf::from("docs/test.md"), file_index);
1292
1293 assert!(!index.is_file_stale(Path::new("docs/test.md"), "hash123"));
1295
1296 assert!(index.is_file_stale(Path::new("docs/test.md"), "different_hash"));
1298 }
1299
1300 #[cfg(feature = "native")]
1301 #[test]
1302 fn test_cache_roundtrip() {
1303 use std::fs;
1304
1305 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_roundtrip");
1307 let _ = fs::remove_dir_all(&temp_dir);
1308 fs::create_dir_all(&temp_dir).unwrap();
1309
1310 let mut index = WorkspaceIndex::new();
1312
1313 let mut file1 = FileIndex::with_hash("abc123".to_string());
1314 file1.add_heading(HeadingIndex {
1315 text: "Test Heading".to_string(),
1316 auto_anchor: "test-heading".to_string(),
1317 custom_anchor: Some("test".to_string()),
1318 line: 1,
1319 is_setext: false,
1320 });
1321 file1.add_cross_file_link(CrossFileLinkIndex {
1322 target_path: "./other.md".to_string(),
1323 fragment: "section".to_string(),
1324 line: 5,
1325 column: 3,
1326 });
1327 index.update_file(Path::new("docs/file1.md"), file1);
1328
1329 let mut file2 = FileIndex::with_hash("def456".to_string());
1330 file2.add_heading(HeadingIndex {
1331 text: "Another Heading".to_string(),
1332 auto_anchor: "another-heading".to_string(),
1333 custom_anchor: None,
1334 line: 1,
1335 is_setext: false,
1336 });
1337 index.update_file(Path::new("docs/other.md"), file2);
1338
1339 index.save_to_cache(&temp_dir).expect("Failed to save cache");
1341
1342 assert!(temp_dir.join("workspace_index.bin").exists());
1344
1345 let loaded = WorkspaceIndex::load_from_cache(&temp_dir).expect("Failed to load cache");
1347
1348 assert_eq!(loaded.file_count(), 2);
1350 assert!(loaded.contains_file(Path::new("docs/file1.md")));
1351 assert!(loaded.contains_file(Path::new("docs/other.md")));
1352
1353 let file1_loaded = loaded.get_file(Path::new("docs/file1.md")).unwrap();
1355 assert_eq!(file1_loaded.content_hash, "abc123");
1356 assert_eq!(file1_loaded.headings.len(), 1);
1357 assert_eq!(file1_loaded.headings[0].text, "Test Heading");
1358 assert_eq!(file1_loaded.headings[0].custom_anchor, Some("test".to_string()));
1359 assert_eq!(file1_loaded.cross_file_links.len(), 1);
1360 assert_eq!(file1_loaded.cross_file_links[0].target_path, "./other.md");
1361
1362 let dependents = loaded.get_dependents(Path::new("docs/other.md"));
1364 assert_eq!(dependents.len(), 1);
1365 assert_eq!(dependents[0], PathBuf::from("docs/file1.md"));
1366
1367 let _ = fs::remove_dir_all(&temp_dir);
1369 }
1370
1371 #[cfg(feature = "native")]
1372 #[test]
1373 fn test_cache_missing_file() {
1374 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_missing");
1375 let _ = std::fs::remove_dir_all(&temp_dir);
1376
1377 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1379 assert!(result.is_none());
1380 }
1381
1382 #[cfg(feature = "native")]
1383 #[test]
1384 fn test_cache_corrupted_file() {
1385 use std::fs;
1386
1387 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_corrupted");
1388 let _ = fs::remove_dir_all(&temp_dir);
1389 fs::create_dir_all(&temp_dir).unwrap();
1390
1391 fs::write(temp_dir.join("workspace_index.bin"), b"bad").unwrap();
1393
1394 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1396 assert!(result.is_none());
1397
1398 assert!(!temp_dir.join("workspace_index.bin").exists());
1400
1401 let _ = fs::remove_dir_all(&temp_dir);
1403 }
1404
1405 #[cfg(feature = "native")]
1406 #[test]
1407 fn test_cache_invalid_magic() {
1408 use std::fs;
1409
1410 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_invalid_magic");
1411 let _ = fs::remove_dir_all(&temp_dir);
1412 fs::create_dir_all(&temp_dir).unwrap();
1413
1414 let mut data = Vec::new();
1416 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();
1420
1421 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1423 assert!(result.is_none());
1424
1425 assert!(!temp_dir.join("workspace_index.bin").exists());
1427
1428 let _ = fs::remove_dir_all(&temp_dir);
1430 }
1431
1432 #[cfg(feature = "native")]
1433 #[test]
1434 fn test_cache_version_mismatch() {
1435 use std::fs;
1436
1437 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_version_mismatch");
1438 let _ = fs::remove_dir_all(&temp_dir);
1439 fs::create_dir_all(&temp_dir).unwrap();
1440
1441 let mut data = Vec::new();
1443 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();
1447
1448 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1450 assert!(result.is_none());
1451
1452 assert!(!temp_dir.join("workspace_index.bin").exists());
1454
1455 let _ = fs::remove_dir_all(&temp_dir);
1457 }
1458
1459 #[cfg(feature = "native")]
1460 #[test]
1461 fn test_cache_atomic_write() {
1462 use std::fs;
1463
1464 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_atomic");
1466 let _ = fs::remove_dir_all(&temp_dir);
1467 fs::create_dir_all(&temp_dir).unwrap();
1468
1469 let index = WorkspaceIndex::new();
1470 index.save_to_cache(&temp_dir).expect("Failed to save");
1471
1472 let entries: Vec<_> = fs::read_dir(&temp_dir).unwrap().collect();
1474 assert_eq!(entries.len(), 1);
1475 assert!(temp_dir.join("workspace_index.bin").exists());
1476
1477 let _ = fs::remove_dir_all(&temp_dir);
1479 }
1480
1481 #[test]
1482 fn test_has_anchor_auto_generated() {
1483 let mut file_index = FileIndex::new();
1484 file_index.add_heading(HeadingIndex {
1485 text: "Installation Guide".to_string(),
1486 auto_anchor: "installation-guide".to_string(),
1487 custom_anchor: None,
1488 line: 1,
1489 is_setext: false,
1490 });
1491
1492 assert!(file_index.has_anchor("installation-guide"));
1494
1495 assert!(file_index.has_anchor("Installation-Guide"));
1497 assert!(file_index.has_anchor("INSTALLATION-GUIDE"));
1498
1499 assert!(!file_index.has_anchor("nonexistent"));
1501 }
1502
1503 #[test]
1504 fn test_has_anchor_custom() {
1505 let mut file_index = FileIndex::new();
1506 file_index.add_heading(HeadingIndex {
1507 text: "Installation Guide".to_string(),
1508 auto_anchor: "installation-guide".to_string(),
1509 custom_anchor: Some("install".to_string()),
1510 line: 1,
1511 is_setext: false,
1512 });
1513
1514 assert!(file_index.has_anchor("installation-guide"));
1516
1517 assert!(file_index.has_anchor("install"));
1519 assert!(file_index.has_anchor("Install")); assert!(!file_index.has_anchor("nonexistent"));
1523 }
1524
1525 #[test]
1526 fn test_get_heading_by_anchor() {
1527 let mut file_index = FileIndex::new();
1528 file_index.add_heading(HeadingIndex {
1529 text: "Installation Guide".to_string(),
1530 auto_anchor: "installation-guide".to_string(),
1531 custom_anchor: Some("install".to_string()),
1532 line: 10,
1533 is_setext: false,
1534 });
1535 file_index.add_heading(HeadingIndex {
1536 text: "Configuration".to_string(),
1537 auto_anchor: "configuration".to_string(),
1538 custom_anchor: None,
1539 line: 20,
1540 is_setext: false,
1541 });
1542
1543 let heading = file_index.get_heading_by_anchor("installation-guide");
1545 assert!(heading.is_some());
1546 assert_eq!(heading.unwrap().text, "Installation Guide");
1547 assert_eq!(heading.unwrap().line, 10);
1548
1549 let heading = file_index.get_heading_by_anchor("install");
1551 assert!(heading.is_some());
1552 assert_eq!(heading.unwrap().text, "Installation Guide");
1553
1554 let heading = file_index.get_heading_by_anchor("configuration");
1556 assert!(heading.is_some());
1557 assert_eq!(heading.unwrap().text, "Configuration");
1558 assert_eq!(heading.unwrap().line, 20);
1559
1560 assert!(file_index.get_heading_by_anchor("nonexistent").is_none());
1562 }
1563
1564 #[test]
1565 fn test_anchor_lookup_many_headings() {
1566 let mut file_index = FileIndex::new();
1568
1569 for i in 0..100 {
1571 file_index.add_heading(HeadingIndex {
1572 text: format!("Heading {i}"),
1573 auto_anchor: format!("heading-{i}"),
1574 custom_anchor: Some(format!("h{i}")),
1575 line: i + 1,
1576 is_setext: false,
1577 });
1578 }
1579
1580 for i in 0..100 {
1582 assert!(file_index.has_anchor(&format!("heading-{i}")));
1583 assert!(file_index.has_anchor(&format!("h{i}")));
1584
1585 let heading = file_index.get_heading_by_anchor(&format!("heading-{i}"));
1586 assert!(heading.is_some());
1587 assert_eq!(heading.unwrap().line, i + 1);
1588 }
1589 }
1590
1591 #[test]
1596 fn test_extract_cross_file_links_basic() {
1597 use crate::config::MarkdownFlavor;
1598
1599 let content = "# Test\n\nSee [link](./other.md) for info.\n";
1600 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1601 let links = extract_cross_file_links(&ctx).relative;
1602
1603 assert_eq!(links.len(), 1);
1604 assert_eq!(links[0].target_path, "./other.md");
1605 assert_eq!(links[0].fragment, "");
1606 assert_eq!(links[0].line, 3);
1607 assert_eq!(links[0].column, 12);
1609 }
1610
1611 #[test]
1612 fn test_extract_cross_file_links_with_fragment() {
1613 use crate::config::MarkdownFlavor;
1614
1615 let content = "Check [guide](./guide.md#install) here.\n";
1616 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1617 let links = extract_cross_file_links(&ctx).relative;
1618
1619 assert_eq!(links.len(), 1);
1620 assert_eq!(links[0].target_path, "./guide.md");
1621 assert_eq!(links[0].fragment, "install");
1622 assert_eq!(links[0].line, 1);
1623 assert_eq!(links[0].column, 15);
1625 }
1626
1627 #[test]
1628 fn test_extract_cross_file_links_multiple_on_same_line() {
1629 use crate::config::MarkdownFlavor;
1630
1631 let content = "See [a](a.md) and [b](b.md) here.\n";
1632 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1633 let links = extract_cross_file_links(&ctx).relative;
1634
1635 assert_eq!(links.len(), 2);
1636
1637 assert_eq!(links[0].target_path, "a.md");
1638 assert_eq!(links[0].line, 1);
1639 assert_eq!(links[0].column, 9);
1641
1642 assert_eq!(links[1].target_path, "b.md");
1643 assert_eq!(links[1].line, 1);
1644 assert_eq!(links[1].column, 23);
1646 }
1647
1648 #[test]
1649 fn test_extract_cross_file_links_angle_brackets() {
1650 use crate::config::MarkdownFlavor;
1651
1652 let content = "See [link](<path/with (parens).md>) here.\n";
1653 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1654 let links = extract_cross_file_links(&ctx).relative;
1655
1656 assert_eq!(links.len(), 1);
1657 assert_eq!(links[0].target_path, "path/with (parens).md");
1658 assert_eq!(links[0].line, 1);
1659 assert_eq!(links[0].column, 13);
1661 }
1662
1663 #[test]
1664 fn test_extract_cross_file_links_skips_external() {
1665 use crate::config::MarkdownFlavor;
1666
1667 let content = r#"
1668[external](https://example.com)
1669[mailto](mailto:test@example.com)
1670[local](./local.md)
1671[fragment](#section)
1672[absolute](/docs/page.md)
1673"#;
1674 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1675 let extracted = extract_cross_file_links(&ctx);
1676
1677 assert_eq!(extracted.relative.len(), 1);
1679 assert_eq!(extracted.relative[0].target_path, "./local.md");
1680 assert_eq!(extracted.root_relative.len(), 1);
1682 assert_eq!(extracted.root_relative[0].target_path, "docs/page.md");
1683 }
1684
1685 #[test]
1686 fn test_extract_cross_file_links_root_relative() {
1687 use crate::config::MarkdownFlavor;
1688
1689 let content = "[a](/guide.md#install)\n[b](/../escape.md)\n[c](//host/x.md)\n[d](/img/pic.png)\n";
1693 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1694 let extracted = extract_cross_file_links(&ctx);
1695
1696 assert!(extracted.relative.is_empty(), "no directory-relative links here");
1697 assert_eq!(
1698 extracted
1699 .root_relative
1700 .iter()
1701 .map(|l| (l.target_path.as_str(), l.fragment.as_str()))
1702 .collect::<Vec<_>>(),
1703 vec![("guide.md", "install")],
1704 "only the safe root-relative markdown link is captured"
1705 );
1706 }
1707
1708 #[test]
1709 fn test_extract_cross_file_links_skips_non_markdown() {
1710 use crate::config::MarkdownFlavor;
1711
1712 let content = r#"
1713[image](./photo.png)
1714[doc](./readme.md)
1715[pdf](./document.pdf)
1716"#;
1717 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1718 let links = extract_cross_file_links(&ctx).relative;
1719
1720 assert_eq!(links.len(), 1);
1722 assert_eq!(links[0].target_path, "./readme.md");
1723 }
1724
1725 #[test]
1726 fn test_extract_cross_file_links_skips_code_spans() {
1727 use crate::config::MarkdownFlavor;
1728
1729 let content = "Normal [link](./file.md) and `[code](./ignored.md)` here.\n";
1730 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1731 let links = extract_cross_file_links(&ctx).relative;
1732
1733 assert_eq!(links.len(), 1);
1735 assert_eq!(links[0].target_path, "./file.md");
1736 }
1737
1738 #[test]
1739 fn test_extract_cross_file_links_with_query_params() {
1740 use crate::config::MarkdownFlavor;
1741
1742 let content = "See [doc](./file.md?raw=true) here.\n";
1743 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1744 let links = extract_cross_file_links(&ctx).relative;
1745
1746 assert_eq!(links.len(), 1);
1747 assert_eq!(links[0].target_path, "./file.md");
1749 }
1750
1751 #[test]
1752 fn test_extract_cross_file_links_empty_content() {
1753 use crate::config::MarkdownFlavor;
1754
1755 let content = "";
1756 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1757 let links = extract_cross_file_links(&ctx).relative;
1758
1759 assert!(links.is_empty());
1760 }
1761
1762 #[test]
1763 fn test_extract_cross_file_links_no_links() {
1764 use crate::config::MarkdownFlavor;
1765
1766 let content = "# Just a heading\n\nSome text without links.\n";
1767 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1768 let links = extract_cross_file_links(&ctx).relative;
1769
1770 assert!(links.is_empty());
1771 }
1772
1773 #[test]
1774 fn test_extract_cross_file_links_position_accuracy_issue_234() {
1775 use crate::config::MarkdownFlavor;
1778
1779 let content = r#"# Test Document
1780
1781Here is a [broken link](nonexistent-file.md) that should trigger MD057.
1782
1783And another [link](also-missing.md) on this line.
1784"#;
1785 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1786 let links = extract_cross_file_links(&ctx).relative;
1787
1788 assert_eq!(links.len(), 2);
1789
1790 assert_eq!(links[0].target_path, "nonexistent-file.md");
1792 assert_eq!(links[0].line, 3);
1793 assert_eq!(links[0].column, 25);
1794
1795 assert_eq!(links[1].target_path, "also-missing.md");
1797 assert_eq!(links[1].line, 5);
1798 assert_eq!(links[1].column, 20);
1799 }
1800}