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, SourcePositions};
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
44pub(crate) fn url_decode(s: &str) -> String {
51 if !s.contains('%') {
53 return s.to_string();
54 }
55
56 let bytes = s.as_bytes();
57 let mut result = Vec::with_capacity(bytes.len());
58 let mut i = 0;
59
60 while i < bytes.len() {
61 if bytes[i] == b'%' && i + 2 < bytes.len() {
62 let hex1 = bytes[i + 1];
64 let hex2 = bytes[i + 2];
65 if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
66 result.push(d1 * 16 + d2);
67 i += 3;
68 continue;
69 }
70 }
71 result.push(bytes[i]);
72 i += 1;
73 }
74
75 String::from_utf8(result).unwrap_or_else(|_| s.to_string())
77}
78
79#[cfg(feature = "native")]
87pub(crate) fn url_encode_path(path: &str) -> std::borrow::Cow<'_, str> {
88 let needs_encoding =
89 |c: char| c.is_whitespace() || c.is_control() || matches!(c, '%' | '(' | ')' | '<' | '>' | '?' | '#' | '\\');
90 if !path.contains(needs_encoding) {
91 return std::borrow::Cow::Borrowed(path);
92 }
93 let mut encoded = String::with_capacity(path.len() + 8);
94 for c in path.chars() {
95 if needs_encoding(c) {
96 let mut bytes = [0; 4];
97 for byte in c.encode_utf8(&mut bytes).bytes() {
98 encoded.push_str(&format!("%{byte:02X}"));
99 }
100 } else {
101 encoded.push(c);
102 }
103 }
104 std::borrow::Cow::Owned(encoded)
105}
106
107static LINK_START_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!?\[[^\]]*\]").unwrap());
117
118pub(crate) static URL_EXTRACT_ANGLE_BRACKET_REGEX: LazyLock<Regex> =
127 LazyLock::new(|| Regex::new(r#"\]\(\s*<([^>]+)>(#[^\)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
128
129pub(crate) static URL_EXTRACT_REGEX: LazyLock<Regex> =
137 LazyLock::new(|| Regex::new(r#"]\(\s*((?:[^()>\s#]|\([^()]*\))+)(#[^)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
138
139pub(crate) static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
141 LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
142
143#[inline]
145fn is_markdown_file(path: &str) -> bool {
146 crate::discovery::has_markdown_extension(std::path::Path::new(path))
147}
148
149pub(crate) fn strip_query_and_fragment(url: &str) -> &str {
152 let query_pos = url.find('?');
153 let fragment_pos = url.find('#');
154
155 match (query_pos, fragment_pos) {
156 (Some(q), Some(f)) => &url[..q.min(f)],
157 (Some(q), None) => &url[..q],
158 (None, Some(f)) => &url[..f],
159 (None, None) => url,
160 }
161}
162
163pub fn link_path_part(url: &str) -> String {
171 url_decode(strip_query_and_fragment(url))
172}
173
174pub fn link_target_file(source_dir: &Path, target_path: &str) -> PathBuf {
185 normalize_relative_path(&resolve_target_against(source_dir, target_path))
186}
187
188fn resolve_target_against(source_dir: &Path, target_path: &str) -> PathBuf {
200 let raw = strip_query_and_fragment(target_path);
201 match raw.strip_prefix('/') {
202 Some(from_root) => resolve_against_project_root(source_dir, &url_decode(from_root)),
203 None => source_dir.join(url_decode(raw)),
204 }
205}
206
207fn resolve_against_project_root(source_dir: &Path, from_root: &str) -> PathBuf {
221 let absolute = crate::utils::project_root::project_root().join(from_root);
222 if source_dir.is_absolute() {
223 return absolute;
224 }
225 match crate::utils::project_root::working_directory().and_then(|cwd| absolute.strip_prefix(cwd).ok()) {
226 Some(relative) => relative.to_path_buf(),
227 None => absolute,
228 }
229}
230
231#[derive(Debug, Default)]
238pub struct ExtractedCrossFileLinks {
239 pub relative: Vec<CrossFileLinkIndex>,
241 pub root_relative: Vec<CrossFileLinkIndex>,
245}
246
247pub fn extract_cross_file_links(ctx: &LintContext) -> ExtractedCrossFileLinks {
255 let content = ctx.content;
256
257 if content.is_empty() || !content.contains("](") {
259 return ExtractedCrossFileLinks::default();
260 }
261
262 let mut links = ExtractedCrossFileLinks::default();
263 let mut processed_lines = HashSet::new();
266 let mut positions = SourcePositions::new(content, &ctx.lines);
267
268 for link in ctx.links() {
269 let line_idx = link.line - 1;
270 if line_idx >= ctx.lines.len() {
271 continue;
272 }
273
274 if ctx.line_info(link.line).is_some_and(|info| info.in_front_matter) {
278 continue;
279 }
280
281 if !processed_lines.insert(line_idx) {
283 continue;
284 }
285
286 let line_info = &ctx.lines[line_idx];
287 let line = line_info.content(content);
288 if !line.contains("](") {
289 continue;
290 }
291
292 let Some(mut destinations) = ctx.index_link_destinations(link.line) else {
293 continue;
294 };
295
296 let mut first_column = true;
299 let mut column_at = |offset| {
300 if std::mem::replace(&mut first_column, false) {
301 byte_to_char_count(line, offset - line_info.byte_offset)
302 } else {
303 positions.column_on_line(line_idx, offset) + 1
304 }
305 };
306
307 for link_match in LINK_START_REGEX.find_iter(line) {
309 let start_pos = link_match.start();
310 let end_pos = link_match.end();
311
312 let line_start_byte = ctx.line_start_byte(line_idx + 1).unwrap_or(0);
314 let absolute_start_pos = line_start_byte + start_pos;
315
316 if ctx.is_in_code_span_byte(absolute_start_pos) {
318 continue;
319 }
320
321 if let Some(destination) = destinations.at(end_pos - 1) {
324 let file_path = content[destination.url.clone()].trim();
325 let fragment = destination.fragment(content).trim_start_matches('#');
326
327 if let Some(rel) = file_path.strip_prefix('/') {
332 if !rel.starts_with('/')
333 && !Path::new(rel)
334 .components()
335 .any(|c| matches!(c, std::path::Component::ParentDir))
336 {
337 let stripped = strip_query_and_fragment(rel);
338 if is_markdown_file(&url_decode(stripped)) {
339 links.root_relative.push(CrossFileLinkIndex {
340 target_path: stripped.to_string(),
341 fragment: fragment.to_string(),
342 line: link.line,
343 column: column_at(destination.url.start),
344 origin: LinkOrigin::Body,
345 });
346 }
347 }
348 continue;
349 }
350
351 if file_path.is_empty()
354 || PROTOCOL_DOMAIN_REGEX.is_match(file_path)
355 || file_path.starts_with("www.")
356 || file_path.starts_with('#')
357 || file_path.starts_with("{{")
358 || file_path.starts_with("{%")
359 || file_path.starts_with('~')
360 || file_path.starts_with('@')
361 || (file_path.starts_with('`') && file_path.ends_with('`'))
362 {
363 continue;
364 }
365
366 let file_path = strip_query_and_fragment(file_path);
368
369 if is_markdown_file(&url_decode(file_path)) {
373 links.relative.push(CrossFileLinkIndex {
374 target_path: file_path.to_string(),
375 fragment: fragment.to_string(),
376 line: link.line,
377 column: column_at(destination.url.start),
378 origin: LinkOrigin::Body,
379 });
380 }
381 }
382 }
383 }
384
385 links
386}
387
388#[cfg(feature = "postcard")]
390const CACHE_MAGIC: &[u8; 4] = b"RWSI";
391
392#[cfg(feature = "postcard")]
428const CACHE_FORMAT_VERSION: u32 = 15;
429
430#[cfg(feature = "postcard")]
432const CACHE_FILE_NAME: &str = "workspace_index.bin";
433
434#[cfg(feature = "postcard")]
438static CACHE_TEMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
439
440#[derive(Debug, Default, Clone, Serialize, Deserialize)]
445pub struct WorkspaceIndex {
446 files: HashMap<PathBuf, FileIndex>,
448 reverse_deps: HashMap<PathBuf, HashSet<PathBuf>>,
451 version: u64,
453 #[serde(skip)]
458 link_target_policy: Option<crate::lint_context::LinkTargetPolicy>,
459}
460
461#[derive(Debug, Clone, Default, Serialize, Deserialize)]
463pub struct FileIndex {
464 pub headings: Vec<HeadingIndex>,
466 pub reference_links: Vec<ReferenceLinkIndex>,
468 pub cross_file_links: Vec<CrossFileLinkIndex>,
470 #[serde(default)]
475 pub root_relative_links: Vec<CrossFileLinkIndex>,
476 #[serde(default)]
481 pub md057_link_targets: Vec<Md057LinkTarget>,
482 pub defined_references: HashSet<String>,
485 pub content_hash: String,
487 anchor_to_heading: HashMap<String, usize>,
490 #[serde(default)]
494 anchor_to_heading_exact: HashMap<String, usize>,
495 html_anchors: HashSet<String>,
498 #[serde(default)]
501 html_anchors_exact: HashSet<String>,
502 attribute_anchors: HashSet<String>,
506 #[serde(default)]
509 attribute_anchors_exact: HashSet<String>,
510 pub file_disabled_rules: HashSet<String>,
513 pub persistent_transitions: Vec<(usize, HashSet<String>, HashSet<String>)>,
516 pub line_disabled_rules: HashMap<usize, HashSet<String>>,
518}
519
520#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
522pub struct HeadingIndex {
523 pub text: String,
525 pub auto_anchor: String,
527 pub custom_anchor: Option<String>,
529 pub line: usize,
531 pub text_lines: usize,
535 #[serde(default)]
537 pub is_setext: bool,
538}
539
540impl HeadingIndex {
541 #[must_use]
543 pub fn first_line(&self) -> usize {
544 self.line + 1 - self.text_lines.max(1)
545 }
546
547 #[must_use]
549 pub fn covers_line(&self, line: usize) -> bool {
550 (self.first_line()..=self.line).contains(&line)
551 }
552}
553
554#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
556pub struct ReferenceLinkIndex {
557 pub reference_id: String,
559 pub line: usize,
561 pub column: usize,
563}
564
565#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
573pub enum LinkOrigin {
574 Body,
576 FrontMatter { field: Option<String> },
585}
586
587#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
589pub struct CrossFileLinkIndex {
590 pub target_path: String,
592 pub fragment: String,
594 pub line: usize,
596 pub column: usize,
598 pub origin: LinkOrigin,
600}
601
602#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
604pub struct Md057LinkTarget {
605 pub target: String,
607 pub origin: LinkOrigin,
609}
610
611pub fn link_target_candidates(source_file: &Path, target_path: &str) -> Vec<PathBuf> {
624 let joined = resolve_target_against(source_file.parent().unwrap_or(Path::new("")), target_path);
625 let base = normalize_relative_path(&joined);
626
627 if base.extension().is_some() {
628 return vec![base];
629 }
630
631 let mut candidates = Vec::with_capacity(crate::discovery::MARKDOWN_EXTENSIONS.len() + 1);
634 for ext in crate::discovery::MARKDOWN_EXTENSIONS {
635 candidates.push(base.with_extension(ext));
636 }
637 candidates.insert(0, base);
638 candidates
639}
640
641static WORKING_DIRECTORY_SPELLINGS: LazyLock<Vec<PathBuf>> = LazyLock::new(|| {
648 let mut spellings = Vec::new();
649 if let Ok(cwd) = std::env::current_dir() {
650 let cwd = normalize_relative_path(&cwd);
651 for spelling in [
652 cwd.canonicalize().ok(),
653 crate::discovery::canonicalize_for_matching(&cwd),
654 ]
655 .into_iter()
656 .flatten()
657 {
658 if spelling != cwd && !spellings.contains(&spelling) {
659 spellings.push(spelling);
660 }
661 }
662 spellings.push(cwd);
663 }
664 spellings
665});
666
667pub fn workspace_key(path: &Path) -> PathBuf {
679 let normalized = normalize_relative_path(path);
680 if normalized.is_absolute() {
681 let inside_working_directory = |absolute: &Path| {
682 WORKING_DIRECTORY_SPELLINGS
683 .iter()
684 .find_map(|cwd| absolute.strip_prefix(cwd).ok().map(Path::to_path_buf))
685 };
686 return inside_working_directory(&normalized)
687 .or_else(|| inside_working_directory(&crate::discovery::resolve_for_matching(&normalized)))
688 .unwrap_or(normalized);
689 }
690 if normalized.starts_with("..")
691 && let Some(cwd) = WORKING_DIRECTORY_SPELLINGS.last()
692 {
693 return workspace_key(&cwd.join(&normalized));
694 }
695 normalized
696}
697
698pub fn absolute_selection_path(path: &Path) -> std::io::Result<PathBuf> {
701 let absolute = std::path::absolute(path)?;
702 let mut selected = PathBuf::new();
703 for component in absolute.components() {
704 if component == std::path::Component::ParentDir {
705 selected = std::fs::canonicalize(&selected)?;
706 selected.pop();
707 } else {
708 selected.push(component.as_os_str());
709 }
710 }
711 Ok(selected)
712}
713
714pub fn normalize_relative_path(path: &Path) -> PathBuf {
725 let mut components: Vec<std::path::Component<'_>> = Vec::new();
726 for component in path.components() {
727 match component {
728 std::path::Component::CurDir => {}
729 std::path::Component::ParentDir => match components.last() {
730 Some(std::path::Component::Normal(_)) => {
731 components.pop();
732 }
733 Some(std::path::Component::RootDir) => {}
734 _ => components.push(component),
735 },
736 c => components.push(c),
737 }
738 }
739 components.iter().collect()
740}
741
742impl CrossFileLinkIndex {
743 pub fn is_navigable(&self) -> bool {
751 matches!(self.origin, LinkOrigin::Body)
752 }
753}
754
755#[derive(Debug, Clone, Serialize, Deserialize)]
757pub struct VulnerableAnchor {
758 pub file: PathBuf,
760 pub line: usize,
762 pub text: String,
764}
765
766impl WorkspaceIndex {
767 pub fn new() -> Self {
769 Self::default()
770 }
771
772 pub fn version(&self) -> u64 {
774 self.version
775 }
776
777 pub fn file_count(&self) -> usize {
779 self.files.len()
780 }
781
782 pub fn contains_file(&self, path: &Path) -> bool {
784 self.files.contains_key(path)
785 }
786
787 pub fn get_file(&self, path: &Path) -> Option<&FileIndex> {
789 self.files.get(path)
790 }
791
792 pub fn get_resolved_file(&self, target: &Path) -> Option<&FileIndex> {
802 let normalized = normalize_relative_path(target);
803 if let Some(index) = self.files.get(&normalized) {
804 return Some(index);
805 }
806 if let Some(index) = self.files.get(&workspace_key(&normalized)) {
807 return Some(index);
808 }
809 let canonical = normalized.canonicalize().ok()?;
812 let matching = crate::discovery::canonicalize_for_matching(&normalized);
813 [Some(canonical), matching]
814 .into_iter()
815 .flatten()
816 .find_map(|path| self.files.get(&path).or_else(|| self.files.get(&workspace_key(&path))))
817 }
818
819 pub fn link_target_policy(&self) -> Option<&crate::lint_context::LinkTargetPolicy> {
821 self.link_target_policy.as_ref()
822 }
823
824 pub fn set_link_target_policy(&mut self, policy: crate::lint_context::LinkTargetPolicy) {
826 self.link_target_policy = Some(policy);
827 }
828
829 pub fn insert_file(&mut self, path: PathBuf, index: FileIndex) {
831 self.files.insert(path, index);
832 self.version = self.version.wrapping_add(1);
833 }
834
835 pub fn remove_file(&mut self, path: &Path) -> Option<FileIndex> {
837 self.clear_reverse_deps_for(path);
839
840 let result = self.files.remove(path);
841 if result.is_some() {
842 self.version = self.version.wrapping_add(1);
843 }
844 result
845 }
846
847 pub fn get_vulnerable_anchors(&self) -> HashMap<String, Vec<VulnerableAnchor>> {
857 let mut vulnerable: HashMap<String, Vec<VulnerableAnchor>> = HashMap::new();
858
859 for (file_path, file_index) in &self.files {
860 for heading in &file_index.headings {
861 if heading.custom_anchor.is_none() && !heading.auto_anchor.is_empty() {
863 let anchor_key = heading.auto_anchor.to_lowercase();
864 vulnerable.entry(anchor_key).or_default().push(VulnerableAnchor {
865 file: file_path.clone(),
866 line: heading.line,
867 text: heading.text.clone(),
868 });
869 }
870 }
871 }
872
873 vulnerable
874 }
875
876 pub fn all_headings(&self) -> impl Iterator<Item = (&Path, &HeadingIndex)> {
878 self.files
879 .iter()
880 .flat_map(|(path, index)| index.headings.iter().map(move |h| (path.as_path(), h)))
881 }
882
883 pub fn files(&self) -> impl Iterator<Item = (&Path, &FileIndex)> {
885 self.files.iter().map(|(p, i)| (p.as_path(), i))
886 }
887
888 pub fn files_sorted(&self) -> Vec<(&Path, &FileIndex)> {
894 let mut entries: Vec<(&Path, &FileIndex)> = self.files.iter().map(|(p, i)| (p.as_path(), i)).collect();
895 entries.sort_by_key(|(a, _)| *a);
896 entries
897 }
898
899 pub fn clear(&mut self) {
901 self.files.clear();
902 self.reverse_deps.clear();
903 self.version = self.version.wrapping_add(1);
904 }
905
906 pub fn update_file(&mut self, path: &Path, index: FileIndex) {
913 self.clear_reverse_deps_as_source(path);
916
917 for link in &index.cross_file_links {
919 let target = self.resolve_target_path(path, &link.target_path);
920 self.reverse_deps.entry(target).or_default().insert(path.to_path_buf());
921 }
922
923 self.files.insert(path.to_path_buf(), index);
924 self.version = self.version.wrapping_add(1);
925 }
926
927 pub fn get_dependents(&self, path: &Path) -> Vec<PathBuf> {
932 self.reverse_deps
933 .get(path)
934 .map(|set| set.iter().cloned().collect())
935 .unwrap_or_default()
936 }
937
938 pub fn is_file_stale(&self, path: &Path, current_hash: &str) -> bool {
942 self.files.get(path).is_none_or(|f| f.content_hash != current_hash)
943 }
944
945 pub fn retain_only(&mut self, current_files: &std::collections::HashSet<PathBuf>) -> usize {
950 let before_count = self.files.len();
951
952 let to_remove: Vec<PathBuf> = self
954 .files
955 .keys()
956 .filter(|path| !current_files.contains(*path))
957 .cloned()
958 .collect();
959
960 for path in &to_remove {
962 self.remove_file(path);
963 }
964
965 before_count - self.files.len()
966 }
967
968 #[cfg(feature = "postcard")]
975 pub fn save_to_cache(&self, cache_dir: &Path) -> std::io::Result<()> {
976 use std::fs;
977 use std::io::Write;
978
979 fs::create_dir_all(cache_dir)?;
981
982 let encoded = postcard::to_allocvec(self)
984 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
985
986 let mut cache_data = Vec::with_capacity(8 + encoded.len());
988 cache_data.extend_from_slice(CACHE_MAGIC);
989 cache_data.extend_from_slice(&CACHE_FORMAT_VERSION.to_le_bytes());
990 cache_data.extend_from_slice(&encoded);
991
992 let final_path = cache_dir.join(CACHE_FILE_NAME);
997 let counter = CACHE_TEMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
998 #[cfg(not(target_arch = "wasm32"))]
999 let temp_path = cache_dir.join(format!("{CACHE_FILE_NAME}.tmp.{}.{counter}", std::process::id()));
1000 #[cfg(target_arch = "wasm32")]
1001 let temp_path = cache_dir.join(format!("{CACHE_FILE_NAME}.tmp.{counter}"));
1002
1003 {
1005 let mut file = fs::File::create(&temp_path)?;
1006 file.write_all(&cache_data)?;
1007 file.sync_all()?;
1008 }
1009
1010 fs::rename(&temp_path, &final_path)?;
1012
1013 log::debug!(
1014 "Saved workspace index to cache: {} files, {} bytes (format v{})",
1015 self.files.len(),
1016 cache_data.len(),
1017 CACHE_FORMAT_VERSION
1018 );
1019
1020 Ok(())
1021 }
1022
1023 #[cfg(feature = "postcard")]
1031 pub fn load_from_cache(cache_dir: &Path) -> Option<Self> {
1032 use std::fs;
1033
1034 let path = cache_dir.join(CACHE_FILE_NAME);
1035 let data = fs::read(&path).ok()?;
1036
1037 if data.len() < 8 {
1039 log::warn!("Workspace index cache too small, discarding");
1040 let _ = fs::remove_file(&path);
1041 return None;
1042 }
1043
1044 if &data[0..4] != CACHE_MAGIC {
1046 log::warn!("Workspace index cache has invalid magic header, discarding");
1047 let _ = fs::remove_file(&path);
1048 return None;
1049 }
1050
1051 let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
1053 if version != CACHE_FORMAT_VERSION {
1054 log::info!(
1055 "Workspace index cache format version mismatch (got {version}, expected {CACHE_FORMAT_VERSION}), rebuilding"
1056 );
1057 let _ = fs::remove_file(&path);
1058 return None;
1059 }
1060
1061 match postcard::from_bytes::<Self>(&data[8..]) {
1063 Ok(index) => {
1064 log::debug!(
1065 "Loaded workspace index from cache: {} files (format v{})",
1066 index.files.len(),
1067 version
1068 );
1069 Some(index)
1070 }
1071 Err(e) => {
1072 log::warn!("Failed to deserialize workspace index cache: {e}");
1073 let _ = fs::remove_file(&path);
1074 None
1075 }
1076 }
1077 }
1078
1079 fn clear_reverse_deps_as_source(&mut self, path: &Path) {
1084 let targets: Vec<PathBuf> = match self.files.get(path) {
1091 Some(index) => index
1092 .cross_file_links
1093 .iter()
1094 .map(|link| self.resolve_target_path(path, &link.target_path))
1095 .collect(),
1096 None => return,
1097 };
1098 for target in targets {
1099 if let Some(deps) = self.reverse_deps.get_mut(&target) {
1100 deps.remove(path);
1101 if deps.is_empty() {
1102 self.reverse_deps.remove(&target);
1103 }
1104 }
1105 }
1106 }
1107
1108 fn clear_reverse_deps_for(&mut self, path: &Path) {
1113 self.clear_reverse_deps_as_source(path);
1115
1116 self.reverse_deps.remove(path);
1118 }
1119
1120 fn resolve_target_path(&self, source_file: &Path, relative_target: &str) -> PathBuf {
1126 let source_dir = source_file.parent().unwrap_or(Path::new(""));
1127 link_target_file(source_dir, relative_target)
1128 }
1129}
1130
1131impl FileIndex {
1132 pub fn new() -> Self {
1134 Self::default()
1135 }
1136
1137 pub fn with_hash(content_hash: String) -> Self {
1139 Self {
1140 content_hash,
1141 ..Default::default()
1142 }
1143 }
1144
1145 pub fn extracted_data_differs(&self, other: &Self) -> bool {
1161 let Self {
1165 headings,
1166 reference_links,
1167 cross_file_links,
1168 root_relative_links,
1169 md057_link_targets,
1170 defined_references,
1171 content_hash: _,
1172 anchor_to_heading,
1173 anchor_to_heading_exact,
1174 html_anchors,
1175 html_anchors_exact,
1176 attribute_anchors,
1177 attribute_anchors_exact,
1178 file_disabled_rules,
1179 persistent_transitions,
1180 line_disabled_rules,
1181 } = self;
1182
1183 headings != &other.headings
1184 || reference_links != &other.reference_links
1185 || cross_file_links != &other.cross_file_links
1186 || root_relative_links != &other.root_relative_links
1187 || md057_link_targets != &other.md057_link_targets
1188 || defined_references != &other.defined_references
1189 || anchor_to_heading != &other.anchor_to_heading
1190 || anchor_to_heading_exact != &other.anchor_to_heading_exact
1191 || html_anchors != &other.html_anchors
1192 || html_anchors_exact != &other.html_anchors_exact
1193 || attribute_anchors != &other.attribute_anchors
1194 || attribute_anchors_exact != &other.attribute_anchors_exact
1195 || file_disabled_rules != &other.file_disabled_rules
1196 || persistent_transitions != &other.persistent_transitions
1197 || line_disabled_rules != &other.line_disabled_rules
1198 }
1199
1200 pub fn add_heading(&mut self, heading: HeadingIndex) {
1206 let index = self.headings.len();
1207
1208 self.anchor_to_heading.insert(heading.auto_anchor.to_lowercase(), index);
1211 self.anchor_to_heading_exact.insert(heading.auto_anchor.clone(), index);
1212
1213 if let Some(ref custom) = heading.custom_anchor {
1215 self.anchor_to_heading.insert(custom.to_lowercase(), index);
1216 self.anchor_to_heading_exact.insert(custom.clone(), index);
1217 }
1218
1219 self.headings.push(heading);
1220 }
1221
1222 pub fn add_anchor_alias(&mut self, anchor: &str, heading_index: usize) {
1225 if heading_index < self.headings.len() {
1226 self.anchor_to_heading.insert(anchor.to_lowercase(), heading_index);
1227 self.anchor_to_heading_exact.insert(anchor.to_string(), heading_index);
1228 }
1229 }
1230
1231 pub fn has_anchor(&self, anchor: &str) -> bool {
1242 self.has_anchor_with_case(anchor, true)
1243 }
1244
1245 pub fn has_anchor_with_case(&self, anchor: &str, ignore_case: bool) -> bool {
1254 if self.lookup_anchor(anchor, ignore_case) {
1255 return true;
1256 }
1257
1258 if anchor.contains('%') {
1260 let decoded = url_decode(anchor);
1261 if decoded != anchor {
1262 return self.lookup_anchor(&decoded, ignore_case);
1263 }
1264 }
1265
1266 false
1267 }
1268
1269 fn lookup_anchor(&self, anchor: &str, ignore_case: bool) -> bool {
1272 if ignore_case {
1273 let lower = anchor.to_lowercase();
1274 self.anchor_to_heading.contains_key(&lower)
1275 || self.html_anchors.contains(&lower)
1276 || self.attribute_anchors.contains(&lower)
1277 } else {
1278 self.anchor_to_heading_exact.contains_key(anchor)
1279 || self.html_anchors_exact.contains(anchor)
1280 || self.attribute_anchors_exact.contains(anchor)
1281 }
1282 }
1283
1284 pub fn add_html_anchor(&mut self, anchor: &str) {
1287 if !anchor.is_empty() {
1288 self.html_anchors.insert(anchor.to_lowercase());
1289 self.html_anchors_exact.insert(anchor.to_string());
1290 }
1291 }
1292
1293 pub fn add_attribute_anchor(&mut self, anchor: &str) {
1296 if !anchor.is_empty() {
1297 self.attribute_anchors.insert(anchor.to_lowercase());
1298 self.attribute_anchors_exact.insert(anchor.to_string());
1299 }
1300 }
1301
1302 pub fn get_heading_by_anchor(&self, anchor: &str) -> Option<&HeadingIndex> {
1306 self.anchor_to_heading
1307 .get(&anchor.to_lowercase())
1308 .and_then(|&idx| self.headings.get(idx))
1309 }
1310
1311 pub fn add_reference_link(&mut self, link: ReferenceLinkIndex) {
1313 self.reference_links.push(link);
1314 }
1315
1316 pub fn is_rule_disabled_at_line(&self, rule_name: &str, line: usize) -> bool {
1321 if self.file_disabled_rules.contains("*") || self.file_disabled_rules.contains(rule_name) {
1323 return true;
1324 }
1325
1326 if let Some(rules) = self.line_disabled_rules.get(&line)
1328 && (rules.contains("*") || rules.contains(rule_name))
1329 {
1330 return true;
1331 }
1332
1333 if !self.persistent_transitions.is_empty() {
1335 let idx = match self.persistent_transitions.binary_search_by_key(&line, |t| t.0) {
1336 Ok(i) => Some(i),
1337 Err(i) => {
1338 if i > 0 {
1339 Some(i - 1)
1340 } else {
1341 None
1342 }
1343 }
1344 };
1345 if let Some(i) = idx {
1346 let (_, ref disabled, ref enabled) = self.persistent_transitions[i];
1347 if disabled.contains("*") {
1348 return !enabled.contains(rule_name);
1349 }
1350 return disabled.contains(rule_name);
1351 }
1352 }
1353
1354 false
1355 }
1356
1357 pub fn add_cross_file_link(&mut self, link: CrossFileLinkIndex) {
1372 let existing = self.cross_file_links.iter_mut().find(|existing| {
1373 existing.fragment == link.fragment
1374 && existing.line == link.line
1375 && strip_query_and_fragment(&existing.target_path) == strip_query_and_fragment(&link.target_path)
1376 });
1377 match existing {
1378 Some(existing) => {
1381 if !existing.target_path.contains('?') && link.target_path.contains('?') {
1382 *existing = link;
1383 }
1384 }
1385 None => self.cross_file_links.push(link),
1386 }
1387 }
1388
1389 pub fn add_root_relative_link(&mut self, link: CrossFileLinkIndex) {
1391 let is_duplicate = self.root_relative_links.iter().any(|existing| {
1392 existing.target_path == link.target_path && existing.fragment == link.fragment && existing.line == link.line
1393 });
1394 if !is_duplicate {
1395 self.root_relative_links.push(link);
1396 }
1397 }
1398
1399 pub fn add_md057_link_target(&mut self, target: Md057LinkTarget) {
1401 self.md057_link_targets.push(target);
1402 }
1403
1404 pub fn add_defined_reference(&mut self, ref_id: String) {
1406 self.defined_references.insert(ref_id);
1407 }
1408
1409 pub fn has_defined_reference(&self, ref_id: &str) -> bool {
1411 self.defined_references.contains(ref_id)
1412 }
1413
1414 pub fn hash_matches(&self, hash: &str) -> bool {
1416 self.content_hash == hash
1417 }
1418
1419 pub fn heading_count(&self) -> usize {
1421 self.headings.len()
1422 }
1423
1424 pub fn reference_link_count(&self) -> usize {
1426 self.reference_links.len()
1427 }
1428}
1429
1430#[cfg(test)]
1431mod tests {
1432 use super::*;
1433
1434 #[test]
1435 fn test_workspace_index_basic() {
1436 let mut index = WorkspaceIndex::new();
1437 assert_eq!(index.file_count(), 0);
1438 assert_eq!(index.version(), 0);
1439
1440 let mut file_index = FileIndex::with_hash("abc123".to_string());
1441 file_index.add_heading(HeadingIndex {
1442 text: "Installation".to_string(),
1443 auto_anchor: "installation".to_string(),
1444 custom_anchor: None,
1445 line: 1,
1446 text_lines: 1,
1447 is_setext: false,
1448 });
1449
1450 index.insert_file(PathBuf::from("docs/install.md"), file_index);
1451 assert_eq!(index.file_count(), 1);
1452 assert_eq!(index.version(), 1);
1453
1454 assert!(index.contains_file(Path::new("docs/install.md")));
1455 assert!(!index.contains_file(Path::new("docs/other.md")));
1456 }
1457
1458 #[test]
1459 fn test_vulnerable_anchors() {
1460 let mut index = WorkspaceIndex::new();
1461
1462 let mut file1 = FileIndex::new();
1464 file1.add_heading(HeadingIndex {
1465 text: "Getting Started".to_string(),
1466 auto_anchor: "getting-started".to_string(),
1467 custom_anchor: None,
1468 line: 1,
1469 text_lines: 1,
1470 is_setext: false,
1471 });
1472 index.insert_file(PathBuf::from("docs/guide.md"), file1);
1473
1474 let mut file2 = FileIndex::new();
1476 file2.add_heading(HeadingIndex {
1477 text: "Installation".to_string(),
1478 auto_anchor: "installation".to_string(),
1479 custom_anchor: Some("install".to_string()),
1480 line: 1,
1481 text_lines: 1,
1482 is_setext: false,
1483 });
1484 index.insert_file(PathBuf::from("docs/install.md"), file2);
1485
1486 let vulnerable = index.get_vulnerable_anchors();
1487 assert_eq!(vulnerable.len(), 1);
1488 assert!(vulnerable.contains_key("getting-started"));
1489 assert!(!vulnerable.contains_key("installation"));
1490
1491 let anchors = vulnerable.get("getting-started").unwrap();
1492 assert_eq!(anchors.len(), 1);
1493 assert_eq!(anchors[0].file, PathBuf::from("docs/guide.md"));
1494 assert_eq!(anchors[0].text, "Getting Started");
1495 }
1496
1497 #[test]
1498 fn test_vulnerable_anchors_multiple_files_same_anchor() {
1499 let mut index = WorkspaceIndex::new();
1502
1503 let mut file1 = FileIndex::new();
1505 file1.add_heading(HeadingIndex {
1506 text: "Installation".to_string(),
1507 auto_anchor: "installation".to_string(),
1508 custom_anchor: None,
1509 line: 1,
1510 text_lines: 1,
1511 is_setext: false,
1512 });
1513 index.insert_file(PathBuf::from("docs/en/guide.md"), file1);
1514
1515 let mut file2 = FileIndex::new();
1517 file2.add_heading(HeadingIndex {
1518 text: "Installation".to_string(),
1519 auto_anchor: "installation".to_string(),
1520 custom_anchor: None,
1521 line: 5,
1522 text_lines: 1,
1523 is_setext: false,
1524 });
1525 index.insert_file(PathBuf::from("docs/fr/guide.md"), file2);
1526
1527 let mut file3 = FileIndex::new();
1529 file3.add_heading(HeadingIndex {
1530 text: "Installation".to_string(),
1531 auto_anchor: "installation".to_string(),
1532 custom_anchor: Some("install".to_string()),
1533 line: 10,
1534 text_lines: 1,
1535 is_setext: false,
1536 });
1537 index.insert_file(PathBuf::from("docs/de/guide.md"), file3);
1538
1539 let vulnerable = index.get_vulnerable_anchors();
1540 assert_eq!(vulnerable.len(), 1); assert!(vulnerable.contains_key("installation"));
1542
1543 let anchors = vulnerable.get("installation").unwrap();
1544 assert_eq!(anchors.len(), 2, "Should collect both vulnerable anchors");
1546
1547 let files: std::collections::HashSet<_> = anchors.iter().map(|a| &a.file).collect();
1549 assert!(files.contains(&PathBuf::from("docs/en/guide.md")));
1550 assert!(files.contains(&PathBuf::from("docs/fr/guide.md")));
1551 }
1552
1553 #[test]
1554 fn test_file_index_hash() {
1555 let index = FileIndex::with_hash("hash123".to_string());
1556 assert!(index.hash_matches("hash123"));
1557 assert!(!index.hash_matches("other"));
1558 }
1559
1560 #[test]
1561 fn test_version_increment() {
1562 let mut index = WorkspaceIndex::new();
1563 assert_eq!(index.version(), 0);
1564
1565 index.insert_file(PathBuf::from("a.md"), FileIndex::new());
1566 assert_eq!(index.version(), 1);
1567
1568 index.insert_file(PathBuf::from("b.md"), FileIndex::new());
1569 assert_eq!(index.version(), 2);
1570
1571 index.remove_file(Path::new("a.md"));
1572 assert_eq!(index.version(), 3);
1573
1574 index.remove_file(Path::new("nonexistent.md"));
1576 assert_eq!(index.version(), 3);
1577 }
1578
1579 #[test]
1580 fn test_files_sorted_is_path_ordered() {
1581 let mut index = WorkspaceIndex::new();
1582 for name in ["docs/zebra.md", "docs/apple.md", "docs/mango.md"] {
1584 index.update_file(Path::new(name), FileIndex::new());
1585 }
1586
1587 let paths: Vec<&Path> = index.files_sorted().into_iter().map(|(p, _)| p).collect();
1588 assert_eq!(
1589 paths,
1590 vec![
1591 Path::new("docs/apple.md"),
1592 Path::new("docs/mango.md"),
1593 Path::new("docs/zebra.md"),
1594 ],
1595 "files_sorted() must return entries ordered by path"
1596 );
1597 }
1598
1599 #[test]
1604 fn test_add_cross_file_link_keeps_the_destination_as_written() {
1605 let as_written = CrossFileLinkIndex {
1606 target_path: "other.md?raw=true".to_string(),
1607 fragment: "missing".to_string(),
1608 line: 3,
1609 column: 1,
1610 origin: LinkOrigin::Body,
1611 };
1612 let file_named = CrossFileLinkIndex {
1613 target_path: "other.md".to_string(),
1614 fragment: "missing".to_string(),
1615 line: 3,
1616 column: 9,
1617 origin: LinkOrigin::Body,
1618 };
1619
1620 for (first, second) in [
1621 (as_written.clone(), file_named.clone()),
1622 (file_named.clone(), as_written.clone()),
1623 ] {
1624 let mut index = FileIndex::new();
1625 index.add_cross_file_link(first);
1626 index.add_cross_file_link(second);
1627
1628 assert_eq!(
1629 index.cross_file_links.len(),
1630 1,
1631 "one link is one entry, got: {:?}",
1632 index.cross_file_links
1633 );
1634 assert_eq!(index.cross_file_links[0].target_path, "other.md?raw=true");
1635 }
1636 }
1637
1638 #[test]
1641 fn test_add_cross_file_link_keeps_distinct_targets() {
1642 let mut index = FileIndex::new();
1643 for target in ["one.md", "two.md"] {
1644 index.add_cross_file_link(CrossFileLinkIndex {
1645 target_path: target.to_string(),
1646 fragment: "missing".to_string(),
1647 line: 3,
1648 column: 1,
1649 origin: LinkOrigin::Body,
1650 });
1651 }
1652 assert_eq!(index.cross_file_links.len(), 2);
1653 }
1654
1655 #[test]
1663 fn test_add_cross_file_link_collapses_one_line_asking_one_file_once() {
1664 let link = |target: &str, fragment: &str, line: usize| CrossFileLinkIndex {
1665 target_path: target.to_string(),
1666 fragment: fragment.to_string(),
1667 line,
1668 column: 1,
1669 origin: LinkOrigin::Body,
1670 };
1671
1672 let mut index = FileIndex::new();
1673 index.add_cross_file_link(link("target.md?raw=true", "missing", 3));
1674 index.add_cross_file_link(link("target.md?plain=1", "missing", 3));
1675 assert_eq!(
1676 index.cross_file_links.len(),
1677 1,
1678 "one file, one fragment, one line is one entry, got: {:?}",
1679 index.cross_file_links
1680 );
1681
1682 index.add_cross_file_link(link("target.md", "other", 3));
1683 index.add_cross_file_link(link("target.md", "missing", 4));
1684 assert_eq!(index.cross_file_links.len(), 3);
1685 }
1686
1687 #[test]
1693 fn test_reverse_deps_ignore_a_query_string_on_the_destination() {
1694 let mut index = WorkspaceIndex::new();
1695
1696 let mut file_a = FileIndex::new();
1697 file_a.add_cross_file_link(CrossFileLinkIndex {
1698 target_path: "b.md?raw=true".to_string(),
1699 fragment: "section".to_string(),
1700 line: 10,
1701 column: 5,
1702 origin: LinkOrigin::Body,
1703 });
1704 index.update_file(Path::new("docs/a.md"), file_a);
1705
1706 assert_eq!(
1707 index.get_dependents(Path::new("docs/b.md")),
1708 vec![PathBuf::from("docs/a.md")],
1709 "editing docs/b.md must re-lint the file linking to it"
1710 );
1711
1712 index.update_file(Path::new("docs/a.md"), FileIndex::new());
1715 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1716 }
1717
1718 #[test]
1719 fn test_reverse_deps_basic() {
1720 let mut index = WorkspaceIndex::new();
1721
1722 let mut file_a = FileIndex::new();
1724 file_a.add_cross_file_link(CrossFileLinkIndex {
1725 target_path: "b.md".to_string(),
1726 fragment: "section".to_string(),
1727 line: 10,
1728 column: 5,
1729 origin: LinkOrigin::Body,
1730 });
1731 index.update_file(Path::new("docs/a.md"), file_a);
1732
1733 let dependents = index.get_dependents(Path::new("docs/b.md"));
1735 assert_eq!(dependents.len(), 1);
1736 assert_eq!(dependents[0], PathBuf::from("docs/a.md"));
1737
1738 let a_dependents = index.get_dependents(Path::new("docs/a.md"));
1740 assert!(a_dependents.is_empty());
1741 }
1742
1743 #[test]
1744 fn test_reverse_deps_multiple() {
1745 let mut index = WorkspaceIndex::new();
1746
1747 let mut file_a = FileIndex::new();
1749 file_a.add_cross_file_link(CrossFileLinkIndex {
1750 target_path: "../b.md".to_string(),
1751 fragment: "".to_string(),
1752 line: 1,
1753 column: 1,
1754 origin: LinkOrigin::Body,
1755 });
1756 index.update_file(Path::new("docs/sub/a.md"), file_a);
1757
1758 let mut file_c = FileIndex::new();
1759 file_c.add_cross_file_link(CrossFileLinkIndex {
1760 target_path: "b.md".to_string(),
1761 fragment: "".to_string(),
1762 line: 1,
1763 column: 1,
1764 origin: LinkOrigin::Body,
1765 });
1766 index.update_file(Path::new("docs/c.md"), file_c);
1767
1768 let dependents = index.get_dependents(Path::new("docs/b.md"));
1770 assert_eq!(dependents.len(), 2);
1771 assert!(dependents.contains(&PathBuf::from("docs/sub/a.md")));
1772 assert!(dependents.contains(&PathBuf::from("docs/c.md")));
1773 }
1774
1775 #[test]
1776 fn test_reverse_deps_update_clears_old() {
1777 let mut index = WorkspaceIndex::new();
1778
1779 let mut file_a = FileIndex::new();
1781 file_a.add_cross_file_link(CrossFileLinkIndex {
1782 target_path: "b.md".to_string(),
1783 fragment: "".to_string(),
1784 line: 1,
1785 column: 1,
1786 origin: LinkOrigin::Body,
1787 });
1788 index.update_file(Path::new("docs/a.md"), file_a);
1789
1790 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1792
1793 let mut file_a_updated = FileIndex::new();
1795 file_a_updated.add_cross_file_link(CrossFileLinkIndex {
1796 target_path: "c.md".to_string(),
1797 fragment: "".to_string(),
1798 line: 1,
1799 column: 1,
1800 origin: LinkOrigin::Body,
1801 });
1802 index.update_file(Path::new("docs/a.md"), file_a_updated);
1803
1804 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1806
1807 let c_deps = index.get_dependents(Path::new("docs/c.md"));
1809 assert_eq!(c_deps.len(), 1);
1810 assert_eq!(c_deps[0], PathBuf::from("docs/a.md"));
1811 }
1812
1813 #[test]
1814 fn test_reverse_deps_remove_file() {
1815 let mut index = WorkspaceIndex::new();
1816
1817 let mut file_a = FileIndex::new();
1819 file_a.add_cross_file_link(CrossFileLinkIndex {
1820 target_path: "b.md".to_string(),
1821 fragment: "".to_string(),
1822 line: 1,
1823 column: 1,
1824 origin: LinkOrigin::Body,
1825 });
1826 index.update_file(Path::new("docs/a.md"), file_a);
1827
1828 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1830
1831 index.remove_file(Path::new("docs/a.md"));
1833
1834 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1836 }
1837
1838 #[test]
1839 fn test_normalize_path() {
1840 let path = Path::new("docs/sub/../other.md");
1842 let normalized = normalize_relative_path(path);
1843 assert_eq!(normalized, PathBuf::from("docs/other.md"));
1844
1845 let path2 = Path::new("docs/./other.md");
1847 let normalized2 = normalize_relative_path(path2);
1848 assert_eq!(normalized2, PathBuf::from("docs/other.md"));
1849
1850 let path3 = Path::new("a/b/c/../../d.md");
1852 let normalized3 = normalize_relative_path(path3);
1853 assert_eq!(normalized3, PathBuf::from("a/d.md"));
1854 }
1855
1856 #[test]
1861 fn normalize_keeps_a_traversal_that_leaves_its_own_root() {
1862 assert_eq!(
1863 normalize_relative_path(Path::new("../notes.md")),
1864 PathBuf::from("../notes.md")
1865 );
1866 assert_eq!(
1867 normalize_relative_path(Path::new("docs/../../notes.md")),
1868 PathBuf::from("../notes.md")
1869 );
1870 assert_eq!(
1871 normalize_relative_path(Path::new("../../a/./b/../notes.md")),
1872 PathBuf::from("../../a/notes.md")
1873 );
1874 }
1875
1876 #[test]
1879 fn normalize_stops_a_traversal_at_a_root() {
1880 let root = if cfg!(windows) { "C:\\" } else { "/" };
1881 assert_eq!(
1882 normalize_relative_path(&Path::new(root).join("..").join("notes.md")),
1883 Path::new(root).join("notes.md")
1884 );
1885 }
1886
1887 #[test]
1888 fn test_clear_clears_reverse_deps() {
1889 let mut index = WorkspaceIndex::new();
1890
1891 let mut file_a = FileIndex::new();
1893 file_a.add_cross_file_link(CrossFileLinkIndex {
1894 target_path: "b.md".to_string(),
1895 fragment: "".to_string(),
1896 line: 1,
1897 column: 1,
1898 origin: LinkOrigin::Body,
1899 });
1900 index.update_file(Path::new("docs/a.md"), file_a);
1901
1902 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1904
1905 index.clear();
1907
1908 assert_eq!(index.file_count(), 0);
1910 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1911 }
1912
1913 #[test]
1914 fn test_is_file_stale() {
1915 let mut index = WorkspaceIndex::new();
1916
1917 assert!(index.is_file_stale(Path::new("nonexistent.md"), "hash123"));
1919
1920 let file_index = FileIndex::with_hash("hash123".to_string());
1922 index.insert_file(PathBuf::from("docs/test.md"), file_index);
1923
1924 assert!(!index.is_file_stale(Path::new("docs/test.md"), "hash123"));
1926
1927 assert!(index.is_file_stale(Path::new("docs/test.md"), "different_hash"));
1929 }
1930
1931 #[cfg(feature = "native")]
1932 #[test]
1933 fn test_cache_roundtrip() {
1934 use std::fs;
1935
1936 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_roundtrip");
1938 let _ = fs::remove_dir_all(&temp_dir);
1939 fs::create_dir_all(&temp_dir).unwrap();
1940
1941 let mut index = WorkspaceIndex::new();
1943
1944 let mut file1 = FileIndex::with_hash("abc123".to_string());
1945 file1.add_heading(HeadingIndex {
1946 text: "Test Heading".to_string(),
1947 auto_anchor: "test-heading".to_string(),
1948 custom_anchor: Some("test".to_string()),
1949 line: 1,
1950 text_lines: 1,
1951 is_setext: false,
1952 });
1953 file1.add_cross_file_link(CrossFileLinkIndex {
1954 target_path: "./other.md".to_string(),
1955 fragment: "section".to_string(),
1956 line: 5,
1957 column: 3,
1958 origin: LinkOrigin::Body,
1959 });
1960 index.update_file(Path::new("docs/file1.md"), file1);
1961
1962 let mut file2 = FileIndex::with_hash("def456".to_string());
1963 file2.add_heading(HeadingIndex {
1964 text: "Another Heading".to_string(),
1965 auto_anchor: "another-heading".to_string(),
1966 custom_anchor: None,
1967 line: 1,
1968 text_lines: 1,
1969 is_setext: false,
1970 });
1971 index.update_file(Path::new("docs/other.md"), file2);
1972
1973 index.save_to_cache(&temp_dir).expect("Failed to save cache");
1975
1976 assert!(temp_dir.join("workspace_index.bin").exists());
1978
1979 let loaded = WorkspaceIndex::load_from_cache(&temp_dir).expect("Failed to load cache");
1981
1982 assert_eq!(loaded.file_count(), 2);
1984 assert!(loaded.contains_file(Path::new("docs/file1.md")));
1985 assert!(loaded.contains_file(Path::new("docs/other.md")));
1986
1987 let file1_loaded = loaded.get_file(Path::new("docs/file1.md")).unwrap();
1989 assert_eq!(file1_loaded.content_hash, "abc123");
1990 assert_eq!(file1_loaded.headings.len(), 1);
1991 assert_eq!(file1_loaded.headings[0].text, "Test Heading");
1992 assert_eq!(file1_loaded.headings[0].custom_anchor, Some("test".to_string()));
1993 assert_eq!(file1_loaded.cross_file_links.len(), 1);
1994 assert_eq!(file1_loaded.cross_file_links[0].target_path, "./other.md");
1995
1996 let dependents = loaded.get_dependents(Path::new("docs/other.md"));
1998 assert_eq!(dependents.len(), 1);
1999 assert_eq!(dependents[0], PathBuf::from("docs/file1.md"));
2000
2001 let _ = fs::remove_dir_all(&temp_dir);
2003 }
2004
2005 #[cfg(feature = "native")]
2006 #[test]
2007 fn test_cache_missing_file() {
2008 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_missing");
2009 let _ = std::fs::remove_dir_all(&temp_dir);
2010
2011 let result = WorkspaceIndex::load_from_cache(&temp_dir);
2013 assert!(result.is_none());
2014 }
2015
2016 #[cfg(feature = "native")]
2017 #[test]
2018 fn test_cache_corrupted_file() {
2019 use std::fs;
2020
2021 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_corrupted");
2022 let _ = fs::remove_dir_all(&temp_dir);
2023 fs::create_dir_all(&temp_dir).unwrap();
2024
2025 fs::write(temp_dir.join("workspace_index.bin"), b"bad").unwrap();
2027
2028 let result = WorkspaceIndex::load_from_cache(&temp_dir);
2030 assert!(result.is_none());
2031
2032 assert!(!temp_dir.join("workspace_index.bin").exists());
2034
2035 let _ = fs::remove_dir_all(&temp_dir);
2037 }
2038
2039 #[cfg(feature = "native")]
2040 #[test]
2041 fn test_cache_invalid_magic() {
2042 use std::fs;
2043
2044 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_invalid_magic");
2045 let _ = fs::remove_dir_all(&temp_dir);
2046 fs::create_dir_all(&temp_dir).unwrap();
2047
2048 let mut data = Vec::new();
2050 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();
2054
2055 let result = WorkspaceIndex::load_from_cache(&temp_dir);
2057 assert!(result.is_none());
2058
2059 assert!(!temp_dir.join("workspace_index.bin").exists());
2061
2062 let _ = fs::remove_dir_all(&temp_dir);
2064 }
2065
2066 #[cfg(feature = "native")]
2067 #[test]
2068 fn test_cache_version_mismatch() {
2069 use std::fs;
2070
2071 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_version_mismatch");
2072 let _ = fs::remove_dir_all(&temp_dir);
2073 fs::create_dir_all(&temp_dir).unwrap();
2074
2075 let mut data = Vec::new();
2077 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();
2081
2082 let result = WorkspaceIndex::load_from_cache(&temp_dir);
2084 assert!(result.is_none());
2085
2086 assert!(!temp_dir.join("workspace_index.bin").exists());
2088
2089 let _ = fs::remove_dir_all(&temp_dir);
2091 }
2092
2093 #[cfg(feature = "native")]
2094 #[test]
2095 fn test_cache_atomic_write() {
2096 use std::fs;
2097
2098 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_atomic");
2100 let _ = fs::remove_dir_all(&temp_dir);
2101 fs::create_dir_all(&temp_dir).unwrap();
2102
2103 let index = WorkspaceIndex::new();
2104 index.save_to_cache(&temp_dir).expect("Failed to save");
2105
2106 let entries: Vec<_> = fs::read_dir(&temp_dir).unwrap().collect();
2108 assert_eq!(entries.len(), 1);
2109 assert!(temp_dir.join("workspace_index.bin").exists());
2110
2111 let _ = fs::remove_dir_all(&temp_dir);
2113 }
2114
2115 #[test]
2116 fn test_has_anchor_auto_generated() {
2117 let mut file_index = FileIndex::new();
2118 file_index.add_heading(HeadingIndex {
2119 text: "Installation Guide".to_string(),
2120 auto_anchor: "installation-guide".to_string(),
2121 custom_anchor: None,
2122 line: 1,
2123 text_lines: 1,
2124 is_setext: false,
2125 });
2126
2127 assert!(file_index.has_anchor("installation-guide"));
2129
2130 assert!(file_index.has_anchor("Installation-Guide"));
2132 assert!(file_index.has_anchor("INSTALLATION-GUIDE"));
2133
2134 assert!(!file_index.has_anchor("nonexistent"));
2136 }
2137
2138 #[test]
2139 fn test_has_anchor_custom() {
2140 let mut file_index = FileIndex::new();
2141 file_index.add_heading(HeadingIndex {
2142 text: "Installation Guide".to_string(),
2143 auto_anchor: "installation-guide".to_string(),
2144 custom_anchor: Some("install".to_string()),
2145 line: 1,
2146 text_lines: 1,
2147 is_setext: false,
2148 });
2149
2150 assert!(file_index.has_anchor("installation-guide"));
2152
2153 assert!(file_index.has_anchor("install"));
2155 assert!(file_index.has_anchor("Install")); assert!(!file_index.has_anchor("nonexistent"));
2159 }
2160
2161 #[test]
2162 fn test_get_heading_by_anchor() {
2163 let mut file_index = FileIndex::new();
2164 file_index.add_heading(HeadingIndex {
2165 text: "Installation Guide".to_string(),
2166 auto_anchor: "installation-guide".to_string(),
2167 custom_anchor: Some("install".to_string()),
2168 line: 10,
2169 text_lines: 1,
2170 is_setext: false,
2171 });
2172 file_index.add_heading(HeadingIndex {
2173 text: "Configuration".to_string(),
2174 auto_anchor: "configuration".to_string(),
2175 custom_anchor: None,
2176 line: 20,
2177 text_lines: 1,
2178 is_setext: false,
2179 });
2180
2181 let heading = file_index.get_heading_by_anchor("installation-guide");
2183 assert!(heading.is_some());
2184 assert_eq!(heading.unwrap().text, "Installation Guide");
2185 assert_eq!(heading.unwrap().line, 10);
2186
2187 let heading = file_index.get_heading_by_anchor("install");
2189 assert!(heading.is_some());
2190 assert_eq!(heading.unwrap().text, "Installation Guide");
2191
2192 let heading = file_index.get_heading_by_anchor("configuration");
2194 assert!(heading.is_some());
2195 assert_eq!(heading.unwrap().text, "Configuration");
2196 assert_eq!(heading.unwrap().line, 20);
2197
2198 assert!(file_index.get_heading_by_anchor("nonexistent").is_none());
2200 }
2201
2202 #[test]
2203 fn test_anchor_lookup_many_headings() {
2204 let mut file_index = FileIndex::new();
2206
2207 for i in 0..100 {
2209 file_index.add_heading(HeadingIndex {
2210 text: format!("Heading {i}"),
2211 auto_anchor: format!("heading-{i}"),
2212 custom_anchor: Some(format!("h{i}")),
2213 line: i + 1,
2214 text_lines: 1,
2215 is_setext: false,
2216 });
2217 }
2218
2219 for i in 0..100 {
2221 assert!(file_index.has_anchor(&format!("heading-{i}")));
2222 assert!(file_index.has_anchor(&format!("h{i}")));
2223
2224 let heading = file_index.get_heading_by_anchor(&format!("heading-{i}"));
2225 assert!(heading.is_some());
2226 assert_eq!(heading.unwrap().line, i + 1);
2227 }
2228 }
2229
2230 #[test]
2235 fn test_extract_cross_file_links_basic() {
2236 use crate::config::MarkdownFlavor;
2237
2238 let content = "# Test\n\nSee [link](./other.md) for info.\n";
2239 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2240 let links = extract_cross_file_links(&ctx).relative;
2241
2242 assert_eq!(links.len(), 1);
2243 assert_eq!(links[0].target_path, "./other.md");
2244 assert_eq!(links[0].fragment, "");
2245 assert_eq!(links[0].line, 3);
2246 assert_eq!(links[0].column, 12);
2248 }
2249
2250 #[test]
2251 fn test_extract_cross_file_links_with_fragment() {
2252 use crate::config::MarkdownFlavor;
2253
2254 let content = "Check [guide](./guide.md#install) here.\n";
2255 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2256 let links = extract_cross_file_links(&ctx).relative;
2257
2258 assert_eq!(links.len(), 1);
2259 assert_eq!(links[0].target_path, "./guide.md");
2260 assert_eq!(links[0].fragment, "install");
2261 assert_eq!(links[0].line, 1);
2262 assert_eq!(links[0].column, 15);
2264 }
2265
2266 #[test]
2267 fn indexed_columns_preserve_unicode_and_line_endings() {
2268 for newline in ["\n", "\r\n"] {
2269 for target in ["目标/é.md", "/目标/é.md"] {
2270 let unit = format!("é日本🙂 [label]({target}#part) ");
2271 let content = format!("{}{newline}{}", unit.repeat(80), unit.repeat(80));
2272 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2273 let extracted = extract_cross_file_links(&ctx);
2274 let links = if target.starts_with('/') {
2275 extracted.root_relative
2276 } else {
2277 extracted.relative
2278 };
2279 assert_eq!(links.len(), 160);
2280 for (link, (offset, _)) in links.iter().zip(content.match_indices(target)) {
2281 assert_eq!((link.line, link.column), ctx.offset_to_line_col(offset));
2282 assert_eq!(link.target_path, "目标/é.md");
2283 assert_eq!(link.fragment, "part");
2284 }
2285 }
2286 }
2287 let content = format!("{}[last](<目标/é.md>)", "é日本🙂 [label](one.md) ".repeat(160));
2290 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2291 let links = extract_cross_file_links(&ctx).relative;
2292 assert_eq!(links.len(), 161);
2293 let expected = ctx.offset_to_line_col(content.find("目标/é.md").unwrap());
2294 assert!(
2295 links
2296 .iter()
2297 .all(|link| (link.line, link.column) == expected && link.target_path == "目标/é.md")
2298 );
2299 }
2300
2301 #[test]
2302 fn test_extract_cross_file_links_multiple_on_same_line() {
2303 use crate::config::MarkdownFlavor;
2304
2305 let content = "See [a](a.md) and [b](b.md) here.\n";
2306 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2307 let links = extract_cross_file_links(&ctx).relative;
2308
2309 assert_eq!(links.len(), 2);
2310
2311 assert_eq!(links[0].target_path, "a.md");
2312 assert_eq!(links[0].line, 1);
2313 assert_eq!(links[0].column, 9);
2315
2316 assert_eq!(links[1].target_path, "b.md");
2317 assert_eq!(links[1].line, 1);
2318 assert_eq!(links[1].column, 23);
2320 }
2321
2322 #[test]
2323 fn test_extract_cross_file_links_angle_brackets() {
2324 use crate::config::MarkdownFlavor;
2325
2326 let content = "See [link](<path/with (parens).md>) here.\n";
2327 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2328 let links = extract_cross_file_links(&ctx).relative;
2329
2330 assert_eq!(links.len(), 1);
2331 assert_eq!(links[0].target_path, "path/with (parens).md");
2332 assert_eq!(links[0].line, 1);
2333 assert_eq!(links[0].column, 13);
2335 }
2336
2337 #[test]
2338 fn test_extract_cross_file_links_bare_parens_in_destination() {
2339 use crate::config::MarkdownFlavor;
2340
2341 let content = "See [link](docs/file(inner).md) and [plain](docs/other.md).\n";
2345 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2346 let links = extract_cross_file_links(&ctx).relative;
2347
2348 let targets: Vec<&str> = links.iter().map(|l| l.target_path.as_str()).collect();
2349 assert_eq!(targets, vec!["docs/file(inner).md", "docs/other.md"]);
2350 assert_eq!(links[0].column, 12);
2352 }
2353
2354 #[test]
2355 fn test_extract_cross_file_links_skips_external() {
2356 use crate::config::MarkdownFlavor;
2357
2358 let content = r#"
2359[external](https://example.com)
2360[mailto](mailto:test@example.com)
2361[local](./local.md)
2362[fragment](#section)
2363[absolute](/docs/page.md)
2364"#;
2365 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2366 let extracted = extract_cross_file_links(&ctx);
2367
2368 assert_eq!(extracted.relative.len(), 1);
2370 assert_eq!(extracted.relative[0].target_path, "./local.md");
2371 assert_eq!(extracted.root_relative.len(), 1);
2373 assert_eq!(extracted.root_relative[0].target_path, "docs/page.md");
2374 }
2375
2376 #[test]
2377 fn test_extract_cross_file_links_root_relative() {
2378 use crate::config::MarkdownFlavor;
2379
2380 let content = "[a](/guide.md#install)\n[b](/../escape.md)\n[c](//host/x.md)\n[d](/img/pic.png)\n";
2384 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2385 let extracted = extract_cross_file_links(&ctx);
2386
2387 assert!(extracted.relative.is_empty(), "no directory-relative links here");
2388 assert_eq!(
2389 extracted
2390 .root_relative
2391 .iter()
2392 .map(|l| (l.target_path.as_str(), l.fragment.as_str()))
2393 .collect::<Vec<_>>(),
2394 vec![("guide.md", "install")],
2395 "only the safe root-relative markdown link is captured"
2396 );
2397 }
2398
2399 #[test]
2400 fn test_extract_cross_file_links_skips_non_markdown() {
2401 use crate::config::MarkdownFlavor;
2402
2403 let content = r#"
2404[image](./photo.png)
2405[doc](./readme.md)
2406[pdf](./document.pdf)
2407"#;
2408 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2409 let links = extract_cross_file_links(&ctx).relative;
2410
2411 assert_eq!(links.len(), 1);
2413 assert_eq!(links[0].target_path, "./readme.md");
2414 }
2415
2416 #[test]
2417 fn test_extract_cross_file_links_skips_code_spans() {
2418 use crate::config::MarkdownFlavor;
2419
2420 let content = "Normal [link](./file.md) and `[code](./ignored.md)` here.\n";
2421 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2422 let links = extract_cross_file_links(&ctx).relative;
2423
2424 assert_eq!(links.len(), 1);
2426 assert_eq!(links[0].target_path, "./file.md");
2427 }
2428
2429 #[test]
2430 fn test_extract_cross_file_links_with_query_params() {
2431 use crate::config::MarkdownFlavor;
2432
2433 let content = "See [doc](./file.md?raw=true) here.\n";
2434 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2435 let links = extract_cross_file_links(&ctx).relative;
2436
2437 assert_eq!(links.len(), 1);
2438 assert_eq!(links[0].target_path, "./file.md");
2440 }
2441
2442 #[test]
2443 fn test_extract_cross_file_links_empty_content() {
2444 use crate::config::MarkdownFlavor;
2445
2446 let content = "";
2447 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2448 let links = extract_cross_file_links(&ctx).relative;
2449
2450 assert!(links.is_empty());
2451 }
2452
2453 #[test]
2454 fn test_extract_cross_file_links_no_links() {
2455 use crate::config::MarkdownFlavor;
2456
2457 let content = "# Just a heading\n\nSome text without links.\n";
2458 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2459 let links = extract_cross_file_links(&ctx).relative;
2460
2461 assert!(links.is_empty());
2462 }
2463
2464 #[test]
2465 fn test_extract_cross_file_links_position_accuracy_issue_234() {
2466 use crate::config::MarkdownFlavor;
2469
2470 let content = r#"# Test Document
2471
2472Here is a [broken link](nonexistent-file.md) that should trigger MD057.
2473
2474And another [link](also-missing.md) on this line.
2475"#;
2476 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2477 let links = extract_cross_file_links(&ctx).relative;
2478
2479 assert_eq!(links.len(), 2);
2480
2481 assert_eq!(links[0].target_path, "nonexistent-file.md");
2483 assert_eq!(links[0].line, 3);
2484 assert_eq!(links[0].column, 25);
2485
2486 assert_eq!(links[1].target_path, "also-missing.md");
2488 assert_eq!(links[1].line, 5);
2489 assert_eq!(links[1].column, 20);
2490 }
2491
2492 #[test]
2493 fn link_path_part_drops_the_query_and_fragment_and_decodes() {
2494 assert_eq!(link_path_part("guide%20one.md?raw=true#real"), "guide one.md");
2495 assert_eq!(link_path_part("guide%2Emd#real"), "guide.md");
2496 assert_eq!(link_path_part("#only-a-fragment"), "");
2497 assert_eq!(link_path_part("?only=query#x"), "");
2498 }
2499
2500 #[cfg(feature = "native")]
2501 #[test]
2502 fn url_encode_path_is_undone_by_link_path_part() {
2503 for path in [
2504 "plain/guide.md",
2505 "my notes.md",
2506 "50%.md",
2507 "a(b)<c>.md",
2508 "what?.md",
2509 "c#.md",
2510 "back\\slash.md",
2511 "tab\there.md",
2512 "日本 語/é.md",
2513 ] {
2514 let encoded = url_encode_path(path);
2515 assert!(
2516 !encoded.contains([' ', '(', ')', '<', '>', '?', '#', '\\', '\t']),
2517 "{encoded}"
2518 );
2519 assert_eq!(link_path_part(&encoded), path, "{encoded}");
2520 }
2521 assert_eq!(url_encode_path("日本/guide.md"), "日本/guide.md");
2522 assert_eq!(url_encode_path("my notes.md"), "my%20notes.md");
2523 }
2524
2525 #[test]
2526 fn workspace_key_spells_every_path_inside_the_working_directory_relatively() {
2527 let cwd = std::env::current_dir().unwrap();
2528 assert_eq!(workspace_key(Path::new("./docs/a.md")), PathBuf::from("docs/a.md"));
2529 assert_eq!(
2530 workspace_key(&cwd.join("docs/../docs/a.md")),
2531 PathBuf::from("docs/a.md")
2532 );
2533 assert_eq!(
2534 workspace_key(&cwd.canonicalize().unwrap().join("docs/a.md")),
2535 PathBuf::from("docs/a.md")
2536 );
2537 let outside = cwd.parent().unwrap().join("sibling.md");
2540 assert_eq!(workspace_key(Path::new("../sibling.md")), workspace_key(&outside));
2541 assert!(workspace_key(&outside).is_absolute());
2542 }
2543
2544 #[cfg(unix)]
2545 #[test]
2546 fn get_resolved_file_finds_a_canonical_key_through_a_symlinked_spelling() {
2547 let dir = tempfile::tempdir().unwrap();
2548 let real = dir.path().canonicalize().unwrap().join("real");
2549 std::fs::create_dir(&real).unwrap();
2550 std::fs::write(real.join("a.md"), "# A\n").unwrap();
2551 let alias = dir.path().canonicalize().unwrap().join("alias");
2552 std::os::unix::fs::symlink(&real, &alias).unwrap();
2553
2554 let mut index = WorkspaceIndex::new();
2555 let mut file_index = FileIndex::new();
2556 file_index.add_heading(HeadingIndex {
2557 text: "A".to_string(),
2558 auto_anchor: "a".to_string(),
2559 custom_anchor: None,
2560 line: 1,
2561 text_lines: 1,
2562 is_setext: false,
2563 });
2564 index.insert_file(real.join("a.md"), file_index);
2565
2566 assert!(index.get_resolved_file(&alias.join("a.md")).is_some());
2567 assert!(index.get_resolved_file(&alias.join("./sub/../a.md")).is_some());
2568 assert!(index.get_resolved_file(&alias.join("b.md")).is_none());
2569 }
2570}