use crate::config::MarkdownFlavor;
use crate::utils::code_block_utils::CodeBlockUtils;
use crate::utils::parser_options::rumdl_parser_options_for;
#[cfg(test)]
use pulldown_cmark::BrokenLink;
use pulldown_cmark::{Event, LinkType, Tag, TagEnd};
use regex::Regex;
use std::borrow::Cow;
use std::collections::HashSet;
use std::sync::LazyLock;
use super::document_parser::ParserDefinition;
use super::types::*;
static LINK_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?sx)
\[((?:[^\[\]\\]|\\.)*)\] # Link text in group 1 (optimized - no nested brackets to prevent catastrophic backtracking)
(?:
\((?:<([^<>\n]*)>|([^\s)"']*))(?:\s+(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|\(((?:[^()\\]|\\.)*)\)))?\) # URL in group 2 (angle) or 3 (bare), title in 4 (dq) / 5 (sq) / 6 (paren)
|
\[([^\]]*)\] # Reference ID in group 7
)"#
).unwrap()
});
static IMAGE_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?sx)
!\[((?:[^\[\]\\]|\\.)*)\] # Alt text in group 1 (optimized - no nested brackets to prevent catastrophic backtracking)
(?:
\((?:<([^<>\n]*)>|([^\s)"']*))(?:\s+(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|\(((?:[^()\\]|\\.)*)\)))?\) # URL in group 2 (angle) or 3 (bare), title in 4 (dq) / 5 (sq) / 6 (paren)
|
\[([^\]]*)\] # Reference ID in group 7
)"#
).unwrap()
});
pub(crate) fn link_pattern() -> &'static Regex {
&LINK_PATTERN
}
pub(crate) fn image_pattern() -> &'static Regex {
&IMAGE_PATTERN
}
pub(super) fn extend_collapsed_byte_end(content: &str, link_type: LinkType, byte_end: usize) -> usize {
if !matches!(link_type, LinkType::Collapsed) {
return byte_end;
}
let bytes = content.as_bytes();
if bytes.get(byte_end) == Some(&b'[') && bytes.get(byte_end + 1) == Some(&b']') {
byte_end + 2
} else {
byte_end
}
}
static REF_DEF_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"(?m)^[ ]{0,3}\[((?:[^\]\\]|\\.)+)\]:\s*(?:<((?:[^<>\n\\]|\\.)*)>|([^\s<][^\s]*))(?:\s+(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|\(((?:[^()\\]|\\.)*)\)))?$"#,
)
.unwrap()
});
static REF_DEF_TITLE_CONTINUATION: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"^\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|\(((?:[^()\\]|\\.)*)\))\s*$"#).unwrap()
});
pub(super) fn leading_reference_definition_lines(lines: &[&str]) -> usize {
if !lines.first().is_some_and(|line| line.trim_start().starts_with('[')) {
return 0;
}
let text = lines.join("\n");
let first_block = pulldown_cmark::Parser::new_ext(&text, rumdl_parser_options_for(&text))
.into_offset_iter()
.next()
.map_or(text.len(), |(_, range)| range.start);
text[..first_block].matches('\n').count() + usize::from(first_block == text.len())
}
pub(super) struct ParsedLinks<'a> {
pub link_byte_ranges: Vec<(usize, usize)>,
pub links: Vec<ParsedLink<'a>>,
pub images: Vec<ParsedImage<'a>>,
pub broken_links: Vec<BrokenLinkInfo>,
pub footnote_refs: Vec<FootnoteRef>,
pub link_found_positions: HashSet<usize>,
pub image_found_positions: HashSet<usize>,
}
#[cfg(test)]
pub(super) fn parse_links_images_pulldown<'a>(
content: &'a str,
lines: &[LineInfo],
code_blocks: &[(usize, usize)],
flavor: MarkdownFlavor,
html_comment_ranges: &[crate::utils::skip_context::ByteRange],
) -> ParsedLinks<'a> {
let mut broken_links = Vec::new();
let options = rumdl_parser_options_for(content);
let parser = pulldown_cmark::Parser::new_with_broken_link_callback(
content,
options,
Some(|link: BrokenLink<'_>| {
let span_end = extend_collapsed_byte_end(content, link.link_type, link.span.end);
broken_links.push(BrokenLinkInfo {
reference: link.reference.to_string(),
span: link.span.start..span_end,
link_type: link.link_type,
});
None
}),
)
.into_offset_iter();
let mut result = collect_links_images(
content,
lines,
code_blocks,
flavor,
html_comment_ranges,
parser,
Vec::new(),
);
result.broken_links = broken_links;
result
}
pub(super) fn collect_links_images<'a>(
content: &'a str,
lines: &[LineInfo],
code_blocks: &[(usize, usize)],
flavor: MarkdownFlavor,
html_comment_ranges: &[crate::utils::skip_context::ByteRange],
events: impl Iterator<Item = (Event<'a>, std::ops::Range<usize>)>,
broken_links: Vec<BrokenLinkInfo>,
) -> ParsedLinks<'a> {
use crate::utils::skip_context::{is_in_html_comment_ranges, is_mkdocs_snippet_line};
let mut positions = super::source_positions::SourcePositions::new(content, lines);
let mut link_byte_ranges = Vec::new();
let mut links = Vec::with_capacity(content.len() / 500);
let mut images = Vec::with_capacity(content.len() / 1000);
let mut footnote_refs = Vec::new();
let mut link_found_positions = HashSet::new();
let mut image_found_positions = HashSet::new();
type StackEntry<'b> = (
usize,
pulldown_cmark::CowStr<'b>,
LinkType,
pulldown_cmark::CowStr<'b>,
pulldown_cmark::CowStr<'b>,
);
struct OpenLink<'b> {
start: usize,
label_end: usize,
url: pulldown_cmark::CowStr<'b>,
link_type: LinkType,
id: pulldown_cmark::CowStr<'b>,
title: pulldown_cmark::CowStr<'b>,
}
let mut link_stack: Vec<OpenLink<'a>> = Vec::new();
let mut image_stack: Vec<StackEntry<'a>> = Vec::new();
let mut link_text_chunks: Vec<(String, usize, usize)> = Vec::new();
for (event, range) in events {
if let Some(open) = link_stack.last_mut()
&& !matches!(&event, Event::Start(Tag::Link { .. }) | Event::End(TagEnd::Link))
{
let event_end = match &event {
Event::Start(Tag::Image { link_type, .. }) => extend_collapsed_byte_end(content, *link_type, range.end),
_ => range.end,
};
open.label_end = open.label_end.max(event_end);
}
match event {
Event::Start(Tag::Link {
link_type,
dest_url,
title,
id,
}) => {
link_stack.push(OpenLink {
start: range.start,
label_end: range.start + 1,
url: dest_url,
link_type,
id,
title,
});
link_text_chunks.clear();
}
Event::Start(Tag::Image {
link_type,
dest_url,
title,
id,
}) => {
image_stack.push((range.start, dest_url, link_type, id, title));
}
Event::Text(text)
if link_stack
.last()
.is_some_and(|link| matches!(link.link_type, LinkType::WikiLink { .. })) =>
{
link_text_chunks.push((text.to_string(), range.start, range.end));
}
Event::Code(code)
if link_stack
.last()
.is_some_and(|link| matches!(link.link_type, LinkType::WikiLink { .. })) =>
{
let code_text = format!("`{code}`");
link_text_chunks.push((code_text, range.start, range.end));
}
Event::End(TagEnd::Link) => {
if let Some(OpenLink {
start: start_pos,
label_end,
url,
link_type,
id: ref_id,
title,
}) = link_stack.pop()
{
let span_end = extend_collapsed_byte_end(content, link_type, range.end);
link_byte_ranges.push((start_pos, span_end));
if is_in_html_comment_ranges(html_comment_ranges, start_pos) {
link_text_chunks.clear();
continue;
}
let (line_idx, line_num, col_start) = positions.position(start_pos);
if is_mkdocs_snippet_line(lines[line_idx].content(content), flavor) {
link_text_chunks.clear();
continue;
}
let (_, end_line_num, col_end) = positions.position(span_end);
let is_reference = matches!(
link_type,
LinkType::Reference | LinkType::Collapsed | LinkType::Shortcut
);
let link_text = if matches!(link_type, LinkType::WikiLink { .. }) {
if !link_text_chunks.is_empty() {
let text: String = link_text_chunks.iter().map(|(t, _, _)| t.as_str()).collect();
Cow::Owned(text)
} else {
Cow::Owned(url.to_string())
}
} else if matches!(link_type, LinkType::Autolink | LinkType::Email) {
Cow::Borrowed("")
} else {
let close = label_end + content[label_end..].find(']').unwrap_or(0);
debug_assert!(
close < span_end && content.as_bytes().get(close) == Some(&b']'),
"link label at {start_pos} does not close on a bracket inside its own span: {:?}",
&content[start_pos..span_end.min(content.len())]
);
Cow::Borrowed(&content[start_pos + 1..close])
};
let reference_id = if is_reference && !ref_id.is_empty() {
Some(Cow::Owned(ref_id.to_lowercase()))
} else if is_reference {
Some(Cow::Owned(link_text.to_lowercase()))
} else {
None
};
link_found_positions.insert(start_pos);
let title_field = if !title.is_empty() {
Some(source_cow(title))
} else if matches!(link_type, LinkType::Inline)
&& has_explicit_empty_inline_title(&content[start_pos..range.end.min(content.len())])
{
Some(Cow::Borrowed(""))
} else {
None
};
links.push(ParsedLink {
line: line_num,
end_line: end_line_num,
start_col: col_start,
end_col: col_end,
byte_offset: start_pos,
byte_end: span_end,
text: link_text,
url: source_cow(url),
title: title_field,
is_reference,
reference_id,
link_type,
});
link_text_chunks.clear();
}
}
Event::End(TagEnd::Image) => {
if let Some((start_pos, url, link_type, ref_id, title)) = image_stack.pop() {
let span_end = extend_collapsed_byte_end(content, link_type, range.end);
if CodeBlockUtils::is_in_code_block(code_blocks, start_pos) {
continue;
}
if is_in_html_comment_ranges(html_comment_ranges, start_pos) {
continue;
}
let (_, line_num, col_start) = positions.position(start_pos);
let (_, end_line_num, col_end) = positions.position(span_end);
let is_reference = matches!(
link_type,
LinkType::Reference | LinkType::Collapsed | LinkType::Shortcut
);
let alt_text = if matches!(link_type, LinkType::WikiLink { .. }) {
Cow::Borrowed("")
} else if start_pos < content.len() {
let image_bytes = &content.as_bytes()[start_pos..range.end.min(content.len())];
let mut close_pos = None;
let mut depth = 0;
if image_bytes.len() > 2 {
for (i, &byte) in image_bytes.iter().enumerate().skip(2) {
let mut backslash_count = 0;
let mut j = i;
while j > 0 && image_bytes[j - 1] == b'\\' {
backslash_count += 1;
j -= 1;
}
let is_escaped = backslash_count % 2 != 0;
if !is_escaped {
if byte == b'[' {
depth += 1;
} else if byte == b']' {
if depth == 0 {
close_pos = Some(i);
break;
} else {
depth -= 1;
}
}
}
}
}
if let Some(pos) = close_pos {
Cow::Borrowed(std::str::from_utf8(&image_bytes[2..pos]).unwrap_or(""))
} else {
Cow::Borrowed("")
}
} else {
Cow::Borrowed("")
};
let url = source_cow(url);
let reference_id = if is_reference && !ref_id.is_empty() {
Some(Cow::Owned(ref_id.to_lowercase()))
} else if is_reference {
Some(Cow::Owned(alt_text.to_lowercase()))
} else {
None
};
image_found_positions.insert(start_pos);
let title_field = if !title.is_empty() {
Some(source_cow(title))
} else if matches!(link_type, LinkType::Inline)
&& has_explicit_empty_inline_title(&content[start_pos..range.end.min(content.len())])
{
Some(Cow::Borrowed(""))
} else {
None
};
images.push(ParsedImage {
line: line_num,
end_line: end_line_num,
start_col: col_start,
end_col: col_end,
byte_offset: start_pos,
byte_end: span_end,
alt_text,
url,
title: title_field,
is_reference,
reference_id,
link_type,
});
}
}
Event::FootnoteReference(footnote_id) => {
if is_in_html_comment_ranges(html_comment_ranges, range.start) {
continue;
}
let line_num = super::source_positions::line_index(lines, range.start) + 1;
footnote_refs.push(FootnoteRef {
id: footnote_id.to_string(),
line: line_num,
byte_offset: range.start,
});
}
_ => {}
}
}
ParsedLinks {
link_byte_ranges,
links,
images,
broken_links,
footnote_refs,
link_found_positions,
image_found_positions,
}
}
fn source_cow(value: pulldown_cmark::CowStr<'_>) -> Cow<'_, str> {
match value {
pulldown_cmark::CowStr::Borrowed(value) => Cow::Borrowed(value),
pulldown_cmark::CowStr::Boxed(value) => Cow::Owned(value.into()),
pulldown_cmark::CowStr::Inlined(value) => Cow::Owned(value.to_string()),
}
}
fn is_literal_html_context(line_info: &LineInfo) -> bool {
line_info.in_html_block && !line_info.in_mkdocs_container()
}
pub(super) struct LinkExclusions<'a> {
pub code_blocks: &'a [(usize, usize)],
pub code_spans: &'a [(usize, usize)],
pub html_comment_ranges: &'a [crate::utils::skip_context::ByteRange],
pub mdx: Option<&'a super::mdx::MdxContext>,
}
pub(super) struct FinalizedLinks<'a> {
pub links: Vec<ParsedLink<'a>>,
pub images: Vec<ParsedImage<'a>>,
pub unresolved_links: Vec<ParsedLink<'a>>,
pub unresolved_images: Vec<ParsedImage<'a>>,
pub broken_links: Vec<BrokenLinkInfo>,
pub footnote_refs: Vec<FootnoteRef>,
}
fn collapse_label(label: &str) -> String {
label.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub(crate) fn reference_label_start(source: &str) -> Option<usize> {
let mut open = None;
let mut chars = source.char_indices();
while let Some((i, c)) = chars.next() {
match c {
'\\' => {
chars.next();
}
'[' => open = Some(i),
_ => {}
}
}
open
}
pub(super) fn fold_label(label: &str) -> String {
unicase::UniCase::new(collapse_label(label)).to_folded_case()
}
struct ReferenceResolver<'c> {
content: &'c str,
lines: &'c [LineInfo],
html_comment_ranges: &'c [crate::utils::skip_context::ByteRange],
mdx: Option<&'c super::mdx::MdxContext>,
flavor: MarkdownFlavor,
heading_labels: std::cell::OnceCell<HashSet<String>>,
parser: std::cell::OnceCell<pulldown_cmark::Parser<'c>>,
container_defs: std::cell::OnceCell<HashSet<String>>,
}
impl<'c> ReferenceResolver<'c> {
fn new(
content: &'c str,
lines: &'c [LineInfo],
html_comment_ranges: &'c [crate::utils::skip_context::ByteRange],
mdx: Option<&'c super::mdx::MdxContext>,
flavor: MarkdownFlavor,
) -> Self {
Self {
content,
lines,
html_comment_ranges,
mdx,
flavor,
heading_labels: std::cell::OnceCell::new(),
parser: std::cell::OnceCell::new(),
container_defs: std::cell::OnceCell::new(),
}
}
fn container_defs(&self) -> &HashSet<String> {
self.container_defs.get_or_init(|| {
if let Some(mdx) = self.mdx {
return mdx
.reference_defs(self.content)
.iter()
.map(|def| fold_label(&def.id))
.collect();
}
let mut body = String::new();
let mut defs = HashSet::new();
for line in self.lines.iter().filter(|line| {
line.in_mkdocs_container()
&& !line.in_code_block
&& !crate::utils::skip_context::is_in_html_comment_ranges(
self.html_comment_ranges,
line.byte_offset,
)
}) {
let text = line.content(self.content).trim();
if let Some(id) = REF_DEF_PATTERN
.captures(text)
.and_then(|cap| cap.get(1))
.map(|m| m.as_str())
.filter(|id| !id.starts_with('^'))
{
defs.insert(fold_label(id));
}
body.push_str(text);
body.push('\n');
}
if !body.is_empty() {
let parser = pulldown_cmark::Parser::new_ext(&body, rumdl_parser_options_for(&body));
defs.extend(
parser
.reference_definitions()
.iter()
.map(|(label, _)| fold_label(label)),
);
}
defs
})
}
fn is_defined(&self, label: &str, parser_reported_missing: bool) -> bool {
if self.container_defs().contains(&fold_label(label)) {
return true;
}
if self.flavor.is_pandoc_compatible()
&& self
.heading_labels
.get_or_init(|| {
self.lines
.iter()
.filter_map(|line| line.heading.as_ref())
.map(|heading| fold_label(&heading.text))
.collect()
})
.contains(&fold_label(label))
{
return true;
}
if parser_reported_missing || self.mdx.is_some() {
return false;
}
let parser = self
.parser
.get_or_init(|| pulldown_cmark::Parser::new_ext(self.content, rumdl_parser_options_for(self.content)));
parser.reference_definitions().get(&collapse_label(label)).is_some()
}
}
pub(super) fn finalize_links_and_images<'a>(
content: &'a str,
lines: &[LineInfo],
flavor: MarkdownFlavor,
exclusions: &LinkExclusions<'_>,
mut result: ParsedLinks<'a>,
) -> FinalizedLinks<'a> {
use crate::utils::skip_context::{is_in_html_comment_ranges, is_mkdocs_snippet_line};
let mut positions = super::source_positions::SourcePositions::new(content, lines);
let LinkExclusions {
code_blocks,
code_spans,
html_comment_ranges,
mdx,
} = *exclusions;
let resolver = ReferenceResolver::new(content, lines, html_comment_ranges, mdx, flavor);
let mut unresolved_links: Vec<ParsedLink<'a>> = Vec::new();
let mut unresolved_images: Vec<ParsedImage<'a>> = Vec::new();
result
.images
.retain(|img| !super::LintContext::binary_search_ranges(code_spans, img.byte_offset));
for broken in &result.broken_links {
let start_pos = broken.span.start;
let span_end = broken.span.end;
if is_in_html_comment_ranges(html_comment_ranges, start_pos) {
continue;
}
let (line_idx, line_num, col_start) = positions.position(start_pos);
if is_mkdocs_snippet_line(lines[line_idx].content(content), flavor) {
continue;
}
let (_, end_line_num, col_end) = positions.position(span_end);
let raw_span = &content[broken.span.clone()];
let is_image = raw_span.starts_with('!');
let bracketed = if is_image { &raw_span[1..] } else { raw_span };
let link_text = if bracketed.starts_with('[') {
let mut depth = 0;
let mut close_pos = None;
for (i, byte) in bracketed.bytes().enumerate().skip(1) {
if byte == b'[' {
depth += 1;
} else if byte == b']' {
if depth == 0 {
close_pos = Some(i);
break;
} else {
depth -= 1;
}
}
}
if let Some(pos) = close_pos {
&bracketed[1..pos]
} else {
""
}
} else {
""
};
let defined = resolver.is_defined(&broken.reference, true);
let is_reference = true;
let reference_id = Some(Cow::Owned(broken.reference.to_lowercase()));
if is_image {
(if defined {
&mut result.images
} else {
&mut unresolved_images
})
.push(ParsedImage {
line: line_num,
end_line: end_line_num,
start_col: col_start,
end_col: col_end,
byte_offset: start_pos,
byte_end: span_end,
alt_text: Cow::Owned(link_text.to_string()),
url: Cow::Borrowed(""),
title: None,
is_reference,
reference_id,
link_type: broken.link_type,
});
result.image_found_positions.insert(start_pos);
} else {
(if defined {
&mut result.links
} else {
&mut unresolved_links
})
.push(ParsedLink {
line: line_num,
end_line: end_line_num,
start_col: col_start,
end_col: col_end,
byte_offset: start_pos,
byte_end: span_end,
text: Cow::Owned(link_text.to_string()),
url: Cow::Borrowed(""),
title: None,
is_reference,
reference_id,
link_type: broken.link_type,
});
result.link_found_positions.insert(start_pos);
}
}
let needs_links = memchr::memchr_iter(b'[', content.as_bytes()).any(|start| {
!result.link_found_positions.contains(&start)
&& (start == 0 || !matches!(content.as_bytes()[start - 1], b'\\' | b'!'))
});
let matches = needs_links
.then(|| LINK_PATTERN.find_iter(content))
.into_iter()
.flatten();
for full_match in matches {
let match_start = full_match.start();
let match_end = full_match.end();
if mdx.is_some_and(|mdx| !mdx.contains_text(match_start, match_end)) {
continue;
}
if result.link_found_positions.contains(&match_start) {
continue;
}
if match_start > 0 && content.as_bytes().get(match_start - 1) == Some(&b'\\') {
continue;
}
if match_start > 0 && content.as_bytes().get(match_start - 1) == Some(&b'!') {
continue;
}
if CodeBlockUtils::is_in_code_block(code_blocks, match_start) {
continue;
}
if super::LintContext::binary_search_ranges(code_spans, match_start) {
continue;
}
if is_in_html_comment_ranges(html_comment_ranges, match_start) {
continue;
}
let (line_idx, line_num, col_start) = positions.position(match_start);
if is_mkdocs_snippet_line(lines[line_idx].content(content), flavor) {
continue;
}
if lines.get(line_idx).is_some_and(is_literal_html_context) {
continue;
}
let (_, end_line_num, col_end) = positions.position(match_end);
let cap = LINK_PATTERN
.captures_at(content, match_start)
.expect("the same regex already found this match");
debug_assert_eq!(cap.get(0).unwrap().range(), full_match.range());
let text = cap.get(1).map_or("", |m| m.as_str());
if let Some(ref_id) = cap.get(7) {
let ref_id_str = ref_id.as_str();
let normalized_ref = if ref_id_str.is_empty() {
Cow::Owned(text.to_lowercase())
} else {
Cow::Owned(ref_id_str.to_lowercase())
};
let defined = resolver.is_defined(if ref_id_str.is_empty() { text } else { ref_id_str }, false);
(if defined {
&mut result.links
} else {
&mut unresolved_links
})
.push(ParsedLink {
line: line_num,
end_line: end_line_num,
start_col: col_start,
end_col: col_end,
byte_offset: match_start,
byte_end: match_end,
text: Cow::Borrowed(text),
url: Cow::Borrowed(""),
title: None,
is_reference: true,
reference_id: Some(normalized_ref),
link_type: LinkType::Reference,
});
} else if let Some(line_info) = lines.get(line_idx)
&& line_info.in_mkdocs_container()
{
let url = cap
.get(2)
.or_else(|| cap.get(3))
.map_or(String::new(), |m| unescape_commonmark_punctuation(m.as_str().trim()));
let title = cap
.get(4)
.or_else(|| cap.get(5))
.or_else(|| cap.get(6))
.map(|m| Cow::Owned(unescape_commonmark_punctuation(m.as_str())));
result.links.push(ParsedLink {
line: line_num,
end_line: end_line_num,
start_col: col_start,
end_col: col_end,
byte_offset: match_start,
byte_end: match_end,
text: Cow::Borrowed(text),
url: Cow::Owned(url),
title,
is_reference: false,
reference_id: None,
link_type: LinkType::Inline,
});
}
}
let needs_images = memchr::memchr_iter(b'!', content.as_bytes()).any(|start| {
content.as_bytes().get(start + 1) == Some(&b'[')
&& !result.image_found_positions.contains(&start)
&& (start == 0 || content.as_bytes()[start - 1] != b'\\')
});
let matches = needs_images
.then(|| IMAGE_PATTERN.find_iter(content))
.into_iter()
.flatten();
for full_match in matches {
let match_start = full_match.start();
let match_end = full_match.end();
if mdx.is_some_and(|mdx| !mdx.contains_text(match_start, match_end)) {
continue;
}
if result.image_found_positions.contains(&match_start) {
continue;
}
if match_start > 0 && content.as_bytes().get(match_start - 1) == Some(&b'\\') {
continue;
}
if CodeBlockUtils::is_in_code_block(code_blocks, match_start)
|| super::LintContext::binary_search_ranges(code_spans, match_start)
|| is_in_html_comment_ranges(html_comment_ranges, match_start)
{
continue;
}
let (line_idx, line_num, col_start) = positions.position(match_start);
if lines.get(line_idx).is_some_and(is_literal_html_context) {
continue;
}
let (_, end_line_num, col_end) = positions.position(match_end);
let cap = IMAGE_PATTERN
.captures_at(content, match_start)
.expect("the same regex already found this match");
debug_assert_eq!(cap.get(0).unwrap().range(), full_match.range());
let alt_text = cap.get(1).map_or("", |m| m.as_str());
if let Some(ref_id) = cap.get(7) {
let ref_id_str = ref_id.as_str();
let normalized_ref = if ref_id_str.is_empty() {
Cow::Owned(alt_text.to_lowercase())
} else {
Cow::Owned(ref_id_str.to_lowercase())
};
let defined = resolver.is_defined(if ref_id_str.is_empty() { alt_text } else { ref_id_str }, false);
(if defined {
&mut result.images
} else {
&mut unresolved_images
})
.push(ParsedImage {
line: line_num,
end_line: end_line_num,
start_col: col_start,
end_col: col_end,
byte_offset: match_start,
byte_end: match_end,
alt_text: Cow::Borrowed(alt_text),
url: Cow::Borrowed(""),
title: None,
is_reference: true,
reference_id: Some(normalized_ref),
link_type: LinkType::Reference,
});
} else if let Some(line_info) = lines.get(line_idx)
&& line_info.in_mkdocs_container()
{
let url = cap
.get(2)
.or_else(|| cap.get(3))
.map_or(String::new(), |m| unescape_commonmark_punctuation(m.as_str().trim()));
let title = cap
.get(4)
.or_else(|| cap.get(5))
.or_else(|| cap.get(6))
.map(|m| Cow::Owned(unescape_commonmark_punctuation(m.as_str())));
result.images.push(ParsedImage {
line: line_num,
end_line: end_line_num,
start_col: col_start,
end_col: col_end,
byte_offset: match_start,
byte_end: match_end,
alt_text: Cow::Borrowed(alt_text),
url: Cow::Owned(url),
title,
is_reference: false,
reference_id: None,
link_type: LinkType::Inline,
});
}
}
result.links.sort_by_key(|l| (l.line, l.byte_offset));
result.images.sort_by_key(|i| (i.line, i.byte_offset));
unresolved_links.sort_by_key(|l| (l.line, l.byte_offset));
unresolved_images.sort_by_key(|i| (i.line, i.byte_offset));
FinalizedLinks {
links: result.links,
images: result.images,
unresolved_links,
unresolved_images,
broken_links: result.broken_links,
footnote_refs: result.footnote_refs,
}
}
pub(super) fn has_explicit_empty_inline_title(span: &str) -> bool {
span.strip_suffix(')').is_some_and(has_explicit_empty_title_ending)
}
pub(super) fn has_explicit_empty_title_ending(span: &str) -> bool {
let bytes = span.as_bytes();
let mut i = bytes.len();
while i > 0 && matches!(bytes[i - 1], b' ' | b'\t' | b'\n' | b'\r') {
i -= 1;
}
if i < 2 {
return false;
}
let close = bytes[i - 1];
let open = match close {
b'"' => b'"',
b'\'' => b'\'',
b')' => b'(',
_ => return false,
};
if bytes[i - 2] != open {
return false;
}
if i < 3 {
return false;
}
matches!(bytes[i - 3], b' ' | b'\t' | b'\n' | b'\r')
}
pub(super) fn unescape_commonmark_punctuation(input: &str) -> String {
if !input.contains('\\') {
return input.to_string();
}
let mut out = String::with_capacity(input.len());
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b == b'\\' && i + 1 < bytes.len() && is_ascii_punctuation(bytes[i + 1]) {
out.push(bytes[i + 1] as char);
i += 2;
} else {
let ch_len = utf8_char_len(b);
out.push_str(&input[i..i + ch_len]);
i += ch_len;
}
}
out
}
#[inline]
fn is_ascii_punctuation(b: u8) -> bool {
matches!(
b,
b'!' | b'"'
| b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'('
| b')'
| b'*'
| b'+'
| b','
| b'-'
| b'.'
| b'/'
| b':'
| b';'
| b'<'
| b'='
| b'>'
| b'?'
| b'@'
| b'['
| b'\\'
| b']'
| b'^'
| b'_'
| b'`'
| b'{'
| b'|'
| b'}'
| b'~'
)
}
#[inline]
fn utf8_char_len(first_byte: u8) -> usize {
match first_byte {
0x00..=0x7F => 1,
0xC0..=0xDF => 2,
0xE0..=0xEF => 3,
0xF0..=0xF7 => 4,
_ => 1, }
}
fn parser_destinations_at(content: &str, starts: &[usize]) -> Vec<Option<String>> {
let longest_run = content.split(|c| c != '\x7f').map(str::len).max().unwrap_or(0);
let marker = "\x7f".repeat(longest_run + 1);
let mut relabeled = String::with_capacity(content.len() + starts.len() * (2 * marker.len() + 8));
let mut copied = 0;
for (index, &start) in starts.iter().enumerate() {
relabeled.push_str(&content[copied..=start]);
relabeled.push_str(&marker);
relabeled.push_str(&index.to_string());
relabeled.push_str(&marker);
copied = start + 1;
}
relabeled.push_str(&content[copied..]);
let mut destinations = vec![None; starts.len()];
let parser = pulldown_cmark::Parser::new_ext(&relabeled, rumdl_parser_options_for(content));
for (label, definition) in parser.reference_definitions().iter() {
let index = label
.strip_prefix(marker.as_str())
.and_then(|rest| rest.split_once(marker.as_str()))
.and_then(|(index, _)| index.parse::<usize>().ok());
if let Some(slot) = index.and_then(|index| destinations.get_mut(index)) {
*slot = Some(definition.dest.to_string());
}
}
destinations
}
fn definition_destination(text: &str) -> Option<String> {
let parser = pulldown_cmark::Parser::new_ext(text, rumdl_parser_options_for(text));
parser
.reference_definitions()
.iter()
.next()
.map(|(_, definition)| definition.dest.to_string())
}
pub(super) fn parse_reference_defs(
content: &str,
lines: &[LineInfo],
flavor: MarkdownFlavor,
parser_definitions: &[ParserDefinition],
) -> Vec<ReferenceDef> {
let mut refs = Vec::with_capacity(lines.len() / 20);
let mut unlisted: Vec<(usize, usize)> = Vec::new();
let mut flavor_definition_end: Option<usize> = None;
for (line_idx, line_info) in lines.iter().enumerate() {
if line_info.in_code_block {
continue;
}
let line_num = line_idx + 1;
let (line, base_offset) = match line_info.blockquote.as_deref() {
Some(bq) => (bq.content.as_str(), line_info.byte_offset + bq.prefix.len()),
None => (line_info.content(content), line_info.byte_offset),
};
if !line.trim_start_matches(' ').starts_with('[') {
continue;
}
if let Some(cap) = REF_DEF_PATTERN.captures(line) {
let id_raw = cap.get(1).unwrap().as_str();
if id_raw.starts_with('^') {
continue;
}
let id = id_raw.to_lowercase();
let url = unescape_commonmark_punctuation(
cap.get(2)
.or_else(|| cap.get(3))
.expect("destination alternation always matches")
.as_str(),
);
let title_match = cap.get(4).or_else(|| cap.get(5)).or_else(|| cap.get(6));
let match_obj = cap.get(0).unwrap();
let byte_offset = base_offset + match_obj.start();
let mut byte_end = base_offset + match_obj.end();
let mut title = title_match.map(|m| unescape_commonmark_punctuation(m.as_str()));
let (mut title_byte_start, mut title_byte_end) = if let Some(m) = title_match {
let start = base_offset + m.start().saturating_sub(1);
let end = base_offset + m.end() + 1;
(Some(start), Some(end))
} else {
(None, None)
};
if title_match.is_none()
&& let Some(next) = lines.get(line_idx + 1)
&& !next.in_code_block
{
let cur_nesting = line_info.blockquote.as_deref().map_or(0, |bq| bq.nesting_level);
let (next_line, next_base, next_nesting) = match next.blockquote.as_deref() {
Some(bq) => (
bq.content.as_str(),
next.byte_offset + bq.prefix.len(),
bq.nesting_level,
),
None => (next.content(content), next.byte_offset, 0),
};
if next_nesting == cur_nesting
&& let Some(tcap) = REF_DEF_TITLE_CONTINUATION.captures(next_line)
{
let tmatch = tcap
.get(1)
.or_else(|| tcap.get(2))
.or_else(|| tcap.get(3))
.expect("title alternation always matches");
title = Some(unescape_commonmark_punctuation(tmatch.as_str()));
title_byte_start = Some(next_base + tmatch.start().saturating_sub(1));
title_byte_end = Some(next_base + tmatch.end() + 1);
byte_end = next.byte_offset + next.byte_len;
}
}
let label_start =
byte_offset + (match_obj.as_str().len() - match_obj.as_str().trim_start_matches(' ').len());
let destination =
match parser_definitions.binary_search_by_key(&label_start, |definition| definition.span.start) {
Ok(index) => parser_definitions[index].dest.clone(),
Err(_) => {
let after_flavor_marker = line_idx
.checked_sub(1)
.and_then(|previous| lines.get(previous))
.is_some_and(|previous| {
previous.is_container_marker
|| previous.is_kramdown_block_ial
|| previous.in_kramdown_extension_block
|| (flavor.is_pandoc_compatible() && previous.is_div_marker)
});
let read_by_flavor = line_info.in_mkdocs_container()
|| after_flavor_marker
|| flavor_definition_end.is_some_and(|end| end + 1 == line_idx);
if read_by_flavor {
let last_line = lines[line_idx..]
.iter()
.take_while(|line| line.byte_offset < byte_end)
.count();
flavor_definition_end = Some(line_idx + last_line.max(1) - 1);
let first_line_end = content[label_start..]
.find('\n')
.map_or(content.len(), |end| label_start + end);
definition_destination(&content[label_start..first_line_end]).unwrap_or_else(|| url.clone())
} else {
unlisted.push((refs.len(), label_start));
String::new()
}
}
};
refs.push(ReferenceDef {
line: line_num,
id,
url,
destination,
title,
byte_offset,
byte_end,
title_byte_start,
title_byte_end,
});
}
}
if !unlisted.is_empty() {
let starts: Vec<usize> = unlisted.iter().map(|&(_, start)| start).collect();
let mut read = vec![true; refs.len()];
for (&(index, _), destination) in unlisted.iter().zip(parser_destinations_at(content, &starts)) {
match destination {
Some(destination) => refs[index].destination = destination,
None => read[index] = false,
}
}
let mut read = read.into_iter();
refs.retain(|_| read.next().unwrap_or(true));
}
refs
}
pub(super) struct DefinitionCandidate {
pub(super) label: String,
pub(super) definition: ParserDefinition,
}
pub(super) fn definition_candidates(
content: &str,
parser_definitions: Vec<ParserDefinition>,
reference_defs: &[ReferenceDef],
) -> Vec<DefinitionCandidate> {
let parser_starts: HashSet<usize> = parser_definitions.iter().map(|d| d.span.start).collect();
let mut candidates: Vec<DefinitionCandidate> = parser_definitions
.into_iter()
.filter_map(|definition| {
Some(DefinitionCandidate {
label: fold_label(written_label(content, definition.span.start)?),
definition,
})
})
.collect();
for def in reference_defs {
let start = definition_label_start(content, def);
if parser_starts.contains(&start) {
continue;
}
candidates.push(DefinitionCandidate {
label: fold_label(&def.id),
definition: ParserDefinition {
span: start..def.byte_end,
dest: def.destination.clone(),
},
});
}
candidates.sort_by_key(|candidate| candidate.definition.span.start);
candidates
}
pub(super) fn definition_label_start(content: &str, def: &ReferenceDef) -> usize {
let rest = &content[def.byte_offset..];
def.byte_offset + rest.len() - rest.trim_start_matches(' ').len()
}
fn written_label(content: &str, start: usize) -> Option<&str> {
let rest = content.get(start..)?.strip_prefix('[')?;
let mut escaped = false;
for (i, c) in rest.char_indices() {
match c {
_ if escaped => escaped = false,
'\\' => escaped = true,
']' => return Some(&rest[..i]),
_ => {}
}
}
None
}