use crate::config::MarkdownFlavor;
use crate::utils::code_block_utils::CodeBlockUtils;
use crate::utils::table_utils::TableUtils;
use std::sync::LazyLock;
use super::line_computation::spanned_lines;
use super::list_blocks::{column_after, column_at};
use super::types::*;
#[derive(Debug, PartialEq, Eq)]
struct AtxMarker<'a> {
indent: usize,
hashes: &'a str,
whitespace: usize,
rest: &'a str,
}
#[derive(Debug, PartialEq, Eq)]
enum AtxOpening<'a> {
Heading(AtxMarker<'a>),
MissingSpace { level: u8 },
}
fn atx_opening(line: &str) -> Option<AtxOpening<'_>> {
let bytes = line.as_bytes();
let indent = bytes.iter().take_while(|&&byte| matches!(byte, b' ' | b'\t')).count();
if bytes.get(indent) != Some(&b'#') {
return None;
}
let level = bytes[indent..].iter().take_while(|&&byte| byte == b'#').count();
if level > 6 {
return None;
}
let marker_end = indent + level;
let after = &line[marker_end..];
if after.is_empty() || after.starts_with([' ', '\t']) {
let whitespace = after.bytes().take_while(|&byte| matches!(byte, b' ' | b'\t')).count();
Some(AtxOpening::Heading(AtxMarker {
indent,
hashes: &line[indent..marker_end],
whitespace,
rest: &after[whitespace..],
}))
} else {
Some(AtxOpening::MissingSpace { level: level 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 is_cm_blank(text: &str) -> bool {
text.trim_matches([' ', '\t']).is_empty()
}
#[derive(Debug, PartialEq, Eq)]
struct OpenedItem {
text_start: usize,
marker_end: usize,
}
fn list_item_content_column(line: &str, interrupting: bool) -> Option<OpenedItem> {
let bytes = line.as_bytes();
let start = bytes.iter().take_while(|&&byte| matches!(byte, b' ' | b'\t')).count();
let marker_end = match bytes.get(start)? {
b'*' | b'+' | b'-' => start + 1,
b'0'..=b'9' => {
let digits = bytes[start..].iter().take_while(|byte| byte.is_ascii_digit()).count();
if digits > MAX_ORDERED_MARKER_DIGITS || !matches!(bytes.get(start + digits), Some(b'.' | b')')) {
return None;
}
let number = &line[start..start + digits];
if interrupting && number.trim_start_matches('0') != "1" {
return None;
}
start + digits + 1
}
_ => return None,
};
if bytes.get(marker_end).is_some_and(|byte| !matches!(byte, b' ' | b'\t')) {
return None;
}
let text_start = marker_end
+ bytes[marker_end..]
.iter()
.take_while(|&&byte| matches!(byte, b' ' | b'\t'))
.count();
if interrupting && is_cm_blank(&line[text_start..]) {
return None;
}
Some(OpenedItem { text_start, marker_end })
}
const MAX_SETEXT_UNDERLINE_INDENT: usize = 3;
const MAX_ITEM_CONTENT_GAP: usize = 4;
const MAX_MARKER_INDENT: usize = 3;
const FOOTNOTE_BODY_INDENT: usize = 4;
#[derive(Clone, Copy, PartialEq)]
enum Marker {
Quote,
Item(usize, usize),
Footnote(usize, 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)))
}
#[derive(Clone, Copy)]
struct ItemSpan {
marker_column: usize,
is_empty: bool,
}
struct Entered<'a> {
matched: usize,
content: &'a str,
indent: usize,
}
fn enter<'a>(
line: &'a str,
open: &[Marker],
paragraph: bool,
in_footnote_definition: bool,
limit_marker_indent: bool,
opened: &mut Vec<Marker>,
item_spans: &mut Vec<ItemSpan>,
) -> Entered<'a> {
opened.clear();
item_spans.clear();
let relocated = std::cell::Cell::new(None::<(usize, usize)>);
let column = |slice: &str| {
let offset = line.len() - slice.len();
match relocated.get() {
Some((from, origin)) if offset >= from => origin + line[from..offset].chars().fold(0, column_after),
_ => column_at(line, offset),
}
};
let quote_edge = |marker: &str, content: &str| column(content).min(column(marker) + 2);
let last_indent = std::cell::Cell::new(None::<(usize, usize)>);
let indent_column = |slice: &str| match last_indent.get() {
Some((len, col)) if len == slice.len() => col,
_ => {
let col = column(slice.trim_start_matches([' ', '\t']));
last_indent.set(Some((slice.len(), col)));
col
}
};
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).filter(|_| {
!limit_marker_indent || column(rest.trim_start_matches([' ', '\t'])) <= edge + MAX_MARKER_INDENT
}) {
Some((marker, content)) => {
edge = quote_edge(marker, content);
rest = content;
}
None => break,
},
Marker::Item(_, width) | Marker::Footnote(_, width) if indent_column(rest) >= edge + width => {
edge += width;
}
Marker::Item(..) | Marker::Footnote(..) => break,
}
matched += 1;
}
loop {
if limit_marker_indent && column(rest.trim_start_matches([' ', '\t'])) > edge + MAX_MARKER_INDENT {
break;
}
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, FOOTNOTE_BODY_INDENT));
rest = &rest[label.end()..];
edge += FOOTNOTE_BODY_INDENT;
rest = rest.trim_start_matches([' ', '\t']);
relocated.set(Some((line.len() - rest.len(), edge)));
continue;
}
let interrupting = paragraph && matched == open.len() && opened.is_empty();
match list_item_content_column(rest, interrupting) {
Some(item) => {
let blank_start = is_cm_blank(&rest[item.text_start..]);
let marker_column = column(rest.trim_start_matches([' ', '\t']));
let after_marker = column(&rest[item.marker_end..]);
let parent_edge = edge;
let content_start = column(&rest[item.text_start..]);
rest = &rest[item.text_start..];
edge = if blank_start || content_start - after_marker > MAX_ITEM_CONTENT_GAP {
after_marker + 1
} else {
content_start
};
opened.push(Marker::Item(edge, edge - parent_edge));
item_spans.push(ItemSpan {
marker_column,
is_empty: blank_start,
});
}
None => break,
}
}
Entered {
matched,
content: rest.trim_matches([' ', '\t']),
indent: column(rest.trim_start_matches([' ', '\t'])).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))
}
fn starts_only_through_condition_7(content: &str) -> bool {
use crate::utils::html_block::NON_SPEC_BLOCK_ELEMENTS;
const RAW_TEXT_ELEMENTS: [&str; 4] = ["pre", "script", "style", "textarea"];
crate::utils::html_block::parse_html_block_start(content).is_some_and(|(tag, closing)| {
NON_SPEC_BLOCK_ELEMENTS.contains(&tag.as_str()) || (closing && RAW_TEXT_ELEMENTS.contains(&tag.as_str()))
})
}
pub(crate) fn is_paragraph_text_line(line: &str) -> bool {
may_hold_open_paragraph(enter(line, &[], false, false, false, &mut Vec::new(), &mut Vec::new()).content)
}
fn structural_blocks(
line: &LineInfo,
flavor: MarkdownFlavor,
in_mdx_flow: bool,
in_mdx_literal_body: bool,
) -> [bool; 17] {
[
line.in_code_block,
line.in_front_matter,
line.in_html_block,
line.in_html_comment,
line.in_math_block,
in_mdx_literal_body,
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 {
is_opaque_heading_body(0, line, None)
}
fn is_opaque_heading_body(index: usize, line: &LineInfo, mdx_literal_bodies: Option<&[bool]>) -> bool {
line.in_math_block
|| line.in_obsidian_comment
|| mdx_literal_bodies.map_or(line.in_mdx_comment, |bodies| bodies[index])
|| line.in_esm_block
|| line.in_mkdocstrings
}
fn mdx_literal_bodies(content_lines: &[&str], lines: &[LineInfo], ranges: [&[(usize, usize)]; 2]) -> Vec<bool> {
lines
.iter()
.enumerate()
.map(|(index, line)| {
if !line.in_mdx_comment && !line.in_jsx_expression {
return false;
}
let content = content_lines[index];
let start = line.byte_offset + content.len() - content.trim_start_matches([' ', '\t']).len();
let end = line.byte_offset + content.trim_end_matches([' ', '\t', '\r']).len();
ranges.into_iter().any(|ranges| {
let first = ranges.partition_point(|&(_, range_end)| range_end <= start);
ranges
.get(first)
.is_some_and(|&(range_start, range_end)| range_start <= start && end <= range_end)
})
})
.collect()
}
fn underline_indent_is_unbounded(line: &LineInfo, flavor: MarkdownFlavor) -> bool {
flavor == MarkdownFlavor::MDX || line.in_admonition || line.in_content_tab
}
#[derive(Clone, Copy)]
pub(super) enum Definitions<'a> {
Lines(&'a [bool]),
TextStarts(&'a [usize]),
}
#[derive(Clone, Copy, Default)]
struct Trailing<'a> {
underlines: Option<usize>,
text: &'a str,
quote_depth: usize,
carries_item: bool,
carries_footnote: bool,
text_start: usize,
hard_break: bool,
}
#[allow(clippy::too_many_arguments)]
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]>,
mdx_literal_bodies: Option<&[bool]>,
definitions: Option<Definitions<'_>>,
) -> Pass<'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,
mdx_literal_bodies.map_or(lines[index].in_mdx_comment, |bodies| bodies[index]),
)
};
let mut states = Vec::with_capacity(lines.len());
let mut atx_is_text = vec![false; lines.len()];
let mut is_indented_text = vec![false; lines.len()];
let mut is_lazy_table_text = vec![false; lines.len()];
let mut containers = Vec::with_capacity(lines.len() + 1);
let mut empty_item: Option<usize> = None;
let mut open: Vec<Marker> = Vec::new();
let mut opened: Vec<Marker> = Vec::new();
let mut item_spans: Vec<ItemSpan> = Vec::new();
let mut item_chains: Vec<Vec<CommonMarkItem>> = vec![Vec::new(); lines.len()];
let mut paragraph: Option<usize> = None;
let mut paragraph_has_text = false;
let mut in_table = false;
let mut header_cells = None;
let mut in_html_block = super::scratch::LineFlags::new(lines.len());
let mut opens_raw_block = super::scratch::LineFlags::new(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(parser_html_block_is_text) {
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_heading_body(index, line, mdx_literal_bodies)
};
for index in 0..lines.len() {
containers.push(container_of(&open));
let pending_empty_item = empty_item.take();
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,
!has_untracked_margin(&lines[index]),
&mut opened,
&mut item_spans,
);
if opened.is_empty() && entered.content.is_empty() {
if let Some(quote) = open[entered.matched..]
.iter()
.position(|marker| *marker == Marker::Quote)
{
open.truncate(entered.matched + quote);
}
if pending_empty_item == Some(open.len()) {
open.pop();
}
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_item = opened.iter().any(|marker| matches!(marker, Marker::Item(..)));
let carries_footnote = opened.iter().any(|marker| matches!(marker, Marker::Footnote(..)));
let text_start = if entered.content.is_empty() {
content_lines[index].len()
} else {
entered.content.as_ptr() as usize - content_lines[index].as_ptr() as usize
};
let in_footnote_body =
lines[index].in_footnote_definition && open.iter().any(|marker| matches!(marker, Marker::Footnote(..)));
let indented_text = paragraph.is_some()
&& (in_footnote_body || !in_untracked_container(&lines[index]))
&& !has_untracked_margin(&lines[index])
&& opened.is_empty()
&& entered.indent > MAX_MARKER_INDENT;
let line_in_definition = matches!(definitions, Some(Definitions::Lines(flags)) if flags[index]);
let only_definitions = match (definitions, paragraph) {
(Some(Definitions::Lines(flags)), Some(_)) if indented_text => !paragraph_has_text && !flags[index],
(Some(Definitions::TextStarts(starts)), Some(first)) if indented_text => {
starts.get(starts.partition_point(|&start| start < first)) == Some(&index)
}
_ => false,
};
let indented_text_is_text = indented_text && flavor != MarkdownFlavor::MDX;
is_lazy_table_text[index] = indented_text_is_text && !only_definitions;
is_indented_text[index] = indented_text_is_text;
atx_is_text[index] =
indented_text_is_text && matches!(atx_opening(entered.content), Some(AtxOpening::Heading(_)));
let holds_paragraph = !lines[index].in_code_block
&& !in_html_block[index]
&& !lines[index].is_container_marker
&& (indented_text
|| may_hold_open_paragraph(entered.content)
|| (flavor != MarkdownFlavor::MDX && !in_table && starts_only_through_condition_7(entered.content)));
let inside = entered.matched == open.len() && opened.is_empty();
in_table |= !in_table
&& !indented_text_is_text
&& header_cells.is_some_and(|cells| {
TableUtils::is_delimiter_row(entered.content)
&& cells == TableUtils::count_cells_with_flavor(entered.content, flavor)
});
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_item,
carries_footnote,
text_start,
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_item,
carries_footnote,
text_start,
hard_break,
});
continue;
}
let continues = paragraph.is_some() && holds_paragraph && opened.is_empty();
let starts_text = !continues || only_definitions;
if !continues {
if open[..entered.matched]
.iter()
.all(|marker| matches!(marker, Marker::Item(..)))
{
item_chains[index] = opened
.iter()
.zip(&item_spans)
.map_while(|(marker, span)| match marker {
Marker::Item(content_column, _) => Some(CommonMarkItem {
marker_column: span.marker_column,
content_column: *content_column,
is_empty: span.is_empty,
}),
_ => None,
})
.collect();
}
open.truncate(entered.matched);
open.extend_from_slice(&opened);
paragraph = holds_paragraph.then_some(index);
paragraph_has_text = !line_in_definition;
empty_item =
(entered.content.is_empty() && matches!(opened.last(), Some(Marker::Item(..)))).then_some(open.len());
} else {
paragraph_has_text |= !line_in_definition;
}
header_cells =
(starts_text && 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_item,
carries_footnote,
text_start,
hard_break,
});
}
containers.push(container_of(&open));
Pass {
states,
containers,
atx_is_text,
indented_text: is_indented_text,
lazy_table_text: is_lazy_table_text,
item_chains,
}
}
pub(super) fn prefix_may_hide_block(prefix: &str) -> bool {
prefix.contains('\t') || prefix.contains(" ")
}
#[allow(clippy::too_many_arguments)]
pub(super) fn indented_text_lines(
content_lines: &[&str],
lines: &[LineInfo],
flavor: MarkdownFlavor,
html_blocks: &[(usize, usize)],
code_blocks: &[(usize, usize)],
code_spans: &[(usize, usize)],
mdx_flow_lines: Option<&[bool]>,
mdx_literal_ranges: Option<[&[(usize, usize)]; 2]>,
definitions: Option<Definitions<'_>>,
) -> Vec<bool> {
let literal_bodies = mdx_literal_ranges
.filter(|ranges| ranges.iter().any(|ranges| !ranges.is_empty()))
.map(|ranges| mdx_literal_bodies(content_lines, lines, ranges));
trailing_state(
content_lines,
lines,
flavor,
html_blocks,
code_blocks,
code_spans,
mdx_flow_lines,
literal_bodies.as_deref(),
definitions,
)
.lazy_table_text
}
struct Pass<'a> {
states: Vec<Trailing<'a>>,
containers: Vec<HeadingContainer>,
atx_is_text: Vec<bool>,
indented_text: Vec<bool>,
lazy_table_text: Vec<bool>,
item_chains: Vec<Vec<CommonMarkItem>>,
}
fn container_of(open: &[Marker]) -> HeadingContainer {
let mut container = HeadingContainer::Outside;
for marker in open {
match marker {
Marker::Item(content_col, _) => {
container = HeadingContainer::Item {
content_col: *content_col,
}
}
Marker::Quote | Marker::Footnote(..) => return HeadingContainer::Unknown,
}
}
container
}
fn in_untracked_container(line: &LineInfo) -> bool {
has_untracked_margin(line) || line.in_footnote_definition
}
fn has_untracked_margin(line: &LineInfo) -> bool {
line.in_pandoc_div
|| line.in_admonition
|| line.in_content_tab
|| line.in_pymdown_block
|| line.in_myst_directive
|| line.in_mkdocs_html_markdown
|| line.in_definition_list
|| line.in_kramdown_extension_block
}
fn heading_container_of(container: HeadingContainer, lines: &[LineInfo]) -> HeadingContainer {
match container {
HeadingContainer::Item { .. } if lines.iter().any(in_untracked_container) => HeadingContainer::Unknown,
other => other,
}
}
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 = trim_setext_underline_start(underline);
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(),
}
}
fn atx_heading_info(
marker: &AtxMarker<'_>,
base: usize,
content_lines: &[&str],
lines: &[LineInfo],
i: usize,
) -> HeadingInfo {
let AtxMarker {
indent,
hashes,
whitespace,
rest,
} = *marker;
let marker_column = base + indent;
let (text, has_closing, closing_seq) = parse_atx_remainder(rest);
let content_column = marker_column + hashes.len() + whitespace;
let raw_text = text.trim_matches([' ', '\t']).to_string();
let heading_text = crate::utils::header_id_utils::extract_heading_text(raw_text.trim());
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);
}
}
HeadingInfo {
level: hashes.len() as u8,
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,
}
}
fn begins_item_with_hash(line: &str) -> bool {
let mut rest = line.trim_start_matches([' ', '\t', '>']);
let mut markers = 0;
loop {
let marker_len = match rest.bytes().next() {
Some(b'-' | b'+' | b'*') => 1,
Some(b'0'..=b'9') => {
let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
if !matches!(rest.as_bytes().get(digits), Some(b'.' | b')')) {
return false;
}
digits + 1
}
_ => return false,
};
let after = &rest[marker_len..];
if !after.is_empty() && !after.starts_with([' ', '\t']) {
return false;
}
markers += 1;
rest = after.trim_start_matches([' ', '\t', '>']);
if rest.starts_with('#') {
return markers > 0;
}
}
}
pub(super) struct HeadingAnalysis {
pub(super) blockquote_headings: Vec<Option<Box<HeadingInfo>>>,
pub(super) lazy_table_text: Option<Vec<bool>>,
}
#[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]>,
mdx_literal_ranges: Option<[&[(usize, usize)]; 2]>,
) -> HeadingAnalysis {
let mut trailing: Option<Pass> = None;
let literal_bodies = mdx_literal_ranges
.filter(|ranges| ranges.iter().any(|ranges| !ranges.is_empty()))
.map(|ranges| mdx_literal_bodies(content_lines, lines, ranges));
let literal_bodies = literal_bodies.as_deref();
let mut html_open_col = 0;
let mut quoted_item_headings: Vec<(usize, HeadingInfo)> = Vec::new();
for i in 0..lines.len() {
let line = content_lines[i];
let html_block_body = lines[i].in_html_block && i > 0 && lines[i - 1].in_html_block;
if lines[i].in_html_block && !html_block_body {
html_open_col = lines[i].visual_indent;
}
let html_text = html_block_body && lines[i].visual_indent >= html_open_col && !lines[i].in_mkdocs_html_markdown;
let in_front_matter = front_matter_end > 0 && i < front_matter_end;
if !in_front_matter
&& !html_text
&& 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 indent_may_hide_block = prefix_may_hide_block(&full_prefix);
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_matches([' ', '\t', '\r']))
{
let lazy_text = indent_may_hide_block
&& trailing
.get_or_insert_with(|| {
trailing_state(
content_lines,
lines,
flavor,
html_blocks,
code_blocks,
code_spans,
mdx_flow_lines,
literal_bodies,
None,
)
})
.indented_text[i];
lines[i].is_horizontal_rule = !lazy_text;
}
}
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_heading_body(i, &lines[i], literal_bodies) {
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 heading = atx_heading_info(&caps, 0, content_lines, lines, i);
let marker_column = heading.marker_column;
let container = if marker_column == 0 {
HeadingContainer::Outside
} else {
let pass = trailing.get_or_insert_with(|| {
trailing_state(
content_lines,
lines,
flavor,
html_blocks,
code_blocks,
code_spans,
mdx_flow_lines,
literal_bodies,
None,
)
});
if pass.atx_is_text[i] {
continue;
}
heading_container_of(pass.containers[i + 1], &lines[i..=i])
};
if let HeadingContainer::Item { content_col } = container
&& lines[i].visual_indent >= content_col + 4
{
continue;
}
lines[i].heading = Some(Box::new(heading));
lines[i].heading_container = Some(container);
continue;
}
if atx.is_none() && !is_snippet_line && begins_item_with_hash(line) {
let pass = trailing.get_or_insert_with(|| {
trailing_state(
content_lines,
lines,
flavor,
html_blocks,
code_blocks,
code_spans,
mdx_flow_lines,
literal_bodies,
None,
)
});
let state = pass.states[i];
if state.carries_item
&& !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)
&& let Some(AtxOpening::Heading(caps)) = atx_opening(state.text)
{
let heading = atx_heading_info(&caps, state.text_start, content_lines, lines, i);
if state.quote_depth == 0 {
lines[i].heading_container = Some(heading_container_of(pass.containers[i + 1], &lines[i..=i]));
lines[i].heading = Some(Box::new(heading));
} else {
quoted_item_headings.push((i, heading));
}
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 pass = trailing.get_or_insert_with(|| {
trailing_state(
content_lines,
lines,
flavor,
html_blocks,
code_blocks,
code_spans,
mdx_flow_lines,
literal_bodies,
None,
)
});
let states = &pass.states;
let Some(first) = states[i + 1].underlines else {
continue;
};
if states[first].carries_footnote {
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() - trim_setext_underline_start(next_line).len(),
if states[first].carries_item {
states[first].text_start
} else {
lines[first].indent
},
attribute_id,
);
let container = heading_container_of(pass.containers[i + 1], &lines[first..=i + 1]);
for text_line in &mut lines[first..=i] {
text_line.is_setext_heading_text = true;
text_line.atx_missing_space = None;
text_line.heading_container = Some(container);
}
lines[i + 1].heading_container = Some(container);
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,
literal_bodies,
)
})
.collect();
for (line_index, heading) in blockquote_headings.iter_mut().enumerate() {
if heading.is_none() {
continue;
}
let pass = trailing.get_or_insert_with(|| {
trailing_state(
content_lines,
lines,
flavor,
html_blocks,
code_blocks,
code_spans,
mdx_flow_lines,
literal_bodies,
None,
)
});
if pass.atx_is_text[line_index] || pass.indented_text[line_index] {
*heading = None;
}
}
for (line_index, heading) in quoted_item_headings {
if blockquote_headings[line_index].is_none() {
blockquote_headings[line_index] = Some(Box::new(heading));
}
}
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 states = &trailing
.get_or_insert_with(|| {
trailing_state(
content_lines,
lines,
flavor,
html_blocks,
code_blocks,
code_spans,
mdx_flow_lines,
literal_bodies,
None,
)
})
.states;
let (underline_text, underline_column) = (underline.content.as_str(), underline.prefix.len());
let Some(first) = states[underline_index].underlines else {
continue;
};
if states[first].carries_footnote {
continue;
}
let quoted_item = states[first].carries_item;
let line_allowed = |index: usize| {
quote_heading_line_allowed(
index,
&lines[index],
html_comment_ranges,
front_matter_end,
literal_bodies,
)
};
let text_quote = blockquote_heading_container(
text_index,
&lines[text_index],
flavor,
html_comment_ranges,
front_matter_end,
literal_bodies,
);
if text_quote.is_none() && !(quoted_item && text_index == first && line_allowed(text_index)) {
continue;
}
let quotes_written = content_lines[text_index][..states[text_index].text_start]
.bytes()
.filter(|&byte| byte == b'>')
.count();
if quotes_written != states[text_index].quote_depth {
continue;
}
let content_column = if quoted_item {
if !line_allowed(first) {
continue;
}
states[first].text_start
} else {
let Some(first_quote) = blockquote_heading_container(
first,
&lines[first],
flavor,
html_comment_ranges,
front_matter_end,
literal_bodies,
) else {
continue;
};
first_quote.prefix.len()
};
blockquote_headings[text_index] = Some(Box::new(setext_heading_info(
¶graph_text(&states[first..=text_index]),
text_index + 1 - first,
underline_text,
underline_column,
content_column,
None,
)));
}
if lines.iter().any(|line| line.list_item.is_some()) {
let pass = trailing.get_or_insert_with(|| {
trailing_state(
content_lines,
lines,
flavor,
html_blocks,
code_blocks,
code_spans,
mdx_flow_lines,
literal_bodies,
None,
)
});
let chains = std::mem::take(&mut pass.item_chains);
for (line, chain) in lines.iter_mut().zip(chains) {
if let Some(item) = line.list_item.as_deref_mut() {
item.commonmark_items = chain;
}
}
}
HeadingAnalysis {
blockquote_headings,
lazy_table_text: trailing.map(|pass| pass.lazy_table_text),
}
}
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_matches([' ', '\t']);
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 || matches!(char_positions[char_idx - 1].1, ' ' | '\t'));
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_matches([' ', '\t'])
.to_string()
} else {
format!(
"{}{}",
trimmed_rest[..start_of_hashes].trim_end_matches([' ', '\t']),
custom_id_part
)
};
(text, true, potential_closing.to_string())
}
pub(crate) fn atx_display_text_for_proof(line: &str) -> Option<String> {
let AtxOpening::Heading(marker) = atx_opening(line)? else {
return None;
};
let (text, _, _) = parse_atx_remainder(marker.rest);
Some(crate::utils::header_id_utils::extract_heading_text(text.trim()).text)
}
fn quote_heading_line_allowed(
line_index: usize,
line: &LineInfo,
html_comment_ranges: &[crate::utils::skip_context::ByteRange],
front_matter_end: usize,
mdx_literal_bodies: Option<&[bool]>,
) -> bool {
!(line.in_code_block
|| (line.in_html_block && !line.in_mkdocs_html_markdown)
|| line.in_kramdown_extension_block
|| is_opaque_heading_body(line_index, line, mdx_literal_bodies)
|| (front_matter_end > 0 && line_index < front_matter_end)
|| crate::utils::skip_context::is_in_html_comment_ranges(html_comment_ranges, line.byte_offset))
}
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,
mdx_literal_bodies: Option<&[bool]>,
) -> Option<&'a BlockquoteInfo> {
if !quote_heading_line_allowed(
line_index,
line,
html_comment_ranges,
front_matter_end,
mdx_literal_bodies,
) {
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,
mdx_literal_bodies: Option<&[bool]>,
) -> Option<Box<HeadingInfo>> {
let blockquote = blockquote_heading_container(
line_index,
line,
flavor,
html_comment_ranges,
front_matter_end,
mdx_literal_bodies,
)?;
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_matches([' ', '\t']).to_string();
let heading_text = crate::utils::header_id_utils::extract_heading_text(raw_text.trim());
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,
}))
}
fn parser_html_block_is_text(opener: &LineInfo) -> bool {
opener.in_front_matter || opener.in_code_block || is_opaque_body(opener)
}
pub(super) fn mark_parser_html_blocks(
content: &str,
lines: &mut [LineInfo],
blocks: &[(usize, usize)],
flavor: MarkdownFlavor,
) {
if flavor.supports_jsx() {
return;
}
for &(start, end) in blocks {
let spanned = spanned_lines(lines, start, end);
let Some(opener) = lines.get(spanned.start) else {
continue;
};
let before_tag = &content[opener.byte_offset..start];
if opener.in_html_block
|| parser_html_block_is_text(opener)
|| !before_tag.trim().is_empty()
|| content[start..].starts_with("<!")
|| content[start..].starts_with("<?")
{
continue;
}
for line in &mut lines[spanned] {
line.in_html_block = true;
line.is_blank &= line.content(content).trim().is_empty();
}
}
}
pub(super) fn parser_html_block_lines(
lines: &[LineInfo],
blocks: &[(usize, usize)],
flavor: MarkdownFlavor,
) -> Vec<std::ops::Range<usize>> {
if flavor.supports_jsx() {
return Vec::new();
}
blocks
.iter()
.map(|&(start, end)| spanned_lines(lines, start, end))
.filter(|spanned| {
lines
.get(spanned.start)
.is_some_and(|opener| !parser_html_block_is_text(opener))
})
.collect()
}
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;
let closing_tag = format!("</{tag_name}>");
let allow_blank_lines = !is_closing && TYPE_1_BLOCK_ELEMENTS.contains(&tag_name.as_str());
if allow_blank_lines && lines[i].content(content).contains(&closing_tag) {
i += 1;
continue;
}
let open_col = lines[i].visual_indent;
let in_block_text =
|line: &LineInfo| line.visual_indent >= open_col && !line.content(content).trim().is_empty();
let ends_block = |line: &LineInfo| !in_block_text(line) && line.is_blank;
let mut j = i + 1;
let mut found_closing_tag = false;
while j < lines.len() {
if !allow_blank_lines && ends_block(&lines[j]) {
break;
}
lines[j].in_html_block = true;
if in_block_text(&lines[j]) {
lines[j].is_blank = false;
}
if lines[j].content(content).contains(&closing_tag) {
found_closing_tag = true;
}
if found_closing_tag {
j += 1;
while j < lines.len() {
if ends_block(&lines[j]) {
break;
}
lines[j].in_html_block = true;
if in_block_text(&lines[j]) {
lines[j].is_blank = false;
}
j += 1;
}
break;
}
j += 1;
}
i = j;
}
}
#[cfg(test)]
mod marker_line_tests {
use super::begins_item_with_hash;
#[test]
fn begins_item_with_hash_accepts_only_marker_chains_ending_in_a_hash() {
for line in [
"- # a", "1. ## a", "- - # a", "- > # a", "> - # a", "1) # a", "-\t# a", "- #",
] {
assert!(begins_item_with_hash(line), "{line:?}");
}
for line in [
"# a", "> # a", "- a # b", "-# a", "1.# a", "text # a", "a - # b", "- ", "",
] {
assert!(!begins_item_with_hash(line), "{line:?}");
}
}
}
#[cfg(test)]
mod marker_tests {
use super::*;
use proptest::prelude::*;
fn regex_atx(line: &str) -> Option<AtxOpening<'_>> {
static PATTERN: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"^([ \t]*)(#{1,6})([ \t]*)(.*)$").unwrap());
let caps = PATTERN.captures(line)?;
let hashes = caps.get(2).unwrap();
let after = &line[hashes.end()..];
if after.is_empty() || after.starts_with([' ', '\t']) {
Some(AtxOpening::Heading(AtxMarker {
indent: caps[1].len(),
hashes: hashes.as_str(),
whitespace: caps[3].len(),
rest: caps.get(4).unwrap().as_str(),
}))
} else if after.starts_with('#') {
None
} else {
Some(AtxOpening::MissingSpace {
level: hashes.len() as u8,
})
}
}
fn regex_item(line: &str, interrupting: bool) -> Option<OpenedItem> {
static BULLET: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"^([ \t]*)([*+-])([ \t]+|$)").unwrap());
static ORDERED: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"^([ \t]*)([0-9]+)([.)])([ \t]+|$)").unwrap());
let (marker_end, text_start) = if let Some(caps) = BULLET.captures(line) {
(caps.get(2)?.end(), caps.get(0)?.end())
} else {
let caps = ORDERED.captures(line)?;
let number = caps.get(2)?.as_str();
if number.len() > 9 || (interrupting && number.trim_start_matches('0') != "1") {
return None;
}
(caps.get(3)?.end(), caps.get(0)?.end())
};
if interrupting && is_cm_blank(&line[text_start..]) {
return None;
}
Some(OpenedItem { text_start, marker_end })
}
proptest! {
#[test]
fn atx_scanner_matches_capture_reading(line in "[ \\t]{0,5}#{0,10}[ \\t]{0,5}[^\\n]{0,40}") {
prop_assert_eq!(atx_opening(&line), regex_atx(&line));
}
#[test]
fn item_scanner_matches_capture_reading(
line in "[ \\t]{0,5}([*+\\-]|[0-9]{1,12}[.)]?)[ \\t]{0,5}[^\\n]{0,40}",
interrupting in any::<bool>(),
) {
prop_assert_eq!(list_item_content_column(&line, interrupting), regex_item(&line, interrupting));
}
}
}