1use crate::discovery::MARKDOWN_EXTENSIONS;
15use crate::lint_context::LintContext;
16use crate::rules::front_matter_utils::FrontMatterUtils;
17use std::collections::HashSet;
18use std::ops::Range;
19
20pub const PATH_TOKEN_WRAPPERS: &[char] = &['\'', '"', '`', '(', ')', '[', ']', '<', '>'];
28
29pub fn value_is_quoted(line: &str, value_start: usize) -> bool {
38 matches!(line[..value_start].chars().next_back(), Some('\'') | Some('"'))
39}
40
41pub fn value_span(line: &str) -> Option<(usize, usize)> {
48 let start = value_offset(line);
49 if start == usize::MAX || start >= line.len() {
50 return None;
51 }
52
53 let before = line[..start].chars().next_back();
58 let at = line[start..].chars().next();
59 let (content_start, quote) = match (before, at) {
60 (Some(q @ ('\'' | '"')), _) => (start, Some(q)),
61 (_, Some(q @ ('\'' | '"'))) => (start + q.len_utf8(), Some(q)),
62 _ => (start, None),
63 };
64
65 let end = if let Some(quote) = quote {
66 let rest = &line[content_start..];
67 match rest.find(quote) {
68 Some(i) => content_start + i,
69 None => content_start + rest.trim_end().len(),
70 }
71 } else {
72 let rest = &line[content_start..];
73 let raw_end = match rest.find(" #") {
74 Some(i) => content_start + i,
75 None => line.len(),
76 };
77 line[..raw_end].trim_end().len()
78 };
79
80 if end <= content_start {
81 None
82 } else {
83 Some((content_start, end))
84 }
85}
86
87pub fn value_offset(line: &str) -> usize {
91 let trimmed = line.trim();
92
93 if trimmed == "---" || trimmed == "+++" || trimmed.is_empty() {
95 return usize::MAX;
96 }
97
98 if trimmed.starts_with('#') {
100 return usize::MAX;
101 }
102
103 let stripped = line.trim_start();
105 if let Some(after_dash) = stripped.strip_prefix("- ") {
106 let leading = line.len() - stripped.len();
107 if let Some(result) = kv_value_offset(line, after_dash, leading + 2) {
109 return result;
110 }
111 return leading + 2;
113 }
114 if stripped == "-" {
115 return usize::MAX;
116 }
117
118 if let Some(result) = kv_value_offset(line, stripped, line.len() - stripped.len()) {
120 return result;
121 }
122
123 if let Some(eq_pos) = line.find('=') {
125 let after_eq = eq_pos + 1;
126 if after_eq < line.len() && line.as_bytes()[after_eq] == b' ' {
127 let value_start = after_eq + 1;
128 let value_slice = &line[value_start..];
129 let value_trimmed = value_slice.trim();
130 if value_trimmed.is_empty() {
131 return usize::MAX;
132 }
133 if (value_trimmed.starts_with('"') && value_trimmed.ends_with('"'))
135 || (value_trimmed.starts_with('\'') && value_trimmed.ends_with('\''))
136 {
137 let quote_offset = value_slice.find(['"', '\'']).unwrap_or(0);
138 return value_start + quote_offset + 1;
139 }
140 return value_start;
141 }
142 return usize::MAX;
144 }
145
146 0
148}
149
150fn kv_value_offset(line: &str, content: &str, base_offset: usize) -> Option<usize> {
154 let colon_pos = content.find(':')?;
155 let abs_colon = base_offset + colon_pos;
156 let after_colon = abs_colon + 1;
157 if after_colon < line.len() && line.as_bytes()[after_colon] == b' ' {
158 let value_start = after_colon + 1;
159 let value_slice = &line[value_start..];
160 let value_trimmed = value_slice.trim();
161 if value_trimmed.is_empty() {
162 return Some(usize::MAX);
163 }
164 if value_trimmed.starts_with('{') || value_trimmed.starts_with('[') {
166 return Some(usize::MAX);
167 }
168 if (value_trimmed.starts_with('"') && value_trimmed.ends_with('"'))
170 || (value_trimmed.starts_with('\'') && value_trimmed.ends_with('\''))
171 {
172 let quote_offset = value_slice.find(['"', '\'']).unwrap_or(0);
173 return Some(value_start + quote_offset + 1);
174 }
175 return Some(value_start);
176 }
177 Some(usize::MAX)
179}
180
181pub fn token_bounds(line: &str, pos: usize, value_start: usize, value_end: usize) -> (usize, usize) {
187 let before = &line[value_start..pos];
188 let start = before.rfind(char::is_whitespace).map_or(value_start, |i| {
189 value_start + i + before[i..].chars().next().unwrap().len_utf8()
190 });
191
192 let after = &line[pos..value_end];
193 let end = after.find(char::is_whitespace).map_or(value_end, |i| pos + i);
194
195 (start, end)
196}
197
198pub fn trim_token_bounds(line: &str, mut start: usize, mut end: usize) -> (usize, usize) {
204 const TRAILING: &[char] = &['.', ',', ';', ':', '!', '?'];
205 while start < end && line[start..end].starts_with(PATH_TOKEN_WRAPPERS) {
206 start += line[start..].chars().next().unwrap().len_utf8();
207 }
208 loop {
209 let before = (start, end);
210 while end > start && line[start..end].ends_with(PATH_TOKEN_WRAPPERS) {
211 end -= line[..end].chars().next_back().unwrap().len_utf8();
212 }
213 while end > start && line[start..end].ends_with(TRAILING) {
214 end -= line[..end].chars().next_back().unwrap().len_utf8();
215 }
216 if (start, end) == before {
217 break;
218 }
219 }
220 (start, end)
221}
222
223fn find_unquoted(s: &str, target: char) -> Option<usize> {
227 let mut in_double = false;
228 let mut in_single = false;
229 let mut chars = s.char_indices();
230 while let Some((i, c)) = chars.next() {
231 if in_double {
232 if c == '\\' {
233 chars.next();
234 } else if c == '"' {
235 in_double = false;
236 }
237 } else if in_single {
238 if c == '\'' {
239 in_single = false;
240 }
241 } else if c == target {
242 return Some(i);
243 } else if c == '"' {
244 in_double = true;
245 } else if c == '\'' {
246 in_single = true;
247 }
248 }
249 None
250}
251
252fn toml_table_header(trimmed: &str) -> Option<&str> {
267 let head = match find_unquoted(trimmed, '#') {
268 Some(i) => trimmed[..i].trim_end(),
269 None => trimmed,
270 };
271
272 let inner = if let Some(rest) = head.strip_prefix("[[") {
273 rest.strip_suffix("]]")?
274 } else {
275 head.strip_prefix('[')?.strip_suffix(']')?
276 };
277
278 if find_unquoted(inner, ',').is_some() {
279 return None;
280 }
281
282 let inner = inner.trim();
283 if inner.is_empty() { None } else { Some(inner) }
284}
285
286fn toml_bracket_delta(trimmed: &str) -> i32 {
291 let mut delta = 0i32;
292 let mut chars = trimmed.chars();
293 let mut in_double = false;
294 let mut in_single = false;
295 while let Some(c) = chars.next() {
296 if in_double {
297 if c == '\\' {
298 chars.next();
299 } else if c == '"' {
300 in_double = false;
301 }
302 } else if in_single {
303 if c == '\'' {
304 in_single = false;
305 }
306 } else {
307 match c {
308 '"' => in_double = true,
309 '\'' => in_single = true,
310 '[' => delta += 1,
311 ']' => delta -= 1,
312 _ => {}
313 }
314 }
315 }
316 delta
317}
318
319fn strip_key_quotes(raw: &str) -> &str {
320 raw.strip_prefix('"')
321 .and_then(|k| k.strip_suffix('"'))
322 .or_else(|| raw.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')))
323 .unwrap_or(raw)
324}
325
326pub fn field_map(ctx: &LintContext) -> Vec<Option<String>> {
336 let mut map = vec![None; ctx.lines.len()];
337 let mut current: Option<String> = None;
338 let mut toml = false;
339 let mut in_toml_table = false;
340 let mut toml_array_depth: i32 = 0;
344
345 for (idx, info) in ctx.lines.iter().enumerate() {
346 if !info.in_front_matter {
347 continue;
348 }
349 let line = info.content(ctx.content);
350 let trimmed = line.trim();
351
352 if trimmed == "---" || trimmed == "+++" {
353 toml = trimmed == "+++";
354 current = None;
355 in_toml_table = false;
356 toml_array_depth = 0;
357 continue;
358 }
359 if trimmed.is_empty() || trimmed.starts_with('#') {
360 map[idx].clone_from(¤t);
361 continue;
362 }
363
364 if toml {
365 let indent = line.len() - line.trim_start().len();
379 let header = if indent == 0 && toml_array_depth == 0 {
380 toml_table_header(trimmed)
381 } else {
382 None
383 };
384 let assignment_eq = if indent == 0 {
385 FrontMatterUtils::separator_pos_outside_quoted_key(trimmed, '=')
386 } else {
387 None
388 };
389 let resync = header.is_some() || assignment_eq.is_some();
390
391 if resync {
392 if let Some(name) = header {
393 current = Some(FrontMatterUtils::toml_root_key(name).to_lowercase());
394 in_toml_table = true;
395 } else if !in_toml_table && let Some(eq) = assignment_eq {
396 let root = FrontMatterUtils::toml_root_key(trimmed[..eq].trim());
397 current = Some(root.to_lowercase());
398 }
399 toml_array_depth = 0;
402 }
403 toml_array_depth = (toml_array_depth + toml_bracket_delta(trimmed)).max(0);
404 } else {
405 let indent = line.len() - line.trim_start().len();
414 if indent == 0 {
415 if trimmed.starts_with("- ") || trimmed == "-" {
416 current = None;
417 } else if let Some(colon) = FrontMatterUtils::separator_pos_outside_quoted_key(trimmed, ':') {
418 let raw = trimmed[..colon].trim();
419 current = Some(strip_key_quotes(raw).to_lowercase());
420 }
421 }
422 }
425 map[idx].clone_from(¤t);
426 }
427 map
428}
429
430#[derive(Debug, Clone, PartialEq, Eq)]
432pub struct FrontMatterLink {
433 pub line: usize,
435 pub range: Range<usize>,
438 pub field: Option<String>,
441}
442
443impl FrontMatterLink {
444 pub fn field_is_in(&self, fields: &HashSet<String>) -> bool {
449 self.field.as_ref().is_some_and(|field| fields.contains(field))
450 }
451}
452
453pub fn link_destinations(ctx: &LintContext) -> Vec<FrontMatterLink> {
460 let mut links = Vec::new();
461 if ctx.front_matter_end_line() == 0 {
462 return links;
463 }
464
465 for (idx, info) in ctx.lines.iter().enumerate() {
466 if !info.in_front_matter {
467 continue;
468 }
469
470 let line = info.content(ctx.content);
471 let Some((value_start, value_end)) = value_span(line) else {
472 continue;
473 };
474 let (start, end) = trim_token_bounds(line, value_start, value_end);
475 if start >= end || !is_link_destination(&line[start..end]) {
476 continue;
477 }
478 links.push(FrontMatterLink {
479 line: idx + 1,
480 range: start..end,
481 field: None,
482 });
483 }
484
485 if !links.is_empty() {
489 let fields = field_map(ctx);
490 for link in &mut links {
491 link.field = fields.get(link.line - 1).cloned().flatten();
492 }
493 }
494
495 links
496}
497
498pub fn is_link_destination(value: &str) -> bool {
514 if value.is_empty() || value.chars().any(char::is_whitespace) {
515 return false;
516 }
517
518 let path = match value.find('#') {
519 Some(0) => return true,
520 Some(i) => &value[..i],
521 None => value,
522 };
523 let path = path.split('?').next().unwrap_or(path);
526
527 let last_segment = path.rsplit('/').next().unwrap_or(path);
528 if has_markdown_extension(last_segment) {
529 return true;
530 }
531
532 path.contains('/')
533 && (path.starts_with('/')
534 || path.starts_with("./")
535 || path.starts_with("../")
536 || path.starts_with("~/")
537 || has_file_extension(last_segment))
538}
539
540fn has_markdown_extension(segment: &str) -> bool {
542 segment.rsplit_once('.').is_some_and(|(stem, ext)| {
543 !stem.is_empty() && MARKDOWN_EXTENSIONS.iter().any(|known| ext.eq_ignore_ascii_case(known))
544 })
545}
546
547fn has_file_extension(segment: &str) -> bool {
551 segment.rsplit_once('.').is_some_and(|(stem, ext)| {
552 !stem.is_empty()
553 && (1..=8).contains(&ext.len())
554 && ext.chars().all(|c| c.is_ascii_alphanumeric())
555 && ext.chars().any(|c| c.is_ascii_alphabetic())
556 })
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562 use crate::config::MarkdownFlavor;
563
564 fn destinations(content: &str) -> Vec<String> {
565 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
566 link_destinations(&ctx)
567 .into_iter()
568 .map(|link| {
569 let line = ctx.lines[link.line - 1].content(ctx.content);
570 line[link.range].to_string()
571 })
572 .collect()
573 }
574
575 #[test]
576 fn a_relative_path_reads_as_a_destination() {
577 assert!(is_link_destination("this/is/a/link/to/myapp.md"));
578 assert!(is_link_destination("./other.md"));
579 assert!(is_link_destination("../parent/other"));
580 assert!(is_link_destination("~/notes/other.md"));
581 assert!(is_link_destination("/absolute/other.md"));
582 assert!(is_link_destination("assets/logo.png"));
583 }
584
585 #[test]
586 fn a_bare_markdown_filename_reads_as_a_destination() {
587 assert!(is_link_destination("myapp.md"));
588 assert!(is_link_destination("report.QMD"));
589 }
590
591 #[test]
592 fn a_fragment_reads_as_a_destination() {
593 assert!(is_link_destination("#installation"));
594 assert!(is_link_destination("other.md#installation"));
595 assert!(is_link_destination("docs/other.md#installation"));
596 }
597
598 #[test]
599 fn a_query_string_is_not_part_of_the_path() {
600 assert!(is_link_destination("docs/other.md?raw=true"));
601 assert!(is_link_destination("other.md?raw=true"));
602 assert!(is_link_destination("docs/other.md?raw=true#installation"));
603 assert!(!is_link_destination("what?about/this"));
605 }
606
607 #[test]
608 fn prose_and_path_shaped_values_do_not() {
609 assert!(!is_link_destination("Node.js"));
612 assert!(!is_link_destination("ci/cd"));
614 assert!(!is_link_destination("2026/07/31"));
615 assert!(!is_link_destination("1.2.3"));
616 assert!(!is_link_destination("docs/guides/intro"));
618 assert!(!is_link_destination("a description of docs/a.md"));
620 assert!(!is_link_destination(""));
621 }
622
623 #[test]
624 fn a_destination_is_read_out_of_its_quotes() {
625 assert_eq!(
626 destinations("---\nlink: 'this/is/a/link/to/myapp.md'\n---\n\n# Title\n"),
627 vec!["this/is/a/link/to/myapp.md"]
628 );
629 assert_eq!(
630 destinations("---\nlink: \"docs/a.md\"\n---\n\n# Title\n"),
631 vec!["docs/a.md"]
632 );
633 }
634
635 #[test]
636 fn a_trailing_comment_is_not_part_of_a_destination() {
637 assert_eq!(
638 destinations("---\nlink: docs/a.md # the guide\n---\n\n# Title\n"),
639 vec!["docs/a.md"]
640 );
641 }
642
643 #[test]
644 fn only_frontmatter_is_read() {
645 assert_eq!(
646 destinations("---\nlink: docs/a.md\n---\n\nSee docs/b.md for more.\n"),
647 vec!["docs/a.md"]
648 );
649 }
650
651 #[test]
652 fn a_sequence_item_carries_a_destination() {
653 assert_eq!(
654 destinations("---\nlinks:\n - docs/a.md\n - docs/b.md\n---\n\n# Title\n"),
655 vec!["docs/a.md", "docs/b.md"]
656 );
657 }
658
659 #[test]
660 fn a_toml_value_carries_a_destination() {
661 assert_eq!(
662 destinations("+++\nlink = \"docs/a.md\"\n+++\n\n# Title\n"),
663 vec!["docs/a.md"]
664 );
665 }
666
667 #[test]
668 fn a_destination_carries_the_field_owning_it_through_a_whole_subtree() {
669 let content = "---\nlink: docs/a.md\nseo:\n canonical: docs/b.md\n---\n\n# Title\n";
670 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
671 let links = link_destinations(&ctx);
672
673 let owners: Vec<Option<&str>> = links.iter().map(|link| link.field.as_deref()).collect();
674 assert_eq!(owners, vec![Some("link"), Some("seo")]);
675
676 let ignored: HashSet<String> = ["seo".to_string()].into_iter().collect();
679 let kept: Vec<String> = links
680 .iter()
681 .filter(|link| !link.field_is_in(&ignored))
682 .map(|link| ctx.lines[link.line - 1].content(ctx.content)[link.range.clone()].to_string())
683 .collect();
684 assert_eq!(kept, vec!["docs/a.md"]);
685 }
686
687 #[test]
688 fn a_destination_with_no_determinable_owner_belongs_to_no_field() {
689 let ctx = LintContext::new("---\n- docs/a.md\n---\n\n# Title\n", MarkdownFlavor::Standard, None);
693 let links = link_destinations(&ctx);
694 assert_eq!(links.len(), 1);
695 assert_eq!(links[0].field, None);
696 assert!(!links[0].field_is_in(&["docs".to_string()].into_iter().collect()));
697 }
698
699 #[test]
700 fn a_document_without_frontmatter_has_no_destinations() {
701 assert!(destinations("# Title\n\nSee docs/a.md.\n").is_empty());
702 }
703}