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
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 lines: Vec<&str> = content.lines().collect();
264
265 let mut processed_lines = HashSet::new();
268
269 for link in ctx.links() {
270 let line_idx = link.line - 1;
271 if line_idx >= lines.len() {
272 continue;
273 }
274
275 if ctx.line_info(link.line).is_some_and(|info| info.in_front_matter) {
279 continue;
280 }
281
282 if !processed_lines.insert(line_idx) {
284 continue;
285 }
286
287 let line = lines[line_idx];
288 if !line.contains("](") {
289 continue;
290 }
291
292 for link_match in LINK_START_REGEX.find_iter(line) {
294 let start_pos = link_match.start();
295 let end_pos = link_match.end();
296
297 let line_start_byte = ctx.line_start_byte(line_idx + 1).unwrap_or(0);
299 let absolute_start_pos = line_start_byte + start_pos;
300
301 if ctx.is_in_code_span_byte(absolute_start_pos) {
303 continue;
304 }
305
306 let caps_result = URL_EXTRACT_ANGLE_BRACKET_REGEX
309 .captures_at(line, end_pos - 1)
310 .or_else(|| URL_EXTRACT_REGEX.captures_at(line, end_pos - 1));
311
312 if let Some(caps) = caps_result
313 && let Some(url_group) = caps.get(1)
314 {
315 let file_path = url_group.as_str().trim();
316
317 if let Some(rel) = file_path.strip_prefix('/') {
322 if !rel.starts_with('/')
323 && !Path::new(rel)
324 .components()
325 .any(|c| matches!(c, std::path::Component::ParentDir))
326 {
327 let stripped = strip_query_and_fragment(rel);
328 if is_markdown_file(&url_decode(stripped)) {
329 let fragment = caps.get(2).map_or("", |m| m.as_str().trim_start_matches('#'));
330 links.root_relative.push(CrossFileLinkIndex {
331 target_path: stripped.to_string(),
332 fragment: fragment.to_string(),
333 line: link.line,
334 column: byte_to_char_count(line, url_group.start()),
335 origin: LinkOrigin::Body,
336 });
337 }
338 }
339 continue;
340 }
341
342 if file_path.is_empty()
345 || PROTOCOL_DOMAIN_REGEX.is_match(file_path)
346 || file_path.starts_with("www.")
347 || file_path.starts_with('#')
348 || file_path.starts_with("{{")
349 || file_path.starts_with("{%")
350 || file_path.starts_with('~')
351 || file_path.starts_with('@')
352 || (file_path.starts_with('`') && file_path.ends_with('`'))
353 {
354 continue;
355 }
356
357 let file_path = strip_query_and_fragment(file_path);
359
360 let fragment = caps.get(2).map_or("", |m| m.as_str().trim_start_matches('#'));
362
363 if is_markdown_file(&url_decode(file_path)) {
367 links.relative.push(CrossFileLinkIndex {
368 target_path: file_path.to_string(),
369 fragment: fragment.to_string(),
370 line: link.line,
371 column: byte_to_char_count(line, url_group.start()),
372 origin: LinkOrigin::Body,
373 });
374 }
375 }
376 }
377 }
378
379 links
380}
381
382#[cfg(feature = "postcard")]
384const CACHE_MAGIC: &[u8; 4] = b"RWSI";
385
386#[cfg(feature = "postcard")]
422const CACHE_FORMAT_VERSION: u32 = 15;
423
424#[cfg(feature = "postcard")]
426const CACHE_FILE_NAME: &str = "workspace_index.bin";
427
428#[cfg(feature = "postcard")]
432static CACHE_TEMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
433
434#[derive(Debug, Default, Clone, Serialize, Deserialize)]
439pub struct WorkspaceIndex {
440 files: HashMap<PathBuf, FileIndex>,
442 reverse_deps: HashMap<PathBuf, HashSet<PathBuf>>,
445 version: u64,
447 #[serde(skip)]
452 link_target_policy: Option<crate::lint_context::LinkTargetPolicy>,
453}
454
455#[derive(Debug, Clone, Default, Serialize, Deserialize)]
457pub struct FileIndex {
458 pub headings: Vec<HeadingIndex>,
460 pub reference_links: Vec<ReferenceLinkIndex>,
462 pub cross_file_links: Vec<CrossFileLinkIndex>,
464 #[serde(default)]
469 pub root_relative_links: Vec<CrossFileLinkIndex>,
470 #[serde(default)]
475 pub md057_link_targets: Vec<Md057LinkTarget>,
476 pub defined_references: HashSet<String>,
479 pub content_hash: String,
481 anchor_to_heading: HashMap<String, usize>,
484 #[serde(default)]
488 anchor_to_heading_exact: HashMap<String, usize>,
489 html_anchors: HashSet<String>,
492 #[serde(default)]
495 html_anchors_exact: HashSet<String>,
496 attribute_anchors: HashSet<String>,
500 #[serde(default)]
503 attribute_anchors_exact: HashSet<String>,
504 pub file_disabled_rules: HashSet<String>,
507 pub persistent_transitions: Vec<(usize, HashSet<String>, HashSet<String>)>,
510 pub line_disabled_rules: HashMap<usize, HashSet<String>>,
512}
513
514#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
516pub struct HeadingIndex {
517 pub text: String,
519 pub auto_anchor: String,
521 pub custom_anchor: Option<String>,
523 pub line: usize,
525 pub text_lines: usize,
529 #[serde(default)]
531 pub is_setext: bool,
532}
533
534impl HeadingIndex {
535 #[must_use]
537 pub fn first_line(&self) -> usize {
538 self.line + 1 - self.text_lines.max(1)
539 }
540
541 #[must_use]
543 pub fn covers_line(&self, line: usize) -> bool {
544 (self.first_line()..=self.line).contains(&line)
545 }
546}
547
548#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
550pub struct ReferenceLinkIndex {
551 pub reference_id: String,
553 pub line: usize,
555 pub column: usize,
557}
558
559#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
567pub enum LinkOrigin {
568 Body,
570 FrontMatter { field: Option<String> },
579}
580
581#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
583pub struct CrossFileLinkIndex {
584 pub target_path: String,
586 pub fragment: String,
588 pub line: usize,
590 pub column: usize,
592 pub origin: LinkOrigin,
594}
595
596#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
598pub struct Md057LinkTarget {
599 pub target: String,
601 pub origin: LinkOrigin,
603}
604
605pub fn link_target_candidates(source_file: &Path, target_path: &str) -> Vec<PathBuf> {
618 let joined = resolve_target_against(source_file.parent().unwrap_or(Path::new("")), target_path);
619 let base = normalize_relative_path(&joined);
620
621 if base.extension().is_some() {
622 return vec![base];
623 }
624
625 let mut candidates = Vec::with_capacity(crate::discovery::MARKDOWN_EXTENSIONS.len() + 1);
628 for ext in crate::discovery::MARKDOWN_EXTENSIONS {
629 candidates.push(base.with_extension(ext));
630 }
631 candidates.insert(0, base);
632 candidates
633}
634
635static WORKING_DIRECTORY_SPELLINGS: LazyLock<Vec<PathBuf>> = LazyLock::new(|| {
642 let mut spellings = Vec::new();
643 if let Ok(cwd) = std::env::current_dir() {
644 let cwd = normalize_relative_path(&cwd);
645 for spelling in [
646 cwd.canonicalize().ok(),
647 crate::discovery::canonicalize_for_matching(&cwd),
648 ]
649 .into_iter()
650 .flatten()
651 {
652 if spelling != cwd && !spellings.contains(&spelling) {
653 spellings.push(spelling);
654 }
655 }
656 spellings.push(cwd);
657 }
658 spellings
659});
660
661pub fn workspace_key(path: &Path) -> PathBuf {
673 let normalized = normalize_relative_path(path);
674 if normalized.is_absolute() {
675 let inside_working_directory = |absolute: &Path| {
676 WORKING_DIRECTORY_SPELLINGS
677 .iter()
678 .find_map(|cwd| absolute.strip_prefix(cwd).ok().map(Path::to_path_buf))
679 };
680 return inside_working_directory(&normalized)
681 .or_else(|| inside_working_directory(&crate::discovery::resolve_for_matching(&normalized)))
682 .unwrap_or(normalized);
683 }
684 if normalized.starts_with("..")
685 && let Some(cwd) = WORKING_DIRECTORY_SPELLINGS.last()
686 {
687 return workspace_key(&cwd.join(&normalized));
688 }
689 normalized
690}
691
692pub fn normalize_relative_path(path: &Path) -> PathBuf {
703 let mut components: Vec<std::path::Component<'_>> = Vec::new();
704 for component in path.components() {
705 match component {
706 std::path::Component::CurDir => {}
707 std::path::Component::ParentDir => match components.last() {
708 Some(std::path::Component::Normal(_)) => {
709 components.pop();
710 }
711 Some(std::path::Component::RootDir) => {}
712 _ => components.push(component),
713 },
714 c => components.push(c),
715 }
716 }
717 components.iter().collect()
718}
719
720impl CrossFileLinkIndex {
721 pub fn is_navigable(&self) -> bool {
729 matches!(self.origin, LinkOrigin::Body)
730 }
731}
732
733#[derive(Debug, Clone, Serialize, Deserialize)]
735pub struct VulnerableAnchor {
736 pub file: PathBuf,
738 pub line: usize,
740 pub text: String,
742}
743
744impl WorkspaceIndex {
745 pub fn new() -> Self {
747 Self::default()
748 }
749
750 pub fn version(&self) -> u64 {
752 self.version
753 }
754
755 pub fn file_count(&self) -> usize {
757 self.files.len()
758 }
759
760 pub fn contains_file(&self, path: &Path) -> bool {
762 self.files.contains_key(path)
763 }
764
765 pub fn get_file(&self, path: &Path) -> Option<&FileIndex> {
767 self.files.get(path)
768 }
769
770 pub fn get_resolved_file(&self, target: &Path) -> Option<&FileIndex> {
780 let normalized = normalize_relative_path(target);
781 if let Some(index) = self.files.get(&normalized) {
782 return Some(index);
783 }
784 if let Some(index) = self.files.get(&workspace_key(&normalized)) {
785 return Some(index);
786 }
787 let canonical = normalized.canonicalize().ok()?;
790 let matching = crate::discovery::canonicalize_for_matching(&normalized);
791 [Some(canonical), matching]
792 .into_iter()
793 .flatten()
794 .find_map(|path| self.files.get(&path).or_else(|| self.files.get(&workspace_key(&path))))
795 }
796
797 pub fn link_target_policy(&self) -> Option<&crate::lint_context::LinkTargetPolicy> {
799 self.link_target_policy.as_ref()
800 }
801
802 pub fn set_link_target_policy(&mut self, policy: crate::lint_context::LinkTargetPolicy) {
804 self.link_target_policy = Some(policy);
805 }
806
807 pub fn insert_file(&mut self, path: PathBuf, index: FileIndex) {
809 self.files.insert(path, index);
810 self.version = self.version.wrapping_add(1);
811 }
812
813 pub fn remove_file(&mut self, path: &Path) -> Option<FileIndex> {
815 self.clear_reverse_deps_for(path);
817
818 let result = self.files.remove(path);
819 if result.is_some() {
820 self.version = self.version.wrapping_add(1);
821 }
822 result
823 }
824
825 pub fn get_vulnerable_anchors(&self) -> HashMap<String, Vec<VulnerableAnchor>> {
835 let mut vulnerable: HashMap<String, Vec<VulnerableAnchor>> = HashMap::new();
836
837 for (file_path, file_index) in &self.files {
838 for heading in &file_index.headings {
839 if heading.custom_anchor.is_none() && !heading.auto_anchor.is_empty() {
841 let anchor_key = heading.auto_anchor.to_lowercase();
842 vulnerable.entry(anchor_key).or_default().push(VulnerableAnchor {
843 file: file_path.clone(),
844 line: heading.line,
845 text: heading.text.clone(),
846 });
847 }
848 }
849 }
850
851 vulnerable
852 }
853
854 pub fn all_headings(&self) -> impl Iterator<Item = (&Path, &HeadingIndex)> {
856 self.files
857 .iter()
858 .flat_map(|(path, index)| index.headings.iter().map(move |h| (path.as_path(), h)))
859 }
860
861 pub fn files(&self) -> impl Iterator<Item = (&Path, &FileIndex)> {
863 self.files.iter().map(|(p, i)| (p.as_path(), i))
864 }
865
866 pub fn files_sorted(&self) -> Vec<(&Path, &FileIndex)> {
872 let mut entries: Vec<(&Path, &FileIndex)> = self.files.iter().map(|(p, i)| (p.as_path(), i)).collect();
873 entries.sort_by_key(|(a, _)| *a);
874 entries
875 }
876
877 pub fn clear(&mut self) {
879 self.files.clear();
880 self.reverse_deps.clear();
881 self.version = self.version.wrapping_add(1);
882 }
883
884 pub fn update_file(&mut self, path: &Path, index: FileIndex) {
891 self.clear_reverse_deps_as_source(path);
894
895 for link in &index.cross_file_links {
897 let target = self.resolve_target_path(path, &link.target_path);
898 self.reverse_deps.entry(target).or_default().insert(path.to_path_buf());
899 }
900
901 self.files.insert(path.to_path_buf(), index);
902 self.version = self.version.wrapping_add(1);
903 }
904
905 pub fn get_dependents(&self, path: &Path) -> Vec<PathBuf> {
910 self.reverse_deps
911 .get(path)
912 .map(|set| set.iter().cloned().collect())
913 .unwrap_or_default()
914 }
915
916 pub fn is_file_stale(&self, path: &Path, current_hash: &str) -> bool {
920 self.files.get(path).is_none_or(|f| f.content_hash != current_hash)
921 }
922
923 pub fn retain_only(&mut self, current_files: &std::collections::HashSet<PathBuf>) -> usize {
928 let before_count = self.files.len();
929
930 let to_remove: Vec<PathBuf> = self
932 .files
933 .keys()
934 .filter(|path| !current_files.contains(*path))
935 .cloned()
936 .collect();
937
938 for path in &to_remove {
940 self.remove_file(path);
941 }
942
943 before_count - self.files.len()
944 }
945
946 #[cfg(feature = "postcard")]
953 pub fn save_to_cache(&self, cache_dir: &Path) -> std::io::Result<()> {
954 use std::fs;
955 use std::io::Write;
956
957 fs::create_dir_all(cache_dir)?;
959
960 let encoded = postcard::to_allocvec(self)
962 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
963
964 let mut cache_data = Vec::with_capacity(8 + encoded.len());
966 cache_data.extend_from_slice(CACHE_MAGIC);
967 cache_data.extend_from_slice(&CACHE_FORMAT_VERSION.to_le_bytes());
968 cache_data.extend_from_slice(&encoded);
969
970 let final_path = cache_dir.join(CACHE_FILE_NAME);
975 let counter = CACHE_TEMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
976 #[cfg(not(target_arch = "wasm32"))]
977 let temp_path = cache_dir.join(format!("{CACHE_FILE_NAME}.tmp.{}.{counter}", std::process::id()));
978 #[cfg(target_arch = "wasm32")]
979 let temp_path = cache_dir.join(format!("{CACHE_FILE_NAME}.tmp.{counter}"));
980
981 {
983 let mut file = fs::File::create(&temp_path)?;
984 file.write_all(&cache_data)?;
985 file.sync_all()?;
986 }
987
988 fs::rename(&temp_path, &final_path)?;
990
991 log::debug!(
992 "Saved workspace index to cache: {} files, {} bytes (format v{})",
993 self.files.len(),
994 cache_data.len(),
995 CACHE_FORMAT_VERSION
996 );
997
998 Ok(())
999 }
1000
1001 #[cfg(feature = "postcard")]
1009 pub fn load_from_cache(cache_dir: &Path) -> Option<Self> {
1010 use std::fs;
1011
1012 let path = cache_dir.join(CACHE_FILE_NAME);
1013 let data = fs::read(&path).ok()?;
1014
1015 if data.len() < 8 {
1017 log::warn!("Workspace index cache too small, discarding");
1018 let _ = fs::remove_file(&path);
1019 return None;
1020 }
1021
1022 if &data[0..4] != CACHE_MAGIC {
1024 log::warn!("Workspace index cache has invalid magic header, discarding");
1025 let _ = fs::remove_file(&path);
1026 return None;
1027 }
1028
1029 let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
1031 if version != CACHE_FORMAT_VERSION {
1032 log::info!(
1033 "Workspace index cache format version mismatch (got {version}, expected {CACHE_FORMAT_VERSION}), rebuilding"
1034 );
1035 let _ = fs::remove_file(&path);
1036 return None;
1037 }
1038
1039 match postcard::from_bytes::<Self>(&data[8..]) {
1041 Ok(index) => {
1042 log::debug!(
1043 "Loaded workspace index from cache: {} files (format v{})",
1044 index.files.len(),
1045 version
1046 );
1047 Some(index)
1048 }
1049 Err(e) => {
1050 log::warn!("Failed to deserialize workspace index cache: {e}");
1051 let _ = fs::remove_file(&path);
1052 None
1053 }
1054 }
1055 }
1056
1057 fn clear_reverse_deps_as_source(&mut self, path: &Path) {
1062 let targets: Vec<PathBuf> = match self.files.get(path) {
1069 Some(index) => index
1070 .cross_file_links
1071 .iter()
1072 .map(|link| self.resolve_target_path(path, &link.target_path))
1073 .collect(),
1074 None => return,
1075 };
1076 for target in targets {
1077 if let Some(deps) = self.reverse_deps.get_mut(&target) {
1078 deps.remove(path);
1079 if deps.is_empty() {
1080 self.reverse_deps.remove(&target);
1081 }
1082 }
1083 }
1084 }
1085
1086 fn clear_reverse_deps_for(&mut self, path: &Path) {
1091 self.clear_reverse_deps_as_source(path);
1093
1094 self.reverse_deps.remove(path);
1096 }
1097
1098 fn resolve_target_path(&self, source_file: &Path, relative_target: &str) -> PathBuf {
1104 let source_dir = source_file.parent().unwrap_or(Path::new(""));
1105 link_target_file(source_dir, relative_target)
1106 }
1107}
1108
1109impl FileIndex {
1110 pub fn new() -> Self {
1112 Self::default()
1113 }
1114
1115 pub fn with_hash(content_hash: String) -> Self {
1117 Self {
1118 content_hash,
1119 ..Default::default()
1120 }
1121 }
1122
1123 pub fn extracted_data_differs(&self, other: &Self) -> bool {
1139 let Self {
1143 headings,
1144 reference_links,
1145 cross_file_links,
1146 root_relative_links,
1147 md057_link_targets,
1148 defined_references,
1149 content_hash: _,
1150 anchor_to_heading,
1151 anchor_to_heading_exact,
1152 html_anchors,
1153 html_anchors_exact,
1154 attribute_anchors,
1155 attribute_anchors_exact,
1156 file_disabled_rules,
1157 persistent_transitions,
1158 line_disabled_rules,
1159 } = self;
1160
1161 headings != &other.headings
1162 || reference_links != &other.reference_links
1163 || cross_file_links != &other.cross_file_links
1164 || root_relative_links != &other.root_relative_links
1165 || md057_link_targets != &other.md057_link_targets
1166 || defined_references != &other.defined_references
1167 || anchor_to_heading != &other.anchor_to_heading
1168 || anchor_to_heading_exact != &other.anchor_to_heading_exact
1169 || html_anchors != &other.html_anchors
1170 || html_anchors_exact != &other.html_anchors_exact
1171 || attribute_anchors != &other.attribute_anchors
1172 || attribute_anchors_exact != &other.attribute_anchors_exact
1173 || file_disabled_rules != &other.file_disabled_rules
1174 || persistent_transitions != &other.persistent_transitions
1175 || line_disabled_rules != &other.line_disabled_rules
1176 }
1177
1178 pub fn add_heading(&mut self, heading: HeadingIndex) {
1184 let index = self.headings.len();
1185
1186 self.anchor_to_heading.insert(heading.auto_anchor.to_lowercase(), index);
1189 self.anchor_to_heading_exact.insert(heading.auto_anchor.clone(), index);
1190
1191 if let Some(ref custom) = heading.custom_anchor {
1193 self.anchor_to_heading.insert(custom.to_lowercase(), index);
1194 self.anchor_to_heading_exact.insert(custom.clone(), index);
1195 }
1196
1197 self.headings.push(heading);
1198 }
1199
1200 pub fn add_anchor_alias(&mut self, anchor: &str, heading_index: usize) {
1203 if heading_index < self.headings.len() {
1204 self.anchor_to_heading.insert(anchor.to_lowercase(), heading_index);
1205 self.anchor_to_heading_exact.insert(anchor.to_string(), heading_index);
1206 }
1207 }
1208
1209 pub fn has_anchor(&self, anchor: &str) -> bool {
1220 self.has_anchor_with_case(anchor, true)
1221 }
1222
1223 pub fn has_anchor_with_case(&self, anchor: &str, ignore_case: bool) -> bool {
1232 if self.lookup_anchor(anchor, ignore_case) {
1233 return true;
1234 }
1235
1236 if anchor.contains('%') {
1238 let decoded = url_decode(anchor);
1239 if decoded != anchor {
1240 return self.lookup_anchor(&decoded, ignore_case);
1241 }
1242 }
1243
1244 false
1245 }
1246
1247 fn lookup_anchor(&self, anchor: &str, ignore_case: bool) -> bool {
1250 if ignore_case {
1251 let lower = anchor.to_lowercase();
1252 self.anchor_to_heading.contains_key(&lower)
1253 || self.html_anchors.contains(&lower)
1254 || self.attribute_anchors.contains(&lower)
1255 } else {
1256 self.anchor_to_heading_exact.contains_key(anchor)
1257 || self.html_anchors_exact.contains(anchor)
1258 || self.attribute_anchors_exact.contains(anchor)
1259 }
1260 }
1261
1262 pub fn add_html_anchor(&mut self, anchor: &str) {
1265 if !anchor.is_empty() {
1266 self.html_anchors.insert(anchor.to_lowercase());
1267 self.html_anchors_exact.insert(anchor.to_string());
1268 }
1269 }
1270
1271 pub fn add_attribute_anchor(&mut self, anchor: &str) {
1274 if !anchor.is_empty() {
1275 self.attribute_anchors.insert(anchor.to_lowercase());
1276 self.attribute_anchors_exact.insert(anchor.to_string());
1277 }
1278 }
1279
1280 pub fn get_heading_by_anchor(&self, anchor: &str) -> Option<&HeadingIndex> {
1284 self.anchor_to_heading
1285 .get(&anchor.to_lowercase())
1286 .and_then(|&idx| self.headings.get(idx))
1287 }
1288
1289 pub fn add_reference_link(&mut self, link: ReferenceLinkIndex) {
1291 self.reference_links.push(link);
1292 }
1293
1294 pub fn is_rule_disabled_at_line(&self, rule_name: &str, line: usize) -> bool {
1299 if self.file_disabled_rules.contains("*") || self.file_disabled_rules.contains(rule_name) {
1301 return true;
1302 }
1303
1304 if let Some(rules) = self.line_disabled_rules.get(&line)
1306 && (rules.contains("*") || rules.contains(rule_name))
1307 {
1308 return true;
1309 }
1310
1311 if !self.persistent_transitions.is_empty() {
1313 let idx = match self.persistent_transitions.binary_search_by_key(&line, |t| t.0) {
1314 Ok(i) => Some(i),
1315 Err(i) => {
1316 if i > 0 {
1317 Some(i - 1)
1318 } else {
1319 None
1320 }
1321 }
1322 };
1323 if let Some(i) = idx {
1324 let (_, ref disabled, ref enabled) = self.persistent_transitions[i];
1325 if disabled.contains("*") {
1326 return !enabled.contains(rule_name);
1327 }
1328 return disabled.contains(rule_name);
1329 }
1330 }
1331
1332 false
1333 }
1334
1335 pub fn add_cross_file_link(&mut self, link: CrossFileLinkIndex) {
1350 let existing = self.cross_file_links.iter_mut().find(|existing| {
1351 existing.fragment == link.fragment
1352 && existing.line == link.line
1353 && strip_query_and_fragment(&existing.target_path) == strip_query_and_fragment(&link.target_path)
1354 });
1355 match existing {
1356 Some(existing) => {
1359 if !existing.target_path.contains('?') && link.target_path.contains('?') {
1360 *existing = link;
1361 }
1362 }
1363 None => self.cross_file_links.push(link),
1364 }
1365 }
1366
1367 pub fn add_root_relative_link(&mut self, link: CrossFileLinkIndex) {
1369 let is_duplicate = self.root_relative_links.iter().any(|existing| {
1370 existing.target_path == link.target_path && existing.fragment == link.fragment && existing.line == link.line
1371 });
1372 if !is_duplicate {
1373 self.root_relative_links.push(link);
1374 }
1375 }
1376
1377 pub fn add_md057_link_target(&mut self, target: Md057LinkTarget) {
1379 self.md057_link_targets.push(target);
1380 }
1381
1382 pub fn add_defined_reference(&mut self, ref_id: String) {
1384 self.defined_references.insert(ref_id);
1385 }
1386
1387 pub fn has_defined_reference(&self, ref_id: &str) -> bool {
1389 self.defined_references.contains(ref_id)
1390 }
1391
1392 pub fn hash_matches(&self, hash: &str) -> bool {
1394 self.content_hash == hash
1395 }
1396
1397 pub fn heading_count(&self) -> usize {
1399 self.headings.len()
1400 }
1401
1402 pub fn reference_link_count(&self) -> usize {
1404 self.reference_links.len()
1405 }
1406}
1407
1408#[cfg(test)]
1409mod tests {
1410 use super::*;
1411
1412 #[test]
1413 fn test_workspace_index_basic() {
1414 let mut index = WorkspaceIndex::new();
1415 assert_eq!(index.file_count(), 0);
1416 assert_eq!(index.version(), 0);
1417
1418 let mut file_index = FileIndex::with_hash("abc123".to_string());
1419 file_index.add_heading(HeadingIndex {
1420 text: "Installation".to_string(),
1421 auto_anchor: "installation".to_string(),
1422 custom_anchor: None,
1423 line: 1,
1424 text_lines: 1,
1425 is_setext: false,
1426 });
1427
1428 index.insert_file(PathBuf::from("docs/install.md"), file_index);
1429 assert_eq!(index.file_count(), 1);
1430 assert_eq!(index.version(), 1);
1431
1432 assert!(index.contains_file(Path::new("docs/install.md")));
1433 assert!(!index.contains_file(Path::new("docs/other.md")));
1434 }
1435
1436 #[test]
1437 fn test_vulnerable_anchors() {
1438 let mut index = WorkspaceIndex::new();
1439
1440 let mut file1 = FileIndex::new();
1442 file1.add_heading(HeadingIndex {
1443 text: "Getting Started".to_string(),
1444 auto_anchor: "getting-started".to_string(),
1445 custom_anchor: None,
1446 line: 1,
1447 text_lines: 1,
1448 is_setext: false,
1449 });
1450 index.insert_file(PathBuf::from("docs/guide.md"), file1);
1451
1452 let mut file2 = FileIndex::new();
1454 file2.add_heading(HeadingIndex {
1455 text: "Installation".to_string(),
1456 auto_anchor: "installation".to_string(),
1457 custom_anchor: Some("install".to_string()),
1458 line: 1,
1459 text_lines: 1,
1460 is_setext: false,
1461 });
1462 index.insert_file(PathBuf::from("docs/install.md"), file2);
1463
1464 let vulnerable = index.get_vulnerable_anchors();
1465 assert_eq!(vulnerable.len(), 1);
1466 assert!(vulnerable.contains_key("getting-started"));
1467 assert!(!vulnerable.contains_key("installation"));
1468
1469 let anchors = vulnerable.get("getting-started").unwrap();
1470 assert_eq!(anchors.len(), 1);
1471 assert_eq!(anchors[0].file, PathBuf::from("docs/guide.md"));
1472 assert_eq!(anchors[0].text, "Getting Started");
1473 }
1474
1475 #[test]
1476 fn test_vulnerable_anchors_multiple_files_same_anchor() {
1477 let mut index = WorkspaceIndex::new();
1480
1481 let mut file1 = FileIndex::new();
1483 file1.add_heading(HeadingIndex {
1484 text: "Installation".to_string(),
1485 auto_anchor: "installation".to_string(),
1486 custom_anchor: None,
1487 line: 1,
1488 text_lines: 1,
1489 is_setext: false,
1490 });
1491 index.insert_file(PathBuf::from("docs/en/guide.md"), file1);
1492
1493 let mut file2 = FileIndex::new();
1495 file2.add_heading(HeadingIndex {
1496 text: "Installation".to_string(),
1497 auto_anchor: "installation".to_string(),
1498 custom_anchor: None,
1499 line: 5,
1500 text_lines: 1,
1501 is_setext: false,
1502 });
1503 index.insert_file(PathBuf::from("docs/fr/guide.md"), file2);
1504
1505 let mut file3 = FileIndex::new();
1507 file3.add_heading(HeadingIndex {
1508 text: "Installation".to_string(),
1509 auto_anchor: "installation".to_string(),
1510 custom_anchor: Some("install".to_string()),
1511 line: 10,
1512 text_lines: 1,
1513 is_setext: false,
1514 });
1515 index.insert_file(PathBuf::from("docs/de/guide.md"), file3);
1516
1517 let vulnerable = index.get_vulnerable_anchors();
1518 assert_eq!(vulnerable.len(), 1); assert!(vulnerable.contains_key("installation"));
1520
1521 let anchors = vulnerable.get("installation").unwrap();
1522 assert_eq!(anchors.len(), 2, "Should collect both vulnerable anchors");
1524
1525 let files: std::collections::HashSet<_> = anchors.iter().map(|a| &a.file).collect();
1527 assert!(files.contains(&PathBuf::from("docs/en/guide.md")));
1528 assert!(files.contains(&PathBuf::from("docs/fr/guide.md")));
1529 }
1530
1531 #[test]
1532 fn test_file_index_hash() {
1533 let index = FileIndex::with_hash("hash123".to_string());
1534 assert!(index.hash_matches("hash123"));
1535 assert!(!index.hash_matches("other"));
1536 }
1537
1538 #[test]
1539 fn test_version_increment() {
1540 let mut index = WorkspaceIndex::new();
1541 assert_eq!(index.version(), 0);
1542
1543 index.insert_file(PathBuf::from("a.md"), FileIndex::new());
1544 assert_eq!(index.version(), 1);
1545
1546 index.insert_file(PathBuf::from("b.md"), FileIndex::new());
1547 assert_eq!(index.version(), 2);
1548
1549 index.remove_file(Path::new("a.md"));
1550 assert_eq!(index.version(), 3);
1551
1552 index.remove_file(Path::new("nonexistent.md"));
1554 assert_eq!(index.version(), 3);
1555 }
1556
1557 #[test]
1558 fn test_files_sorted_is_path_ordered() {
1559 let mut index = WorkspaceIndex::new();
1560 for name in ["docs/zebra.md", "docs/apple.md", "docs/mango.md"] {
1562 index.update_file(Path::new(name), FileIndex::new());
1563 }
1564
1565 let paths: Vec<&Path> = index.files_sorted().into_iter().map(|(p, _)| p).collect();
1566 assert_eq!(
1567 paths,
1568 vec![
1569 Path::new("docs/apple.md"),
1570 Path::new("docs/mango.md"),
1571 Path::new("docs/zebra.md"),
1572 ],
1573 "files_sorted() must return entries ordered by path"
1574 );
1575 }
1576
1577 #[test]
1582 fn test_add_cross_file_link_keeps_the_destination_as_written() {
1583 let as_written = CrossFileLinkIndex {
1584 target_path: "other.md?raw=true".to_string(),
1585 fragment: "missing".to_string(),
1586 line: 3,
1587 column: 1,
1588 origin: LinkOrigin::Body,
1589 };
1590 let file_named = CrossFileLinkIndex {
1591 target_path: "other.md".to_string(),
1592 fragment: "missing".to_string(),
1593 line: 3,
1594 column: 9,
1595 origin: LinkOrigin::Body,
1596 };
1597
1598 for (first, second) in [
1599 (as_written.clone(), file_named.clone()),
1600 (file_named.clone(), as_written.clone()),
1601 ] {
1602 let mut index = FileIndex::new();
1603 index.add_cross_file_link(first);
1604 index.add_cross_file_link(second);
1605
1606 assert_eq!(
1607 index.cross_file_links.len(),
1608 1,
1609 "one link is one entry, got: {:?}",
1610 index.cross_file_links
1611 );
1612 assert_eq!(index.cross_file_links[0].target_path, "other.md?raw=true");
1613 }
1614 }
1615
1616 #[test]
1619 fn test_add_cross_file_link_keeps_distinct_targets() {
1620 let mut index = FileIndex::new();
1621 for target in ["one.md", "two.md"] {
1622 index.add_cross_file_link(CrossFileLinkIndex {
1623 target_path: target.to_string(),
1624 fragment: "missing".to_string(),
1625 line: 3,
1626 column: 1,
1627 origin: LinkOrigin::Body,
1628 });
1629 }
1630 assert_eq!(index.cross_file_links.len(), 2);
1631 }
1632
1633 #[test]
1641 fn test_add_cross_file_link_collapses_one_line_asking_one_file_once() {
1642 let link = |target: &str, fragment: &str, line: usize| CrossFileLinkIndex {
1643 target_path: target.to_string(),
1644 fragment: fragment.to_string(),
1645 line,
1646 column: 1,
1647 origin: LinkOrigin::Body,
1648 };
1649
1650 let mut index = FileIndex::new();
1651 index.add_cross_file_link(link("target.md?raw=true", "missing", 3));
1652 index.add_cross_file_link(link("target.md?plain=1", "missing", 3));
1653 assert_eq!(
1654 index.cross_file_links.len(),
1655 1,
1656 "one file, one fragment, one line is one entry, got: {:?}",
1657 index.cross_file_links
1658 );
1659
1660 index.add_cross_file_link(link("target.md", "other", 3));
1661 index.add_cross_file_link(link("target.md", "missing", 4));
1662 assert_eq!(index.cross_file_links.len(), 3);
1663 }
1664
1665 #[test]
1671 fn test_reverse_deps_ignore_a_query_string_on_the_destination() {
1672 let mut index = WorkspaceIndex::new();
1673
1674 let mut file_a = FileIndex::new();
1675 file_a.add_cross_file_link(CrossFileLinkIndex {
1676 target_path: "b.md?raw=true".to_string(),
1677 fragment: "section".to_string(),
1678 line: 10,
1679 column: 5,
1680 origin: LinkOrigin::Body,
1681 });
1682 index.update_file(Path::new("docs/a.md"), file_a);
1683
1684 assert_eq!(
1685 index.get_dependents(Path::new("docs/b.md")),
1686 vec![PathBuf::from("docs/a.md")],
1687 "editing docs/b.md must re-lint the file linking to it"
1688 );
1689
1690 index.update_file(Path::new("docs/a.md"), FileIndex::new());
1693 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1694 }
1695
1696 #[test]
1697 fn test_reverse_deps_basic() {
1698 let mut index = WorkspaceIndex::new();
1699
1700 let mut file_a = FileIndex::new();
1702 file_a.add_cross_file_link(CrossFileLinkIndex {
1703 target_path: "b.md".to_string(),
1704 fragment: "section".to_string(),
1705 line: 10,
1706 column: 5,
1707 origin: LinkOrigin::Body,
1708 });
1709 index.update_file(Path::new("docs/a.md"), file_a);
1710
1711 let dependents = index.get_dependents(Path::new("docs/b.md"));
1713 assert_eq!(dependents.len(), 1);
1714 assert_eq!(dependents[0], PathBuf::from("docs/a.md"));
1715
1716 let a_dependents = index.get_dependents(Path::new("docs/a.md"));
1718 assert!(a_dependents.is_empty());
1719 }
1720
1721 #[test]
1722 fn test_reverse_deps_multiple() {
1723 let mut index = WorkspaceIndex::new();
1724
1725 let mut file_a = FileIndex::new();
1727 file_a.add_cross_file_link(CrossFileLinkIndex {
1728 target_path: "../b.md".to_string(),
1729 fragment: "".to_string(),
1730 line: 1,
1731 column: 1,
1732 origin: LinkOrigin::Body,
1733 });
1734 index.update_file(Path::new("docs/sub/a.md"), file_a);
1735
1736 let mut file_c = FileIndex::new();
1737 file_c.add_cross_file_link(CrossFileLinkIndex {
1738 target_path: "b.md".to_string(),
1739 fragment: "".to_string(),
1740 line: 1,
1741 column: 1,
1742 origin: LinkOrigin::Body,
1743 });
1744 index.update_file(Path::new("docs/c.md"), file_c);
1745
1746 let dependents = index.get_dependents(Path::new("docs/b.md"));
1748 assert_eq!(dependents.len(), 2);
1749 assert!(dependents.contains(&PathBuf::from("docs/sub/a.md")));
1750 assert!(dependents.contains(&PathBuf::from("docs/c.md")));
1751 }
1752
1753 #[test]
1754 fn test_reverse_deps_update_clears_old() {
1755 let mut index = WorkspaceIndex::new();
1756
1757 let mut file_a = FileIndex::new();
1759 file_a.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/a.md"), file_a);
1767
1768 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1770
1771 let mut file_a_updated = FileIndex::new();
1773 file_a_updated.add_cross_file_link(CrossFileLinkIndex {
1774 target_path: "c.md".to_string(),
1775 fragment: "".to_string(),
1776 line: 1,
1777 column: 1,
1778 origin: LinkOrigin::Body,
1779 });
1780 index.update_file(Path::new("docs/a.md"), file_a_updated);
1781
1782 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1784
1785 let c_deps = index.get_dependents(Path::new("docs/c.md"));
1787 assert_eq!(c_deps.len(), 1);
1788 assert_eq!(c_deps[0], PathBuf::from("docs/a.md"));
1789 }
1790
1791 #[test]
1792 fn test_reverse_deps_remove_file() {
1793 let mut index = WorkspaceIndex::new();
1794
1795 let mut file_a = FileIndex::new();
1797 file_a.add_cross_file_link(CrossFileLinkIndex {
1798 target_path: "b.md".to_string(),
1799 fragment: "".to_string(),
1800 line: 1,
1801 column: 1,
1802 origin: LinkOrigin::Body,
1803 });
1804 index.update_file(Path::new("docs/a.md"), file_a);
1805
1806 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1808
1809 index.remove_file(Path::new("docs/a.md"));
1811
1812 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1814 }
1815
1816 #[test]
1817 fn test_normalize_path() {
1818 let path = Path::new("docs/sub/../other.md");
1820 let normalized = normalize_relative_path(path);
1821 assert_eq!(normalized, PathBuf::from("docs/other.md"));
1822
1823 let path2 = Path::new("docs/./other.md");
1825 let normalized2 = normalize_relative_path(path2);
1826 assert_eq!(normalized2, PathBuf::from("docs/other.md"));
1827
1828 let path3 = Path::new("a/b/c/../../d.md");
1830 let normalized3 = normalize_relative_path(path3);
1831 assert_eq!(normalized3, PathBuf::from("a/d.md"));
1832 }
1833
1834 #[test]
1839 fn normalize_keeps_a_traversal_that_leaves_its_own_root() {
1840 assert_eq!(
1841 normalize_relative_path(Path::new("../notes.md")),
1842 PathBuf::from("../notes.md")
1843 );
1844 assert_eq!(
1845 normalize_relative_path(Path::new("docs/../../notes.md")),
1846 PathBuf::from("../notes.md")
1847 );
1848 assert_eq!(
1849 normalize_relative_path(Path::new("../../a/./b/../notes.md")),
1850 PathBuf::from("../../a/notes.md")
1851 );
1852 }
1853
1854 #[test]
1857 fn normalize_stops_a_traversal_at_a_root() {
1858 let root = if cfg!(windows) { "C:\\" } else { "/" };
1859 assert_eq!(
1860 normalize_relative_path(&Path::new(root).join("..").join("notes.md")),
1861 Path::new(root).join("notes.md")
1862 );
1863 }
1864
1865 #[test]
1866 fn test_clear_clears_reverse_deps() {
1867 let mut index = WorkspaceIndex::new();
1868
1869 let mut file_a = FileIndex::new();
1871 file_a.add_cross_file_link(CrossFileLinkIndex {
1872 target_path: "b.md".to_string(),
1873 fragment: "".to_string(),
1874 line: 1,
1875 column: 1,
1876 origin: LinkOrigin::Body,
1877 });
1878 index.update_file(Path::new("docs/a.md"), file_a);
1879
1880 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1882
1883 index.clear();
1885
1886 assert_eq!(index.file_count(), 0);
1888 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1889 }
1890
1891 #[test]
1892 fn test_is_file_stale() {
1893 let mut index = WorkspaceIndex::new();
1894
1895 assert!(index.is_file_stale(Path::new("nonexistent.md"), "hash123"));
1897
1898 let file_index = FileIndex::with_hash("hash123".to_string());
1900 index.insert_file(PathBuf::from("docs/test.md"), file_index);
1901
1902 assert!(!index.is_file_stale(Path::new("docs/test.md"), "hash123"));
1904
1905 assert!(index.is_file_stale(Path::new("docs/test.md"), "different_hash"));
1907 }
1908
1909 #[cfg(feature = "native")]
1910 #[test]
1911 fn test_cache_roundtrip() {
1912 use std::fs;
1913
1914 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_roundtrip");
1916 let _ = fs::remove_dir_all(&temp_dir);
1917 fs::create_dir_all(&temp_dir).unwrap();
1918
1919 let mut index = WorkspaceIndex::new();
1921
1922 let mut file1 = FileIndex::with_hash("abc123".to_string());
1923 file1.add_heading(HeadingIndex {
1924 text: "Test Heading".to_string(),
1925 auto_anchor: "test-heading".to_string(),
1926 custom_anchor: Some("test".to_string()),
1927 line: 1,
1928 text_lines: 1,
1929 is_setext: false,
1930 });
1931 file1.add_cross_file_link(CrossFileLinkIndex {
1932 target_path: "./other.md".to_string(),
1933 fragment: "section".to_string(),
1934 line: 5,
1935 column: 3,
1936 origin: LinkOrigin::Body,
1937 });
1938 index.update_file(Path::new("docs/file1.md"), file1);
1939
1940 let mut file2 = FileIndex::with_hash("def456".to_string());
1941 file2.add_heading(HeadingIndex {
1942 text: "Another Heading".to_string(),
1943 auto_anchor: "another-heading".to_string(),
1944 custom_anchor: None,
1945 line: 1,
1946 text_lines: 1,
1947 is_setext: false,
1948 });
1949 index.update_file(Path::new("docs/other.md"), file2);
1950
1951 index.save_to_cache(&temp_dir).expect("Failed to save cache");
1953
1954 assert!(temp_dir.join("workspace_index.bin").exists());
1956
1957 let loaded = WorkspaceIndex::load_from_cache(&temp_dir).expect("Failed to load cache");
1959
1960 assert_eq!(loaded.file_count(), 2);
1962 assert!(loaded.contains_file(Path::new("docs/file1.md")));
1963 assert!(loaded.contains_file(Path::new("docs/other.md")));
1964
1965 let file1_loaded = loaded.get_file(Path::new("docs/file1.md")).unwrap();
1967 assert_eq!(file1_loaded.content_hash, "abc123");
1968 assert_eq!(file1_loaded.headings.len(), 1);
1969 assert_eq!(file1_loaded.headings[0].text, "Test Heading");
1970 assert_eq!(file1_loaded.headings[0].custom_anchor, Some("test".to_string()));
1971 assert_eq!(file1_loaded.cross_file_links.len(), 1);
1972 assert_eq!(file1_loaded.cross_file_links[0].target_path, "./other.md");
1973
1974 let dependents = loaded.get_dependents(Path::new("docs/other.md"));
1976 assert_eq!(dependents.len(), 1);
1977 assert_eq!(dependents[0], PathBuf::from("docs/file1.md"));
1978
1979 let _ = fs::remove_dir_all(&temp_dir);
1981 }
1982
1983 #[cfg(feature = "native")]
1984 #[test]
1985 fn test_cache_missing_file() {
1986 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_missing");
1987 let _ = std::fs::remove_dir_all(&temp_dir);
1988
1989 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1991 assert!(result.is_none());
1992 }
1993
1994 #[cfg(feature = "native")]
1995 #[test]
1996 fn test_cache_corrupted_file() {
1997 use std::fs;
1998
1999 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_corrupted");
2000 let _ = fs::remove_dir_all(&temp_dir);
2001 fs::create_dir_all(&temp_dir).unwrap();
2002
2003 fs::write(temp_dir.join("workspace_index.bin"), b"bad").unwrap();
2005
2006 let result = WorkspaceIndex::load_from_cache(&temp_dir);
2008 assert!(result.is_none());
2009
2010 assert!(!temp_dir.join("workspace_index.bin").exists());
2012
2013 let _ = fs::remove_dir_all(&temp_dir);
2015 }
2016
2017 #[cfg(feature = "native")]
2018 #[test]
2019 fn test_cache_invalid_magic() {
2020 use std::fs;
2021
2022 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_invalid_magic");
2023 let _ = fs::remove_dir_all(&temp_dir);
2024 fs::create_dir_all(&temp_dir).unwrap();
2025
2026 let mut data = Vec::new();
2028 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();
2032
2033 let result = WorkspaceIndex::load_from_cache(&temp_dir);
2035 assert!(result.is_none());
2036
2037 assert!(!temp_dir.join("workspace_index.bin").exists());
2039
2040 let _ = fs::remove_dir_all(&temp_dir);
2042 }
2043
2044 #[cfg(feature = "native")]
2045 #[test]
2046 fn test_cache_version_mismatch() {
2047 use std::fs;
2048
2049 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_version_mismatch");
2050 let _ = fs::remove_dir_all(&temp_dir);
2051 fs::create_dir_all(&temp_dir).unwrap();
2052
2053 let mut data = Vec::new();
2055 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();
2059
2060 let result = WorkspaceIndex::load_from_cache(&temp_dir);
2062 assert!(result.is_none());
2063
2064 assert!(!temp_dir.join("workspace_index.bin").exists());
2066
2067 let _ = fs::remove_dir_all(&temp_dir);
2069 }
2070
2071 #[cfg(feature = "native")]
2072 #[test]
2073 fn test_cache_atomic_write() {
2074 use std::fs;
2075
2076 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_atomic");
2078 let _ = fs::remove_dir_all(&temp_dir);
2079 fs::create_dir_all(&temp_dir).unwrap();
2080
2081 let index = WorkspaceIndex::new();
2082 index.save_to_cache(&temp_dir).expect("Failed to save");
2083
2084 let entries: Vec<_> = fs::read_dir(&temp_dir).unwrap().collect();
2086 assert_eq!(entries.len(), 1);
2087 assert!(temp_dir.join("workspace_index.bin").exists());
2088
2089 let _ = fs::remove_dir_all(&temp_dir);
2091 }
2092
2093 #[test]
2094 fn test_has_anchor_auto_generated() {
2095 let mut file_index = FileIndex::new();
2096 file_index.add_heading(HeadingIndex {
2097 text: "Installation Guide".to_string(),
2098 auto_anchor: "installation-guide".to_string(),
2099 custom_anchor: None,
2100 line: 1,
2101 text_lines: 1,
2102 is_setext: false,
2103 });
2104
2105 assert!(file_index.has_anchor("installation-guide"));
2107
2108 assert!(file_index.has_anchor("Installation-Guide"));
2110 assert!(file_index.has_anchor("INSTALLATION-GUIDE"));
2111
2112 assert!(!file_index.has_anchor("nonexistent"));
2114 }
2115
2116 #[test]
2117 fn test_has_anchor_custom() {
2118 let mut file_index = FileIndex::new();
2119 file_index.add_heading(HeadingIndex {
2120 text: "Installation Guide".to_string(),
2121 auto_anchor: "installation-guide".to_string(),
2122 custom_anchor: Some("install".to_string()),
2123 line: 1,
2124 text_lines: 1,
2125 is_setext: false,
2126 });
2127
2128 assert!(file_index.has_anchor("installation-guide"));
2130
2131 assert!(file_index.has_anchor("install"));
2133 assert!(file_index.has_anchor("Install")); assert!(!file_index.has_anchor("nonexistent"));
2137 }
2138
2139 #[test]
2140 fn test_get_heading_by_anchor() {
2141 let mut file_index = FileIndex::new();
2142 file_index.add_heading(HeadingIndex {
2143 text: "Installation Guide".to_string(),
2144 auto_anchor: "installation-guide".to_string(),
2145 custom_anchor: Some("install".to_string()),
2146 line: 10,
2147 text_lines: 1,
2148 is_setext: false,
2149 });
2150 file_index.add_heading(HeadingIndex {
2151 text: "Configuration".to_string(),
2152 auto_anchor: "configuration".to_string(),
2153 custom_anchor: None,
2154 line: 20,
2155 text_lines: 1,
2156 is_setext: false,
2157 });
2158
2159 let heading = file_index.get_heading_by_anchor("installation-guide");
2161 assert!(heading.is_some());
2162 assert_eq!(heading.unwrap().text, "Installation Guide");
2163 assert_eq!(heading.unwrap().line, 10);
2164
2165 let heading = file_index.get_heading_by_anchor("install");
2167 assert!(heading.is_some());
2168 assert_eq!(heading.unwrap().text, "Installation Guide");
2169
2170 let heading = file_index.get_heading_by_anchor("configuration");
2172 assert!(heading.is_some());
2173 assert_eq!(heading.unwrap().text, "Configuration");
2174 assert_eq!(heading.unwrap().line, 20);
2175
2176 assert!(file_index.get_heading_by_anchor("nonexistent").is_none());
2178 }
2179
2180 #[test]
2181 fn test_anchor_lookup_many_headings() {
2182 let mut file_index = FileIndex::new();
2184
2185 for i in 0..100 {
2187 file_index.add_heading(HeadingIndex {
2188 text: format!("Heading {i}"),
2189 auto_anchor: format!("heading-{i}"),
2190 custom_anchor: Some(format!("h{i}")),
2191 line: i + 1,
2192 text_lines: 1,
2193 is_setext: false,
2194 });
2195 }
2196
2197 for i in 0..100 {
2199 assert!(file_index.has_anchor(&format!("heading-{i}")));
2200 assert!(file_index.has_anchor(&format!("h{i}")));
2201
2202 let heading = file_index.get_heading_by_anchor(&format!("heading-{i}"));
2203 assert!(heading.is_some());
2204 assert_eq!(heading.unwrap().line, i + 1);
2205 }
2206 }
2207
2208 #[test]
2213 fn test_extract_cross_file_links_basic() {
2214 use crate::config::MarkdownFlavor;
2215
2216 let content = "# Test\n\nSee [link](./other.md) for info.\n";
2217 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2218 let links = extract_cross_file_links(&ctx).relative;
2219
2220 assert_eq!(links.len(), 1);
2221 assert_eq!(links[0].target_path, "./other.md");
2222 assert_eq!(links[0].fragment, "");
2223 assert_eq!(links[0].line, 3);
2224 assert_eq!(links[0].column, 12);
2226 }
2227
2228 #[test]
2229 fn test_extract_cross_file_links_with_fragment() {
2230 use crate::config::MarkdownFlavor;
2231
2232 let content = "Check [guide](./guide.md#install) here.\n";
2233 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2234 let links = extract_cross_file_links(&ctx).relative;
2235
2236 assert_eq!(links.len(), 1);
2237 assert_eq!(links[0].target_path, "./guide.md");
2238 assert_eq!(links[0].fragment, "install");
2239 assert_eq!(links[0].line, 1);
2240 assert_eq!(links[0].column, 15);
2242 }
2243
2244 #[test]
2245 fn test_extract_cross_file_links_multiple_on_same_line() {
2246 use crate::config::MarkdownFlavor;
2247
2248 let content = "See [a](a.md) and [b](b.md) here.\n";
2249 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2250 let links = extract_cross_file_links(&ctx).relative;
2251
2252 assert_eq!(links.len(), 2);
2253
2254 assert_eq!(links[0].target_path, "a.md");
2255 assert_eq!(links[0].line, 1);
2256 assert_eq!(links[0].column, 9);
2258
2259 assert_eq!(links[1].target_path, "b.md");
2260 assert_eq!(links[1].line, 1);
2261 assert_eq!(links[1].column, 23);
2263 }
2264
2265 #[test]
2266 fn test_extract_cross_file_links_angle_brackets() {
2267 use crate::config::MarkdownFlavor;
2268
2269 let content = "See [link](<path/with (parens).md>) here.\n";
2270 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2271 let links = extract_cross_file_links(&ctx).relative;
2272
2273 assert_eq!(links.len(), 1);
2274 assert_eq!(links[0].target_path, "path/with (parens).md");
2275 assert_eq!(links[0].line, 1);
2276 assert_eq!(links[0].column, 13);
2278 }
2279
2280 #[test]
2281 fn test_extract_cross_file_links_bare_parens_in_destination() {
2282 use crate::config::MarkdownFlavor;
2283
2284 let content = "See [link](docs/file(inner).md) and [plain](docs/other.md).\n";
2288 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2289 let links = extract_cross_file_links(&ctx).relative;
2290
2291 let targets: Vec<&str> = links.iter().map(|l| l.target_path.as_str()).collect();
2292 assert_eq!(targets, vec!["docs/file(inner).md", "docs/other.md"]);
2293 assert_eq!(links[0].column, 12);
2295 }
2296
2297 #[test]
2298 fn test_extract_cross_file_links_skips_external() {
2299 use crate::config::MarkdownFlavor;
2300
2301 let content = r#"
2302[external](https://example.com)
2303[mailto](mailto:test@example.com)
2304[local](./local.md)
2305[fragment](#section)
2306[absolute](/docs/page.md)
2307"#;
2308 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2309 let extracted = extract_cross_file_links(&ctx);
2310
2311 assert_eq!(extracted.relative.len(), 1);
2313 assert_eq!(extracted.relative[0].target_path, "./local.md");
2314 assert_eq!(extracted.root_relative.len(), 1);
2316 assert_eq!(extracted.root_relative[0].target_path, "docs/page.md");
2317 }
2318
2319 #[test]
2320 fn test_extract_cross_file_links_root_relative() {
2321 use crate::config::MarkdownFlavor;
2322
2323 let content = "[a](/guide.md#install)\n[b](/../escape.md)\n[c](//host/x.md)\n[d](/img/pic.png)\n";
2327 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2328 let extracted = extract_cross_file_links(&ctx);
2329
2330 assert!(extracted.relative.is_empty(), "no directory-relative links here");
2331 assert_eq!(
2332 extracted
2333 .root_relative
2334 .iter()
2335 .map(|l| (l.target_path.as_str(), l.fragment.as_str()))
2336 .collect::<Vec<_>>(),
2337 vec![("guide.md", "install")],
2338 "only the safe root-relative markdown link is captured"
2339 );
2340 }
2341
2342 #[test]
2343 fn test_extract_cross_file_links_skips_non_markdown() {
2344 use crate::config::MarkdownFlavor;
2345
2346 let content = r#"
2347[image](./photo.png)
2348[doc](./readme.md)
2349[pdf](./document.pdf)
2350"#;
2351 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2352 let links = extract_cross_file_links(&ctx).relative;
2353
2354 assert_eq!(links.len(), 1);
2356 assert_eq!(links[0].target_path, "./readme.md");
2357 }
2358
2359 #[test]
2360 fn test_extract_cross_file_links_skips_code_spans() {
2361 use crate::config::MarkdownFlavor;
2362
2363 let content = "Normal [link](./file.md) and `[code](./ignored.md)` here.\n";
2364 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2365 let links = extract_cross_file_links(&ctx).relative;
2366
2367 assert_eq!(links.len(), 1);
2369 assert_eq!(links[0].target_path, "./file.md");
2370 }
2371
2372 #[test]
2373 fn test_extract_cross_file_links_with_query_params() {
2374 use crate::config::MarkdownFlavor;
2375
2376 let content = "See [doc](./file.md?raw=true) here.\n";
2377 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2378 let links = extract_cross_file_links(&ctx).relative;
2379
2380 assert_eq!(links.len(), 1);
2381 assert_eq!(links[0].target_path, "./file.md");
2383 }
2384
2385 #[test]
2386 fn test_extract_cross_file_links_empty_content() {
2387 use crate::config::MarkdownFlavor;
2388
2389 let content = "";
2390 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2391 let links = extract_cross_file_links(&ctx).relative;
2392
2393 assert!(links.is_empty());
2394 }
2395
2396 #[test]
2397 fn test_extract_cross_file_links_no_links() {
2398 use crate::config::MarkdownFlavor;
2399
2400 let content = "# Just a heading\n\nSome text without links.\n";
2401 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2402 let links = extract_cross_file_links(&ctx).relative;
2403
2404 assert!(links.is_empty());
2405 }
2406
2407 #[test]
2408 fn test_extract_cross_file_links_position_accuracy_issue_234() {
2409 use crate::config::MarkdownFlavor;
2412
2413 let content = r#"# Test Document
2414
2415Here is a [broken link](nonexistent-file.md) that should trigger MD057.
2416
2417And another [link](also-missing.md) on this line.
2418"#;
2419 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2420 let links = extract_cross_file_links(&ctx).relative;
2421
2422 assert_eq!(links.len(), 2);
2423
2424 assert_eq!(links[0].target_path, "nonexistent-file.md");
2426 assert_eq!(links[0].line, 3);
2427 assert_eq!(links[0].column, 25);
2428
2429 assert_eq!(links[1].target_path, "also-missing.md");
2431 assert_eq!(links[1].line, 5);
2432 assert_eq!(links[1].column, 20);
2433 }
2434
2435 #[test]
2436 fn link_path_part_drops_the_query_and_fragment_and_decodes() {
2437 assert_eq!(link_path_part("guide%20one.md?raw=true#real"), "guide one.md");
2438 assert_eq!(link_path_part("guide%2Emd#real"), "guide.md");
2439 assert_eq!(link_path_part("#only-a-fragment"), "");
2440 assert_eq!(link_path_part("?only=query#x"), "");
2441 }
2442
2443 #[cfg(feature = "native")]
2444 #[test]
2445 fn url_encode_path_is_undone_by_link_path_part() {
2446 for path in [
2447 "plain/guide.md",
2448 "my notes.md",
2449 "50%.md",
2450 "a(b)<c>.md",
2451 "what?.md",
2452 "c#.md",
2453 "back\\slash.md",
2454 "tab\there.md",
2455 "日本 語/é.md",
2456 ] {
2457 let encoded = url_encode_path(path);
2458 assert!(
2459 !encoded.contains([' ', '(', ')', '<', '>', '?', '#', '\\', '\t']),
2460 "{encoded}"
2461 );
2462 assert_eq!(link_path_part(&encoded), path, "{encoded}");
2463 }
2464 assert_eq!(url_encode_path("日本/guide.md"), "日本/guide.md");
2465 assert_eq!(url_encode_path("my notes.md"), "my%20notes.md");
2466 }
2467
2468 #[test]
2469 fn workspace_key_spells_every_path_inside_the_working_directory_relatively() {
2470 let cwd = std::env::current_dir().unwrap();
2471 assert_eq!(workspace_key(Path::new("./docs/a.md")), PathBuf::from("docs/a.md"));
2472 assert_eq!(
2473 workspace_key(&cwd.join("docs/../docs/a.md")),
2474 PathBuf::from("docs/a.md")
2475 );
2476 assert_eq!(
2477 workspace_key(&cwd.canonicalize().unwrap().join("docs/a.md")),
2478 PathBuf::from("docs/a.md")
2479 );
2480 let outside = cwd.parent().unwrap().join("sibling.md");
2483 assert_eq!(workspace_key(Path::new("../sibling.md")), workspace_key(&outside));
2484 assert!(workspace_key(&outside).is_absolute());
2485 }
2486
2487 #[cfg(unix)]
2488 #[test]
2489 fn get_resolved_file_finds_a_canonical_key_through_a_symlinked_spelling() {
2490 let dir = tempfile::tempdir().unwrap();
2491 let real = dir.path().canonicalize().unwrap().join("real");
2492 std::fs::create_dir(&real).unwrap();
2493 std::fs::write(real.join("a.md"), "# A\n").unwrap();
2494 let alias = dir.path().canonicalize().unwrap().join("alias");
2495 std::os::unix::fs::symlink(&real, &alias).unwrap();
2496
2497 let mut index = WorkspaceIndex::new();
2498 let mut file_index = FileIndex::new();
2499 file_index.add_heading(HeadingIndex {
2500 text: "A".to_string(),
2501 auto_anchor: "a".to_string(),
2502 custom_anchor: None,
2503 line: 1,
2504 text_lines: 1,
2505 is_setext: false,
2506 });
2507 index.insert_file(real.join("a.md"), file_index);
2508
2509 assert!(index.get_resolved_file(&alias.join("a.md")).is_some());
2510 assert!(index.get_resolved_file(&alias.join("./sub/../a.md")).is_some());
2511 assert!(index.get_resolved_file(&alias.join("b.md")).is_none());
2512 }
2513}