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")]
302const CACHE_FORMAT_VERSION: u32 = 12;
303
304#[cfg(feature = "postcard")]
306const CACHE_FILE_NAME: &str = "workspace_index.bin";
307
308#[cfg(feature = "postcard")]
312static CACHE_TEMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
313
314#[derive(Debug, Default, Clone, Serialize, Deserialize)]
319pub struct WorkspaceIndex {
320 files: HashMap<PathBuf, FileIndex>,
322 reverse_deps: HashMap<PathBuf, HashSet<PathBuf>>,
325 version: u64,
327}
328
329#[derive(Debug, Clone, Default, Serialize, Deserialize)]
331pub struct FileIndex {
332 pub headings: Vec<HeadingIndex>,
334 pub reference_links: Vec<ReferenceLinkIndex>,
336 pub cross_file_links: Vec<CrossFileLinkIndex>,
338 #[serde(default)]
343 pub root_relative_links: Vec<CrossFileLinkIndex>,
344 #[serde(default)]
349 pub md057_link_targets: Vec<Md057LinkTarget>,
350 pub defined_references: HashSet<String>,
353 pub content_hash: String,
355 anchor_to_heading: HashMap<String, usize>,
358 #[serde(default)]
362 anchor_to_heading_exact: HashMap<String, usize>,
363 html_anchors: HashSet<String>,
366 #[serde(default)]
369 html_anchors_exact: HashSet<String>,
370 attribute_anchors: HashSet<String>,
374 #[serde(default)]
377 attribute_anchors_exact: HashSet<String>,
378 pub file_disabled_rules: HashSet<String>,
381 pub persistent_transitions: Vec<(usize, HashSet<String>, HashSet<String>)>,
384 pub line_disabled_rules: HashMap<usize, HashSet<String>>,
386}
387
388#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
390pub struct HeadingIndex {
391 pub text: String,
393 pub auto_anchor: String,
395 pub custom_anchor: Option<String>,
397 pub line: usize,
399 #[serde(default)]
401 pub is_setext: bool,
402}
403
404#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
406pub struct ReferenceLinkIndex {
407 pub reference_id: String,
409 pub line: usize,
411 pub column: usize,
413}
414
415#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
423pub enum LinkOrigin {
424 Body,
426 FrontMatter { field: Option<String> },
435}
436
437#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
439pub struct CrossFileLinkIndex {
440 pub target_path: String,
442 pub fragment: String,
444 pub line: usize,
446 pub column: usize,
448 pub origin: LinkOrigin,
450}
451
452#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
454pub struct Md057LinkTarget {
455 pub target: String,
457 pub origin: LinkOrigin,
459}
460
461pub fn link_target_candidates(source_file: &Path, target_path: &str) -> Vec<PathBuf> {
472 let target_path = strip_query_and_fragment(target_path);
473
474 let joined = match source_file.parent() {
475 Some(parent) => parent.join(target_path),
476 None => PathBuf::from(target_path),
477 };
478 let base = normalize_relative_path(&joined);
479
480 if base.extension().is_some() {
481 return vec![base];
482 }
483
484 let mut candidates = Vec::with_capacity(crate::discovery::MARKDOWN_EXTENSIONS.len() + 1);
487 for ext in crate::discovery::MARKDOWN_EXTENSIONS {
488 candidates.push(base.with_extension(ext));
489 }
490 candidates.insert(0, base);
491 candidates
492}
493
494pub fn normalize_relative_path(path: &Path) -> PathBuf {
505 let mut components: Vec<std::path::Component<'_>> = Vec::new();
506 for component in path.components() {
507 match component {
508 std::path::Component::CurDir => {}
509 std::path::Component::ParentDir => match components.last() {
510 Some(std::path::Component::Normal(_)) => {
511 components.pop();
512 }
513 Some(std::path::Component::RootDir) => {}
514 _ => components.push(component),
515 },
516 c => components.push(c),
517 }
518 }
519 components.iter().collect()
520}
521
522impl CrossFileLinkIndex {
523 pub fn is_navigable(&self) -> bool {
531 matches!(self.origin, LinkOrigin::Body)
532 }
533}
534
535#[derive(Debug, Clone, Serialize, Deserialize)]
537pub struct VulnerableAnchor {
538 pub file: PathBuf,
540 pub line: usize,
542 pub text: String,
544}
545
546impl WorkspaceIndex {
547 pub fn new() -> Self {
549 Self::default()
550 }
551
552 pub fn version(&self) -> u64 {
554 self.version
555 }
556
557 pub fn file_count(&self) -> usize {
559 self.files.len()
560 }
561
562 pub fn contains_file(&self, path: &Path) -> bool {
564 self.files.contains_key(path)
565 }
566
567 pub fn get_file(&self, path: &Path) -> Option<&FileIndex> {
569 self.files.get(path)
570 }
571
572 pub fn insert_file(&mut self, path: PathBuf, index: FileIndex) {
574 self.files.insert(path, index);
575 self.version = self.version.wrapping_add(1);
576 }
577
578 pub fn remove_file(&mut self, path: &Path) -> Option<FileIndex> {
580 self.clear_reverse_deps_for(path);
582
583 let result = self.files.remove(path);
584 if result.is_some() {
585 self.version = self.version.wrapping_add(1);
586 }
587 result
588 }
589
590 pub fn get_vulnerable_anchors(&self) -> HashMap<String, Vec<VulnerableAnchor>> {
600 let mut vulnerable: HashMap<String, Vec<VulnerableAnchor>> = HashMap::new();
601
602 for (file_path, file_index) in &self.files {
603 for heading in &file_index.headings {
604 if heading.custom_anchor.is_none() && !heading.auto_anchor.is_empty() {
606 let anchor_key = heading.auto_anchor.to_lowercase();
607 vulnerable.entry(anchor_key).or_default().push(VulnerableAnchor {
608 file: file_path.clone(),
609 line: heading.line,
610 text: heading.text.clone(),
611 });
612 }
613 }
614 }
615
616 vulnerable
617 }
618
619 pub fn all_headings(&self) -> impl Iterator<Item = (&Path, &HeadingIndex)> {
621 self.files
622 .iter()
623 .flat_map(|(path, index)| index.headings.iter().map(move |h| (path.as_path(), h)))
624 }
625
626 pub fn files(&self) -> impl Iterator<Item = (&Path, &FileIndex)> {
628 self.files.iter().map(|(p, i)| (p.as_path(), i))
629 }
630
631 pub fn files_sorted(&self) -> Vec<(&Path, &FileIndex)> {
637 let mut entries: Vec<(&Path, &FileIndex)> = self.files.iter().map(|(p, i)| (p.as_path(), i)).collect();
638 entries.sort_by_key(|(a, _)| *a);
639 entries
640 }
641
642 pub fn clear(&mut self) {
644 self.files.clear();
645 self.reverse_deps.clear();
646 self.version = self.version.wrapping_add(1);
647 }
648
649 pub fn update_file(&mut self, path: &Path, index: FileIndex) {
656 self.clear_reverse_deps_as_source(path);
659
660 for link in &index.cross_file_links {
662 let target = self.resolve_target_path(path, &link.target_path);
663 self.reverse_deps.entry(target).or_default().insert(path.to_path_buf());
664 }
665
666 self.files.insert(path.to_path_buf(), index);
667 self.version = self.version.wrapping_add(1);
668 }
669
670 pub fn get_dependents(&self, path: &Path) -> Vec<PathBuf> {
675 self.reverse_deps
676 .get(path)
677 .map(|set| set.iter().cloned().collect())
678 .unwrap_or_default()
679 }
680
681 pub fn is_file_stale(&self, path: &Path, current_hash: &str) -> bool {
685 self.files.get(path).is_none_or(|f| f.content_hash != current_hash)
686 }
687
688 pub fn retain_only(&mut self, current_files: &std::collections::HashSet<PathBuf>) -> usize {
693 let before_count = self.files.len();
694
695 let to_remove: Vec<PathBuf> = self
697 .files
698 .keys()
699 .filter(|path| !current_files.contains(*path))
700 .cloned()
701 .collect();
702
703 for path in &to_remove {
705 self.remove_file(path);
706 }
707
708 before_count - self.files.len()
709 }
710
711 #[cfg(feature = "postcard")]
718 pub fn save_to_cache(&self, cache_dir: &Path) -> std::io::Result<()> {
719 use std::fs;
720 use std::io::Write;
721
722 fs::create_dir_all(cache_dir)?;
724
725 let encoded = postcard::to_allocvec(self)
727 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
728
729 let mut cache_data = Vec::with_capacity(8 + encoded.len());
731 cache_data.extend_from_slice(CACHE_MAGIC);
732 cache_data.extend_from_slice(&CACHE_FORMAT_VERSION.to_le_bytes());
733 cache_data.extend_from_slice(&encoded);
734
735 let final_path = cache_dir.join(CACHE_FILE_NAME);
740 let counter = CACHE_TEMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
741 #[cfg(not(target_arch = "wasm32"))]
742 let temp_path = cache_dir.join(format!("{CACHE_FILE_NAME}.tmp.{}.{counter}", std::process::id()));
743 #[cfg(target_arch = "wasm32")]
744 let temp_path = cache_dir.join(format!("{CACHE_FILE_NAME}.tmp.{counter}"));
745
746 {
748 let mut file = fs::File::create(&temp_path)?;
749 file.write_all(&cache_data)?;
750 file.sync_all()?;
751 }
752
753 fs::rename(&temp_path, &final_path)?;
755
756 log::debug!(
757 "Saved workspace index to cache: {} files, {} bytes (format v{})",
758 self.files.len(),
759 cache_data.len(),
760 CACHE_FORMAT_VERSION
761 );
762
763 Ok(())
764 }
765
766 #[cfg(feature = "postcard")]
774 pub fn load_from_cache(cache_dir: &Path) -> Option<Self> {
775 use std::fs;
776
777 let path = cache_dir.join(CACHE_FILE_NAME);
778 let data = fs::read(&path).ok()?;
779
780 if data.len() < 8 {
782 log::warn!("Workspace index cache too small, discarding");
783 let _ = fs::remove_file(&path);
784 return None;
785 }
786
787 if &data[0..4] != CACHE_MAGIC {
789 log::warn!("Workspace index cache has invalid magic header, discarding");
790 let _ = fs::remove_file(&path);
791 return None;
792 }
793
794 let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
796 if version != CACHE_FORMAT_VERSION {
797 log::info!(
798 "Workspace index cache format version mismatch (got {version}, expected {CACHE_FORMAT_VERSION}), rebuilding"
799 );
800 let _ = fs::remove_file(&path);
801 return None;
802 }
803
804 match postcard::from_bytes::<Self>(&data[8..]) {
806 Ok(index) => {
807 log::debug!(
808 "Loaded workspace index from cache: {} files (format v{})",
809 index.files.len(),
810 version
811 );
812 Some(index)
813 }
814 Err(e) => {
815 log::warn!("Failed to deserialize workspace index cache: {e}");
816 let _ = fs::remove_file(&path);
817 None
818 }
819 }
820 }
821
822 fn clear_reverse_deps_as_source(&mut self, path: &Path) {
827 let targets: Vec<PathBuf> = match self.files.get(path) {
834 Some(index) => index
835 .cross_file_links
836 .iter()
837 .map(|link| self.resolve_target_path(path, &link.target_path))
838 .collect(),
839 None => return,
840 };
841 for target in targets {
842 if let Some(deps) = self.reverse_deps.get_mut(&target) {
843 deps.remove(path);
844 if deps.is_empty() {
845 self.reverse_deps.remove(&target);
846 }
847 }
848 }
849 }
850
851 fn clear_reverse_deps_for(&mut self, path: &Path) {
856 self.clear_reverse_deps_as_source(path);
858
859 self.reverse_deps.remove(path);
861 }
862
863 fn resolve_target_path(&self, source_file: &Path, relative_target: &str) -> PathBuf {
869 let source_dir = source_file.parent().unwrap_or(Path::new(""));
870 link_target_file(source_dir, relative_target)
871 }
872}
873
874impl FileIndex {
875 pub fn new() -> Self {
877 Self::default()
878 }
879
880 pub fn with_hash(content_hash: String) -> Self {
882 Self {
883 content_hash,
884 ..Default::default()
885 }
886 }
887
888 pub fn extracted_data_differs(&self, other: &Self) -> bool {
904 let Self {
908 headings,
909 reference_links,
910 cross_file_links,
911 root_relative_links,
912 md057_link_targets,
913 defined_references,
914 content_hash: _,
915 anchor_to_heading,
916 anchor_to_heading_exact,
917 html_anchors,
918 html_anchors_exact,
919 attribute_anchors,
920 attribute_anchors_exact,
921 file_disabled_rules,
922 persistent_transitions,
923 line_disabled_rules,
924 } = self;
925
926 headings != &other.headings
927 || reference_links != &other.reference_links
928 || cross_file_links != &other.cross_file_links
929 || root_relative_links != &other.root_relative_links
930 || md057_link_targets != &other.md057_link_targets
931 || defined_references != &other.defined_references
932 || anchor_to_heading != &other.anchor_to_heading
933 || anchor_to_heading_exact != &other.anchor_to_heading_exact
934 || html_anchors != &other.html_anchors
935 || html_anchors_exact != &other.html_anchors_exact
936 || attribute_anchors != &other.attribute_anchors
937 || attribute_anchors_exact != &other.attribute_anchors_exact
938 || file_disabled_rules != &other.file_disabled_rules
939 || persistent_transitions != &other.persistent_transitions
940 || line_disabled_rules != &other.line_disabled_rules
941 }
942
943 pub fn add_heading(&mut self, heading: HeadingIndex) {
949 let index = self.headings.len();
950
951 self.anchor_to_heading.insert(heading.auto_anchor.to_lowercase(), index);
954 self.anchor_to_heading_exact.insert(heading.auto_anchor.clone(), index);
955
956 if let Some(ref custom) = heading.custom_anchor {
958 self.anchor_to_heading.insert(custom.to_lowercase(), index);
959 self.anchor_to_heading_exact.insert(custom.clone(), index);
960 }
961
962 self.headings.push(heading);
963 }
964
965 pub fn add_anchor_alias(&mut self, anchor: &str, heading_index: usize) {
968 if heading_index < self.headings.len() {
969 self.anchor_to_heading.insert(anchor.to_lowercase(), heading_index);
970 self.anchor_to_heading_exact.insert(anchor.to_string(), heading_index);
971 }
972 }
973
974 pub fn has_anchor(&self, anchor: &str) -> bool {
985 self.has_anchor_with_case(anchor, true)
986 }
987
988 pub fn has_anchor_with_case(&self, anchor: &str, ignore_case: bool) -> bool {
997 if self.lookup_anchor(anchor, ignore_case) {
998 return true;
999 }
1000
1001 if anchor.contains('%') {
1003 let decoded = url_decode(anchor);
1004 if decoded != anchor {
1005 return self.lookup_anchor(&decoded, ignore_case);
1006 }
1007 }
1008
1009 false
1010 }
1011
1012 fn lookup_anchor(&self, anchor: &str, ignore_case: bool) -> bool {
1015 if ignore_case {
1016 let lower = anchor.to_lowercase();
1017 self.anchor_to_heading.contains_key(&lower)
1018 || self.html_anchors.contains(&lower)
1019 || self.attribute_anchors.contains(&lower)
1020 } else {
1021 self.anchor_to_heading_exact.contains_key(anchor)
1022 || self.html_anchors_exact.contains(anchor)
1023 || self.attribute_anchors_exact.contains(anchor)
1024 }
1025 }
1026
1027 pub fn add_html_anchor(&mut self, anchor: &str) {
1030 if !anchor.is_empty() {
1031 self.html_anchors.insert(anchor.to_lowercase());
1032 self.html_anchors_exact.insert(anchor.to_string());
1033 }
1034 }
1035
1036 pub fn add_attribute_anchor(&mut self, anchor: &str) {
1039 if !anchor.is_empty() {
1040 self.attribute_anchors.insert(anchor.to_lowercase());
1041 self.attribute_anchors_exact.insert(anchor.to_string());
1042 }
1043 }
1044
1045 pub fn get_heading_by_anchor(&self, anchor: &str) -> Option<&HeadingIndex> {
1049 self.anchor_to_heading
1050 .get(&anchor.to_lowercase())
1051 .and_then(|&idx| self.headings.get(idx))
1052 }
1053
1054 pub fn add_reference_link(&mut self, link: ReferenceLinkIndex) {
1056 self.reference_links.push(link);
1057 }
1058
1059 pub fn is_rule_disabled_at_line(&self, rule_name: &str, line: usize) -> bool {
1064 if self.file_disabled_rules.contains("*") || self.file_disabled_rules.contains(rule_name) {
1066 return true;
1067 }
1068
1069 if let Some(rules) = self.line_disabled_rules.get(&line)
1071 && (rules.contains("*") || rules.contains(rule_name))
1072 {
1073 return true;
1074 }
1075
1076 if !self.persistent_transitions.is_empty() {
1078 let idx = match self.persistent_transitions.binary_search_by_key(&line, |t| t.0) {
1079 Ok(i) => Some(i),
1080 Err(i) => {
1081 if i > 0 {
1082 Some(i - 1)
1083 } else {
1084 None
1085 }
1086 }
1087 };
1088 if let Some(i) = idx {
1089 let (_, ref disabled, ref enabled) = self.persistent_transitions[i];
1090 if disabled.contains("*") {
1091 return !enabled.contains(rule_name);
1092 }
1093 return disabled.contains(rule_name);
1094 }
1095 }
1096
1097 false
1098 }
1099
1100 pub fn add_cross_file_link(&mut self, link: CrossFileLinkIndex) {
1115 let existing = self.cross_file_links.iter_mut().find(|existing| {
1116 existing.fragment == link.fragment
1117 && existing.line == link.line
1118 && strip_query_and_fragment(&existing.target_path) == strip_query_and_fragment(&link.target_path)
1119 });
1120 match existing {
1121 Some(existing) => {
1124 if !existing.target_path.contains('?') && link.target_path.contains('?') {
1125 *existing = link;
1126 }
1127 }
1128 None => self.cross_file_links.push(link),
1129 }
1130 }
1131
1132 pub fn add_root_relative_link(&mut self, link: CrossFileLinkIndex) {
1134 let is_duplicate = self.root_relative_links.iter().any(|existing| {
1135 existing.target_path == link.target_path && existing.fragment == link.fragment && existing.line == link.line
1136 });
1137 if !is_duplicate {
1138 self.root_relative_links.push(link);
1139 }
1140 }
1141
1142 pub fn add_md057_link_target(&mut self, target: Md057LinkTarget) {
1144 self.md057_link_targets.push(target);
1145 }
1146
1147 pub fn add_defined_reference(&mut self, ref_id: String) {
1149 self.defined_references.insert(ref_id);
1150 }
1151
1152 pub fn has_defined_reference(&self, ref_id: &str) -> bool {
1154 self.defined_references.contains(ref_id)
1155 }
1156
1157 pub fn hash_matches(&self, hash: &str) -> bool {
1159 self.content_hash == hash
1160 }
1161
1162 pub fn heading_count(&self) -> usize {
1164 self.headings.len()
1165 }
1166
1167 pub fn reference_link_count(&self) -> usize {
1169 self.reference_links.len()
1170 }
1171}
1172
1173#[cfg(test)]
1174mod tests {
1175 use super::*;
1176
1177 #[test]
1178 fn test_workspace_index_basic() {
1179 let mut index = WorkspaceIndex::new();
1180 assert_eq!(index.file_count(), 0);
1181 assert_eq!(index.version(), 0);
1182
1183 let mut file_index = FileIndex::with_hash("abc123".to_string());
1184 file_index.add_heading(HeadingIndex {
1185 text: "Installation".to_string(),
1186 auto_anchor: "installation".to_string(),
1187 custom_anchor: None,
1188 line: 1,
1189 is_setext: false,
1190 });
1191
1192 index.insert_file(PathBuf::from("docs/install.md"), file_index);
1193 assert_eq!(index.file_count(), 1);
1194 assert_eq!(index.version(), 1);
1195
1196 assert!(index.contains_file(Path::new("docs/install.md")));
1197 assert!(!index.contains_file(Path::new("docs/other.md")));
1198 }
1199
1200 #[test]
1201 fn test_vulnerable_anchors() {
1202 let mut index = WorkspaceIndex::new();
1203
1204 let mut file1 = FileIndex::new();
1206 file1.add_heading(HeadingIndex {
1207 text: "Getting Started".to_string(),
1208 auto_anchor: "getting-started".to_string(),
1209 custom_anchor: None,
1210 line: 1,
1211 is_setext: false,
1212 });
1213 index.insert_file(PathBuf::from("docs/guide.md"), file1);
1214
1215 let mut file2 = FileIndex::new();
1217 file2.add_heading(HeadingIndex {
1218 text: "Installation".to_string(),
1219 auto_anchor: "installation".to_string(),
1220 custom_anchor: Some("install".to_string()),
1221 line: 1,
1222 is_setext: false,
1223 });
1224 index.insert_file(PathBuf::from("docs/install.md"), file2);
1225
1226 let vulnerable = index.get_vulnerable_anchors();
1227 assert_eq!(vulnerable.len(), 1);
1228 assert!(vulnerable.contains_key("getting-started"));
1229 assert!(!vulnerable.contains_key("installation"));
1230
1231 let anchors = vulnerable.get("getting-started").unwrap();
1232 assert_eq!(anchors.len(), 1);
1233 assert_eq!(anchors[0].file, PathBuf::from("docs/guide.md"));
1234 assert_eq!(anchors[0].text, "Getting Started");
1235 }
1236
1237 #[test]
1238 fn test_vulnerable_anchors_multiple_files_same_anchor() {
1239 let mut index = WorkspaceIndex::new();
1242
1243 let mut file1 = FileIndex::new();
1245 file1.add_heading(HeadingIndex {
1246 text: "Installation".to_string(),
1247 auto_anchor: "installation".to_string(),
1248 custom_anchor: None,
1249 line: 1,
1250 is_setext: false,
1251 });
1252 index.insert_file(PathBuf::from("docs/en/guide.md"), file1);
1253
1254 let mut file2 = FileIndex::new();
1256 file2.add_heading(HeadingIndex {
1257 text: "Installation".to_string(),
1258 auto_anchor: "installation".to_string(),
1259 custom_anchor: None,
1260 line: 5,
1261 is_setext: false,
1262 });
1263 index.insert_file(PathBuf::from("docs/fr/guide.md"), file2);
1264
1265 let mut file3 = FileIndex::new();
1267 file3.add_heading(HeadingIndex {
1268 text: "Installation".to_string(),
1269 auto_anchor: "installation".to_string(),
1270 custom_anchor: Some("install".to_string()),
1271 line: 10,
1272 is_setext: false,
1273 });
1274 index.insert_file(PathBuf::from("docs/de/guide.md"), file3);
1275
1276 let vulnerable = index.get_vulnerable_anchors();
1277 assert_eq!(vulnerable.len(), 1); assert!(vulnerable.contains_key("installation"));
1279
1280 let anchors = vulnerable.get("installation").unwrap();
1281 assert_eq!(anchors.len(), 2, "Should collect both vulnerable anchors");
1283
1284 let files: std::collections::HashSet<_> = anchors.iter().map(|a| &a.file).collect();
1286 assert!(files.contains(&PathBuf::from("docs/en/guide.md")));
1287 assert!(files.contains(&PathBuf::from("docs/fr/guide.md")));
1288 }
1289
1290 #[test]
1291 fn test_file_index_hash() {
1292 let index = FileIndex::with_hash("hash123".to_string());
1293 assert!(index.hash_matches("hash123"));
1294 assert!(!index.hash_matches("other"));
1295 }
1296
1297 #[test]
1298 fn test_version_increment() {
1299 let mut index = WorkspaceIndex::new();
1300 assert_eq!(index.version(), 0);
1301
1302 index.insert_file(PathBuf::from("a.md"), FileIndex::new());
1303 assert_eq!(index.version(), 1);
1304
1305 index.insert_file(PathBuf::from("b.md"), FileIndex::new());
1306 assert_eq!(index.version(), 2);
1307
1308 index.remove_file(Path::new("a.md"));
1309 assert_eq!(index.version(), 3);
1310
1311 index.remove_file(Path::new("nonexistent.md"));
1313 assert_eq!(index.version(), 3);
1314 }
1315
1316 #[test]
1317 fn test_files_sorted_is_path_ordered() {
1318 let mut index = WorkspaceIndex::new();
1319 for name in ["docs/zebra.md", "docs/apple.md", "docs/mango.md"] {
1321 index.update_file(Path::new(name), FileIndex::new());
1322 }
1323
1324 let paths: Vec<&Path> = index.files_sorted().into_iter().map(|(p, _)| p).collect();
1325 assert_eq!(
1326 paths,
1327 vec![
1328 Path::new("docs/apple.md"),
1329 Path::new("docs/mango.md"),
1330 Path::new("docs/zebra.md"),
1331 ],
1332 "files_sorted() must return entries ordered by path"
1333 );
1334 }
1335
1336 #[test]
1341 fn test_add_cross_file_link_keeps_the_destination_as_written() {
1342 let as_written = CrossFileLinkIndex {
1343 target_path: "other.md?raw=true".to_string(),
1344 fragment: "missing".to_string(),
1345 line: 3,
1346 column: 1,
1347 origin: LinkOrigin::Body,
1348 };
1349 let file_named = CrossFileLinkIndex {
1350 target_path: "other.md".to_string(),
1351 fragment: "missing".to_string(),
1352 line: 3,
1353 column: 9,
1354 origin: LinkOrigin::Body,
1355 };
1356
1357 for (first, second) in [
1358 (as_written.clone(), file_named.clone()),
1359 (file_named.clone(), as_written.clone()),
1360 ] {
1361 let mut index = FileIndex::new();
1362 index.add_cross_file_link(first);
1363 index.add_cross_file_link(second);
1364
1365 assert_eq!(
1366 index.cross_file_links.len(),
1367 1,
1368 "one link is one entry, got: {:?}",
1369 index.cross_file_links
1370 );
1371 assert_eq!(index.cross_file_links[0].target_path, "other.md?raw=true");
1372 }
1373 }
1374
1375 #[test]
1378 fn test_add_cross_file_link_keeps_distinct_targets() {
1379 let mut index = FileIndex::new();
1380 for target in ["one.md", "two.md"] {
1381 index.add_cross_file_link(CrossFileLinkIndex {
1382 target_path: target.to_string(),
1383 fragment: "missing".to_string(),
1384 line: 3,
1385 column: 1,
1386 origin: LinkOrigin::Body,
1387 });
1388 }
1389 assert_eq!(index.cross_file_links.len(), 2);
1390 }
1391
1392 #[test]
1400 fn test_add_cross_file_link_collapses_one_line_asking_one_file_once() {
1401 let link = |target: &str, fragment: &str, line: usize| CrossFileLinkIndex {
1402 target_path: target.to_string(),
1403 fragment: fragment.to_string(),
1404 line,
1405 column: 1,
1406 origin: LinkOrigin::Body,
1407 };
1408
1409 let mut index = FileIndex::new();
1410 index.add_cross_file_link(link("target.md?raw=true", "missing", 3));
1411 index.add_cross_file_link(link("target.md?plain=1", "missing", 3));
1412 assert_eq!(
1413 index.cross_file_links.len(),
1414 1,
1415 "one file, one fragment, one line is one entry, got: {:?}",
1416 index.cross_file_links
1417 );
1418
1419 index.add_cross_file_link(link("target.md", "other", 3));
1420 index.add_cross_file_link(link("target.md", "missing", 4));
1421 assert_eq!(index.cross_file_links.len(), 3);
1422 }
1423
1424 #[test]
1430 fn test_reverse_deps_ignore_a_query_string_on_the_destination() {
1431 let mut index = WorkspaceIndex::new();
1432
1433 let mut file_a = FileIndex::new();
1434 file_a.add_cross_file_link(CrossFileLinkIndex {
1435 target_path: "b.md?raw=true".to_string(),
1436 fragment: "section".to_string(),
1437 line: 10,
1438 column: 5,
1439 origin: LinkOrigin::Body,
1440 });
1441 index.update_file(Path::new("docs/a.md"), file_a);
1442
1443 assert_eq!(
1444 index.get_dependents(Path::new("docs/b.md")),
1445 vec![PathBuf::from("docs/a.md")],
1446 "editing docs/b.md must re-lint the file linking to it"
1447 );
1448
1449 index.update_file(Path::new("docs/a.md"), FileIndex::new());
1452 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1453 }
1454
1455 #[test]
1456 fn test_reverse_deps_basic() {
1457 let mut index = WorkspaceIndex::new();
1458
1459 let mut file_a = FileIndex::new();
1461 file_a.add_cross_file_link(CrossFileLinkIndex {
1462 target_path: "b.md".to_string(),
1463 fragment: "section".to_string(),
1464 line: 10,
1465 column: 5,
1466 origin: LinkOrigin::Body,
1467 });
1468 index.update_file(Path::new("docs/a.md"), file_a);
1469
1470 let dependents = index.get_dependents(Path::new("docs/b.md"));
1472 assert_eq!(dependents.len(), 1);
1473 assert_eq!(dependents[0], PathBuf::from("docs/a.md"));
1474
1475 let a_dependents = index.get_dependents(Path::new("docs/a.md"));
1477 assert!(a_dependents.is_empty());
1478 }
1479
1480 #[test]
1481 fn test_reverse_deps_multiple() {
1482 let mut index = WorkspaceIndex::new();
1483
1484 let mut file_a = FileIndex::new();
1486 file_a.add_cross_file_link(CrossFileLinkIndex {
1487 target_path: "../b.md".to_string(),
1488 fragment: "".to_string(),
1489 line: 1,
1490 column: 1,
1491 origin: LinkOrigin::Body,
1492 });
1493 index.update_file(Path::new("docs/sub/a.md"), file_a);
1494
1495 let mut file_c = FileIndex::new();
1496 file_c.add_cross_file_link(CrossFileLinkIndex {
1497 target_path: "b.md".to_string(),
1498 fragment: "".to_string(),
1499 line: 1,
1500 column: 1,
1501 origin: LinkOrigin::Body,
1502 });
1503 index.update_file(Path::new("docs/c.md"), file_c);
1504
1505 let dependents = index.get_dependents(Path::new("docs/b.md"));
1507 assert_eq!(dependents.len(), 2);
1508 assert!(dependents.contains(&PathBuf::from("docs/sub/a.md")));
1509 assert!(dependents.contains(&PathBuf::from("docs/c.md")));
1510 }
1511
1512 #[test]
1513 fn test_reverse_deps_update_clears_old() {
1514 let mut index = WorkspaceIndex::new();
1515
1516 let mut file_a = FileIndex::new();
1518 file_a.add_cross_file_link(CrossFileLinkIndex {
1519 target_path: "b.md".to_string(),
1520 fragment: "".to_string(),
1521 line: 1,
1522 column: 1,
1523 origin: LinkOrigin::Body,
1524 });
1525 index.update_file(Path::new("docs/a.md"), file_a);
1526
1527 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1529
1530 let mut file_a_updated = FileIndex::new();
1532 file_a_updated.add_cross_file_link(CrossFileLinkIndex {
1533 target_path: "c.md".to_string(),
1534 fragment: "".to_string(),
1535 line: 1,
1536 column: 1,
1537 origin: LinkOrigin::Body,
1538 });
1539 index.update_file(Path::new("docs/a.md"), file_a_updated);
1540
1541 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1543
1544 let c_deps = index.get_dependents(Path::new("docs/c.md"));
1546 assert_eq!(c_deps.len(), 1);
1547 assert_eq!(c_deps[0], PathBuf::from("docs/a.md"));
1548 }
1549
1550 #[test]
1551 fn test_reverse_deps_remove_file() {
1552 let mut index = WorkspaceIndex::new();
1553
1554 let mut file_a = FileIndex::new();
1556 file_a.add_cross_file_link(CrossFileLinkIndex {
1557 target_path: "b.md".to_string(),
1558 fragment: "".to_string(),
1559 line: 1,
1560 column: 1,
1561 origin: LinkOrigin::Body,
1562 });
1563 index.update_file(Path::new("docs/a.md"), file_a);
1564
1565 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1567
1568 index.remove_file(Path::new("docs/a.md"));
1570
1571 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1573 }
1574
1575 #[test]
1576 fn test_normalize_path() {
1577 let path = Path::new("docs/sub/../other.md");
1579 let normalized = normalize_relative_path(path);
1580 assert_eq!(normalized, PathBuf::from("docs/other.md"));
1581
1582 let path2 = Path::new("docs/./other.md");
1584 let normalized2 = normalize_relative_path(path2);
1585 assert_eq!(normalized2, PathBuf::from("docs/other.md"));
1586
1587 let path3 = Path::new("a/b/c/../../d.md");
1589 let normalized3 = normalize_relative_path(path3);
1590 assert_eq!(normalized3, PathBuf::from("a/d.md"));
1591 }
1592
1593 #[test]
1598 fn normalize_keeps_a_traversal_that_leaves_its_own_root() {
1599 assert_eq!(
1600 normalize_relative_path(Path::new("../notes.md")),
1601 PathBuf::from("../notes.md")
1602 );
1603 assert_eq!(
1604 normalize_relative_path(Path::new("docs/../../notes.md")),
1605 PathBuf::from("../notes.md")
1606 );
1607 assert_eq!(
1608 normalize_relative_path(Path::new("../../a/./b/../notes.md")),
1609 PathBuf::from("../../a/notes.md")
1610 );
1611 }
1612
1613 #[test]
1616 fn normalize_stops_a_traversal_at_a_root() {
1617 let root = if cfg!(windows) { "C:\\" } else { "/" };
1618 assert_eq!(
1619 normalize_relative_path(&Path::new(root).join("..").join("notes.md")),
1620 Path::new(root).join("notes.md")
1621 );
1622 }
1623
1624 #[test]
1625 fn test_clear_clears_reverse_deps() {
1626 let mut index = WorkspaceIndex::new();
1627
1628 let mut file_a = FileIndex::new();
1630 file_a.add_cross_file_link(CrossFileLinkIndex {
1631 target_path: "b.md".to_string(),
1632 fragment: "".to_string(),
1633 line: 1,
1634 column: 1,
1635 origin: LinkOrigin::Body,
1636 });
1637 index.update_file(Path::new("docs/a.md"), file_a);
1638
1639 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1641
1642 index.clear();
1644
1645 assert_eq!(index.file_count(), 0);
1647 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1648 }
1649
1650 #[test]
1651 fn test_is_file_stale() {
1652 let mut index = WorkspaceIndex::new();
1653
1654 assert!(index.is_file_stale(Path::new("nonexistent.md"), "hash123"));
1656
1657 let file_index = FileIndex::with_hash("hash123".to_string());
1659 index.insert_file(PathBuf::from("docs/test.md"), file_index);
1660
1661 assert!(!index.is_file_stale(Path::new("docs/test.md"), "hash123"));
1663
1664 assert!(index.is_file_stale(Path::new("docs/test.md"), "different_hash"));
1666 }
1667
1668 #[cfg(feature = "native")]
1669 #[test]
1670 fn test_cache_roundtrip() {
1671 use std::fs;
1672
1673 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_roundtrip");
1675 let _ = fs::remove_dir_all(&temp_dir);
1676 fs::create_dir_all(&temp_dir).unwrap();
1677
1678 let mut index = WorkspaceIndex::new();
1680
1681 let mut file1 = FileIndex::with_hash("abc123".to_string());
1682 file1.add_heading(HeadingIndex {
1683 text: "Test Heading".to_string(),
1684 auto_anchor: "test-heading".to_string(),
1685 custom_anchor: Some("test".to_string()),
1686 line: 1,
1687 is_setext: false,
1688 });
1689 file1.add_cross_file_link(CrossFileLinkIndex {
1690 target_path: "./other.md".to_string(),
1691 fragment: "section".to_string(),
1692 line: 5,
1693 column: 3,
1694 origin: LinkOrigin::Body,
1695 });
1696 index.update_file(Path::new("docs/file1.md"), file1);
1697
1698 let mut file2 = FileIndex::with_hash("def456".to_string());
1699 file2.add_heading(HeadingIndex {
1700 text: "Another Heading".to_string(),
1701 auto_anchor: "another-heading".to_string(),
1702 custom_anchor: None,
1703 line: 1,
1704 is_setext: false,
1705 });
1706 index.update_file(Path::new("docs/other.md"), file2);
1707
1708 index.save_to_cache(&temp_dir).expect("Failed to save cache");
1710
1711 assert!(temp_dir.join("workspace_index.bin").exists());
1713
1714 let loaded = WorkspaceIndex::load_from_cache(&temp_dir).expect("Failed to load cache");
1716
1717 assert_eq!(loaded.file_count(), 2);
1719 assert!(loaded.contains_file(Path::new("docs/file1.md")));
1720 assert!(loaded.contains_file(Path::new("docs/other.md")));
1721
1722 let file1_loaded = loaded.get_file(Path::new("docs/file1.md")).unwrap();
1724 assert_eq!(file1_loaded.content_hash, "abc123");
1725 assert_eq!(file1_loaded.headings.len(), 1);
1726 assert_eq!(file1_loaded.headings[0].text, "Test Heading");
1727 assert_eq!(file1_loaded.headings[0].custom_anchor, Some("test".to_string()));
1728 assert_eq!(file1_loaded.cross_file_links.len(), 1);
1729 assert_eq!(file1_loaded.cross_file_links[0].target_path, "./other.md");
1730
1731 let dependents = loaded.get_dependents(Path::new("docs/other.md"));
1733 assert_eq!(dependents.len(), 1);
1734 assert_eq!(dependents[0], PathBuf::from("docs/file1.md"));
1735
1736 let _ = fs::remove_dir_all(&temp_dir);
1738 }
1739
1740 #[cfg(feature = "native")]
1741 #[test]
1742 fn test_cache_missing_file() {
1743 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_missing");
1744 let _ = std::fs::remove_dir_all(&temp_dir);
1745
1746 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1748 assert!(result.is_none());
1749 }
1750
1751 #[cfg(feature = "native")]
1752 #[test]
1753 fn test_cache_corrupted_file() {
1754 use std::fs;
1755
1756 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_corrupted");
1757 let _ = fs::remove_dir_all(&temp_dir);
1758 fs::create_dir_all(&temp_dir).unwrap();
1759
1760 fs::write(temp_dir.join("workspace_index.bin"), b"bad").unwrap();
1762
1763 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1765 assert!(result.is_none());
1766
1767 assert!(!temp_dir.join("workspace_index.bin").exists());
1769
1770 let _ = fs::remove_dir_all(&temp_dir);
1772 }
1773
1774 #[cfg(feature = "native")]
1775 #[test]
1776 fn test_cache_invalid_magic() {
1777 use std::fs;
1778
1779 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_invalid_magic");
1780 let _ = fs::remove_dir_all(&temp_dir);
1781 fs::create_dir_all(&temp_dir).unwrap();
1782
1783 let mut data = Vec::new();
1785 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();
1789
1790 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1792 assert!(result.is_none());
1793
1794 assert!(!temp_dir.join("workspace_index.bin").exists());
1796
1797 let _ = fs::remove_dir_all(&temp_dir);
1799 }
1800
1801 #[cfg(feature = "native")]
1802 #[test]
1803 fn test_cache_version_mismatch() {
1804 use std::fs;
1805
1806 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_version_mismatch");
1807 let _ = fs::remove_dir_all(&temp_dir);
1808 fs::create_dir_all(&temp_dir).unwrap();
1809
1810 let mut data = Vec::new();
1812 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();
1816
1817 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1819 assert!(result.is_none());
1820
1821 assert!(!temp_dir.join("workspace_index.bin").exists());
1823
1824 let _ = fs::remove_dir_all(&temp_dir);
1826 }
1827
1828 #[cfg(feature = "native")]
1829 #[test]
1830 fn test_cache_atomic_write() {
1831 use std::fs;
1832
1833 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_atomic");
1835 let _ = fs::remove_dir_all(&temp_dir);
1836 fs::create_dir_all(&temp_dir).unwrap();
1837
1838 let index = WorkspaceIndex::new();
1839 index.save_to_cache(&temp_dir).expect("Failed to save");
1840
1841 let entries: Vec<_> = fs::read_dir(&temp_dir).unwrap().collect();
1843 assert_eq!(entries.len(), 1);
1844 assert!(temp_dir.join("workspace_index.bin").exists());
1845
1846 let _ = fs::remove_dir_all(&temp_dir);
1848 }
1849
1850 #[test]
1851 fn test_has_anchor_auto_generated() {
1852 let mut file_index = FileIndex::new();
1853 file_index.add_heading(HeadingIndex {
1854 text: "Installation Guide".to_string(),
1855 auto_anchor: "installation-guide".to_string(),
1856 custom_anchor: None,
1857 line: 1,
1858 is_setext: false,
1859 });
1860
1861 assert!(file_index.has_anchor("installation-guide"));
1863
1864 assert!(file_index.has_anchor("Installation-Guide"));
1866 assert!(file_index.has_anchor("INSTALLATION-GUIDE"));
1867
1868 assert!(!file_index.has_anchor("nonexistent"));
1870 }
1871
1872 #[test]
1873 fn test_has_anchor_custom() {
1874 let mut file_index = FileIndex::new();
1875 file_index.add_heading(HeadingIndex {
1876 text: "Installation Guide".to_string(),
1877 auto_anchor: "installation-guide".to_string(),
1878 custom_anchor: Some("install".to_string()),
1879 line: 1,
1880 is_setext: false,
1881 });
1882
1883 assert!(file_index.has_anchor("installation-guide"));
1885
1886 assert!(file_index.has_anchor("install"));
1888 assert!(file_index.has_anchor("Install")); assert!(!file_index.has_anchor("nonexistent"));
1892 }
1893
1894 #[test]
1895 fn test_get_heading_by_anchor() {
1896 let mut file_index = FileIndex::new();
1897 file_index.add_heading(HeadingIndex {
1898 text: "Installation Guide".to_string(),
1899 auto_anchor: "installation-guide".to_string(),
1900 custom_anchor: Some("install".to_string()),
1901 line: 10,
1902 is_setext: false,
1903 });
1904 file_index.add_heading(HeadingIndex {
1905 text: "Configuration".to_string(),
1906 auto_anchor: "configuration".to_string(),
1907 custom_anchor: None,
1908 line: 20,
1909 is_setext: false,
1910 });
1911
1912 let heading = file_index.get_heading_by_anchor("installation-guide");
1914 assert!(heading.is_some());
1915 assert_eq!(heading.unwrap().text, "Installation Guide");
1916 assert_eq!(heading.unwrap().line, 10);
1917
1918 let heading = file_index.get_heading_by_anchor("install");
1920 assert!(heading.is_some());
1921 assert_eq!(heading.unwrap().text, "Installation Guide");
1922
1923 let heading = file_index.get_heading_by_anchor("configuration");
1925 assert!(heading.is_some());
1926 assert_eq!(heading.unwrap().text, "Configuration");
1927 assert_eq!(heading.unwrap().line, 20);
1928
1929 assert!(file_index.get_heading_by_anchor("nonexistent").is_none());
1931 }
1932
1933 #[test]
1934 fn test_anchor_lookup_many_headings() {
1935 let mut file_index = FileIndex::new();
1937
1938 for i in 0..100 {
1940 file_index.add_heading(HeadingIndex {
1941 text: format!("Heading {i}"),
1942 auto_anchor: format!("heading-{i}"),
1943 custom_anchor: Some(format!("h{i}")),
1944 line: i + 1,
1945 is_setext: false,
1946 });
1947 }
1948
1949 for i in 0..100 {
1951 assert!(file_index.has_anchor(&format!("heading-{i}")));
1952 assert!(file_index.has_anchor(&format!("h{i}")));
1953
1954 let heading = file_index.get_heading_by_anchor(&format!("heading-{i}"));
1955 assert!(heading.is_some());
1956 assert_eq!(heading.unwrap().line, i + 1);
1957 }
1958 }
1959
1960 #[test]
1965 fn test_extract_cross_file_links_basic() {
1966 use crate::config::MarkdownFlavor;
1967
1968 let content = "# Test\n\nSee [link](./other.md) for info.\n";
1969 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1970 let links = extract_cross_file_links(&ctx).relative;
1971
1972 assert_eq!(links.len(), 1);
1973 assert_eq!(links[0].target_path, "./other.md");
1974 assert_eq!(links[0].fragment, "");
1975 assert_eq!(links[0].line, 3);
1976 assert_eq!(links[0].column, 12);
1978 }
1979
1980 #[test]
1981 fn test_extract_cross_file_links_with_fragment() {
1982 use crate::config::MarkdownFlavor;
1983
1984 let content = "Check [guide](./guide.md#install) here.\n";
1985 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1986 let links = extract_cross_file_links(&ctx).relative;
1987
1988 assert_eq!(links.len(), 1);
1989 assert_eq!(links[0].target_path, "./guide.md");
1990 assert_eq!(links[0].fragment, "install");
1991 assert_eq!(links[0].line, 1);
1992 assert_eq!(links[0].column, 15);
1994 }
1995
1996 #[test]
1997 fn test_extract_cross_file_links_multiple_on_same_line() {
1998 use crate::config::MarkdownFlavor;
1999
2000 let content = "See [a](a.md) and [b](b.md) here.\n";
2001 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2002 let links = extract_cross_file_links(&ctx).relative;
2003
2004 assert_eq!(links.len(), 2);
2005
2006 assert_eq!(links[0].target_path, "a.md");
2007 assert_eq!(links[0].line, 1);
2008 assert_eq!(links[0].column, 9);
2010
2011 assert_eq!(links[1].target_path, "b.md");
2012 assert_eq!(links[1].line, 1);
2013 assert_eq!(links[1].column, 23);
2015 }
2016
2017 #[test]
2018 fn test_extract_cross_file_links_angle_brackets() {
2019 use crate::config::MarkdownFlavor;
2020
2021 let content = "See [link](<path/with (parens).md>) here.\n";
2022 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2023 let links = extract_cross_file_links(&ctx).relative;
2024
2025 assert_eq!(links.len(), 1);
2026 assert_eq!(links[0].target_path, "path/with (parens).md");
2027 assert_eq!(links[0].line, 1);
2028 assert_eq!(links[0].column, 13);
2030 }
2031
2032 #[test]
2033 fn test_extract_cross_file_links_skips_external() {
2034 use crate::config::MarkdownFlavor;
2035
2036 let content = r#"
2037[external](https://example.com)
2038[mailto](mailto:test@example.com)
2039[local](./local.md)
2040[fragment](#section)
2041[absolute](/docs/page.md)
2042"#;
2043 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2044 let extracted = extract_cross_file_links(&ctx);
2045
2046 assert_eq!(extracted.relative.len(), 1);
2048 assert_eq!(extracted.relative[0].target_path, "./local.md");
2049 assert_eq!(extracted.root_relative.len(), 1);
2051 assert_eq!(extracted.root_relative[0].target_path, "docs/page.md");
2052 }
2053
2054 #[test]
2055 fn test_extract_cross_file_links_root_relative() {
2056 use crate::config::MarkdownFlavor;
2057
2058 let content = "[a](/guide.md#install)\n[b](/../escape.md)\n[c](//host/x.md)\n[d](/img/pic.png)\n";
2062 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2063 let extracted = extract_cross_file_links(&ctx);
2064
2065 assert!(extracted.relative.is_empty(), "no directory-relative links here");
2066 assert_eq!(
2067 extracted
2068 .root_relative
2069 .iter()
2070 .map(|l| (l.target_path.as_str(), l.fragment.as_str()))
2071 .collect::<Vec<_>>(),
2072 vec![("guide.md", "install")],
2073 "only the safe root-relative markdown link is captured"
2074 );
2075 }
2076
2077 #[test]
2078 fn test_extract_cross_file_links_skips_non_markdown() {
2079 use crate::config::MarkdownFlavor;
2080
2081 let content = r#"
2082[image](./photo.png)
2083[doc](./readme.md)
2084[pdf](./document.pdf)
2085"#;
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);
2091 assert_eq!(links[0].target_path, "./readme.md");
2092 }
2093
2094 #[test]
2095 fn test_extract_cross_file_links_skips_code_spans() {
2096 use crate::config::MarkdownFlavor;
2097
2098 let content = "Normal [link](./file.md) and `[code](./ignored.md)` here.\n";
2099 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2100 let links = extract_cross_file_links(&ctx).relative;
2101
2102 assert_eq!(links.len(), 1);
2104 assert_eq!(links[0].target_path, "./file.md");
2105 }
2106
2107 #[test]
2108 fn test_extract_cross_file_links_with_query_params() {
2109 use crate::config::MarkdownFlavor;
2110
2111 let content = "See [doc](./file.md?raw=true) here.\n";
2112 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2113 let links = extract_cross_file_links(&ctx).relative;
2114
2115 assert_eq!(links.len(), 1);
2116 assert_eq!(links[0].target_path, "./file.md");
2118 }
2119
2120 #[test]
2121 fn test_extract_cross_file_links_empty_content() {
2122 use crate::config::MarkdownFlavor;
2123
2124 let content = "";
2125 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2126 let links = extract_cross_file_links(&ctx).relative;
2127
2128 assert!(links.is_empty());
2129 }
2130
2131 #[test]
2132 fn test_extract_cross_file_links_no_links() {
2133 use crate::config::MarkdownFlavor;
2134
2135 let content = "# Just a heading\n\nSome text without links.\n";
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_position_accuracy_issue_234() {
2144 use crate::config::MarkdownFlavor;
2147
2148 let content = r#"# Test Document
2149
2150Here is a [broken link](nonexistent-file.md) that should trigger MD057.
2151
2152And another [link](also-missing.md) on this line.
2153"#;
2154 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2155 let links = extract_cross_file_links(&ctx).relative;
2156
2157 assert_eq!(links.len(), 2);
2158
2159 assert_eq!(links[0].target_path, "nonexistent-file.md");
2161 assert_eq!(links[0].line, 3);
2162 assert_eq!(links[0].column, 25);
2163
2164 assert_eq!(links[1].target_path, "also-missing.md");
2166 assert_eq!(links[1].line, 5);
2167 assert_eq!(links[1].column, 20);
2168 }
2169}