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, 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, 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, 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 add_heading(&mut self, heading: HeadingIndex) {
876 let index = self.headings.len();
877
878 self.anchor_to_heading.insert(heading.auto_anchor.to_lowercase(), index);
881 self.anchor_to_heading_exact.insert(heading.auto_anchor.clone(), index);
882
883 if let Some(ref custom) = heading.custom_anchor {
885 self.anchor_to_heading.insert(custom.to_lowercase(), index);
886 self.anchor_to_heading_exact.insert(custom.clone(), index);
887 }
888
889 self.headings.push(heading);
890 }
891
892 pub fn add_anchor_alias(&mut self, anchor: &str, heading_index: usize) {
895 if heading_index < self.headings.len() {
896 self.anchor_to_heading.insert(anchor.to_lowercase(), heading_index);
897 self.anchor_to_heading_exact.insert(anchor.to_string(), heading_index);
898 }
899 }
900
901 pub fn has_anchor(&self, anchor: &str) -> bool {
912 self.has_anchor_with_case(anchor, true)
913 }
914
915 pub fn has_anchor_with_case(&self, anchor: &str, ignore_case: bool) -> bool {
924 if self.lookup_anchor(anchor, ignore_case) {
925 return true;
926 }
927
928 if anchor.contains('%') {
930 let decoded = url_decode(anchor);
931 if decoded != anchor {
932 return self.lookup_anchor(&decoded, ignore_case);
933 }
934 }
935
936 false
937 }
938
939 fn lookup_anchor(&self, anchor: &str, ignore_case: bool) -> bool {
942 if ignore_case {
943 let lower = anchor.to_lowercase();
944 self.anchor_to_heading.contains_key(&lower)
945 || self.html_anchors.contains(&lower)
946 || self.attribute_anchors.contains(&lower)
947 } else {
948 self.anchor_to_heading_exact.contains_key(anchor)
949 || self.html_anchors_exact.contains(anchor)
950 || self.attribute_anchors_exact.contains(anchor)
951 }
952 }
953
954 pub fn add_html_anchor(&mut self, anchor: &str) {
957 if !anchor.is_empty() {
958 self.html_anchors.insert(anchor.to_lowercase());
959 self.html_anchors_exact.insert(anchor.to_string());
960 }
961 }
962
963 pub fn add_attribute_anchor(&mut self, anchor: &str) {
966 if !anchor.is_empty() {
967 self.attribute_anchors.insert(anchor.to_lowercase());
968 self.attribute_anchors_exact.insert(anchor.to_string());
969 }
970 }
971
972 pub fn get_heading_by_anchor(&self, anchor: &str) -> Option<&HeadingIndex> {
976 self.anchor_to_heading
977 .get(&anchor.to_lowercase())
978 .and_then(|&idx| self.headings.get(idx))
979 }
980
981 pub fn add_reference_link(&mut self, link: ReferenceLinkIndex) {
983 self.reference_links.push(link);
984 }
985
986 pub fn is_rule_disabled_at_line(&self, rule_name: &str, line: usize) -> bool {
991 if self.file_disabled_rules.contains("*") || self.file_disabled_rules.contains(rule_name) {
993 return true;
994 }
995
996 if let Some(rules) = self.line_disabled_rules.get(&line)
998 && (rules.contains("*") || rules.contains(rule_name))
999 {
1000 return true;
1001 }
1002
1003 if !self.persistent_transitions.is_empty() {
1005 let idx = match self.persistent_transitions.binary_search_by_key(&line, |t| t.0) {
1006 Ok(i) => Some(i),
1007 Err(i) => {
1008 if i > 0 {
1009 Some(i - 1)
1010 } else {
1011 None
1012 }
1013 }
1014 };
1015 if let Some(i) = idx {
1016 let (_, ref disabled, ref enabled) = self.persistent_transitions[i];
1017 if disabled.contains("*") {
1018 return !enabled.contains(rule_name);
1019 }
1020 return disabled.contains(rule_name);
1021 }
1022 }
1023
1024 false
1025 }
1026
1027 pub fn add_cross_file_link(&mut self, link: CrossFileLinkIndex) {
1042 let existing = self.cross_file_links.iter_mut().find(|existing| {
1043 existing.fragment == link.fragment
1044 && existing.line == link.line
1045 && strip_query_and_fragment(&existing.target_path) == strip_query_and_fragment(&link.target_path)
1046 });
1047 match existing {
1048 Some(existing) => {
1051 if !existing.target_path.contains('?') && link.target_path.contains('?') {
1052 *existing = link;
1053 }
1054 }
1055 None => self.cross_file_links.push(link),
1056 }
1057 }
1058
1059 pub fn add_root_relative_link(&mut self, link: CrossFileLinkIndex) {
1061 let is_duplicate = self.root_relative_links.iter().any(|existing| {
1062 existing.target_path == link.target_path && existing.fragment == link.fragment && existing.line == link.line
1063 });
1064 if !is_duplicate {
1065 self.root_relative_links.push(link);
1066 }
1067 }
1068
1069 pub fn add_defined_reference(&mut self, ref_id: String) {
1071 self.defined_references.insert(ref_id);
1072 }
1073
1074 pub fn has_defined_reference(&self, ref_id: &str) -> bool {
1076 self.defined_references.contains(ref_id)
1077 }
1078
1079 pub fn hash_matches(&self, hash: &str) -> bool {
1081 self.content_hash == hash
1082 }
1083
1084 pub fn heading_count(&self) -> usize {
1086 self.headings.len()
1087 }
1088
1089 pub fn reference_link_count(&self) -> usize {
1091 self.reference_links.len()
1092 }
1093}
1094
1095#[cfg(test)]
1096mod tests {
1097 use super::*;
1098
1099 #[test]
1100 fn test_workspace_index_basic() {
1101 let mut index = WorkspaceIndex::new();
1102 assert_eq!(index.file_count(), 0);
1103 assert_eq!(index.version(), 0);
1104
1105 let mut file_index = FileIndex::with_hash("abc123".to_string());
1106 file_index.add_heading(HeadingIndex {
1107 text: "Installation".to_string(),
1108 auto_anchor: "installation".to_string(),
1109 custom_anchor: None,
1110 line: 1,
1111 is_setext: false,
1112 });
1113
1114 index.insert_file(PathBuf::from("docs/install.md"), file_index);
1115 assert_eq!(index.file_count(), 1);
1116 assert_eq!(index.version(), 1);
1117
1118 assert!(index.contains_file(Path::new("docs/install.md")));
1119 assert!(!index.contains_file(Path::new("docs/other.md")));
1120 }
1121
1122 #[test]
1123 fn test_vulnerable_anchors() {
1124 let mut index = WorkspaceIndex::new();
1125
1126 let mut file1 = FileIndex::new();
1128 file1.add_heading(HeadingIndex {
1129 text: "Getting Started".to_string(),
1130 auto_anchor: "getting-started".to_string(),
1131 custom_anchor: None,
1132 line: 1,
1133 is_setext: false,
1134 });
1135 index.insert_file(PathBuf::from("docs/guide.md"), file1);
1136
1137 let mut file2 = FileIndex::new();
1139 file2.add_heading(HeadingIndex {
1140 text: "Installation".to_string(),
1141 auto_anchor: "installation".to_string(),
1142 custom_anchor: Some("install".to_string()),
1143 line: 1,
1144 is_setext: false,
1145 });
1146 index.insert_file(PathBuf::from("docs/install.md"), file2);
1147
1148 let vulnerable = index.get_vulnerable_anchors();
1149 assert_eq!(vulnerable.len(), 1);
1150 assert!(vulnerable.contains_key("getting-started"));
1151 assert!(!vulnerable.contains_key("installation"));
1152
1153 let anchors = vulnerable.get("getting-started").unwrap();
1154 assert_eq!(anchors.len(), 1);
1155 assert_eq!(anchors[0].file, PathBuf::from("docs/guide.md"));
1156 assert_eq!(anchors[0].text, "Getting Started");
1157 }
1158
1159 #[test]
1160 fn test_vulnerable_anchors_multiple_files_same_anchor() {
1161 let mut index = WorkspaceIndex::new();
1164
1165 let mut file1 = FileIndex::new();
1167 file1.add_heading(HeadingIndex {
1168 text: "Installation".to_string(),
1169 auto_anchor: "installation".to_string(),
1170 custom_anchor: None,
1171 line: 1,
1172 is_setext: false,
1173 });
1174 index.insert_file(PathBuf::from("docs/en/guide.md"), file1);
1175
1176 let mut file2 = FileIndex::new();
1178 file2.add_heading(HeadingIndex {
1179 text: "Installation".to_string(),
1180 auto_anchor: "installation".to_string(),
1181 custom_anchor: None,
1182 line: 5,
1183 is_setext: false,
1184 });
1185 index.insert_file(PathBuf::from("docs/fr/guide.md"), file2);
1186
1187 let mut file3 = FileIndex::new();
1189 file3.add_heading(HeadingIndex {
1190 text: "Installation".to_string(),
1191 auto_anchor: "installation".to_string(),
1192 custom_anchor: Some("install".to_string()),
1193 line: 10,
1194 is_setext: false,
1195 });
1196 index.insert_file(PathBuf::from("docs/de/guide.md"), file3);
1197
1198 let vulnerable = index.get_vulnerable_anchors();
1199 assert_eq!(vulnerable.len(), 1); assert!(vulnerable.contains_key("installation"));
1201
1202 let anchors = vulnerable.get("installation").unwrap();
1203 assert_eq!(anchors.len(), 2, "Should collect both vulnerable anchors");
1205
1206 let files: std::collections::HashSet<_> = anchors.iter().map(|a| &a.file).collect();
1208 assert!(files.contains(&PathBuf::from("docs/en/guide.md")));
1209 assert!(files.contains(&PathBuf::from("docs/fr/guide.md")));
1210 }
1211
1212 #[test]
1213 fn test_file_index_hash() {
1214 let index = FileIndex::with_hash("hash123".to_string());
1215 assert!(index.hash_matches("hash123"));
1216 assert!(!index.hash_matches("other"));
1217 }
1218
1219 #[test]
1220 fn test_version_increment() {
1221 let mut index = WorkspaceIndex::new();
1222 assert_eq!(index.version(), 0);
1223
1224 index.insert_file(PathBuf::from("a.md"), FileIndex::new());
1225 assert_eq!(index.version(), 1);
1226
1227 index.insert_file(PathBuf::from("b.md"), FileIndex::new());
1228 assert_eq!(index.version(), 2);
1229
1230 index.remove_file(Path::new("a.md"));
1231 assert_eq!(index.version(), 3);
1232
1233 index.remove_file(Path::new("nonexistent.md"));
1235 assert_eq!(index.version(), 3);
1236 }
1237
1238 #[test]
1239 fn test_files_sorted_is_path_ordered() {
1240 let mut index = WorkspaceIndex::new();
1241 for name in ["docs/zebra.md", "docs/apple.md", "docs/mango.md"] {
1243 index.update_file(Path::new(name), FileIndex::new());
1244 }
1245
1246 let paths: Vec<&Path> = index.files_sorted().into_iter().map(|(p, _)| p).collect();
1247 assert_eq!(
1248 paths,
1249 vec![
1250 Path::new("docs/apple.md"),
1251 Path::new("docs/mango.md"),
1252 Path::new("docs/zebra.md"),
1253 ],
1254 "files_sorted() must return entries ordered by path"
1255 );
1256 }
1257
1258 #[test]
1263 fn test_add_cross_file_link_keeps_the_destination_as_written() {
1264 let as_written = CrossFileLinkIndex {
1265 target_path: "other.md?raw=true".to_string(),
1266 fragment: "missing".to_string(),
1267 line: 3,
1268 column: 1,
1269 origin: LinkOrigin::Body,
1270 };
1271 let file_named = CrossFileLinkIndex {
1272 target_path: "other.md".to_string(),
1273 fragment: "missing".to_string(),
1274 line: 3,
1275 column: 9,
1276 origin: LinkOrigin::Body,
1277 };
1278
1279 for (first, second) in [
1280 (as_written.clone(), file_named.clone()),
1281 (file_named.clone(), as_written.clone()),
1282 ] {
1283 let mut index = FileIndex::new();
1284 index.add_cross_file_link(first);
1285 index.add_cross_file_link(second);
1286
1287 assert_eq!(
1288 index.cross_file_links.len(),
1289 1,
1290 "one link is one entry, got: {:?}",
1291 index.cross_file_links
1292 );
1293 assert_eq!(index.cross_file_links[0].target_path, "other.md?raw=true");
1294 }
1295 }
1296
1297 #[test]
1300 fn test_add_cross_file_link_keeps_distinct_targets() {
1301 let mut index = FileIndex::new();
1302 for target in ["one.md", "two.md"] {
1303 index.add_cross_file_link(CrossFileLinkIndex {
1304 target_path: target.to_string(),
1305 fragment: "missing".to_string(),
1306 line: 3,
1307 column: 1,
1308 origin: LinkOrigin::Body,
1309 });
1310 }
1311 assert_eq!(index.cross_file_links.len(), 2);
1312 }
1313
1314 #[test]
1322 fn test_add_cross_file_link_collapses_one_line_asking_one_file_once() {
1323 let link = |target: &str, fragment: &str, line: usize| CrossFileLinkIndex {
1324 target_path: target.to_string(),
1325 fragment: fragment.to_string(),
1326 line,
1327 column: 1,
1328 origin: LinkOrigin::Body,
1329 };
1330
1331 let mut index = FileIndex::new();
1332 index.add_cross_file_link(link("target.md?raw=true", "missing", 3));
1333 index.add_cross_file_link(link("target.md?plain=1", "missing", 3));
1334 assert_eq!(
1335 index.cross_file_links.len(),
1336 1,
1337 "one file, one fragment, one line is one entry, got: {:?}",
1338 index.cross_file_links
1339 );
1340
1341 index.add_cross_file_link(link("target.md", "other", 3));
1342 index.add_cross_file_link(link("target.md", "missing", 4));
1343 assert_eq!(index.cross_file_links.len(), 3);
1344 }
1345
1346 #[test]
1352 fn test_reverse_deps_ignore_a_query_string_on_the_destination() {
1353 let mut index = WorkspaceIndex::new();
1354
1355 let mut file_a = FileIndex::new();
1356 file_a.add_cross_file_link(CrossFileLinkIndex {
1357 target_path: "b.md?raw=true".to_string(),
1358 fragment: "section".to_string(),
1359 line: 10,
1360 column: 5,
1361 origin: LinkOrigin::Body,
1362 });
1363 index.update_file(Path::new("docs/a.md"), file_a);
1364
1365 assert_eq!(
1366 index.get_dependents(Path::new("docs/b.md")),
1367 vec![PathBuf::from("docs/a.md")],
1368 "editing docs/b.md must re-lint the file linking to it"
1369 );
1370
1371 index.update_file(Path::new("docs/a.md"), FileIndex::new());
1374 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1375 }
1376
1377 #[test]
1378 fn test_reverse_deps_basic() {
1379 let mut index = WorkspaceIndex::new();
1380
1381 let mut file_a = FileIndex::new();
1383 file_a.add_cross_file_link(CrossFileLinkIndex {
1384 target_path: "b.md".to_string(),
1385 fragment: "section".to_string(),
1386 line: 10,
1387 column: 5,
1388 origin: LinkOrigin::Body,
1389 });
1390 index.update_file(Path::new("docs/a.md"), file_a);
1391
1392 let dependents = index.get_dependents(Path::new("docs/b.md"));
1394 assert_eq!(dependents.len(), 1);
1395 assert_eq!(dependents[0], PathBuf::from("docs/a.md"));
1396
1397 let a_dependents = index.get_dependents(Path::new("docs/a.md"));
1399 assert!(a_dependents.is_empty());
1400 }
1401
1402 #[test]
1403 fn test_reverse_deps_multiple() {
1404 let mut index = WorkspaceIndex::new();
1405
1406 let mut file_a = FileIndex::new();
1408 file_a.add_cross_file_link(CrossFileLinkIndex {
1409 target_path: "../b.md".to_string(),
1410 fragment: "".to_string(),
1411 line: 1,
1412 column: 1,
1413 origin: LinkOrigin::Body,
1414 });
1415 index.update_file(Path::new("docs/sub/a.md"), file_a);
1416
1417 let mut file_c = FileIndex::new();
1418 file_c.add_cross_file_link(CrossFileLinkIndex {
1419 target_path: "b.md".to_string(),
1420 fragment: "".to_string(),
1421 line: 1,
1422 column: 1,
1423 origin: LinkOrigin::Body,
1424 });
1425 index.update_file(Path::new("docs/c.md"), file_c);
1426
1427 let dependents = index.get_dependents(Path::new("docs/b.md"));
1429 assert_eq!(dependents.len(), 2);
1430 assert!(dependents.contains(&PathBuf::from("docs/sub/a.md")));
1431 assert!(dependents.contains(&PathBuf::from("docs/c.md")));
1432 }
1433
1434 #[test]
1435 fn test_reverse_deps_update_clears_old() {
1436 let mut index = WorkspaceIndex::new();
1437
1438 let mut file_a = FileIndex::new();
1440 file_a.add_cross_file_link(CrossFileLinkIndex {
1441 target_path: "b.md".to_string(),
1442 fragment: "".to_string(),
1443 line: 1,
1444 column: 1,
1445 origin: LinkOrigin::Body,
1446 });
1447 index.update_file(Path::new("docs/a.md"), file_a);
1448
1449 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1451
1452 let mut file_a_updated = FileIndex::new();
1454 file_a_updated.add_cross_file_link(CrossFileLinkIndex {
1455 target_path: "c.md".to_string(),
1456 fragment: "".to_string(),
1457 line: 1,
1458 column: 1,
1459 origin: LinkOrigin::Body,
1460 });
1461 index.update_file(Path::new("docs/a.md"), file_a_updated);
1462
1463 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1465
1466 let c_deps = index.get_dependents(Path::new("docs/c.md"));
1468 assert_eq!(c_deps.len(), 1);
1469 assert_eq!(c_deps[0], PathBuf::from("docs/a.md"));
1470 }
1471
1472 #[test]
1473 fn test_reverse_deps_remove_file() {
1474 let mut index = WorkspaceIndex::new();
1475
1476 let mut file_a = FileIndex::new();
1478 file_a.add_cross_file_link(CrossFileLinkIndex {
1479 target_path: "b.md".to_string(),
1480 fragment: "".to_string(),
1481 line: 1,
1482 column: 1,
1483 origin: LinkOrigin::Body,
1484 });
1485 index.update_file(Path::new("docs/a.md"), file_a);
1486
1487 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1489
1490 index.remove_file(Path::new("docs/a.md"));
1492
1493 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1495 }
1496
1497 #[test]
1498 fn test_normalize_path() {
1499 let path = Path::new("docs/sub/../other.md");
1501 let normalized = normalize_relative_path(path);
1502 assert_eq!(normalized, PathBuf::from("docs/other.md"));
1503
1504 let path2 = Path::new("docs/./other.md");
1506 let normalized2 = normalize_relative_path(path2);
1507 assert_eq!(normalized2, PathBuf::from("docs/other.md"));
1508
1509 let path3 = Path::new("a/b/c/../../d.md");
1511 let normalized3 = normalize_relative_path(path3);
1512 assert_eq!(normalized3, PathBuf::from("a/d.md"));
1513 }
1514
1515 #[test]
1520 fn normalize_keeps_a_traversal_that_leaves_its_own_root() {
1521 assert_eq!(
1522 normalize_relative_path(Path::new("../notes.md")),
1523 PathBuf::from("../notes.md")
1524 );
1525 assert_eq!(
1526 normalize_relative_path(Path::new("docs/../../notes.md")),
1527 PathBuf::from("../notes.md")
1528 );
1529 assert_eq!(
1530 normalize_relative_path(Path::new("../../a/./b/../notes.md")),
1531 PathBuf::from("../../a/notes.md")
1532 );
1533 }
1534
1535 #[test]
1538 fn normalize_stops_a_traversal_at_a_root() {
1539 let root = if cfg!(windows) { "C:\\" } else { "/" };
1540 assert_eq!(
1541 normalize_relative_path(&Path::new(root).join("..").join("notes.md")),
1542 Path::new(root).join("notes.md")
1543 );
1544 }
1545
1546 #[test]
1547 fn test_clear_clears_reverse_deps() {
1548 let mut index = WorkspaceIndex::new();
1549
1550 let mut file_a = FileIndex::new();
1552 file_a.add_cross_file_link(CrossFileLinkIndex {
1553 target_path: "b.md".to_string(),
1554 fragment: "".to_string(),
1555 line: 1,
1556 column: 1,
1557 origin: LinkOrigin::Body,
1558 });
1559 index.update_file(Path::new("docs/a.md"), file_a);
1560
1561 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1563
1564 index.clear();
1566
1567 assert_eq!(index.file_count(), 0);
1569 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1570 }
1571
1572 #[test]
1573 fn test_is_file_stale() {
1574 let mut index = WorkspaceIndex::new();
1575
1576 assert!(index.is_file_stale(Path::new("nonexistent.md"), "hash123"));
1578
1579 let file_index = FileIndex::with_hash("hash123".to_string());
1581 index.insert_file(PathBuf::from("docs/test.md"), file_index);
1582
1583 assert!(!index.is_file_stale(Path::new("docs/test.md"), "hash123"));
1585
1586 assert!(index.is_file_stale(Path::new("docs/test.md"), "different_hash"));
1588 }
1589
1590 #[cfg(feature = "native")]
1591 #[test]
1592 fn test_cache_roundtrip() {
1593 use std::fs;
1594
1595 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_roundtrip");
1597 let _ = fs::remove_dir_all(&temp_dir);
1598 fs::create_dir_all(&temp_dir).unwrap();
1599
1600 let mut index = WorkspaceIndex::new();
1602
1603 let mut file1 = FileIndex::with_hash("abc123".to_string());
1604 file1.add_heading(HeadingIndex {
1605 text: "Test Heading".to_string(),
1606 auto_anchor: "test-heading".to_string(),
1607 custom_anchor: Some("test".to_string()),
1608 line: 1,
1609 is_setext: false,
1610 });
1611 file1.add_cross_file_link(CrossFileLinkIndex {
1612 target_path: "./other.md".to_string(),
1613 fragment: "section".to_string(),
1614 line: 5,
1615 column: 3,
1616 origin: LinkOrigin::Body,
1617 });
1618 index.update_file(Path::new("docs/file1.md"), file1);
1619
1620 let mut file2 = FileIndex::with_hash("def456".to_string());
1621 file2.add_heading(HeadingIndex {
1622 text: "Another Heading".to_string(),
1623 auto_anchor: "another-heading".to_string(),
1624 custom_anchor: None,
1625 line: 1,
1626 is_setext: false,
1627 });
1628 index.update_file(Path::new("docs/other.md"), file2);
1629
1630 index.save_to_cache(&temp_dir).expect("Failed to save cache");
1632
1633 assert!(temp_dir.join("workspace_index.bin").exists());
1635
1636 let loaded = WorkspaceIndex::load_from_cache(&temp_dir).expect("Failed to load cache");
1638
1639 assert_eq!(loaded.file_count(), 2);
1641 assert!(loaded.contains_file(Path::new("docs/file1.md")));
1642 assert!(loaded.contains_file(Path::new("docs/other.md")));
1643
1644 let file1_loaded = loaded.get_file(Path::new("docs/file1.md")).unwrap();
1646 assert_eq!(file1_loaded.content_hash, "abc123");
1647 assert_eq!(file1_loaded.headings.len(), 1);
1648 assert_eq!(file1_loaded.headings[0].text, "Test Heading");
1649 assert_eq!(file1_loaded.headings[0].custom_anchor, Some("test".to_string()));
1650 assert_eq!(file1_loaded.cross_file_links.len(), 1);
1651 assert_eq!(file1_loaded.cross_file_links[0].target_path, "./other.md");
1652
1653 let dependents = loaded.get_dependents(Path::new("docs/other.md"));
1655 assert_eq!(dependents.len(), 1);
1656 assert_eq!(dependents[0], PathBuf::from("docs/file1.md"));
1657
1658 let _ = fs::remove_dir_all(&temp_dir);
1660 }
1661
1662 #[cfg(feature = "native")]
1663 #[test]
1664 fn test_cache_missing_file() {
1665 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_missing");
1666 let _ = std::fs::remove_dir_all(&temp_dir);
1667
1668 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1670 assert!(result.is_none());
1671 }
1672
1673 #[cfg(feature = "native")]
1674 #[test]
1675 fn test_cache_corrupted_file() {
1676 use std::fs;
1677
1678 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_corrupted");
1679 let _ = fs::remove_dir_all(&temp_dir);
1680 fs::create_dir_all(&temp_dir).unwrap();
1681
1682 fs::write(temp_dir.join("workspace_index.bin"), b"bad").unwrap();
1684
1685 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1687 assert!(result.is_none());
1688
1689 assert!(!temp_dir.join("workspace_index.bin").exists());
1691
1692 let _ = fs::remove_dir_all(&temp_dir);
1694 }
1695
1696 #[cfg(feature = "native")]
1697 #[test]
1698 fn test_cache_invalid_magic() {
1699 use std::fs;
1700
1701 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_invalid_magic");
1702 let _ = fs::remove_dir_all(&temp_dir);
1703 fs::create_dir_all(&temp_dir).unwrap();
1704
1705 let mut data = Vec::new();
1707 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();
1711
1712 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1714 assert!(result.is_none());
1715
1716 assert!(!temp_dir.join("workspace_index.bin").exists());
1718
1719 let _ = fs::remove_dir_all(&temp_dir);
1721 }
1722
1723 #[cfg(feature = "native")]
1724 #[test]
1725 fn test_cache_version_mismatch() {
1726 use std::fs;
1727
1728 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_version_mismatch");
1729 let _ = fs::remove_dir_all(&temp_dir);
1730 fs::create_dir_all(&temp_dir).unwrap();
1731
1732 let mut data = Vec::new();
1734 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();
1738
1739 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1741 assert!(result.is_none());
1742
1743 assert!(!temp_dir.join("workspace_index.bin").exists());
1745
1746 let _ = fs::remove_dir_all(&temp_dir);
1748 }
1749
1750 #[cfg(feature = "native")]
1751 #[test]
1752 fn test_cache_atomic_write() {
1753 use std::fs;
1754
1755 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_atomic");
1757 let _ = fs::remove_dir_all(&temp_dir);
1758 fs::create_dir_all(&temp_dir).unwrap();
1759
1760 let index = WorkspaceIndex::new();
1761 index.save_to_cache(&temp_dir).expect("Failed to save");
1762
1763 let entries: Vec<_> = fs::read_dir(&temp_dir).unwrap().collect();
1765 assert_eq!(entries.len(), 1);
1766 assert!(temp_dir.join("workspace_index.bin").exists());
1767
1768 let _ = fs::remove_dir_all(&temp_dir);
1770 }
1771
1772 #[test]
1773 fn test_has_anchor_auto_generated() {
1774 let mut file_index = FileIndex::new();
1775 file_index.add_heading(HeadingIndex {
1776 text: "Installation Guide".to_string(),
1777 auto_anchor: "installation-guide".to_string(),
1778 custom_anchor: None,
1779 line: 1,
1780 is_setext: false,
1781 });
1782
1783 assert!(file_index.has_anchor("installation-guide"));
1785
1786 assert!(file_index.has_anchor("Installation-Guide"));
1788 assert!(file_index.has_anchor("INSTALLATION-GUIDE"));
1789
1790 assert!(!file_index.has_anchor("nonexistent"));
1792 }
1793
1794 #[test]
1795 fn test_has_anchor_custom() {
1796 let mut file_index = FileIndex::new();
1797 file_index.add_heading(HeadingIndex {
1798 text: "Installation Guide".to_string(),
1799 auto_anchor: "installation-guide".to_string(),
1800 custom_anchor: Some("install".to_string()),
1801 line: 1,
1802 is_setext: false,
1803 });
1804
1805 assert!(file_index.has_anchor("installation-guide"));
1807
1808 assert!(file_index.has_anchor("install"));
1810 assert!(file_index.has_anchor("Install")); assert!(!file_index.has_anchor("nonexistent"));
1814 }
1815
1816 #[test]
1817 fn test_get_heading_by_anchor() {
1818 let mut file_index = FileIndex::new();
1819 file_index.add_heading(HeadingIndex {
1820 text: "Installation Guide".to_string(),
1821 auto_anchor: "installation-guide".to_string(),
1822 custom_anchor: Some("install".to_string()),
1823 line: 10,
1824 is_setext: false,
1825 });
1826 file_index.add_heading(HeadingIndex {
1827 text: "Configuration".to_string(),
1828 auto_anchor: "configuration".to_string(),
1829 custom_anchor: None,
1830 line: 20,
1831 is_setext: false,
1832 });
1833
1834 let heading = file_index.get_heading_by_anchor("installation-guide");
1836 assert!(heading.is_some());
1837 assert_eq!(heading.unwrap().text, "Installation Guide");
1838 assert_eq!(heading.unwrap().line, 10);
1839
1840 let heading = file_index.get_heading_by_anchor("install");
1842 assert!(heading.is_some());
1843 assert_eq!(heading.unwrap().text, "Installation Guide");
1844
1845 let heading = file_index.get_heading_by_anchor("configuration");
1847 assert!(heading.is_some());
1848 assert_eq!(heading.unwrap().text, "Configuration");
1849 assert_eq!(heading.unwrap().line, 20);
1850
1851 assert!(file_index.get_heading_by_anchor("nonexistent").is_none());
1853 }
1854
1855 #[test]
1856 fn test_anchor_lookup_many_headings() {
1857 let mut file_index = FileIndex::new();
1859
1860 for i in 0..100 {
1862 file_index.add_heading(HeadingIndex {
1863 text: format!("Heading {i}"),
1864 auto_anchor: format!("heading-{i}"),
1865 custom_anchor: Some(format!("h{i}")),
1866 line: i + 1,
1867 is_setext: false,
1868 });
1869 }
1870
1871 for i in 0..100 {
1873 assert!(file_index.has_anchor(&format!("heading-{i}")));
1874 assert!(file_index.has_anchor(&format!("h{i}")));
1875
1876 let heading = file_index.get_heading_by_anchor(&format!("heading-{i}"));
1877 assert!(heading.is_some());
1878 assert_eq!(heading.unwrap().line, i + 1);
1879 }
1880 }
1881
1882 #[test]
1887 fn test_extract_cross_file_links_basic() {
1888 use crate::config::MarkdownFlavor;
1889
1890 let content = "# Test\n\nSee [link](./other.md) for info.\n";
1891 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1892 let links = extract_cross_file_links(&ctx).relative;
1893
1894 assert_eq!(links.len(), 1);
1895 assert_eq!(links[0].target_path, "./other.md");
1896 assert_eq!(links[0].fragment, "");
1897 assert_eq!(links[0].line, 3);
1898 assert_eq!(links[0].column, 12);
1900 }
1901
1902 #[test]
1903 fn test_extract_cross_file_links_with_fragment() {
1904 use crate::config::MarkdownFlavor;
1905
1906 let content = "Check [guide](./guide.md#install) here.\n";
1907 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1908 let links = extract_cross_file_links(&ctx).relative;
1909
1910 assert_eq!(links.len(), 1);
1911 assert_eq!(links[0].target_path, "./guide.md");
1912 assert_eq!(links[0].fragment, "install");
1913 assert_eq!(links[0].line, 1);
1914 assert_eq!(links[0].column, 15);
1916 }
1917
1918 #[test]
1919 fn test_extract_cross_file_links_multiple_on_same_line() {
1920 use crate::config::MarkdownFlavor;
1921
1922 let content = "See [a](a.md) and [b](b.md) here.\n";
1923 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1924 let links = extract_cross_file_links(&ctx).relative;
1925
1926 assert_eq!(links.len(), 2);
1927
1928 assert_eq!(links[0].target_path, "a.md");
1929 assert_eq!(links[0].line, 1);
1930 assert_eq!(links[0].column, 9);
1932
1933 assert_eq!(links[1].target_path, "b.md");
1934 assert_eq!(links[1].line, 1);
1935 assert_eq!(links[1].column, 23);
1937 }
1938
1939 #[test]
1940 fn test_extract_cross_file_links_angle_brackets() {
1941 use crate::config::MarkdownFlavor;
1942
1943 let content = "See [link](<path/with (parens).md>) here.\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, "path/with (parens).md");
1949 assert_eq!(links[0].line, 1);
1950 assert_eq!(links[0].column, 13);
1952 }
1953
1954 #[test]
1955 fn test_extract_cross_file_links_skips_external() {
1956 use crate::config::MarkdownFlavor;
1957
1958 let content = r#"
1959[external](https://example.com)
1960[mailto](mailto:test@example.com)
1961[local](./local.md)
1962[fragment](#section)
1963[absolute](/docs/page.md)
1964"#;
1965 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1966 let extracted = extract_cross_file_links(&ctx);
1967
1968 assert_eq!(extracted.relative.len(), 1);
1970 assert_eq!(extracted.relative[0].target_path, "./local.md");
1971 assert_eq!(extracted.root_relative.len(), 1);
1973 assert_eq!(extracted.root_relative[0].target_path, "docs/page.md");
1974 }
1975
1976 #[test]
1977 fn test_extract_cross_file_links_root_relative() {
1978 use crate::config::MarkdownFlavor;
1979
1980 let content = "[a](/guide.md#install)\n[b](/../escape.md)\n[c](//host/x.md)\n[d](/img/pic.png)\n";
1984 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1985 let extracted = extract_cross_file_links(&ctx);
1986
1987 assert!(extracted.relative.is_empty(), "no directory-relative links here");
1988 assert_eq!(
1989 extracted
1990 .root_relative
1991 .iter()
1992 .map(|l| (l.target_path.as_str(), l.fragment.as_str()))
1993 .collect::<Vec<_>>(),
1994 vec![("guide.md", "install")],
1995 "only the safe root-relative markdown link is captured"
1996 );
1997 }
1998
1999 #[test]
2000 fn test_extract_cross_file_links_skips_non_markdown() {
2001 use crate::config::MarkdownFlavor;
2002
2003 let content = r#"
2004[image](./photo.png)
2005[doc](./readme.md)
2006[pdf](./document.pdf)
2007"#;
2008 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2009 let links = extract_cross_file_links(&ctx).relative;
2010
2011 assert_eq!(links.len(), 1);
2013 assert_eq!(links[0].target_path, "./readme.md");
2014 }
2015
2016 #[test]
2017 fn test_extract_cross_file_links_skips_code_spans() {
2018 use crate::config::MarkdownFlavor;
2019
2020 let content = "Normal [link](./file.md) and `[code](./ignored.md)` here.\n";
2021 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2022 let links = extract_cross_file_links(&ctx).relative;
2023
2024 assert_eq!(links.len(), 1);
2026 assert_eq!(links[0].target_path, "./file.md");
2027 }
2028
2029 #[test]
2030 fn test_extract_cross_file_links_with_query_params() {
2031 use crate::config::MarkdownFlavor;
2032
2033 let content = "See [doc](./file.md?raw=true) here.\n";
2034 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2035 let links = extract_cross_file_links(&ctx).relative;
2036
2037 assert_eq!(links.len(), 1);
2038 assert_eq!(links[0].target_path, "./file.md");
2040 }
2041
2042 #[test]
2043 fn test_extract_cross_file_links_empty_content() {
2044 use crate::config::MarkdownFlavor;
2045
2046 let content = "";
2047 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2048 let links = extract_cross_file_links(&ctx).relative;
2049
2050 assert!(links.is_empty());
2051 }
2052
2053 #[test]
2054 fn test_extract_cross_file_links_no_links() {
2055 use crate::config::MarkdownFlavor;
2056
2057 let content = "# Just a heading\n\nSome text without links.\n";
2058 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2059 let links = extract_cross_file_links(&ctx).relative;
2060
2061 assert!(links.is_empty());
2062 }
2063
2064 #[test]
2065 fn test_extract_cross_file_links_position_accuracy_issue_234() {
2066 use crate::config::MarkdownFlavor;
2069
2070 let content = r#"# Test Document
2071
2072Here is a [broken link](nonexistent-file.md) that should trigger MD057.
2073
2074And another [link](also-missing.md) on this line.
2075"#;
2076 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2077 let links = extract_cross_file_links(&ctx).relative;
2078
2079 assert_eq!(links.len(), 2);
2080
2081 assert_eq!(links[0].target_path, "nonexistent-file.md");
2083 assert_eq!(links[0].line, 3);
2084 assert_eq!(links[0].column, 25);
2085
2086 assert_eq!(links[1].target_path, "also-missing.md");
2088 assert_eq!(links[1].line, 5);
2089 assert_eq!(links[1].column, 20);
2090 }
2091}