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 mut processed_lines = HashSet::new();
266
267 for link in ctx.links() {
268 let line_idx = link.line - 1;
269 if line_idx >= ctx.lines.len() {
270 continue;
271 }
272
273 if ctx.line_info(link.line).is_some_and(|info| info.in_front_matter) {
277 continue;
278 }
279
280 if !processed_lines.insert(line_idx) {
282 continue;
283 }
284
285 let line_info = &ctx.lines[line_idx];
286 let line = line_info.content(content);
287 if !line.contains("](") {
288 continue;
289 }
290
291 for link_match in LINK_START_REGEX.find_iter(line) {
293 let start_pos = link_match.start();
294 let end_pos = link_match.end();
295
296 let line_start_byte = ctx.line_start_byte(line_idx + 1).unwrap_or(0);
298 let absolute_start_pos = line_start_byte + start_pos;
299
300 if ctx.is_in_code_span_byte(absolute_start_pos) {
302 continue;
303 }
304
305 if let Some(destination) = ctx.indexed_link_destination(link.line, end_pos - 1) {
308 let file_path = content[destination.url.clone()].trim();
309 let fragment = destination.fragment(content).trim_start_matches('#');
310 let url_start = destination.url.start - line_info.byte_offset;
311
312 if let Some(rel) = file_path.strip_prefix('/') {
317 if !rel.starts_with('/')
318 && !Path::new(rel)
319 .components()
320 .any(|c| matches!(c, std::path::Component::ParentDir))
321 {
322 let stripped = strip_query_and_fragment(rel);
323 if is_markdown_file(&url_decode(stripped)) {
324 links.root_relative.push(CrossFileLinkIndex {
325 target_path: stripped.to_string(),
326 fragment: fragment.to_string(),
327 line: link.line,
328 column: byte_to_char_count(line, url_start),
329 origin: LinkOrigin::Body,
330 });
331 }
332 }
333 continue;
334 }
335
336 if file_path.is_empty()
339 || PROTOCOL_DOMAIN_REGEX.is_match(file_path)
340 || file_path.starts_with("www.")
341 || file_path.starts_with('#')
342 || file_path.starts_with("{{")
343 || file_path.starts_with("{%")
344 || file_path.starts_with('~')
345 || file_path.starts_with('@')
346 || (file_path.starts_with('`') && file_path.ends_with('`'))
347 {
348 continue;
349 }
350
351 let file_path = strip_query_and_fragment(file_path);
353
354 if is_markdown_file(&url_decode(file_path)) {
358 links.relative.push(CrossFileLinkIndex {
359 target_path: file_path.to_string(),
360 fragment: fragment.to_string(),
361 line: link.line,
362 column: byte_to_char_count(line, url_start),
363 origin: LinkOrigin::Body,
364 });
365 }
366 }
367 }
368 }
369
370 links
371}
372
373#[cfg(feature = "postcard")]
375const CACHE_MAGIC: &[u8; 4] = b"RWSI";
376
377#[cfg(feature = "postcard")]
413const CACHE_FORMAT_VERSION: u32 = 15;
414
415#[cfg(feature = "postcard")]
417const CACHE_FILE_NAME: &str = "workspace_index.bin";
418
419#[cfg(feature = "postcard")]
423static CACHE_TEMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
424
425#[derive(Debug, Default, Clone, Serialize, Deserialize)]
430pub struct WorkspaceIndex {
431 files: HashMap<PathBuf, FileIndex>,
433 reverse_deps: HashMap<PathBuf, HashSet<PathBuf>>,
436 version: u64,
438 #[serde(skip)]
443 link_target_policy: Option<crate::lint_context::LinkTargetPolicy>,
444}
445
446#[derive(Debug, Clone, Default, Serialize, Deserialize)]
448pub struct FileIndex {
449 pub headings: Vec<HeadingIndex>,
451 pub reference_links: Vec<ReferenceLinkIndex>,
453 pub cross_file_links: Vec<CrossFileLinkIndex>,
455 #[serde(default)]
460 pub root_relative_links: Vec<CrossFileLinkIndex>,
461 #[serde(default)]
466 pub md057_link_targets: Vec<Md057LinkTarget>,
467 pub defined_references: HashSet<String>,
470 pub content_hash: String,
472 anchor_to_heading: HashMap<String, usize>,
475 #[serde(default)]
479 anchor_to_heading_exact: HashMap<String, usize>,
480 html_anchors: HashSet<String>,
483 #[serde(default)]
486 html_anchors_exact: HashSet<String>,
487 attribute_anchors: HashSet<String>,
491 #[serde(default)]
494 attribute_anchors_exact: HashSet<String>,
495 pub file_disabled_rules: HashSet<String>,
498 pub persistent_transitions: Vec<(usize, HashSet<String>, HashSet<String>)>,
501 pub line_disabled_rules: HashMap<usize, HashSet<String>>,
503}
504
505#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
507pub struct HeadingIndex {
508 pub text: String,
510 pub auto_anchor: String,
512 pub custom_anchor: Option<String>,
514 pub line: usize,
516 pub text_lines: usize,
520 #[serde(default)]
522 pub is_setext: bool,
523}
524
525impl HeadingIndex {
526 #[must_use]
528 pub fn first_line(&self) -> usize {
529 self.line + 1 - self.text_lines.max(1)
530 }
531
532 #[must_use]
534 pub fn covers_line(&self, line: usize) -> bool {
535 (self.first_line()..=self.line).contains(&line)
536 }
537}
538
539#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
541pub struct ReferenceLinkIndex {
542 pub reference_id: String,
544 pub line: usize,
546 pub column: usize,
548}
549
550#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
558pub enum LinkOrigin {
559 Body,
561 FrontMatter { field: Option<String> },
570}
571
572#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
574pub struct CrossFileLinkIndex {
575 pub target_path: String,
577 pub fragment: String,
579 pub line: usize,
581 pub column: usize,
583 pub origin: LinkOrigin,
585}
586
587#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
589pub struct Md057LinkTarget {
590 pub target: String,
592 pub origin: LinkOrigin,
594}
595
596pub fn link_target_candidates(source_file: &Path, target_path: &str) -> Vec<PathBuf> {
609 let joined = resolve_target_against(source_file.parent().unwrap_or(Path::new("")), target_path);
610 let base = normalize_relative_path(&joined);
611
612 if base.extension().is_some() {
613 return vec![base];
614 }
615
616 let mut candidates = Vec::with_capacity(crate::discovery::MARKDOWN_EXTENSIONS.len() + 1);
619 for ext in crate::discovery::MARKDOWN_EXTENSIONS {
620 candidates.push(base.with_extension(ext));
621 }
622 candidates.insert(0, base);
623 candidates
624}
625
626static WORKING_DIRECTORY_SPELLINGS: LazyLock<Vec<PathBuf>> = LazyLock::new(|| {
633 let mut spellings = Vec::new();
634 if let Ok(cwd) = std::env::current_dir() {
635 let cwd = normalize_relative_path(&cwd);
636 for spelling in [
637 cwd.canonicalize().ok(),
638 crate::discovery::canonicalize_for_matching(&cwd),
639 ]
640 .into_iter()
641 .flatten()
642 {
643 if spelling != cwd && !spellings.contains(&spelling) {
644 spellings.push(spelling);
645 }
646 }
647 spellings.push(cwd);
648 }
649 spellings
650});
651
652pub fn workspace_key(path: &Path) -> PathBuf {
664 let normalized = normalize_relative_path(path);
665 if normalized.is_absolute() {
666 let inside_working_directory = |absolute: &Path| {
667 WORKING_DIRECTORY_SPELLINGS
668 .iter()
669 .find_map(|cwd| absolute.strip_prefix(cwd).ok().map(Path::to_path_buf))
670 };
671 return inside_working_directory(&normalized)
672 .or_else(|| inside_working_directory(&crate::discovery::resolve_for_matching(&normalized)))
673 .unwrap_or(normalized);
674 }
675 if normalized.starts_with("..")
676 && let Some(cwd) = WORKING_DIRECTORY_SPELLINGS.last()
677 {
678 return workspace_key(&cwd.join(&normalized));
679 }
680 normalized
681}
682
683pub fn absolute_selection_path(path: &Path) -> std::io::Result<PathBuf> {
686 let absolute = std::path::absolute(path)?;
687 let mut selected = PathBuf::new();
688 for component in absolute.components() {
689 if component == std::path::Component::ParentDir {
690 selected = std::fs::canonicalize(&selected)?;
691 selected.pop();
692 } else {
693 selected.push(component.as_os_str());
694 }
695 }
696 Ok(selected)
697}
698
699pub fn normalize_relative_path(path: &Path) -> PathBuf {
710 let mut components: Vec<std::path::Component<'_>> = Vec::new();
711 for component in path.components() {
712 match component {
713 std::path::Component::CurDir => {}
714 std::path::Component::ParentDir => match components.last() {
715 Some(std::path::Component::Normal(_)) => {
716 components.pop();
717 }
718 Some(std::path::Component::RootDir) => {}
719 _ => components.push(component),
720 },
721 c => components.push(c),
722 }
723 }
724 components.iter().collect()
725}
726
727impl CrossFileLinkIndex {
728 pub fn is_navigable(&self) -> bool {
736 matches!(self.origin, LinkOrigin::Body)
737 }
738}
739
740#[derive(Debug, Clone, Serialize, Deserialize)]
742pub struct VulnerableAnchor {
743 pub file: PathBuf,
745 pub line: usize,
747 pub text: String,
749}
750
751impl WorkspaceIndex {
752 pub fn new() -> Self {
754 Self::default()
755 }
756
757 pub fn version(&self) -> u64 {
759 self.version
760 }
761
762 pub fn file_count(&self) -> usize {
764 self.files.len()
765 }
766
767 pub fn contains_file(&self, path: &Path) -> bool {
769 self.files.contains_key(path)
770 }
771
772 pub fn get_file(&self, path: &Path) -> Option<&FileIndex> {
774 self.files.get(path)
775 }
776
777 pub fn get_resolved_file(&self, target: &Path) -> Option<&FileIndex> {
787 let normalized = normalize_relative_path(target);
788 if let Some(index) = self.files.get(&normalized) {
789 return Some(index);
790 }
791 if let Some(index) = self.files.get(&workspace_key(&normalized)) {
792 return Some(index);
793 }
794 let canonical = normalized.canonicalize().ok()?;
797 let matching = crate::discovery::canonicalize_for_matching(&normalized);
798 [Some(canonical), matching]
799 .into_iter()
800 .flatten()
801 .find_map(|path| self.files.get(&path).or_else(|| self.files.get(&workspace_key(&path))))
802 }
803
804 pub fn link_target_policy(&self) -> Option<&crate::lint_context::LinkTargetPolicy> {
806 self.link_target_policy.as_ref()
807 }
808
809 pub fn set_link_target_policy(&mut self, policy: crate::lint_context::LinkTargetPolicy) {
811 self.link_target_policy = Some(policy);
812 }
813
814 pub fn insert_file(&mut self, path: PathBuf, index: FileIndex) {
816 self.files.insert(path, index);
817 self.version = self.version.wrapping_add(1);
818 }
819
820 pub fn remove_file(&mut self, path: &Path) -> Option<FileIndex> {
822 self.clear_reverse_deps_for(path);
824
825 let result = self.files.remove(path);
826 if result.is_some() {
827 self.version = self.version.wrapping_add(1);
828 }
829 result
830 }
831
832 pub fn get_vulnerable_anchors(&self) -> HashMap<String, Vec<VulnerableAnchor>> {
842 let mut vulnerable: HashMap<String, Vec<VulnerableAnchor>> = HashMap::new();
843
844 for (file_path, file_index) in &self.files {
845 for heading in &file_index.headings {
846 if heading.custom_anchor.is_none() && !heading.auto_anchor.is_empty() {
848 let anchor_key = heading.auto_anchor.to_lowercase();
849 vulnerable.entry(anchor_key).or_default().push(VulnerableAnchor {
850 file: file_path.clone(),
851 line: heading.line,
852 text: heading.text.clone(),
853 });
854 }
855 }
856 }
857
858 vulnerable
859 }
860
861 pub fn all_headings(&self) -> impl Iterator<Item = (&Path, &HeadingIndex)> {
863 self.files
864 .iter()
865 .flat_map(|(path, index)| index.headings.iter().map(move |h| (path.as_path(), h)))
866 }
867
868 pub fn files(&self) -> impl Iterator<Item = (&Path, &FileIndex)> {
870 self.files.iter().map(|(p, i)| (p.as_path(), i))
871 }
872
873 pub fn files_sorted(&self) -> Vec<(&Path, &FileIndex)> {
879 let mut entries: Vec<(&Path, &FileIndex)> = self.files.iter().map(|(p, i)| (p.as_path(), i)).collect();
880 entries.sort_by_key(|(a, _)| *a);
881 entries
882 }
883
884 pub fn clear(&mut self) {
886 self.files.clear();
887 self.reverse_deps.clear();
888 self.version = self.version.wrapping_add(1);
889 }
890
891 pub fn update_file(&mut self, path: &Path, index: FileIndex) {
898 self.clear_reverse_deps_as_source(path);
901
902 for link in &index.cross_file_links {
904 let target = self.resolve_target_path(path, &link.target_path);
905 self.reverse_deps.entry(target).or_default().insert(path.to_path_buf());
906 }
907
908 self.files.insert(path.to_path_buf(), index);
909 self.version = self.version.wrapping_add(1);
910 }
911
912 pub fn get_dependents(&self, path: &Path) -> Vec<PathBuf> {
917 self.reverse_deps
918 .get(path)
919 .map(|set| set.iter().cloned().collect())
920 .unwrap_or_default()
921 }
922
923 pub fn is_file_stale(&self, path: &Path, current_hash: &str) -> bool {
927 self.files.get(path).is_none_or(|f| f.content_hash != current_hash)
928 }
929
930 pub fn retain_only(&mut self, current_files: &std::collections::HashSet<PathBuf>) -> usize {
935 let before_count = self.files.len();
936
937 let to_remove: Vec<PathBuf> = self
939 .files
940 .keys()
941 .filter(|path| !current_files.contains(*path))
942 .cloned()
943 .collect();
944
945 for path in &to_remove {
947 self.remove_file(path);
948 }
949
950 before_count - self.files.len()
951 }
952
953 #[cfg(feature = "postcard")]
960 pub fn save_to_cache(&self, cache_dir: &Path) -> std::io::Result<()> {
961 use std::fs;
962 use std::io::Write;
963
964 fs::create_dir_all(cache_dir)?;
966
967 let encoded = postcard::to_allocvec(self)
969 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
970
971 let mut cache_data = Vec::with_capacity(8 + encoded.len());
973 cache_data.extend_from_slice(CACHE_MAGIC);
974 cache_data.extend_from_slice(&CACHE_FORMAT_VERSION.to_le_bytes());
975 cache_data.extend_from_slice(&encoded);
976
977 let final_path = cache_dir.join(CACHE_FILE_NAME);
982 let counter = CACHE_TEMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
983 #[cfg(not(target_arch = "wasm32"))]
984 let temp_path = cache_dir.join(format!("{CACHE_FILE_NAME}.tmp.{}.{counter}", std::process::id()));
985 #[cfg(target_arch = "wasm32")]
986 let temp_path = cache_dir.join(format!("{CACHE_FILE_NAME}.tmp.{counter}"));
987
988 {
990 let mut file = fs::File::create(&temp_path)?;
991 file.write_all(&cache_data)?;
992 file.sync_all()?;
993 }
994
995 fs::rename(&temp_path, &final_path)?;
997
998 log::debug!(
999 "Saved workspace index to cache: {} files, {} bytes (format v{})",
1000 self.files.len(),
1001 cache_data.len(),
1002 CACHE_FORMAT_VERSION
1003 );
1004
1005 Ok(())
1006 }
1007
1008 #[cfg(feature = "postcard")]
1016 pub fn load_from_cache(cache_dir: &Path) -> Option<Self> {
1017 use std::fs;
1018
1019 let path = cache_dir.join(CACHE_FILE_NAME);
1020 let data = fs::read(&path).ok()?;
1021
1022 if data.len() < 8 {
1024 log::warn!("Workspace index cache too small, discarding");
1025 let _ = fs::remove_file(&path);
1026 return None;
1027 }
1028
1029 if &data[0..4] != CACHE_MAGIC {
1031 log::warn!("Workspace index cache has invalid magic header, discarding");
1032 let _ = fs::remove_file(&path);
1033 return None;
1034 }
1035
1036 let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
1038 if version != CACHE_FORMAT_VERSION {
1039 log::info!(
1040 "Workspace index cache format version mismatch (got {version}, expected {CACHE_FORMAT_VERSION}), rebuilding"
1041 );
1042 let _ = fs::remove_file(&path);
1043 return None;
1044 }
1045
1046 match postcard::from_bytes::<Self>(&data[8..]) {
1048 Ok(index) => {
1049 log::debug!(
1050 "Loaded workspace index from cache: {} files (format v{})",
1051 index.files.len(),
1052 version
1053 );
1054 Some(index)
1055 }
1056 Err(e) => {
1057 log::warn!("Failed to deserialize workspace index cache: {e}");
1058 let _ = fs::remove_file(&path);
1059 None
1060 }
1061 }
1062 }
1063
1064 fn clear_reverse_deps_as_source(&mut self, path: &Path) {
1069 let targets: Vec<PathBuf> = match self.files.get(path) {
1076 Some(index) => index
1077 .cross_file_links
1078 .iter()
1079 .map(|link| self.resolve_target_path(path, &link.target_path))
1080 .collect(),
1081 None => return,
1082 };
1083 for target in targets {
1084 if let Some(deps) = self.reverse_deps.get_mut(&target) {
1085 deps.remove(path);
1086 if deps.is_empty() {
1087 self.reverse_deps.remove(&target);
1088 }
1089 }
1090 }
1091 }
1092
1093 fn clear_reverse_deps_for(&mut self, path: &Path) {
1098 self.clear_reverse_deps_as_source(path);
1100
1101 self.reverse_deps.remove(path);
1103 }
1104
1105 fn resolve_target_path(&self, source_file: &Path, relative_target: &str) -> PathBuf {
1111 let source_dir = source_file.parent().unwrap_or(Path::new(""));
1112 link_target_file(source_dir, relative_target)
1113 }
1114}
1115
1116impl FileIndex {
1117 pub fn new() -> Self {
1119 Self::default()
1120 }
1121
1122 pub fn with_hash(content_hash: String) -> Self {
1124 Self {
1125 content_hash,
1126 ..Default::default()
1127 }
1128 }
1129
1130 pub fn extracted_data_differs(&self, other: &Self) -> bool {
1146 let Self {
1150 headings,
1151 reference_links,
1152 cross_file_links,
1153 root_relative_links,
1154 md057_link_targets,
1155 defined_references,
1156 content_hash: _,
1157 anchor_to_heading,
1158 anchor_to_heading_exact,
1159 html_anchors,
1160 html_anchors_exact,
1161 attribute_anchors,
1162 attribute_anchors_exact,
1163 file_disabled_rules,
1164 persistent_transitions,
1165 line_disabled_rules,
1166 } = self;
1167
1168 headings != &other.headings
1169 || reference_links != &other.reference_links
1170 || cross_file_links != &other.cross_file_links
1171 || root_relative_links != &other.root_relative_links
1172 || md057_link_targets != &other.md057_link_targets
1173 || defined_references != &other.defined_references
1174 || anchor_to_heading != &other.anchor_to_heading
1175 || anchor_to_heading_exact != &other.anchor_to_heading_exact
1176 || html_anchors != &other.html_anchors
1177 || html_anchors_exact != &other.html_anchors_exact
1178 || attribute_anchors != &other.attribute_anchors
1179 || attribute_anchors_exact != &other.attribute_anchors_exact
1180 || file_disabled_rules != &other.file_disabled_rules
1181 || persistent_transitions != &other.persistent_transitions
1182 || line_disabled_rules != &other.line_disabled_rules
1183 }
1184
1185 pub fn add_heading(&mut self, heading: HeadingIndex) {
1191 let index = self.headings.len();
1192
1193 self.anchor_to_heading.insert(heading.auto_anchor.to_lowercase(), index);
1196 self.anchor_to_heading_exact.insert(heading.auto_anchor.clone(), index);
1197
1198 if let Some(ref custom) = heading.custom_anchor {
1200 self.anchor_to_heading.insert(custom.to_lowercase(), index);
1201 self.anchor_to_heading_exact.insert(custom.clone(), index);
1202 }
1203
1204 self.headings.push(heading);
1205 }
1206
1207 pub fn add_anchor_alias(&mut self, anchor: &str, heading_index: usize) {
1210 if heading_index < self.headings.len() {
1211 self.anchor_to_heading.insert(anchor.to_lowercase(), heading_index);
1212 self.anchor_to_heading_exact.insert(anchor.to_string(), heading_index);
1213 }
1214 }
1215
1216 pub fn has_anchor(&self, anchor: &str) -> bool {
1227 self.has_anchor_with_case(anchor, true)
1228 }
1229
1230 pub fn has_anchor_with_case(&self, anchor: &str, ignore_case: bool) -> bool {
1239 if self.lookup_anchor(anchor, ignore_case) {
1240 return true;
1241 }
1242
1243 if anchor.contains('%') {
1245 let decoded = url_decode(anchor);
1246 if decoded != anchor {
1247 return self.lookup_anchor(&decoded, ignore_case);
1248 }
1249 }
1250
1251 false
1252 }
1253
1254 fn lookup_anchor(&self, anchor: &str, ignore_case: bool) -> bool {
1257 if ignore_case {
1258 let lower = anchor.to_lowercase();
1259 self.anchor_to_heading.contains_key(&lower)
1260 || self.html_anchors.contains(&lower)
1261 || self.attribute_anchors.contains(&lower)
1262 } else {
1263 self.anchor_to_heading_exact.contains_key(anchor)
1264 || self.html_anchors_exact.contains(anchor)
1265 || self.attribute_anchors_exact.contains(anchor)
1266 }
1267 }
1268
1269 pub fn add_html_anchor(&mut self, anchor: &str) {
1272 if !anchor.is_empty() {
1273 self.html_anchors.insert(anchor.to_lowercase());
1274 self.html_anchors_exact.insert(anchor.to_string());
1275 }
1276 }
1277
1278 pub fn add_attribute_anchor(&mut self, anchor: &str) {
1281 if !anchor.is_empty() {
1282 self.attribute_anchors.insert(anchor.to_lowercase());
1283 self.attribute_anchors_exact.insert(anchor.to_string());
1284 }
1285 }
1286
1287 pub fn get_heading_by_anchor(&self, anchor: &str) -> Option<&HeadingIndex> {
1291 self.anchor_to_heading
1292 .get(&anchor.to_lowercase())
1293 .and_then(|&idx| self.headings.get(idx))
1294 }
1295
1296 pub fn add_reference_link(&mut self, link: ReferenceLinkIndex) {
1298 self.reference_links.push(link);
1299 }
1300
1301 pub fn is_rule_disabled_at_line(&self, rule_name: &str, line: usize) -> bool {
1306 if self.file_disabled_rules.contains("*") || self.file_disabled_rules.contains(rule_name) {
1308 return true;
1309 }
1310
1311 if let Some(rules) = self.line_disabled_rules.get(&line)
1313 && (rules.contains("*") || rules.contains(rule_name))
1314 {
1315 return true;
1316 }
1317
1318 if !self.persistent_transitions.is_empty() {
1320 let idx = match self.persistent_transitions.binary_search_by_key(&line, |t| t.0) {
1321 Ok(i) => Some(i),
1322 Err(i) => {
1323 if i > 0 {
1324 Some(i - 1)
1325 } else {
1326 None
1327 }
1328 }
1329 };
1330 if let Some(i) = idx {
1331 let (_, ref disabled, ref enabled) = self.persistent_transitions[i];
1332 if disabled.contains("*") {
1333 return !enabled.contains(rule_name);
1334 }
1335 return disabled.contains(rule_name);
1336 }
1337 }
1338
1339 false
1340 }
1341
1342 pub fn add_cross_file_link(&mut self, link: CrossFileLinkIndex) {
1357 let existing = self.cross_file_links.iter_mut().find(|existing| {
1358 existing.fragment == link.fragment
1359 && existing.line == link.line
1360 && strip_query_and_fragment(&existing.target_path) == strip_query_and_fragment(&link.target_path)
1361 });
1362 match existing {
1363 Some(existing) => {
1366 if !existing.target_path.contains('?') && link.target_path.contains('?') {
1367 *existing = link;
1368 }
1369 }
1370 None => self.cross_file_links.push(link),
1371 }
1372 }
1373
1374 pub fn add_root_relative_link(&mut self, link: CrossFileLinkIndex) {
1376 let is_duplicate = self.root_relative_links.iter().any(|existing| {
1377 existing.target_path == link.target_path && existing.fragment == link.fragment && existing.line == link.line
1378 });
1379 if !is_duplicate {
1380 self.root_relative_links.push(link);
1381 }
1382 }
1383
1384 pub fn add_md057_link_target(&mut self, target: Md057LinkTarget) {
1386 self.md057_link_targets.push(target);
1387 }
1388
1389 pub fn add_defined_reference(&mut self, ref_id: String) {
1391 self.defined_references.insert(ref_id);
1392 }
1393
1394 pub fn has_defined_reference(&self, ref_id: &str) -> bool {
1396 self.defined_references.contains(ref_id)
1397 }
1398
1399 pub fn hash_matches(&self, hash: &str) -> bool {
1401 self.content_hash == hash
1402 }
1403
1404 pub fn heading_count(&self) -> usize {
1406 self.headings.len()
1407 }
1408
1409 pub fn reference_link_count(&self) -> usize {
1411 self.reference_links.len()
1412 }
1413}
1414
1415#[cfg(test)]
1416mod tests {
1417 use super::*;
1418
1419 #[test]
1420 fn test_workspace_index_basic() {
1421 let mut index = WorkspaceIndex::new();
1422 assert_eq!(index.file_count(), 0);
1423 assert_eq!(index.version(), 0);
1424
1425 let mut file_index = FileIndex::with_hash("abc123".to_string());
1426 file_index.add_heading(HeadingIndex {
1427 text: "Installation".to_string(),
1428 auto_anchor: "installation".to_string(),
1429 custom_anchor: None,
1430 line: 1,
1431 text_lines: 1,
1432 is_setext: false,
1433 });
1434
1435 index.insert_file(PathBuf::from("docs/install.md"), file_index);
1436 assert_eq!(index.file_count(), 1);
1437 assert_eq!(index.version(), 1);
1438
1439 assert!(index.contains_file(Path::new("docs/install.md")));
1440 assert!(!index.contains_file(Path::new("docs/other.md")));
1441 }
1442
1443 #[test]
1444 fn test_vulnerable_anchors() {
1445 let mut index = WorkspaceIndex::new();
1446
1447 let mut file1 = FileIndex::new();
1449 file1.add_heading(HeadingIndex {
1450 text: "Getting Started".to_string(),
1451 auto_anchor: "getting-started".to_string(),
1452 custom_anchor: None,
1453 line: 1,
1454 text_lines: 1,
1455 is_setext: false,
1456 });
1457 index.insert_file(PathBuf::from("docs/guide.md"), file1);
1458
1459 let mut file2 = FileIndex::new();
1461 file2.add_heading(HeadingIndex {
1462 text: "Installation".to_string(),
1463 auto_anchor: "installation".to_string(),
1464 custom_anchor: Some("install".to_string()),
1465 line: 1,
1466 text_lines: 1,
1467 is_setext: false,
1468 });
1469 index.insert_file(PathBuf::from("docs/install.md"), file2);
1470
1471 let vulnerable = index.get_vulnerable_anchors();
1472 assert_eq!(vulnerable.len(), 1);
1473 assert!(vulnerable.contains_key("getting-started"));
1474 assert!(!vulnerable.contains_key("installation"));
1475
1476 let anchors = vulnerable.get("getting-started").unwrap();
1477 assert_eq!(anchors.len(), 1);
1478 assert_eq!(anchors[0].file, PathBuf::from("docs/guide.md"));
1479 assert_eq!(anchors[0].text, "Getting Started");
1480 }
1481
1482 #[test]
1483 fn test_vulnerable_anchors_multiple_files_same_anchor() {
1484 let mut index = WorkspaceIndex::new();
1487
1488 let mut file1 = FileIndex::new();
1490 file1.add_heading(HeadingIndex {
1491 text: "Installation".to_string(),
1492 auto_anchor: "installation".to_string(),
1493 custom_anchor: None,
1494 line: 1,
1495 text_lines: 1,
1496 is_setext: false,
1497 });
1498 index.insert_file(PathBuf::from("docs/en/guide.md"), file1);
1499
1500 let mut file2 = FileIndex::new();
1502 file2.add_heading(HeadingIndex {
1503 text: "Installation".to_string(),
1504 auto_anchor: "installation".to_string(),
1505 custom_anchor: None,
1506 line: 5,
1507 text_lines: 1,
1508 is_setext: false,
1509 });
1510 index.insert_file(PathBuf::from("docs/fr/guide.md"), file2);
1511
1512 let mut file3 = FileIndex::new();
1514 file3.add_heading(HeadingIndex {
1515 text: "Installation".to_string(),
1516 auto_anchor: "installation".to_string(),
1517 custom_anchor: Some("install".to_string()),
1518 line: 10,
1519 text_lines: 1,
1520 is_setext: false,
1521 });
1522 index.insert_file(PathBuf::from("docs/de/guide.md"), file3);
1523
1524 let vulnerable = index.get_vulnerable_anchors();
1525 assert_eq!(vulnerable.len(), 1); assert!(vulnerable.contains_key("installation"));
1527
1528 let anchors = vulnerable.get("installation").unwrap();
1529 assert_eq!(anchors.len(), 2, "Should collect both vulnerable anchors");
1531
1532 let files: std::collections::HashSet<_> = anchors.iter().map(|a| &a.file).collect();
1534 assert!(files.contains(&PathBuf::from("docs/en/guide.md")));
1535 assert!(files.contains(&PathBuf::from("docs/fr/guide.md")));
1536 }
1537
1538 #[test]
1539 fn test_file_index_hash() {
1540 let index = FileIndex::with_hash("hash123".to_string());
1541 assert!(index.hash_matches("hash123"));
1542 assert!(!index.hash_matches("other"));
1543 }
1544
1545 #[test]
1546 fn test_version_increment() {
1547 let mut index = WorkspaceIndex::new();
1548 assert_eq!(index.version(), 0);
1549
1550 index.insert_file(PathBuf::from("a.md"), FileIndex::new());
1551 assert_eq!(index.version(), 1);
1552
1553 index.insert_file(PathBuf::from("b.md"), FileIndex::new());
1554 assert_eq!(index.version(), 2);
1555
1556 index.remove_file(Path::new("a.md"));
1557 assert_eq!(index.version(), 3);
1558
1559 index.remove_file(Path::new("nonexistent.md"));
1561 assert_eq!(index.version(), 3);
1562 }
1563
1564 #[test]
1565 fn test_files_sorted_is_path_ordered() {
1566 let mut index = WorkspaceIndex::new();
1567 for name in ["docs/zebra.md", "docs/apple.md", "docs/mango.md"] {
1569 index.update_file(Path::new(name), FileIndex::new());
1570 }
1571
1572 let paths: Vec<&Path> = index.files_sorted().into_iter().map(|(p, _)| p).collect();
1573 assert_eq!(
1574 paths,
1575 vec![
1576 Path::new("docs/apple.md"),
1577 Path::new("docs/mango.md"),
1578 Path::new("docs/zebra.md"),
1579 ],
1580 "files_sorted() must return entries ordered by path"
1581 );
1582 }
1583
1584 #[test]
1589 fn test_add_cross_file_link_keeps_the_destination_as_written() {
1590 let as_written = CrossFileLinkIndex {
1591 target_path: "other.md?raw=true".to_string(),
1592 fragment: "missing".to_string(),
1593 line: 3,
1594 column: 1,
1595 origin: LinkOrigin::Body,
1596 };
1597 let file_named = CrossFileLinkIndex {
1598 target_path: "other.md".to_string(),
1599 fragment: "missing".to_string(),
1600 line: 3,
1601 column: 9,
1602 origin: LinkOrigin::Body,
1603 };
1604
1605 for (first, second) in [
1606 (as_written.clone(), file_named.clone()),
1607 (file_named.clone(), as_written.clone()),
1608 ] {
1609 let mut index = FileIndex::new();
1610 index.add_cross_file_link(first);
1611 index.add_cross_file_link(second);
1612
1613 assert_eq!(
1614 index.cross_file_links.len(),
1615 1,
1616 "one link is one entry, got: {:?}",
1617 index.cross_file_links
1618 );
1619 assert_eq!(index.cross_file_links[0].target_path, "other.md?raw=true");
1620 }
1621 }
1622
1623 #[test]
1626 fn test_add_cross_file_link_keeps_distinct_targets() {
1627 let mut index = FileIndex::new();
1628 for target in ["one.md", "two.md"] {
1629 index.add_cross_file_link(CrossFileLinkIndex {
1630 target_path: target.to_string(),
1631 fragment: "missing".to_string(),
1632 line: 3,
1633 column: 1,
1634 origin: LinkOrigin::Body,
1635 });
1636 }
1637 assert_eq!(index.cross_file_links.len(), 2);
1638 }
1639
1640 #[test]
1648 fn test_add_cross_file_link_collapses_one_line_asking_one_file_once() {
1649 let link = |target: &str, fragment: &str, line: usize| CrossFileLinkIndex {
1650 target_path: target.to_string(),
1651 fragment: fragment.to_string(),
1652 line,
1653 column: 1,
1654 origin: LinkOrigin::Body,
1655 };
1656
1657 let mut index = FileIndex::new();
1658 index.add_cross_file_link(link("target.md?raw=true", "missing", 3));
1659 index.add_cross_file_link(link("target.md?plain=1", "missing", 3));
1660 assert_eq!(
1661 index.cross_file_links.len(),
1662 1,
1663 "one file, one fragment, one line is one entry, got: {:?}",
1664 index.cross_file_links
1665 );
1666
1667 index.add_cross_file_link(link("target.md", "other", 3));
1668 index.add_cross_file_link(link("target.md", "missing", 4));
1669 assert_eq!(index.cross_file_links.len(), 3);
1670 }
1671
1672 #[test]
1678 fn test_reverse_deps_ignore_a_query_string_on_the_destination() {
1679 let mut index = WorkspaceIndex::new();
1680
1681 let mut file_a = FileIndex::new();
1682 file_a.add_cross_file_link(CrossFileLinkIndex {
1683 target_path: "b.md?raw=true".to_string(),
1684 fragment: "section".to_string(),
1685 line: 10,
1686 column: 5,
1687 origin: LinkOrigin::Body,
1688 });
1689 index.update_file(Path::new("docs/a.md"), file_a);
1690
1691 assert_eq!(
1692 index.get_dependents(Path::new("docs/b.md")),
1693 vec![PathBuf::from("docs/a.md")],
1694 "editing docs/b.md must re-lint the file linking to it"
1695 );
1696
1697 index.update_file(Path::new("docs/a.md"), FileIndex::new());
1700 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1701 }
1702
1703 #[test]
1704 fn test_reverse_deps_basic() {
1705 let mut index = WorkspaceIndex::new();
1706
1707 let mut file_a = FileIndex::new();
1709 file_a.add_cross_file_link(CrossFileLinkIndex {
1710 target_path: "b.md".to_string(),
1711 fragment: "section".to_string(),
1712 line: 10,
1713 column: 5,
1714 origin: LinkOrigin::Body,
1715 });
1716 index.update_file(Path::new("docs/a.md"), file_a);
1717
1718 let dependents = index.get_dependents(Path::new("docs/b.md"));
1720 assert_eq!(dependents.len(), 1);
1721 assert_eq!(dependents[0], PathBuf::from("docs/a.md"));
1722
1723 let a_dependents = index.get_dependents(Path::new("docs/a.md"));
1725 assert!(a_dependents.is_empty());
1726 }
1727
1728 #[test]
1729 fn test_reverse_deps_multiple() {
1730 let mut index = WorkspaceIndex::new();
1731
1732 let mut file_a = FileIndex::new();
1734 file_a.add_cross_file_link(CrossFileLinkIndex {
1735 target_path: "../b.md".to_string(),
1736 fragment: "".to_string(),
1737 line: 1,
1738 column: 1,
1739 origin: LinkOrigin::Body,
1740 });
1741 index.update_file(Path::new("docs/sub/a.md"), file_a);
1742
1743 let mut file_c = FileIndex::new();
1744 file_c.add_cross_file_link(CrossFileLinkIndex {
1745 target_path: "b.md".to_string(),
1746 fragment: "".to_string(),
1747 line: 1,
1748 column: 1,
1749 origin: LinkOrigin::Body,
1750 });
1751 index.update_file(Path::new("docs/c.md"), file_c);
1752
1753 let dependents = index.get_dependents(Path::new("docs/b.md"));
1755 assert_eq!(dependents.len(), 2);
1756 assert!(dependents.contains(&PathBuf::from("docs/sub/a.md")));
1757 assert!(dependents.contains(&PathBuf::from("docs/c.md")));
1758 }
1759
1760 #[test]
1761 fn test_reverse_deps_update_clears_old() {
1762 let mut index = WorkspaceIndex::new();
1763
1764 let mut file_a = FileIndex::new();
1766 file_a.add_cross_file_link(CrossFileLinkIndex {
1767 target_path: "b.md".to_string(),
1768 fragment: "".to_string(),
1769 line: 1,
1770 column: 1,
1771 origin: LinkOrigin::Body,
1772 });
1773 index.update_file(Path::new("docs/a.md"), file_a);
1774
1775 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1777
1778 let mut file_a_updated = FileIndex::new();
1780 file_a_updated.add_cross_file_link(CrossFileLinkIndex {
1781 target_path: "c.md".to_string(),
1782 fragment: "".to_string(),
1783 line: 1,
1784 column: 1,
1785 origin: LinkOrigin::Body,
1786 });
1787 index.update_file(Path::new("docs/a.md"), file_a_updated);
1788
1789 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1791
1792 let c_deps = index.get_dependents(Path::new("docs/c.md"));
1794 assert_eq!(c_deps.len(), 1);
1795 assert_eq!(c_deps[0], PathBuf::from("docs/a.md"));
1796 }
1797
1798 #[test]
1799 fn test_reverse_deps_remove_file() {
1800 let mut index = WorkspaceIndex::new();
1801
1802 let mut file_a = FileIndex::new();
1804 file_a.add_cross_file_link(CrossFileLinkIndex {
1805 target_path: "b.md".to_string(),
1806 fragment: "".to_string(),
1807 line: 1,
1808 column: 1,
1809 origin: LinkOrigin::Body,
1810 });
1811 index.update_file(Path::new("docs/a.md"), file_a);
1812
1813 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1815
1816 index.remove_file(Path::new("docs/a.md"));
1818
1819 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1821 }
1822
1823 #[test]
1824 fn test_normalize_path() {
1825 let path = Path::new("docs/sub/../other.md");
1827 let normalized = normalize_relative_path(path);
1828 assert_eq!(normalized, PathBuf::from("docs/other.md"));
1829
1830 let path2 = Path::new("docs/./other.md");
1832 let normalized2 = normalize_relative_path(path2);
1833 assert_eq!(normalized2, PathBuf::from("docs/other.md"));
1834
1835 let path3 = Path::new("a/b/c/../../d.md");
1837 let normalized3 = normalize_relative_path(path3);
1838 assert_eq!(normalized3, PathBuf::from("a/d.md"));
1839 }
1840
1841 #[test]
1846 fn normalize_keeps_a_traversal_that_leaves_its_own_root() {
1847 assert_eq!(
1848 normalize_relative_path(Path::new("../notes.md")),
1849 PathBuf::from("../notes.md")
1850 );
1851 assert_eq!(
1852 normalize_relative_path(Path::new("docs/../../notes.md")),
1853 PathBuf::from("../notes.md")
1854 );
1855 assert_eq!(
1856 normalize_relative_path(Path::new("../../a/./b/../notes.md")),
1857 PathBuf::from("../../a/notes.md")
1858 );
1859 }
1860
1861 #[test]
1864 fn normalize_stops_a_traversal_at_a_root() {
1865 let root = if cfg!(windows) { "C:\\" } else { "/" };
1866 assert_eq!(
1867 normalize_relative_path(&Path::new(root).join("..").join("notes.md")),
1868 Path::new(root).join("notes.md")
1869 );
1870 }
1871
1872 #[test]
1873 fn test_clear_clears_reverse_deps() {
1874 let mut index = WorkspaceIndex::new();
1875
1876 let mut file_a = FileIndex::new();
1878 file_a.add_cross_file_link(CrossFileLinkIndex {
1879 target_path: "b.md".to_string(),
1880 fragment: "".to_string(),
1881 line: 1,
1882 column: 1,
1883 origin: LinkOrigin::Body,
1884 });
1885 index.update_file(Path::new("docs/a.md"), file_a);
1886
1887 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1889
1890 index.clear();
1892
1893 assert_eq!(index.file_count(), 0);
1895 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1896 }
1897
1898 #[test]
1899 fn test_is_file_stale() {
1900 let mut index = WorkspaceIndex::new();
1901
1902 assert!(index.is_file_stale(Path::new("nonexistent.md"), "hash123"));
1904
1905 let file_index = FileIndex::with_hash("hash123".to_string());
1907 index.insert_file(PathBuf::from("docs/test.md"), file_index);
1908
1909 assert!(!index.is_file_stale(Path::new("docs/test.md"), "hash123"));
1911
1912 assert!(index.is_file_stale(Path::new("docs/test.md"), "different_hash"));
1914 }
1915
1916 #[cfg(feature = "native")]
1917 #[test]
1918 fn test_cache_roundtrip() {
1919 use std::fs;
1920
1921 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_roundtrip");
1923 let _ = fs::remove_dir_all(&temp_dir);
1924 fs::create_dir_all(&temp_dir).unwrap();
1925
1926 let mut index = WorkspaceIndex::new();
1928
1929 let mut file1 = FileIndex::with_hash("abc123".to_string());
1930 file1.add_heading(HeadingIndex {
1931 text: "Test Heading".to_string(),
1932 auto_anchor: "test-heading".to_string(),
1933 custom_anchor: Some("test".to_string()),
1934 line: 1,
1935 text_lines: 1,
1936 is_setext: false,
1937 });
1938 file1.add_cross_file_link(CrossFileLinkIndex {
1939 target_path: "./other.md".to_string(),
1940 fragment: "section".to_string(),
1941 line: 5,
1942 column: 3,
1943 origin: LinkOrigin::Body,
1944 });
1945 index.update_file(Path::new("docs/file1.md"), file1);
1946
1947 let mut file2 = FileIndex::with_hash("def456".to_string());
1948 file2.add_heading(HeadingIndex {
1949 text: "Another Heading".to_string(),
1950 auto_anchor: "another-heading".to_string(),
1951 custom_anchor: None,
1952 line: 1,
1953 text_lines: 1,
1954 is_setext: false,
1955 });
1956 index.update_file(Path::new("docs/other.md"), file2);
1957
1958 index.save_to_cache(&temp_dir).expect("Failed to save cache");
1960
1961 assert!(temp_dir.join("workspace_index.bin").exists());
1963
1964 let loaded = WorkspaceIndex::load_from_cache(&temp_dir).expect("Failed to load cache");
1966
1967 assert_eq!(loaded.file_count(), 2);
1969 assert!(loaded.contains_file(Path::new("docs/file1.md")));
1970 assert!(loaded.contains_file(Path::new("docs/other.md")));
1971
1972 let file1_loaded = loaded.get_file(Path::new("docs/file1.md")).unwrap();
1974 assert_eq!(file1_loaded.content_hash, "abc123");
1975 assert_eq!(file1_loaded.headings.len(), 1);
1976 assert_eq!(file1_loaded.headings[0].text, "Test Heading");
1977 assert_eq!(file1_loaded.headings[0].custom_anchor, Some("test".to_string()));
1978 assert_eq!(file1_loaded.cross_file_links.len(), 1);
1979 assert_eq!(file1_loaded.cross_file_links[0].target_path, "./other.md");
1980
1981 let dependents = loaded.get_dependents(Path::new("docs/other.md"));
1983 assert_eq!(dependents.len(), 1);
1984 assert_eq!(dependents[0], PathBuf::from("docs/file1.md"));
1985
1986 let _ = fs::remove_dir_all(&temp_dir);
1988 }
1989
1990 #[cfg(feature = "native")]
1991 #[test]
1992 fn test_cache_missing_file() {
1993 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_missing");
1994 let _ = std::fs::remove_dir_all(&temp_dir);
1995
1996 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1998 assert!(result.is_none());
1999 }
2000
2001 #[cfg(feature = "native")]
2002 #[test]
2003 fn test_cache_corrupted_file() {
2004 use std::fs;
2005
2006 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_corrupted");
2007 let _ = fs::remove_dir_all(&temp_dir);
2008 fs::create_dir_all(&temp_dir).unwrap();
2009
2010 fs::write(temp_dir.join("workspace_index.bin"), b"bad").unwrap();
2012
2013 let result = WorkspaceIndex::load_from_cache(&temp_dir);
2015 assert!(result.is_none());
2016
2017 assert!(!temp_dir.join("workspace_index.bin").exists());
2019
2020 let _ = fs::remove_dir_all(&temp_dir);
2022 }
2023
2024 #[cfg(feature = "native")]
2025 #[test]
2026 fn test_cache_invalid_magic() {
2027 use std::fs;
2028
2029 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_invalid_magic");
2030 let _ = fs::remove_dir_all(&temp_dir);
2031 fs::create_dir_all(&temp_dir).unwrap();
2032
2033 let mut data = Vec::new();
2035 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();
2039
2040 let result = WorkspaceIndex::load_from_cache(&temp_dir);
2042 assert!(result.is_none());
2043
2044 assert!(!temp_dir.join("workspace_index.bin").exists());
2046
2047 let _ = fs::remove_dir_all(&temp_dir);
2049 }
2050
2051 #[cfg(feature = "native")]
2052 #[test]
2053 fn test_cache_version_mismatch() {
2054 use std::fs;
2055
2056 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_version_mismatch");
2057 let _ = fs::remove_dir_all(&temp_dir);
2058 fs::create_dir_all(&temp_dir).unwrap();
2059
2060 let mut data = Vec::new();
2062 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();
2066
2067 let result = WorkspaceIndex::load_from_cache(&temp_dir);
2069 assert!(result.is_none());
2070
2071 assert!(!temp_dir.join("workspace_index.bin").exists());
2073
2074 let _ = fs::remove_dir_all(&temp_dir);
2076 }
2077
2078 #[cfg(feature = "native")]
2079 #[test]
2080 fn test_cache_atomic_write() {
2081 use std::fs;
2082
2083 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_atomic");
2085 let _ = fs::remove_dir_all(&temp_dir);
2086 fs::create_dir_all(&temp_dir).unwrap();
2087
2088 let index = WorkspaceIndex::new();
2089 index.save_to_cache(&temp_dir).expect("Failed to save");
2090
2091 let entries: Vec<_> = fs::read_dir(&temp_dir).unwrap().collect();
2093 assert_eq!(entries.len(), 1);
2094 assert!(temp_dir.join("workspace_index.bin").exists());
2095
2096 let _ = fs::remove_dir_all(&temp_dir);
2098 }
2099
2100 #[test]
2101 fn test_has_anchor_auto_generated() {
2102 let mut file_index = FileIndex::new();
2103 file_index.add_heading(HeadingIndex {
2104 text: "Installation Guide".to_string(),
2105 auto_anchor: "installation-guide".to_string(),
2106 custom_anchor: None,
2107 line: 1,
2108 text_lines: 1,
2109 is_setext: false,
2110 });
2111
2112 assert!(file_index.has_anchor("installation-guide"));
2114
2115 assert!(file_index.has_anchor("Installation-Guide"));
2117 assert!(file_index.has_anchor("INSTALLATION-GUIDE"));
2118
2119 assert!(!file_index.has_anchor("nonexistent"));
2121 }
2122
2123 #[test]
2124 fn test_has_anchor_custom() {
2125 let mut file_index = FileIndex::new();
2126 file_index.add_heading(HeadingIndex {
2127 text: "Installation Guide".to_string(),
2128 auto_anchor: "installation-guide".to_string(),
2129 custom_anchor: Some("install".to_string()),
2130 line: 1,
2131 text_lines: 1,
2132 is_setext: false,
2133 });
2134
2135 assert!(file_index.has_anchor("installation-guide"));
2137
2138 assert!(file_index.has_anchor("install"));
2140 assert!(file_index.has_anchor("Install")); assert!(!file_index.has_anchor("nonexistent"));
2144 }
2145
2146 #[test]
2147 fn test_get_heading_by_anchor() {
2148 let mut file_index = FileIndex::new();
2149 file_index.add_heading(HeadingIndex {
2150 text: "Installation Guide".to_string(),
2151 auto_anchor: "installation-guide".to_string(),
2152 custom_anchor: Some("install".to_string()),
2153 line: 10,
2154 text_lines: 1,
2155 is_setext: false,
2156 });
2157 file_index.add_heading(HeadingIndex {
2158 text: "Configuration".to_string(),
2159 auto_anchor: "configuration".to_string(),
2160 custom_anchor: None,
2161 line: 20,
2162 text_lines: 1,
2163 is_setext: false,
2164 });
2165
2166 let heading = file_index.get_heading_by_anchor("installation-guide");
2168 assert!(heading.is_some());
2169 assert_eq!(heading.unwrap().text, "Installation Guide");
2170 assert_eq!(heading.unwrap().line, 10);
2171
2172 let heading = file_index.get_heading_by_anchor("install");
2174 assert!(heading.is_some());
2175 assert_eq!(heading.unwrap().text, "Installation Guide");
2176
2177 let heading = file_index.get_heading_by_anchor("configuration");
2179 assert!(heading.is_some());
2180 assert_eq!(heading.unwrap().text, "Configuration");
2181 assert_eq!(heading.unwrap().line, 20);
2182
2183 assert!(file_index.get_heading_by_anchor("nonexistent").is_none());
2185 }
2186
2187 #[test]
2188 fn test_anchor_lookup_many_headings() {
2189 let mut file_index = FileIndex::new();
2191
2192 for i in 0..100 {
2194 file_index.add_heading(HeadingIndex {
2195 text: format!("Heading {i}"),
2196 auto_anchor: format!("heading-{i}"),
2197 custom_anchor: Some(format!("h{i}")),
2198 line: i + 1,
2199 text_lines: 1,
2200 is_setext: false,
2201 });
2202 }
2203
2204 for i in 0..100 {
2206 assert!(file_index.has_anchor(&format!("heading-{i}")));
2207 assert!(file_index.has_anchor(&format!("h{i}")));
2208
2209 let heading = file_index.get_heading_by_anchor(&format!("heading-{i}"));
2210 assert!(heading.is_some());
2211 assert_eq!(heading.unwrap().line, i + 1);
2212 }
2213 }
2214
2215 #[test]
2220 fn test_extract_cross_file_links_basic() {
2221 use crate::config::MarkdownFlavor;
2222
2223 let content = "# Test\n\nSee [link](./other.md) for info.\n";
2224 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2225 let links = extract_cross_file_links(&ctx).relative;
2226
2227 assert_eq!(links.len(), 1);
2228 assert_eq!(links[0].target_path, "./other.md");
2229 assert_eq!(links[0].fragment, "");
2230 assert_eq!(links[0].line, 3);
2231 assert_eq!(links[0].column, 12);
2233 }
2234
2235 #[test]
2236 fn test_extract_cross_file_links_with_fragment() {
2237 use crate::config::MarkdownFlavor;
2238
2239 let content = "Check [guide](./guide.md#install) here.\n";
2240 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2241 let links = extract_cross_file_links(&ctx).relative;
2242
2243 assert_eq!(links.len(), 1);
2244 assert_eq!(links[0].target_path, "./guide.md");
2245 assert_eq!(links[0].fragment, "install");
2246 assert_eq!(links[0].line, 1);
2247 assert_eq!(links[0].column, 15);
2249 }
2250
2251 #[test]
2252 fn test_extract_cross_file_links_multiple_on_same_line() {
2253 use crate::config::MarkdownFlavor;
2254
2255 let content = "See [a](a.md) and [b](b.md) here.\n";
2256 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2257 let links = extract_cross_file_links(&ctx).relative;
2258
2259 assert_eq!(links.len(), 2);
2260
2261 assert_eq!(links[0].target_path, "a.md");
2262 assert_eq!(links[0].line, 1);
2263 assert_eq!(links[0].column, 9);
2265
2266 assert_eq!(links[1].target_path, "b.md");
2267 assert_eq!(links[1].line, 1);
2268 assert_eq!(links[1].column, 23);
2270 }
2271
2272 #[test]
2273 fn test_extract_cross_file_links_angle_brackets() {
2274 use crate::config::MarkdownFlavor;
2275
2276 let content = "See [link](<path/with (parens).md>) here.\n";
2277 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2278 let links = extract_cross_file_links(&ctx).relative;
2279
2280 assert_eq!(links.len(), 1);
2281 assert_eq!(links[0].target_path, "path/with (parens).md");
2282 assert_eq!(links[0].line, 1);
2283 assert_eq!(links[0].column, 13);
2285 }
2286
2287 #[test]
2288 fn test_extract_cross_file_links_bare_parens_in_destination() {
2289 use crate::config::MarkdownFlavor;
2290
2291 let content = "See [link](docs/file(inner).md) and [plain](docs/other.md).\n";
2295 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2296 let links = extract_cross_file_links(&ctx).relative;
2297
2298 let targets: Vec<&str> = links.iter().map(|l| l.target_path.as_str()).collect();
2299 assert_eq!(targets, vec!["docs/file(inner).md", "docs/other.md"]);
2300 assert_eq!(links[0].column, 12);
2302 }
2303
2304 #[test]
2305 fn test_extract_cross_file_links_skips_external() {
2306 use crate::config::MarkdownFlavor;
2307
2308 let content = r#"
2309[external](https://example.com)
2310[mailto](mailto:test@example.com)
2311[local](./local.md)
2312[fragment](#section)
2313[absolute](/docs/page.md)
2314"#;
2315 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2316 let extracted = extract_cross_file_links(&ctx);
2317
2318 assert_eq!(extracted.relative.len(), 1);
2320 assert_eq!(extracted.relative[0].target_path, "./local.md");
2321 assert_eq!(extracted.root_relative.len(), 1);
2323 assert_eq!(extracted.root_relative[0].target_path, "docs/page.md");
2324 }
2325
2326 #[test]
2327 fn test_extract_cross_file_links_root_relative() {
2328 use crate::config::MarkdownFlavor;
2329
2330 let content = "[a](/guide.md#install)\n[b](/../escape.md)\n[c](//host/x.md)\n[d](/img/pic.png)\n";
2334 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2335 let extracted = extract_cross_file_links(&ctx);
2336
2337 assert!(extracted.relative.is_empty(), "no directory-relative links here");
2338 assert_eq!(
2339 extracted
2340 .root_relative
2341 .iter()
2342 .map(|l| (l.target_path.as_str(), l.fragment.as_str()))
2343 .collect::<Vec<_>>(),
2344 vec![("guide.md", "install")],
2345 "only the safe root-relative markdown link is captured"
2346 );
2347 }
2348
2349 #[test]
2350 fn test_extract_cross_file_links_skips_non_markdown() {
2351 use crate::config::MarkdownFlavor;
2352
2353 let content = r#"
2354[image](./photo.png)
2355[doc](./readme.md)
2356[pdf](./document.pdf)
2357"#;
2358 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2359 let links = extract_cross_file_links(&ctx).relative;
2360
2361 assert_eq!(links.len(), 1);
2363 assert_eq!(links[0].target_path, "./readme.md");
2364 }
2365
2366 #[test]
2367 fn test_extract_cross_file_links_skips_code_spans() {
2368 use crate::config::MarkdownFlavor;
2369
2370 let content = "Normal [link](./file.md) and `[code](./ignored.md)` here.\n";
2371 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2372 let links = extract_cross_file_links(&ctx).relative;
2373
2374 assert_eq!(links.len(), 1);
2376 assert_eq!(links[0].target_path, "./file.md");
2377 }
2378
2379 #[test]
2380 fn test_extract_cross_file_links_with_query_params() {
2381 use crate::config::MarkdownFlavor;
2382
2383 let content = "See [doc](./file.md?raw=true) here.\n";
2384 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2385 let links = extract_cross_file_links(&ctx).relative;
2386
2387 assert_eq!(links.len(), 1);
2388 assert_eq!(links[0].target_path, "./file.md");
2390 }
2391
2392 #[test]
2393 fn test_extract_cross_file_links_empty_content() {
2394 use crate::config::MarkdownFlavor;
2395
2396 let content = "";
2397 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2398 let links = extract_cross_file_links(&ctx).relative;
2399
2400 assert!(links.is_empty());
2401 }
2402
2403 #[test]
2404 fn test_extract_cross_file_links_no_links() {
2405 use crate::config::MarkdownFlavor;
2406
2407 let content = "# Just a heading\n\nSome text without links.\n";
2408 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2409 let links = extract_cross_file_links(&ctx).relative;
2410
2411 assert!(links.is_empty());
2412 }
2413
2414 #[test]
2415 fn test_extract_cross_file_links_position_accuracy_issue_234() {
2416 use crate::config::MarkdownFlavor;
2419
2420 let content = r#"# Test Document
2421
2422Here is a [broken link](nonexistent-file.md) that should trigger MD057.
2423
2424And another [link](also-missing.md) on this line.
2425"#;
2426 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2427 let links = extract_cross_file_links(&ctx).relative;
2428
2429 assert_eq!(links.len(), 2);
2430
2431 assert_eq!(links[0].target_path, "nonexistent-file.md");
2433 assert_eq!(links[0].line, 3);
2434 assert_eq!(links[0].column, 25);
2435
2436 assert_eq!(links[1].target_path, "also-missing.md");
2438 assert_eq!(links[1].line, 5);
2439 assert_eq!(links[1].column, 20);
2440 }
2441
2442 #[test]
2443 fn link_path_part_drops_the_query_and_fragment_and_decodes() {
2444 assert_eq!(link_path_part("guide%20one.md?raw=true#real"), "guide one.md");
2445 assert_eq!(link_path_part("guide%2Emd#real"), "guide.md");
2446 assert_eq!(link_path_part("#only-a-fragment"), "");
2447 assert_eq!(link_path_part("?only=query#x"), "");
2448 }
2449
2450 #[cfg(feature = "native")]
2451 #[test]
2452 fn url_encode_path_is_undone_by_link_path_part() {
2453 for path in [
2454 "plain/guide.md",
2455 "my notes.md",
2456 "50%.md",
2457 "a(b)<c>.md",
2458 "what?.md",
2459 "c#.md",
2460 "back\\slash.md",
2461 "tab\there.md",
2462 "日本 語/é.md",
2463 ] {
2464 let encoded = url_encode_path(path);
2465 assert!(
2466 !encoded.contains([' ', '(', ')', '<', '>', '?', '#', '\\', '\t']),
2467 "{encoded}"
2468 );
2469 assert_eq!(link_path_part(&encoded), path, "{encoded}");
2470 }
2471 assert_eq!(url_encode_path("日本/guide.md"), "日本/guide.md");
2472 assert_eq!(url_encode_path("my notes.md"), "my%20notes.md");
2473 }
2474
2475 #[test]
2476 fn workspace_key_spells_every_path_inside_the_working_directory_relatively() {
2477 let cwd = std::env::current_dir().unwrap();
2478 assert_eq!(workspace_key(Path::new("./docs/a.md")), PathBuf::from("docs/a.md"));
2479 assert_eq!(
2480 workspace_key(&cwd.join("docs/../docs/a.md")),
2481 PathBuf::from("docs/a.md")
2482 );
2483 assert_eq!(
2484 workspace_key(&cwd.canonicalize().unwrap().join("docs/a.md")),
2485 PathBuf::from("docs/a.md")
2486 );
2487 let outside = cwd.parent().unwrap().join("sibling.md");
2490 assert_eq!(workspace_key(Path::new("../sibling.md")), workspace_key(&outside));
2491 assert!(workspace_key(&outside).is_absolute());
2492 }
2493
2494 #[cfg(unix)]
2495 #[test]
2496 fn get_resolved_file_finds_a_canonical_key_through_a_symlinked_spelling() {
2497 let dir = tempfile::tempdir().unwrap();
2498 let real = dir.path().canonicalize().unwrap().join("real");
2499 std::fs::create_dir(&real).unwrap();
2500 std::fs::write(real.join("a.md"), "# A\n").unwrap();
2501 let alias = dir.path().canonicalize().unwrap().join("alias");
2502 std::os::unix::fs::symlink(&real, &alias).unwrap();
2503
2504 let mut index = WorkspaceIndex::new();
2505 let mut file_index = FileIndex::new();
2506 file_index.add_heading(HeadingIndex {
2507 text: "A".to_string(),
2508 auto_anchor: "a".to_string(),
2509 custom_anchor: None,
2510 line: 1,
2511 text_lines: 1,
2512 is_setext: false,
2513 });
2514 index.insert_file(real.join("a.md"), file_index);
2515
2516 assert!(index.get_resolved_file(&alias.join("a.md")).is_some());
2517 assert!(index.get_resolved_file(&alias.join("./sub/../a.md")).is_some());
2518 assert!(index.get_resolved_file(&alias.join("b.md")).is_none());
2519 }
2520}