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
87pub(crate) static URL_EXTRACT_ANGLE_BRACKET_REGEX: LazyLock<Regex> =
96 LazyLock::new(|| Regex::new(r#"\]\(\s*<([^>]+)>(#[^\)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
97
98pub(crate) static URL_EXTRACT_REGEX: LazyLock<Regex> =
106 LazyLock::new(|| Regex::new(r#"]\(\s*((?:[^()>\s#]|\([^()]*\))+)(#[^)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
107
108pub(crate) static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
110 LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
111
112#[inline]
114fn is_markdown_file(path: &str) -> bool {
115 crate::discovery::has_markdown_extension(std::path::Path::new(path))
116}
117
118fn strip_query_and_fragment(url: &str) -> &str {
121 let query_pos = url.find('?');
122 let fragment_pos = url.find('#');
123
124 match (query_pos, fragment_pos) {
125 (Some(q), Some(f)) => &url[..q.min(f)],
126 (Some(q), None) => &url[..q],
127 (None, Some(f)) => &url[..f],
128 (None, None) => url,
129 }
130}
131
132pub fn link_target_file(source_dir: &Path, target_path: &str) -> PathBuf {
142 normalize_relative_path(&resolve_target_against(
143 source_dir,
144 strip_query_and_fragment(target_path),
145 ))
146}
147
148fn resolve_target_against(source_dir: &Path, target_path: &str) -> PathBuf {
157 match target_path.strip_prefix('/') {
158 Some(from_root) => resolve_against_project_root(source_dir, from_root),
159 None => source_dir.join(target_path),
160 }
161}
162
163fn resolve_against_project_root(source_dir: &Path, from_root: &str) -> PathBuf {
177 let absolute = crate::utils::project_root::project_root().join(from_root);
178 if source_dir.is_absolute() {
179 return absolute;
180 }
181 match crate::utils::project_root::working_directory().and_then(|cwd| absolute.strip_prefix(cwd).ok()) {
182 Some(relative) => relative.to_path_buf(),
183 None => absolute,
184 }
185}
186
187#[derive(Debug, Default)]
194pub struct ExtractedCrossFileLinks {
195 pub relative: Vec<CrossFileLinkIndex>,
197 pub root_relative: Vec<CrossFileLinkIndex>,
201}
202
203pub fn extract_cross_file_links(ctx: &LintContext) -> ExtractedCrossFileLinks {
211 let content = ctx.content;
212
213 if content.is_empty() || !content.contains("](") {
215 return ExtractedCrossFileLinks::default();
216 }
217
218 let mut links = ExtractedCrossFileLinks::default();
219 let lines: Vec<&str> = content.lines().collect();
220
221 let mut processed_lines = HashSet::new();
224
225 for link in ctx.links() {
226 let line_idx = link.line - 1;
227 if line_idx >= lines.len() {
228 continue;
229 }
230
231 if ctx.line_info(link.line).is_some_and(|info| info.in_front_matter) {
235 continue;
236 }
237
238 if !processed_lines.insert(line_idx) {
240 continue;
241 }
242
243 let line = lines[line_idx];
244 if !line.contains("](") {
245 continue;
246 }
247
248 for link_match in LINK_START_REGEX.find_iter(line) {
250 let start_pos = link_match.start();
251 let end_pos = link_match.end();
252
253 let line_start_byte = ctx.line_start_byte(line_idx + 1).unwrap_or(0);
255 let absolute_start_pos = line_start_byte + start_pos;
256
257 if ctx.is_in_code_span_byte(absolute_start_pos) {
259 continue;
260 }
261
262 let caps_result = URL_EXTRACT_ANGLE_BRACKET_REGEX
265 .captures_at(line, end_pos - 1)
266 .or_else(|| URL_EXTRACT_REGEX.captures_at(line, end_pos - 1));
267
268 if let Some(caps) = caps_result
269 && let Some(url_group) = caps.get(1)
270 {
271 let file_path = url_group.as_str().trim();
272
273 if let Some(rel) = file_path.strip_prefix('/') {
278 if !rel.starts_with('/')
279 && !Path::new(rel)
280 .components()
281 .any(|c| matches!(c, std::path::Component::ParentDir))
282 {
283 let stripped = strip_query_and_fragment(rel);
284 if is_markdown_file(stripped) {
285 let fragment = caps.get(2).map_or("", |m| m.as_str().trim_start_matches('#'));
286 links.root_relative.push(CrossFileLinkIndex {
287 target_path: stripped.to_string(),
288 fragment: fragment.to_string(),
289 line: link.line,
290 column: byte_to_char_count(line, url_group.start()),
291 origin: LinkOrigin::Body,
292 });
293 }
294 }
295 continue;
296 }
297
298 if file_path.is_empty()
301 || PROTOCOL_DOMAIN_REGEX.is_match(file_path)
302 || file_path.starts_with("www.")
303 || file_path.starts_with('#')
304 || file_path.starts_with("{{")
305 || file_path.starts_with("{%")
306 || file_path.starts_with('~')
307 || file_path.starts_with('@')
308 || (file_path.starts_with('`') && file_path.ends_with('`'))
309 {
310 continue;
311 }
312
313 let file_path = strip_query_and_fragment(file_path);
315
316 let fragment = caps.get(2).map_or("", |m| m.as_str().trim_start_matches('#'));
318
319 if is_markdown_file(file_path) {
321 links.relative.push(CrossFileLinkIndex {
322 target_path: file_path.to_string(),
323 fragment: fragment.to_string(),
324 line: link.line,
325 column: byte_to_char_count(line, url_group.start()),
326 origin: LinkOrigin::Body,
327 });
328 }
329 }
330 }
331 }
332
333 links
334}
335
336#[cfg(feature = "postcard")]
338const CACHE_MAGIC: &[u8; 4] = b"RWSI";
339
340#[cfg(feature = "postcard")]
371const CACHE_FORMAT_VERSION: u32 = 14;
372
373#[cfg(feature = "postcard")]
375const CACHE_FILE_NAME: &str = "workspace_index.bin";
376
377#[cfg(feature = "postcard")]
381static CACHE_TEMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
382
383#[derive(Debug, Default, Clone, Serialize, Deserialize)]
388pub struct WorkspaceIndex {
389 files: HashMap<PathBuf, FileIndex>,
391 reverse_deps: HashMap<PathBuf, HashSet<PathBuf>>,
394 version: u64,
396}
397
398#[derive(Debug, Clone, Default, Serialize, Deserialize)]
400pub struct FileIndex {
401 pub headings: Vec<HeadingIndex>,
403 pub reference_links: Vec<ReferenceLinkIndex>,
405 pub cross_file_links: Vec<CrossFileLinkIndex>,
407 #[serde(default)]
412 pub root_relative_links: Vec<CrossFileLinkIndex>,
413 #[serde(default)]
418 pub md057_link_targets: Vec<Md057LinkTarget>,
419 pub defined_references: HashSet<String>,
422 pub content_hash: String,
424 anchor_to_heading: HashMap<String, usize>,
427 #[serde(default)]
431 anchor_to_heading_exact: HashMap<String, usize>,
432 html_anchors: HashSet<String>,
435 #[serde(default)]
438 html_anchors_exact: HashSet<String>,
439 attribute_anchors: HashSet<String>,
443 #[serde(default)]
446 attribute_anchors_exact: HashSet<String>,
447 pub file_disabled_rules: HashSet<String>,
450 pub persistent_transitions: Vec<(usize, HashSet<String>, HashSet<String>)>,
453 pub line_disabled_rules: HashMap<usize, HashSet<String>>,
455}
456
457#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
459pub struct HeadingIndex {
460 pub text: String,
462 pub auto_anchor: String,
464 pub custom_anchor: Option<String>,
466 pub line: usize,
468 pub text_lines: usize,
472 #[serde(default)]
474 pub is_setext: bool,
475}
476
477impl HeadingIndex {
478 #[must_use]
480 pub fn first_line(&self) -> usize {
481 self.line + 1 - self.text_lines.max(1)
482 }
483
484 #[must_use]
486 pub fn covers_line(&self, line: usize) -> bool {
487 (self.first_line()..=self.line).contains(&line)
488 }
489}
490
491#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
493pub struct ReferenceLinkIndex {
494 pub reference_id: String,
496 pub line: usize,
498 pub column: usize,
500}
501
502#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
510pub enum LinkOrigin {
511 Body,
513 FrontMatter { field: Option<String> },
522}
523
524#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
526pub struct CrossFileLinkIndex {
527 pub target_path: String,
529 pub fragment: String,
531 pub line: usize,
533 pub column: usize,
535 pub origin: LinkOrigin,
537}
538
539#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
541pub struct Md057LinkTarget {
542 pub target: String,
544 pub origin: LinkOrigin,
546}
547
548pub fn link_target_candidates(source_file: &Path, target_path: &str) -> Vec<PathBuf> {
560 let target_path = strip_query_and_fragment(target_path);
561
562 let joined = resolve_target_against(source_file.parent().unwrap_or(Path::new("")), target_path);
563 let base = normalize_relative_path(&joined);
564
565 if base.extension().is_some() {
566 return vec![base];
567 }
568
569 let mut candidates = Vec::with_capacity(crate::discovery::MARKDOWN_EXTENSIONS.len() + 1);
572 for ext in crate::discovery::MARKDOWN_EXTENSIONS {
573 candidates.push(base.with_extension(ext));
574 }
575 candidates.insert(0, base);
576 candidates
577}
578
579pub fn normalize_relative_path(path: &Path) -> PathBuf {
590 let mut components: Vec<std::path::Component<'_>> = Vec::new();
591 for component in path.components() {
592 match component {
593 std::path::Component::CurDir => {}
594 std::path::Component::ParentDir => match components.last() {
595 Some(std::path::Component::Normal(_)) => {
596 components.pop();
597 }
598 Some(std::path::Component::RootDir) => {}
599 _ => components.push(component),
600 },
601 c => components.push(c),
602 }
603 }
604 components.iter().collect()
605}
606
607impl CrossFileLinkIndex {
608 pub fn is_navigable(&self) -> bool {
616 matches!(self.origin, LinkOrigin::Body)
617 }
618}
619
620#[derive(Debug, Clone, Serialize, Deserialize)]
622pub struct VulnerableAnchor {
623 pub file: PathBuf,
625 pub line: usize,
627 pub text: String,
629}
630
631impl WorkspaceIndex {
632 pub fn new() -> Self {
634 Self::default()
635 }
636
637 pub fn version(&self) -> u64 {
639 self.version
640 }
641
642 pub fn file_count(&self) -> usize {
644 self.files.len()
645 }
646
647 pub fn contains_file(&self, path: &Path) -> bool {
649 self.files.contains_key(path)
650 }
651
652 pub fn get_file(&self, path: &Path) -> Option<&FileIndex> {
654 self.files.get(path)
655 }
656
657 pub fn insert_file(&mut self, path: PathBuf, index: FileIndex) {
659 self.files.insert(path, index);
660 self.version = self.version.wrapping_add(1);
661 }
662
663 pub fn remove_file(&mut self, path: &Path) -> Option<FileIndex> {
665 self.clear_reverse_deps_for(path);
667
668 let result = self.files.remove(path);
669 if result.is_some() {
670 self.version = self.version.wrapping_add(1);
671 }
672 result
673 }
674
675 pub fn get_vulnerable_anchors(&self) -> HashMap<String, Vec<VulnerableAnchor>> {
685 let mut vulnerable: HashMap<String, Vec<VulnerableAnchor>> = HashMap::new();
686
687 for (file_path, file_index) in &self.files {
688 for heading in &file_index.headings {
689 if heading.custom_anchor.is_none() && !heading.auto_anchor.is_empty() {
691 let anchor_key = heading.auto_anchor.to_lowercase();
692 vulnerable.entry(anchor_key).or_default().push(VulnerableAnchor {
693 file: file_path.clone(),
694 line: heading.line,
695 text: heading.text.clone(),
696 });
697 }
698 }
699 }
700
701 vulnerable
702 }
703
704 pub fn all_headings(&self) -> impl Iterator<Item = (&Path, &HeadingIndex)> {
706 self.files
707 .iter()
708 .flat_map(|(path, index)| index.headings.iter().map(move |h| (path.as_path(), h)))
709 }
710
711 pub fn files(&self) -> impl Iterator<Item = (&Path, &FileIndex)> {
713 self.files.iter().map(|(p, i)| (p.as_path(), i))
714 }
715
716 pub fn files_sorted(&self) -> Vec<(&Path, &FileIndex)> {
722 let mut entries: Vec<(&Path, &FileIndex)> = self.files.iter().map(|(p, i)| (p.as_path(), i)).collect();
723 entries.sort_by_key(|(a, _)| *a);
724 entries
725 }
726
727 pub fn clear(&mut self) {
729 self.files.clear();
730 self.reverse_deps.clear();
731 self.version = self.version.wrapping_add(1);
732 }
733
734 pub fn update_file(&mut self, path: &Path, index: FileIndex) {
741 self.clear_reverse_deps_as_source(path);
744
745 for link in &index.cross_file_links {
747 let target = self.resolve_target_path(path, &link.target_path);
748 self.reverse_deps.entry(target).or_default().insert(path.to_path_buf());
749 }
750
751 self.files.insert(path.to_path_buf(), index);
752 self.version = self.version.wrapping_add(1);
753 }
754
755 pub fn get_dependents(&self, path: &Path) -> Vec<PathBuf> {
760 self.reverse_deps
761 .get(path)
762 .map(|set| set.iter().cloned().collect())
763 .unwrap_or_default()
764 }
765
766 pub fn is_file_stale(&self, path: &Path, current_hash: &str) -> bool {
770 self.files.get(path).is_none_or(|f| f.content_hash != current_hash)
771 }
772
773 pub fn retain_only(&mut self, current_files: &std::collections::HashSet<PathBuf>) -> usize {
778 let before_count = self.files.len();
779
780 let to_remove: Vec<PathBuf> = self
782 .files
783 .keys()
784 .filter(|path| !current_files.contains(*path))
785 .cloned()
786 .collect();
787
788 for path in &to_remove {
790 self.remove_file(path);
791 }
792
793 before_count - self.files.len()
794 }
795
796 #[cfg(feature = "postcard")]
803 pub fn save_to_cache(&self, cache_dir: &Path) -> std::io::Result<()> {
804 use std::fs;
805 use std::io::Write;
806
807 fs::create_dir_all(cache_dir)?;
809
810 let encoded = postcard::to_allocvec(self)
812 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
813
814 let mut cache_data = Vec::with_capacity(8 + encoded.len());
816 cache_data.extend_from_slice(CACHE_MAGIC);
817 cache_data.extend_from_slice(&CACHE_FORMAT_VERSION.to_le_bytes());
818 cache_data.extend_from_slice(&encoded);
819
820 let final_path = cache_dir.join(CACHE_FILE_NAME);
825 let counter = CACHE_TEMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
826 #[cfg(not(target_arch = "wasm32"))]
827 let temp_path = cache_dir.join(format!("{CACHE_FILE_NAME}.tmp.{}.{counter}", std::process::id()));
828 #[cfg(target_arch = "wasm32")]
829 let temp_path = cache_dir.join(format!("{CACHE_FILE_NAME}.tmp.{counter}"));
830
831 {
833 let mut file = fs::File::create(&temp_path)?;
834 file.write_all(&cache_data)?;
835 file.sync_all()?;
836 }
837
838 fs::rename(&temp_path, &final_path)?;
840
841 log::debug!(
842 "Saved workspace index to cache: {} files, {} bytes (format v{})",
843 self.files.len(),
844 cache_data.len(),
845 CACHE_FORMAT_VERSION
846 );
847
848 Ok(())
849 }
850
851 #[cfg(feature = "postcard")]
859 pub fn load_from_cache(cache_dir: &Path) -> Option<Self> {
860 use std::fs;
861
862 let path = cache_dir.join(CACHE_FILE_NAME);
863 let data = fs::read(&path).ok()?;
864
865 if data.len() < 8 {
867 log::warn!("Workspace index cache too small, discarding");
868 let _ = fs::remove_file(&path);
869 return None;
870 }
871
872 if &data[0..4] != CACHE_MAGIC {
874 log::warn!("Workspace index cache has invalid magic header, discarding");
875 let _ = fs::remove_file(&path);
876 return None;
877 }
878
879 let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
881 if version != CACHE_FORMAT_VERSION {
882 log::info!(
883 "Workspace index cache format version mismatch (got {version}, expected {CACHE_FORMAT_VERSION}), rebuilding"
884 );
885 let _ = fs::remove_file(&path);
886 return None;
887 }
888
889 match postcard::from_bytes::<Self>(&data[8..]) {
891 Ok(index) => {
892 log::debug!(
893 "Loaded workspace index from cache: {} files (format v{})",
894 index.files.len(),
895 version
896 );
897 Some(index)
898 }
899 Err(e) => {
900 log::warn!("Failed to deserialize workspace index cache: {e}");
901 let _ = fs::remove_file(&path);
902 None
903 }
904 }
905 }
906
907 fn clear_reverse_deps_as_source(&mut self, path: &Path) {
912 let targets: Vec<PathBuf> = match self.files.get(path) {
919 Some(index) => index
920 .cross_file_links
921 .iter()
922 .map(|link| self.resolve_target_path(path, &link.target_path))
923 .collect(),
924 None => return,
925 };
926 for target in targets {
927 if let Some(deps) = self.reverse_deps.get_mut(&target) {
928 deps.remove(path);
929 if deps.is_empty() {
930 self.reverse_deps.remove(&target);
931 }
932 }
933 }
934 }
935
936 fn clear_reverse_deps_for(&mut self, path: &Path) {
941 self.clear_reverse_deps_as_source(path);
943
944 self.reverse_deps.remove(path);
946 }
947
948 fn resolve_target_path(&self, source_file: &Path, relative_target: &str) -> PathBuf {
954 let source_dir = source_file.parent().unwrap_or(Path::new(""));
955 link_target_file(source_dir, relative_target)
956 }
957}
958
959impl FileIndex {
960 pub fn new() -> Self {
962 Self::default()
963 }
964
965 pub fn with_hash(content_hash: String) -> Self {
967 Self {
968 content_hash,
969 ..Default::default()
970 }
971 }
972
973 pub fn extracted_data_differs(&self, other: &Self) -> bool {
989 let Self {
993 headings,
994 reference_links,
995 cross_file_links,
996 root_relative_links,
997 md057_link_targets,
998 defined_references,
999 content_hash: _,
1000 anchor_to_heading,
1001 anchor_to_heading_exact,
1002 html_anchors,
1003 html_anchors_exact,
1004 attribute_anchors,
1005 attribute_anchors_exact,
1006 file_disabled_rules,
1007 persistent_transitions,
1008 line_disabled_rules,
1009 } = self;
1010
1011 headings != &other.headings
1012 || reference_links != &other.reference_links
1013 || cross_file_links != &other.cross_file_links
1014 || root_relative_links != &other.root_relative_links
1015 || md057_link_targets != &other.md057_link_targets
1016 || defined_references != &other.defined_references
1017 || anchor_to_heading != &other.anchor_to_heading
1018 || anchor_to_heading_exact != &other.anchor_to_heading_exact
1019 || html_anchors != &other.html_anchors
1020 || html_anchors_exact != &other.html_anchors_exact
1021 || attribute_anchors != &other.attribute_anchors
1022 || attribute_anchors_exact != &other.attribute_anchors_exact
1023 || file_disabled_rules != &other.file_disabled_rules
1024 || persistent_transitions != &other.persistent_transitions
1025 || line_disabled_rules != &other.line_disabled_rules
1026 }
1027
1028 pub fn add_heading(&mut self, heading: HeadingIndex) {
1034 let index = self.headings.len();
1035
1036 self.anchor_to_heading.insert(heading.auto_anchor.to_lowercase(), index);
1039 self.anchor_to_heading_exact.insert(heading.auto_anchor.clone(), index);
1040
1041 if let Some(ref custom) = heading.custom_anchor {
1043 self.anchor_to_heading.insert(custom.to_lowercase(), index);
1044 self.anchor_to_heading_exact.insert(custom.clone(), index);
1045 }
1046
1047 self.headings.push(heading);
1048 }
1049
1050 pub fn add_anchor_alias(&mut self, anchor: &str, heading_index: usize) {
1053 if heading_index < self.headings.len() {
1054 self.anchor_to_heading.insert(anchor.to_lowercase(), heading_index);
1055 self.anchor_to_heading_exact.insert(anchor.to_string(), heading_index);
1056 }
1057 }
1058
1059 pub fn has_anchor(&self, anchor: &str) -> bool {
1070 self.has_anchor_with_case(anchor, true)
1071 }
1072
1073 pub fn has_anchor_with_case(&self, anchor: &str, ignore_case: bool) -> bool {
1082 if self.lookup_anchor(anchor, ignore_case) {
1083 return true;
1084 }
1085
1086 if anchor.contains('%') {
1088 let decoded = url_decode(anchor);
1089 if decoded != anchor {
1090 return self.lookup_anchor(&decoded, ignore_case);
1091 }
1092 }
1093
1094 false
1095 }
1096
1097 fn lookup_anchor(&self, anchor: &str, ignore_case: bool) -> bool {
1100 if ignore_case {
1101 let lower = anchor.to_lowercase();
1102 self.anchor_to_heading.contains_key(&lower)
1103 || self.html_anchors.contains(&lower)
1104 || self.attribute_anchors.contains(&lower)
1105 } else {
1106 self.anchor_to_heading_exact.contains_key(anchor)
1107 || self.html_anchors_exact.contains(anchor)
1108 || self.attribute_anchors_exact.contains(anchor)
1109 }
1110 }
1111
1112 pub fn add_html_anchor(&mut self, anchor: &str) {
1115 if !anchor.is_empty() {
1116 self.html_anchors.insert(anchor.to_lowercase());
1117 self.html_anchors_exact.insert(anchor.to_string());
1118 }
1119 }
1120
1121 pub fn add_attribute_anchor(&mut self, anchor: &str) {
1124 if !anchor.is_empty() {
1125 self.attribute_anchors.insert(anchor.to_lowercase());
1126 self.attribute_anchors_exact.insert(anchor.to_string());
1127 }
1128 }
1129
1130 pub fn get_heading_by_anchor(&self, anchor: &str) -> Option<&HeadingIndex> {
1134 self.anchor_to_heading
1135 .get(&anchor.to_lowercase())
1136 .and_then(|&idx| self.headings.get(idx))
1137 }
1138
1139 pub fn add_reference_link(&mut self, link: ReferenceLinkIndex) {
1141 self.reference_links.push(link);
1142 }
1143
1144 pub fn is_rule_disabled_at_line(&self, rule_name: &str, line: usize) -> bool {
1149 if self.file_disabled_rules.contains("*") || self.file_disabled_rules.contains(rule_name) {
1151 return true;
1152 }
1153
1154 if let Some(rules) = self.line_disabled_rules.get(&line)
1156 && (rules.contains("*") || rules.contains(rule_name))
1157 {
1158 return true;
1159 }
1160
1161 if !self.persistent_transitions.is_empty() {
1163 let idx = match self.persistent_transitions.binary_search_by_key(&line, |t| t.0) {
1164 Ok(i) => Some(i),
1165 Err(i) => {
1166 if i > 0 {
1167 Some(i - 1)
1168 } else {
1169 None
1170 }
1171 }
1172 };
1173 if let Some(i) = idx {
1174 let (_, ref disabled, ref enabled) = self.persistent_transitions[i];
1175 if disabled.contains("*") {
1176 return !enabled.contains(rule_name);
1177 }
1178 return disabled.contains(rule_name);
1179 }
1180 }
1181
1182 false
1183 }
1184
1185 pub fn add_cross_file_link(&mut self, link: CrossFileLinkIndex) {
1200 let existing = self.cross_file_links.iter_mut().find(|existing| {
1201 existing.fragment == link.fragment
1202 && existing.line == link.line
1203 && strip_query_and_fragment(&existing.target_path) == strip_query_and_fragment(&link.target_path)
1204 });
1205 match existing {
1206 Some(existing) => {
1209 if !existing.target_path.contains('?') && link.target_path.contains('?') {
1210 *existing = link;
1211 }
1212 }
1213 None => self.cross_file_links.push(link),
1214 }
1215 }
1216
1217 pub fn add_root_relative_link(&mut self, link: CrossFileLinkIndex) {
1219 let is_duplicate = self.root_relative_links.iter().any(|existing| {
1220 existing.target_path == link.target_path && existing.fragment == link.fragment && existing.line == link.line
1221 });
1222 if !is_duplicate {
1223 self.root_relative_links.push(link);
1224 }
1225 }
1226
1227 pub fn add_md057_link_target(&mut self, target: Md057LinkTarget) {
1229 self.md057_link_targets.push(target);
1230 }
1231
1232 pub fn add_defined_reference(&mut self, ref_id: String) {
1234 self.defined_references.insert(ref_id);
1235 }
1236
1237 pub fn has_defined_reference(&self, ref_id: &str) -> bool {
1239 self.defined_references.contains(ref_id)
1240 }
1241
1242 pub fn hash_matches(&self, hash: &str) -> bool {
1244 self.content_hash == hash
1245 }
1246
1247 pub fn heading_count(&self) -> usize {
1249 self.headings.len()
1250 }
1251
1252 pub fn reference_link_count(&self) -> usize {
1254 self.reference_links.len()
1255 }
1256}
1257
1258#[cfg(test)]
1259mod tests {
1260 use super::*;
1261
1262 #[test]
1263 fn test_workspace_index_basic() {
1264 let mut index = WorkspaceIndex::new();
1265 assert_eq!(index.file_count(), 0);
1266 assert_eq!(index.version(), 0);
1267
1268 let mut file_index = FileIndex::with_hash("abc123".to_string());
1269 file_index.add_heading(HeadingIndex {
1270 text: "Installation".to_string(),
1271 auto_anchor: "installation".to_string(),
1272 custom_anchor: None,
1273 line: 1,
1274 text_lines: 1,
1275 is_setext: false,
1276 });
1277
1278 index.insert_file(PathBuf::from("docs/install.md"), file_index);
1279 assert_eq!(index.file_count(), 1);
1280 assert_eq!(index.version(), 1);
1281
1282 assert!(index.contains_file(Path::new("docs/install.md")));
1283 assert!(!index.contains_file(Path::new("docs/other.md")));
1284 }
1285
1286 #[test]
1287 fn test_vulnerable_anchors() {
1288 let mut index = WorkspaceIndex::new();
1289
1290 let mut file1 = FileIndex::new();
1292 file1.add_heading(HeadingIndex {
1293 text: "Getting Started".to_string(),
1294 auto_anchor: "getting-started".to_string(),
1295 custom_anchor: None,
1296 line: 1,
1297 text_lines: 1,
1298 is_setext: false,
1299 });
1300 index.insert_file(PathBuf::from("docs/guide.md"), file1);
1301
1302 let mut file2 = FileIndex::new();
1304 file2.add_heading(HeadingIndex {
1305 text: "Installation".to_string(),
1306 auto_anchor: "installation".to_string(),
1307 custom_anchor: Some("install".to_string()),
1308 line: 1,
1309 text_lines: 1,
1310 is_setext: false,
1311 });
1312 index.insert_file(PathBuf::from("docs/install.md"), file2);
1313
1314 let vulnerable = index.get_vulnerable_anchors();
1315 assert_eq!(vulnerable.len(), 1);
1316 assert!(vulnerable.contains_key("getting-started"));
1317 assert!(!vulnerable.contains_key("installation"));
1318
1319 let anchors = vulnerable.get("getting-started").unwrap();
1320 assert_eq!(anchors.len(), 1);
1321 assert_eq!(anchors[0].file, PathBuf::from("docs/guide.md"));
1322 assert_eq!(anchors[0].text, "Getting Started");
1323 }
1324
1325 #[test]
1326 fn test_vulnerable_anchors_multiple_files_same_anchor() {
1327 let mut index = WorkspaceIndex::new();
1330
1331 let mut file1 = FileIndex::new();
1333 file1.add_heading(HeadingIndex {
1334 text: "Installation".to_string(),
1335 auto_anchor: "installation".to_string(),
1336 custom_anchor: None,
1337 line: 1,
1338 text_lines: 1,
1339 is_setext: false,
1340 });
1341 index.insert_file(PathBuf::from("docs/en/guide.md"), file1);
1342
1343 let mut file2 = FileIndex::new();
1345 file2.add_heading(HeadingIndex {
1346 text: "Installation".to_string(),
1347 auto_anchor: "installation".to_string(),
1348 custom_anchor: None,
1349 line: 5,
1350 text_lines: 1,
1351 is_setext: false,
1352 });
1353 index.insert_file(PathBuf::from("docs/fr/guide.md"), file2);
1354
1355 let mut file3 = FileIndex::new();
1357 file3.add_heading(HeadingIndex {
1358 text: "Installation".to_string(),
1359 auto_anchor: "installation".to_string(),
1360 custom_anchor: Some("install".to_string()),
1361 line: 10,
1362 text_lines: 1,
1363 is_setext: false,
1364 });
1365 index.insert_file(PathBuf::from("docs/de/guide.md"), file3);
1366
1367 let vulnerable = index.get_vulnerable_anchors();
1368 assert_eq!(vulnerable.len(), 1); assert!(vulnerable.contains_key("installation"));
1370
1371 let anchors = vulnerable.get("installation").unwrap();
1372 assert_eq!(anchors.len(), 2, "Should collect both vulnerable anchors");
1374
1375 let files: std::collections::HashSet<_> = anchors.iter().map(|a| &a.file).collect();
1377 assert!(files.contains(&PathBuf::from("docs/en/guide.md")));
1378 assert!(files.contains(&PathBuf::from("docs/fr/guide.md")));
1379 }
1380
1381 #[test]
1382 fn test_file_index_hash() {
1383 let index = FileIndex::with_hash("hash123".to_string());
1384 assert!(index.hash_matches("hash123"));
1385 assert!(!index.hash_matches("other"));
1386 }
1387
1388 #[test]
1389 fn test_version_increment() {
1390 let mut index = WorkspaceIndex::new();
1391 assert_eq!(index.version(), 0);
1392
1393 index.insert_file(PathBuf::from("a.md"), FileIndex::new());
1394 assert_eq!(index.version(), 1);
1395
1396 index.insert_file(PathBuf::from("b.md"), FileIndex::new());
1397 assert_eq!(index.version(), 2);
1398
1399 index.remove_file(Path::new("a.md"));
1400 assert_eq!(index.version(), 3);
1401
1402 index.remove_file(Path::new("nonexistent.md"));
1404 assert_eq!(index.version(), 3);
1405 }
1406
1407 #[test]
1408 fn test_files_sorted_is_path_ordered() {
1409 let mut index = WorkspaceIndex::new();
1410 for name in ["docs/zebra.md", "docs/apple.md", "docs/mango.md"] {
1412 index.update_file(Path::new(name), FileIndex::new());
1413 }
1414
1415 let paths: Vec<&Path> = index.files_sorted().into_iter().map(|(p, _)| p).collect();
1416 assert_eq!(
1417 paths,
1418 vec![
1419 Path::new("docs/apple.md"),
1420 Path::new("docs/mango.md"),
1421 Path::new("docs/zebra.md"),
1422 ],
1423 "files_sorted() must return entries ordered by path"
1424 );
1425 }
1426
1427 #[test]
1432 fn test_add_cross_file_link_keeps_the_destination_as_written() {
1433 let as_written = CrossFileLinkIndex {
1434 target_path: "other.md?raw=true".to_string(),
1435 fragment: "missing".to_string(),
1436 line: 3,
1437 column: 1,
1438 origin: LinkOrigin::Body,
1439 };
1440 let file_named = CrossFileLinkIndex {
1441 target_path: "other.md".to_string(),
1442 fragment: "missing".to_string(),
1443 line: 3,
1444 column: 9,
1445 origin: LinkOrigin::Body,
1446 };
1447
1448 for (first, second) in [
1449 (as_written.clone(), file_named.clone()),
1450 (file_named.clone(), as_written.clone()),
1451 ] {
1452 let mut index = FileIndex::new();
1453 index.add_cross_file_link(first);
1454 index.add_cross_file_link(second);
1455
1456 assert_eq!(
1457 index.cross_file_links.len(),
1458 1,
1459 "one link is one entry, got: {:?}",
1460 index.cross_file_links
1461 );
1462 assert_eq!(index.cross_file_links[0].target_path, "other.md?raw=true");
1463 }
1464 }
1465
1466 #[test]
1469 fn test_add_cross_file_link_keeps_distinct_targets() {
1470 let mut index = FileIndex::new();
1471 for target in ["one.md", "two.md"] {
1472 index.add_cross_file_link(CrossFileLinkIndex {
1473 target_path: target.to_string(),
1474 fragment: "missing".to_string(),
1475 line: 3,
1476 column: 1,
1477 origin: LinkOrigin::Body,
1478 });
1479 }
1480 assert_eq!(index.cross_file_links.len(), 2);
1481 }
1482
1483 #[test]
1491 fn test_add_cross_file_link_collapses_one_line_asking_one_file_once() {
1492 let link = |target: &str, fragment: &str, line: usize| CrossFileLinkIndex {
1493 target_path: target.to_string(),
1494 fragment: fragment.to_string(),
1495 line,
1496 column: 1,
1497 origin: LinkOrigin::Body,
1498 };
1499
1500 let mut index = FileIndex::new();
1501 index.add_cross_file_link(link("target.md?raw=true", "missing", 3));
1502 index.add_cross_file_link(link("target.md?plain=1", "missing", 3));
1503 assert_eq!(
1504 index.cross_file_links.len(),
1505 1,
1506 "one file, one fragment, one line is one entry, got: {:?}",
1507 index.cross_file_links
1508 );
1509
1510 index.add_cross_file_link(link("target.md", "other", 3));
1511 index.add_cross_file_link(link("target.md", "missing", 4));
1512 assert_eq!(index.cross_file_links.len(), 3);
1513 }
1514
1515 #[test]
1521 fn test_reverse_deps_ignore_a_query_string_on_the_destination() {
1522 let mut index = WorkspaceIndex::new();
1523
1524 let mut file_a = FileIndex::new();
1525 file_a.add_cross_file_link(CrossFileLinkIndex {
1526 target_path: "b.md?raw=true".to_string(),
1527 fragment: "section".to_string(),
1528 line: 10,
1529 column: 5,
1530 origin: LinkOrigin::Body,
1531 });
1532 index.update_file(Path::new("docs/a.md"), file_a);
1533
1534 assert_eq!(
1535 index.get_dependents(Path::new("docs/b.md")),
1536 vec![PathBuf::from("docs/a.md")],
1537 "editing docs/b.md must re-lint the file linking to it"
1538 );
1539
1540 index.update_file(Path::new("docs/a.md"), FileIndex::new());
1543 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1544 }
1545
1546 #[test]
1547 fn test_reverse_deps_basic() {
1548 let mut index = WorkspaceIndex::new();
1549
1550 let mut file_a = FileIndex::new();
1552 file_a.add_cross_file_link(CrossFileLinkIndex {
1553 target_path: "b.md".to_string(),
1554 fragment: "section".to_string(),
1555 line: 10,
1556 column: 5,
1557 origin: LinkOrigin::Body,
1558 });
1559 index.update_file(Path::new("docs/a.md"), file_a);
1560
1561 let dependents = index.get_dependents(Path::new("docs/b.md"));
1563 assert_eq!(dependents.len(), 1);
1564 assert_eq!(dependents[0], PathBuf::from("docs/a.md"));
1565
1566 let a_dependents = index.get_dependents(Path::new("docs/a.md"));
1568 assert!(a_dependents.is_empty());
1569 }
1570
1571 #[test]
1572 fn test_reverse_deps_multiple() {
1573 let mut index = WorkspaceIndex::new();
1574
1575 let mut file_a = FileIndex::new();
1577 file_a.add_cross_file_link(CrossFileLinkIndex {
1578 target_path: "../b.md".to_string(),
1579 fragment: "".to_string(),
1580 line: 1,
1581 column: 1,
1582 origin: LinkOrigin::Body,
1583 });
1584 index.update_file(Path::new("docs/sub/a.md"), file_a);
1585
1586 let mut file_c = FileIndex::new();
1587 file_c.add_cross_file_link(CrossFileLinkIndex {
1588 target_path: "b.md".to_string(),
1589 fragment: "".to_string(),
1590 line: 1,
1591 column: 1,
1592 origin: LinkOrigin::Body,
1593 });
1594 index.update_file(Path::new("docs/c.md"), file_c);
1595
1596 let dependents = index.get_dependents(Path::new("docs/b.md"));
1598 assert_eq!(dependents.len(), 2);
1599 assert!(dependents.contains(&PathBuf::from("docs/sub/a.md")));
1600 assert!(dependents.contains(&PathBuf::from("docs/c.md")));
1601 }
1602
1603 #[test]
1604 fn test_reverse_deps_update_clears_old() {
1605 let mut index = WorkspaceIndex::new();
1606
1607 let mut file_a = FileIndex::new();
1609 file_a.add_cross_file_link(CrossFileLinkIndex {
1610 target_path: "b.md".to_string(),
1611 fragment: "".to_string(),
1612 line: 1,
1613 column: 1,
1614 origin: LinkOrigin::Body,
1615 });
1616 index.update_file(Path::new("docs/a.md"), file_a);
1617
1618 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1620
1621 let mut file_a_updated = FileIndex::new();
1623 file_a_updated.add_cross_file_link(CrossFileLinkIndex {
1624 target_path: "c.md".to_string(),
1625 fragment: "".to_string(),
1626 line: 1,
1627 column: 1,
1628 origin: LinkOrigin::Body,
1629 });
1630 index.update_file(Path::new("docs/a.md"), file_a_updated);
1631
1632 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1634
1635 let c_deps = index.get_dependents(Path::new("docs/c.md"));
1637 assert_eq!(c_deps.len(), 1);
1638 assert_eq!(c_deps[0], PathBuf::from("docs/a.md"));
1639 }
1640
1641 #[test]
1642 fn test_reverse_deps_remove_file() {
1643 let mut index = WorkspaceIndex::new();
1644
1645 let mut file_a = FileIndex::new();
1647 file_a.add_cross_file_link(CrossFileLinkIndex {
1648 target_path: "b.md".to_string(),
1649 fragment: "".to_string(),
1650 line: 1,
1651 column: 1,
1652 origin: LinkOrigin::Body,
1653 });
1654 index.update_file(Path::new("docs/a.md"), file_a);
1655
1656 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1658
1659 index.remove_file(Path::new("docs/a.md"));
1661
1662 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1664 }
1665
1666 #[test]
1667 fn test_normalize_path() {
1668 let path = Path::new("docs/sub/../other.md");
1670 let normalized = normalize_relative_path(path);
1671 assert_eq!(normalized, PathBuf::from("docs/other.md"));
1672
1673 let path2 = Path::new("docs/./other.md");
1675 let normalized2 = normalize_relative_path(path2);
1676 assert_eq!(normalized2, PathBuf::from("docs/other.md"));
1677
1678 let path3 = Path::new("a/b/c/../../d.md");
1680 let normalized3 = normalize_relative_path(path3);
1681 assert_eq!(normalized3, PathBuf::from("a/d.md"));
1682 }
1683
1684 #[test]
1689 fn normalize_keeps_a_traversal_that_leaves_its_own_root() {
1690 assert_eq!(
1691 normalize_relative_path(Path::new("../notes.md")),
1692 PathBuf::from("../notes.md")
1693 );
1694 assert_eq!(
1695 normalize_relative_path(Path::new("docs/../../notes.md")),
1696 PathBuf::from("../notes.md")
1697 );
1698 assert_eq!(
1699 normalize_relative_path(Path::new("../../a/./b/../notes.md")),
1700 PathBuf::from("../../a/notes.md")
1701 );
1702 }
1703
1704 #[test]
1707 fn normalize_stops_a_traversal_at_a_root() {
1708 let root = if cfg!(windows) { "C:\\" } else { "/" };
1709 assert_eq!(
1710 normalize_relative_path(&Path::new(root).join("..").join("notes.md")),
1711 Path::new(root).join("notes.md")
1712 );
1713 }
1714
1715 #[test]
1716 fn test_clear_clears_reverse_deps() {
1717 let mut index = WorkspaceIndex::new();
1718
1719 let mut file_a = FileIndex::new();
1721 file_a.add_cross_file_link(CrossFileLinkIndex {
1722 target_path: "b.md".to_string(),
1723 fragment: "".to_string(),
1724 line: 1,
1725 column: 1,
1726 origin: LinkOrigin::Body,
1727 });
1728 index.update_file(Path::new("docs/a.md"), file_a);
1729
1730 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1732
1733 index.clear();
1735
1736 assert_eq!(index.file_count(), 0);
1738 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1739 }
1740
1741 #[test]
1742 fn test_is_file_stale() {
1743 let mut index = WorkspaceIndex::new();
1744
1745 assert!(index.is_file_stale(Path::new("nonexistent.md"), "hash123"));
1747
1748 let file_index = FileIndex::with_hash("hash123".to_string());
1750 index.insert_file(PathBuf::from("docs/test.md"), file_index);
1751
1752 assert!(!index.is_file_stale(Path::new("docs/test.md"), "hash123"));
1754
1755 assert!(index.is_file_stale(Path::new("docs/test.md"), "different_hash"));
1757 }
1758
1759 #[cfg(feature = "native")]
1760 #[test]
1761 fn test_cache_roundtrip() {
1762 use std::fs;
1763
1764 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_roundtrip");
1766 let _ = fs::remove_dir_all(&temp_dir);
1767 fs::create_dir_all(&temp_dir).unwrap();
1768
1769 let mut index = WorkspaceIndex::new();
1771
1772 let mut file1 = FileIndex::with_hash("abc123".to_string());
1773 file1.add_heading(HeadingIndex {
1774 text: "Test Heading".to_string(),
1775 auto_anchor: "test-heading".to_string(),
1776 custom_anchor: Some("test".to_string()),
1777 line: 1,
1778 text_lines: 1,
1779 is_setext: false,
1780 });
1781 file1.add_cross_file_link(CrossFileLinkIndex {
1782 target_path: "./other.md".to_string(),
1783 fragment: "section".to_string(),
1784 line: 5,
1785 column: 3,
1786 origin: LinkOrigin::Body,
1787 });
1788 index.update_file(Path::new("docs/file1.md"), file1);
1789
1790 let mut file2 = FileIndex::with_hash("def456".to_string());
1791 file2.add_heading(HeadingIndex {
1792 text: "Another Heading".to_string(),
1793 auto_anchor: "another-heading".to_string(),
1794 custom_anchor: None,
1795 line: 1,
1796 text_lines: 1,
1797 is_setext: false,
1798 });
1799 index.update_file(Path::new("docs/other.md"), file2);
1800
1801 index.save_to_cache(&temp_dir).expect("Failed to save cache");
1803
1804 assert!(temp_dir.join("workspace_index.bin").exists());
1806
1807 let loaded = WorkspaceIndex::load_from_cache(&temp_dir).expect("Failed to load cache");
1809
1810 assert_eq!(loaded.file_count(), 2);
1812 assert!(loaded.contains_file(Path::new("docs/file1.md")));
1813 assert!(loaded.contains_file(Path::new("docs/other.md")));
1814
1815 let file1_loaded = loaded.get_file(Path::new("docs/file1.md")).unwrap();
1817 assert_eq!(file1_loaded.content_hash, "abc123");
1818 assert_eq!(file1_loaded.headings.len(), 1);
1819 assert_eq!(file1_loaded.headings[0].text, "Test Heading");
1820 assert_eq!(file1_loaded.headings[0].custom_anchor, Some("test".to_string()));
1821 assert_eq!(file1_loaded.cross_file_links.len(), 1);
1822 assert_eq!(file1_loaded.cross_file_links[0].target_path, "./other.md");
1823
1824 let dependents = loaded.get_dependents(Path::new("docs/other.md"));
1826 assert_eq!(dependents.len(), 1);
1827 assert_eq!(dependents[0], PathBuf::from("docs/file1.md"));
1828
1829 let _ = fs::remove_dir_all(&temp_dir);
1831 }
1832
1833 #[cfg(feature = "native")]
1834 #[test]
1835 fn test_cache_missing_file() {
1836 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_missing");
1837 let _ = std::fs::remove_dir_all(&temp_dir);
1838
1839 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1841 assert!(result.is_none());
1842 }
1843
1844 #[cfg(feature = "native")]
1845 #[test]
1846 fn test_cache_corrupted_file() {
1847 use std::fs;
1848
1849 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_corrupted");
1850 let _ = fs::remove_dir_all(&temp_dir);
1851 fs::create_dir_all(&temp_dir).unwrap();
1852
1853 fs::write(temp_dir.join("workspace_index.bin"), b"bad").unwrap();
1855
1856 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1858 assert!(result.is_none());
1859
1860 assert!(!temp_dir.join("workspace_index.bin").exists());
1862
1863 let _ = fs::remove_dir_all(&temp_dir);
1865 }
1866
1867 #[cfg(feature = "native")]
1868 #[test]
1869 fn test_cache_invalid_magic() {
1870 use std::fs;
1871
1872 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_invalid_magic");
1873 let _ = fs::remove_dir_all(&temp_dir);
1874 fs::create_dir_all(&temp_dir).unwrap();
1875
1876 let mut data = Vec::new();
1878 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();
1882
1883 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1885 assert!(result.is_none());
1886
1887 assert!(!temp_dir.join("workspace_index.bin").exists());
1889
1890 let _ = fs::remove_dir_all(&temp_dir);
1892 }
1893
1894 #[cfg(feature = "native")]
1895 #[test]
1896 fn test_cache_version_mismatch() {
1897 use std::fs;
1898
1899 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_version_mismatch");
1900 let _ = fs::remove_dir_all(&temp_dir);
1901 fs::create_dir_all(&temp_dir).unwrap();
1902
1903 let mut data = Vec::new();
1905 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();
1909
1910 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1912 assert!(result.is_none());
1913
1914 assert!(!temp_dir.join("workspace_index.bin").exists());
1916
1917 let _ = fs::remove_dir_all(&temp_dir);
1919 }
1920
1921 #[cfg(feature = "native")]
1922 #[test]
1923 fn test_cache_atomic_write() {
1924 use std::fs;
1925
1926 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_atomic");
1928 let _ = fs::remove_dir_all(&temp_dir);
1929 fs::create_dir_all(&temp_dir).unwrap();
1930
1931 let index = WorkspaceIndex::new();
1932 index.save_to_cache(&temp_dir).expect("Failed to save");
1933
1934 let entries: Vec<_> = fs::read_dir(&temp_dir).unwrap().collect();
1936 assert_eq!(entries.len(), 1);
1937 assert!(temp_dir.join("workspace_index.bin").exists());
1938
1939 let _ = fs::remove_dir_all(&temp_dir);
1941 }
1942
1943 #[test]
1944 fn test_has_anchor_auto_generated() {
1945 let mut file_index = FileIndex::new();
1946 file_index.add_heading(HeadingIndex {
1947 text: "Installation Guide".to_string(),
1948 auto_anchor: "installation-guide".to_string(),
1949 custom_anchor: None,
1950 line: 1,
1951 text_lines: 1,
1952 is_setext: false,
1953 });
1954
1955 assert!(file_index.has_anchor("installation-guide"));
1957
1958 assert!(file_index.has_anchor("Installation-Guide"));
1960 assert!(file_index.has_anchor("INSTALLATION-GUIDE"));
1961
1962 assert!(!file_index.has_anchor("nonexistent"));
1964 }
1965
1966 #[test]
1967 fn test_has_anchor_custom() {
1968 let mut file_index = FileIndex::new();
1969 file_index.add_heading(HeadingIndex {
1970 text: "Installation Guide".to_string(),
1971 auto_anchor: "installation-guide".to_string(),
1972 custom_anchor: Some("install".to_string()),
1973 line: 1,
1974 text_lines: 1,
1975 is_setext: false,
1976 });
1977
1978 assert!(file_index.has_anchor("installation-guide"));
1980
1981 assert!(file_index.has_anchor("install"));
1983 assert!(file_index.has_anchor("Install")); assert!(!file_index.has_anchor("nonexistent"));
1987 }
1988
1989 #[test]
1990 fn test_get_heading_by_anchor() {
1991 let mut file_index = FileIndex::new();
1992 file_index.add_heading(HeadingIndex {
1993 text: "Installation Guide".to_string(),
1994 auto_anchor: "installation-guide".to_string(),
1995 custom_anchor: Some("install".to_string()),
1996 line: 10,
1997 text_lines: 1,
1998 is_setext: false,
1999 });
2000 file_index.add_heading(HeadingIndex {
2001 text: "Configuration".to_string(),
2002 auto_anchor: "configuration".to_string(),
2003 custom_anchor: None,
2004 line: 20,
2005 text_lines: 1,
2006 is_setext: false,
2007 });
2008
2009 let heading = file_index.get_heading_by_anchor("installation-guide");
2011 assert!(heading.is_some());
2012 assert_eq!(heading.unwrap().text, "Installation Guide");
2013 assert_eq!(heading.unwrap().line, 10);
2014
2015 let heading = file_index.get_heading_by_anchor("install");
2017 assert!(heading.is_some());
2018 assert_eq!(heading.unwrap().text, "Installation Guide");
2019
2020 let heading = file_index.get_heading_by_anchor("configuration");
2022 assert!(heading.is_some());
2023 assert_eq!(heading.unwrap().text, "Configuration");
2024 assert_eq!(heading.unwrap().line, 20);
2025
2026 assert!(file_index.get_heading_by_anchor("nonexistent").is_none());
2028 }
2029
2030 #[test]
2031 fn test_anchor_lookup_many_headings() {
2032 let mut file_index = FileIndex::new();
2034
2035 for i in 0..100 {
2037 file_index.add_heading(HeadingIndex {
2038 text: format!("Heading {i}"),
2039 auto_anchor: format!("heading-{i}"),
2040 custom_anchor: Some(format!("h{i}")),
2041 line: i + 1,
2042 text_lines: 1,
2043 is_setext: false,
2044 });
2045 }
2046
2047 for i in 0..100 {
2049 assert!(file_index.has_anchor(&format!("heading-{i}")));
2050 assert!(file_index.has_anchor(&format!("h{i}")));
2051
2052 let heading = file_index.get_heading_by_anchor(&format!("heading-{i}"));
2053 assert!(heading.is_some());
2054 assert_eq!(heading.unwrap().line, i + 1);
2055 }
2056 }
2057
2058 #[test]
2063 fn test_extract_cross_file_links_basic() {
2064 use crate::config::MarkdownFlavor;
2065
2066 let content = "# Test\n\nSee [link](./other.md) for info.\n";
2067 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2068 let links = extract_cross_file_links(&ctx).relative;
2069
2070 assert_eq!(links.len(), 1);
2071 assert_eq!(links[0].target_path, "./other.md");
2072 assert_eq!(links[0].fragment, "");
2073 assert_eq!(links[0].line, 3);
2074 assert_eq!(links[0].column, 12);
2076 }
2077
2078 #[test]
2079 fn test_extract_cross_file_links_with_fragment() {
2080 use crate::config::MarkdownFlavor;
2081
2082 let content = "Check [guide](./guide.md#install) here.\n";
2083 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2084 let links = extract_cross_file_links(&ctx).relative;
2085
2086 assert_eq!(links.len(), 1);
2087 assert_eq!(links[0].target_path, "./guide.md");
2088 assert_eq!(links[0].fragment, "install");
2089 assert_eq!(links[0].line, 1);
2090 assert_eq!(links[0].column, 15);
2092 }
2093
2094 #[test]
2095 fn test_extract_cross_file_links_multiple_on_same_line() {
2096 use crate::config::MarkdownFlavor;
2097
2098 let content = "See [a](a.md) and [b](b.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(), 2);
2103
2104 assert_eq!(links[0].target_path, "a.md");
2105 assert_eq!(links[0].line, 1);
2106 assert_eq!(links[0].column, 9);
2108
2109 assert_eq!(links[1].target_path, "b.md");
2110 assert_eq!(links[1].line, 1);
2111 assert_eq!(links[1].column, 23);
2113 }
2114
2115 #[test]
2116 fn test_extract_cross_file_links_angle_brackets() {
2117 use crate::config::MarkdownFlavor;
2118
2119 let content = "See [link](<path/with (parens).md>) here.\n";
2120 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2121 let links = extract_cross_file_links(&ctx).relative;
2122
2123 assert_eq!(links.len(), 1);
2124 assert_eq!(links[0].target_path, "path/with (parens).md");
2125 assert_eq!(links[0].line, 1);
2126 assert_eq!(links[0].column, 13);
2128 }
2129
2130 #[test]
2131 fn test_extract_cross_file_links_bare_parens_in_destination() {
2132 use crate::config::MarkdownFlavor;
2133
2134 let content = "See [link](docs/file(inner).md) and [plain](docs/other.md).\n";
2138 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2139 let links = extract_cross_file_links(&ctx).relative;
2140
2141 let targets: Vec<&str> = links.iter().map(|l| l.target_path.as_str()).collect();
2142 assert_eq!(targets, vec!["docs/file(inner).md", "docs/other.md"]);
2143 assert_eq!(links[0].column, 12);
2145 }
2146
2147 #[test]
2148 fn test_extract_cross_file_links_skips_external() {
2149 use crate::config::MarkdownFlavor;
2150
2151 let content = r#"
2152[external](https://example.com)
2153[mailto](mailto:test@example.com)
2154[local](./local.md)
2155[fragment](#section)
2156[absolute](/docs/page.md)
2157"#;
2158 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2159 let extracted = extract_cross_file_links(&ctx);
2160
2161 assert_eq!(extracted.relative.len(), 1);
2163 assert_eq!(extracted.relative[0].target_path, "./local.md");
2164 assert_eq!(extracted.root_relative.len(), 1);
2166 assert_eq!(extracted.root_relative[0].target_path, "docs/page.md");
2167 }
2168
2169 #[test]
2170 fn test_extract_cross_file_links_root_relative() {
2171 use crate::config::MarkdownFlavor;
2172
2173 let content = "[a](/guide.md#install)\n[b](/../escape.md)\n[c](//host/x.md)\n[d](/img/pic.png)\n";
2177 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2178 let extracted = extract_cross_file_links(&ctx);
2179
2180 assert!(extracted.relative.is_empty(), "no directory-relative links here");
2181 assert_eq!(
2182 extracted
2183 .root_relative
2184 .iter()
2185 .map(|l| (l.target_path.as_str(), l.fragment.as_str()))
2186 .collect::<Vec<_>>(),
2187 vec![("guide.md", "install")],
2188 "only the safe root-relative markdown link is captured"
2189 );
2190 }
2191
2192 #[test]
2193 fn test_extract_cross_file_links_skips_non_markdown() {
2194 use crate::config::MarkdownFlavor;
2195
2196 let content = r#"
2197[image](./photo.png)
2198[doc](./readme.md)
2199[pdf](./document.pdf)
2200"#;
2201 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2202 let links = extract_cross_file_links(&ctx).relative;
2203
2204 assert_eq!(links.len(), 1);
2206 assert_eq!(links[0].target_path, "./readme.md");
2207 }
2208
2209 #[test]
2210 fn test_extract_cross_file_links_skips_code_spans() {
2211 use crate::config::MarkdownFlavor;
2212
2213 let content = "Normal [link](./file.md) and `[code](./ignored.md)` here.\n";
2214 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2215 let links = extract_cross_file_links(&ctx).relative;
2216
2217 assert_eq!(links.len(), 1);
2219 assert_eq!(links[0].target_path, "./file.md");
2220 }
2221
2222 #[test]
2223 fn test_extract_cross_file_links_with_query_params() {
2224 use crate::config::MarkdownFlavor;
2225
2226 let content = "See [doc](./file.md?raw=true) here.\n";
2227 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2228 let links = extract_cross_file_links(&ctx).relative;
2229
2230 assert_eq!(links.len(), 1);
2231 assert_eq!(links[0].target_path, "./file.md");
2233 }
2234
2235 #[test]
2236 fn test_extract_cross_file_links_empty_content() {
2237 use crate::config::MarkdownFlavor;
2238
2239 let content = "";
2240 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2241 let links = extract_cross_file_links(&ctx).relative;
2242
2243 assert!(links.is_empty());
2244 }
2245
2246 #[test]
2247 fn test_extract_cross_file_links_no_links() {
2248 use crate::config::MarkdownFlavor;
2249
2250 let content = "# Just a heading\n\nSome text without links.\n";
2251 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2252 let links = extract_cross_file_links(&ctx).relative;
2253
2254 assert!(links.is_empty());
2255 }
2256
2257 #[test]
2258 fn test_extract_cross_file_links_position_accuracy_issue_234() {
2259 use crate::config::MarkdownFlavor;
2262
2263 let content = r#"# Test Document
2264
2265Here is a [broken link](nonexistent-file.md) that should trigger MD057.
2266
2267And another [link](also-missing.md) on this line.
2268"#;
2269 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2270 let links = extract_cross_file_links(&ctx).relative;
2271
2272 assert_eq!(links.len(), 2);
2273
2274 assert_eq!(links[0].target_path, "nonexistent-file.md");
2276 assert_eq!(links[0].line, 3);
2277 assert_eq!(links[0].column, 25);
2278
2279 assert_eq!(links[1].target_path, "also-missing.md");
2281 assert_eq!(links[1].line, 5);
2282 assert_eq!(links[1].column, 20);
2283 }
2284}