1use regex::Regex;
22use serde::{Deserialize, Serialize};
23use std::collections::{HashMap, HashSet};
24use std::path::{Path, PathBuf};
25use std::sync::LazyLock;
26
27use crate::lint_context::LintContext;
28use crate::utils::range_utils::byte_to_char_count;
29
30fn hex_digit_to_value(c: u8) -> Option<u8> {
36 match c {
37 b'0'..=b'9' => Some(c - b'0'),
38 b'a'..=b'f' => Some(c - b'a' + 10),
39 b'A'..=b'F' => Some(c - b'A' + 10),
40 _ => None,
41 }
42}
43
44fn url_decode(s: &str) -> String {
48 if !s.contains('%') {
50 return s.to_string();
51 }
52
53 let bytes = s.as_bytes();
54 let mut result = Vec::with_capacity(bytes.len());
55 let mut i = 0;
56
57 while i < bytes.len() {
58 if bytes[i] == b'%' && i + 2 < bytes.len() {
59 let hex1 = bytes[i + 1];
61 let hex2 = bytes[i + 2];
62 if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
63 result.push(d1 * 16 + d2);
64 i += 3;
65 continue;
66 }
67 }
68 result.push(bytes[i]);
69 i += 1;
70 }
71
72 String::from_utf8(result).unwrap_or_else(|_| s.to_string())
74}
75
76static LINK_START_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!?\[[^\]]*\]").unwrap());
86
87static URL_EXTRACT_ANGLE_BRACKET_REGEX: LazyLock<Regex> =
90 LazyLock::new(|| Regex::new(r#"\]\(\s*<([^>]+)>(#[^\)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
91
92static URL_EXTRACT_REGEX: LazyLock<Regex> =
95 LazyLock::new(|| Regex::new(r#"]\(\s*([^>)\s#]+)(#[^)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
96
97pub(crate) static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
99 LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
100
101#[inline]
103fn is_markdown_file(path: &str) -> bool {
104 crate::discovery::has_markdown_extension(std::path::Path::new(path))
105}
106
107fn strip_query_and_fragment(url: &str) -> &str {
110 let query_pos = url.find('?');
111 let fragment_pos = url.find('#');
112
113 match (query_pos, fragment_pos) {
114 (Some(q), Some(f)) => &url[..q.min(f)],
115 (Some(q), None) => &url[..q],
116 (None, Some(f)) => &url[..f],
117 (None, None) => url,
118 }
119}
120
121#[derive(Debug, Default)]
128pub struct ExtractedCrossFileLinks {
129 pub relative: Vec<CrossFileLinkIndex>,
131 pub root_relative: Vec<CrossFileLinkIndex>,
135}
136
137pub fn extract_cross_file_links(ctx: &LintContext) -> ExtractedCrossFileLinks {
145 let content = ctx.content;
146
147 if content.is_empty() || !content.contains("](") {
149 return ExtractedCrossFileLinks::default();
150 }
151
152 let mut links = ExtractedCrossFileLinks::default();
153 let lines: Vec<&str> = content.lines().collect();
154 let line_index = &ctx.line_index;
155
156 let mut processed_lines = HashSet::new();
159
160 for link in &ctx.links {
161 let line_idx = link.line - 1;
162 if line_idx >= lines.len() {
163 continue;
164 }
165
166 if !processed_lines.insert(line_idx) {
168 continue;
169 }
170
171 let line = lines[line_idx];
172 if !line.contains("](") {
173 continue;
174 }
175
176 for link_match in LINK_START_REGEX.find_iter(line) {
178 let start_pos = link_match.start();
179 let end_pos = link_match.end();
180
181 let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
183 let absolute_start_pos = line_start_byte + start_pos;
184
185 if ctx.is_in_code_span_byte(absolute_start_pos) {
187 continue;
188 }
189
190 let caps_result = URL_EXTRACT_ANGLE_BRACKET_REGEX
193 .captures_at(line, end_pos - 1)
194 .or_else(|| URL_EXTRACT_REGEX.captures_at(line, end_pos - 1));
195
196 if let Some(caps) = caps_result
197 && let Some(url_group) = caps.get(1)
198 {
199 let file_path = url_group.as_str().trim();
200
201 if let Some(rel) = file_path.strip_prefix('/') {
206 if !rel.starts_with('/')
207 && !Path::new(rel)
208 .components()
209 .any(|c| matches!(c, std::path::Component::ParentDir))
210 {
211 let stripped = strip_query_and_fragment(rel);
212 if is_markdown_file(stripped) {
213 let fragment = caps.get(2).map_or("", |m| m.as_str().trim_start_matches('#'));
214 links.root_relative.push(CrossFileLinkIndex {
215 target_path: stripped.to_string(),
216 fragment: fragment.to_string(),
217 line: link.line,
218 column: byte_to_char_count(line, url_group.start()),
219 });
220 }
221 }
222 continue;
223 }
224
225 if file_path.is_empty()
228 || PROTOCOL_DOMAIN_REGEX.is_match(file_path)
229 || file_path.starts_with("www.")
230 || file_path.starts_with('#')
231 || file_path.starts_with("{{")
232 || file_path.starts_with("{%")
233 || file_path.starts_with('~')
234 || file_path.starts_with('@')
235 || (file_path.starts_with('`') && file_path.ends_with('`'))
236 {
237 continue;
238 }
239
240 let file_path = strip_query_and_fragment(file_path);
242
243 let fragment = caps.get(2).map_or("", |m| m.as_str().trim_start_matches('#'));
245
246 if is_markdown_file(file_path) {
248 links.relative.push(CrossFileLinkIndex {
249 target_path: file_path.to_string(),
250 fragment: fragment.to_string(),
251 line: link.line,
252 column: byte_to_char_count(line, url_group.start()),
253 });
254 }
255 }
256 }
257 }
258
259 links
260}
261
262#[cfg(feature = "postcard")]
264const CACHE_MAGIC: &[u8; 4] = b"RWSI";
265
266#[cfg(feature = "postcard")]
272const CACHE_FORMAT_VERSION: u32 = 8;
273
274#[cfg(feature = "postcard")]
276const CACHE_FILE_NAME: &str = "workspace_index.bin";
277
278#[cfg(feature = "postcard")]
282static CACHE_TEMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
283
284#[derive(Debug, Default, Clone, Serialize, Deserialize)]
289pub struct WorkspaceIndex {
290 files: HashMap<PathBuf, FileIndex>,
292 reverse_deps: HashMap<PathBuf, HashSet<PathBuf>>,
295 version: u64,
297}
298
299#[derive(Debug, Clone, Default, Serialize, Deserialize)]
301pub struct FileIndex {
302 pub headings: Vec<HeadingIndex>,
304 pub reference_links: Vec<ReferenceLinkIndex>,
306 pub cross_file_links: Vec<CrossFileLinkIndex>,
308 #[serde(default)]
313 pub root_relative_links: Vec<CrossFileLinkIndex>,
314 pub defined_references: HashSet<String>,
317 pub content_hash: String,
319 anchor_to_heading: HashMap<String, usize>,
322 #[serde(default)]
326 anchor_to_heading_exact: HashMap<String, usize>,
327 html_anchors: HashSet<String>,
330 #[serde(default)]
333 html_anchors_exact: HashSet<String>,
334 attribute_anchors: HashSet<String>,
338 #[serde(default)]
341 attribute_anchors_exact: HashSet<String>,
342 pub file_disabled_rules: HashSet<String>,
345 pub persistent_transitions: Vec<(usize, HashSet<String>, HashSet<String>)>,
348 pub line_disabled_rules: HashMap<usize, HashSet<String>>,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
354pub struct HeadingIndex {
355 pub text: String,
357 pub auto_anchor: String,
359 pub custom_anchor: Option<String>,
361 pub line: usize,
363 #[serde(default)]
365 pub is_setext: bool,
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct ReferenceLinkIndex {
371 pub reference_id: String,
373 pub line: usize,
375 pub column: usize,
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct CrossFileLinkIndex {
382 pub target_path: String,
384 pub fragment: String,
386 pub line: usize,
388 pub column: usize,
390}
391
392#[derive(Debug, Clone, Serialize, Deserialize)]
394pub struct VulnerableAnchor {
395 pub file: PathBuf,
397 pub line: usize,
399 pub text: String,
401}
402
403impl WorkspaceIndex {
404 pub fn new() -> Self {
406 Self::default()
407 }
408
409 pub fn version(&self) -> u64 {
411 self.version
412 }
413
414 pub fn file_count(&self) -> usize {
416 self.files.len()
417 }
418
419 pub fn contains_file(&self, path: &Path) -> bool {
421 self.files.contains_key(path)
422 }
423
424 pub fn get_file(&self, path: &Path) -> Option<&FileIndex> {
426 self.files.get(path)
427 }
428
429 pub fn insert_file(&mut self, path: PathBuf, index: FileIndex) {
431 self.files.insert(path, index);
432 self.version = self.version.wrapping_add(1);
433 }
434
435 pub fn remove_file(&mut self, path: &Path) -> Option<FileIndex> {
437 self.clear_reverse_deps_for(path);
439
440 let result = self.files.remove(path);
441 if result.is_some() {
442 self.version = self.version.wrapping_add(1);
443 }
444 result
445 }
446
447 pub fn get_vulnerable_anchors(&self) -> HashMap<String, Vec<VulnerableAnchor>> {
457 let mut vulnerable: HashMap<String, Vec<VulnerableAnchor>> = HashMap::new();
458
459 for (file_path, file_index) in &self.files {
460 for heading in &file_index.headings {
461 if heading.custom_anchor.is_none() && !heading.auto_anchor.is_empty() {
463 let anchor_key = heading.auto_anchor.to_lowercase();
464 vulnerable.entry(anchor_key).or_default().push(VulnerableAnchor {
465 file: file_path.clone(),
466 line: heading.line,
467 text: heading.text.clone(),
468 });
469 }
470 }
471 }
472
473 vulnerable
474 }
475
476 pub fn all_headings(&self) -> impl Iterator<Item = (&Path, &HeadingIndex)> {
478 self.files
479 .iter()
480 .flat_map(|(path, index)| index.headings.iter().map(move |h| (path.as_path(), h)))
481 }
482
483 pub fn files(&self) -> impl Iterator<Item = (&Path, &FileIndex)> {
485 self.files.iter().map(|(p, i)| (p.as_path(), i))
486 }
487
488 pub fn files_sorted(&self) -> Vec<(&Path, &FileIndex)> {
494 let mut entries: Vec<(&Path, &FileIndex)> = self.files.iter().map(|(p, i)| (p.as_path(), i)).collect();
495 entries.sort_by_key(|(a, _)| *a);
496 entries
497 }
498
499 pub fn clear(&mut self) {
501 self.files.clear();
502 self.reverse_deps.clear();
503 self.version = self.version.wrapping_add(1);
504 }
505
506 pub fn update_file(&mut self, path: &Path, index: FileIndex) {
513 self.clear_reverse_deps_as_source(path);
516
517 for link in &index.cross_file_links {
519 let target = self.resolve_target_path(path, &link.target_path);
520 self.reverse_deps.entry(target).or_default().insert(path.to_path_buf());
521 }
522
523 self.files.insert(path.to_path_buf(), index);
524 self.version = self.version.wrapping_add(1);
525 }
526
527 pub fn get_dependents(&self, path: &Path) -> Vec<PathBuf> {
532 self.reverse_deps
533 .get(path)
534 .map(|set| set.iter().cloned().collect())
535 .unwrap_or_default()
536 }
537
538 pub fn is_file_stale(&self, path: &Path, current_hash: &str) -> bool {
542 self.files.get(path).is_none_or(|f| f.content_hash != current_hash)
543 }
544
545 pub fn retain_only(&mut self, current_files: &std::collections::HashSet<PathBuf>) -> usize {
550 let before_count = self.files.len();
551
552 let to_remove: Vec<PathBuf> = self
554 .files
555 .keys()
556 .filter(|path| !current_files.contains(*path))
557 .cloned()
558 .collect();
559
560 for path in &to_remove {
562 self.remove_file(path);
563 }
564
565 before_count - self.files.len()
566 }
567
568 #[cfg(feature = "postcard")]
575 pub fn save_to_cache(&self, cache_dir: &Path) -> std::io::Result<()> {
576 use std::fs;
577 use std::io::Write;
578
579 fs::create_dir_all(cache_dir)?;
581
582 let encoded = postcard::to_allocvec(self)
584 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
585
586 let mut cache_data = Vec::with_capacity(8 + encoded.len());
588 cache_data.extend_from_slice(CACHE_MAGIC);
589 cache_data.extend_from_slice(&CACHE_FORMAT_VERSION.to_le_bytes());
590 cache_data.extend_from_slice(&encoded);
591
592 let final_path = cache_dir.join(CACHE_FILE_NAME);
597 let counter = CACHE_TEMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
598 #[cfg(not(target_arch = "wasm32"))]
599 let temp_path = cache_dir.join(format!("{CACHE_FILE_NAME}.tmp.{}.{counter}", std::process::id()));
600 #[cfg(target_arch = "wasm32")]
601 let temp_path = cache_dir.join(format!("{CACHE_FILE_NAME}.tmp.{counter}"));
602
603 {
605 let mut file = fs::File::create(&temp_path)?;
606 file.write_all(&cache_data)?;
607 file.sync_all()?;
608 }
609
610 fs::rename(&temp_path, &final_path)?;
612
613 log::debug!(
614 "Saved workspace index to cache: {} files, {} bytes (format v{})",
615 self.files.len(),
616 cache_data.len(),
617 CACHE_FORMAT_VERSION
618 );
619
620 Ok(())
621 }
622
623 #[cfg(feature = "postcard")]
631 pub fn load_from_cache(cache_dir: &Path) -> Option<Self> {
632 use std::fs;
633
634 let path = cache_dir.join(CACHE_FILE_NAME);
635 let data = fs::read(&path).ok()?;
636
637 if data.len() < 8 {
639 log::warn!("Workspace index cache too small, discarding");
640 let _ = fs::remove_file(&path);
641 return None;
642 }
643
644 if &data[0..4] != CACHE_MAGIC {
646 log::warn!("Workspace index cache has invalid magic header, discarding");
647 let _ = fs::remove_file(&path);
648 return None;
649 }
650
651 let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
653 if version != CACHE_FORMAT_VERSION {
654 log::info!(
655 "Workspace index cache format version mismatch (got {version}, expected {CACHE_FORMAT_VERSION}), rebuilding"
656 );
657 let _ = fs::remove_file(&path);
658 return None;
659 }
660
661 match postcard::from_bytes::<Self>(&data[8..]) {
663 Ok(index) => {
664 log::debug!(
665 "Loaded workspace index from cache: {} files (format v{})",
666 index.files.len(),
667 version
668 );
669 Some(index)
670 }
671 Err(e) => {
672 log::warn!("Failed to deserialize workspace index cache: {e}");
673 let _ = fs::remove_file(&path);
674 None
675 }
676 }
677 }
678
679 fn clear_reverse_deps_as_source(&mut self, path: &Path) {
684 let targets: Vec<PathBuf> = match self.files.get(path) {
691 Some(index) => index
692 .cross_file_links
693 .iter()
694 .map(|link| self.resolve_target_path(path, &link.target_path))
695 .collect(),
696 None => return,
697 };
698 for target in targets {
699 if let Some(deps) = self.reverse_deps.get_mut(&target) {
700 deps.remove(path);
701 if deps.is_empty() {
702 self.reverse_deps.remove(&target);
703 }
704 }
705 }
706 }
707
708 fn clear_reverse_deps_for(&mut self, path: &Path) {
713 self.clear_reverse_deps_as_source(path);
715
716 self.reverse_deps.remove(path);
718 }
719
720 fn resolve_target_path(&self, source_file: &Path, relative_target: &str) -> PathBuf {
722 let source_dir = source_file.parent().unwrap_or(Path::new(""));
724
725 let target = source_dir.join(relative_target);
727
728 Self::normalize_path(&target)
730 }
731
732 fn normalize_path(path: &Path) -> PathBuf {
734 let mut components = Vec::new();
735
736 for component in path.components() {
737 match component {
738 std::path::Component::ParentDir => {
739 if !components.is_empty() {
741 components.pop();
742 }
743 }
744 std::path::Component::CurDir => {
745 }
747 _ => {
748 components.push(component);
749 }
750 }
751 }
752
753 components.iter().collect()
754 }
755}
756
757impl FileIndex {
758 pub fn new() -> Self {
760 Self::default()
761 }
762
763 pub fn with_hash(content_hash: String) -> Self {
765 Self {
766 content_hash,
767 ..Default::default()
768 }
769 }
770
771 pub fn add_heading(&mut self, heading: HeadingIndex) {
777 let index = self.headings.len();
778
779 self.anchor_to_heading.insert(heading.auto_anchor.to_lowercase(), index);
782 self.anchor_to_heading_exact.insert(heading.auto_anchor.clone(), index);
783
784 if let Some(ref custom) = heading.custom_anchor {
786 self.anchor_to_heading.insert(custom.to_lowercase(), index);
787 self.anchor_to_heading_exact.insert(custom.clone(), index);
788 }
789
790 self.headings.push(heading);
791 }
792
793 pub fn add_anchor_alias(&mut self, anchor: &str, heading_index: usize) {
796 if heading_index < self.headings.len() {
797 self.anchor_to_heading.insert(anchor.to_lowercase(), heading_index);
798 self.anchor_to_heading_exact.insert(anchor.to_string(), heading_index);
799 }
800 }
801
802 pub fn has_anchor(&self, anchor: &str) -> bool {
813 self.has_anchor_with_case(anchor, true)
814 }
815
816 pub fn has_anchor_with_case(&self, anchor: &str, ignore_case: bool) -> bool {
825 if self.lookup_anchor(anchor, ignore_case) {
826 return true;
827 }
828
829 if anchor.contains('%') {
831 let decoded = url_decode(anchor);
832 if decoded != anchor {
833 return self.lookup_anchor(&decoded, ignore_case);
834 }
835 }
836
837 false
838 }
839
840 fn lookup_anchor(&self, anchor: &str, ignore_case: bool) -> bool {
843 if ignore_case {
844 let lower = anchor.to_lowercase();
845 self.anchor_to_heading.contains_key(&lower)
846 || self.html_anchors.contains(&lower)
847 || self.attribute_anchors.contains(&lower)
848 } else {
849 self.anchor_to_heading_exact.contains_key(anchor)
850 || self.html_anchors_exact.contains(anchor)
851 || self.attribute_anchors_exact.contains(anchor)
852 }
853 }
854
855 pub fn add_html_anchor(&mut self, anchor: &str) {
858 if !anchor.is_empty() {
859 self.html_anchors.insert(anchor.to_lowercase());
860 self.html_anchors_exact.insert(anchor.to_string());
861 }
862 }
863
864 pub fn add_attribute_anchor(&mut self, anchor: &str) {
867 if !anchor.is_empty() {
868 self.attribute_anchors.insert(anchor.to_lowercase());
869 self.attribute_anchors_exact.insert(anchor.to_string());
870 }
871 }
872
873 pub fn get_heading_by_anchor(&self, anchor: &str) -> Option<&HeadingIndex> {
877 self.anchor_to_heading
878 .get(&anchor.to_lowercase())
879 .and_then(|&idx| self.headings.get(idx))
880 }
881
882 pub fn add_reference_link(&mut self, link: ReferenceLinkIndex) {
884 self.reference_links.push(link);
885 }
886
887 pub fn is_rule_disabled_at_line(&self, rule_name: &str, line: usize) -> bool {
892 if self.file_disabled_rules.contains("*") || self.file_disabled_rules.contains(rule_name) {
894 return true;
895 }
896
897 if let Some(rules) = self.line_disabled_rules.get(&line)
899 && (rules.contains("*") || rules.contains(rule_name))
900 {
901 return true;
902 }
903
904 if !self.persistent_transitions.is_empty() {
906 let idx = match self.persistent_transitions.binary_search_by_key(&line, |t| t.0) {
907 Ok(i) => Some(i),
908 Err(i) => {
909 if i > 0 {
910 Some(i - 1)
911 } else {
912 None
913 }
914 }
915 };
916 if let Some(i) = idx {
917 let (_, ref disabled, ref enabled) = self.persistent_transitions[i];
918 if disabled.contains("*") {
919 return !enabled.contains(rule_name);
920 }
921 return disabled.contains(rule_name);
922 }
923 }
924
925 false
926 }
927
928 pub fn add_cross_file_link(&mut self, link: CrossFileLinkIndex) {
930 let is_duplicate = self.cross_file_links.iter().any(|existing| {
933 existing.target_path == link.target_path && existing.fragment == link.fragment && existing.line == link.line
934 });
935 if !is_duplicate {
936 self.cross_file_links.push(link);
937 }
938 }
939
940 pub fn add_root_relative_link(&mut self, link: CrossFileLinkIndex) {
942 let is_duplicate = self.root_relative_links.iter().any(|existing| {
943 existing.target_path == link.target_path && existing.fragment == link.fragment && existing.line == link.line
944 });
945 if !is_duplicate {
946 self.root_relative_links.push(link);
947 }
948 }
949
950 pub fn add_defined_reference(&mut self, ref_id: String) {
952 self.defined_references.insert(ref_id);
953 }
954
955 pub fn has_defined_reference(&self, ref_id: &str) -> bool {
957 self.defined_references.contains(ref_id)
958 }
959
960 pub fn hash_matches(&self, hash: &str) -> bool {
962 self.content_hash == hash
963 }
964
965 pub fn heading_count(&self) -> usize {
967 self.headings.len()
968 }
969
970 pub fn reference_link_count(&self) -> usize {
972 self.reference_links.len()
973 }
974}
975
976#[cfg(test)]
977mod tests {
978 use super::*;
979
980 #[test]
981 fn test_workspace_index_basic() {
982 let mut index = WorkspaceIndex::new();
983 assert_eq!(index.file_count(), 0);
984 assert_eq!(index.version(), 0);
985
986 let mut file_index = FileIndex::with_hash("abc123".to_string());
987 file_index.add_heading(HeadingIndex {
988 text: "Installation".to_string(),
989 auto_anchor: "installation".to_string(),
990 custom_anchor: None,
991 line: 1,
992 is_setext: false,
993 });
994
995 index.insert_file(PathBuf::from("docs/install.md"), file_index);
996 assert_eq!(index.file_count(), 1);
997 assert_eq!(index.version(), 1);
998
999 assert!(index.contains_file(Path::new("docs/install.md")));
1000 assert!(!index.contains_file(Path::new("docs/other.md")));
1001 }
1002
1003 #[test]
1004 fn test_vulnerable_anchors() {
1005 let mut index = WorkspaceIndex::new();
1006
1007 let mut file1 = FileIndex::new();
1009 file1.add_heading(HeadingIndex {
1010 text: "Getting Started".to_string(),
1011 auto_anchor: "getting-started".to_string(),
1012 custom_anchor: None,
1013 line: 1,
1014 is_setext: false,
1015 });
1016 index.insert_file(PathBuf::from("docs/guide.md"), file1);
1017
1018 let mut file2 = FileIndex::new();
1020 file2.add_heading(HeadingIndex {
1021 text: "Installation".to_string(),
1022 auto_anchor: "installation".to_string(),
1023 custom_anchor: Some("install".to_string()),
1024 line: 1,
1025 is_setext: false,
1026 });
1027 index.insert_file(PathBuf::from("docs/install.md"), file2);
1028
1029 let vulnerable = index.get_vulnerable_anchors();
1030 assert_eq!(vulnerable.len(), 1);
1031 assert!(vulnerable.contains_key("getting-started"));
1032 assert!(!vulnerable.contains_key("installation"));
1033
1034 let anchors = vulnerable.get("getting-started").unwrap();
1035 assert_eq!(anchors.len(), 1);
1036 assert_eq!(anchors[0].file, PathBuf::from("docs/guide.md"));
1037 assert_eq!(anchors[0].text, "Getting Started");
1038 }
1039
1040 #[test]
1041 fn test_vulnerable_anchors_multiple_files_same_anchor() {
1042 let mut index = WorkspaceIndex::new();
1045
1046 let mut file1 = FileIndex::new();
1048 file1.add_heading(HeadingIndex {
1049 text: "Installation".to_string(),
1050 auto_anchor: "installation".to_string(),
1051 custom_anchor: None,
1052 line: 1,
1053 is_setext: false,
1054 });
1055 index.insert_file(PathBuf::from("docs/en/guide.md"), file1);
1056
1057 let mut file2 = FileIndex::new();
1059 file2.add_heading(HeadingIndex {
1060 text: "Installation".to_string(),
1061 auto_anchor: "installation".to_string(),
1062 custom_anchor: None,
1063 line: 5,
1064 is_setext: false,
1065 });
1066 index.insert_file(PathBuf::from("docs/fr/guide.md"), file2);
1067
1068 let mut file3 = FileIndex::new();
1070 file3.add_heading(HeadingIndex {
1071 text: "Installation".to_string(),
1072 auto_anchor: "installation".to_string(),
1073 custom_anchor: Some("install".to_string()),
1074 line: 10,
1075 is_setext: false,
1076 });
1077 index.insert_file(PathBuf::from("docs/de/guide.md"), file3);
1078
1079 let vulnerable = index.get_vulnerable_anchors();
1080 assert_eq!(vulnerable.len(), 1); assert!(vulnerable.contains_key("installation"));
1082
1083 let anchors = vulnerable.get("installation").unwrap();
1084 assert_eq!(anchors.len(), 2, "Should collect both vulnerable anchors");
1086
1087 let files: std::collections::HashSet<_> = anchors.iter().map(|a| &a.file).collect();
1089 assert!(files.contains(&PathBuf::from("docs/en/guide.md")));
1090 assert!(files.contains(&PathBuf::from("docs/fr/guide.md")));
1091 }
1092
1093 #[test]
1094 fn test_file_index_hash() {
1095 let index = FileIndex::with_hash("hash123".to_string());
1096 assert!(index.hash_matches("hash123"));
1097 assert!(!index.hash_matches("other"));
1098 }
1099
1100 #[test]
1101 fn test_version_increment() {
1102 let mut index = WorkspaceIndex::new();
1103 assert_eq!(index.version(), 0);
1104
1105 index.insert_file(PathBuf::from("a.md"), FileIndex::new());
1106 assert_eq!(index.version(), 1);
1107
1108 index.insert_file(PathBuf::from("b.md"), FileIndex::new());
1109 assert_eq!(index.version(), 2);
1110
1111 index.remove_file(Path::new("a.md"));
1112 assert_eq!(index.version(), 3);
1113
1114 index.remove_file(Path::new("nonexistent.md"));
1116 assert_eq!(index.version(), 3);
1117 }
1118
1119 #[test]
1120 fn test_files_sorted_is_path_ordered() {
1121 let mut index = WorkspaceIndex::new();
1122 for name in ["docs/zebra.md", "docs/apple.md", "docs/mango.md"] {
1124 index.update_file(Path::new(name), FileIndex::new());
1125 }
1126
1127 let paths: Vec<&Path> = index.files_sorted().into_iter().map(|(p, _)| p).collect();
1128 assert_eq!(
1129 paths,
1130 vec![
1131 Path::new("docs/apple.md"),
1132 Path::new("docs/mango.md"),
1133 Path::new("docs/zebra.md"),
1134 ],
1135 "files_sorted() must return entries ordered by path"
1136 );
1137 }
1138
1139 #[test]
1140 fn test_reverse_deps_basic() {
1141 let mut index = WorkspaceIndex::new();
1142
1143 let mut file_a = FileIndex::new();
1145 file_a.add_cross_file_link(CrossFileLinkIndex {
1146 target_path: "b.md".to_string(),
1147 fragment: "section".to_string(),
1148 line: 10,
1149 column: 5,
1150 });
1151 index.update_file(Path::new("docs/a.md"), file_a);
1152
1153 let dependents = index.get_dependents(Path::new("docs/b.md"));
1155 assert_eq!(dependents.len(), 1);
1156 assert_eq!(dependents[0], PathBuf::from("docs/a.md"));
1157
1158 let a_dependents = index.get_dependents(Path::new("docs/a.md"));
1160 assert!(a_dependents.is_empty());
1161 }
1162
1163 #[test]
1164 fn test_reverse_deps_multiple() {
1165 let mut index = WorkspaceIndex::new();
1166
1167 let mut file_a = FileIndex::new();
1169 file_a.add_cross_file_link(CrossFileLinkIndex {
1170 target_path: "../b.md".to_string(),
1171 fragment: "".to_string(),
1172 line: 1,
1173 column: 1,
1174 });
1175 index.update_file(Path::new("docs/sub/a.md"), file_a);
1176
1177 let mut file_c = FileIndex::new();
1178 file_c.add_cross_file_link(CrossFileLinkIndex {
1179 target_path: "b.md".to_string(),
1180 fragment: "".to_string(),
1181 line: 1,
1182 column: 1,
1183 });
1184 index.update_file(Path::new("docs/c.md"), file_c);
1185
1186 let dependents = index.get_dependents(Path::new("docs/b.md"));
1188 assert_eq!(dependents.len(), 2);
1189 assert!(dependents.contains(&PathBuf::from("docs/sub/a.md")));
1190 assert!(dependents.contains(&PathBuf::from("docs/c.md")));
1191 }
1192
1193 #[test]
1194 fn test_reverse_deps_update_clears_old() {
1195 let mut index = WorkspaceIndex::new();
1196
1197 let mut file_a = FileIndex::new();
1199 file_a.add_cross_file_link(CrossFileLinkIndex {
1200 target_path: "b.md".to_string(),
1201 fragment: "".to_string(),
1202 line: 1,
1203 column: 1,
1204 });
1205 index.update_file(Path::new("docs/a.md"), file_a);
1206
1207 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1209
1210 let mut file_a_updated = FileIndex::new();
1212 file_a_updated.add_cross_file_link(CrossFileLinkIndex {
1213 target_path: "c.md".to_string(),
1214 fragment: "".to_string(),
1215 line: 1,
1216 column: 1,
1217 });
1218 index.update_file(Path::new("docs/a.md"), file_a_updated);
1219
1220 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1222
1223 let c_deps = index.get_dependents(Path::new("docs/c.md"));
1225 assert_eq!(c_deps.len(), 1);
1226 assert_eq!(c_deps[0], PathBuf::from("docs/a.md"));
1227 }
1228
1229 #[test]
1230 fn test_reverse_deps_remove_file() {
1231 let mut index = WorkspaceIndex::new();
1232
1233 let mut file_a = FileIndex::new();
1235 file_a.add_cross_file_link(CrossFileLinkIndex {
1236 target_path: "b.md".to_string(),
1237 fragment: "".to_string(),
1238 line: 1,
1239 column: 1,
1240 });
1241 index.update_file(Path::new("docs/a.md"), file_a);
1242
1243 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1245
1246 index.remove_file(Path::new("docs/a.md"));
1248
1249 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1251 }
1252
1253 #[test]
1254 fn test_normalize_path() {
1255 let path = Path::new("docs/sub/../other.md");
1257 let normalized = WorkspaceIndex::normalize_path(path);
1258 assert_eq!(normalized, PathBuf::from("docs/other.md"));
1259
1260 let path2 = Path::new("docs/./other.md");
1262 let normalized2 = WorkspaceIndex::normalize_path(path2);
1263 assert_eq!(normalized2, PathBuf::from("docs/other.md"));
1264
1265 let path3 = Path::new("a/b/c/../../d.md");
1267 let normalized3 = WorkspaceIndex::normalize_path(path3);
1268 assert_eq!(normalized3, PathBuf::from("a/d.md"));
1269 }
1270
1271 #[test]
1272 fn test_clear_clears_reverse_deps() {
1273 let mut index = WorkspaceIndex::new();
1274
1275 let mut file_a = FileIndex::new();
1277 file_a.add_cross_file_link(CrossFileLinkIndex {
1278 target_path: "b.md".to_string(),
1279 fragment: "".to_string(),
1280 line: 1,
1281 column: 1,
1282 });
1283 index.update_file(Path::new("docs/a.md"), file_a);
1284
1285 assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1287
1288 index.clear();
1290
1291 assert_eq!(index.file_count(), 0);
1293 assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1294 }
1295
1296 #[test]
1297 fn test_is_file_stale() {
1298 let mut index = WorkspaceIndex::new();
1299
1300 assert!(index.is_file_stale(Path::new("nonexistent.md"), "hash123"));
1302
1303 let file_index = FileIndex::with_hash("hash123".to_string());
1305 index.insert_file(PathBuf::from("docs/test.md"), file_index);
1306
1307 assert!(!index.is_file_stale(Path::new("docs/test.md"), "hash123"));
1309
1310 assert!(index.is_file_stale(Path::new("docs/test.md"), "different_hash"));
1312 }
1313
1314 #[cfg(feature = "native")]
1315 #[test]
1316 fn test_cache_roundtrip() {
1317 use std::fs;
1318
1319 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_roundtrip");
1321 let _ = fs::remove_dir_all(&temp_dir);
1322 fs::create_dir_all(&temp_dir).unwrap();
1323
1324 let mut index = WorkspaceIndex::new();
1326
1327 let mut file1 = FileIndex::with_hash("abc123".to_string());
1328 file1.add_heading(HeadingIndex {
1329 text: "Test Heading".to_string(),
1330 auto_anchor: "test-heading".to_string(),
1331 custom_anchor: Some("test".to_string()),
1332 line: 1,
1333 is_setext: false,
1334 });
1335 file1.add_cross_file_link(CrossFileLinkIndex {
1336 target_path: "./other.md".to_string(),
1337 fragment: "section".to_string(),
1338 line: 5,
1339 column: 3,
1340 });
1341 index.update_file(Path::new("docs/file1.md"), file1);
1342
1343 let mut file2 = FileIndex::with_hash("def456".to_string());
1344 file2.add_heading(HeadingIndex {
1345 text: "Another Heading".to_string(),
1346 auto_anchor: "another-heading".to_string(),
1347 custom_anchor: None,
1348 line: 1,
1349 is_setext: false,
1350 });
1351 index.update_file(Path::new("docs/other.md"), file2);
1352
1353 index.save_to_cache(&temp_dir).expect("Failed to save cache");
1355
1356 assert!(temp_dir.join("workspace_index.bin").exists());
1358
1359 let loaded = WorkspaceIndex::load_from_cache(&temp_dir).expect("Failed to load cache");
1361
1362 assert_eq!(loaded.file_count(), 2);
1364 assert!(loaded.contains_file(Path::new("docs/file1.md")));
1365 assert!(loaded.contains_file(Path::new("docs/other.md")));
1366
1367 let file1_loaded = loaded.get_file(Path::new("docs/file1.md")).unwrap();
1369 assert_eq!(file1_loaded.content_hash, "abc123");
1370 assert_eq!(file1_loaded.headings.len(), 1);
1371 assert_eq!(file1_loaded.headings[0].text, "Test Heading");
1372 assert_eq!(file1_loaded.headings[0].custom_anchor, Some("test".to_string()));
1373 assert_eq!(file1_loaded.cross_file_links.len(), 1);
1374 assert_eq!(file1_loaded.cross_file_links[0].target_path, "./other.md");
1375
1376 let dependents = loaded.get_dependents(Path::new("docs/other.md"));
1378 assert_eq!(dependents.len(), 1);
1379 assert_eq!(dependents[0], PathBuf::from("docs/file1.md"));
1380
1381 let _ = fs::remove_dir_all(&temp_dir);
1383 }
1384
1385 #[cfg(feature = "native")]
1386 #[test]
1387 fn test_cache_missing_file() {
1388 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_missing");
1389 let _ = std::fs::remove_dir_all(&temp_dir);
1390
1391 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1393 assert!(result.is_none());
1394 }
1395
1396 #[cfg(feature = "native")]
1397 #[test]
1398 fn test_cache_corrupted_file() {
1399 use std::fs;
1400
1401 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_corrupted");
1402 let _ = fs::remove_dir_all(&temp_dir);
1403 fs::create_dir_all(&temp_dir).unwrap();
1404
1405 fs::write(temp_dir.join("workspace_index.bin"), b"bad").unwrap();
1407
1408 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1410 assert!(result.is_none());
1411
1412 assert!(!temp_dir.join("workspace_index.bin").exists());
1414
1415 let _ = fs::remove_dir_all(&temp_dir);
1417 }
1418
1419 #[cfg(feature = "native")]
1420 #[test]
1421 fn test_cache_invalid_magic() {
1422 use std::fs;
1423
1424 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_invalid_magic");
1425 let _ = fs::remove_dir_all(&temp_dir);
1426 fs::create_dir_all(&temp_dir).unwrap();
1427
1428 let mut data = Vec::new();
1430 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();
1434
1435 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1437 assert!(result.is_none());
1438
1439 assert!(!temp_dir.join("workspace_index.bin").exists());
1441
1442 let _ = fs::remove_dir_all(&temp_dir);
1444 }
1445
1446 #[cfg(feature = "native")]
1447 #[test]
1448 fn test_cache_version_mismatch() {
1449 use std::fs;
1450
1451 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_version_mismatch");
1452 let _ = fs::remove_dir_all(&temp_dir);
1453 fs::create_dir_all(&temp_dir).unwrap();
1454
1455 let mut data = Vec::new();
1457 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();
1461
1462 let result = WorkspaceIndex::load_from_cache(&temp_dir);
1464 assert!(result.is_none());
1465
1466 assert!(!temp_dir.join("workspace_index.bin").exists());
1468
1469 let _ = fs::remove_dir_all(&temp_dir);
1471 }
1472
1473 #[cfg(feature = "native")]
1474 #[test]
1475 fn test_cache_atomic_write() {
1476 use std::fs;
1477
1478 let temp_dir = std::env::temp_dir().join("rumdl_test_cache_atomic");
1480 let _ = fs::remove_dir_all(&temp_dir);
1481 fs::create_dir_all(&temp_dir).unwrap();
1482
1483 let index = WorkspaceIndex::new();
1484 index.save_to_cache(&temp_dir).expect("Failed to save");
1485
1486 let entries: Vec<_> = fs::read_dir(&temp_dir).unwrap().collect();
1488 assert_eq!(entries.len(), 1);
1489 assert!(temp_dir.join("workspace_index.bin").exists());
1490
1491 let _ = fs::remove_dir_all(&temp_dir);
1493 }
1494
1495 #[test]
1496 fn test_has_anchor_auto_generated() {
1497 let mut file_index = FileIndex::new();
1498 file_index.add_heading(HeadingIndex {
1499 text: "Installation Guide".to_string(),
1500 auto_anchor: "installation-guide".to_string(),
1501 custom_anchor: None,
1502 line: 1,
1503 is_setext: false,
1504 });
1505
1506 assert!(file_index.has_anchor("installation-guide"));
1508
1509 assert!(file_index.has_anchor("Installation-Guide"));
1511 assert!(file_index.has_anchor("INSTALLATION-GUIDE"));
1512
1513 assert!(!file_index.has_anchor("nonexistent"));
1515 }
1516
1517 #[test]
1518 fn test_has_anchor_custom() {
1519 let mut file_index = FileIndex::new();
1520 file_index.add_heading(HeadingIndex {
1521 text: "Installation Guide".to_string(),
1522 auto_anchor: "installation-guide".to_string(),
1523 custom_anchor: Some("install".to_string()),
1524 line: 1,
1525 is_setext: false,
1526 });
1527
1528 assert!(file_index.has_anchor("installation-guide"));
1530
1531 assert!(file_index.has_anchor("install"));
1533 assert!(file_index.has_anchor("Install")); assert!(!file_index.has_anchor("nonexistent"));
1537 }
1538
1539 #[test]
1540 fn test_get_heading_by_anchor() {
1541 let mut file_index = FileIndex::new();
1542 file_index.add_heading(HeadingIndex {
1543 text: "Installation Guide".to_string(),
1544 auto_anchor: "installation-guide".to_string(),
1545 custom_anchor: Some("install".to_string()),
1546 line: 10,
1547 is_setext: false,
1548 });
1549 file_index.add_heading(HeadingIndex {
1550 text: "Configuration".to_string(),
1551 auto_anchor: "configuration".to_string(),
1552 custom_anchor: None,
1553 line: 20,
1554 is_setext: false,
1555 });
1556
1557 let heading = file_index.get_heading_by_anchor("installation-guide");
1559 assert!(heading.is_some());
1560 assert_eq!(heading.unwrap().text, "Installation Guide");
1561 assert_eq!(heading.unwrap().line, 10);
1562
1563 let heading = file_index.get_heading_by_anchor("install");
1565 assert!(heading.is_some());
1566 assert_eq!(heading.unwrap().text, "Installation Guide");
1567
1568 let heading = file_index.get_heading_by_anchor("configuration");
1570 assert!(heading.is_some());
1571 assert_eq!(heading.unwrap().text, "Configuration");
1572 assert_eq!(heading.unwrap().line, 20);
1573
1574 assert!(file_index.get_heading_by_anchor("nonexistent").is_none());
1576 }
1577
1578 #[test]
1579 fn test_anchor_lookup_many_headings() {
1580 let mut file_index = FileIndex::new();
1582
1583 for i in 0..100 {
1585 file_index.add_heading(HeadingIndex {
1586 text: format!("Heading {i}"),
1587 auto_anchor: format!("heading-{i}"),
1588 custom_anchor: Some(format!("h{i}")),
1589 line: i + 1,
1590 is_setext: false,
1591 });
1592 }
1593
1594 for i in 0..100 {
1596 assert!(file_index.has_anchor(&format!("heading-{i}")));
1597 assert!(file_index.has_anchor(&format!("h{i}")));
1598
1599 let heading = file_index.get_heading_by_anchor(&format!("heading-{i}"));
1600 assert!(heading.is_some());
1601 assert_eq!(heading.unwrap().line, i + 1);
1602 }
1603 }
1604
1605 #[test]
1610 fn test_extract_cross_file_links_basic() {
1611 use crate::config::MarkdownFlavor;
1612
1613 let content = "# Test\n\nSee [link](./other.md) for info.\n";
1614 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1615 let links = extract_cross_file_links(&ctx).relative;
1616
1617 assert_eq!(links.len(), 1);
1618 assert_eq!(links[0].target_path, "./other.md");
1619 assert_eq!(links[0].fragment, "");
1620 assert_eq!(links[0].line, 3);
1621 assert_eq!(links[0].column, 12);
1623 }
1624
1625 #[test]
1626 fn test_extract_cross_file_links_with_fragment() {
1627 use crate::config::MarkdownFlavor;
1628
1629 let content = "Check [guide](./guide.md#install) here.\n";
1630 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1631 let links = extract_cross_file_links(&ctx).relative;
1632
1633 assert_eq!(links.len(), 1);
1634 assert_eq!(links[0].target_path, "./guide.md");
1635 assert_eq!(links[0].fragment, "install");
1636 assert_eq!(links[0].line, 1);
1637 assert_eq!(links[0].column, 15);
1639 }
1640
1641 #[test]
1642 fn test_extract_cross_file_links_multiple_on_same_line() {
1643 use crate::config::MarkdownFlavor;
1644
1645 let content = "See [a](a.md) and [b](b.md) here.\n";
1646 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1647 let links = extract_cross_file_links(&ctx).relative;
1648
1649 assert_eq!(links.len(), 2);
1650
1651 assert_eq!(links[0].target_path, "a.md");
1652 assert_eq!(links[0].line, 1);
1653 assert_eq!(links[0].column, 9);
1655
1656 assert_eq!(links[1].target_path, "b.md");
1657 assert_eq!(links[1].line, 1);
1658 assert_eq!(links[1].column, 23);
1660 }
1661
1662 #[test]
1663 fn test_extract_cross_file_links_angle_brackets() {
1664 use crate::config::MarkdownFlavor;
1665
1666 let content = "See [link](<path/with (parens).md>) here.\n";
1667 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1668 let links = extract_cross_file_links(&ctx).relative;
1669
1670 assert_eq!(links.len(), 1);
1671 assert_eq!(links[0].target_path, "path/with (parens).md");
1672 assert_eq!(links[0].line, 1);
1673 assert_eq!(links[0].column, 13);
1675 }
1676
1677 #[test]
1678 fn test_extract_cross_file_links_skips_external() {
1679 use crate::config::MarkdownFlavor;
1680
1681 let content = r#"
1682[external](https://example.com)
1683[mailto](mailto:test@example.com)
1684[local](./local.md)
1685[fragment](#section)
1686[absolute](/docs/page.md)
1687"#;
1688 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1689 let extracted = extract_cross_file_links(&ctx);
1690
1691 assert_eq!(extracted.relative.len(), 1);
1693 assert_eq!(extracted.relative[0].target_path, "./local.md");
1694 assert_eq!(extracted.root_relative.len(), 1);
1696 assert_eq!(extracted.root_relative[0].target_path, "docs/page.md");
1697 }
1698
1699 #[test]
1700 fn test_extract_cross_file_links_root_relative() {
1701 use crate::config::MarkdownFlavor;
1702
1703 let content = "[a](/guide.md#install)\n[b](/../escape.md)\n[c](//host/x.md)\n[d](/img/pic.png)\n";
1707 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1708 let extracted = extract_cross_file_links(&ctx);
1709
1710 assert!(extracted.relative.is_empty(), "no directory-relative links here");
1711 assert_eq!(
1712 extracted
1713 .root_relative
1714 .iter()
1715 .map(|l| (l.target_path.as_str(), l.fragment.as_str()))
1716 .collect::<Vec<_>>(),
1717 vec![("guide.md", "install")],
1718 "only the safe root-relative markdown link is captured"
1719 );
1720 }
1721
1722 #[test]
1723 fn test_extract_cross_file_links_skips_non_markdown() {
1724 use crate::config::MarkdownFlavor;
1725
1726 let content = r#"
1727[image](./photo.png)
1728[doc](./readme.md)
1729[pdf](./document.pdf)
1730"#;
1731 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1732 let links = extract_cross_file_links(&ctx).relative;
1733
1734 assert_eq!(links.len(), 1);
1736 assert_eq!(links[0].target_path, "./readme.md");
1737 }
1738
1739 #[test]
1740 fn test_extract_cross_file_links_skips_code_spans() {
1741 use crate::config::MarkdownFlavor;
1742
1743 let content = "Normal [link](./file.md) and `[code](./ignored.md)` here.\n";
1744 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1745 let links = extract_cross_file_links(&ctx).relative;
1746
1747 assert_eq!(links.len(), 1);
1749 assert_eq!(links[0].target_path, "./file.md");
1750 }
1751
1752 #[test]
1753 fn test_extract_cross_file_links_with_query_params() {
1754 use crate::config::MarkdownFlavor;
1755
1756 let content = "See [doc](./file.md?raw=true) here.\n";
1757 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1758 let links = extract_cross_file_links(&ctx).relative;
1759
1760 assert_eq!(links.len(), 1);
1761 assert_eq!(links[0].target_path, "./file.md");
1763 }
1764
1765 #[test]
1766 fn test_extract_cross_file_links_empty_content() {
1767 use crate::config::MarkdownFlavor;
1768
1769 let content = "";
1770 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1771 let links = extract_cross_file_links(&ctx).relative;
1772
1773 assert!(links.is_empty());
1774 }
1775
1776 #[test]
1777 fn test_extract_cross_file_links_no_links() {
1778 use crate::config::MarkdownFlavor;
1779
1780 let content = "# Just a heading\n\nSome text without links.\n";
1781 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1782 let links = extract_cross_file_links(&ctx).relative;
1783
1784 assert!(links.is_empty());
1785 }
1786
1787 #[test]
1788 fn test_extract_cross_file_links_position_accuracy_issue_234() {
1789 use crate::config::MarkdownFlavor;
1792
1793 let content = r#"# Test Document
1794
1795Here is a [broken link](nonexistent-file.md) that should trigger MD057.
1796
1797And another [link](also-missing.md) on this line.
1798"#;
1799 let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1800 let links = extract_cross_file_links(&ctx).relative;
1801
1802 assert_eq!(links.len(), 2);
1803
1804 assert_eq!(links[0].target_path, "nonexistent-file.md");
1806 assert_eq!(links[0].line, 3);
1807 assert_eq!(links[0].column, 25);
1808
1809 assert_eq!(links[1].target_path, "also-missing.md");
1811 assert_eq!(links[1].line, 5);
1812 assert_eq!(links[1].column, 20);
1813 }
1814}