use crate::config::MarkdownFlavor;
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::types::*;
static ATX_HEADING_REGEX: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"^(\s*)(#{1,6})(\s*)(.*)$").unwrap());
const MAX_ORDERED_MARKER_DIGITS: usize = 9;
fn list_item_content_column(line: &str, interrupting: bool) -> Option<usize> {
if let Some(marker) = UNORDERED_LIST_MARKER_REGEX.find(line) {
return Some(marker.end());
}
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;
}
Some(marker.get(0)?.end())
}
#[derive(Clone, Copy, PartialEq)]
enum Marker {
Quote,
Item(usize),
}
fn bare_content_column(open: &[Marker]) -> Option<usize> {
let mut column = 0;
for marker in open {
match marker {
Marker::Quote => return None,
Marker::Item(content_column) => column = *content_column,
}
}
Some(column)
}
struct Entered<'a> {
matched: usize,
content: &'a str,
}
fn enter<'a>(line: &'a str, open: &[Marker], paragraph: bool, opened: &mut Vec<Marker>) -> Entered<'a> {
opened.clear();
let mut rest = line;
let mut matched = 0;
while matched < open.len() {
match open[matched] {
Marker::Quote => match crate::utils::blockquote::parse_blockquote_prefix(rest) {
Some(quote) => rest = quote.content,
None => break,
},
Marker::Item(content_column) if line.len() - rest.trim_start().len() >= content_column => {}
Marker::Item(_) => break,
}
matched += 1;
}
loop {
if let Some(quote) = crate::utils::blockquote::parse_blockquote_prefix(rest) {
opened.push(Marker::Quote);
rest = quote.content;
continue;
}
if is_horizontal_rule_content(rest.trim()) {
break;
}
let interrupting = paragraph && matched == open.len() && opened.is_empty();
match list_item_content_column(rest, interrupting) {
Some(end) => {
rest = &rest[end..];
opened.push(Marker::Item(line.len() - rest.len()));
}
None => break,
}
}
Entered {
matched,
content: rest.trim(),
}
}
fn may_hold_open_paragraph(content: &str) -> bool {
if content.is_empty() {
return false;
}
!(is_horizontal_rule_content(content)
|| ATX_HEADING_REGEX.is_match(content)
|| 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, &mut Vec::new()).content)
}
fn structural_blocks(line: &LineInfo) -> [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,
line.in_jsx_block,
line.in_pandoc_div,
line.is_div_marker,
line.in_admonition,
line.in_content_tab,
line.in_pymdown_block,
line.in_myst_directive,
line.is_myst_comment,
]
}
#[derive(Clone, Copy)]
struct Trailing {
open: bool,
in_table: bool,
bare_column: Option<usize>,
}
fn trailing_state(content_lines: &[&str], lines: &[LineInfo], flavor: MarkdownFlavor) -> Vec<Trailing> {
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 = false;
let mut in_table = false;
let mut header_cells = None;
for index in 0..lines.len() {
if index > 0 && structural_blocks(&lines[index]) != structural_blocks(&lines[index - 1]) {
open.clear();
paragraph = false;
in_table = false;
header_cells = None;
}
if lines[index].is_blank {
if let Some(quote) = open.iter().position(|marker| *marker == Marker::Quote) {
open.truncate(quote);
}
paragraph = false;
in_table = false;
header_cells = None;
states.push(Trailing {
open: false,
in_table: false,
bare_column: bare_content_column(&open),
});
continue;
}
let entered = enter(content_lines[index], &open, paragraph, &mut opened);
let holds_paragraph = 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 && inside && is_setext_underline_content(entered.content);
if in_table || underlines {
paragraph = false;
header_cells = None;
states.push(Trailing {
open: false,
in_table,
bare_column: bare_content_column(&open),
});
continue;
}
let continues = paragraph && holds_paragraph && opened.is_empty();
if !continues {
open.truncate(entered.matched);
open.extend_from_slice(&opened);
}
paragraph = holds_paragraph;
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 {
open: paragraph,
in_table: false,
bare_column: bare_content_column(&open),
});
}
states
}
fn setext_underline_is_lazy(text_line: Trailing, underline_indent: usize) -> bool {
let underline_is_inside = text_line.bare_column.is_some_and(|column| underline_indent >= column);
text_line.in_table || (text_line.open && !underline_is_inside)
}
pub(super) fn detect_headings_and_blockquotes(
content_lines: &[&str],
lines: &mut [LineInfo],
flavor: MarkdownFlavor,
html_comment_ranges: &[crate::utils::skip_context::ByteRange],
link_byte_ranges: &[(usize, usize)],
front_matter_end: usize,
) -> 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 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
};
if !is_snippet_line && let Some(caps) = ATX_HEADING_REGEX.captures(line) {
if crate::utils::skip_context::is_in_html_comment_ranges(html_comment_ranges, lines[i].byte_offset) {
continue;
}
let line_offset = lines[i].byte_offset;
if link_byte_ranges
.iter()
.any(|&(start, end)| line_offset > start && line_offset < end)
{
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 (clean_text, mut custom_id) = crate::utils::header_id_utils::extract_header_id(&raw_text);
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);
}
}
let is_valid = !spaces_after.is_empty()
|| rest.is_empty()
|| level > 1
|| rest.trim().chars().next().is_some_and(char::is_uppercase);
lines[i].heading = Some(Box::new(HeadingInfo {
level,
style: HeadingStyle::ATX,
marker: hashes.to_string(),
marker_column,
content_column,
text: clean_text,
custom_id,
raw_text,
has_closing_sequence: has_closing,
closing_sequence: closing_seq,
is_valid,
}));
}
else 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 content_line = line.trim();
if content_line.starts_with('-') || content_line.starts_with('*') || content_line.starts_with('+') {
continue;
}
if content_line.starts_with('_') {
let non_ws: String = content_line.chars().filter(|c| !c.is_whitespace()).collect();
if non_ws.len() >= 3 && non_ws.chars().all(|c| c == '_') {
continue;
}
}
if let Some(first_char) = content_line.chars().next()
&& first_char.is_ascii_digit()
{
let num_end = content_line.chars().take_while(char::is_ascii_digit).count();
if num_end < content_line.len() {
let next = content_line.chars().nth(num_end);
if next == Some('.') || next == Some(')') {
continue;
}
}
}
if ATX_HEADING_REGEX.is_match(line) {
continue;
}
if content_line.starts_with('>') {
continue;
}
let trimmed_start = line.trim_start();
if trimmed_start.len() >= 3 {
let first_three: String = trimmed_start.chars().take(3).collect();
if first_three == "```" || first_three == "~~~" {
continue;
}
}
if content_line.starts_with('<') {
continue;
}
if content_line.starts_with('|') {
let mut is_in_table = false;
if TableUtils::is_delimiter_row(content_line)
&& i > 0
&& content_lines[i - 1].trim().contains('|')
&& !lines[i - 1].in_code_block
{
is_in_table = true;
}
if !is_in_table {
for j in (0..i).rev() {
let prev = content_lines[j].trim();
if prev.is_empty() || lines[j].in_code_block || lines[j].in_html_block {
break;
}
if TableUtils::is_delimiter_row(prev) {
is_in_table = true;
break;
}
if !prev.contains('|') {
break;
}
}
}
if is_in_table {
continue;
}
}
let underline_indent = next_line.len() - next_line.trim_start().len();
let text_line = trailing.get_or_insert_with(|| trailing_state(content_lines, lines, flavor))[i];
if setext_underline_is_lazy(text_line, underline_indent) {
continue;
}
let underline = next_line.trim();
let level = if underline.starts_with('=') { 1 } else { 2 };
let style = if level == 1 {
HeadingStyle::Setext1
} else {
HeadingStyle::Setext2
};
let raw_text = line.trim().to_string();
let (clean_text, mut custom_id) = crate::utils::header_id_utils::extract_header_id(&raw_text);
if custom_id.is_none() && i + 2 < content_lines.len() && i + 2 < lines.len() {
let attr_line = content_lines[i + 2];
if !lines[i + 2].in_code_block
&& crate::utils::header_id_utils::is_standalone_attr_list(attr_line)
&& let Some(attr_line_id) =
crate::utils::header_id_utils::extract_standalone_attr_list_id(attr_line)
{
custom_id = Some(attr_line_id);
}
}
lines[i].heading = Some(Box::new(HeadingInfo {
level,
style,
marker: underline.to_string(),
marker_column: next_line.len() - next_line.trim_start().len(),
content_column: lines[i].indent,
text: clean_text,
custom_id,
raw_text,
has_closing_sequence: false,
closing_sequence: String::new(),
is_valid: true,
}));
}
}
}
lines
.iter()
.enumerate()
.map(|(line_index, line)| detect_blockquote_atx_heading(line_index, line, flavor, front_matter_end))
.collect()
}
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 detect_blockquote_atx_heading(
line_index: usize,
line: &LineInfo,
flavor: MarkdownFlavor,
front_matter_end: usize,
) -> Option<Box<HeadingInfo>> {
if line.in_code_block
|| (line.in_html_block && !line.in_mkdocs_html_markdown)
|| line.in_kramdown_extension_block
|| (front_matter_end > 0 && line_index < front_matter_end)
{
return None;
}
let blockquote = line.blockquote.as_ref()?;
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;
}
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(u8::is_ascii_whitespace).count();
if spaces_len == 0 {
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 (text, custom_id) = crate::utils::header_id_utils::extract_header_id(&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,
custom_id,
raw_text,
has_closing_sequence,
closing_sequence,
is_valid: true,
}))
}
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;
}
}