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 ctx.line_info(link.line).is_some_and(|info| info.in_front_matter) {
182 continue;
183 }
184
185 if !processed_lines.insert(line_idx) {
187 continue;
188 }
189
190 let line = lines[line_idx];
191 if !line.contains("](") {
192 continue;
193 }
194
195 for link_match in LINK_START_REGEX.find_iter(line) {
197 let start_pos = link_match.start();
198 let end_pos = link_match.end();
199
200 let line_start_byte = ctx.line_start_byte(line_idx + 1).unwrap_or(0);
202 let absolute_start_pos = line_start_byte + start_pos;
203
204 if ctx.is_in_code_span_byte(absolute_start_pos) {
206 continue;
207 }
208
209 let caps_result = URL_EXTRACT_ANGLE_BRACKET_REGEX
212 .captures_at(line, end_pos - 1)
213 .or_else(|| URL_EXTRACT_REGEX.captures_at(line, end_pos - 1));
214
215 if let Some(caps) = caps_result
216 && let Some(url_group) = caps.get(1)
217 {
218 let file_path = url_group.as_str().trim();
219
220 if let Some(rel) = file_path.strip_prefix('/') {
225 if !rel.starts_with('/')
226 && !Path::new(rel)
227 .components()
228 .any(|c| matches!(c, std::path::Component::ParentDir))
229 {
230 let stripped = strip_query_and_fragment(rel);
231 if is_markdown_file(stripped) {
232 let fragment = caps.get(2).map_or("", |m| m.as_str().trim_start_matches('#'));
233 links.root_relative.push(CrossFileLinkIndex {
234 target_path: stripped.to_string(),
235 fragment: fragment.to_string(),
236 line: link.line,
237 column: byte_to_char_count(line, url_group.start()),
238 origin: LinkOrigin::Body,
239 });
240 }
241 }
242 continue;
243 }
244
245 if file_path.is_empty()
248 || PROTOCOL_DOMAIN_REGEX.is_match(file_path)
249 || file_path.starts_with("www.")
250 || file_path.starts_with('#')
251 || file_path.starts_with("{{")
252 || file_path.starts_with("{%")
253 || file_path.starts_with('~')
254 || file_path.starts_with('@')
255 || (file_path.starts_with('`') && file_path.ends_with('`'))
256 {
257 continue;
258 }
259
260 let file_path = strip_query_and_fragment(file_path);
262
263 let fragment = caps.get(2).map_or("", |m| m.as_str().trim_start_matches('#'));
265
266 if is_markdown_file(file_path) {
268 links.relative.push(CrossFileLinkIndex {
269 target_path: file_path.to_string(),
270 fragment: fragment.to_string(),
271 line: link.line,
272 column: byte_to_char_count(line, url_group.start()),
273 origin: LinkOrigin::Body,
274 });
275 }
276 }
277 }
278 }
279
280 links
281}
282
283#[cfg(feature = "postcard")]
285const CACHE_MAGIC: &[u8; 4] = b"RWSI";
286
287#[cfg(feature = "postcard")]
313const CACHE_FORMAT_VERSION: u32 = 13;
314
315#[cfg(feature = "postcard")]
317const CACHE_FILE_NAME: &str = "workspace_index.bin";
318
319#[cfg(feature = "postcard")]
323static CACHE_TEMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
324
325#[derive(Debug, Default, Clone, Serialize, Deserialize)]
330pub struct WorkspaceIndex {
331 files: HashMap<PathBuf, FileIndex>,
333 reverse_deps: HashMap<PathBuf, HashSet<PathBuf>>,
336 version: u64,
338}
339
340#[derive(Debug, Clone, Default, Serialize, Deserialize)]
342pub struct FileIndex {
343 pub headings: Vec<HeadingIndex>,
345 pub reference_links: Vec<ReferenceLinkIndex>,
347 pub cross_file_links: Vec<CrossFileLinkIndex>,
349 #[serde(default)]
354 pub root_relative_links: Vec<CrossFileLinkIndex>,
355 #[serde(default)]
360 pub md057_link_targets: Vec<Md057LinkTarget>,
361 pub defined_references: HashSet<String>,
364 pub content_hash: String,
366 anchor_to_heading: HashMap<String, usize>,
369 #[serde(default)]
373 anchor_to_heading_exact: HashMap<String, usize>,
374 html_anchors: HashSet<String>,
377 #[serde(default)]
380 html_anchors_exact: HashSet<String>,
381 attribute_anchors: HashSet<String>,
385 #[serde(default)]
388 attribute_anchors_exact: HashSet<String>,
389 pub file_disabled_rules: HashSet<String>,
392 pub persistent_transitions: Vec<(usize, HashSet<String>, HashSet<String>)>,
395 pub line_disabled_rules: HashMap<usize, HashSet<String>>,
397}
398
399#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
401pub struct HeadingIndex {
402 pub text: String,
404 pub auto_anchor: String,
406 pub custom_anchor: Option<String>,
408 pub line: usize,
410 #[serde(default)]
412 pub is_setext: bool,
413}
414
415#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
417pub struct ReferenceLinkIndex {
418 pub reference_id: String,
420 pub line: usize,
422 pub column: usize,
424}
425
426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
434pub enum LinkOrigin {
435 Body,
437 FrontMatter { field: Option<String> },
446}
447
448#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
450pub struct CrossFileLinkIndex {
451 pub target_path: String,
453 pub fragment: String,
455 pub line: usize,
457 pub column: usize,
459 pub origin: LinkOrigin,
461}
462
463#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
465pub struct Md057LinkTarget {
466 pub target: String,
468 pub origin: LinkOrigin,
470}
471
472pub fn link_target_candidates(source_file: &Path, target_path: &str) -> Vec<PathBuf> {
483 let target_path = strip_query_and_fragment(target_path);
484
485 let joined = match source_file.parent() {
486 Some(parent) => parent.join(target_path),
487 None => PathBuf::from(target_path),
488 };
489 let base = normalize_relative_path(&joined);
490
491 if base.extension().is_some() {
492 return vec![base];
493 }
494
495 let mut candidates = Vec::with_capacity(crate::discovery::MARKDOWN_EXTENSIONS.len() + 1);
498 for ext in crate::discovery::MARKDOWN_EXTENSIONS {
499 candidates.push(base.with_extension(ext));
500 }
501 candidates.insert(0, base);
502 candidates
503}
504
505pub fn normalize_relative_path(path: &Path) -> PathBuf {
516 let mut components: Vec<std::path::Component<'_>> = Vec::new();
517 for component in path.components() {
518 match component {
519 std::path::Component::CurDir => {}
520 std::path::Component::ParentDir => match components.last() {
521 Some(std::path::Component::Normal(_)) => {
522 components.pop();
523 }
524 Some(std::path::Component::RootDir) => {}
525 _ => components.push(component),
526 },
527 c => components.push(c),
528 }
529 }
530 components.iter().collect()
531}
532
533impl CrossFileLinkIndex {
534 pub fn is_navigable(&self) -> bool {
542 matches!(self.origin, LinkOrigin::Body)
543 }
544}
545
546#[derive(Debug, Clone, Serialize, Deserialize)]
548pub struct VulnerableAnchor {
549 pub file: PathBuf,
551 pub line: usize,
553 pub text: String,
555}
556
557impl WorkspaceIndex {
558 pub fn new() -> Self {
560 Self::default()
561 }
562
563 pub fn version(&self) -> u64 {
565 self.version
566 }
567
568 pub fn file_count(&self) -> usize {
570 self.files.len()
571 }
572
573 pub fn contains_file(&self, path: &Path) -> bool {
575 self.files.contains_key(path)
576 }
577
578 pub fn get_file(&self, path: &Path) -> Option<&FileIndex> {
580 self.files.get(path)
581 }
582
583 pub fn insert_file(&mut self, path: PathBuf, index: FileIndex) {
585 self.files.insert(path, index);
586 self.version = self.version.wrapping_add(1);
587 }
588
589 pub fn remove_file(&mut self, path: &Path) -> Option<FileIndex> {
591 self.clear_reverse_deps_for(path);
593
594 let result = self.files.remove(path);
595 if result.is_some() {
596 self.version = self.version.wrapping_add(1);
597 }
598 result
599 }
600
601 pub fn get_vulnerable_anchors(&self) -> HashMap<String, Vec<VulnerableAnchor>> {
611 let mut vulnerable: HashMap<String, Vec<VulnerableAnchor>> = HashMap::new();
612
613 for (file_path, file_index) in &self.files {
614 for heading in &file_index.headings {
615 if heading.custom_anchor.is_none() && !heading.auto_anchor.is_empty() {
617 let anchor_key = heading.auto_anchor.to_lowercase();
618 vulnerable.entry(anchor_key).or_default().push(VulnerableAnchor {
619 file: file_path.clone(),
620 line: heading.line,
621 text: heading.text.clone(),
622 });
623 }
624 }
625 }
626
627 vulnerable
628 }
629
630 pub fn all_headings(&self) -> impl Iterator<Item = (&Path, &HeadingIndex)> {
632 self.files
633 .iter()
634 .flat_map(|(path, index)| index.headings.iter().map(move |h| (path.as_path(), h)))
635 }
636
637 pub fn files(&self) -> impl Iterator<Item = (&Path, &FileIndex)> {
639 self.files.iter().map(|(p, i)| (p.as_path(), i))
640 }
641
642 pub fn files_sorted(&self) -> Vec<(&Path, &FileIndex)> {
648 let mut entries: Vec<(&Path, &FileIndex)> = self.files.iter().map(|(p, i)| (p.as_path(), i)).collect();
649 entries.sort_by_key(|(a, _)| *a);
650 entries
651 }
652
653 pub fn clear(&mut self) {
655 self.files.clear();
656 self.reverse_deps.clear();
657 self.version = self.version.wrapping_add(1);
658 }
659
660 pub fn update_file(&mut self, path: &Path, index: FileIndex) {
667 self.clear_reverse_deps_as_source(path);
670
671 for link in &index.cross_file_links {
673 let target = self.resolve_target_path(path, &link.target_path);
674 self.reverse_deps.entry(target).or_default().insert(path.to_path_buf());
675 }
676
677 self.files.insert(path.to_path_buf(), index);
678 self.version = self.version.wrapping_add(1);
679 }
680
681 pub fn get_dependents(&self, path: &Path) -> Vec<PathBuf> {
686 self.reverse_deps
687 .get(path)
688 .map(|set| set.iter().cloned().collect())
689 .unwrap_or_default()
690 }
691
692 pub fn is_file_stale(&self, path: &Path, current_hash: &str) -> bool {
696 self.files.get(path).is_none_or(|f| f.content_hash != current_hash)
697 }
698
699 pub fn retain_only(&mut self, current_files: &std::collections::HashSet<PathBuf>) -> usize {
704 let before_count = self.files.len();
705
706 let to_remove: Vec<PathBuf> = self
708 .files
709 .keys()
710 .filter(|path| !current_files.contains(*path))
711 .cloned()
712 .collect();
713
714 for path in &to_remove {
716 self.remove_file(path);
717 }
718
719 before_count - self.files.len()
720 }
721
722 #[cfg(feature = "postcard")]
729 pub fn save_to_cache(&self, cache_dir: &Path) -> std::io::Result<()> {
730 use std::fs;
731 use std::io::Write;
732
733 fs::create_dir_all(cache_dir)?;
735
736 let encoded = postcard::to_allocvec(self)
738 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
739
740 let mut cache_data = Vec::with_capacity(8 + encoded.len());
742 cache_data.extend_from_slice(CACHE_MAGIC);
743 cache_data.extend_from_slice(&CACHE_FORMAT_VERSION.to_le_bytes());
744 cache_data.extend_from_slice(&encoded);
745
746 let final_path = cache_dir.join(CACHE_FILE_NAME);
751 let counter = CACHE_TEMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
752 #[cfg(not(target_arch = "wasm32"))]
753 let temp_path = cache_dir.join(format!("{CACHE_FILE_NAME}.tmp.{}.{counter}", std::process::id()));
754 #[cfg(target_arch = "wasm32")]
755 let temp_path = cache_dir.join(format!("{CACHE_FILE_NAME}.tmp.{counter}"));
756
757 {
759 let mut file = fs::File::create(&temp_path)?;
760 file.write_all(&cache_data)?;
761 file.sync_all()?;
762 }
763
764 fs::rename(&temp_path, &final_path)?;
766
767 log::debug!(
768 "Saved workspace index to cache: {} files, {} bytes (format v{})",
769 self.files.len(),
770 cache_data.len(),
771 CACHE_FORMAT_VERSION
772 );
773
774 Ok(())
775 }
776
777 #[cfg(feature = "postcard")]
785 pub fn load_from_cache(cache_dir: &Path) -> Option<Self> {
786 use std::fs;
787
788 let path = cache_dir.join(CACHE_FILE_NAME);
789 let data = fs::read(&path).ok()?;
790
791 if data.len() < 8 {
793 log::warn!("Workspace index cache too small, discarding");
794 let _ = fs::remove_file(&path);
795 return None;
796 }
797
798 if &data[0..4] != CACHE_MAGIC {
800 log::warn!("Workspace index cache has invalid magic header, discarding");
801 let _ = fs::remove_file(&path);
802 return None;
803 }
804
805 let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
807 if version != CACHE_FORMAT_VERSION {
808 log::info!(
809 "Workspace index cache format version mismatch (got {version}, expected {CACHE_FORMAT_VERSION}), rebuilding"
810 );
811 let _ = fs::remove_file(&path);
812 return None;
813 }
814
815 match postcard::from_bytes::<Self>(&data[8..]) {
817 Ok(index) => {
818 log::debug!(
819 "Loaded workspace index from cache: {} files (format v{})",
820 index.files.len(),
821 version
822 );
823 Some(index)
824 }
825 Err(e) => {
826 log::warn!("Failed to deserialize workspace index cache: {e}");
827 let _ = fs::remove_file(&path);
828 None
829 }
830 }
831 }
832
833 fn clear_reverse_deps_as_source(&mut self, path: &Path) {
838 let targets: Vec<PathBuf> = match self.files.get(path) {
845 Some(index) => index
846 .cross_file_links
847 .iter()
848 .map(|link| self.resolve_target_path(path, &link.target_path))
849 .collect(),
850 None => return,
851 };
852 for target in targets {
853 if let Some(deps) = self.reverse_deps.get_mut(&target) {
854 deps.remove(path);
855 if deps.is_empty() {
856 self.reverse_deps.remove(&target);
857 }
858 }
859 }
860 }
861
862 fn clear_reverse_deps_for(&mut self, path: &Path) {
867 self.clear_reverse_deps_as_source(path);
869
870 self.reverse_deps.remove(path);
872 }
873
874 fn resolve_target_path(&self, source_file: &Path, relative_target: &str) -> PathBuf {
880 let source_dir = source_file.parent().unwrap_or(Path::new(""));
881 link_target_file(source_dir, relative_target)
882 }
883}
884
885impl FileIndex {
886 pub fn new() -> Self {
888 Self::default()
889 }
890
891 pub fn with_hash(content_hash: String) -> Self {
893 Self {
894 content_hash,
895 ..Default::default()
896 }
897 }
898
899 pub fn extracted_data_differs(&self, other: &Self) -> bool {
915 let Self {
919 headings,
920 reference_links,
921 cross_file_links,
922 root_relative_links,
923 md057_link_targets,
924 defined_references,
925 content_hash: _,
926 anchor_to_heading,
927 anchor_to_heading_exact,
928 html_anchors,
929 html_anchors_exact,
930 attribute_anchors,
931 attribute_anchors_exact,
932 file_disabled_rules,
933 persistent_transitions,
934 line_disabled_rules,
935 } = self;
936
937 headings != &other.headings
938 || reference_links != &other.reference_links
939 || cross_file_links != &other.cross_file_links
940 || root_relative_links != &other.root_relative_links
941 || md057_link_targets != &other.md057_link_targets
942 || defined_references != &other.defined_references
943 || anchor_to_heading != &other.anchor_to_heading
944 || anchor_to_heading_exact != &other.anchor_to_heading_exact
945 || html_anchors != &other.html_anchors
946 || html_anchors_exact != &other.html_anchors_exact
947 || attribute_anchors != &other.attribute_anchors
948 || attribute_anchors_exact != &other.attribute_anchors_exact
949 || file_disabled_rules != &other.file_disabled_rules
950 || persistent_transitions != &other.persistent_transitions
951 || line_disabled_rules != &other.line_disabled_rules
952 }
953
954 pub fn add_heading(&mut self, heading: HeadingIndex) {
960 let index = self.headings.len();
961
962 self.anchor_to_heading.insert(heading.auto_anchor.to_lowercase(), index);
965 self.anchor_to_heading_exact.insert(heading.auto_anchor.clone(), index);
966
967 if let Some(ref custom) = heading.custom_anchor {
969 self.anchor_to_heading.insert(custom.to_lowercase(), index);
970 self.anchor_to_heading_exact.insert(custom.clone(), index);
971 }
972
973 self.headings.push(heading);
974 }
975
976 pub fn add_anchor_alias(&mut self, anchor: &str, heading_index: usize) {
979 if heading_index < self.headings.len() {
980 self.anchor_to_heading.insert(anchor.to_lowercase(), heading_index);
981 self.anchor_to_heading_exact.insert(anchor.to_string(), heading_index);
982 }
983 }
984
985 pub fn has_anchor(&self, anchor: &str) -> bool {
996 self.has_anchor_with_case(anchor, true)
997 }
998
999 pub fn has_anchor_with_case(&self, anchor: &str, ignore_case: bool) -> bool {
1008 if self.lookup_anchor(anchor, ignore_case) {
1009 return true;
1010 }
1011
1012 if anchor.contains('%') {
1014 let decoded = url_decode(anchor);
1015 if decoded != anchor {
1016 return self.lookup_anchor(&decoded, ignore_case);
1017 }
1018 }
1019
1020 false
1021 }
1022
1023 fn lookup_anchor(&self, anchor: &str, ignore_case: bool) -> bool {
1026 if ignore_case {
1027 let lower = anchor.to_lowercase();
1028 self.anchor_to_heading.contains_key(&lower)
1029 || self.html_anchors.contains(&lower)
1030 || self.attribute_anchors.contains(&lower)
1031 } else {
1032 self.anchor_to_heading_exact.contains_key(anchor)
1033 || self.html_anchors_exact.contains(anchor)
1034 || self.attribute_anchors_exact.contains(anchor)
1035 }
1036 }
1037
1038 pub fn add_html_anchor(&mut self, anchor: &str) {
1041 if !anchor.is_empty() {
1042 self.html_anchors.insert(anchor.to_lowercase());
1043 self.html_anchors_exact.insert(anchor.to_string());
1044 }
1045 }
1046
1047 pub fn add_attribute_anchor(&mut self, anchor: &str) {
1050 if !anchor.is_empty() {
1051 self.attribute_anchors.insert(anchor.to_lowercase());
1052 self.attribute_anchors_exact.insert(anchor.to_string());
1053 }
1054 }
1055
1056 pub fn get_heading_by_anchor(&self, anchor: &str) -> Option<&HeadingIndex> {
1060 self.anchor_to_heading
1061 .get(&anchor.to_lowercase())
1062 .and_then(|&idx| self.headings.get(idx))
1063 }
1064
1065 pub fn add_reference_link(&mut self, link: ReferenceLinkIndex) {
1067 self.reference_links.push(link);
1068 }
1069
1070 pub fn is_rule_disabled_at_line(&self, rule_name: &str, line: usize) -> bool {
1075 if self.file_disabled_rules.contains("*") || self.file_disabled_rules.contains(rule_name) {
1077 return true;
1078 }
1079
1080 if let Some(rules) = self.line_disabled_rules.get(&line)
1082 && (rules.contains("*") || rules.contains(rule_name))
1083 {
1084 return true;
1085 }
1086
1087 if !self.persistent_transitions.is_empty() {
1089 let idx = match self.persistent_transitions.binary_search_by_key(&line, |t| t.0) {
1090 Ok(i) => Some(i),
1091 Err(i) => {
1092 if i > 0 {
1093 Some(i - 1)
1094 } else {
1095 None
1096 }
1097 }
1098 };
1099 if let Some(i) = idx {
1100 let (_, ref disabled, ref enabled) = self.persistent_transitions[i];
1101 if disabled.contains("*") {
1102 return !enabled.contains(rule_name);
1103 }
1104 return disabled.contains(rule_name);
1105 }
1106 }
1107
1108 false
1109 }
1110
1111 pub fn add_cross_file_link(&mut self, link: CrossFileLinkIndex) {
1126 let existing = self.cross_file_links.iter_mut().find(|existing| {
1127 existing.fragment == link.fragment
1128 && existing.line == link.line
1129 && strip_query_and_fragment(&existing.target_path) == strip_query_and_fragment(&link.target_path)
1130 });
1131 match existing {
1132 Some(existing) => {
1135 if !existing.target_path.contains('?') && link.target_path.contains('?') {
1136 *existing = link;
1137 }
1138 }
1139 None => self.cross_file_links.push(link),
1140 }
1141 }
1142
1143 pub fn add_root_relative_link(&mut self, link: CrossFileLinkIndex) {
1145 let is_duplicate = self.root_relative_links.iter().any(|existing| {
1146 existing.target_path == link.target_path && existing.fragment == link.fragment && existing.line == link.line
1147 });
1148 if !is_duplicate {
1149 self.root_relative_links.push(link);
1150 }
1151 }
1152
1153 pub fn add_md057_link_target(&mut self, target: Md057LinkTarget) {
1155 self.md057_link_targets.push(target);
1156 }
1157
1158 pub fn add_defined_reference(&mut self, ref_id: String) {
1160 self.defined_references.insert(ref_id);
1161 }
1162
1163 pub fn has_defined_reference(&self, ref_id: &str) -> bool {
1165 self.defined_references.contains(ref_id)
1166 }
1167
1168 pub fn hash_matches(&self, hash: &str) -> bool {
1170 self.content_hash == hash
1171 }
1172
1173 pub fn heading_count(&self) -> usize {
1175 self.headings.len()
1176 }
1177
1178 pub fn reference_link_count(&self) -> usize {
1180 self.reference_links.len()
1181 }
1182}
1183
1184#[cfg(test)]
1185mod tests {
1186 use super::*;
1187
1188 #[test]
1189 fn test_workspace_index_basic() {
1190 let mut index = WorkspaceIndex::new();
1191 assert_eq!(index.file_count(), 0);
1192 assert_eq!(index.version(), 0);
1193
1194 let mut file_index = FileIndex::with_hash("abc123".to_string());
1195 file_index.add_heading(HeadingIndex {
1196 text: "Installation".to_string(),
1197 auto_anchor: "installation".to_string(),
1198 custom_anchor: None,
1199 line: 1,
1200 is_setext: false,
1201 });
1202
1203 index.insert_file(PathBuf::from("docs/install.md"), file_index);
1204 assert_eq!(index.file_count(), 1);
1205 assert_eq!(index.version(), 1);
1206
1207 assert!(index.contains_file(Path::new("docs/install.md")));
1208 assert!(!index.contains_file(Path::new("docs/other.md")));
1209 }
1210
1211 #[test]
1212 fn test_vulnerable_anchors() {
1213 let mut index = WorkspaceIndex::new();
1214
1215 let mut file1 = FileIndex::new();
1217 file1.add_heading(HeadingIndex {
1218 text: "Getting Started".to_string(),
1219 auto_anchor: "getting-started".to_string(),
1220 custom_anchor: None,
1221 line: 1,
1222 is_setext: false,
1223 });
1224 index.insert_file(PathBuf::from("docs/guide.md"), file1);
1225
1226 let mut file2 = FileIndex::new();
1228 file2.add_heading(HeadingIndex {
1229 text: "Installation".to_string(),
1230 auto_anchor: "installation".to_string(),
1231 custom_anchor: Some("install".to_string()),
1232 line: 1,
1233 is_setext: false,
1234 });
1235 index.insert_file(PathBuf::from("docs/install.md"), file2);
1236
1237 let vulnerable = index.get_vulnerable_anchors();
1238 assert_eq!(vulnerable.len(), 1);
1239 assert!(vulnerable.contains_key("getting-started"));
1240 assert!(!vulnerable.contains_key("installation"));
1241
1242 let anchors = vulnerable.get("getting-started").unwrap();
1243 assert_eq!(anchors.len(), 1);
1244 assert_eq!(anchors[0].file, PathBuf::from("docs/guide.md"));
1245 assert_eq!(anchors[0].text, "Getting Started");
1246 }
1247
1248 #[test]
1249 fn test_vulnerable_anchors_multiple_files_same_anchor() {
1250 let mut index = WorkspaceIndex::new();
1253
1254 let mut file1 = FileIndex::new();
1256 file1.add_heading(HeadingIndex {
1257 text: "Installation".to_string(),
1258 auto_anchor: "installation".to_string(),
1259 custom_anchor: None,
1260 line: 1,
1261 is_setext: false,
1262 });
1263 index.insert_file(PathBuf::from("docs/en/guide.md"), file1);
1264
1265 let mut file2 = FileIndex::new();
1267 file2.add_heading(HeadingIndex {
1268 text: "Installation".to_string(),
1269 auto_anchor: "installation".to_string(),
1270 custom_anchor: None,
1271 line: 5,
1272 is_setext: false,
1273 });
1274 index.insert_file(PathBuf::from("docs/fr/guide.md"), file2);
1275
1276 let mut file3 = FileIndex::new();
1278 file3.add_heading(HeadingIndex {
1279 text: "Installation".to_string(),
1280 auto_anchor: "installation".to_string(),
1281 custom_anchor: Some("install".to_string()),
1282 line: 10,
1283 is_setext: false,
1284 });
1285 index.insert_file(PathBuf::from("docs/de/guide.md"), file3);
1286
1287 let vulnerable = index.get_vulnerable_anchors();
1288 assert_eq!(vulnerable.len(), 1); assert!(vulnerable.contains_key("installation"));
1290
1291 let anchors = vulnerable.get("installation").unwrap();
1292 assert_eq!(anchors.len(), 2, "Should collect both vulnerable anchors");
1294
1295 let files: std::collections::HashSet<_> = anchors.iter().map(|a| &a.file).collect();
1297 assert!(files.contains(&PathBuf::from("docs/en/guide.md")));
1298 assert!(files.contains(&PathBuf::from("docs/fr/guide.md")));
1299 }
1300
1301 #[test]
1302 fn test_file_index_hash() {
1303 let index = FileIndex::with_hash("hash123".to_string());
1304 assert!(index.hash_matches("hash123"));
1305 assert!(!index.hash_matches("other"));
1306 }
1307
1308 #[test]
1309 fn test_version_increment() {
1310 let mut index = WorkspaceIndex::new();
1311 assert_eq!(index.version(), 0);
1312
1313 index.insert_file(PathBuf::from("a.md"), FileIndex::new());
1314 assert_eq!(index.version(), 1);
1315
1316 index.insert_file(PathBuf::from("b.md"), FileIndex::new());
1317 assert_eq!(index.version(), 2);
1318
1319 index.remove_file(Path::new("a.md"));
1320 assert_eq!(index.version(), 3);
1321
1322 index.remove_file(Path::new("nonexistent.md"));
1324 assert_eq!(index.version(), 3);
1325 }
1326
1327 #[test]
1328 fn test_files_sorted_is_path_ordered() {
1329 let mut index = WorkspaceIndex::new();
1330 for name in ["docs/zebra.md", "docs/apple.md", "docs/mango.md"] {
1332 index.update_file(Path::new(name), FileIndex::new());
1333 }
1334
1335 let paths: Vec<&Path> = index.files_sorted().into_iter().map(|(p, _)| p).collect();
1336 assert_eq!(
1337 paths,
1338 vec![
1339 Path::new("docs/apple.md"),
1340 Path::new("docs/mango.md"),
1341 Path::new("docs/zebra.md"),
1342 ],
1343 "files_sorted() must return entries ordered by path"
1344 );
1345 }
1346
1347 #[test]
1352 fn test_add_cross_file_link_keeps_the_destination_as_written() {
1353 let as_written = CrossFileLinkIndex {
1354 target_path: "other.md?raw=true".to_string(),
1355 fragment: "missing".to_string(),
1356 line: 3,
1357 column: 1,
1358 origin: LinkOrigin::Body,
1359 };
1360 let file_named = CrossFileLinkIndex {
1361 target_path: "other.md".to_string(),
1362 fragment: "missing".to_string(),
1363 line: 3,
1364 column: 9,
1365 origin: LinkOrigin::Body,
1366 };
1367
1368 for (first, second) in [
1369 (as_written.clone(), file_named.clone()),
1370 (file_named.clone(), as_written.clone()),
1371 ] {
1372 let mut index = FileIndex::new();
1373 index.add_cross_file_link(first);
1374 index.add_cross_file_link(second);
1375
1376 assert_eq!(
1377 index.cross_file_links.len(),
1378 1,
1379 "one link is one entry, got: {:?}",
1380 index.cross_file_links
1381 );
1382 assert_eq!(index.cross_file_links[0].target_path, "other.md?raw=true");
1383 }
1384 }
1385
1386 #[test]
1389 fn test_add_cross_file_link_keeps_distinct_targets() {
1390 let mut index = FileIndex::new();
1391 for target in ["one.md", "two.md"] {
1392 index.add_cross_file_link(CrossFileLinkIndex {
1393 target_path: target.to_string(),
1394 fragment: "missing".to_string(),
1395 line: 3,
1396 column: 1,
1397 origin: LinkOrigin::Body,
1398 });
1399 }
1400 assert_eq!(index.cross_file_links.len(), 2);
1401 }
1402
1403 #[test]
1411 fn test_add_cross_file_link_collapses_one_line_asking_one_file_once() {
1412 let link = |target: &str, fragment: &str, line: usize| CrossFileLinkIndex {
1413 target_path: target.to_string(),
1414 fragment: fragment.to_string(),
1415 line,
1416 column: 1,
1417 origin: LinkOrigin::Body,
1418 };
1419
1420 let mut index = FileIndex::new();
1421 index.add_cross_file_link(link("target.md?raw=true", "missing", 3));
1422 index.add_cross_file_link(link("target.md?plain=1", "missing", 3));
1423 assert_eq!(
1424 index.cross_file_links.len(),
1425 1,
1426 "one file, one fragment, one line is one entry, got: {:?}",
1427 index.cross_file_links
1428 );
1429
1430 index.add_cross_file_link(link("target.md", "other", 3));
1431 index.add_cross_file_link(link("target.md", "missing", 4));
1432 assert_eq!(index.cross_file_links.len(), 3);
1433 }
1434
1435 #[test]
1441 fn test_reverse_deps_ignore_a_query_string_on_the_destination() {
1442 let mut index = WorkspaceIndex::new();
1443
1444 let mut file_a = FileIndex::new();
1445 file_a.add_cross_file_link(CrossFileLinkIndex {
1446 target_path: "b.md?raw=true".to_string(),
1447 fragment: "section".to_string(),
1448 line: 10,
1449 column: 5,
1450 origin: LinkOrigin::Body,
1451 });
1452 index.update_file(Path::new("docs/a.md"), file_a);
1453
1454 assert_eq!(
1455 index.get_dependents(Path::new("docs/b.md")),
1456 vec![PathBuf::from("docs/a.md")],
1457 "editing docs/b.md must re-lint the file linking to it"
1458 );
1459
1460 index.update_file(Path::new("docs/a.md"), FileIndex::new());
1463 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1464 }
1465
1466 #[test]
1467 fn test_reverse_deps_basic() {
1468 let mut index = WorkspaceIndex::new();
1469
1470 let mut file_a = FileIndex::new();
1472 file_a.add_cross_file_link(CrossFileLinkIndex {
1473 target_path: "b.md".to_string(),
1474 fragment: "section".to_string(),
1475 line: 10,
1476 column: 5,
1477 origin: LinkOrigin::Body,
1478 });
1479 index.update_file(Path::new("docs/a.md"), file_a);
1480
1481 let dependents = index.get_dependents(Path::new("docs/b.md"));
1483 assert_eq!(dependents.len(), 1);
1484 assert_eq!(dependents[0], PathBuf::from("docs/a.md"));
1485
1486 let a_dependents = index.get_dependents(Path::new("docs/a.md"));
1488 assert!(a_dependents.is_empty());
1489 }
1490
1491 #[test]
1492 fn test_reverse_deps_multiple() {
1493 let mut index = WorkspaceIndex::new();
1494
1495 let mut file_a = FileIndex::new();
1497 file_a.add_cross_file_link(CrossFileLinkIndex {
1498 target_path: "../b.md".to_string(),
1499 fragment: "".to_string(),
1500 line: 1,
1501 column: 1,
1502 origin: LinkOrigin::Body,
1503 });
1504 index.update_file(Path::new("docs/sub/a.md"), file_a);
1505
1506 let mut file_c = FileIndex::new();
1507 file_c.add_cross_file_link(CrossFileLinkIndex {
1508 target_path: "b.md".to_string(),
1509 fragment: "".to_string(),
1510 line: 1,
1511 column: 1,
1512 origin: LinkOrigin::Body,
1513 });
1514 index.update_file(Path::new("docs/c.md"), file_c);
1515
1516 let dependents = index.get_dependents(Path::new("docs/b.md"));
1518 assert_eq!(dependents.len(), 2);
1519 assert!(dependents.contains(&PathBuf::from("docs/sub/a.md")));
1520 assert!(dependents.contains(&PathBuf::from("docs/c.md")));
1521 }
1522
1523 #[test]
1524 fn test_reverse_deps_update_clears_old() {
1525 let mut index = WorkspaceIndex::new();
1526
1527 let mut file_a = FileIndex::new();
1529 file_a.add_cross_file_link(CrossFileLinkIndex {
1530 target_path: "b.md".to_string(),
1531 fragment: "".to_string(),
1532 line: 1,
1533 column: 1,
1534 origin: LinkOrigin::Body,
1535 });
1536 index.update_file(Path::new("docs/a.md"), file_a);
1537
1538 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1540
1541 let mut file_a_updated = FileIndex::new();
1543 file_a_updated.add_cross_file_link(CrossFileLinkIndex {
1544 target_path: "c.md".to_string(),
1545 fragment: "".to_string(),
1546 line: 1,
1547 column: 1,
1548 origin: LinkOrigin::Body,
1549 });
1550 index.update_file(Path::new("docs/a.md"), file_a_updated);
1551
1552 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1554
1555 let c_deps = index.get_dependents(Path::new("docs/c.md"));
1557 assert_eq!(c_deps.len(), 1);
1558 assert_eq!(c_deps[0], PathBuf::from("docs/a.md"));
1559 }
1560
1561 #[test]
1562 fn test_reverse_deps_remove_file() {
1563 let mut index = WorkspaceIndex::new();
1564
1565 let mut file_a = FileIndex::new();
1567 file_a.add_cross_file_link(CrossFileLinkIndex {
1568 target_path: "b.md".to_string(),
1569 fragment: "".to_string(),
1570 line: 1,
1571 column: 1,
1572 origin: LinkOrigin::Body,
1573 });
1574 index.update_file(Path::new("docs/a.md"), file_a);
1575
1576 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1578
1579 index.remove_file(Path::new("docs/a.md"));
1581
1582 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1584 }
1585
1586 #[test]
1587 fn test_normalize_path() {
1588 let path = Path::new("docs/sub/../other.md");
1590 let normalized = normalize_relative_path(path);
1591 assert_eq!(normalized, PathBuf::from("docs/other.md"));
1592
1593 let path2 = Path::new("docs/./other.md");
1595 let normalized2 = normalize_relative_path(path2);
1596 assert_eq!(normalized2, PathBuf::from("docs/other.md"));
1597
1598 let path3 = Path::new("a/b/c/../../d.md");
1600 let normalized3 = normalize_relative_path(path3);
1601 assert_eq!(normalized3, PathBuf::from("a/d.md"));
1602 }
1603
1604 #[test]
1609 fn normalize_keeps_a_traversal_that_leaves_its_own_root() {
1610 assert_eq!(
1611 normalize_relative_path(Path::new("../notes.md")),
1612 PathBuf::from("../notes.md")
1613 );
1614 assert_eq!(
1615 normalize_relative_path(Path::new("docs/../../notes.md")),
1616 PathBuf::from("../notes.md")
1617 );
1618 assert_eq!(
1619 normalize_relative_path(Path::new("../../a/./b/../notes.md")),
1620 PathBuf::from("../../a/notes.md")
1621 );
1622 }
1623
1624 #[test]
1627 fn normalize_stops_a_traversal_at_a_root() {
1628 let root = if cfg!(windows) { "C:\\" } else { "/" };
1629 assert_eq!(
1630 normalize_relative_path(&Path::new(root).join("..").join("notes.md")),
1631 Path::new(root).join("notes.md")
1632 );
1633 }
1634
1635 #[test]
1636 fn test_clear_clears_reverse_deps() {
1637 let mut index = WorkspaceIndex::new();
1638
1639 let mut file_a = FileIndex::new();
1641 file_a.add_cross_file_link(CrossFileLinkIndex {
1642 target_path: "b.md".to_string(),
1643 fragment: "".to_string(),
1644 line: 1,
1645 column: 1,
1646 origin: LinkOrigin::Body,
1647 });
1648 index.update_file(Path::new("docs/a.md"), file_a);
1649
1650 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1652
1653 index.clear();
1655
1656 assert_eq!(index.file_count(), 0);
1658 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1659 }
1660
1661 #[test]
1662 fn test_is_file_stale() {
1663 let mut index = WorkspaceIndex::new();
1664
1665 assert!(index.is_file_stale(Path::new("nonexistent.md"), "hash123"));
1667
1668 let file_index = FileIndex::with_hash("hash123".to_string());
1670 index.insert_file(PathBuf::from("docs/test.md"), file_index);
1671
1672 assert!(!index.is_file_stale(Path::new("docs/test.md"), "hash123"));
1674
1675 assert!(index.is_file_stale(Path::new("docs/test.md"), "different_hash"));
1677 }
1678
1679 #[cfg(feature = "native")]
1680 #[test]
1681 fn test_cache_roundtrip() {
1682 use std::fs;
1683
1684 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_roundtrip");
1686 let _ = fs::remove_dir_all(&temp_dir);
1687 fs::create_dir_all(&temp_dir).unwrap();
1688
1689 let mut index = WorkspaceIndex::new();
1691
1692 let mut file1 = FileIndex::with_hash("abc123".to_string());
1693 file1.add_heading(HeadingIndex {
1694 text: "Test Heading".to_string(),
1695 auto_anchor: "test-heading".to_string(),
1696 custom_anchor: Some("test".to_string()),
1697 line: 1,
1698 is_setext: false,
1699 });
1700 file1.add_cross_file_link(CrossFileLinkIndex {
1701 target_path: "./other.md".to_string(),
1702 fragment: "section".to_string(),
1703 line: 5,
1704 column: 3,
1705 origin: LinkOrigin::Body,
1706 });
1707 index.update_file(Path::new("docs/file1.md"), file1);
1708
1709 let mut file2 = FileIndex::with_hash("def456".to_string());
1710 file2.add_heading(HeadingIndex {
1711 text: "Another Heading".to_string(),
1712 auto_anchor: "another-heading".to_string(),
1713 custom_anchor: None,
1714 line: 1,
1715 is_setext: false,
1716 });
1717 index.update_file(Path::new("docs/other.md"), file2);
1718
1719 index.save_to_cache(&temp_dir).expect("Failed to save cache");
1721
1722 assert!(temp_dir.join("workspace_index.bin").exists());
1724
1725 let loaded = WorkspaceIndex::load_from_cache(&temp_dir).expect("Failed to load cache");
1727
1728 assert_eq!(loaded.file_count(), 2);
1730 assert!(loaded.contains_file(Path::new("docs/file1.md")));
1731 assert!(loaded.contains_file(Path::new("docs/other.md")));
1732
1733 let file1_loaded = loaded.get_file(Path::new("docs/file1.md")).unwrap();
1735 assert_eq!(file1_loaded.content_hash, "abc123");
1736 assert_eq!(file1_loaded.headings.len(), 1);
1737 assert_eq!(file1_loaded.headings[0].text, "Test Heading");
1738 assert_eq!(file1_loaded.headings[0].custom_anchor, Some("test".to_string()));
1739 assert_eq!(file1_loaded.cross_file_links.len(), 1);
1740 assert_eq!(file1_loaded.cross_file_links[0].target_path, "./other.md");
1741
1742 let dependents = loaded.get_dependents(Path::new("docs/other.md"));
1744 assert_eq!(dependents.len(), 1);
1745 assert_eq!(dependents[0], PathBuf::from("docs/file1.md"));
1746
1747 let _ = fs::remove_dir_all(&temp_dir);
1749 }
1750
1751 #[cfg(feature = "native")]
1752 #[test]
1753 fn test_cache_missing_file() {
1754 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_missing");
1755 let _ = std::fs::remove_dir_all(&temp_dir);
1756
1757 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1759 assert!(result.is_none());
1760 }
1761
1762 #[cfg(feature = "native")]
1763 #[test]
1764 fn test_cache_corrupted_file() {
1765 use std::fs;
1766
1767 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_corrupted");
1768 let _ = fs::remove_dir_all(&temp_dir);
1769 fs::create_dir_all(&temp_dir).unwrap();
1770
1771 fs::write(temp_dir.join("workspace_index.bin"), b"bad").unwrap();
1773
1774 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1776 assert!(result.is_none());
1777
1778 assert!(!temp_dir.join("workspace_index.bin").exists());
1780
1781 let _ = fs::remove_dir_all(&temp_dir);
1783 }
1784
1785 #[cfg(feature = "native")]
1786 #[test]
1787 fn test_cache_invalid_magic() {
1788 use std::fs;
1789
1790 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_invalid_magic");
1791 let _ = fs::remove_dir_all(&temp_dir);
1792 fs::create_dir_all(&temp_dir).unwrap();
1793
1794 let mut data = Vec::new();
1796 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();
1800
1801 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1803 assert!(result.is_none());
1804
1805 assert!(!temp_dir.join("workspace_index.bin").exists());
1807
1808 let _ = fs::remove_dir_all(&temp_dir);
1810 }
1811
1812 #[cfg(feature = "native")]
1813 #[test]
1814 fn test_cache_version_mismatch() {
1815 use std::fs;
1816
1817 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_version_mismatch");
1818 let _ = fs::remove_dir_all(&temp_dir);
1819 fs::create_dir_all(&temp_dir).unwrap();
1820
1821 let mut data = Vec::new();
1823 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();
1827
1828 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1830 assert!(result.is_none());
1831
1832 assert!(!temp_dir.join("workspace_index.bin").exists());
1834
1835 let _ = fs::remove_dir_all(&temp_dir);
1837 }
1838
1839 #[cfg(feature = "native")]
1840 #[test]
1841 fn test_cache_atomic_write() {
1842 use std::fs;
1843
1844 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_atomic");
1846 let _ = fs::remove_dir_all(&temp_dir);
1847 fs::create_dir_all(&temp_dir).unwrap();
1848
1849 let index = WorkspaceIndex::new();
1850 index.save_to_cache(&temp_dir).expect("Failed to save");
1851
1852 let entries: Vec<_> = fs::read_dir(&temp_dir).unwrap().collect();
1854 assert_eq!(entries.len(), 1);
1855 assert!(temp_dir.join("workspace_index.bin").exists());
1856
1857 let _ = fs::remove_dir_all(&temp_dir);
1859 }
1860
1861 #[test]
1862 fn test_has_anchor_auto_generated() {
1863 let mut file_index = FileIndex::new();
1864 file_index.add_heading(HeadingIndex {
1865 text: "Installation Guide".to_string(),
1866 auto_anchor: "installation-guide".to_string(),
1867 custom_anchor: None,
1868 line: 1,
1869 is_setext: false,
1870 });
1871
1872 assert!(file_index.has_anchor("installation-guide"));
1874
1875 assert!(file_index.has_anchor("Installation-Guide"));
1877 assert!(file_index.has_anchor("INSTALLATION-GUIDE"));
1878
1879 assert!(!file_index.has_anchor("nonexistent"));
1881 }
1882
1883 #[test]
1884 fn test_has_anchor_custom() {
1885 let mut file_index = FileIndex::new();
1886 file_index.add_heading(HeadingIndex {
1887 text: "Installation Guide".to_string(),
1888 auto_anchor: "installation-guide".to_string(),
1889 custom_anchor: Some("install".to_string()),
1890 line: 1,
1891 is_setext: false,
1892 });
1893
1894 assert!(file_index.has_anchor("installation-guide"));
1896
1897 assert!(file_index.has_anchor("install"));
1899 assert!(file_index.has_anchor("Install")); assert!(!file_index.has_anchor("nonexistent"));
1903 }
1904
1905 #[test]
1906 fn test_get_heading_by_anchor() {
1907 let mut file_index = FileIndex::new();
1908 file_index.add_heading(HeadingIndex {
1909 text: "Installation Guide".to_string(),
1910 auto_anchor: "installation-guide".to_string(),
1911 custom_anchor: Some("install".to_string()),
1912 line: 10,
1913 is_setext: false,
1914 });
1915 file_index.add_heading(HeadingIndex {
1916 text: "Configuration".to_string(),
1917 auto_anchor: "configuration".to_string(),
1918 custom_anchor: None,
1919 line: 20,
1920 is_setext: false,
1921 });
1922
1923 let heading = file_index.get_heading_by_anchor("installation-guide");
1925 assert!(heading.is_some());
1926 assert_eq!(heading.unwrap().text, "Installation Guide");
1927 assert_eq!(heading.unwrap().line, 10);
1928
1929 let heading = file_index.get_heading_by_anchor("install");
1931 assert!(heading.is_some());
1932 assert_eq!(heading.unwrap().text, "Installation Guide");
1933
1934 let heading = file_index.get_heading_by_anchor("configuration");
1936 assert!(heading.is_some());
1937 assert_eq!(heading.unwrap().text, "Configuration");
1938 assert_eq!(heading.unwrap().line, 20);
1939
1940 assert!(file_index.get_heading_by_anchor("nonexistent").is_none());
1942 }
1943
1944 #[test]
1945 fn test_anchor_lookup_many_headings() {
1946 let mut file_index = FileIndex::new();
1948
1949 for i in 0..100 {
1951 file_index.add_heading(HeadingIndex {
1952 text: format!("Heading {i}"),
1953 auto_anchor: format!("heading-{i}"),
1954 custom_anchor: Some(format!("h{i}")),
1955 line: i + 1,
1956 is_setext: false,
1957 });
1958 }
1959
1960 for i in 0..100 {
1962 assert!(file_index.has_anchor(&format!("heading-{i}")));
1963 assert!(file_index.has_anchor(&format!("h{i}")));
1964
1965 let heading = file_index.get_heading_by_anchor(&format!("heading-{i}"));
1966 assert!(heading.is_some());
1967 assert_eq!(heading.unwrap().line, i + 1);
1968 }
1969 }
1970
1971 #[test]
1976 fn test_extract_cross_file_links_basic() {
1977 use crate::config::MarkdownFlavor;
1978
1979 let content = "# Test\n\nSee [link](./other.md) for info.\n";
1980 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1981 let links = extract_cross_file_links(&ctx).relative;
1982
1983 assert_eq!(links.len(), 1);
1984 assert_eq!(links[0].target_path, "./other.md");
1985 assert_eq!(links[0].fragment, "");
1986 assert_eq!(links[0].line, 3);
1987 assert_eq!(links[0].column, 12);
1989 }
1990
1991 #[test]
1992 fn test_extract_cross_file_links_with_fragment() {
1993 use crate::config::MarkdownFlavor;
1994
1995 let content = "Check [guide](./guide.md#install) 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, "./guide.md");
2001 assert_eq!(links[0].fragment, "install");
2002 assert_eq!(links[0].line, 1);
2003 assert_eq!(links[0].column, 15);
2005 }
2006
2007 #[test]
2008 fn test_extract_cross_file_links_multiple_on_same_line() {
2009 use crate::config::MarkdownFlavor;
2010
2011 let content = "See [a](a.md) and [b](b.md) here.\n";
2012 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2013 let links = extract_cross_file_links(&ctx).relative;
2014
2015 assert_eq!(links.len(), 2);
2016
2017 assert_eq!(links[0].target_path, "a.md");
2018 assert_eq!(links[0].line, 1);
2019 assert_eq!(links[0].column, 9);
2021
2022 assert_eq!(links[1].target_path, "b.md");
2023 assert_eq!(links[1].line, 1);
2024 assert_eq!(links[1].column, 23);
2026 }
2027
2028 #[test]
2029 fn test_extract_cross_file_links_angle_brackets() {
2030 use crate::config::MarkdownFlavor;
2031
2032 let content = "See [link](<path/with (parens).md>) here.\n";
2033 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2034 let links = extract_cross_file_links(&ctx).relative;
2035
2036 assert_eq!(links.len(), 1);
2037 assert_eq!(links[0].target_path, "path/with (parens).md");
2038 assert_eq!(links[0].line, 1);
2039 assert_eq!(links[0].column, 13);
2041 }
2042
2043 #[test]
2044 fn test_extract_cross_file_links_skips_external() {
2045 use crate::config::MarkdownFlavor;
2046
2047 let content = r#"
2048[external](https://example.com)
2049[mailto](mailto:test@example.com)
2050[local](./local.md)
2051[fragment](#section)
2052[absolute](/docs/page.md)
2053"#;
2054 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2055 let extracted = extract_cross_file_links(&ctx);
2056
2057 assert_eq!(extracted.relative.len(), 1);
2059 assert_eq!(extracted.relative[0].target_path, "./local.md");
2060 assert_eq!(extracted.root_relative.len(), 1);
2062 assert_eq!(extracted.root_relative[0].target_path, "docs/page.md");
2063 }
2064
2065 #[test]
2066 fn test_extract_cross_file_links_root_relative() {
2067 use crate::config::MarkdownFlavor;
2068
2069 let content = "[a](/guide.md#install)\n[b](/../escape.md)\n[c](//host/x.md)\n[d](/img/pic.png)\n";
2073 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2074 let extracted = extract_cross_file_links(&ctx);
2075
2076 assert!(extracted.relative.is_empty(), "no directory-relative links here");
2077 assert_eq!(
2078 extracted
2079 .root_relative
2080 .iter()
2081 .map(|l| (l.target_path.as_str(), l.fragment.as_str()))
2082 .collect::<Vec<_>>(),
2083 vec![("guide.md", "install")],
2084 "only the safe root-relative markdown link is captured"
2085 );
2086 }
2087
2088 #[test]
2089 fn test_extract_cross_file_links_skips_non_markdown() {
2090 use crate::config::MarkdownFlavor;
2091
2092 let content = r#"
2093[image](./photo.png)
2094[doc](./readme.md)
2095[pdf](./document.pdf)
2096"#;
2097 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2098 let links = extract_cross_file_links(&ctx).relative;
2099
2100 assert_eq!(links.len(), 1);
2102 assert_eq!(links[0].target_path, "./readme.md");
2103 }
2104
2105 #[test]
2106 fn test_extract_cross_file_links_skips_code_spans() {
2107 use crate::config::MarkdownFlavor;
2108
2109 let content = "Normal [link](./file.md) and `[code](./ignored.md)` here.\n";
2110 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2111 let links = extract_cross_file_links(&ctx).relative;
2112
2113 assert_eq!(links.len(), 1);
2115 assert_eq!(links[0].target_path, "./file.md");
2116 }
2117
2118 #[test]
2119 fn test_extract_cross_file_links_with_query_params() {
2120 use crate::config::MarkdownFlavor;
2121
2122 let content = "See [doc](./file.md?raw=true) here.\n";
2123 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2124 let links = extract_cross_file_links(&ctx).relative;
2125
2126 assert_eq!(links.len(), 1);
2127 assert_eq!(links[0].target_path, "./file.md");
2129 }
2130
2131 #[test]
2132 fn test_extract_cross_file_links_empty_content() {
2133 use crate::config::MarkdownFlavor;
2134
2135 let content = "";
2136 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2137 let links = extract_cross_file_links(&ctx).relative;
2138
2139 assert!(links.is_empty());
2140 }
2141
2142 #[test]
2143 fn test_extract_cross_file_links_no_links() {
2144 use crate::config::MarkdownFlavor;
2145
2146 let content = "# Just a heading\n\nSome text without links.\n";
2147 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2148 let links = extract_cross_file_links(&ctx).relative;
2149
2150 assert!(links.is_empty());
2151 }
2152
2153 #[test]
2154 fn test_extract_cross_file_links_position_accuracy_issue_234() {
2155 use crate::config::MarkdownFlavor;
2158
2159 let content = r#"# Test Document
2160
2161Here is a [broken link](nonexistent-file.md) that should trigger MD057.
2162
2163And another [link](also-missing.md) on this line.
2164"#;
2165 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2166 let links = extract_cross_file_links(&ctx).relative;
2167
2168 assert_eq!(links.len(), 2);
2169
2170 assert_eq!(links[0].target_path, "nonexistent-file.md");
2172 assert_eq!(links[0].line, 3);
2173 assert_eq!(links[0].column, 25);
2174
2175 assert_eq!(links[1].target_path, "also-missing.md");
2177 assert_eq!(links[1].line, 5);
2178 assert_eq!(links[1].column, 20);
2179 }
2180}