use crate::config::MarkdownFlavor;
use crate::utils::code_block_utils::CodeBlockUtils;
use crate::utils::regex_cache::{ORDERED_LIST_MARKER_REGEX, UNORDERED_LIST_MARKER_REGEX};
use crate::utils::table_utils::TableUtils;
use std::sync::LazyLock;
use super::line_computation::spanned_lines;
use super::list_blocks::column_at;
use super::types::*;
static ATX_HEADING_REGEX: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"^([ \t]*)(#{1,6})([ \t]*)(.*)$").unwrap());
enum AtxOpening<'a> {
Heading(regex::Captures<'a>),
MissingSpace { level: u8 },
}
fn atx_opening(line: &str) -> Option<AtxOpening<'_>> {
let caps = ATX_HEADING_REGEX.captures(line)?;
let hashes = caps.get(2)?;
let after = &line[hashes.end()..];
if after.is_empty() || after.starts_with([' ', '\t']) {
Some(AtxOpening::Heading(caps))
} else if after.starts_with('#') {
None
} else {
Some(AtxOpening::MissingSpace {
level: hashes.len() as u8,
})
}
}
static FOOTNOTE_LABEL_REGEX: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"^[ \t]*\[\^[^\]]+\]:").unwrap());
const MAX_ORDERED_MARKER_DIGITS: usize = 9;
fn list_item_content_column(line: &str, interrupting: bool) -> Option<usize> {
let end = match UNORDERED_LIST_MARKER_REGEX.find(line) {
Some(marker) => marker.end(),
None => {
let marker = ORDERED_LIST_MARKER_REGEX.captures(line)?;
let number = marker.get(2)?.as_str();
if number.len() > MAX_ORDERED_MARKER_DIGITS {
return None;
}
if interrupting && number.trim_start_matches('0') != "1" {
return None;
}
marker.get(0)?.end()
}
};
if interrupting && line[end..].trim().is_empty() {
return None;
}
Some(end)
}
const MAX_SETEXT_UNDERLINE_INDENT: usize = 3;
const FOOTNOTE_BODY_INDENT: usize = 4;
#[derive(Clone, Copy, PartialEq)]
enum Marker {
Quote,
Item(usize),
Footnote(usize),
}
fn strip_quote_marker(line: &str) -> Option<(&str, &str)> {
let marker = line.trim_start_matches([' ', '\t']);
let after_marker = marker.strip_prefix('>')?;
Some((marker, after_marker.strip_prefix([' ', '\t']).unwrap_or(after_marker)))
}
struct Entered<'a> {
matched: usize,
content: &'a str,
indent: usize,
}
fn enter<'a>(
line: &'a str,
open: &[Marker],
paragraph: bool,
in_footnote_definition: bool,
opened: &mut Vec<Marker>,
) -> Entered<'a> {
opened.clear();
let column = |slice: &str| column_at(line, line.len() - slice.len());
let quote_edge = |marker: &str, content: &str| column(content).min(column(marker) + 2);
let mut rest = line;
let mut matched = 0;
let mut edge = 0;
while matched < open.len() {
match open[matched] {
Marker::Quote => match strip_quote_marker(rest) {
Some((marker, content)) => {
edge = quote_edge(marker, content);
rest = content;
}
None => break,
},
Marker::Item(content_column) | Marker::Footnote(content_column)
if column(rest.trim_start()) >= content_column =>
{
edge = content_column;
}
Marker::Item(_) | Marker::Footnote(_) => break,
}
matched += 1;
}
loop {
if let Some((marker, content)) = strip_quote_marker(rest) {
opened.push(Marker::Quote);
edge = quote_edge(marker, content);
rest = content;
continue;
}
if is_horizontal_rule_content(rest.trim()) {
break;
}
let in_footnote_body = open[..matched]
.iter()
.chain(opened.iter())
.any(|marker| matches!(marker, Marker::Footnote(_)));
if in_footnote_definition
&& !in_footnote_body
&& let Some(label) = FOOTNOTE_LABEL_REGEX.find(rest)
{
opened.push(Marker::Footnote(edge + FOOTNOTE_BODY_INDENT));
rest = &rest[label.end()..];
edge = column(rest);
continue;
}
let interrupting = paragraph && matched == open.len() && opened.is_empty();
match list_item_content_column(rest, interrupting) {
Some(end) => {
rest = &rest[end..];
edge = column(rest);
opened.push(Marker::Item(edge));
}
None => break,
}
}
Entered {
matched,
content: rest.trim(),
indent: column(rest.trim_start()).saturating_sub(edge),
}
}
fn may_hold_open_paragraph(content: &str) -> bool {
if content.is_empty() {
return false;
}
!(is_horizontal_rule_content(content)
|| matches!(atx_opening(content), Some(AtxOpening::Heading(_)))
|| crate::utils::html_block::parse_html_block_start(content).is_some()
|| crate::utils::html_block::opens_untagged_html_block(content))
}
pub(crate) fn is_paragraph_text_line(line: &str) -> bool {
may_hold_open_paragraph(enter(line, &[], false, false, &mut Vec::new()).content)
}
fn structural_blocks(line: &LineInfo, flavor: MarkdownFlavor, in_mdx_flow: bool) -> [bool; 17] {
[
line.in_code_block,
line.in_front_matter,
line.in_html_block,
line.in_html_comment,
line.in_math_block,
line.in_mdx_comment,
line.in_obsidian_comment,
line.in_mkdocstrings,
line.in_esm_block,
in_mdx_flow,
line.in_pandoc_div,
line.is_div_marker && flavor.is_pandoc_compatible(),
line.in_admonition,
line.in_content_tab,
line.in_pymdown_block,
line.in_myst_directive,
line.is_myst_comment,
]
}
fn is_opaque_body(line: &LineInfo) -> bool {
line.in_math_block || line.in_obsidian_comment || line.in_mdx_comment || line.in_esm_block || line.in_mkdocstrings
}
fn underline_indent_is_unbounded(line: &LineInfo, flavor: MarkdownFlavor) -> bool {
flavor == MarkdownFlavor::MDX || line.in_admonition || line.in_content_tab
}
#[derive(Clone, Copy, Default)]
struct Trailing<'a> {
underlines: Option<usize>,
text: &'a str,
quote_depth: usize,
carries_marker: bool,
hard_break: bool,
}
fn trailing_state<'a>(
content_lines: &[&'a str],
lines: &[LineInfo],
flavor: MarkdownFlavor,
html_blocks: &[(usize, usize)],
code_blocks: &[(usize, usize)],
code_spans: &[(usize, usize)],
mdx_flow_lines: Option<&[bool]>,
) -> Vec<Trailing<'a>> {
let blocks = |index: usize| {
let in_mdx_flow = mdx_flow_lines.map_or(lines[index].in_jsx_block, |flow| flow[index]);
structural_blocks(&lines[index], flavor, in_mdx_flow)
};
let mut states = Vec::with_capacity(lines.len());
let mut open: Vec<Marker> = Vec::new();
let mut opened: Vec<Marker> = Vec::new();
let mut paragraph: Option<usize> = None;
let mut in_table = false;
let mut header_cells = None;
let mut in_html_block = vec![false; lines.len()];
let mut opens_raw_block = vec![false; lines.len()];
if flavor != MarkdownFlavor::MDX {
for &(start, end) in html_blocks {
let spanned = spanned_lines(lines, start, end);
if lines
.get(spanned.start)
.is_none_or(|line| line.in_front_matter || line.in_code_block || is_opaque_body(line))
{
continue;
}
opens_raw_block[spanned.start] = true;
in_html_block[spanned].fill(true);
}
}
for &(start, end) in code_blocks {
let spanned = spanned_lines(lines, start, end);
if spanned.start < spanned.end {
opens_raw_block[spanned.start] = true;
}
}
let raw = |index: usize| {
let line = &lines[index];
line.in_code_block
|| line.in_front_matter
|| line.in_html_comment
|| in_html_block[index]
|| is_opaque_body(line)
};
for index in 0..lines.len() {
let boundary = index > 0 && blocks(index) != blocks(index - 1);
if boundary {
paragraph = None;
in_table = false;
header_cells = None;
}
let entered = enter(
content_lines[index],
&open,
paragraph.is_some(),
lines[index].in_footnote_definition,
&mut opened,
);
if opened.is_empty() && entered.content.trim().is_empty() {
if let Some(quote) = open[entered.matched..]
.iter()
.position(|marker| *marker == Marker::Quote)
{
open.truncate(entered.matched + quote);
}
paragraph = None;
in_table = false;
header_cells = None;
states.push(Trailing::default());
continue;
}
if !boundary && index > 0 && raw(index) && raw(index - 1) && !opens_raw_block[index] {
open.truncate(entered.matched);
paragraph = None;
in_table = false;
header_cells = None;
states.push(Trailing::default());
continue;
}
let carries_marker = opened
.iter()
.any(|marker| matches!(marker, Marker::Item(_) | Marker::Footnote(_)));
let holds_paragraph = !lines[index].in_code_block
&& !in_html_block[index]
&& !lines[index].is_container_marker
&& may_hold_open_paragraph(entered.content);
let inside = entered.matched == open.len() && opened.is_empty();
in_table |= header_cells == Some(TableUtils::count_cells_with_flavor(entered.content, flavor))
&& TableUtils::is_delimiter_row(entered.content);
in_table &= inside && holds_paragraph;
let underlines = paragraph.filter(|_| {
inside
&& is_setext_underline_content(entered.content)
&& (entered.indent <= MAX_SETEXT_UNDERLINE_INDENT
|| underline_indent_is_unbounded(&lines[index], flavor))
});
let underlines = underlines.map(|first| {
let texts: Vec<&str> = states[first..index].iter().map(|state| state.text).collect();
first + super::link_parser::leading_reference_definition_lines(&texts)
});
let hard_break = ends_with_hard_break(content_lines[index], lines[index].byte_offset, code_spans);
if underlines == Some(index) {
header_cells = None;
states.push(Trailing {
underlines: None,
text: entered.content,
quote_depth: open.iter().filter(|marker| **marker == Marker::Quote).count(),
carries_marker,
hard_break,
});
continue;
}
if in_table || underlines.is_some() {
paragraph = None;
header_cells = None;
states.push(Trailing {
underlines,
text: entered.content,
quote_depth: 0,
carries_marker,
hard_break,
});
continue;
}
let continues = paragraph.is_some() && holds_paragraph && opened.is_empty();
if !continues {
open.truncate(entered.matched);
open.extend_from_slice(&opened);
paragraph = holds_paragraph.then_some(index);
}
header_cells =
(!continues && holds_paragraph && TableUtils::is_potential_table_row_with_flavor(entered.content, flavor))
.then(|| TableUtils::count_cells_with_flavor(entered.content, flavor));
states.push(Trailing {
underlines: None,
text: entered.content,
quote_depth: open.iter().filter(|marker| **marker == Marker::Quote).count(),
carries_marker,
hard_break,
});
}
states
}
fn paragraph_text(states: &[Trailing]) -> String {
let mut text = String::new();
for (index, state) in states.iter().enumerate() {
if index > 0 {
text.push(' ');
}
text.push_str(if index + 1 < states.len() && state.hard_break {
state.text.strip_suffix('\\').unwrap_or(state.text)
} else {
state.text
});
}
text
}
pub(super) fn ends_with_hard_break(line: &str, byte_offset: usize, code_spans: &[(usize, usize)]) -> bool {
let backslashes = line.len() - line.trim_end_matches('\\').len();
backslashes % 2 == 1 && !CodeBlockUtils::is_in_code_block(code_spans, byte_offset + line.len() - 1)
}
fn setext_heading_info(
raw_text: &str,
text_lines: usize,
underline: &str,
marker_column: usize,
content_column: usize,
attribute_id: Option<String>,
) -> HeadingInfo {
let underline = underline.trim();
let (level, style) = if underline.starts_with('=') {
(1, HeadingStyle::Setext1)
} else {
(2, HeadingStyle::Setext2)
};
let heading_text = crate::utils::header_id_utils::extract_heading_text(raw_text);
HeadingInfo {
level,
style,
marker: underline.to_string(),
marker_column,
content_column,
text: heading_text.text,
slug_text: heading_text.slug_text,
custom_id: heading_text.custom_id.or(attribute_id),
raw_text: raw_text.to_string(),
text_lines,
has_closing_sequence: false,
closing_sequence: String::new(),
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn detect_headings_and_blockquotes(
content_lines: &[&str],
lines: &mut [LineInfo],
flavor: MarkdownFlavor,
html_comment_ranges: &[crate::utils::skip_context::ByteRange],
html_blocks: &[(usize, usize)],
code_blocks: &[(usize, usize)],
code_spans: &[(usize, usize)],
link_byte_ranges: &[(usize, usize)],
front_matter_end: usize,
mdx_flow_lines: Option<&[bool]>,
) -> Vec<Option<Box<HeadingInfo>>> {
let mut trailing: Option<Vec<Trailing>> = None;
for i in 0..lines.len() {
let line = content_lines[i];
if !(front_matter_end > 0 && i < front_matter_end)
&& let Some(bq) = crate::utils::blockquote::parse_blockquote_prefix(line)
{
let nesting_level = bq.nesting_level;
let marker_column = bq.indent.len();
let content_leading_ws_len = bq.content.len() - bq.content.trim_start_matches([' ', '\t']).len();
let full_prefix = format!("{}{}", bq.prefix, &bq.content[..content_leading_ws_len]);
let normalized_content = &bq.content[content_leading_ws_len..];
let has_multiple_spaces = bq.spaces_after_marker.chars().filter(|&c| c == ' ').count() > 1;
lines[i].blockquote = Some(Box::new(BlockquoteInfo {
nesting_level,
marker_column,
prefix: full_prefix,
content: normalized_content.to_string(),
has_multiple_spaces_after_marker: has_multiple_spaces,
}));
if !lines[i].in_code_block && is_horizontal_rule_content(normalized_content.trim()) {
lines[i].is_horizontal_rule = true;
}
}
if lines[i].in_code_block {
continue;
}
if front_matter_end > 0 && i < front_matter_end {
continue;
}
if lines[i].in_html_block {
continue;
}
if is_opaque_body(&lines[i]) {
continue;
}
if lines[i].is_blank {
continue;
}
let is_snippet_line = if flavor == MarkdownFlavor::MkDocs {
crate::utils::mkdocs_snippets::is_snippet_section_start(line)
|| crate::utils::mkdocs_snippets::is_snippet_section_end(line)
} else {
false
};
let atx = if is_snippet_line { None } else { atx_opening(line) };
let line_offset = lines[i].byte_offset;
let in_html_comment_or_link = atx.is_some()
&& (crate::utils::skip_context::is_in_html_comment_ranges(html_comment_ranges, line_offset)
|| link_byte_ranges
.iter()
.any(|&(start, end)| line_offset > start && line_offset < end));
if let Some(AtxOpening::MissingSpace { level }) = atx
&& !in_html_comment_or_link
{
lines[i].atx_missing_space = Some(AtxMissingSpace { level });
}
if let Some(AtxOpening::Heading(caps)) = atx {
if in_html_comment_or_link {
continue;
}
let leading_spaces = caps.get(1).map_or("", |m| m.as_str());
let hashes = caps.get(2).map_or("", |m| m.as_str());
let spaces_after = caps.get(3).map_or("", |m| m.as_str());
let rest = caps.get(4).map_or("", |m| m.as_str());
let level = hashes.len() as u8;
let marker_column = leading_spaces.len();
let (text, has_closing, closing_seq) = parse_atx_remainder(rest);
let content_column = marker_column + hashes.len() + spaces_after.len();
let raw_text = text.trim().to_string();
let heading_text = crate::utils::header_id_utils::extract_heading_text(&raw_text);
let mut custom_id = heading_text.custom_id;
if custom_id.is_none() && i + 1 < content_lines.len() && i + 1 < lines.len() {
let next_line = content_lines[i + 1];
if !lines[i + 1].in_code_block
&& crate::utils::header_id_utils::is_standalone_attr_list(next_line)
&& let Some(next_line_id) =
crate::utils::header_id_utils::extract_standalone_attr_list_id(next_line)
{
custom_id = Some(next_line_id);
}
}
lines[i].heading = Some(Box::new(HeadingInfo {
level,
style: HeadingStyle::ATX,
marker: hashes.to_string(),
marker_column,
content_column,
text: heading_text.text,
slug_text: heading_text.slug_text,
custom_id,
raw_text,
text_lines: 1,
has_closing_sequence: has_closing,
closing_sequence: closing_seq,
}));
continue;
}
if i + 1 < content_lines.len() && i + 1 < lines.len() {
let next_line = content_lines[i + 1];
if !lines[i + 1].in_code_block && is_setext_underline_content(next_line) {
if front_matter_end > 0 && i < front_matter_end {
continue;
}
if crate::utils::skip_context::is_in_html_comment_ranges(html_comment_ranges, lines[i].byte_offset) {
continue;
}
let states = trailing.get_or_insert_with(|| {
trailing_state(
content_lines,
lines,
flavor,
html_blocks,
code_blocks,
code_spans,
mdx_flow_lines,
)
});
let Some(first) = states[i + 1].underlines else {
continue;
};
if states[first].carries_marker {
continue;
}
let attribute_id = content_lines
.get(i + 2)
.filter(|attr_line| {
lines.get(i + 2).is_some_and(|attr_info| !attr_info.in_code_block)
&& crate::utils::header_id_utils::is_standalone_attr_list(attr_line)
})
.and_then(|attr_line| crate::utils::header_id_utils::extract_standalone_attr_list_id(attr_line));
let heading = setext_heading_info(
¶graph_text(&states[first..=i]),
i + 1 - first,
next_line,
next_line.len() - next_line.trim_start().len(),
lines[first].indent,
attribute_id,
);
for text_line in &mut lines[first..=i] {
text_line.is_setext_heading_text = true;
text_line.atx_missing_space = None;
}
lines[i].heading = Some(Box::new(heading));
}
}
}
let mut blockquote_headings: Vec<Option<Box<HeadingInfo>>> = lines
.iter()
.enumerate()
.map(|(line_index, line)| {
detect_blockquote_atx_heading(line_index, line, flavor, html_comment_ranges, front_matter_end)
})
.collect();
for underline_index in 1..lines.len() {
let text_index = underline_index - 1;
if blockquote_headings[text_index].is_some() || lines[underline_index].in_code_block {
continue;
}
let Some(underline) = lines[underline_index].blockquote.as_deref() else {
continue;
};
if !is_setext_underline_content(&underline.content) {
continue;
}
let Some(quote) = blockquote_heading_container(
text_index,
&lines[text_index],
flavor,
html_comment_ranges,
front_matter_end,
) else {
continue;
};
let states = trailing.get_or_insert_with(|| {
trailing_state(
content_lines,
lines,
flavor,
html_blocks,
code_blocks,
code_spans,
mdx_flow_lines,
)
});
let Some(first) = states[underline_index].underlines else {
continue;
};
if states[first].carries_marker || states[text_index].quote_depth != quote.nesting_level {
continue;
}
let Some(first_quote) =
blockquote_heading_container(first, &lines[first], flavor, html_comment_ranges, front_matter_end)
else {
continue;
};
blockquote_headings[text_index] = Some(Box::new(setext_heading_info(
¶graph_text(&states[first..=text_index]),
text_index + 1 - first,
&underline.content,
underline.prefix.len(),
first_quote.prefix.len(),
None,
)));
}
blockquote_headings
}
fn parse_atx_remainder(rest: &str) -> (String, bool, String) {
let (rest_without_id, custom_id_part) = if let Some(id_start) = rest.rfind(" {#") {
if rest[id_start..].trim_end().ends_with('}') {
(&rest[..id_start], &rest[id_start..])
} else {
(rest, "")
}
} else {
(rest, "")
};
let trimmed_rest = rest_without_id.trim_end();
let Some(last_hash_byte_pos) = trimmed_rest.rfind('#') else {
return (rest.to_string(), false, String::new());
};
let char_positions: Vec<(usize, char)> = trimmed_rest.char_indices().collect();
let Some(mut char_idx) = char_positions
.iter()
.position(|(byte_pos, _)| *byte_pos == last_hash_byte_pos)
else {
return (rest.to_string(), false, String::new());
};
while char_idx > 0 && char_positions[char_idx - 1].1 == '#' {
char_idx -= 1;
}
let start_of_hashes = char_positions[char_idx].0;
let potential_closing = &trimmed_rest[start_of_hashes..];
let is_closing = potential_closing.chars().all(|c| c == '#')
&& (char_idx == 0 || char_positions[char_idx - 1].1.is_whitespace());
if !is_closing {
return (rest.to_string(), false, String::new());
}
let text = if custom_id_part.is_empty() {
trimmed_rest[..start_of_hashes].trim_end().to_string()
} else {
format!("{}{}", trimmed_rest[..start_of_hashes].trim_end(), custom_id_part)
};
(text, true, potential_closing.to_string())
}
fn blockquote_heading_container<'a>(
line_index: usize,
line: &'a LineInfo,
flavor: MarkdownFlavor,
html_comment_ranges: &[crate::utils::skip_context::ByteRange],
front_matter_end: usize,
) -> Option<&'a BlockquoteInfo> {
if line.in_code_block
|| (line.in_html_block && !line.in_mkdocs_html_markdown)
|| line.in_kramdown_extension_block
|| is_opaque_body(line)
|| (front_matter_end > 0 && line_index < front_matter_end)
|| crate::utils::skip_context::is_in_html_comment_ranges(html_comment_ranges, line.byte_offset)
{
return None;
}
let blockquote = line.blockquote.as_deref()?;
let content = blockquote.content.as_str();
if flavor == MarkdownFlavor::MkDocs
&& (crate::utils::mkdocs_snippets::is_snippet_section_start(content)
|| crate::utils::mkdocs_snippets::is_snippet_section_end(content))
{
return None;
}
Some(blockquote)
}
fn detect_blockquote_atx_heading(
line_index: usize,
line: &LineInfo,
flavor: MarkdownFlavor,
html_comment_ranges: &[crate::utils::skip_context::ByteRange],
front_matter_end: usize,
) -> Option<Box<HeadingInfo>> {
let blockquote = blockquote_heading_container(line_index, line, flavor, html_comment_ranges, front_matter_end)?;
let content = blockquote.content.as_str();
let marker_len = content.bytes().take_while(|&byte| byte == b'#').count();
if !(1..=6).contains(&marker_len) {
return None;
}
let after_marker = &content[marker_len..];
let spaces_len = after_marker
.bytes()
.take_while(|&byte| matches!(byte, b' ' | b'\t'))
.count();
if spaces_len == 0 && !after_marker.is_empty() {
return None;
}
let rest = &after_marker[spaces_len..];
let (text, has_closing_sequence, closing_sequence) = parse_atx_remainder(rest);
let raw_text = text.trim().to_string();
let heading_text = crate::utils::header_id_utils::extract_heading_text(&raw_text);
Some(Box::new(HeadingInfo {
level: marker_len as u8,
style: HeadingStyle::ATX,
marker: content[..marker_len].to_string(),
marker_column: blockquote.prefix.len(),
content_column: blockquote.prefix.len() + marker_len + spaces_len,
text: heading_text.text,
slug_text: heading_text.slug_text,
custom_id: heading_text.custom_id,
raw_text,
text_lines: 1,
has_closing_sequence,
closing_sequence,
}))
}
pub(super) fn detect_html_blocks(content: &str, lines: &mut [LineInfo]) {
use crate::utils::html_block::{TYPE_1_BLOCK_ELEMENTS, parse_html_block_start};
let mut i = 0;
while i < lines.len() {
if lines[i].in_code_block || lines[i].in_front_matter {
i += 1;
continue;
}
let trimmed = lines[i].content(content).trim_start();
let Some((tag_name, is_closing)) = parse_html_block_start(trimmed) else {
i += 1;
continue;
};
lines[i].in_html_block = true;
if is_closing {
i += 1;
continue;
}
let closing_tag = format!("</{tag_name}>");
if lines[i].content(content).contains(&closing_tag) {
i += 1;
continue;
}
let allow_blank_lines = TYPE_1_BLOCK_ELEMENTS.contains(&tag_name.as_str());
let mut j = i + 1;
let mut found_closing_tag = false;
while j < lines.len() {
if !allow_blank_lines && lines[j].is_blank {
break;
}
lines[j].in_html_block = true;
if lines[j].content(content).contains(&closing_tag) {
found_closing_tag = true;
}
if found_closing_tag {
j += 1;
while j < lines.len() {
if lines[j].is_blank {
break;
}
lines[j].in_html_block = true;
j += 1;
}
break;
}
j += 1;
}
i = j;
}
}