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}
439
440pub fn link_destinations(ctx: &LintContext, ignored_fields: &HashSet<String>) -> Vec<FrontMatterLink> {
446 let mut links = Vec::new();
447 if ctx.front_matter_end_line() == 0 {
448 return links;
449 }
450
451 let fields = if ignored_fields.is_empty() {
453 Vec::new()
454 } else {
455 field_map(ctx)
456 };
457
458 for (idx, info) in ctx.lines.iter().enumerate() {
459 if !info.in_front_matter {
460 continue;
461 }
462 if let Some(Some(field)) = fields.get(idx)
463 && ignored_fields.contains(field)
464 {
465 continue;
466 }
467
468 let line = info.content(ctx.content);
469 let Some((value_start, value_end)) = value_span(line) else {
470 continue;
471 };
472 let (start, end) = trim_token_bounds(line, value_start, value_end);
473 if start >= end || !is_link_destination(&line[start..end]) {
474 continue;
475 }
476 links.push(FrontMatterLink {
477 line: idx + 1,
478 range: start..end,
479 });
480 }
481
482 links
483}
484
485pub fn is_link_destination(value: &str) -> bool {
501 if value.is_empty() || value.chars().any(char::is_whitespace) {
502 return false;
503 }
504
505 let path = match value.find('#') {
506 Some(0) => return true,
507 Some(i) => &value[..i],
508 None => value,
509 };
510 let path = path.split('?').next().unwrap_or(path);
513
514 let last_segment = path.rsplit('/').next().unwrap_or(path);
515 if has_markdown_extension(last_segment) {
516 return true;
517 }
518
519 path.contains('/')
520 && (path.starts_with('/')
521 || path.starts_with("./")
522 || path.starts_with("../")
523 || path.starts_with("~/")
524 || has_file_extension(last_segment))
525}
526
527fn has_markdown_extension(segment: &str) -> bool {
529 segment.rsplit_once('.').is_some_and(|(stem, ext)| {
530 !stem.is_empty() && MARKDOWN_EXTENSIONS.iter().any(|known| ext.eq_ignore_ascii_case(known))
531 })
532}
533
534fn has_file_extension(segment: &str) -> bool {
538 segment.rsplit_once('.').is_some_and(|(stem, ext)| {
539 !stem.is_empty()
540 && (1..=8).contains(&ext.len())
541 && ext.chars().all(|c| c.is_ascii_alphanumeric())
542 && ext.chars().any(|c| c.is_ascii_alphabetic())
543 })
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549 use crate::config::MarkdownFlavor;
550
551 fn destinations(content: &str) -> Vec<String> {
552 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
553 link_destinations(&ctx, &HashSet::new())
554 .into_iter()
555 .map(|link| {
556 let line = ctx.lines[link.line - 1].content(ctx.content);
557 line[link.range].to_string()
558 })
559 .collect()
560 }
561
562 #[test]
563 fn a_relative_path_reads_as_a_destination() {
564 assert!(is_link_destination("this/is/a/link/to/myapp.md"));
565 assert!(is_link_destination("./other.md"));
566 assert!(is_link_destination("../parent/other"));
567 assert!(is_link_destination("~/notes/other.md"));
568 assert!(is_link_destination("/absolute/other.md"));
569 assert!(is_link_destination("assets/logo.png"));
570 }
571
572 #[test]
573 fn a_bare_markdown_filename_reads_as_a_destination() {
574 assert!(is_link_destination("myapp.md"));
575 assert!(is_link_destination("report.QMD"));
576 }
577
578 #[test]
579 fn a_fragment_reads_as_a_destination() {
580 assert!(is_link_destination("#installation"));
581 assert!(is_link_destination("other.md#installation"));
582 assert!(is_link_destination("docs/other.md#installation"));
583 }
584
585 #[test]
586 fn a_query_string_is_not_part_of_the_path() {
587 assert!(is_link_destination("docs/other.md?raw=true"));
588 assert!(is_link_destination("other.md?raw=true"));
589 assert!(is_link_destination("docs/other.md?raw=true#installation"));
590 assert!(!is_link_destination("what?about/this"));
592 }
593
594 #[test]
595 fn prose_and_path_shaped_values_do_not() {
596 assert!(!is_link_destination("Node.js"));
599 assert!(!is_link_destination("ci/cd"));
601 assert!(!is_link_destination("2026/07/31"));
602 assert!(!is_link_destination("1.2.3"));
603 assert!(!is_link_destination("docs/guides/intro"));
605 assert!(!is_link_destination("a description of docs/a.md"));
607 assert!(!is_link_destination(""));
608 }
609
610 #[test]
611 fn a_destination_is_read_out_of_its_quotes() {
612 assert_eq!(
613 destinations("---\nlink: 'this/is/a/link/to/myapp.md'\n---\n\n# Title\n"),
614 vec!["this/is/a/link/to/myapp.md"]
615 );
616 assert_eq!(
617 destinations("---\nlink: \"docs/a.md\"\n---\n\n# Title\n"),
618 vec!["docs/a.md"]
619 );
620 }
621
622 #[test]
623 fn a_trailing_comment_is_not_part_of_a_destination() {
624 assert_eq!(
625 destinations("---\nlink: docs/a.md # the guide\n---\n\n# Title\n"),
626 vec!["docs/a.md"]
627 );
628 }
629
630 #[test]
631 fn only_frontmatter_is_read() {
632 assert_eq!(
633 destinations("---\nlink: docs/a.md\n---\n\nSee docs/b.md for more.\n"),
634 vec!["docs/a.md"]
635 );
636 }
637
638 #[test]
639 fn a_sequence_item_carries_a_destination() {
640 assert_eq!(
641 destinations("---\nlinks:\n - docs/a.md\n - docs/b.md\n---\n\n# Title\n"),
642 vec!["docs/a.md", "docs/b.md"]
643 );
644 }
645
646 #[test]
647 fn a_toml_value_carries_a_destination() {
648 assert_eq!(
649 destinations("+++\nlink = \"docs/a.md\"\n+++\n\n# Title\n"),
650 vec!["docs/a.md"]
651 );
652 }
653
654 #[test]
655 fn an_ignored_field_hides_its_whole_subtree() {
656 let content = "---\nlink: docs/a.md\nseo:\n canonical: docs/b.md\n---\n\n# Title\n";
657 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
658 let ignored: HashSet<String> = ["seo".to_string()].into_iter().collect();
659 let found: Vec<String> = link_destinations(&ctx, &ignored)
660 .into_iter()
661 .map(|link| ctx.lines[link.line - 1].content(ctx.content)[link.range].to_string())
662 .collect();
663 assert_eq!(found, vec!["docs/a.md"]);
664 }
665
666 #[test]
667 fn a_document_without_frontmatter_has_no_destinations() {
668 assert!(destinations("# Title\n\nSee docs/a.md.\n").is_empty());
669 }
670}