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
121pub fn link_target_file(source_dir: &Path, target_path: &str) -> PathBuf {
131 normalize_relative_path(&source_dir.join(strip_query_and_fragment(target_path)))
132}
133
134#[derive(Debug, Default)]
141pub struct ExtractedCrossFileLinks {
142 pub relative: Vec<CrossFileLinkIndex>,
144 pub root_relative: Vec<CrossFileLinkIndex>,
148}
149
150pub fn extract_cross_file_links(ctx: &LintContext) -> ExtractedCrossFileLinks {
158 let content = ctx.content;
159
160 if content.is_empty() || !content.contains("](") {
162 return ExtractedCrossFileLinks::default();
163 }
164
165 let mut links = ExtractedCrossFileLinks::default();
166 let lines: Vec<&str> = content.lines().collect();
167 let line_index = &ctx.line_index;
168
169 let mut processed_lines = HashSet::new();
172
173 for link in &ctx.links {
174 let line_idx = link.line - 1;
175 if line_idx >= lines.len() {
176 continue;
177 }
178
179 if !processed_lines.insert(line_idx) {
181 continue;
182 }
183
184 let line = lines[line_idx];
185 if !line.contains("](") {
186 continue;
187 }
188
189 for link_match in LINK_START_REGEX.find_iter(line) {
191 let start_pos = link_match.start();
192 let end_pos = link_match.end();
193
194 let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
196 let absolute_start_pos = line_start_byte + start_pos;
197
198 if ctx.is_in_code_span_byte(absolute_start_pos) {
200 continue;
201 }
202
203 let caps_result = URL_EXTRACT_ANGLE_BRACKET_REGEX
206 .captures_at(line, end_pos - 1)
207 .or_else(|| URL_EXTRACT_REGEX.captures_at(line, end_pos - 1));
208
209 if let Some(caps) = caps_result
210 && let Some(url_group) = caps.get(1)
211 {
212 let file_path = url_group.as_str().trim();
213
214 if let Some(rel) = file_path.strip_prefix('/') {
219 if !rel.starts_with('/')
220 && !Path::new(rel)
221 .components()
222 .any(|c| matches!(c, std::path::Component::ParentDir))
223 {
224 let stripped = strip_query_and_fragment(rel);
225 if is_markdown_file(stripped) {
226 let fragment = caps.get(2).map_or("", |m| m.as_str().trim_start_matches('#'));
227 links.root_relative.push(CrossFileLinkIndex {
228 target_path: stripped.to_string(),
229 fragment: fragment.to_string(),
230 line: link.line,
231 column: byte_to_char_count(line, url_group.start()),
232 origin: LinkOrigin::Body,
233 });
234 }
235 }
236 continue;
237 }
238
239 if file_path.is_empty()
242 || PROTOCOL_DOMAIN_REGEX.is_match(file_path)
243 || file_path.starts_with("www.")
244 || file_path.starts_with('#')
245 || file_path.starts_with("{{")
246 || file_path.starts_with("{%")
247 || file_path.starts_with('~')
248 || file_path.starts_with('@')
249 || (file_path.starts_with('`') && file_path.ends_with('`'))
250 {
251 continue;
252 }
253
254 let file_path = strip_query_and_fragment(file_path);
256
257 let fragment = caps.get(2).map_or("", |m| m.as_str().trim_start_matches('#'));
259
260 if is_markdown_file(file_path) {
262 links.relative.push(CrossFileLinkIndex {
263 target_path: file_path.to_string(),
264 fragment: fragment.to_string(),
265 line: link.line,
266 column: byte_to_char_count(line, url_group.start()),
267 origin: LinkOrigin::Body,
268 });
269 }
270 }
271 }
272 }
273
274 links
275}
276
277#[cfg(feature = "postcard")]
279const CACHE_MAGIC: &[u8; 4] = b"RWSI";
280
281#[cfg(feature = "postcard")]
299const CACHE_FORMAT_VERSION: u32 = 11;
300
301#[cfg(feature = "postcard")]
303const CACHE_FILE_NAME: &str = "workspace_index.bin";
304
305#[cfg(feature = "postcard")]
309static CACHE_TEMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
310
311#[derive(Debug, Default, Clone, Serialize, Deserialize)]
316pub struct WorkspaceIndex {
317 files: HashMap<PathBuf, FileIndex>,
319 reverse_deps: HashMap<PathBuf, HashSet<PathBuf>>,
322 version: u64,
324}
325
326#[derive(Debug, Clone, Default, Serialize, Deserialize)]
328pub struct FileIndex {
329 pub headings: Vec<HeadingIndex>,
331 pub reference_links: Vec<ReferenceLinkIndex>,
333 pub cross_file_links: Vec<CrossFileLinkIndex>,
335 #[serde(default)]
340 pub root_relative_links: Vec<CrossFileLinkIndex>,
341 pub defined_references: HashSet<String>,
344 pub content_hash: String,
346 anchor_to_heading: HashMap<String, usize>,
349 #[serde(default)]
353 anchor_to_heading_exact: HashMap<String, usize>,
354 html_anchors: HashSet<String>,
357 #[serde(default)]
360 html_anchors_exact: HashSet<String>,
361 attribute_anchors: HashSet<String>,
365 #[serde(default)]
368 attribute_anchors_exact: HashSet<String>,
369 pub file_disabled_rules: HashSet<String>,
372 pub persistent_transitions: Vec<(usize, HashSet<String>, HashSet<String>)>,
375 pub line_disabled_rules: HashMap<usize, HashSet<String>>,
377}
378
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
381pub struct HeadingIndex {
382 pub text: String,
384 pub auto_anchor: String,
386 pub custom_anchor: Option<String>,
388 pub line: usize,
390 #[serde(default)]
392 pub is_setext: bool,
393}
394
395#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
397pub struct ReferenceLinkIndex {
398 pub reference_id: String,
400 pub line: usize,
402 pub column: usize,
404}
405
406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
414pub enum LinkOrigin {
415 Body,
417 FrontMatter { field: Option<String> },
426}
427
428#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
430pub struct CrossFileLinkIndex {
431 pub target_path: String,
433 pub fragment: String,
435 pub line: usize,
437 pub column: usize,
439 pub origin: LinkOrigin,
441}
442
443pub fn link_target_candidates(source_file: &Path, target_path: &str) -> Vec<PathBuf> {
454 let target_path = strip_query_and_fragment(target_path);
455
456 let joined = match source_file.parent() {
457 Some(parent) => parent.join(target_path),
458 None => PathBuf::from(target_path),
459 };
460 let base = normalize_relative_path(&joined);
461
462 if base.extension().is_some() {
463 return vec![base];
464 }
465
466 let mut candidates = Vec::with_capacity(crate::discovery::MARKDOWN_EXTENSIONS.len() + 1);
469 for ext in crate::discovery::MARKDOWN_EXTENSIONS {
470 candidates.push(base.with_extension(ext));
471 }
472 candidates.insert(0, base);
473 candidates
474}
475
476pub fn normalize_relative_path(path: &Path) -> PathBuf {
487 let mut components: Vec<std::path::Component<'_>> = Vec::new();
488 for component in path.components() {
489 match component {
490 std::path::Component::CurDir => {}
491 std::path::Component::ParentDir => match components.last() {
492 Some(std::path::Component::Normal(_)) => {
493 components.pop();
494 }
495 Some(std::path::Component::RootDir) => {}
496 _ => components.push(component),
497 },
498 c => components.push(c),
499 }
500 }
501 components.iter().collect()
502}
503
504impl CrossFileLinkIndex {
505 pub fn is_navigable(&self) -> bool {
513 matches!(self.origin, LinkOrigin::Body)
514 }
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize)]
519pub struct VulnerableAnchor {
520 pub file: PathBuf,
522 pub line: usize,
524 pub text: String,
526}
527
528impl WorkspaceIndex {
529 pub fn new() -> Self {
531 Self::default()
532 }
533
534 pub fn version(&self) -> u64 {
536 self.version
537 }
538
539 pub fn file_count(&self) -> usize {
541 self.files.len()
542 }
543
544 pub fn contains_file(&self, path: &Path) -> bool {
546 self.files.contains_key(path)
547 }
548
549 pub fn get_file(&self, path: &Path) -> Option<&FileIndex> {
551 self.files.get(path)
552 }
553
554 pub fn insert_file(&mut self, path: PathBuf, index: FileIndex) {
556 self.files.insert(path, index);
557 self.version = self.version.wrapping_add(1);
558 }
559
560 pub fn remove_file(&mut self, path: &Path) -> Option<FileIndex> {
562 self.clear_reverse_deps_for(path);
564
565 let result = self.files.remove(path);
566 if result.is_some() {
567 self.version = self.version.wrapping_add(1);
568 }
569 result
570 }
571
572 pub fn get_vulnerable_anchors(&self) -> HashMap<String, Vec<VulnerableAnchor>> {
582 let mut vulnerable: HashMap<String, Vec<VulnerableAnchor>> = HashMap::new();
583
584 for (file_path, file_index) in &self.files {
585 for heading in &file_index.headings {
586 if heading.custom_anchor.is_none() && !heading.auto_anchor.is_empty() {
588 let anchor_key = heading.auto_anchor.to_lowercase();
589 vulnerable.entry(anchor_key).or_default().push(VulnerableAnchor {
590 file: file_path.clone(),
591 line: heading.line,
592 text: heading.text.clone(),
593 });
594 }
595 }
596 }
597
598 vulnerable
599 }
600
601 pub fn all_headings(&self) -> impl Iterator<Item = (&Path, &HeadingIndex)> {
603 self.files
604 .iter()
605 .flat_map(|(path, index)| index.headings.iter().map(move |h| (path.as_path(), h)))
606 }
607
608 pub fn files(&self) -> impl Iterator<Item = (&Path, &FileIndex)> {
610 self.files.iter().map(|(p, i)| (p.as_path(), i))
611 }
612
613 pub fn files_sorted(&self) -> Vec<(&Path, &FileIndex)> {
619 let mut entries: Vec<(&Path, &FileIndex)> = self.files.iter().map(|(p, i)| (p.as_path(), i)).collect();
620 entries.sort_by_key(|(a, _)| *a);
621 entries
622 }
623
624 pub fn clear(&mut self) {
626 self.files.clear();
627 self.reverse_deps.clear();
628 self.version = self.version.wrapping_add(1);
629 }
630
631 pub fn update_file(&mut self, path: &Path, index: FileIndex) {
638 self.clear_reverse_deps_as_source(path);
641
642 for link in &index.cross_file_links {
644 let target = self.resolve_target_path(path, &link.target_path);
645 self.reverse_deps.entry(target).or_default().insert(path.to_path_buf());
646 }
647
648 self.files.insert(path.to_path_buf(), index);
649 self.version = self.version.wrapping_add(1);
650 }
651
652 pub fn get_dependents(&self, path: &Path) -> Vec<PathBuf> {
657 self.reverse_deps
658 .get(path)
659 .map(|set| set.iter().cloned().collect())
660 .unwrap_or_default()
661 }
662
663 pub fn is_file_stale(&self, path: &Path, current_hash: &str) -> bool {
667 self.files.get(path).is_none_or(|f| f.content_hash != current_hash)
668 }
669
670 pub fn retain_only(&mut self, current_files: &std::collections::HashSet<PathBuf>) -> usize {
675 let before_count = self.files.len();
676
677 let to_remove: Vec<PathBuf> = self
679 .files
680 .keys()
681 .filter(|path| !current_files.contains(*path))
682 .cloned()
683 .collect();
684
685 for path in &to_remove {
687 self.remove_file(path);
688 }
689
690 before_count - self.files.len()
691 }
692
693 #[cfg(feature = "postcard")]
700 pub fn save_to_cache(&self, cache_dir: &Path) -> std::io::Result<()> {
701 use std::fs;
702 use std::io::Write;
703
704 fs::create_dir_all(cache_dir)?;
706
707 let encoded = postcard::to_allocvec(self)
709 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
710
711 let mut cache_data = Vec::with_capacity(8 + encoded.len());
713 cache_data.extend_from_slice(CACHE_MAGIC);
714 cache_data.extend_from_slice(&CACHE_FORMAT_VERSION.to_le_bytes());
715 cache_data.extend_from_slice(&encoded);
716
717 let final_path = cache_dir.join(CACHE_FILE_NAME);
722 let counter = CACHE_TEMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
723 #[cfg(not(target_arch = "wasm32"))]
724 let temp_path = cache_dir.join(format!("{CACHE_FILE_NAME}.tmp.{}.{counter}", std::process::id()));
725 #[cfg(target_arch = "wasm32")]
726 let temp_path = cache_dir.join(format!("{CACHE_FILE_NAME}.tmp.{counter}"));
727
728 {
730 let mut file = fs::File::create(&temp_path)?;
731 file.write_all(&cache_data)?;
732 file.sync_all()?;
733 }
734
735 fs::rename(&temp_path, &final_path)?;
737
738 log::debug!(
739 "Saved workspace index to cache: {} files, {} bytes (format v{})",
740 self.files.len(),
741 cache_data.len(),
742 CACHE_FORMAT_VERSION
743 );
744
745 Ok(())
746 }
747
748 #[cfg(feature = "postcard")]
756 pub fn load_from_cache(cache_dir: &Path) -> Option<Self> {
757 use std::fs;
758
759 let path = cache_dir.join(CACHE_FILE_NAME);
760 let data = fs::read(&path).ok()?;
761
762 if data.len() < 8 {
764 log::warn!("Workspace index cache too small, discarding");
765 let _ = fs::remove_file(&path);
766 return None;
767 }
768
769 if &data[0..4] != CACHE_MAGIC {
771 log::warn!("Workspace index cache has invalid magic header, discarding");
772 let _ = fs::remove_file(&path);
773 return None;
774 }
775
776 let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
778 if version != CACHE_FORMAT_VERSION {
779 log::info!(
780 "Workspace index cache format version mismatch (got {version}, expected {CACHE_FORMAT_VERSION}), rebuilding"
781 );
782 let _ = fs::remove_file(&path);
783 return None;
784 }
785
786 match postcard::from_bytes::<Self>(&data[8..]) {
788 Ok(index) => {
789 log::debug!(
790 "Loaded workspace index from cache: {} files (format v{})",
791 index.files.len(),
792 version
793 );
794 Some(index)
795 }
796 Err(e) => {
797 log::warn!("Failed to deserialize workspace index cache: {e}");
798 let _ = fs::remove_file(&path);
799 None
800 }
801 }
802 }
803
804 fn clear_reverse_deps_as_source(&mut self, path: &Path) {
809 let targets: Vec<PathBuf> = match self.files.get(path) {
816 Some(index) => index
817 .cross_file_links
818 .iter()
819 .map(|link| self.resolve_target_path(path, &link.target_path))
820 .collect(),
821 None => return,
822 };
823 for target in targets {
824 if let Some(deps) = self.reverse_deps.get_mut(&target) {
825 deps.remove(path);
826 if deps.is_empty() {
827 self.reverse_deps.remove(&target);
828 }
829 }
830 }
831 }
832
833 fn clear_reverse_deps_for(&mut self, path: &Path) {
838 self.clear_reverse_deps_as_source(path);
840
841 self.reverse_deps.remove(path);
843 }
844
845 fn resolve_target_path(&self, source_file: &Path, relative_target: &str) -> PathBuf {
851 let source_dir = source_file.parent().unwrap_or(Path::new(""));
852 link_target_file(source_dir, relative_target)
853 }
854}
855
856impl FileIndex {
857 pub fn new() -> Self {
859 Self::default()
860 }
861
862 pub fn with_hash(content_hash: String) -> Self {
864 Self {
865 content_hash,
866 ..Default::default()
867 }
868 }
869
870 pub fn extracted_data_differs(&self, other: &Self) -> bool {
886 let Self {
890 headings,
891 reference_links,
892 cross_file_links,
893 root_relative_links,
894 defined_references,
895 content_hash: _,
896 anchor_to_heading,
897 anchor_to_heading_exact,
898 html_anchors,
899 html_anchors_exact,
900 attribute_anchors,
901 attribute_anchors_exact,
902 file_disabled_rules,
903 persistent_transitions,
904 line_disabled_rules,
905 } = self;
906
907 headings != &other.headings
908 || reference_links != &other.reference_links
909 || cross_file_links != &other.cross_file_links
910 || root_relative_links != &other.root_relative_links
911 || defined_references != &other.defined_references
912 || anchor_to_heading != &other.anchor_to_heading
913 || anchor_to_heading_exact != &other.anchor_to_heading_exact
914 || html_anchors != &other.html_anchors
915 || html_anchors_exact != &other.html_anchors_exact
916 || attribute_anchors != &other.attribute_anchors
917 || attribute_anchors_exact != &other.attribute_anchors_exact
918 || file_disabled_rules != &other.file_disabled_rules
919 || persistent_transitions != &other.persistent_transitions
920 || line_disabled_rules != &other.line_disabled_rules
921 }
922
923 pub fn add_heading(&mut self, heading: HeadingIndex) {
929 let index = self.headings.len();
930
931 self.anchor_to_heading.insert(heading.auto_anchor.to_lowercase(), index);
934 self.anchor_to_heading_exact.insert(heading.auto_anchor.clone(), index);
935
936 if let Some(ref custom) = heading.custom_anchor {
938 self.anchor_to_heading.insert(custom.to_lowercase(), index);
939 self.anchor_to_heading_exact.insert(custom.clone(), index);
940 }
941
942 self.headings.push(heading);
943 }
944
945 pub fn add_anchor_alias(&mut self, anchor: &str, heading_index: usize) {
948 if heading_index < self.headings.len() {
949 self.anchor_to_heading.insert(anchor.to_lowercase(), heading_index);
950 self.anchor_to_heading_exact.insert(anchor.to_string(), heading_index);
951 }
952 }
953
954 pub fn has_anchor(&self, anchor: &str) -> bool {
965 self.has_anchor_with_case(anchor, true)
966 }
967
968 pub fn has_anchor_with_case(&self, anchor: &str, ignore_case: bool) -> bool {
977 if self.lookup_anchor(anchor, ignore_case) {
978 return true;
979 }
980
981 if anchor.contains('%') {
983 let decoded = url_decode(anchor);
984 if decoded != anchor {
985 return self.lookup_anchor(&decoded, ignore_case);
986 }
987 }
988
989 false
990 }
991
992 fn lookup_anchor(&self, anchor: &str, ignore_case: bool) -> bool {
995 if ignore_case {
996 let lower = anchor.to_lowercase();
997 self.anchor_to_heading.contains_key(&lower)
998 || self.html_anchors.contains(&lower)
999 || self.attribute_anchors.contains(&lower)
1000 } else {
1001 self.anchor_to_heading_exact.contains_key(anchor)
1002 || self.html_anchors_exact.contains(anchor)
1003 || self.attribute_anchors_exact.contains(anchor)
1004 }
1005 }
1006
1007 pub fn add_html_anchor(&mut self, anchor: &str) {
1010 if !anchor.is_empty() {
1011 self.html_anchors.insert(anchor.to_lowercase());
1012 self.html_anchors_exact.insert(anchor.to_string());
1013 }
1014 }
1015
1016 pub fn add_attribute_anchor(&mut self, anchor: &str) {
1019 if !anchor.is_empty() {
1020 self.attribute_anchors.insert(anchor.to_lowercase());
1021 self.attribute_anchors_exact.insert(anchor.to_string());
1022 }
1023 }
1024
1025 pub fn get_heading_by_anchor(&self, anchor: &str) -> Option<&HeadingIndex> {
1029 self.anchor_to_heading
1030 .get(&anchor.to_lowercase())
1031 .and_then(|&idx| self.headings.get(idx))
1032 }
1033
1034 pub fn add_reference_link(&mut self, link: ReferenceLinkIndex) {
1036 self.reference_links.push(link);
1037 }
1038
1039 pub fn is_rule_disabled_at_line(&self, rule_name: &str, line: usize) -> bool {
1044 if self.file_disabled_rules.contains("*") || self.file_disabled_rules.contains(rule_name) {
1046 return true;
1047 }
1048
1049 if let Some(rules) = self.line_disabled_rules.get(&line)
1051 && (rules.contains("*") || rules.contains(rule_name))
1052 {
1053 return true;
1054 }
1055
1056 if !self.persistent_transitions.is_empty() {
1058 let idx = match self.persistent_transitions.binary_search_by_key(&line, |t| t.0) {
1059 Ok(i) => Some(i),
1060 Err(i) => {
1061 if i > 0 {
1062 Some(i - 1)
1063 } else {
1064 None
1065 }
1066 }
1067 };
1068 if let Some(i) = idx {
1069 let (_, ref disabled, ref enabled) = self.persistent_transitions[i];
1070 if disabled.contains("*") {
1071 return !enabled.contains(rule_name);
1072 }
1073 return disabled.contains(rule_name);
1074 }
1075 }
1076
1077 false
1078 }
1079
1080 pub fn add_cross_file_link(&mut self, link: CrossFileLinkIndex) {
1095 let existing = self.cross_file_links.iter_mut().find(|existing| {
1096 existing.fragment == link.fragment
1097 && existing.line == link.line
1098 && strip_query_and_fragment(&existing.target_path) == strip_query_and_fragment(&link.target_path)
1099 });
1100 match existing {
1101 Some(existing) => {
1104 if !existing.target_path.contains('?') && link.target_path.contains('?') {
1105 *existing = link;
1106 }
1107 }
1108 None => self.cross_file_links.push(link),
1109 }
1110 }
1111
1112 pub fn add_root_relative_link(&mut self, link: CrossFileLinkIndex) {
1114 let is_duplicate = self.root_relative_links.iter().any(|existing| {
1115 existing.target_path == link.target_path && existing.fragment == link.fragment && existing.line == link.line
1116 });
1117 if !is_duplicate {
1118 self.root_relative_links.push(link);
1119 }
1120 }
1121
1122 pub fn add_defined_reference(&mut self, ref_id: String) {
1124 self.defined_references.insert(ref_id);
1125 }
1126
1127 pub fn has_defined_reference(&self, ref_id: &str) -> bool {
1129 self.defined_references.contains(ref_id)
1130 }
1131
1132 pub fn hash_matches(&self, hash: &str) -> bool {
1134 self.content_hash == hash
1135 }
1136
1137 pub fn heading_count(&self) -> usize {
1139 self.headings.len()
1140 }
1141
1142 pub fn reference_link_count(&self) -> usize {
1144 self.reference_links.len()
1145 }
1146}
1147
1148#[cfg(test)]
1149mod tests {
1150 use super::*;
1151
1152 #[test]
1153 fn test_workspace_index_basic() {
1154 let mut index = WorkspaceIndex::new();
1155 assert_eq!(index.file_count(), 0);
1156 assert_eq!(index.version(), 0);
1157
1158 let mut file_index = FileIndex::with_hash("abc123".to_string());
1159 file_index.add_heading(HeadingIndex {
1160 text: "Installation".to_string(),
1161 auto_anchor: "installation".to_string(),
1162 custom_anchor: None,
1163 line: 1,
1164 is_setext: false,
1165 });
1166
1167 index.insert_file(PathBuf::from("docs/install.md"), file_index);
1168 assert_eq!(index.file_count(), 1);
1169 assert_eq!(index.version(), 1);
1170
1171 assert!(index.contains_file(Path::new("docs/install.md")));
1172 assert!(!index.contains_file(Path::new("docs/other.md")));
1173 }
1174
1175 #[test]
1176 fn test_vulnerable_anchors() {
1177 let mut index = WorkspaceIndex::new();
1178
1179 let mut file1 = FileIndex::new();
1181 file1.add_heading(HeadingIndex {
1182 text: "Getting Started".to_string(),
1183 auto_anchor: "getting-started".to_string(),
1184 custom_anchor: None,
1185 line: 1,
1186 is_setext: false,
1187 });
1188 index.insert_file(PathBuf::from("docs/guide.md"), file1);
1189
1190 let mut file2 = FileIndex::new();
1192 file2.add_heading(HeadingIndex {
1193 text: "Installation".to_string(),
1194 auto_anchor: "installation".to_string(),
1195 custom_anchor: Some("install".to_string()),
1196 line: 1,
1197 is_setext: false,
1198 });
1199 index.insert_file(PathBuf::from("docs/install.md"), file2);
1200
1201 let vulnerable = index.get_vulnerable_anchors();
1202 assert_eq!(vulnerable.len(), 1);
1203 assert!(vulnerable.contains_key("getting-started"));
1204 assert!(!vulnerable.contains_key("installation"));
1205
1206 let anchors = vulnerable.get("getting-started").unwrap();
1207 assert_eq!(anchors.len(), 1);
1208 assert_eq!(anchors[0].file, PathBuf::from("docs/guide.md"));
1209 assert_eq!(anchors[0].text, "Getting Started");
1210 }
1211
1212 #[test]
1213 fn test_vulnerable_anchors_multiple_files_same_anchor() {
1214 let mut index = WorkspaceIndex::new();
1217
1218 let mut file1 = FileIndex::new();
1220 file1.add_heading(HeadingIndex {
1221 text: "Installation".to_string(),
1222 auto_anchor: "installation".to_string(),
1223 custom_anchor: None,
1224 line: 1,
1225 is_setext: false,
1226 });
1227 index.insert_file(PathBuf::from("docs/en/guide.md"), file1);
1228
1229 let mut file2 = FileIndex::new();
1231 file2.add_heading(HeadingIndex {
1232 text: "Installation".to_string(),
1233 auto_anchor: "installation".to_string(),
1234 custom_anchor: None,
1235 line: 5,
1236 is_setext: false,
1237 });
1238 index.insert_file(PathBuf::from("docs/fr/guide.md"), file2);
1239
1240 let mut file3 = FileIndex::new();
1242 file3.add_heading(HeadingIndex {
1243 text: "Installation".to_string(),
1244 auto_anchor: "installation".to_string(),
1245 custom_anchor: Some("install".to_string()),
1246 line: 10,
1247 is_setext: false,
1248 });
1249 index.insert_file(PathBuf::from("docs/de/guide.md"), file3);
1250
1251 let vulnerable = index.get_vulnerable_anchors();
1252 assert_eq!(vulnerable.len(), 1); assert!(vulnerable.contains_key("installation"));
1254
1255 let anchors = vulnerable.get("installation").unwrap();
1256 assert_eq!(anchors.len(), 2, "Should collect both vulnerable anchors");
1258
1259 let files: std::collections::HashSet<_> = anchors.iter().map(|a| &a.file).collect();
1261 assert!(files.contains(&PathBuf::from("docs/en/guide.md")));
1262 assert!(files.contains(&PathBuf::from("docs/fr/guide.md")));
1263 }
1264
1265 #[test]
1266 fn test_file_index_hash() {
1267 let index = FileIndex::with_hash("hash123".to_string());
1268 assert!(index.hash_matches("hash123"));
1269 assert!(!index.hash_matches("other"));
1270 }
1271
1272 #[test]
1273 fn test_version_increment() {
1274 let mut index = WorkspaceIndex::new();
1275 assert_eq!(index.version(), 0);
1276
1277 index.insert_file(PathBuf::from("a.md"), FileIndex::new());
1278 assert_eq!(index.version(), 1);
1279
1280 index.insert_file(PathBuf::from("b.md"), FileIndex::new());
1281 assert_eq!(index.version(), 2);
1282
1283 index.remove_file(Path::new("a.md"));
1284 assert_eq!(index.version(), 3);
1285
1286 index.remove_file(Path::new("nonexistent.md"));
1288 assert_eq!(index.version(), 3);
1289 }
1290
1291 #[test]
1292 fn test_files_sorted_is_path_ordered() {
1293 let mut index = WorkspaceIndex::new();
1294 for name in ["docs/zebra.md", "docs/apple.md", "docs/mango.md"] {
1296 index.update_file(Path::new(name), FileIndex::new());
1297 }
1298
1299 let paths: Vec<&Path> = index.files_sorted().into_iter().map(|(p, _)| p).collect();
1300 assert_eq!(
1301 paths,
1302 vec![
1303 Path::new("docs/apple.md"),
1304 Path::new("docs/mango.md"),
1305 Path::new("docs/zebra.md"),
1306 ],
1307 "files_sorted() must return entries ordered by path"
1308 );
1309 }
1310
1311 #[test]
1316 fn test_add_cross_file_link_keeps_the_destination_as_written() {
1317 let as_written = CrossFileLinkIndex {
1318 target_path: "other.md?raw=true".to_string(),
1319 fragment: "missing".to_string(),
1320 line: 3,
1321 column: 1,
1322 origin: LinkOrigin::Body,
1323 };
1324 let file_named = CrossFileLinkIndex {
1325 target_path: "other.md".to_string(),
1326 fragment: "missing".to_string(),
1327 line: 3,
1328 column: 9,
1329 origin: LinkOrigin::Body,
1330 };
1331
1332 for (first, second) in [
1333 (as_written.clone(), file_named.clone()),
1334 (file_named.clone(), as_written.clone()),
1335 ] {
1336 let mut index = FileIndex::new();
1337 index.add_cross_file_link(first);
1338 index.add_cross_file_link(second);
1339
1340 assert_eq!(
1341 index.cross_file_links.len(),
1342 1,
1343 "one link is one entry, got: {:?}",
1344 index.cross_file_links
1345 );
1346 assert_eq!(index.cross_file_links[0].target_path, "other.md?raw=true");
1347 }
1348 }
1349
1350 #[test]
1353 fn test_add_cross_file_link_keeps_distinct_targets() {
1354 let mut index = FileIndex::new();
1355 for target in ["one.md", "two.md"] {
1356 index.add_cross_file_link(CrossFileLinkIndex {
1357 target_path: target.to_string(),
1358 fragment: "missing".to_string(),
1359 line: 3,
1360 column: 1,
1361 origin: LinkOrigin::Body,
1362 });
1363 }
1364 assert_eq!(index.cross_file_links.len(), 2);
1365 }
1366
1367 #[test]
1375 fn test_add_cross_file_link_collapses_one_line_asking_one_file_once() {
1376 let link = |target: &str, fragment: &str, line: usize| CrossFileLinkIndex {
1377 target_path: target.to_string(),
1378 fragment: fragment.to_string(),
1379 line,
1380 column: 1,
1381 origin: LinkOrigin::Body,
1382 };
1383
1384 let mut index = FileIndex::new();
1385 index.add_cross_file_link(link("target.md?raw=true", "missing", 3));
1386 index.add_cross_file_link(link("target.md?plain=1", "missing", 3));
1387 assert_eq!(
1388 index.cross_file_links.len(),
1389 1,
1390 "one file, one fragment, one line is one entry, got: {:?}",
1391 index.cross_file_links
1392 );
1393
1394 index.add_cross_file_link(link("target.md", "other", 3));
1395 index.add_cross_file_link(link("target.md", "missing", 4));
1396 assert_eq!(index.cross_file_links.len(), 3);
1397 }
1398
1399 #[test]
1405 fn test_reverse_deps_ignore_a_query_string_on_the_destination() {
1406 let mut index = WorkspaceIndex::new();
1407
1408 let mut file_a = FileIndex::new();
1409 file_a.add_cross_file_link(CrossFileLinkIndex {
1410 target_path: "b.md?raw=true".to_string(),
1411 fragment: "section".to_string(),
1412 line: 10,
1413 column: 5,
1414 origin: LinkOrigin::Body,
1415 });
1416 index.update_file(Path::new("docs/a.md"), file_a);
1417
1418 assert_eq!(
1419 index.get_dependents(Path::new("docs/b.md")),
1420 vec![PathBuf::from("docs/a.md")],
1421 "editing docs/b.md must re-lint the file linking to it"
1422 );
1423
1424 index.update_file(Path::new("docs/a.md"), FileIndex::new());
1427 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1428 }
1429
1430 #[test]
1431 fn test_reverse_deps_basic() {
1432 let mut index = WorkspaceIndex::new();
1433
1434 let mut file_a = FileIndex::new();
1436 file_a.add_cross_file_link(CrossFileLinkIndex {
1437 target_path: "b.md".to_string(),
1438 fragment: "section".to_string(),
1439 line: 10,
1440 column: 5,
1441 origin: LinkOrigin::Body,
1442 });
1443 index.update_file(Path::new("docs/a.md"), file_a);
1444
1445 let dependents = index.get_dependents(Path::new("docs/b.md"));
1447 assert_eq!(dependents.len(), 1);
1448 assert_eq!(dependents[0], PathBuf::from("docs/a.md"));
1449
1450 let a_dependents = index.get_dependents(Path::new("docs/a.md"));
1452 assert!(a_dependents.is_empty());
1453 }
1454
1455 #[test]
1456 fn test_reverse_deps_multiple() {
1457 let mut index = WorkspaceIndex::new();
1458
1459 let mut file_a = FileIndex::new();
1461 file_a.add_cross_file_link(CrossFileLinkIndex {
1462 target_path: "../b.md".to_string(),
1463 fragment: "".to_string(),
1464 line: 1,
1465 column: 1,
1466 origin: LinkOrigin::Body,
1467 });
1468 index.update_file(Path::new("docs/sub/a.md"), file_a);
1469
1470 let mut file_c = FileIndex::new();
1471 file_c.add_cross_file_link(CrossFileLinkIndex {
1472 target_path: "b.md".to_string(),
1473 fragment: "".to_string(),
1474 line: 1,
1475 column: 1,
1476 origin: LinkOrigin::Body,
1477 });
1478 index.update_file(Path::new("docs/c.md"), file_c);
1479
1480 let dependents = index.get_dependents(Path::new("docs/b.md"));
1482 assert_eq!(dependents.len(), 2);
1483 assert!(dependents.contains(&PathBuf::from("docs/sub/a.md")));
1484 assert!(dependents.contains(&PathBuf::from("docs/c.md")));
1485 }
1486
1487 #[test]
1488 fn test_reverse_deps_update_clears_old() {
1489 let mut index = WorkspaceIndex::new();
1490
1491 let mut file_a = FileIndex::new();
1493 file_a.add_cross_file_link(CrossFileLinkIndex {
1494 target_path: "b.md".to_string(),
1495 fragment: "".to_string(),
1496 line: 1,
1497 column: 1,
1498 origin: LinkOrigin::Body,
1499 });
1500 index.update_file(Path::new("docs/a.md"), file_a);
1501
1502 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1504
1505 let mut file_a_updated = FileIndex::new();
1507 file_a_updated.add_cross_file_link(CrossFileLinkIndex {
1508 target_path: "c.md".to_string(),
1509 fragment: "".to_string(),
1510 line: 1,
1511 column: 1,
1512 origin: LinkOrigin::Body,
1513 });
1514 index.update_file(Path::new("docs/a.md"), file_a_updated);
1515
1516 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1518
1519 let c_deps = index.get_dependents(Path::new("docs/c.md"));
1521 assert_eq!(c_deps.len(), 1);
1522 assert_eq!(c_deps[0], PathBuf::from("docs/a.md"));
1523 }
1524
1525 #[test]
1526 fn test_reverse_deps_remove_file() {
1527 let mut index = WorkspaceIndex::new();
1528
1529 let mut file_a = FileIndex::new();
1531 file_a.add_cross_file_link(CrossFileLinkIndex {
1532 target_path: "b.md".to_string(),
1533 fragment: "".to_string(),
1534 line: 1,
1535 column: 1,
1536 origin: LinkOrigin::Body,
1537 });
1538 index.update_file(Path::new("docs/a.md"), file_a);
1539
1540 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1542
1543 index.remove_file(Path::new("docs/a.md"));
1545
1546 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1548 }
1549
1550 #[test]
1551 fn test_normalize_path() {
1552 let path = Path::new("docs/sub/../other.md");
1554 let normalized = normalize_relative_path(path);
1555 assert_eq!(normalized, PathBuf::from("docs/other.md"));
1556
1557 let path2 = Path::new("docs/./other.md");
1559 let normalized2 = normalize_relative_path(path2);
1560 assert_eq!(normalized2, PathBuf::from("docs/other.md"));
1561
1562 let path3 = Path::new("a/b/c/../../d.md");
1564 let normalized3 = normalize_relative_path(path3);
1565 assert_eq!(normalized3, PathBuf::from("a/d.md"));
1566 }
1567
1568 #[test]
1573 fn normalize_keeps_a_traversal_that_leaves_its_own_root() {
1574 assert_eq!(
1575 normalize_relative_path(Path::new("../notes.md")),
1576 PathBuf::from("../notes.md")
1577 );
1578 assert_eq!(
1579 normalize_relative_path(Path::new("docs/../../notes.md")),
1580 PathBuf::from("../notes.md")
1581 );
1582 assert_eq!(
1583 normalize_relative_path(Path::new("../../a/./b/../notes.md")),
1584 PathBuf::from("../../a/notes.md")
1585 );
1586 }
1587
1588 #[test]
1591 fn normalize_stops_a_traversal_at_a_root() {
1592 let root = if cfg!(windows) { "C:\\" } else { "/" };
1593 assert_eq!(
1594 normalize_relative_path(&Path::new(root).join("..").join("notes.md")),
1595 Path::new(root).join("notes.md")
1596 );
1597 }
1598
1599 #[test]
1600 fn test_clear_clears_reverse_deps() {
1601 let mut index = WorkspaceIndex::new();
1602
1603 let mut file_a = FileIndex::new();
1605 file_a.add_cross_file_link(CrossFileLinkIndex {
1606 target_path: "b.md".to_string(),
1607 fragment: "".to_string(),
1608 line: 1,
1609 column: 1,
1610 origin: LinkOrigin::Body,
1611 });
1612 index.update_file(Path::new("docs/a.md"), file_a);
1613
1614 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1616
1617 index.clear();
1619
1620 assert_eq!(index.file_count(), 0);
1622 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1623 }
1624
1625 #[test]
1626 fn test_is_file_stale() {
1627 let mut index = WorkspaceIndex::new();
1628
1629 assert!(index.is_file_stale(Path::new("nonexistent.md"), "hash123"));
1631
1632 let file_index = FileIndex::with_hash("hash123".to_string());
1634 index.insert_file(PathBuf::from("docs/test.md"), file_index);
1635
1636 assert!(!index.is_file_stale(Path::new("docs/test.md"), "hash123"));
1638
1639 assert!(index.is_file_stale(Path::new("docs/test.md"), "different_hash"));
1641 }
1642
1643 #[cfg(feature = "native")]
1644 #[test]
1645 fn test_cache_roundtrip() {
1646 use std::fs;
1647
1648 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_roundtrip");
1650 let _ = fs::remove_dir_all(&temp_dir);
1651 fs::create_dir_all(&temp_dir).unwrap();
1652
1653 let mut index = WorkspaceIndex::new();
1655
1656 let mut file1 = FileIndex::with_hash("abc123".to_string());
1657 file1.add_heading(HeadingIndex {
1658 text: "Test Heading".to_string(),
1659 auto_anchor: "test-heading".to_string(),
1660 custom_anchor: Some("test".to_string()),
1661 line: 1,
1662 is_setext: false,
1663 });
1664 file1.add_cross_file_link(CrossFileLinkIndex {
1665 target_path: "./other.md".to_string(),
1666 fragment: "section".to_string(),
1667 line: 5,
1668 column: 3,
1669 origin: LinkOrigin::Body,
1670 });
1671 index.update_file(Path::new("docs/file1.md"), file1);
1672
1673 let mut file2 = FileIndex::with_hash("def456".to_string());
1674 file2.add_heading(HeadingIndex {
1675 text: "Another Heading".to_string(),
1676 auto_anchor: "another-heading".to_string(),
1677 custom_anchor: None,
1678 line: 1,
1679 is_setext: false,
1680 });
1681 index.update_file(Path::new("docs/other.md"), file2);
1682
1683 index.save_to_cache(&temp_dir).expect("Failed to save cache");
1685
1686 assert!(temp_dir.join("workspace_index.bin").exists());
1688
1689 let loaded = WorkspaceIndex::load_from_cache(&temp_dir).expect("Failed to load cache");
1691
1692 assert_eq!(loaded.file_count(), 2);
1694 assert!(loaded.contains_file(Path::new("docs/file1.md")));
1695 assert!(loaded.contains_file(Path::new("docs/other.md")));
1696
1697 let file1_loaded = loaded.get_file(Path::new("docs/file1.md")).unwrap();
1699 assert_eq!(file1_loaded.content_hash, "abc123");
1700 assert_eq!(file1_loaded.headings.len(), 1);
1701 assert_eq!(file1_loaded.headings[0].text, "Test Heading");
1702 assert_eq!(file1_loaded.headings[0].custom_anchor, Some("test".to_string()));
1703 assert_eq!(file1_loaded.cross_file_links.len(), 1);
1704 assert_eq!(file1_loaded.cross_file_links[0].target_path, "./other.md");
1705
1706 let dependents = loaded.get_dependents(Path::new("docs/other.md"));
1708 assert_eq!(dependents.len(), 1);
1709 assert_eq!(dependents[0], PathBuf::from("docs/file1.md"));
1710
1711 let _ = fs::remove_dir_all(&temp_dir);
1713 }
1714
1715 #[cfg(feature = "native")]
1716 #[test]
1717 fn test_cache_missing_file() {
1718 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_missing");
1719 let _ = std::fs::remove_dir_all(&temp_dir);
1720
1721 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1723 assert!(result.is_none());
1724 }
1725
1726 #[cfg(feature = "native")]
1727 #[test]
1728 fn test_cache_corrupted_file() {
1729 use std::fs;
1730
1731 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_corrupted");
1732 let _ = fs::remove_dir_all(&temp_dir);
1733 fs::create_dir_all(&temp_dir).unwrap();
1734
1735 fs::write(temp_dir.join("workspace_index.bin"), b"bad").unwrap();
1737
1738 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1740 assert!(result.is_none());
1741
1742 assert!(!temp_dir.join("workspace_index.bin").exists());
1744
1745 let _ = fs::remove_dir_all(&temp_dir);
1747 }
1748
1749 #[cfg(feature = "native")]
1750 #[test]
1751 fn test_cache_invalid_magic() {
1752 use std::fs;
1753
1754 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_invalid_magic");
1755 let _ = fs::remove_dir_all(&temp_dir);
1756 fs::create_dir_all(&temp_dir).unwrap();
1757
1758 let mut data = Vec::new();
1760 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();
1764
1765 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1767 assert!(result.is_none());
1768
1769 assert!(!temp_dir.join("workspace_index.bin").exists());
1771
1772 let _ = fs::remove_dir_all(&temp_dir);
1774 }
1775
1776 #[cfg(feature = "native")]
1777 #[test]
1778 fn test_cache_version_mismatch() {
1779 use std::fs;
1780
1781 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_version_mismatch");
1782 let _ = fs::remove_dir_all(&temp_dir);
1783 fs::create_dir_all(&temp_dir).unwrap();
1784
1785 let mut data = Vec::new();
1787 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();
1791
1792 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1794 assert!(result.is_none());
1795
1796 assert!(!temp_dir.join("workspace_index.bin").exists());
1798
1799 let _ = fs::remove_dir_all(&temp_dir);
1801 }
1802
1803 #[cfg(feature = "native")]
1804 #[test]
1805 fn test_cache_atomic_write() {
1806 use std::fs;
1807
1808 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_atomic");
1810 let _ = fs::remove_dir_all(&temp_dir);
1811 fs::create_dir_all(&temp_dir).unwrap();
1812
1813 let index = WorkspaceIndex::new();
1814 index.save_to_cache(&temp_dir).expect("Failed to save");
1815
1816 let entries: Vec<_> = fs::read_dir(&temp_dir).unwrap().collect();
1818 assert_eq!(entries.len(), 1);
1819 assert!(temp_dir.join("workspace_index.bin").exists());
1820
1821 let _ = fs::remove_dir_all(&temp_dir);
1823 }
1824
1825 #[test]
1826 fn test_has_anchor_auto_generated() {
1827 let mut file_index = FileIndex::new();
1828 file_index.add_heading(HeadingIndex {
1829 text: "Installation Guide".to_string(),
1830 auto_anchor: "installation-guide".to_string(),
1831 custom_anchor: None,
1832 line: 1,
1833 is_setext: false,
1834 });
1835
1836 assert!(file_index.has_anchor("installation-guide"));
1838
1839 assert!(file_index.has_anchor("Installation-Guide"));
1841 assert!(file_index.has_anchor("INSTALLATION-GUIDE"));
1842
1843 assert!(!file_index.has_anchor("nonexistent"));
1845 }
1846
1847 #[test]
1848 fn test_has_anchor_custom() {
1849 let mut file_index = FileIndex::new();
1850 file_index.add_heading(HeadingIndex {
1851 text: "Installation Guide".to_string(),
1852 auto_anchor: "installation-guide".to_string(),
1853 custom_anchor: Some("install".to_string()),
1854 line: 1,
1855 is_setext: false,
1856 });
1857
1858 assert!(file_index.has_anchor("installation-guide"));
1860
1861 assert!(file_index.has_anchor("install"));
1863 assert!(file_index.has_anchor("Install")); assert!(!file_index.has_anchor("nonexistent"));
1867 }
1868
1869 #[test]
1870 fn test_get_heading_by_anchor() {
1871 let mut file_index = FileIndex::new();
1872 file_index.add_heading(HeadingIndex {
1873 text: "Installation Guide".to_string(),
1874 auto_anchor: "installation-guide".to_string(),
1875 custom_anchor: Some("install".to_string()),
1876 line: 10,
1877 is_setext: false,
1878 });
1879 file_index.add_heading(HeadingIndex {
1880 text: "Configuration".to_string(),
1881 auto_anchor: "configuration".to_string(),
1882 custom_anchor: None,
1883 line: 20,
1884 is_setext: false,
1885 });
1886
1887 let heading = file_index.get_heading_by_anchor("installation-guide");
1889 assert!(heading.is_some());
1890 assert_eq!(heading.unwrap().text, "Installation Guide");
1891 assert_eq!(heading.unwrap().line, 10);
1892
1893 let heading = file_index.get_heading_by_anchor("install");
1895 assert!(heading.is_some());
1896 assert_eq!(heading.unwrap().text, "Installation Guide");
1897
1898 let heading = file_index.get_heading_by_anchor("configuration");
1900 assert!(heading.is_some());
1901 assert_eq!(heading.unwrap().text, "Configuration");
1902 assert_eq!(heading.unwrap().line, 20);
1903
1904 assert!(file_index.get_heading_by_anchor("nonexistent").is_none());
1906 }
1907
1908 #[test]
1909 fn test_anchor_lookup_many_headings() {
1910 let mut file_index = FileIndex::new();
1912
1913 for i in 0..100 {
1915 file_index.add_heading(HeadingIndex {
1916 text: format!("Heading {i}"),
1917 auto_anchor: format!("heading-{i}"),
1918 custom_anchor: Some(format!("h{i}")),
1919 line: i + 1,
1920 is_setext: false,
1921 });
1922 }
1923
1924 for i in 0..100 {
1926 assert!(file_index.has_anchor(&format!("heading-{i}")));
1927 assert!(file_index.has_anchor(&format!("h{i}")));
1928
1929 let heading = file_index.get_heading_by_anchor(&format!("heading-{i}"));
1930 assert!(heading.is_some());
1931 assert_eq!(heading.unwrap().line, i + 1);
1932 }
1933 }
1934
1935 #[test]
1940 fn test_extract_cross_file_links_basic() {
1941 use crate::config::MarkdownFlavor;
1942
1943 let content = "# Test\n\nSee [link](./other.md) for info.\n";
1944 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1945 let links = extract_cross_file_links(&ctx).relative;
1946
1947 assert_eq!(links.len(), 1);
1948 assert_eq!(links[0].target_path, "./other.md");
1949 assert_eq!(links[0].fragment, "");
1950 assert_eq!(links[0].line, 3);
1951 assert_eq!(links[0].column, 12);
1953 }
1954
1955 #[test]
1956 fn test_extract_cross_file_links_with_fragment() {
1957 use crate::config::MarkdownFlavor;
1958
1959 let content = "Check [guide](./guide.md#install) here.\n";
1960 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1961 let links = extract_cross_file_links(&ctx).relative;
1962
1963 assert_eq!(links.len(), 1);
1964 assert_eq!(links[0].target_path, "./guide.md");
1965 assert_eq!(links[0].fragment, "install");
1966 assert_eq!(links[0].line, 1);
1967 assert_eq!(links[0].column, 15);
1969 }
1970
1971 #[test]
1972 fn test_extract_cross_file_links_multiple_on_same_line() {
1973 use crate::config::MarkdownFlavor;
1974
1975 let content = "See [a](a.md) and [b](b.md) here.\n";
1976 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1977 let links = extract_cross_file_links(&ctx).relative;
1978
1979 assert_eq!(links.len(), 2);
1980
1981 assert_eq!(links[0].target_path, "a.md");
1982 assert_eq!(links[0].line, 1);
1983 assert_eq!(links[0].column, 9);
1985
1986 assert_eq!(links[1].target_path, "b.md");
1987 assert_eq!(links[1].line, 1);
1988 assert_eq!(links[1].column, 23);
1990 }
1991
1992 #[test]
1993 fn test_extract_cross_file_links_angle_brackets() {
1994 use crate::config::MarkdownFlavor;
1995
1996 let content = "See [link](<path/with (parens).md>) here.\n";
1997 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1998 let links = extract_cross_file_links(&ctx).relative;
1999
2000 assert_eq!(links.len(), 1);
2001 assert_eq!(links[0].target_path, "path/with (parens).md");
2002 assert_eq!(links[0].line, 1);
2003 assert_eq!(links[0].column, 13);
2005 }
2006
2007 #[test]
2008 fn test_extract_cross_file_links_skips_external() {
2009 use crate::config::MarkdownFlavor;
2010
2011 let content = r#"
2012[external](https://example.com)
2013[mailto](mailto:test@example.com)
2014[local](./local.md)
2015[fragment](#section)
2016[absolute](/docs/page.md)
2017"#;
2018 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2019 let extracted = extract_cross_file_links(&ctx);
2020
2021 assert_eq!(extracted.relative.len(), 1);
2023 assert_eq!(extracted.relative[0].target_path, "./local.md");
2024 assert_eq!(extracted.root_relative.len(), 1);
2026 assert_eq!(extracted.root_relative[0].target_path, "docs/page.md");
2027 }
2028
2029 #[test]
2030 fn test_extract_cross_file_links_root_relative() {
2031 use crate::config::MarkdownFlavor;
2032
2033 let content = "[a](/guide.md#install)\n[b](/../escape.md)\n[c](//host/x.md)\n[d](/img/pic.png)\n";
2037 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2038 let extracted = extract_cross_file_links(&ctx);
2039
2040 assert!(extracted.relative.is_empty(), "no directory-relative links here");
2041 assert_eq!(
2042 extracted
2043 .root_relative
2044 .iter()
2045 .map(|l| (l.target_path.as_str(), l.fragment.as_str()))
2046 .collect::<Vec<_>>(),
2047 vec![("guide.md", "install")],
2048 "only the safe root-relative markdown link is captured"
2049 );
2050 }
2051
2052 #[test]
2053 fn test_extract_cross_file_links_skips_non_markdown() {
2054 use crate::config::MarkdownFlavor;
2055
2056 let content = r#"
2057[image](./photo.png)
2058[doc](./readme.md)
2059[pdf](./document.pdf)
2060"#;
2061 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2062 let links = extract_cross_file_links(&ctx).relative;
2063
2064 assert_eq!(links.len(), 1);
2066 assert_eq!(links[0].target_path, "./readme.md");
2067 }
2068
2069 #[test]
2070 fn test_extract_cross_file_links_skips_code_spans() {
2071 use crate::config::MarkdownFlavor;
2072
2073 let content = "Normal [link](./file.md) and `[code](./ignored.md)` here.\n";
2074 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2075 let links = extract_cross_file_links(&ctx).relative;
2076
2077 assert_eq!(links.len(), 1);
2079 assert_eq!(links[0].target_path, "./file.md");
2080 }
2081
2082 #[test]
2083 fn test_extract_cross_file_links_with_query_params() {
2084 use crate::config::MarkdownFlavor;
2085
2086 let content = "See [doc](./file.md?raw=true) here.\n";
2087 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2088 let links = extract_cross_file_links(&ctx).relative;
2089
2090 assert_eq!(links.len(), 1);
2091 assert_eq!(links[0].target_path, "./file.md");
2093 }
2094
2095 #[test]
2096 fn test_extract_cross_file_links_empty_content() {
2097 use crate::config::MarkdownFlavor;
2098
2099 let content = "";
2100 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2101 let links = extract_cross_file_links(&ctx).relative;
2102
2103 assert!(links.is_empty());
2104 }
2105
2106 #[test]
2107 fn test_extract_cross_file_links_no_links() {
2108 use crate::config::MarkdownFlavor;
2109
2110 let content = "# Just a heading\n\nSome text without links.\n";
2111 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2112 let links = extract_cross_file_links(&ctx).relative;
2113
2114 assert!(links.is_empty());
2115 }
2116
2117 #[test]
2118 fn test_extract_cross_file_links_position_accuracy_issue_234() {
2119 use crate::config::MarkdownFlavor;
2122
2123 let content = r#"# Test Document
2124
2125Here is a [broken link](nonexistent-file.md) that should trigger MD057.
2126
2127And another [link](also-missing.md) on this line.
2128"#;
2129 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2130 let links = extract_cross_file_links(&ctx).relative;
2131
2132 assert_eq!(links.len(), 2);
2133
2134 assert_eq!(links[0].target_path, "nonexistent-file.md");
2136 assert_eq!(links[0].line, 3);
2137 assert_eq!(links[0].column, 25);
2138
2139 assert_eq!(links[1].target_path, "also-missing.md");
2141 assert_eq!(links[1].line, 5);
2142 assert_eq!(links[1].column, 20);
2143 }
2144}