1use regex::Regex;
22use serde::{Deserialize, Serialize};
23use std::collections::{HashMap, HashSet};
24use std::path::{Path, PathBuf};
25use std::sync::LazyLock;
26
27use crate::lint_context::{LintContext, SourcePositions};
28
29fn hex_digit_to_value(c: u8) -> Option<u8> {
35 match c {
36 b'0'..=b'9' => Some(c - b'0'),
37 b'a'..=b'f' => Some(c - b'a' + 10),
38 b'A'..=b'F' => Some(c - b'A' + 10),
39 _ => None,
40 }
41}
42
43pub(crate) fn url_decode(s: &str) -> String {
50 if !s.contains('%') {
52 return s.to_string();
53 }
54
55 let bytes = s.as_bytes();
56 let mut result = Vec::with_capacity(bytes.len());
57 let mut i = 0;
58
59 while i < bytes.len() {
60 if bytes[i] == b'%' && i + 2 < bytes.len() {
61 let hex1 = bytes[i + 1];
63 let hex2 = bytes[i + 2];
64 if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
65 result.push(d1 * 16 + d2);
66 i += 3;
67 continue;
68 }
69 }
70 result.push(bytes[i]);
71 i += 1;
72 }
73
74 String::from_utf8(result).unwrap_or_else(|_| s.to_string())
76}
77
78#[cfg(feature = "native")]
86pub(crate) fn url_encode_path(path: &str) -> std::borrow::Cow<'_, str> {
87 let needs_encoding =
88 |c: char| c.is_whitespace() || c.is_control() || matches!(c, '%' | '(' | ')' | '<' | '>' | '?' | '#' | '\\');
89 if !path.contains(needs_encoding) {
90 return std::borrow::Cow::Borrowed(path);
91 }
92 let mut encoded = String::with_capacity(path.len() + 8);
93 for c in path.chars() {
94 if needs_encoding(c) {
95 let mut bytes = [0; 4];
96 for byte in c.encode_utf8(&mut bytes).bytes() {
97 encoded.push_str(&format!("%{byte:02X}"));
98 }
99 } else {
100 encoded.push(c);
101 }
102 }
103 std::borrow::Cow::Owned(encoded)
104}
105
106pub(crate) static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
115 LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
116
117#[inline]
119fn is_markdown_file(path: &str) -> bool {
120 crate::discovery::has_markdown_extension(std::path::Path::new(path))
121}
122
123pub(crate) fn strip_query_and_fragment(url: &str) -> &str {
126 let query_pos = url.find('?');
127 let fragment_pos = url.find('#');
128
129 match (query_pos, fragment_pos) {
130 (Some(q), Some(f)) => &url[..q.min(f)],
131 (Some(q), None) => &url[..q],
132 (None, Some(f)) => &url[..f],
133 (None, None) => url,
134 }
135}
136
137pub fn link_path_part(url: &str) -> String {
145 url_decode(strip_query_and_fragment(url))
146}
147
148pub fn link_target_file(source_dir: &Path, target_path: &str) -> PathBuf {
159 normalize_relative_path(&resolve_target_against(source_dir, target_path))
160}
161
162fn resolve_target_against(source_dir: &Path, target_path: &str) -> PathBuf {
174 let raw = strip_query_and_fragment(target_path);
175 match raw.strip_prefix('/') {
176 Some(from_root) => resolve_against_project_root(source_dir, &url_decode(from_root)),
177 None => source_dir.join(url_decode(raw)),
178 }
179}
180
181fn resolve_against_project_root(source_dir: &Path, from_root: &str) -> PathBuf {
195 let absolute = crate::utils::project_root::project_root().join(from_root);
196 if source_dir.is_absolute() {
197 return absolute;
198 }
199 match crate::utils::project_root::working_directory().and_then(|cwd| absolute.strip_prefix(cwd).ok()) {
200 Some(relative) => relative.to_path_buf(),
201 None => absolute,
202 }
203}
204
205#[derive(Debug, Default)]
212pub struct ExtractedCrossFileLinks {
213 pub relative: Vec<CrossFileLinkIndex>,
215 pub root_relative: Vec<CrossFileLinkIndex>,
219}
220
221pub(crate) enum IndexedDestination<'a> {
223 Relative(&'a str),
226 RootRelative(&'a str),
229}
230
231pub(crate) fn indexed_destination(path: &str) -> Option<IndexedDestination<'_>> {
238 let path = path.trim();
239 if let Some(rel) = path.strip_prefix('/') {
240 if rel.starts_with('/')
241 || Path::new(rel)
242 .components()
243 .any(|c| matches!(c, std::path::Component::ParentDir))
244 {
245 return None;
246 }
247 let stripped = strip_query_and_fragment(rel);
248 return is_markdown_file(&url_decode(stripped)).then_some(IndexedDestination::RootRelative(stripped));
249 }
250
251 if path.is_empty()
252 || PROTOCOL_DOMAIN_REGEX.is_match(path)
253 || path.starts_with("www.")
254 || path.starts_with('#')
255 || path.starts_with("{{")
256 || path.starts_with("{%")
257 || path.starts_with('~')
258 || path.starts_with('@')
259 || (path.starts_with('`') && path.ends_with('`'))
260 {
261 return None;
262 }
263
264 let stripped = strip_query_and_fragment(path);
267 is_markdown_file(&url_decode(stripped)).then_some(IndexedDestination::Relative(stripped))
268}
269
270pub fn extract_cross_file_links(ctx: &LintContext) -> ExtractedCrossFileLinks {
281 let content = ctx.content;
282 let mut links = ExtractedCrossFileLinks::default();
283 if content.is_empty() {
284 return links;
285 }
286
287 let mut positions = SourcePositions::new(content, &ctx.lines);
288 let mut record = |path: &str, fragment: &str, start: usize| {
289 let Some(destination) = indexed_destination(path) else {
290 return;
291 };
292 let (_, line, column) = positions.position(start);
293 let entry = |target_path: &str| CrossFileLinkIndex {
294 target_path: target_path.to_string(),
295 fragment: fragment.to_string(),
296 line,
297 column: column + 1,
298 origin: LinkOrigin::Body,
299 };
300 match destination {
301 IndexedDestination::Relative(path) => links.relative.push(entry(path)),
302 IndexedDestination::RootRelative(path) => links.root_relative.push(entry(path)),
303 }
304 };
305
306 for destination in ctx.body_link_destinations() {
310 let at = if content[destination.link_start..destination.url_range.start].contains('\n') {
311 destination.link_start
312 } else {
313 destination.url_range.start
314 };
315 record(destination.url, destination.fragment.trim_start_matches('#'), at);
316 }
317
318 for definition in ctx.body_reference_definitions() {
321 let (path, fragment) = definition.url.split_once('#').unwrap_or((definition.url, ""));
322 let at = definition
323 .destination
324 .as_ref()
325 .map_or(definition.start, |range| range.start);
326 record(path, fragment, at);
327 }
328
329 links.relative.sort_by_key(|link| (link.line, link.column));
331 links.root_relative.sort_by_key(|link| (link.line, link.column));
332 links
333}
334
335#[cfg(feature = "postcard")]
337const CACHE_MAGIC: &[u8; 4] = b"RWSI";
338
339#[cfg(feature = "postcard")]
381const CACHE_FORMAT_VERSION: u32 = 16;
382
383#[cfg(feature = "postcard")]
385const CACHE_FILE_NAME: &str = "workspace_index.bin";
386
387#[cfg(feature = "postcard")]
391static CACHE_TEMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
392
393#[derive(Debug, Default, Clone, Serialize, Deserialize)]
398pub struct WorkspaceIndex {
399 files: HashMap<PathBuf, FileIndex>,
401 reverse_deps: HashMap<PathBuf, HashSet<PathBuf>>,
404 version: u64,
406 #[serde(skip)]
411 link_target_policy: Option<crate::lint_context::LinkTargetPolicy>,
412}
413
414#[derive(Debug, Clone, Default, Serialize, Deserialize)]
416pub struct FileIndex {
417 pub headings: Vec<HeadingIndex>,
419 pub reference_links: Vec<ReferenceLinkIndex>,
421 pub cross_file_links: Vec<CrossFileLinkIndex>,
423 #[serde(default)]
428 pub root_relative_links: Vec<CrossFileLinkIndex>,
429 #[serde(default)]
434 pub md057_link_targets: Vec<Md057LinkTarget>,
435 pub defined_references: HashSet<String>,
438 pub content_hash: String,
440 anchor_to_heading: HashMap<String, usize>,
443 #[serde(default)]
447 anchor_to_heading_exact: HashMap<String, usize>,
448 html_anchors: HashSet<String>,
451 #[serde(default)]
454 html_anchors_exact: HashSet<String>,
455 attribute_anchors: HashSet<String>,
459 #[serde(default)]
462 attribute_anchors_exact: HashSet<String>,
463 pub file_disabled_rules: HashSet<String>,
466 pub persistent_transitions: Vec<(usize, HashSet<String>, HashSet<String>)>,
469 pub line_disabled_rules: HashMap<usize, HashSet<String>>,
471}
472
473#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
475pub struct HeadingIndex {
476 pub text: String,
478 pub auto_anchor: String,
480 pub custom_anchor: Option<String>,
482 pub line: usize,
484 pub text_lines: usize,
488 #[serde(default)]
490 pub is_setext: bool,
491}
492
493impl HeadingIndex {
494 #[must_use]
496 pub fn first_line(&self) -> usize {
497 self.line + 1 - self.text_lines.max(1)
498 }
499
500 #[must_use]
502 pub fn covers_line(&self, line: usize) -> bool {
503 (self.first_line()..=self.line).contains(&line)
504 }
505}
506
507#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
509pub struct ReferenceLinkIndex {
510 pub reference_id: String,
512 pub line: usize,
514 pub column: usize,
516}
517
518#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
526pub enum LinkOrigin {
527 Body,
529 FrontMatter { field: Option<String> },
538}
539
540#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
542pub struct CrossFileLinkIndex {
543 pub target_path: String,
545 pub fragment: String,
547 pub line: usize,
549 pub column: usize,
551 pub origin: LinkOrigin,
553}
554
555#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
557pub struct Md057LinkTarget {
558 pub target: String,
560 pub origin: LinkOrigin,
562}
563
564pub fn link_target_candidates(source_file: &Path, target_path: &str) -> Vec<PathBuf> {
577 let joined = resolve_target_against(source_file.parent().unwrap_or(Path::new("")), target_path);
578 let base = normalize_relative_path(&joined);
579
580 if base.extension().is_some() {
581 return vec![base];
582 }
583
584 let mut candidates = Vec::with_capacity(crate::discovery::MARKDOWN_EXTENSIONS.len() + 1);
587 for ext in crate::discovery::MARKDOWN_EXTENSIONS {
588 candidates.push(base.with_extension(ext));
589 }
590 candidates.insert(0, base);
591 candidates
592}
593
594static WORKING_DIRECTORY_SPELLINGS: LazyLock<Vec<PathBuf>> = LazyLock::new(|| {
601 let mut spellings = Vec::new();
602 if let Ok(cwd) = std::env::current_dir() {
603 let cwd = normalize_relative_path(&cwd);
604 for spelling in [
605 cwd.canonicalize().ok(),
606 crate::discovery::canonicalize_for_matching(&cwd),
607 ]
608 .into_iter()
609 .flatten()
610 {
611 if spelling != cwd && !spellings.contains(&spelling) {
612 spellings.push(spelling);
613 }
614 }
615 spellings.push(cwd);
616 }
617 spellings
618});
619
620pub fn workspace_key(path: &Path) -> PathBuf {
632 let normalized = normalize_relative_path(path);
633 if normalized.is_absolute() {
634 let inside_working_directory = |absolute: &Path| {
635 WORKING_DIRECTORY_SPELLINGS
636 .iter()
637 .find_map(|cwd| absolute.strip_prefix(cwd).ok().map(Path::to_path_buf))
638 };
639 return inside_working_directory(&normalized)
640 .or_else(|| inside_working_directory(&crate::discovery::resolve_for_matching(&normalized)))
641 .unwrap_or(normalized);
642 }
643 if normalized.starts_with("..")
644 && let Some(cwd) = WORKING_DIRECTORY_SPELLINGS.last()
645 {
646 return workspace_key(&cwd.join(&normalized));
647 }
648 normalized
649}
650
651pub fn absolute_selection_path(path: &Path) -> std::io::Result<PathBuf> {
654 let absolute = std::path::absolute(path)?;
655 let mut selected = PathBuf::new();
656 for component in absolute.components() {
657 if component == std::path::Component::ParentDir {
658 selected = std::fs::canonicalize(&selected)?;
659 selected.pop();
660 } else {
661 selected.push(component.as_os_str());
662 }
663 }
664 Ok(selected)
665}
666
667pub fn normalize_relative_path(path: &Path) -> PathBuf {
678 let mut components: Vec<std::path::Component<'_>> = Vec::new();
679 for component in path.components() {
680 match component {
681 std::path::Component::CurDir => {}
682 std::path::Component::ParentDir => match components.last() {
683 Some(std::path::Component::Normal(_)) => {
684 components.pop();
685 }
686 Some(std::path::Component::RootDir) => {}
687 _ => components.push(component),
688 },
689 c => components.push(c),
690 }
691 }
692 components.iter().collect()
693}
694
695impl CrossFileLinkIndex {
696 pub fn is_navigable(&self) -> bool {
704 matches!(self.origin, LinkOrigin::Body)
705 }
706}
707
708#[derive(Debug, Clone, Serialize, Deserialize)]
710pub struct VulnerableAnchor {
711 pub file: PathBuf,
713 pub line: usize,
715 pub text: String,
717}
718
719impl WorkspaceIndex {
720 pub fn new() -> Self {
722 Self::default()
723 }
724
725 pub fn version(&self) -> u64 {
727 self.version
728 }
729
730 pub fn file_count(&self) -> usize {
732 self.files.len()
733 }
734
735 pub fn contains_file(&self, path: &Path) -> bool {
737 self.files.contains_key(path)
738 }
739
740 pub fn get_file(&self, path: &Path) -> Option<&FileIndex> {
742 self.files.get(path)
743 }
744
745 pub fn get_resolved_file(&self, target: &Path) -> Option<&FileIndex> {
755 let normalized = normalize_relative_path(target);
756 if let Some(index) = self.files.get(&normalized) {
757 return Some(index);
758 }
759 if let Some(index) = self.files.get(&workspace_key(&normalized)) {
760 return Some(index);
761 }
762 let canonical = normalized.canonicalize().ok()?;
765 let matching = crate::discovery::canonicalize_for_matching(&normalized);
766 [Some(canonical), matching]
767 .into_iter()
768 .flatten()
769 .find_map(|path| self.files.get(&path).or_else(|| self.files.get(&workspace_key(&path))))
770 }
771
772 pub fn link_target_policy(&self) -> Option<&crate::lint_context::LinkTargetPolicy> {
774 self.link_target_policy.as_ref()
775 }
776
777 pub fn set_link_target_policy(&mut self, policy: crate::lint_context::LinkTargetPolicy) {
779 self.link_target_policy = Some(policy);
780 }
781
782 pub fn insert_file(&mut self, path: PathBuf, index: FileIndex) {
784 self.files.insert(path, index);
785 self.version = self.version.wrapping_add(1);
786 }
787
788 pub fn remove_file(&mut self, path: &Path) -> Option<FileIndex> {
790 self.clear_reverse_deps_for(path);
792
793 let result = self.files.remove(path);
794 if result.is_some() {
795 self.version = self.version.wrapping_add(1);
796 }
797 result
798 }
799
800 pub fn get_vulnerable_anchors(&self) -> HashMap<String, Vec<VulnerableAnchor>> {
810 let mut vulnerable: HashMap<String, Vec<VulnerableAnchor>> = HashMap::new();
811
812 for (file_path, file_index) in &self.files {
813 for heading in &file_index.headings {
814 if heading.custom_anchor.is_none() && !heading.auto_anchor.is_empty() {
816 let anchor_key = heading.auto_anchor.to_lowercase();
817 vulnerable.entry(anchor_key).or_default().push(VulnerableAnchor {
818 file: file_path.clone(),
819 line: heading.line,
820 text: heading.text.clone(),
821 });
822 }
823 }
824 }
825
826 vulnerable
827 }
828
829 pub fn all_headings(&self) -> impl Iterator<Item = (&Path, &HeadingIndex)> {
831 self.files
832 .iter()
833 .flat_map(|(path, index)| index.headings.iter().map(move |h| (path.as_path(), h)))
834 }
835
836 pub fn files(&self) -> impl Iterator<Item = (&Path, &FileIndex)> {
838 self.files.iter().map(|(p, i)| (p.as_path(), i))
839 }
840
841 pub fn files_sorted(&self) -> Vec<(&Path, &FileIndex)> {
847 let mut entries: Vec<(&Path, &FileIndex)> = self.files.iter().map(|(p, i)| (p.as_path(), i)).collect();
848 entries.sort_by_key(|(a, _)| *a);
849 entries
850 }
851
852 pub fn clear(&mut self) {
854 self.files.clear();
855 self.reverse_deps.clear();
856 self.version = self.version.wrapping_add(1);
857 }
858
859 pub fn update_file(&mut self, path: &Path, index: FileIndex) {
866 self.clear_reverse_deps_as_source(path);
869
870 for link in &index.cross_file_links {
872 let target = self.resolve_target_path(path, &link.target_path);
873 self.reverse_deps.entry(target).or_default().insert(path.to_path_buf());
874 }
875
876 self.files.insert(path.to_path_buf(), index);
877 self.version = self.version.wrapping_add(1);
878 }
879
880 pub fn get_dependents(&self, path: &Path) -> Vec<PathBuf> {
885 self.reverse_deps
886 .get(path)
887 .map(|set| set.iter().cloned().collect())
888 .unwrap_or_default()
889 }
890
891 pub fn is_file_stale(&self, path: &Path, current_hash: &str) -> bool {
895 self.files.get(path).is_none_or(|f| f.content_hash != current_hash)
896 }
897
898 pub fn retain_only(&mut self, current_files: &std::collections::HashSet<PathBuf>) -> usize {
903 let before_count = self.files.len();
904
905 let to_remove: Vec<PathBuf> = self
907 .files
908 .keys()
909 .filter(|path| !current_files.contains(*path))
910 .cloned()
911 .collect();
912
913 for path in &to_remove {
915 self.remove_file(path);
916 }
917
918 before_count - self.files.len()
919 }
920
921 #[cfg(feature = "postcard")]
928 pub fn save_to_cache(&self, cache_dir: &Path) -> std::io::Result<()> {
929 use std::fs;
930 use std::io::Write;
931
932 fs::create_dir_all(cache_dir)?;
934
935 let encoded = postcard::to_allocvec(self)
937 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
938
939 let mut cache_data = Vec::with_capacity(8 + encoded.len());
941 cache_data.extend_from_slice(CACHE_MAGIC);
942 cache_data.extend_from_slice(&CACHE_FORMAT_VERSION.to_le_bytes());
943 cache_data.extend_from_slice(&encoded);
944
945 let final_path = cache_dir.join(CACHE_FILE_NAME);
950 let counter = CACHE_TEMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
951 #[cfg(not(target_arch = "wasm32"))]
952 let temp_path = cache_dir.join(format!("{CACHE_FILE_NAME}.tmp.{}.{counter}", std::process::id()));
953 #[cfg(target_arch = "wasm32")]
954 let temp_path = cache_dir.join(format!("{CACHE_FILE_NAME}.tmp.{counter}"));
955
956 {
958 let mut file = fs::File::create(&temp_path)?;
959 file.write_all(&cache_data)?;
960 file.sync_all()?;
961 }
962
963 fs::rename(&temp_path, &final_path)?;
965
966 log::debug!(
967 "Saved workspace index to cache: {} files, {} bytes (format v{})",
968 self.files.len(),
969 cache_data.len(),
970 CACHE_FORMAT_VERSION
971 );
972
973 Ok(())
974 }
975
976 #[cfg(feature = "postcard")]
984 pub fn load_from_cache(cache_dir: &Path) -> Option<Self> {
985 use std::fs;
986
987 let path = cache_dir.join(CACHE_FILE_NAME);
988 let data = fs::read(&path).ok()?;
989
990 if data.len() < 8 {
992 log::warn!("Workspace index cache too small, discarding");
993 let _ = fs::remove_file(&path);
994 return None;
995 }
996
997 if &data[0..4] != CACHE_MAGIC {
999 log::warn!("Workspace index cache has invalid magic header, discarding");
1000 let _ = fs::remove_file(&path);
1001 return None;
1002 }
1003
1004 let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
1006 if version != CACHE_FORMAT_VERSION {
1007 log::info!(
1008 "Workspace index cache format version mismatch (got {version}, expected {CACHE_FORMAT_VERSION}), rebuilding"
1009 );
1010 let _ = fs::remove_file(&path);
1011 return None;
1012 }
1013
1014 match postcard::from_bytes::<Self>(&data[8..]) {
1016 Ok(index) => {
1017 log::debug!(
1018 "Loaded workspace index from cache: {} files (format v{})",
1019 index.files.len(),
1020 version
1021 );
1022 Some(index)
1023 }
1024 Err(e) => {
1025 log::warn!("Failed to deserialize workspace index cache: {e}");
1026 let _ = fs::remove_file(&path);
1027 None
1028 }
1029 }
1030 }
1031
1032 fn clear_reverse_deps_as_source(&mut self, path: &Path) {
1037 let targets: Vec<PathBuf> = match self.files.get(path) {
1044 Some(index) => index
1045 .cross_file_links
1046 .iter()
1047 .map(|link| self.resolve_target_path(path, &link.target_path))
1048 .collect(),
1049 None => return,
1050 };
1051 for target in targets {
1052 if let Some(deps) = self.reverse_deps.get_mut(&target) {
1053 deps.remove(path);
1054 if deps.is_empty() {
1055 self.reverse_deps.remove(&target);
1056 }
1057 }
1058 }
1059 }
1060
1061 fn clear_reverse_deps_for(&mut self, path: &Path) {
1066 self.clear_reverse_deps_as_source(path);
1068
1069 self.reverse_deps.remove(path);
1071 }
1072
1073 fn resolve_target_path(&self, source_file: &Path, relative_target: &str) -> PathBuf {
1079 let source_dir = source_file.parent().unwrap_or(Path::new(""));
1080 link_target_file(source_dir, relative_target)
1081 }
1082}
1083
1084impl FileIndex {
1085 pub fn new() -> Self {
1087 Self::default()
1088 }
1089
1090 pub fn with_hash(content_hash: String) -> Self {
1092 Self {
1093 content_hash,
1094 ..Default::default()
1095 }
1096 }
1097
1098 pub fn extracted_data_differs(&self, other: &Self) -> bool {
1114 let Self {
1118 headings,
1119 reference_links,
1120 cross_file_links,
1121 root_relative_links,
1122 md057_link_targets,
1123 defined_references,
1124 content_hash: _,
1125 anchor_to_heading,
1126 anchor_to_heading_exact,
1127 html_anchors,
1128 html_anchors_exact,
1129 attribute_anchors,
1130 attribute_anchors_exact,
1131 file_disabled_rules,
1132 persistent_transitions,
1133 line_disabled_rules,
1134 } = self;
1135
1136 headings != &other.headings
1137 || reference_links != &other.reference_links
1138 || cross_file_links != &other.cross_file_links
1139 || root_relative_links != &other.root_relative_links
1140 || md057_link_targets != &other.md057_link_targets
1141 || defined_references != &other.defined_references
1142 || anchor_to_heading != &other.anchor_to_heading
1143 || anchor_to_heading_exact != &other.anchor_to_heading_exact
1144 || html_anchors != &other.html_anchors
1145 || html_anchors_exact != &other.html_anchors_exact
1146 || attribute_anchors != &other.attribute_anchors
1147 || attribute_anchors_exact != &other.attribute_anchors_exact
1148 || file_disabled_rules != &other.file_disabled_rules
1149 || persistent_transitions != &other.persistent_transitions
1150 || line_disabled_rules != &other.line_disabled_rules
1151 }
1152
1153 pub fn add_heading(&mut self, heading: HeadingIndex) {
1159 let index = self.headings.len();
1160
1161 self.anchor_to_heading.insert(heading.auto_anchor.to_lowercase(), index);
1164 self.anchor_to_heading_exact.insert(heading.auto_anchor.clone(), index);
1165
1166 if let Some(ref custom) = heading.custom_anchor {
1168 self.anchor_to_heading.insert(custom.to_lowercase(), index);
1169 self.anchor_to_heading_exact.insert(custom.clone(), index);
1170 }
1171
1172 self.headings.push(heading);
1173 }
1174
1175 pub fn add_anchor_alias(&mut self, anchor: &str, heading_index: usize) {
1178 if heading_index < self.headings.len() {
1179 self.anchor_to_heading.insert(anchor.to_lowercase(), heading_index);
1180 self.anchor_to_heading_exact.insert(anchor.to_string(), heading_index);
1181 }
1182 }
1183
1184 pub fn has_anchor(&self, anchor: &str) -> bool {
1195 self.has_anchor_with_case(anchor, true)
1196 }
1197
1198 pub fn has_anchor_with_case(&self, anchor: &str, ignore_case: bool) -> bool {
1207 if self.lookup_anchor(anchor, ignore_case) {
1208 return true;
1209 }
1210
1211 if anchor.contains('%') {
1213 let decoded = url_decode(anchor);
1214 if decoded != anchor {
1215 return self.lookup_anchor(&decoded, ignore_case);
1216 }
1217 }
1218
1219 false
1220 }
1221
1222 pub fn anchor_names(&self, ignore_case: bool) -> Box<dyn Iterator<Item = &str> + '_> {
1227 if ignore_case {
1228 Box::new(
1229 self.anchor_to_heading
1230 .keys()
1231 .chain(self.html_anchors.iter())
1232 .chain(self.attribute_anchors.iter())
1233 .map(String::as_str),
1234 )
1235 } else {
1236 Box::new(
1237 self.anchor_to_heading_exact
1238 .keys()
1239 .chain(self.html_anchors_exact.iter())
1240 .chain(self.attribute_anchors_exact.iter())
1241 .map(String::as_str),
1242 )
1243 }
1244 }
1245
1246 fn lookup_anchor(&self, anchor: &str, ignore_case: bool) -> bool {
1249 if ignore_case {
1250 let lower = anchor.to_lowercase();
1251 self.anchor_to_heading.contains_key(&lower)
1252 || self.html_anchors.contains(&lower)
1253 || self.attribute_anchors.contains(&lower)
1254 } else {
1255 self.anchor_to_heading_exact.contains_key(anchor)
1256 || self.html_anchors_exact.contains(anchor)
1257 || self.attribute_anchors_exact.contains(anchor)
1258 }
1259 }
1260
1261 pub fn add_html_anchor(&mut self, anchor: &str) {
1264 if !anchor.is_empty() {
1265 self.html_anchors.insert(anchor.to_lowercase());
1266 self.html_anchors_exact.insert(anchor.to_string());
1267 }
1268 }
1269
1270 pub fn add_attribute_anchor(&mut self, anchor: &str) {
1273 if !anchor.is_empty() {
1274 self.attribute_anchors.insert(anchor.to_lowercase());
1275 self.attribute_anchors_exact.insert(anchor.to_string());
1276 }
1277 }
1278
1279 pub fn get_heading_by_anchor(&self, anchor: &str) -> Option<&HeadingIndex> {
1283 self.anchor_to_heading
1284 .get(&anchor.to_lowercase())
1285 .and_then(|&idx| self.headings.get(idx))
1286 }
1287
1288 pub fn add_reference_link(&mut self, link: ReferenceLinkIndex) {
1290 self.reference_links.push(link);
1291 }
1292
1293 pub fn is_rule_disabled_at_line(&self, rule_name: &str, line: usize) -> bool {
1298 if self.file_disabled_rules.contains("*") || self.file_disabled_rules.contains(rule_name) {
1300 return true;
1301 }
1302
1303 if let Some(rules) = self.line_disabled_rules.get(&line)
1305 && (rules.contains("*") || rules.contains(rule_name))
1306 {
1307 return true;
1308 }
1309
1310 if !self.persistent_transitions.is_empty() {
1312 let idx = match self.persistent_transitions.binary_search_by_key(&line, |t| t.0) {
1313 Ok(i) => Some(i),
1314 Err(i) => {
1315 if i > 0 {
1316 Some(i - 1)
1317 } else {
1318 None
1319 }
1320 }
1321 };
1322 if let Some(i) = idx {
1323 let (_, ref disabled, ref enabled) = self.persistent_transitions[i];
1324 if disabled.contains("*") {
1325 return !enabled.contains(rule_name);
1326 }
1327 return disabled.contains(rule_name);
1328 }
1329 }
1330
1331 false
1332 }
1333
1334 pub fn add_cross_file_link(&mut self, link: CrossFileLinkIndex) {
1349 let existing = self.cross_file_links.iter_mut().find(|existing| {
1350 existing.fragment == link.fragment
1351 && existing.line == link.line
1352 && strip_query_and_fragment(&existing.target_path) == strip_query_and_fragment(&link.target_path)
1353 });
1354 match existing {
1355 Some(existing) => {
1358 if !existing.target_path.contains('?') && link.target_path.contains('?') {
1359 *existing = link;
1360 }
1361 }
1362 None => self.cross_file_links.push(link),
1363 }
1364 }
1365
1366 pub fn add_root_relative_link(&mut self, link: CrossFileLinkIndex) {
1368 let is_duplicate = self.root_relative_links.iter().any(|existing| {
1369 existing.target_path == link.target_path && existing.fragment == link.fragment && existing.line == link.line
1370 });
1371 if !is_duplicate {
1372 self.root_relative_links.push(link);
1373 }
1374 }
1375
1376 pub fn add_md057_link_target(&mut self, target: Md057LinkTarget) {
1378 self.md057_link_targets.push(target);
1379 }
1380
1381 pub fn add_defined_reference(&mut self, ref_id: String) {
1383 self.defined_references.insert(ref_id);
1384 }
1385
1386 pub fn has_defined_reference(&self, ref_id: &str) -> bool {
1388 self.defined_references.contains(ref_id)
1389 }
1390
1391 pub fn hash_matches(&self, hash: &str) -> bool {
1393 self.content_hash == hash
1394 }
1395
1396 pub fn heading_count(&self) -> usize {
1398 self.headings.len()
1399 }
1400
1401 pub fn reference_link_count(&self) -> usize {
1403 self.reference_links.len()
1404 }
1405}
1406
1407#[cfg(test)]
1408mod tests {
1409 use super::*;
1410
1411 #[test]
1412 fn test_workspace_index_basic() {
1413 let mut index = WorkspaceIndex::new();
1414 assert_eq!(index.file_count(), 0);
1415 assert_eq!(index.version(), 0);
1416
1417 let mut file_index = FileIndex::with_hash("abc123".to_string());
1418 file_index.add_heading(HeadingIndex {
1419 text: "Installation".to_string(),
1420 auto_anchor: "installation".to_string(),
1421 custom_anchor: None,
1422 line: 1,
1423 text_lines: 1,
1424 is_setext: false,
1425 });
1426
1427 index.insert_file(PathBuf::from("docs/install.md"), file_index);
1428 assert_eq!(index.file_count(), 1);
1429 assert_eq!(index.version(), 1);
1430
1431 assert!(index.contains_file(Path::new("docs/install.md")));
1432 assert!(!index.contains_file(Path::new("docs/other.md")));
1433 }
1434
1435 #[test]
1436 fn test_vulnerable_anchors() {
1437 let mut index = WorkspaceIndex::new();
1438
1439 let mut file1 = FileIndex::new();
1441 file1.add_heading(HeadingIndex {
1442 text: "Getting Started".to_string(),
1443 auto_anchor: "getting-started".to_string(),
1444 custom_anchor: None,
1445 line: 1,
1446 text_lines: 1,
1447 is_setext: false,
1448 });
1449 index.insert_file(PathBuf::from("docs/guide.md"), file1);
1450
1451 let mut file2 = FileIndex::new();
1453 file2.add_heading(HeadingIndex {
1454 text: "Installation".to_string(),
1455 auto_anchor: "installation".to_string(),
1456 custom_anchor: Some("install".to_string()),
1457 line: 1,
1458 text_lines: 1,
1459 is_setext: false,
1460 });
1461 index.insert_file(PathBuf::from("docs/install.md"), file2);
1462
1463 let vulnerable = index.get_vulnerable_anchors();
1464 assert_eq!(vulnerable.len(), 1);
1465 assert!(vulnerable.contains_key("getting-started"));
1466 assert!(!vulnerable.contains_key("installation"));
1467
1468 let anchors = vulnerable.get("getting-started").unwrap();
1469 assert_eq!(anchors.len(), 1);
1470 assert_eq!(anchors[0].file, PathBuf::from("docs/guide.md"));
1471 assert_eq!(anchors[0].text, "Getting Started");
1472 }
1473
1474 #[test]
1475 fn test_vulnerable_anchors_multiple_files_same_anchor() {
1476 let mut index = WorkspaceIndex::new();
1479
1480 let mut file1 = FileIndex::new();
1482 file1.add_heading(HeadingIndex {
1483 text: "Installation".to_string(),
1484 auto_anchor: "installation".to_string(),
1485 custom_anchor: None,
1486 line: 1,
1487 text_lines: 1,
1488 is_setext: false,
1489 });
1490 index.insert_file(PathBuf::from("docs/en/guide.md"), file1);
1491
1492 let mut file2 = FileIndex::new();
1494 file2.add_heading(HeadingIndex {
1495 text: "Installation".to_string(),
1496 auto_anchor: "installation".to_string(),
1497 custom_anchor: None,
1498 line: 5,
1499 text_lines: 1,
1500 is_setext: false,
1501 });
1502 index.insert_file(PathBuf::from("docs/fr/guide.md"), file2);
1503
1504 let mut file3 = FileIndex::new();
1506 file3.add_heading(HeadingIndex {
1507 text: "Installation".to_string(),
1508 auto_anchor: "installation".to_string(),
1509 custom_anchor: Some("install".to_string()),
1510 line: 10,
1511 text_lines: 1,
1512 is_setext: false,
1513 });
1514 index.insert_file(PathBuf::from("docs/de/guide.md"), file3);
1515
1516 let vulnerable = index.get_vulnerable_anchors();
1517 assert_eq!(vulnerable.len(), 1); assert!(vulnerable.contains_key("installation"));
1519
1520 let anchors = vulnerable.get("installation").unwrap();
1521 assert_eq!(anchors.len(), 2, "Should collect both vulnerable anchors");
1523
1524 let files: std::collections::HashSet<_> = anchors.iter().map(|a| &a.file).collect();
1526 assert!(files.contains(&PathBuf::from("docs/en/guide.md")));
1527 assert!(files.contains(&PathBuf::from("docs/fr/guide.md")));
1528 }
1529
1530 #[test]
1531 fn test_file_index_hash() {
1532 let index = FileIndex::with_hash("hash123".to_string());
1533 assert!(index.hash_matches("hash123"));
1534 assert!(!index.hash_matches("other"));
1535 }
1536
1537 #[test]
1538 fn test_version_increment() {
1539 let mut index = WorkspaceIndex::new();
1540 assert_eq!(index.version(), 0);
1541
1542 index.insert_file(PathBuf::from("a.md"), FileIndex::new());
1543 assert_eq!(index.version(), 1);
1544
1545 index.insert_file(PathBuf::from("b.md"), FileIndex::new());
1546 assert_eq!(index.version(), 2);
1547
1548 index.remove_file(Path::new("a.md"));
1549 assert_eq!(index.version(), 3);
1550
1551 index.remove_file(Path::new("nonexistent.md"));
1553 assert_eq!(index.version(), 3);
1554 }
1555
1556 #[test]
1557 fn test_files_sorted_is_path_ordered() {
1558 let mut index = WorkspaceIndex::new();
1559 for name in ["docs/zebra.md", "docs/apple.md", "docs/mango.md"] {
1561 index.update_file(Path::new(name), FileIndex::new());
1562 }
1563
1564 let paths: Vec<&Path> = index.files_sorted().into_iter().map(|(p, _)| p).collect();
1565 assert_eq!(
1566 paths,
1567 vec![
1568 Path::new("docs/apple.md"),
1569 Path::new("docs/mango.md"),
1570 Path::new("docs/zebra.md"),
1571 ],
1572 "files_sorted() must return entries ordered by path"
1573 );
1574 }
1575
1576 #[test]
1581 fn test_add_cross_file_link_keeps_the_destination_as_written() {
1582 let as_written = CrossFileLinkIndex {
1583 target_path: "other.md?raw=true".to_string(),
1584 fragment: "missing".to_string(),
1585 line: 3,
1586 column: 1,
1587 origin: LinkOrigin::Body,
1588 };
1589 let file_named = CrossFileLinkIndex {
1590 target_path: "other.md".to_string(),
1591 fragment: "missing".to_string(),
1592 line: 3,
1593 column: 9,
1594 origin: LinkOrigin::Body,
1595 };
1596
1597 for (first, second) in [
1598 (as_written.clone(), file_named.clone()),
1599 (file_named.clone(), as_written.clone()),
1600 ] {
1601 let mut index = FileIndex::new();
1602 index.add_cross_file_link(first);
1603 index.add_cross_file_link(second);
1604
1605 assert_eq!(
1606 index.cross_file_links.len(),
1607 1,
1608 "one link is one entry, got: {:?}",
1609 index.cross_file_links
1610 );
1611 assert_eq!(index.cross_file_links[0].target_path, "other.md?raw=true");
1612 }
1613 }
1614
1615 #[test]
1618 fn test_add_cross_file_link_keeps_distinct_targets() {
1619 let mut index = FileIndex::new();
1620 for target in ["one.md", "two.md"] {
1621 index.add_cross_file_link(CrossFileLinkIndex {
1622 target_path: target.to_string(),
1623 fragment: "missing".to_string(),
1624 line: 3,
1625 column: 1,
1626 origin: LinkOrigin::Body,
1627 });
1628 }
1629 assert_eq!(index.cross_file_links.len(), 2);
1630 }
1631
1632 #[test]
1640 fn test_add_cross_file_link_collapses_one_line_asking_one_file_once() {
1641 let link = |target: &str, fragment: &str, line: usize| CrossFileLinkIndex {
1642 target_path: target.to_string(),
1643 fragment: fragment.to_string(),
1644 line,
1645 column: 1,
1646 origin: LinkOrigin::Body,
1647 };
1648
1649 let mut index = FileIndex::new();
1650 index.add_cross_file_link(link("target.md?raw=true", "missing", 3));
1651 index.add_cross_file_link(link("target.md?plain=1", "missing", 3));
1652 assert_eq!(
1653 index.cross_file_links.len(),
1654 1,
1655 "one file, one fragment, one line is one entry, got: {:?}",
1656 index.cross_file_links
1657 );
1658
1659 index.add_cross_file_link(link("target.md", "other", 3));
1660 index.add_cross_file_link(link("target.md", "missing", 4));
1661 assert_eq!(index.cross_file_links.len(), 3);
1662 }
1663
1664 #[test]
1670 fn test_reverse_deps_ignore_a_query_string_on_the_destination() {
1671 let mut index = WorkspaceIndex::new();
1672
1673 let mut file_a = FileIndex::new();
1674 file_a.add_cross_file_link(CrossFileLinkIndex {
1675 target_path: "b.md?raw=true".to_string(),
1676 fragment: "section".to_string(),
1677 line: 10,
1678 column: 5,
1679 origin: LinkOrigin::Body,
1680 });
1681 index.update_file(Path::new("docs/a.md"), file_a);
1682
1683 assert_eq!(
1684 index.get_dependents(Path::new("docs/b.md")),
1685 vec![PathBuf::from("docs/a.md")],
1686 "editing docs/b.md must re-lint the file linking to it"
1687 );
1688
1689 index.update_file(Path::new("docs/a.md"), FileIndex::new());
1692 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1693 }
1694
1695 #[test]
1696 fn test_reverse_deps_basic() {
1697 let mut index = WorkspaceIndex::new();
1698
1699 let mut file_a = FileIndex::new();
1701 file_a.add_cross_file_link(CrossFileLinkIndex {
1702 target_path: "b.md".to_string(),
1703 fragment: "section".to_string(),
1704 line: 10,
1705 column: 5,
1706 origin: LinkOrigin::Body,
1707 });
1708 index.update_file(Path::new("docs/a.md"), file_a);
1709
1710 let dependents = index.get_dependents(Path::new("docs/b.md"));
1712 assert_eq!(dependents.len(), 1);
1713 assert_eq!(dependents[0], PathBuf::from("docs/a.md"));
1714
1715 let a_dependents = index.get_dependents(Path::new("docs/a.md"));
1717 assert!(a_dependents.is_empty());
1718 }
1719
1720 #[test]
1721 fn test_reverse_deps_multiple() {
1722 let mut index = WorkspaceIndex::new();
1723
1724 let mut file_a = FileIndex::new();
1726 file_a.add_cross_file_link(CrossFileLinkIndex {
1727 target_path: "../b.md".to_string(),
1728 fragment: "".to_string(),
1729 line: 1,
1730 column: 1,
1731 origin: LinkOrigin::Body,
1732 });
1733 index.update_file(Path::new("docs/sub/a.md"), file_a);
1734
1735 let mut file_c = FileIndex::new();
1736 file_c.add_cross_file_link(CrossFileLinkIndex {
1737 target_path: "b.md".to_string(),
1738 fragment: "".to_string(),
1739 line: 1,
1740 column: 1,
1741 origin: LinkOrigin::Body,
1742 });
1743 index.update_file(Path::new("docs/c.md"), file_c);
1744
1745 let dependents = index.get_dependents(Path::new("docs/b.md"));
1747 assert_eq!(dependents.len(), 2);
1748 assert!(dependents.contains(&PathBuf::from("docs/sub/a.md")));
1749 assert!(dependents.contains(&PathBuf::from("docs/c.md")));
1750 }
1751
1752 #[test]
1753 fn test_reverse_deps_update_clears_old() {
1754 let mut index = WorkspaceIndex::new();
1755
1756 let mut file_a = FileIndex::new();
1758 file_a.add_cross_file_link(CrossFileLinkIndex {
1759 target_path: "b.md".to_string(),
1760 fragment: "".to_string(),
1761 line: 1,
1762 column: 1,
1763 origin: LinkOrigin::Body,
1764 });
1765 index.update_file(Path::new("docs/a.md"), file_a);
1766
1767 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1769
1770 let mut file_a_updated = FileIndex::new();
1772 file_a_updated.add_cross_file_link(CrossFileLinkIndex {
1773 target_path: "c.md".to_string(),
1774 fragment: "".to_string(),
1775 line: 1,
1776 column: 1,
1777 origin: LinkOrigin::Body,
1778 });
1779 index.update_file(Path::new("docs/a.md"), file_a_updated);
1780
1781 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1783
1784 let c_deps = index.get_dependents(Path::new("docs/c.md"));
1786 assert_eq!(c_deps.len(), 1);
1787 assert_eq!(c_deps[0], PathBuf::from("docs/a.md"));
1788 }
1789
1790 #[test]
1791 fn test_reverse_deps_remove_file() {
1792 let mut index = WorkspaceIndex::new();
1793
1794 let mut file_a = FileIndex::new();
1796 file_a.add_cross_file_link(CrossFileLinkIndex {
1797 target_path: "b.md".to_string(),
1798 fragment: "".to_string(),
1799 line: 1,
1800 column: 1,
1801 origin: LinkOrigin::Body,
1802 });
1803 index.update_file(Path::new("docs/a.md"), file_a);
1804
1805 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1807
1808 index.remove_file(Path::new("docs/a.md"));
1810
1811 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1813 }
1814
1815 #[test]
1816 fn test_normalize_path() {
1817 let path = Path::new("docs/sub/../other.md");
1819 let normalized = normalize_relative_path(path);
1820 assert_eq!(normalized, PathBuf::from("docs/other.md"));
1821
1822 let path2 = Path::new("docs/./other.md");
1824 let normalized2 = normalize_relative_path(path2);
1825 assert_eq!(normalized2, PathBuf::from("docs/other.md"));
1826
1827 let path3 = Path::new("a/b/c/../../d.md");
1829 let normalized3 = normalize_relative_path(path3);
1830 assert_eq!(normalized3, PathBuf::from("a/d.md"));
1831 }
1832
1833 #[test]
1838 fn normalize_keeps_a_traversal_that_leaves_its_own_root() {
1839 assert_eq!(
1840 normalize_relative_path(Path::new("../notes.md")),
1841 PathBuf::from("../notes.md")
1842 );
1843 assert_eq!(
1844 normalize_relative_path(Path::new("docs/../../notes.md")),
1845 PathBuf::from("../notes.md")
1846 );
1847 assert_eq!(
1848 normalize_relative_path(Path::new("../../a/./b/../notes.md")),
1849 PathBuf::from("../../a/notes.md")
1850 );
1851 }
1852
1853 #[test]
1856 fn normalize_stops_a_traversal_at_a_root() {
1857 let root = if cfg!(windows) { "C:\\" } else { "/" };
1858 assert_eq!(
1859 normalize_relative_path(&Path::new(root).join("..").join("notes.md")),
1860 Path::new(root).join("notes.md")
1861 );
1862 }
1863
1864 #[test]
1865 fn test_clear_clears_reverse_deps() {
1866 let mut index = WorkspaceIndex::new();
1867
1868 let mut file_a = FileIndex::new();
1870 file_a.add_cross_file_link(CrossFileLinkIndex {
1871 target_path: "b.md".to_string(),
1872 fragment: "".to_string(),
1873 line: 1,
1874 column: 1,
1875 origin: LinkOrigin::Body,
1876 });
1877 index.update_file(Path::new("docs/a.md"), file_a);
1878
1879 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1881
1882 index.clear();
1884
1885 assert_eq!(index.file_count(), 0);
1887 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1888 }
1889
1890 #[test]
1891 fn test_is_file_stale() {
1892 let mut index = WorkspaceIndex::new();
1893
1894 assert!(index.is_file_stale(Path::new("nonexistent.md"), "hash123"));
1896
1897 let file_index = FileIndex::with_hash("hash123".to_string());
1899 index.insert_file(PathBuf::from("docs/test.md"), file_index);
1900
1901 assert!(!index.is_file_stale(Path::new("docs/test.md"), "hash123"));
1903
1904 assert!(index.is_file_stale(Path::new("docs/test.md"), "different_hash"));
1906 }
1907
1908 #[cfg(feature = "native")]
1909 #[test]
1910 fn test_cache_roundtrip() {
1911 use std::fs;
1912
1913 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_roundtrip");
1915 let _ = fs::remove_dir_all(&temp_dir);
1916 fs::create_dir_all(&temp_dir).unwrap();
1917
1918 let mut index = WorkspaceIndex::new();
1920
1921 let mut file1 = FileIndex::with_hash("abc123".to_string());
1922 file1.add_heading(HeadingIndex {
1923 text: "Test Heading".to_string(),
1924 auto_anchor: "test-heading".to_string(),
1925 custom_anchor: Some("test".to_string()),
1926 line: 1,
1927 text_lines: 1,
1928 is_setext: false,
1929 });
1930 file1.add_cross_file_link(CrossFileLinkIndex {
1931 target_path: "./other.md".to_string(),
1932 fragment: "section".to_string(),
1933 line: 5,
1934 column: 3,
1935 origin: LinkOrigin::Body,
1936 });
1937 index.update_file(Path::new("docs/file1.md"), file1);
1938
1939 let mut file2 = FileIndex::with_hash("def456".to_string());
1940 file2.add_heading(HeadingIndex {
1941 text: "Another Heading".to_string(),
1942 auto_anchor: "another-heading".to_string(),
1943 custom_anchor: None,
1944 line: 1,
1945 text_lines: 1,
1946 is_setext: false,
1947 });
1948 index.update_file(Path::new("docs/other.md"), file2);
1949
1950 index.save_to_cache(&temp_dir).expect("Failed to save cache");
1952
1953 assert!(temp_dir.join("workspace_index.bin").exists());
1955
1956 let loaded = WorkspaceIndex::load_from_cache(&temp_dir).expect("Failed to load cache");
1958
1959 assert_eq!(loaded.file_count(), 2);
1961 assert!(loaded.contains_file(Path::new("docs/file1.md")));
1962 assert!(loaded.contains_file(Path::new("docs/other.md")));
1963
1964 let file1_loaded = loaded.get_file(Path::new("docs/file1.md")).unwrap();
1966 assert_eq!(file1_loaded.content_hash, "abc123");
1967 assert_eq!(file1_loaded.headings.len(), 1);
1968 assert_eq!(file1_loaded.headings[0].text, "Test Heading");
1969 assert_eq!(file1_loaded.headings[0].custom_anchor, Some("test".to_string()));
1970 assert_eq!(file1_loaded.cross_file_links.len(), 1);
1971 assert_eq!(file1_loaded.cross_file_links[0].target_path, "./other.md");
1972
1973 let dependents = loaded.get_dependents(Path::new("docs/other.md"));
1975 assert_eq!(dependents.len(), 1);
1976 assert_eq!(dependents[0], PathBuf::from("docs/file1.md"));
1977
1978 let _ = fs::remove_dir_all(&temp_dir);
1980 }
1981
1982 #[cfg(feature = "native")]
1983 #[test]
1984 fn test_cache_missing_file() {
1985 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_missing");
1986 let _ = std::fs::remove_dir_all(&temp_dir);
1987
1988 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1990 assert!(result.is_none());
1991 }
1992
1993 #[cfg(feature = "native")]
1994 #[test]
1995 fn test_cache_corrupted_file() {
1996 use std::fs;
1997
1998 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_corrupted");
1999 let _ = fs::remove_dir_all(&temp_dir);
2000 fs::create_dir_all(&temp_dir).unwrap();
2001
2002 fs::write(temp_dir.join("workspace_index.bin"), b"bad").unwrap();
2004
2005 let result = WorkspaceIndex::load_from_cache(&temp_dir);
2007 assert!(result.is_none());
2008
2009 assert!(!temp_dir.join("workspace_index.bin").exists());
2011
2012 let _ = fs::remove_dir_all(&temp_dir);
2014 }
2015
2016 #[cfg(feature = "native")]
2017 #[test]
2018 fn test_cache_invalid_magic() {
2019 use std::fs;
2020
2021 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_invalid_magic");
2022 let _ = fs::remove_dir_all(&temp_dir);
2023 fs::create_dir_all(&temp_dir).unwrap();
2024
2025 let mut data = Vec::new();
2027 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();
2031
2032 let result = WorkspaceIndex::load_from_cache(&temp_dir);
2034 assert!(result.is_none());
2035
2036 assert!(!temp_dir.join("workspace_index.bin").exists());
2038
2039 let _ = fs::remove_dir_all(&temp_dir);
2041 }
2042
2043 #[cfg(feature = "native")]
2044 #[test]
2045 fn test_cache_version_mismatch() {
2046 use std::fs;
2047
2048 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_version_mismatch");
2049 let _ = fs::remove_dir_all(&temp_dir);
2050 fs::create_dir_all(&temp_dir).unwrap();
2051
2052 let mut data = Vec::new();
2054 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();
2058
2059 let result = WorkspaceIndex::load_from_cache(&temp_dir);
2061 assert!(result.is_none());
2062
2063 assert!(!temp_dir.join("workspace_index.bin").exists());
2065
2066 let _ = fs::remove_dir_all(&temp_dir);
2068 }
2069
2070 #[cfg(feature = "native")]
2071 #[test]
2072 fn test_cache_atomic_write() {
2073 use std::fs;
2074
2075 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_atomic");
2077 let _ = fs::remove_dir_all(&temp_dir);
2078 fs::create_dir_all(&temp_dir).unwrap();
2079
2080 let index = WorkspaceIndex::new();
2081 index.save_to_cache(&temp_dir).expect("Failed to save");
2082
2083 let entries: Vec<_> = fs::read_dir(&temp_dir).unwrap().collect();
2085 assert_eq!(entries.len(), 1);
2086 assert!(temp_dir.join("workspace_index.bin").exists());
2087
2088 let _ = fs::remove_dir_all(&temp_dir);
2090 }
2091
2092 #[test]
2093 fn test_has_anchor_auto_generated() {
2094 let mut file_index = FileIndex::new();
2095 file_index.add_heading(HeadingIndex {
2096 text: "Installation Guide".to_string(),
2097 auto_anchor: "installation-guide".to_string(),
2098 custom_anchor: None,
2099 line: 1,
2100 text_lines: 1,
2101 is_setext: false,
2102 });
2103
2104 assert!(file_index.has_anchor("installation-guide"));
2106
2107 assert!(file_index.has_anchor("Installation-Guide"));
2109 assert!(file_index.has_anchor("INSTALLATION-GUIDE"));
2110
2111 assert!(!file_index.has_anchor("nonexistent"));
2113 }
2114
2115 #[test]
2116 fn test_has_anchor_custom() {
2117 let mut file_index = FileIndex::new();
2118 file_index.add_heading(HeadingIndex {
2119 text: "Installation Guide".to_string(),
2120 auto_anchor: "installation-guide".to_string(),
2121 custom_anchor: Some("install".to_string()),
2122 line: 1,
2123 text_lines: 1,
2124 is_setext: false,
2125 });
2126
2127 assert!(file_index.has_anchor("installation-guide"));
2129
2130 assert!(file_index.has_anchor("install"));
2132 assert!(file_index.has_anchor("Install")); assert!(!file_index.has_anchor("nonexistent"));
2136 }
2137
2138 #[test]
2139 fn test_get_heading_by_anchor() {
2140 let mut file_index = FileIndex::new();
2141 file_index.add_heading(HeadingIndex {
2142 text: "Installation Guide".to_string(),
2143 auto_anchor: "installation-guide".to_string(),
2144 custom_anchor: Some("install".to_string()),
2145 line: 10,
2146 text_lines: 1,
2147 is_setext: false,
2148 });
2149 file_index.add_heading(HeadingIndex {
2150 text: "Configuration".to_string(),
2151 auto_anchor: "configuration".to_string(),
2152 custom_anchor: None,
2153 line: 20,
2154 text_lines: 1,
2155 is_setext: false,
2156 });
2157
2158 let heading = file_index.get_heading_by_anchor("installation-guide");
2160 assert!(heading.is_some());
2161 assert_eq!(heading.unwrap().text, "Installation Guide");
2162 assert_eq!(heading.unwrap().line, 10);
2163
2164 let heading = file_index.get_heading_by_anchor("install");
2166 assert!(heading.is_some());
2167 assert_eq!(heading.unwrap().text, "Installation Guide");
2168
2169 let heading = file_index.get_heading_by_anchor("configuration");
2171 assert!(heading.is_some());
2172 assert_eq!(heading.unwrap().text, "Configuration");
2173 assert_eq!(heading.unwrap().line, 20);
2174
2175 assert!(file_index.get_heading_by_anchor("nonexistent").is_none());
2177 }
2178
2179 #[test]
2180 fn test_anchor_lookup_many_headings() {
2181 let mut file_index = FileIndex::new();
2183
2184 for i in 0..100 {
2186 file_index.add_heading(HeadingIndex {
2187 text: format!("Heading {i}"),
2188 auto_anchor: format!("heading-{i}"),
2189 custom_anchor: Some(format!("h{i}")),
2190 line: i + 1,
2191 text_lines: 1,
2192 is_setext: false,
2193 });
2194 }
2195
2196 for i in 0..100 {
2198 assert!(file_index.has_anchor(&format!("heading-{i}")));
2199 assert!(file_index.has_anchor(&format!("h{i}")));
2200
2201 let heading = file_index.get_heading_by_anchor(&format!("heading-{i}"));
2202 assert!(heading.is_some());
2203 assert_eq!(heading.unwrap().line, i + 1);
2204 }
2205 }
2206
2207 #[test]
2212 fn test_extract_cross_file_links_basic() {
2213 use crate::config::MarkdownFlavor;
2214
2215 let content = "# Test\n\nSee [link](./other.md) for info.\n";
2216 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2217 let links = extract_cross_file_links(&ctx).relative;
2218
2219 assert_eq!(links.len(), 1);
2220 assert_eq!(links[0].target_path, "./other.md");
2221 assert_eq!(links[0].fragment, "");
2222 assert_eq!(links[0].line, 3);
2223 assert_eq!(links[0].column, 12);
2225 }
2226
2227 #[test]
2228 fn test_extract_cross_file_links_with_fragment() {
2229 use crate::config::MarkdownFlavor;
2230
2231 let content = "Check [guide](./guide.md#install) here.\n";
2232 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2233 let links = extract_cross_file_links(&ctx).relative;
2234
2235 assert_eq!(links.len(), 1);
2236 assert_eq!(links[0].target_path, "./guide.md");
2237 assert_eq!(links[0].fragment, "install");
2238 assert_eq!(links[0].line, 1);
2239 assert_eq!(links[0].column, 15);
2241 }
2242
2243 #[test]
2244 fn indexed_columns_preserve_unicode_and_line_endings() {
2245 for newline in ["\n", "\r\n"] {
2246 for target in ["目标/é.md", "/目标/é.md"] {
2247 let unit = format!("é日本🙂 [label]({target}#part) ");
2248 let content = format!("{}{newline}{}", unit.repeat(80), unit.repeat(80));
2249 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2250 let extracted = extract_cross_file_links(&ctx);
2251 let links = if target.starts_with('/') {
2252 extracted.root_relative
2253 } else {
2254 extracted.relative
2255 };
2256 assert_eq!(links.len(), 160);
2257 for (link, (offset, _)) in links.iter().zip(content.match_indices(target)) {
2258 assert_eq!((link.line, link.column), ctx.offset_to_line_col(offset));
2259 assert_eq!(link.target_path, "目标/é.md");
2260 assert_eq!(link.fragment, "part");
2261 }
2262 }
2263 }
2264 }
2265
2266 #[test]
2267 fn test_extract_cross_file_links_multiple_on_same_line() {
2268 use crate::config::MarkdownFlavor;
2269
2270 let content = "See [a](a.md) and [b](b.md) here.\n";
2271 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2272 let links = extract_cross_file_links(&ctx).relative;
2273
2274 assert_eq!(links.len(), 2);
2275
2276 assert_eq!(links[0].target_path, "a.md");
2277 assert_eq!(links[0].line, 1);
2278 assert_eq!(links[0].column, 9);
2280
2281 assert_eq!(links[1].target_path, "b.md");
2282 assert_eq!(links[1].line, 1);
2283 assert_eq!(links[1].column, 23);
2285 }
2286
2287 #[test]
2288 fn test_extract_cross_file_links_angle_brackets() {
2289 use crate::config::MarkdownFlavor;
2290
2291 let content = "See [link](<path/with (parens).md>) here.\n";
2292 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2293 let links = extract_cross_file_links(&ctx).relative;
2294
2295 assert_eq!(links.len(), 1);
2296 assert_eq!(links[0].target_path, "path/with (parens).md");
2297 assert_eq!(links[0].line, 1);
2298 assert_eq!(links[0].column, 13);
2300 }
2301
2302 #[test]
2303 fn test_extract_cross_file_links_bare_parens_in_destination() {
2304 use crate::config::MarkdownFlavor;
2305
2306 let content = "See [link](docs/file(inner).md) and [plain](docs/other.md).\n";
2310 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2311 let links = extract_cross_file_links(&ctx).relative;
2312
2313 let targets: Vec<&str> = links.iter().map(|l| l.target_path.as_str()).collect();
2314 assert_eq!(targets, vec!["docs/file(inner).md", "docs/other.md"]);
2315 assert_eq!(links[0].column, 12);
2317 }
2318
2319 #[test]
2320 fn test_extract_cross_file_links_skips_external() {
2321 use crate::config::MarkdownFlavor;
2322
2323 let content = r#"
2324[external](https://example.com)
2325[mailto](mailto:test@example.com)
2326[local](./local.md)
2327[fragment](#section)
2328[absolute](/docs/page.md)
2329"#;
2330 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2331 let extracted = extract_cross_file_links(&ctx);
2332
2333 assert_eq!(extracted.relative.len(), 1);
2335 assert_eq!(extracted.relative[0].target_path, "./local.md");
2336 assert_eq!(extracted.root_relative.len(), 1);
2338 assert_eq!(extracted.root_relative[0].target_path, "docs/page.md");
2339 }
2340
2341 #[test]
2342 fn test_extract_cross_file_links_root_relative() {
2343 use crate::config::MarkdownFlavor;
2344
2345 let content = "[a](/guide.md#install)\n[b](/../escape.md)\n[c](//host/x.md)\n[d](/img/pic.png)\n";
2349 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2350 let extracted = extract_cross_file_links(&ctx);
2351
2352 assert!(extracted.relative.is_empty(), "no directory-relative links here");
2353 assert_eq!(
2354 extracted
2355 .root_relative
2356 .iter()
2357 .map(|l| (l.target_path.as_str(), l.fragment.as_str()))
2358 .collect::<Vec<_>>(),
2359 vec![("guide.md", "install")],
2360 "only the safe root-relative markdown link is captured"
2361 );
2362 }
2363
2364 #[test]
2365 fn test_extract_cross_file_links_skips_non_markdown() {
2366 use crate::config::MarkdownFlavor;
2367
2368 let content = r#"
2369[image](./photo.png)
2370[doc](./readme.md)
2371[pdf](./document.pdf)
2372"#;
2373 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2374 let links = extract_cross_file_links(&ctx).relative;
2375
2376 assert_eq!(links.len(), 1);
2378 assert_eq!(links[0].target_path, "./readme.md");
2379 }
2380
2381 #[test]
2382 fn test_extract_cross_file_links_skips_code_spans() {
2383 use crate::config::MarkdownFlavor;
2384
2385 let content = "Normal [link](./file.md) and `[code](./ignored.md)` here.\n";
2386 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2387 let links = extract_cross_file_links(&ctx).relative;
2388
2389 assert_eq!(links.len(), 1);
2391 assert_eq!(links[0].target_path, "./file.md");
2392 }
2393
2394 #[test]
2395 fn test_extract_cross_file_links_with_query_params() {
2396 use crate::config::MarkdownFlavor;
2397
2398 let content = "See [doc](./file.md?raw=true) here.\n";
2399 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2400 let links = extract_cross_file_links(&ctx).relative;
2401
2402 assert_eq!(links.len(), 1);
2403 assert_eq!(links[0].target_path, "./file.md");
2405 }
2406
2407 #[test]
2408 fn test_extract_cross_file_links_empty_content() {
2409 use crate::config::MarkdownFlavor;
2410
2411 let content = "";
2412 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2413 let links = extract_cross_file_links(&ctx).relative;
2414
2415 assert!(links.is_empty());
2416 }
2417
2418 #[test]
2419 fn test_extract_cross_file_links_no_links() {
2420 use crate::config::MarkdownFlavor;
2421
2422 let content = "# Just a heading\n\nSome text without links.\n";
2423 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2424 let links = extract_cross_file_links(&ctx).relative;
2425
2426 assert!(links.is_empty());
2427 }
2428
2429 #[test]
2430 fn test_extract_cross_file_links_position_accuracy_issue_234() {
2431 use crate::config::MarkdownFlavor;
2434
2435 let content = r#"# Test Document
2436
2437Here is a [broken link](nonexistent-file.md) that should trigger MD057.
2438
2439And another [link](also-missing.md) on this line.
2440"#;
2441 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2442 let links = extract_cross_file_links(&ctx).relative;
2443
2444 assert_eq!(links.len(), 2);
2445
2446 assert_eq!(links[0].target_path, "nonexistent-file.md");
2448 assert_eq!(links[0].line, 3);
2449 assert_eq!(links[0].column, 25);
2450
2451 assert_eq!(links[1].target_path, "also-missing.md");
2453 assert_eq!(links[1].line, 5);
2454 assert_eq!(links[1].column, 20);
2455 }
2456
2457 #[test]
2458 fn link_path_part_drops_the_query_and_fragment_and_decodes() {
2459 assert_eq!(link_path_part("guide%20one.md?raw=true#real"), "guide one.md");
2460 assert_eq!(link_path_part("guide%2Emd#real"), "guide.md");
2461 assert_eq!(link_path_part("#only-a-fragment"), "");
2462 assert_eq!(link_path_part("?only=query#x"), "");
2463 }
2464
2465 #[cfg(feature = "native")]
2466 #[test]
2467 fn url_encode_path_is_undone_by_link_path_part() {
2468 for path in [
2469 "plain/guide.md",
2470 "my notes.md",
2471 "50%.md",
2472 "a(b)<c>.md",
2473 "what?.md",
2474 "c#.md",
2475 "back\\slash.md",
2476 "tab\there.md",
2477 "日本 語/é.md",
2478 ] {
2479 let encoded = url_encode_path(path);
2480 assert!(
2481 !encoded.contains([' ', '(', ')', '<', '>', '?', '#', '\\', '\t']),
2482 "{encoded}"
2483 );
2484 assert_eq!(link_path_part(&encoded), path, "{encoded}");
2485 }
2486 assert_eq!(url_encode_path("日本/guide.md"), "日本/guide.md");
2487 assert_eq!(url_encode_path("my notes.md"), "my%20notes.md");
2488 }
2489
2490 #[test]
2491 fn workspace_key_spells_every_path_inside_the_working_directory_relatively() {
2492 let cwd = std::env::current_dir().unwrap();
2493 assert_eq!(workspace_key(Path::new("./docs/a.md")), PathBuf::from("docs/a.md"));
2494 assert_eq!(
2495 workspace_key(&cwd.join("docs/../docs/a.md")),
2496 PathBuf::from("docs/a.md")
2497 );
2498 assert_eq!(
2499 workspace_key(&cwd.canonicalize().unwrap().join("docs/a.md")),
2500 PathBuf::from("docs/a.md")
2501 );
2502 let outside = cwd.parent().unwrap().join("sibling.md");
2505 assert_eq!(workspace_key(Path::new("../sibling.md")), workspace_key(&outside));
2506 assert!(workspace_key(&outside).is_absolute());
2507 }
2508
2509 #[cfg(unix)]
2510 #[test]
2511 fn get_resolved_file_finds_a_canonical_key_through_a_symlinked_spelling() {
2512 let dir = tempfile::tempdir().unwrap();
2513 let real = dir.path().canonicalize().unwrap().join("real");
2514 std::fs::create_dir(&real).unwrap();
2515 std::fs::write(real.join("a.md"), "# A\n").unwrap();
2516 let alias = dir.path().canonicalize().unwrap().join("alias");
2517 std::os::unix::fs::symlink(&real, &alias).unwrap();
2518
2519 let mut index = WorkspaceIndex::new();
2520 let mut file_index = FileIndex::new();
2521 file_index.add_heading(HeadingIndex {
2522 text: "A".to_string(),
2523 auto_anchor: "a".to_string(),
2524 custom_anchor: None,
2525 line: 1,
2526 text_lines: 1,
2527 is_setext: false,
2528 });
2529 index.insert_file(real.join("a.md"), file_index);
2530
2531 assert!(index.get_resolved_file(&alias.join("a.md")).is_some());
2532 assert!(index.get_resolved_file(&alias.join("./sub/../a.md")).is_some());
2533 assert!(index.get_resolved_file(&alias.join("b.md")).is_none());
2534 }
2535}