use crate::discovery::MARKDOWN_EXTENSIONS;
use crate::lint_context::LintContext;
use crate::rules::front_matter_utils::FrontMatterUtils;
use std::collections::HashSet;
use std::ops::Range;
pub const PATH_TOKEN_WRAPPERS: &[char] = &['\'', '"', '`', '(', ')', '[', ']', '<', '>'];
pub fn value_is_quoted(line: &str, value_start: usize) -> bool {
matches!(line[..value_start].chars().next_back(), Some('\'') | Some('"'))
}
pub fn value_span(line: &str) -> Option<(usize, usize)> {
let start = value_offset(line);
if start == usize::MAX || start >= line.len() {
return None;
}
let before = line[..start].chars().next_back();
let at = line[start..].chars().next();
let (content_start, quote) = match (before, at) {
(Some(q @ ('\'' | '"')), _) => (start, Some(q)),
(_, Some(q @ ('\'' | '"'))) => (start + q.len_utf8(), Some(q)),
_ => (start, None),
};
let end = if let Some(quote) = quote {
let rest = &line[content_start..];
match rest.find(quote) {
Some(i) => content_start + i,
None => content_start + rest.trim_end().len(),
}
} else {
let rest = &line[content_start..];
let raw_end = match rest.find(" #") {
Some(i) => content_start + i,
None => line.len(),
};
line[..raw_end].trim_end().len()
};
if end <= content_start {
None
} else {
Some((content_start, end))
}
}
pub fn value_offset(line: &str) -> usize {
let trimmed = line.trim();
if trimmed == "---" || trimmed == "+++" || trimmed.is_empty() {
return usize::MAX;
}
if trimmed.starts_with('#') {
return usize::MAX;
}
let stripped = line.trim_start();
if let Some(after_dash) = stripped.strip_prefix("- ") {
let leading = line.len() - stripped.len();
if let Some(result) = kv_value_offset(line, after_dash, leading + 2) {
return result;
}
return leading + 2;
}
if stripped == "-" {
return usize::MAX;
}
if let Some(result) = kv_value_offset(line, stripped, line.len() - stripped.len()) {
return result;
}
if let Some(eq_pos) = line.find('=') {
let after_eq = eq_pos + 1;
if after_eq < line.len() && line.as_bytes()[after_eq] == b' ' {
let value_start = after_eq + 1;
let value_slice = &line[value_start..];
let value_trimmed = value_slice.trim();
if value_trimmed.is_empty() {
return usize::MAX;
}
if (value_trimmed.starts_with('"') && value_trimmed.ends_with('"'))
|| (value_trimmed.starts_with('\'') && value_trimmed.ends_with('\''))
{
let quote_offset = value_slice.find(['"', '\'']).unwrap_or(0);
return value_start + quote_offset + 1;
}
return value_start;
}
return usize::MAX;
}
0
}
fn kv_value_offset(line: &str, content: &str, base_offset: usize) -> Option<usize> {
let colon_pos = content.find(':')?;
let abs_colon = base_offset + colon_pos;
let after_colon = abs_colon + 1;
if after_colon < line.len() && line.as_bytes()[after_colon] == b' ' {
let value_start = after_colon + 1;
let value_slice = &line[value_start..];
let value_trimmed = value_slice.trim();
if value_trimmed.is_empty() {
return Some(usize::MAX);
}
if value_trimmed.starts_with('{') || value_trimmed.starts_with('[') {
return Some(usize::MAX);
}
if (value_trimmed.starts_with('"') && value_trimmed.ends_with('"'))
|| (value_trimmed.starts_with('\'') && value_trimmed.ends_with('\''))
{
let quote_offset = value_slice.find(['"', '\'']).unwrap_or(0);
return Some(value_start + quote_offset + 1);
}
return Some(value_start);
}
Some(usize::MAX)
}
pub fn token_bounds(line: &str, pos: usize, value_start: usize, value_end: usize) -> (usize, usize) {
let before = &line[value_start..pos];
let start = before.rfind(char::is_whitespace).map_or(value_start, |i| {
value_start + i + before[i..].chars().next().unwrap().len_utf8()
});
let after = &line[pos..value_end];
let end = after.find(char::is_whitespace).map_or(value_end, |i| pos + i);
(start, end)
}
pub fn trim_token_bounds(line: &str, mut start: usize, mut end: usize) -> (usize, usize) {
const TRAILING: &[char] = &['.', ',', ';', ':', '!', '?'];
while start < end && line[start..end].starts_with(PATH_TOKEN_WRAPPERS) {
start += line[start..].chars().next().unwrap().len_utf8();
}
loop {
let before = (start, end);
while end > start && line[start..end].ends_with(PATH_TOKEN_WRAPPERS) {
end -= line[..end].chars().next_back().unwrap().len_utf8();
}
while end > start && line[start..end].ends_with(TRAILING) {
end -= line[..end].chars().next_back().unwrap().len_utf8();
}
if (start, end) == before {
break;
}
}
(start, end)
}
fn find_unquoted(s: &str, target: char) -> Option<usize> {
let mut in_double = false;
let mut in_single = false;
let mut chars = s.char_indices();
while let Some((i, c)) = chars.next() {
if in_double {
if c == '\\' {
chars.next();
} else if c == '"' {
in_double = false;
}
} else if in_single {
if c == '\'' {
in_single = false;
}
} else if c == target {
return Some(i);
} else if c == '"' {
in_double = true;
} else if c == '\'' {
in_single = true;
}
}
None
}
fn toml_table_header(trimmed: &str) -> Option<&str> {
let head = match find_unquoted(trimmed, '#') {
Some(i) => trimmed[..i].trim_end(),
None => trimmed,
};
let inner = if let Some(rest) = head.strip_prefix("[[") {
rest.strip_suffix("]]")?
} else {
head.strip_prefix('[')?.strip_suffix(']')?
};
if find_unquoted(inner, ',').is_some() {
return None;
}
let inner = inner.trim();
if inner.is_empty() { None } else { Some(inner) }
}
fn toml_bracket_delta(trimmed: &str) -> i32 {
let mut delta = 0i32;
let mut chars = trimmed.chars();
let mut in_double = false;
let mut in_single = false;
while let Some(c) = chars.next() {
if in_double {
if c == '\\' {
chars.next();
} else if c == '"' {
in_double = false;
}
} else if in_single {
if c == '\'' {
in_single = false;
}
} else {
match c {
'"' => in_double = true,
'\'' => in_single = true,
'[' => delta += 1,
']' => delta -= 1,
_ => {}
}
}
}
delta
}
fn strip_key_quotes(raw: &str) -> &str {
raw.strip_prefix('"')
.and_then(|k| k.strip_suffix('"'))
.or_else(|| raw.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')))
.unwrap_or(raw)
}
pub fn field_map(ctx: &LintContext) -> Vec<Option<String>> {
let mut map = vec![None; ctx.lines.len()];
let mut current: Option<String> = None;
let mut toml = false;
let mut in_toml_table = false;
let mut toml_array_depth: i32 = 0;
for (idx, info) in ctx.lines.iter().enumerate() {
if !info.in_front_matter {
continue;
}
let line = info.content(ctx.content);
let trimmed = line.trim();
if trimmed == "---" || trimmed == "+++" {
toml = trimmed == "+++";
current = None;
in_toml_table = false;
toml_array_depth = 0;
continue;
}
if trimmed.is_empty() || trimmed.starts_with('#') {
map[idx].clone_from(¤t);
continue;
}
if toml {
let indent = line.len() - line.trim_start().len();
let header = if indent == 0 && toml_array_depth == 0 {
toml_table_header(trimmed)
} else {
None
};
let assignment_eq = if indent == 0 {
FrontMatterUtils::separator_pos_outside_quoted_key(trimmed, '=')
} else {
None
};
let resync = header.is_some() || assignment_eq.is_some();
if resync {
if let Some(name) = header {
current = Some(FrontMatterUtils::toml_root_key(name).to_lowercase());
in_toml_table = true;
} else if !in_toml_table && let Some(eq) = assignment_eq {
let root = FrontMatterUtils::toml_root_key(trimmed[..eq].trim());
current = Some(root.to_lowercase());
}
toml_array_depth = 0;
}
toml_array_depth = (toml_array_depth + toml_bracket_delta(trimmed)).max(0);
} else {
let indent = line.len() - line.trim_start().len();
if indent == 0 {
if trimmed.starts_with("- ") || trimmed == "-" {
current = None;
} else if let Some(colon) = FrontMatterUtils::separator_pos_outside_quoted_key(trimmed, ':') {
let raw = trimmed[..colon].trim();
current = Some(strip_key_quotes(raw).to_lowercase());
}
}
}
map[idx].clone_from(¤t);
}
map
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FrontMatterLink {
pub line: usize,
pub range: Range<usize>,
pub field: Option<String>,
}
impl FrontMatterLink {
pub fn field_is_in(&self, fields: &HashSet<String>) -> bool {
self.field.as_ref().is_some_and(|field| fields.contains(field))
}
}
pub fn link_destinations(ctx: &LintContext) -> Vec<FrontMatterLink> {
let mut links = Vec::new();
if ctx.front_matter_end_line() == 0 {
return links;
}
for (idx, info) in ctx.lines.iter().enumerate() {
if !info.in_front_matter {
continue;
}
let line = info.content(ctx.content);
let Some((value_start, value_end)) = value_span(line) else {
continue;
};
let (start, end) = trim_token_bounds(line, value_start, value_end);
if start >= end || !is_link_destination(&line[start..end]) {
continue;
}
links.push(FrontMatterLink {
line: idx + 1,
range: start..end,
field: None,
});
}
if !links.is_empty() {
let fields = field_map(ctx);
for link in &mut links {
link.field = fields.get(link.line - 1).cloned().flatten();
}
}
links
}
pub fn is_link_destination(value: &str) -> bool {
if value.is_empty() || value.chars().any(char::is_whitespace) {
return false;
}
let path = match value.find('#') {
Some(0) => return true,
Some(i) => &value[..i],
None => value,
};
let path = path.split('?').next().unwrap_or(path);
let last_segment = path.rsplit('/').next().unwrap_or(path);
if has_markdown_extension(last_segment) {
return true;
}
path.contains('/')
&& (path.starts_with('/')
|| path.starts_with("./")
|| path.starts_with("../")
|| path.starts_with("~/")
|| has_file_extension(last_segment))
}
fn has_markdown_extension(segment: &str) -> bool {
segment.rsplit_once('.').is_some_and(|(stem, ext)| {
!stem.is_empty() && MARKDOWN_EXTENSIONS.iter().any(|known| ext.eq_ignore_ascii_case(known))
})
}
fn has_file_extension(segment: &str) -> bool {
segment.rsplit_once('.').is_some_and(|(stem, ext)| {
!stem.is_empty()
&& (1..=8).contains(&ext.len())
&& ext.chars().all(|c| c.is_ascii_alphanumeric())
&& ext.chars().any(|c| c.is_ascii_alphabetic())
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::MarkdownFlavor;
fn destinations(content: &str) -> Vec<String> {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
link_destinations(&ctx)
.into_iter()
.map(|link| {
let line = ctx.lines[link.line - 1].content(ctx.content);
line[link.range].to_string()
})
.collect()
}
#[test]
fn a_relative_path_reads_as_a_destination() {
assert!(is_link_destination("this/is/a/link/to/myapp.md"));
assert!(is_link_destination("./other.md"));
assert!(is_link_destination("../parent/other"));
assert!(is_link_destination("~/notes/other.md"));
assert!(is_link_destination("/absolute/other.md"));
assert!(is_link_destination("assets/logo.png"));
}
#[test]
fn a_bare_markdown_filename_reads_as_a_destination() {
assert!(is_link_destination("myapp.md"));
assert!(is_link_destination("report.QMD"));
}
#[test]
fn a_fragment_reads_as_a_destination() {
assert!(is_link_destination("#installation"));
assert!(is_link_destination("other.md#installation"));
assert!(is_link_destination("docs/other.md#installation"));
}
#[test]
fn a_query_string_is_not_part_of_the_path() {
assert!(is_link_destination("docs/other.md?raw=true"));
assert!(is_link_destination("other.md?raw=true"));
assert!(is_link_destination("docs/other.md?raw=true#installation"));
assert!(!is_link_destination("what?about/this"));
}
#[test]
fn prose_and_path_shaped_values_do_not() {
assert!(!is_link_destination("Node.js"));
assert!(!is_link_destination("ci/cd"));
assert!(!is_link_destination("2026/07/31"));
assert!(!is_link_destination("1.2.3"));
assert!(!is_link_destination("docs/guides/intro"));
assert!(!is_link_destination("a description of docs/a.md"));
assert!(!is_link_destination(""));
}
#[test]
fn a_destination_is_read_out_of_its_quotes() {
assert_eq!(
destinations("---\nlink: 'this/is/a/link/to/myapp.md'\n---\n\n# Title\n"),
vec!["this/is/a/link/to/myapp.md"]
);
assert_eq!(
destinations("---\nlink: \"docs/a.md\"\n---\n\n# Title\n"),
vec!["docs/a.md"]
);
}
#[test]
fn a_trailing_comment_is_not_part_of_a_destination() {
assert_eq!(
destinations("---\nlink: docs/a.md # the guide\n---\n\n# Title\n"),
vec!["docs/a.md"]
);
}
#[test]
fn only_frontmatter_is_read() {
assert_eq!(
destinations("---\nlink: docs/a.md\n---\n\nSee docs/b.md for more.\n"),
vec!["docs/a.md"]
);
}
#[test]
fn a_sequence_item_carries_a_destination() {
assert_eq!(
destinations("---\nlinks:\n - docs/a.md\n - docs/b.md\n---\n\n# Title\n"),
vec!["docs/a.md", "docs/b.md"]
);
}
#[test]
fn a_toml_value_carries_a_destination() {
assert_eq!(
destinations("+++\nlink = \"docs/a.md\"\n+++\n\n# Title\n"),
vec!["docs/a.md"]
);
}
#[test]
fn a_destination_carries_the_field_owning_it_through_a_whole_subtree() {
let content = "---\nlink: docs/a.md\nseo:\n canonical: docs/b.md\n---\n\n# Title\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let links = link_destinations(&ctx);
let owners: Vec<Option<&str>> = links.iter().map(|link| link.field.as_deref()).collect();
assert_eq!(owners, vec![Some("link"), Some("seo")]);
let ignored: HashSet<String> = ["seo".to_string()].into_iter().collect();
let kept: Vec<String> = links
.iter()
.filter(|link| !link.field_is_in(&ignored))
.map(|link| ctx.lines[link.line - 1].content(ctx.content)[link.range.clone()].to_string())
.collect();
assert_eq!(kept, vec!["docs/a.md"]);
}
#[test]
fn a_destination_with_no_determinable_owner_belongs_to_no_field() {
let ctx = LintContext::new("---\n- docs/a.md\n---\n\n# Title\n", MarkdownFlavor::Standard, None);
let links = link_destinations(&ctx);
assert_eq!(links.len(), 1);
assert_eq!(links[0].field, None);
assert!(!links[0].field_is_in(&["docs".to_string()].into_iter().collect()));
}
#[test]
fn a_document_without_frontmatter_has_no_destinations() {
assert!(destinations("# Title\n\nSee docs/a.md.\n").is_empty());
}
}