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
168 let mut processed_lines = HashSet::new();
171
172 for link in ctx.links() {
173 let line_idx = link.line - 1;
174 if line_idx >= lines.len() {
175 continue;
176 }
177
178 if !processed_lines.insert(line_idx) {
180 continue;
181 }
182
183 let line = lines[line_idx];
184 if !line.contains("](") {
185 continue;
186 }
187
188 for link_match in LINK_START_REGEX.find_iter(line) {
190 let start_pos = link_match.start();
191 let end_pos = link_match.end();
192
193 let line_start_byte = ctx.line_start_byte(line_idx + 1).unwrap_or(0);
195 let absolute_start_pos = line_start_byte + start_pos;
196
197 if ctx.is_in_code_span_byte(absolute_start_pos) {
199 continue;
200 }
201
202 let caps_result = URL_EXTRACT_ANGLE_BRACKET_REGEX
205 .captures_at(line, end_pos - 1)
206 .or_else(|| URL_EXTRACT_REGEX.captures_at(line, end_pos - 1));
207
208 if let Some(caps) = caps_result
209 && let Some(url_group) = caps.get(1)
210 {
211 let file_path = url_group.as_str().trim();
212
213 if let Some(rel) = file_path.strip_prefix('/') {
218 if !rel.starts_with('/')
219 && !Path::new(rel)
220 .components()
221 .any(|c| matches!(c, std::path::Component::ParentDir))
222 {
223 let stripped = strip_query_and_fragment(rel);
224 if is_markdown_file(stripped) {
225 let fragment = caps.get(2).map_or("", |m| m.as_str().trim_start_matches('#'));
226 links.root_relative.push(CrossFileLinkIndex {
227 target_path: stripped.to_string(),
228 fragment: fragment.to_string(),
229 line: link.line,
230 column: byte_to_char_count(line, url_group.start()),
231 origin: LinkOrigin::Body,
232 });
233 }
234 }
235 continue;
236 }
237
238 if file_path.is_empty()
241 || PROTOCOL_DOMAIN_REGEX.is_match(file_path)
242 || file_path.starts_with("www.")
243 || file_path.starts_with('#')
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('`') && file_path.ends_with('`'))
249 {
250 continue;
251 }
252
253 let file_path = strip_query_and_fragment(file_path);
255
256 let fragment = caps.get(2).map_or("", |m| m.as_str().trim_start_matches('#'));
258
259 if is_markdown_file(file_path) {
261 links.relative.push(CrossFileLinkIndex {
262 target_path: file_path.to_string(),
263 fragment: fragment.to_string(),
264 line: link.line,
265 column: byte_to_char_count(line, url_group.start()),
266 origin: LinkOrigin::Body,
267 });
268 }
269 }
270 }
271 }
272
273 links
274}
275
276#[cfg(feature = "postcard")]
278const CACHE_MAGIC: &[u8; 4] = b"RWSI";
279
280#[cfg(feature = "postcard")]
298const CACHE_FORMAT_VERSION: u32 = 11;
299
300#[cfg(feature = "postcard")]
302const CACHE_FILE_NAME: &str = "workspace_index.bin";
303
304#[cfg(feature = "postcard")]
308static CACHE_TEMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
309
310#[derive(Debug, Default, Clone, Serialize, Deserialize)]
315pub struct WorkspaceIndex {
316 files: HashMap<PathBuf, FileIndex>,
318 reverse_deps: HashMap<PathBuf, HashSet<PathBuf>>,
321 version: u64,
323}
324
325#[derive(Debug, Clone, Default, Serialize, Deserialize)]
327pub struct FileIndex {
328 pub headings: Vec<HeadingIndex>,
330 pub reference_links: Vec<ReferenceLinkIndex>,
332 pub cross_file_links: Vec<CrossFileLinkIndex>,
334 #[serde(default)]
339 pub root_relative_links: Vec<CrossFileLinkIndex>,
340 pub defined_references: HashSet<String>,
343 pub content_hash: String,
345 anchor_to_heading: HashMap<String, usize>,
348 #[serde(default)]
352 anchor_to_heading_exact: HashMap<String, usize>,
353 html_anchors: HashSet<String>,
356 #[serde(default)]
359 html_anchors_exact: HashSet<String>,
360 attribute_anchors: HashSet<String>,
364 #[serde(default)]
367 attribute_anchors_exact: HashSet<String>,
368 pub file_disabled_rules: HashSet<String>,
371 pub persistent_transitions: Vec<(usize, HashSet<String>, HashSet<String>)>,
374 pub line_disabled_rules: HashMap<usize, HashSet<String>>,
376}
377
378#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
380pub struct HeadingIndex {
381 pub text: String,
383 pub auto_anchor: String,
385 pub custom_anchor: Option<String>,
387 pub line: usize,
389 #[serde(default)]
391 pub is_setext: bool,
392}
393
394#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
396pub struct ReferenceLinkIndex {
397 pub reference_id: String,
399 pub line: usize,
401 pub column: usize,
403}
404
405#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
413pub enum LinkOrigin {
414 Body,
416 FrontMatter { field: Option<String> },
425}
426
427#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
429pub struct CrossFileLinkIndex {
430 pub target_path: String,
432 pub fragment: String,
434 pub line: usize,
436 pub column: usize,
438 pub origin: LinkOrigin,
440}
441
442pub fn link_target_candidates(source_file: &Path, target_path: &str) -> Vec<PathBuf> {
453 let target_path = strip_query_and_fragment(target_path);
454
455 let joined = match source_file.parent() {
456 Some(parent) => parent.join(target_path),
457 None => PathBuf::from(target_path),
458 };
459 let base = normalize_relative_path(&joined);
460
461 if base.extension().is_some() {
462 return vec![base];
463 }
464
465 let mut candidates = Vec::with_capacity(crate::discovery::MARKDOWN_EXTENSIONS.len() + 1);
468 for ext in crate::discovery::MARKDOWN_EXTENSIONS {
469 candidates.push(base.with_extension(ext));
470 }
471 candidates.insert(0, base);
472 candidates
473}
474
475pub fn normalize_relative_path(path: &Path) -> PathBuf {
486 let mut components: Vec<std::path::Component<'_>> = Vec::new();
487 for component in path.components() {
488 match component {
489 std::path::Component::CurDir => {}
490 std::path::Component::ParentDir => match components.last() {
491 Some(std::path::Component::Normal(_)) => {
492 components.pop();
493 }
494 Some(std::path::Component::RootDir) => {}
495 _ => components.push(component),
496 },
497 c => components.push(c),
498 }
499 }
500 components.iter().collect()
501}
502
503impl CrossFileLinkIndex {
504 pub fn is_navigable(&self) -> bool {
512 matches!(self.origin, LinkOrigin::Body)
513 }
514}
515
516#[derive(Debug, Clone, Serialize, Deserialize)]
518pub struct VulnerableAnchor {
519 pub file: PathBuf,
521 pub line: usize,
523 pub text: String,
525}
526
527impl WorkspaceIndex {
528 pub fn new() -> Self {
530 Self::default()
531 }
532
533 pub fn version(&self) -> u64 {
535 self.version
536 }
537
538 pub fn file_count(&self) -> usize {
540 self.files.len()
541 }
542
543 pub fn contains_file(&self, path: &Path) -> bool {
545 self.files.contains_key(path)
546 }
547
548 pub fn get_file(&self, path: &Path) -> Option<&FileIndex> {
550 self.files.get(path)
551 }
552
553 pub fn insert_file(&mut self, path: PathBuf, index: FileIndex) {
555 self.files.insert(path, index);
556 self.version = self.version.wrapping_add(1);
557 }
558
559 pub fn remove_file(&mut self, path: &Path) -> Option<FileIndex> {
561 self.clear_reverse_deps_for(path);
563
564 let result = self.files.remove(path);
565 if result.is_some() {
566 self.version = self.version.wrapping_add(1);
567 }
568 result
569 }
570
571 pub fn get_vulnerable_anchors(&self) -> HashMap<String, Vec<VulnerableAnchor>> {
581 let mut vulnerable: HashMap<String, Vec<VulnerableAnchor>> = HashMap::new();
582
583 for (file_path, file_index) in &self.files {
584 for heading in &file_index.headings {
585 if heading.custom_anchor.is_none() && !heading.auto_anchor.is_empty() {
587 let anchor_key = heading.auto_anchor.to_lowercase();
588 vulnerable.entry(anchor_key).or_default().push(VulnerableAnchor {
589 file: file_path.clone(),
590 line: heading.line,
591 text: heading.text.clone(),
592 });
593 }
594 }
595 }
596
597 vulnerable
598 }
599
600 pub fn all_headings(&self) -> impl Iterator<Item = (&Path, &HeadingIndex)> {
602 self.files
603 .iter()
604 .flat_map(|(path, index)| index.headings.iter().map(move |h| (path.as_path(), h)))
605 }
606
607 pub fn files(&self) -> impl Iterator<Item = (&Path, &FileIndex)> {
609 self.files.iter().map(|(p, i)| (p.as_path(), i))
610 }
611
612 pub fn files_sorted(&self) -> Vec<(&Path, &FileIndex)> {
618 let mut entries: Vec<(&Path, &FileIndex)> = self.files.iter().map(|(p, i)| (p.as_path(), i)).collect();
619 entries.sort_by_key(|(a, _)| *a);
620 entries
621 }
622
623 pub fn clear(&mut self) {
625 self.files.clear();
626 self.reverse_deps.clear();
627 self.version = self.version.wrapping_add(1);
628 }
629
630 pub fn update_file(&mut self, path: &Path, index: FileIndex) {
637 self.clear_reverse_deps_as_source(path);
640
641 for link in &index.cross_file_links {
643 let target = self.resolve_target_path(path, &link.target_path);
644 self.reverse_deps.entry(target).or_default().insert(path.to_path_buf());
645 }
646
647 self.files.insert(path.to_path_buf(), index);
648 self.version = self.version.wrapping_add(1);
649 }
650
651 pub fn get_dependents(&self, path: &Path) -> Vec<PathBuf> {
656 self.reverse_deps
657 .get(path)
658 .map(|set| set.iter().cloned().collect())
659 .unwrap_or_default()
660 }
661
662 pub fn is_file_stale(&self, path: &Path, current_hash: &str) -> bool {
666 self.files.get(path).is_none_or(|f| f.content_hash != current_hash)
667 }
668
669 pub fn retain_only(&mut self, current_files: &std::collections::HashSet<PathBuf>) -> usize {
674 let before_count = self.files.len();
675
676 let to_remove: Vec<PathBuf> = self
678 .files
679 .keys()
680 .filter(|path| !current_files.contains(*path))
681 .cloned()
682 .collect();
683
684 for path in &to_remove {
686 self.remove_file(path);
687 }
688
689 before_count - self.files.len()
690 }
691
692 #[cfg(feature = "postcard")]
699 pub fn save_to_cache(&self, cache_dir: &Path) -> std::io::Result<()> {
700 use std::fs;
701 use std::io::Write;
702
703 fs::create_dir_all(cache_dir)?;
705
706 let encoded = postcard::to_allocvec(self)
708 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
709
710 let mut cache_data = Vec::with_capacity(8 + encoded.len());
712 cache_data.extend_from_slice(CACHE_MAGIC);
713 cache_data.extend_from_slice(&CACHE_FORMAT_VERSION.to_le_bytes());
714 cache_data.extend_from_slice(&encoded);
715
716 let final_path = cache_dir.join(CACHE_FILE_NAME);
721 let counter = CACHE_TEMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
722 #[cfg(not(target_arch = "wasm32"))]
723 let temp_path = cache_dir.join(format!("{CACHE_FILE_NAME}.tmp.{}.{counter}", std::process::id()));
724 #[cfg(target_arch = "wasm32")]
725 let temp_path = cache_dir.join(format!("{CACHE_FILE_NAME}.tmp.{counter}"));
726
727 {
729 let mut file = fs::File::create(&temp_path)?;
730 file.write_all(&cache_data)?;
731 file.sync_all()?;
732 }
733
734 fs::rename(&temp_path, &final_path)?;
736
737 log::debug!(
738 "Saved workspace index to cache: {} files, {} bytes (format v{})",
739 self.files.len(),
740 cache_data.len(),
741 CACHE_FORMAT_VERSION
742 );
743
744 Ok(())
745 }
746
747 #[cfg(feature = "postcard")]
755 pub fn load_from_cache(cache_dir: &Path) -> Option<Self> {
756 use std::fs;
757
758 let path = cache_dir.join(CACHE_FILE_NAME);
759 let data = fs::read(&path).ok()?;
760
761 if data.len() < 8 {
763 log::warn!("Workspace index cache too small, discarding");
764 let _ = fs::remove_file(&path);
765 return None;
766 }
767
768 if &data[0..4] != CACHE_MAGIC {
770 log::warn!("Workspace index cache has invalid magic header, discarding");
771 let _ = fs::remove_file(&path);
772 return None;
773 }
774
775 let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
777 if version != CACHE_FORMAT_VERSION {
778 log::info!(
779 "Workspace index cache format version mismatch (got {version}, expected {CACHE_FORMAT_VERSION}), rebuilding"
780 );
781 let _ = fs::remove_file(&path);
782 return None;
783 }
784
785 match postcard::from_bytes::<Self>(&data[8..]) {
787 Ok(index) => {
788 log::debug!(
789 "Loaded workspace index from cache: {} files (format v{})",
790 index.files.len(),
791 version
792 );
793 Some(index)
794 }
795 Err(e) => {
796 log::warn!("Failed to deserialize workspace index cache: {e}");
797 let _ = fs::remove_file(&path);
798 None
799 }
800 }
801 }
802
803 fn clear_reverse_deps_as_source(&mut self, path: &Path) {
808 let targets: Vec<PathBuf> = match self.files.get(path) {
815 Some(index) => index
816 .cross_file_links
817 .iter()
818 .map(|link| self.resolve_target_path(path, &link.target_path))
819 .collect(),
820 None => return,
821 };
822 for target in targets {
823 if let Some(deps) = self.reverse_deps.get_mut(&target) {
824 deps.remove(path);
825 if deps.is_empty() {
826 self.reverse_deps.remove(&target);
827 }
828 }
829 }
830 }
831
832 fn clear_reverse_deps_for(&mut self, path: &Path) {
837 self.clear_reverse_deps_as_source(path);
839
840 self.reverse_deps.remove(path);
842 }
843
844 fn resolve_target_path(&self, source_file: &Path, relative_target: &str) -> PathBuf {
850 let source_dir = source_file.parent().unwrap_or(Path::new(""));
851 link_target_file(source_dir, relative_target)
852 }
853}
854
855impl FileIndex {
856 pub fn new() -> Self {
858 Self::default()
859 }
860
861 pub fn with_hash(content_hash: String) -> Self {
863 Self {
864 content_hash,
865 ..Default::default()
866 }
867 }
868
869 pub fn extracted_data_differs(&self, other: &Self) -> bool {
885 let Self {
889 headings,
890 reference_links,
891 cross_file_links,
892 root_relative_links,
893 defined_references,
894 content_hash: _,
895 anchor_to_heading,
896 anchor_to_heading_exact,
897 html_anchors,
898 html_anchors_exact,
899 attribute_anchors,
900 attribute_anchors_exact,
901 file_disabled_rules,
902 persistent_transitions,
903 line_disabled_rules,
904 } = self;
905
906 headings != &other.headings
907 || reference_links != &other.reference_links
908 || cross_file_links != &other.cross_file_links
909 || root_relative_links != &other.root_relative_links
910 || defined_references != &other.defined_references
911 || anchor_to_heading != &other.anchor_to_heading
912 || anchor_to_heading_exact != &other.anchor_to_heading_exact
913 || html_anchors != &other.html_anchors
914 || html_anchors_exact != &other.html_anchors_exact
915 || attribute_anchors != &other.attribute_anchors
916 || attribute_anchors_exact != &other.attribute_anchors_exact
917 || file_disabled_rules != &other.file_disabled_rules
918 || persistent_transitions != &other.persistent_transitions
919 || line_disabled_rules != &other.line_disabled_rules
920 }
921
922 pub fn add_heading(&mut self, heading: HeadingIndex) {
928 let index = self.headings.len();
929
930 self.anchor_to_heading.insert(heading.auto_anchor.to_lowercase(), index);
933 self.anchor_to_heading_exact.insert(heading.auto_anchor.clone(), index);
934
935 if let Some(ref custom) = heading.custom_anchor {
937 self.anchor_to_heading.insert(custom.to_lowercase(), index);
938 self.anchor_to_heading_exact.insert(custom.clone(), index);
939 }
940
941 self.headings.push(heading);
942 }
943
944 pub fn add_anchor_alias(&mut self, anchor: &str, heading_index: usize) {
947 if heading_index < self.headings.len() {
948 self.anchor_to_heading.insert(anchor.to_lowercase(), heading_index);
949 self.anchor_to_heading_exact.insert(anchor.to_string(), heading_index);
950 }
951 }
952
953 pub fn has_anchor(&self, anchor: &str) -> bool {
964 self.has_anchor_with_case(anchor, true)
965 }
966
967 pub fn has_anchor_with_case(&self, anchor: &str, ignore_case: bool) -> bool {
976 if self.lookup_anchor(anchor, ignore_case) {
977 return true;
978 }
979
980 if anchor.contains('%') {
982 let decoded = url_decode(anchor);
983 if decoded != anchor {
984 return self.lookup_anchor(&decoded, ignore_case);
985 }
986 }
987
988 false
989 }
990
991 fn lookup_anchor(&self, anchor: &str, ignore_case: bool) -> bool {
994 if ignore_case {
995 let lower = anchor.to_lowercase();
996 self.anchor_to_heading.contains_key(&lower)
997 || self.html_anchors.contains(&lower)
998 || self.attribute_anchors.contains(&lower)
999 } else {
1000 self.anchor_to_heading_exact.contains_key(anchor)
1001 || self.html_anchors_exact.contains(anchor)
1002 || self.attribute_anchors_exact.contains(anchor)
1003 }
1004 }
1005
1006 pub fn add_html_anchor(&mut self, anchor: &str) {
1009 if !anchor.is_empty() {
1010 self.html_anchors.insert(anchor.to_lowercase());
1011 self.html_anchors_exact.insert(anchor.to_string());
1012 }
1013 }
1014
1015 pub fn add_attribute_anchor(&mut self, anchor: &str) {
1018 if !anchor.is_empty() {
1019 self.attribute_anchors.insert(anchor.to_lowercase());
1020 self.attribute_anchors_exact.insert(anchor.to_string());
1021 }
1022 }
1023
1024 pub fn get_heading_by_anchor(&self, anchor: &str) -> Option<&HeadingIndex> {
1028 self.anchor_to_heading
1029 .get(&anchor.to_lowercase())
1030 .and_then(|&idx| self.headings.get(idx))
1031 }
1032
1033 pub fn add_reference_link(&mut self, link: ReferenceLinkIndex) {
1035 self.reference_links.push(link);
1036 }
1037
1038 pub fn is_rule_disabled_at_line(&self, rule_name: &str, line: usize) -> bool {
1043 if self.file_disabled_rules.contains("*") || self.file_disabled_rules.contains(rule_name) {
1045 return true;
1046 }
1047
1048 if let Some(rules) = self.line_disabled_rules.get(&line)
1050 && (rules.contains("*") || rules.contains(rule_name))
1051 {
1052 return true;
1053 }
1054
1055 if !self.persistent_transitions.is_empty() {
1057 let idx = match self.persistent_transitions.binary_search_by_key(&line, |t| t.0) {
1058 Ok(i) => Some(i),
1059 Err(i) => {
1060 if i > 0 {
1061 Some(i - 1)
1062 } else {
1063 None
1064 }
1065 }
1066 };
1067 if let Some(i) = idx {
1068 let (_, ref disabled, ref enabled) = self.persistent_transitions[i];
1069 if disabled.contains("*") {
1070 return !enabled.contains(rule_name);
1071 }
1072 return disabled.contains(rule_name);
1073 }
1074 }
1075
1076 false
1077 }
1078
1079 pub fn add_cross_file_link(&mut self, link: CrossFileLinkIndex) {
1094 let existing = self.cross_file_links.iter_mut().find(|existing| {
1095 existing.fragment == link.fragment
1096 && existing.line == link.line
1097 && strip_query_and_fragment(&existing.target_path) == strip_query_and_fragment(&link.target_path)
1098 });
1099 match existing {
1100 Some(existing) => {
1103 if !existing.target_path.contains('?') && link.target_path.contains('?') {
1104 *existing = link;
1105 }
1106 }
1107 None => self.cross_file_links.push(link),
1108 }
1109 }
1110
1111 pub fn add_root_relative_link(&mut self, link: CrossFileLinkIndex) {
1113 let is_duplicate = self.root_relative_links.iter().any(|existing| {
1114 existing.target_path == link.target_path && existing.fragment == link.fragment && existing.line == link.line
1115 });
1116 if !is_duplicate {
1117 self.root_relative_links.push(link);
1118 }
1119 }
1120
1121 pub fn add_defined_reference(&mut self, ref_id: String) {
1123 self.defined_references.insert(ref_id);
1124 }
1125
1126 pub fn has_defined_reference(&self, ref_id: &str) -> bool {
1128 self.defined_references.contains(ref_id)
1129 }
1130
1131 pub fn hash_matches(&self, hash: &str) -> bool {
1133 self.content_hash == hash
1134 }
1135
1136 pub fn heading_count(&self) -> usize {
1138 self.headings.len()
1139 }
1140
1141 pub fn reference_link_count(&self) -> usize {
1143 self.reference_links.len()
1144 }
1145}
1146
1147#[cfg(test)]
1148mod tests {
1149 use super::*;
1150
1151 #[test]
1152 fn test_workspace_index_basic() {
1153 let mut index = WorkspaceIndex::new();
1154 assert_eq!(index.file_count(), 0);
1155 assert_eq!(index.version(), 0);
1156
1157 let mut file_index = FileIndex::with_hash("abc123".to_string());
1158 file_index.add_heading(HeadingIndex {
1159 text: "Installation".to_string(),
1160 auto_anchor: "installation".to_string(),
1161 custom_anchor: None,
1162 line: 1,
1163 is_setext: false,
1164 });
1165
1166 index.insert_file(PathBuf::from("docs/install.md"), file_index);
1167 assert_eq!(index.file_count(), 1);
1168 assert_eq!(index.version(), 1);
1169
1170 assert!(index.contains_file(Path::new("docs/install.md")));
1171 assert!(!index.contains_file(Path::new("docs/other.md")));
1172 }
1173
1174 #[test]
1175 fn test_vulnerable_anchors() {
1176 let mut index = WorkspaceIndex::new();
1177
1178 let mut file1 = FileIndex::new();
1180 file1.add_heading(HeadingIndex {
1181 text: "Getting Started".to_string(),
1182 auto_anchor: "getting-started".to_string(),
1183 custom_anchor: None,
1184 line: 1,
1185 is_setext: false,
1186 });
1187 index.insert_file(PathBuf::from("docs/guide.md"), file1);
1188
1189 let mut file2 = FileIndex::new();
1191 file2.add_heading(HeadingIndex {
1192 text: "Installation".to_string(),
1193 auto_anchor: "installation".to_string(),
1194 custom_anchor: Some("install".to_string()),
1195 line: 1,
1196 is_setext: false,
1197 });
1198 index.insert_file(PathBuf::from("docs/install.md"), file2);
1199
1200 let vulnerable = index.get_vulnerable_anchors();
1201 assert_eq!(vulnerable.len(), 1);
1202 assert!(vulnerable.contains_key("getting-started"));
1203 assert!(!vulnerable.contains_key("installation"));
1204
1205 let anchors = vulnerable.get("getting-started").unwrap();
1206 assert_eq!(anchors.len(), 1);
1207 assert_eq!(anchors[0].file, PathBuf::from("docs/guide.md"));
1208 assert_eq!(anchors[0].text, "Getting Started");
1209 }
1210
1211 #[test]
1212 fn test_vulnerable_anchors_multiple_files_same_anchor() {
1213 let mut index = WorkspaceIndex::new();
1216
1217 let mut file1 = FileIndex::new();
1219 file1.add_heading(HeadingIndex {
1220 text: "Installation".to_string(),
1221 auto_anchor: "installation".to_string(),
1222 custom_anchor: None,
1223 line: 1,
1224 is_setext: false,
1225 });
1226 index.insert_file(PathBuf::from("docs/en/guide.md"), file1);
1227
1228 let mut file2 = FileIndex::new();
1230 file2.add_heading(HeadingIndex {
1231 text: "Installation".to_string(),
1232 auto_anchor: "installation".to_string(),
1233 custom_anchor: None,
1234 line: 5,
1235 is_setext: false,
1236 });
1237 index.insert_file(PathBuf::from("docs/fr/guide.md"), file2);
1238
1239 let mut file3 = FileIndex::new();
1241 file3.add_heading(HeadingIndex {
1242 text: "Installation".to_string(),
1243 auto_anchor: "installation".to_string(),
1244 custom_anchor: Some("install".to_string()),
1245 line: 10,
1246 is_setext: false,
1247 });
1248 index.insert_file(PathBuf::from("docs/de/guide.md"), file3);
1249
1250 let vulnerable = index.get_vulnerable_anchors();
1251 assert_eq!(vulnerable.len(), 1); assert!(vulnerable.contains_key("installation"));
1253
1254 let anchors = vulnerable.get("installation").unwrap();
1255 assert_eq!(anchors.len(), 2, "Should collect both vulnerable anchors");
1257
1258 let files: std::collections::HashSet<_> = anchors.iter().map(|a| &a.file).collect();
1260 assert!(files.contains(&PathBuf::from("docs/en/guide.md")));
1261 assert!(files.contains(&PathBuf::from("docs/fr/guide.md")));
1262 }
1263
1264 #[test]
1265 fn test_file_index_hash() {
1266 let index = FileIndex::with_hash("hash123".to_string());
1267 assert!(index.hash_matches("hash123"));
1268 assert!(!index.hash_matches("other"));
1269 }
1270
1271 #[test]
1272 fn test_version_increment() {
1273 let mut index = WorkspaceIndex::new();
1274 assert_eq!(index.version(), 0);
1275
1276 index.insert_file(PathBuf::from("a.md"), FileIndex::new());
1277 assert_eq!(index.version(), 1);
1278
1279 index.insert_file(PathBuf::from("b.md"), FileIndex::new());
1280 assert_eq!(index.version(), 2);
1281
1282 index.remove_file(Path::new("a.md"));
1283 assert_eq!(index.version(), 3);
1284
1285 index.remove_file(Path::new("nonexistent.md"));
1287 assert_eq!(index.version(), 3);
1288 }
1289
1290 #[test]
1291 fn test_files_sorted_is_path_ordered() {
1292 let mut index = WorkspaceIndex::new();
1293 for name in ["docs/zebra.md", "docs/apple.md", "docs/mango.md"] {
1295 index.update_file(Path::new(name), FileIndex::new());
1296 }
1297
1298 let paths: Vec<&Path> = index.files_sorted().into_iter().map(|(p, _)| p).collect();
1299 assert_eq!(
1300 paths,
1301 vec![
1302 Path::new("docs/apple.md"),
1303 Path::new("docs/mango.md"),
1304 Path::new("docs/zebra.md"),
1305 ],
1306 "files_sorted() must return entries ordered by path"
1307 );
1308 }
1309
1310 #[test]
1315 fn test_add_cross_file_link_keeps_the_destination_as_written() {
1316 let as_written = CrossFileLinkIndex {
1317 target_path: "other.md?raw=true".to_string(),
1318 fragment: "missing".to_string(),
1319 line: 3,
1320 column: 1,
1321 origin: LinkOrigin::Body,
1322 };
1323 let file_named = CrossFileLinkIndex {
1324 target_path: "other.md".to_string(),
1325 fragment: "missing".to_string(),
1326 line: 3,
1327 column: 9,
1328 origin: LinkOrigin::Body,
1329 };
1330
1331 for (first, second) in [
1332 (as_written.clone(), file_named.clone()),
1333 (file_named.clone(), as_written.clone()),
1334 ] {
1335 let mut index = FileIndex::new();
1336 index.add_cross_file_link(first);
1337 index.add_cross_file_link(second);
1338
1339 assert_eq!(
1340 index.cross_file_links.len(),
1341 1,
1342 "one link is one entry, got: {:?}",
1343 index.cross_file_links
1344 );
1345 assert_eq!(index.cross_file_links[0].target_path, "other.md?raw=true");
1346 }
1347 }
1348
1349 #[test]
1352 fn test_add_cross_file_link_keeps_distinct_targets() {
1353 let mut index = FileIndex::new();
1354 for target in ["one.md", "two.md"] {
1355 index.add_cross_file_link(CrossFileLinkIndex {
1356 target_path: target.to_string(),
1357 fragment: "missing".to_string(),
1358 line: 3,
1359 column: 1,
1360 origin: LinkOrigin::Body,
1361 });
1362 }
1363 assert_eq!(index.cross_file_links.len(), 2);
1364 }
1365
1366 #[test]
1374 fn test_add_cross_file_link_collapses_one_line_asking_one_file_once() {
1375 let link = |target: &str, fragment: &str, line: usize| CrossFileLinkIndex {
1376 target_path: target.to_string(),
1377 fragment: fragment.to_string(),
1378 line,
1379 column: 1,
1380 origin: LinkOrigin::Body,
1381 };
1382
1383 let mut index = FileIndex::new();
1384 index.add_cross_file_link(link("target.md?raw=true", "missing", 3));
1385 index.add_cross_file_link(link("target.md?plain=1", "missing", 3));
1386 assert_eq!(
1387 index.cross_file_links.len(),
1388 1,
1389 "one file, one fragment, one line is one entry, got: {:?}",
1390 index.cross_file_links
1391 );
1392
1393 index.add_cross_file_link(link("target.md", "other", 3));
1394 index.add_cross_file_link(link("target.md", "missing", 4));
1395 assert_eq!(index.cross_file_links.len(), 3);
1396 }
1397
1398 #[test]
1404 fn test_reverse_deps_ignore_a_query_string_on_the_destination() {
1405 let mut index = WorkspaceIndex::new();
1406
1407 let mut file_a = FileIndex::new();
1408 file_a.add_cross_file_link(CrossFileLinkIndex {
1409 target_path: "b.md?raw=true".to_string(),
1410 fragment: "section".to_string(),
1411 line: 10,
1412 column: 5,
1413 origin: LinkOrigin::Body,
1414 });
1415 index.update_file(Path::new("docs/a.md"), file_a);
1416
1417 assert_eq!(
1418 index.get_dependents(Path::new("docs/b.md")),
1419 vec![PathBuf::from("docs/a.md")],
1420 "editing docs/b.md must re-lint the file linking to it"
1421 );
1422
1423 index.update_file(Path::new("docs/a.md"), FileIndex::new());
1426 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1427 }
1428
1429 #[test]
1430 fn test_reverse_deps_basic() {
1431 let mut index = WorkspaceIndex::new();
1432
1433 let mut file_a = FileIndex::new();
1435 file_a.add_cross_file_link(CrossFileLinkIndex {
1436 target_path: "b.md".to_string(),
1437 fragment: "section".to_string(),
1438 line: 10,
1439 column: 5,
1440 origin: LinkOrigin::Body,
1441 });
1442 index.update_file(Path::new("docs/a.md"), file_a);
1443
1444 let dependents = index.get_dependents(Path::new("docs/b.md"));
1446 assert_eq!(dependents.len(), 1);
1447 assert_eq!(dependents[0], PathBuf::from("docs/a.md"));
1448
1449 let a_dependents = index.get_dependents(Path::new("docs/a.md"));
1451 assert!(a_dependents.is_empty());
1452 }
1453
1454 #[test]
1455 fn test_reverse_deps_multiple() {
1456 let mut index = WorkspaceIndex::new();
1457
1458 let mut file_a = FileIndex::new();
1460 file_a.add_cross_file_link(CrossFileLinkIndex {
1461 target_path: "../b.md".to_string(),
1462 fragment: "".to_string(),
1463 line: 1,
1464 column: 1,
1465 origin: LinkOrigin::Body,
1466 });
1467 index.update_file(Path::new("docs/sub/a.md"), file_a);
1468
1469 let mut file_c = FileIndex::new();
1470 file_c.add_cross_file_link(CrossFileLinkIndex {
1471 target_path: "b.md".to_string(),
1472 fragment: "".to_string(),
1473 line: 1,
1474 column: 1,
1475 origin: LinkOrigin::Body,
1476 });
1477 index.update_file(Path::new("docs/c.md"), file_c);
1478
1479 let dependents = index.get_dependents(Path::new("docs/b.md"));
1481 assert_eq!(dependents.len(), 2);
1482 assert!(dependents.contains(&PathBuf::from("docs/sub/a.md")));
1483 assert!(dependents.contains(&PathBuf::from("docs/c.md")));
1484 }
1485
1486 #[test]
1487 fn test_reverse_deps_update_clears_old() {
1488 let mut index = WorkspaceIndex::new();
1489
1490 let mut file_a = FileIndex::new();
1492 file_a.add_cross_file_link(CrossFileLinkIndex {
1493 target_path: "b.md".to_string(),
1494 fragment: "".to_string(),
1495 line: 1,
1496 column: 1,
1497 origin: LinkOrigin::Body,
1498 });
1499 index.update_file(Path::new("docs/a.md"), file_a);
1500
1501 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1503
1504 let mut file_a_updated = FileIndex::new();
1506 file_a_updated.add_cross_file_link(CrossFileLinkIndex {
1507 target_path: "c.md".to_string(),
1508 fragment: "".to_string(),
1509 line: 1,
1510 column: 1,
1511 origin: LinkOrigin::Body,
1512 });
1513 index.update_file(Path::new("docs/a.md"), file_a_updated);
1514
1515 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1517
1518 let c_deps = index.get_dependents(Path::new("docs/c.md"));
1520 assert_eq!(c_deps.len(), 1);
1521 assert_eq!(c_deps[0], PathBuf::from("docs/a.md"));
1522 }
1523
1524 #[test]
1525 fn test_reverse_deps_remove_file() {
1526 let mut index = WorkspaceIndex::new();
1527
1528 let mut file_a = FileIndex::new();
1530 file_a.add_cross_file_link(CrossFileLinkIndex {
1531 target_path: "b.md".to_string(),
1532 fragment: "".to_string(),
1533 line: 1,
1534 column: 1,
1535 origin: LinkOrigin::Body,
1536 });
1537 index.update_file(Path::new("docs/a.md"), file_a);
1538
1539 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1541
1542 index.remove_file(Path::new("docs/a.md"));
1544
1545 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1547 }
1548
1549 #[test]
1550 fn test_normalize_path() {
1551 let path = Path::new("docs/sub/../other.md");
1553 let normalized = normalize_relative_path(path);
1554 assert_eq!(normalized, PathBuf::from("docs/other.md"));
1555
1556 let path2 = Path::new("docs/./other.md");
1558 let normalized2 = normalize_relative_path(path2);
1559 assert_eq!(normalized2, PathBuf::from("docs/other.md"));
1560
1561 let path3 = Path::new("a/b/c/../../d.md");
1563 let normalized3 = normalize_relative_path(path3);
1564 assert_eq!(normalized3, PathBuf::from("a/d.md"));
1565 }
1566
1567 #[test]
1572 fn normalize_keeps_a_traversal_that_leaves_its_own_root() {
1573 assert_eq!(
1574 normalize_relative_path(Path::new("../notes.md")),
1575 PathBuf::from("../notes.md")
1576 );
1577 assert_eq!(
1578 normalize_relative_path(Path::new("docs/../../notes.md")),
1579 PathBuf::from("../notes.md")
1580 );
1581 assert_eq!(
1582 normalize_relative_path(Path::new("../../a/./b/../notes.md")),
1583 PathBuf::from("../../a/notes.md")
1584 );
1585 }
1586
1587 #[test]
1590 fn normalize_stops_a_traversal_at_a_root() {
1591 let root = if cfg!(windows) { "C:\\" } else { "/" };
1592 assert_eq!(
1593 normalize_relative_path(&Path::new(root).join("..").join("notes.md")),
1594 Path::new(root).join("notes.md")
1595 );
1596 }
1597
1598 #[test]
1599 fn test_clear_clears_reverse_deps() {
1600 let mut index = WorkspaceIndex::new();
1601
1602 let mut file_a = FileIndex::new();
1604 file_a.add_cross_file_link(CrossFileLinkIndex {
1605 target_path: "b.md".to_string(),
1606 fragment: "".to_string(),
1607 line: 1,
1608 column: 1,
1609 origin: LinkOrigin::Body,
1610 });
1611 index.update_file(Path::new("docs/a.md"), file_a);
1612
1613 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1615
1616 index.clear();
1618
1619 assert_eq!(index.file_count(), 0);
1621 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1622 }
1623
1624 #[test]
1625 fn test_is_file_stale() {
1626 let mut index = WorkspaceIndex::new();
1627
1628 assert!(index.is_file_stale(Path::new("nonexistent.md"), "hash123"));
1630
1631 let file_index = FileIndex::with_hash("hash123".to_string());
1633 index.insert_file(PathBuf::from("docs/test.md"), file_index);
1634
1635 assert!(!index.is_file_stale(Path::new("docs/test.md"), "hash123"));
1637
1638 assert!(index.is_file_stale(Path::new("docs/test.md"), "different_hash"));
1640 }
1641
1642 #[cfg(feature = "native")]
1643 #[test]
1644 fn test_cache_roundtrip() {
1645 use std::fs;
1646
1647 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_roundtrip");
1649 let _ = fs::remove_dir_all(&temp_dir);
1650 fs::create_dir_all(&temp_dir).unwrap();
1651
1652 let mut index = WorkspaceIndex::new();
1654
1655 let mut file1 = FileIndex::with_hash("abc123".to_string());
1656 file1.add_heading(HeadingIndex {
1657 text: "Test Heading".to_string(),
1658 auto_anchor: "test-heading".to_string(),
1659 custom_anchor: Some("test".to_string()),
1660 line: 1,
1661 is_setext: false,
1662 });
1663 file1.add_cross_file_link(CrossFileLinkIndex {
1664 target_path: "./other.md".to_string(),
1665 fragment: "section".to_string(),
1666 line: 5,
1667 column: 3,
1668 origin: LinkOrigin::Body,
1669 });
1670 index.update_file(Path::new("docs/file1.md"), file1);
1671
1672 let mut file2 = FileIndex::with_hash("def456".to_string());
1673 file2.add_heading(HeadingIndex {
1674 text: "Another Heading".to_string(),
1675 auto_anchor: "another-heading".to_string(),
1676 custom_anchor: None,
1677 line: 1,
1678 is_setext: false,
1679 });
1680 index.update_file(Path::new("docs/other.md"), file2);
1681
1682 index.save_to_cache(&temp_dir).expect("Failed to save cache");
1684
1685 assert!(temp_dir.join("workspace_index.bin").exists());
1687
1688 let loaded = WorkspaceIndex::load_from_cache(&temp_dir).expect("Failed to load cache");
1690
1691 assert_eq!(loaded.file_count(), 2);
1693 assert!(loaded.contains_file(Path::new("docs/file1.md")));
1694 assert!(loaded.contains_file(Path::new("docs/other.md")));
1695
1696 let file1_loaded = loaded.get_file(Path::new("docs/file1.md")).unwrap();
1698 assert_eq!(file1_loaded.content_hash, "abc123");
1699 assert_eq!(file1_loaded.headings.len(), 1);
1700 assert_eq!(file1_loaded.headings[0].text, "Test Heading");
1701 assert_eq!(file1_loaded.headings[0].custom_anchor, Some("test".to_string()));
1702 assert_eq!(file1_loaded.cross_file_links.len(), 1);
1703 assert_eq!(file1_loaded.cross_file_links[0].target_path, "./other.md");
1704
1705 let dependents = loaded.get_dependents(Path::new("docs/other.md"));
1707 assert_eq!(dependents.len(), 1);
1708 assert_eq!(dependents[0], PathBuf::from("docs/file1.md"));
1709
1710 let _ = fs::remove_dir_all(&temp_dir);
1712 }
1713
1714 #[cfg(feature = "native")]
1715 #[test]
1716 fn test_cache_missing_file() {
1717 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_missing");
1718 let _ = std::fs::remove_dir_all(&temp_dir);
1719
1720 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1722 assert!(result.is_none());
1723 }
1724
1725 #[cfg(feature = "native")]
1726 #[test]
1727 fn test_cache_corrupted_file() {
1728 use std::fs;
1729
1730 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_corrupted");
1731 let _ = fs::remove_dir_all(&temp_dir);
1732 fs::create_dir_all(&temp_dir).unwrap();
1733
1734 fs::write(temp_dir.join("workspace_index.bin"), b"bad").unwrap();
1736
1737 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1739 assert!(result.is_none());
1740
1741 assert!(!temp_dir.join("workspace_index.bin").exists());
1743
1744 let _ = fs::remove_dir_all(&temp_dir);
1746 }
1747
1748 #[cfg(feature = "native")]
1749 #[test]
1750 fn test_cache_invalid_magic() {
1751 use std::fs;
1752
1753 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_invalid_magic");
1754 let _ = fs::remove_dir_all(&temp_dir);
1755 fs::create_dir_all(&temp_dir).unwrap();
1756
1757 let mut data = Vec::new();
1759 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();
1763
1764 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1766 assert!(result.is_none());
1767
1768 assert!(!temp_dir.join("workspace_index.bin").exists());
1770
1771 let _ = fs::remove_dir_all(&temp_dir);
1773 }
1774
1775 #[cfg(feature = "native")]
1776 #[test]
1777 fn test_cache_version_mismatch() {
1778 use std::fs;
1779
1780 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_version_mismatch");
1781 let _ = fs::remove_dir_all(&temp_dir);
1782 fs::create_dir_all(&temp_dir).unwrap();
1783
1784 let mut data = Vec::new();
1786 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();
1790
1791 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1793 assert!(result.is_none());
1794
1795 assert!(!temp_dir.join("workspace_index.bin").exists());
1797
1798 let _ = fs::remove_dir_all(&temp_dir);
1800 }
1801
1802 #[cfg(feature = "native")]
1803 #[test]
1804 fn test_cache_atomic_write() {
1805 use std::fs;
1806
1807 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_atomic");
1809 let _ = fs::remove_dir_all(&temp_dir);
1810 fs::create_dir_all(&temp_dir).unwrap();
1811
1812 let index = WorkspaceIndex::new();
1813 index.save_to_cache(&temp_dir).expect("Failed to save");
1814
1815 let entries: Vec<_> = fs::read_dir(&temp_dir).unwrap().collect();
1817 assert_eq!(entries.len(), 1);
1818 assert!(temp_dir.join("workspace_index.bin").exists());
1819
1820 let _ = fs::remove_dir_all(&temp_dir);
1822 }
1823
1824 #[test]
1825 fn test_has_anchor_auto_generated() {
1826 let mut file_index = FileIndex::new();
1827 file_index.add_heading(HeadingIndex {
1828 text: "Installation Guide".to_string(),
1829 auto_anchor: "installation-guide".to_string(),
1830 custom_anchor: None,
1831 line: 1,
1832 is_setext: false,
1833 });
1834
1835 assert!(file_index.has_anchor("installation-guide"));
1837
1838 assert!(file_index.has_anchor("Installation-Guide"));
1840 assert!(file_index.has_anchor("INSTALLATION-GUIDE"));
1841
1842 assert!(!file_index.has_anchor("nonexistent"));
1844 }
1845
1846 #[test]
1847 fn test_has_anchor_custom() {
1848 let mut file_index = FileIndex::new();
1849 file_index.add_heading(HeadingIndex {
1850 text: "Installation Guide".to_string(),
1851 auto_anchor: "installation-guide".to_string(),
1852 custom_anchor: Some("install".to_string()),
1853 line: 1,
1854 is_setext: false,
1855 });
1856
1857 assert!(file_index.has_anchor("installation-guide"));
1859
1860 assert!(file_index.has_anchor("install"));
1862 assert!(file_index.has_anchor("Install")); assert!(!file_index.has_anchor("nonexistent"));
1866 }
1867
1868 #[test]
1869 fn test_get_heading_by_anchor() {
1870 let mut file_index = FileIndex::new();
1871 file_index.add_heading(HeadingIndex {
1872 text: "Installation Guide".to_string(),
1873 auto_anchor: "installation-guide".to_string(),
1874 custom_anchor: Some("install".to_string()),
1875 line: 10,
1876 is_setext: false,
1877 });
1878 file_index.add_heading(HeadingIndex {
1879 text: "Configuration".to_string(),
1880 auto_anchor: "configuration".to_string(),
1881 custom_anchor: None,
1882 line: 20,
1883 is_setext: false,
1884 });
1885
1886 let heading = file_index.get_heading_by_anchor("installation-guide");
1888 assert!(heading.is_some());
1889 assert_eq!(heading.unwrap().text, "Installation Guide");
1890 assert_eq!(heading.unwrap().line, 10);
1891
1892 let heading = file_index.get_heading_by_anchor("install");
1894 assert!(heading.is_some());
1895 assert_eq!(heading.unwrap().text, "Installation Guide");
1896
1897 let heading = file_index.get_heading_by_anchor("configuration");
1899 assert!(heading.is_some());
1900 assert_eq!(heading.unwrap().text, "Configuration");
1901 assert_eq!(heading.unwrap().line, 20);
1902
1903 assert!(file_index.get_heading_by_anchor("nonexistent").is_none());
1905 }
1906
1907 #[test]
1908 fn test_anchor_lookup_many_headings() {
1909 let mut file_index = FileIndex::new();
1911
1912 for i in 0..100 {
1914 file_index.add_heading(HeadingIndex {
1915 text: format!("Heading {i}"),
1916 auto_anchor: format!("heading-{i}"),
1917 custom_anchor: Some(format!("h{i}")),
1918 line: i + 1,
1919 is_setext: false,
1920 });
1921 }
1922
1923 for i in 0..100 {
1925 assert!(file_index.has_anchor(&format!("heading-{i}")));
1926 assert!(file_index.has_anchor(&format!("h{i}")));
1927
1928 let heading = file_index.get_heading_by_anchor(&format!("heading-{i}"));
1929 assert!(heading.is_some());
1930 assert_eq!(heading.unwrap().line, i + 1);
1931 }
1932 }
1933
1934 #[test]
1939 fn test_extract_cross_file_links_basic() {
1940 use crate::config::MarkdownFlavor;
1941
1942 let content = "# Test\n\nSee [link](./other.md) for info.\n";
1943 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1944 let links = extract_cross_file_links(&ctx).relative;
1945
1946 assert_eq!(links.len(), 1);
1947 assert_eq!(links[0].target_path, "./other.md");
1948 assert_eq!(links[0].fragment, "");
1949 assert_eq!(links[0].line, 3);
1950 assert_eq!(links[0].column, 12);
1952 }
1953
1954 #[test]
1955 fn test_extract_cross_file_links_with_fragment() {
1956 use crate::config::MarkdownFlavor;
1957
1958 let content = "Check [guide](./guide.md#install) here.\n";
1959 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1960 let links = extract_cross_file_links(&ctx).relative;
1961
1962 assert_eq!(links.len(), 1);
1963 assert_eq!(links[0].target_path, "./guide.md");
1964 assert_eq!(links[0].fragment, "install");
1965 assert_eq!(links[0].line, 1);
1966 assert_eq!(links[0].column, 15);
1968 }
1969
1970 #[test]
1971 fn test_extract_cross_file_links_multiple_on_same_line() {
1972 use crate::config::MarkdownFlavor;
1973
1974 let content = "See [a](a.md) and [b](b.md) here.\n";
1975 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1976 let links = extract_cross_file_links(&ctx).relative;
1977
1978 assert_eq!(links.len(), 2);
1979
1980 assert_eq!(links[0].target_path, "a.md");
1981 assert_eq!(links[0].line, 1);
1982 assert_eq!(links[0].column, 9);
1984
1985 assert_eq!(links[1].target_path, "b.md");
1986 assert_eq!(links[1].line, 1);
1987 assert_eq!(links[1].column, 23);
1989 }
1990
1991 #[test]
1992 fn test_extract_cross_file_links_angle_brackets() {
1993 use crate::config::MarkdownFlavor;
1994
1995 let content = "See [link](<path/with (parens).md>) here.\n";
1996 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1997 let links = extract_cross_file_links(&ctx).relative;
1998
1999 assert_eq!(links.len(), 1);
2000 assert_eq!(links[0].target_path, "path/with (parens).md");
2001 assert_eq!(links[0].line, 1);
2002 assert_eq!(links[0].column, 13);
2004 }
2005
2006 #[test]
2007 fn test_extract_cross_file_links_skips_external() {
2008 use crate::config::MarkdownFlavor;
2009
2010 let content = r#"
2011[external](https://example.com)
2012[mailto](mailto:test@example.com)
2013[local](./local.md)
2014[fragment](#section)
2015[absolute](/docs/page.md)
2016"#;
2017 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2018 let extracted = extract_cross_file_links(&ctx);
2019
2020 assert_eq!(extracted.relative.len(), 1);
2022 assert_eq!(extracted.relative[0].target_path, "./local.md");
2023 assert_eq!(extracted.root_relative.len(), 1);
2025 assert_eq!(extracted.root_relative[0].target_path, "docs/page.md");
2026 }
2027
2028 #[test]
2029 fn test_extract_cross_file_links_root_relative() {
2030 use crate::config::MarkdownFlavor;
2031
2032 let content = "[a](/guide.md#install)\n[b](/../escape.md)\n[c](//host/x.md)\n[d](/img/pic.png)\n";
2036 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2037 let extracted = extract_cross_file_links(&ctx);
2038
2039 assert!(extracted.relative.is_empty(), "no directory-relative links here");
2040 assert_eq!(
2041 extracted
2042 .root_relative
2043 .iter()
2044 .map(|l| (l.target_path.as_str(), l.fragment.as_str()))
2045 .collect::<Vec<_>>(),
2046 vec![("guide.md", "install")],
2047 "only the safe root-relative markdown link is captured"
2048 );
2049 }
2050
2051 #[test]
2052 fn test_extract_cross_file_links_skips_non_markdown() {
2053 use crate::config::MarkdownFlavor;
2054
2055 let content = r#"
2056[image](./photo.png)
2057[doc](./readme.md)
2058[pdf](./document.pdf)
2059"#;
2060 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2061 let links = extract_cross_file_links(&ctx).relative;
2062
2063 assert_eq!(links.len(), 1);
2065 assert_eq!(links[0].target_path, "./readme.md");
2066 }
2067
2068 #[test]
2069 fn test_extract_cross_file_links_skips_code_spans() {
2070 use crate::config::MarkdownFlavor;
2071
2072 let content = "Normal [link](./file.md) and `[code](./ignored.md)` here.\n";
2073 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2074 let links = extract_cross_file_links(&ctx).relative;
2075
2076 assert_eq!(links.len(), 1);
2078 assert_eq!(links[0].target_path, "./file.md");
2079 }
2080
2081 #[test]
2082 fn test_extract_cross_file_links_with_query_params() {
2083 use crate::config::MarkdownFlavor;
2084
2085 let content = "See [doc](./file.md?raw=true) here.\n";
2086 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2087 let links = extract_cross_file_links(&ctx).relative;
2088
2089 assert_eq!(links.len(), 1);
2090 assert_eq!(links[0].target_path, "./file.md");
2092 }
2093
2094 #[test]
2095 fn test_extract_cross_file_links_empty_content() {
2096 use crate::config::MarkdownFlavor;
2097
2098 let content = "";
2099 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2100 let links = extract_cross_file_links(&ctx).relative;
2101
2102 assert!(links.is_empty());
2103 }
2104
2105 #[test]
2106 fn test_extract_cross_file_links_no_links() {
2107 use crate::config::MarkdownFlavor;
2108
2109 let content = "# Just a heading\n\nSome text without links.\n";
2110 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2111 let links = extract_cross_file_links(&ctx).relative;
2112
2113 assert!(links.is_empty());
2114 }
2115
2116 #[test]
2117 fn test_extract_cross_file_links_position_accuracy_issue_234() {
2118 use crate::config::MarkdownFlavor;
2121
2122 let content = r#"# Test Document
2123
2124Here is a [broken link](nonexistent-file.md) that should trigger MD057.
2125
2126And another [link](also-missing.md) on this line.
2127"#;
2128 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2129 let links = extract_cross_file_links(&ctx).relative;
2130
2131 assert_eq!(links.len(), 2);
2132
2133 assert_eq!(links[0].target_path, "nonexistent-file.md");
2135 assert_eq!(links[0].line, 3);
2136 assert_eq!(links[0].column, 25);
2137
2138 assert_eq!(links[1].target_path, "also-missing.md");
2140 assert_eq!(links[1].line, 5);
2141 assert_eq!(links[1].column, 20);
2142 }
2143}