use tracing::trace;
use crate::{
classify::{
ClassifyCtx,
LineClass,
ListContinuationState,
classify_line_with_body,
is_atx_heading_line,
is_setext_text_line,
is_setext_underline_line,
quote_depth,
},
wrap::{
BlockKind,
FenceTracker,
LinkReferenceMatcher,
classify_residual_block,
leading_indent,
},
};
#[must_use]
pub fn convert_setext_headings(lines: &[String]) -> Vec<String> {
let mut out = Vec::with_capacity(lines.len());
let setext_text_lines = setext_text_lines(lines);
let link_matcher = LinkReferenceMatcher::production();
let mut idx = 0;
while idx < lines.len() {
let line = &lines[idx];
if setext_text_lines[idx]
&& let Some(emitted) = detect_verified_setext_heading(
line,
lines.get(idx + 1).map(String::as_str),
link_matcher,
None,
)
{
out.push(emitted);
idx += 2;
continue;
}
out.push(line.clone());
idx += 1;
}
out
}
#[must_use]
pub(crate) fn setext_text_lines(lines: &[String]) -> Vec<bool> {
let mut setext_text_lines = vec![false; lines.len()];
let link_matcher = LinkReferenceMatcher::production();
let mut fence_tracker = FenceTracker::default();
let mut lists = ListContinuationState::default();
let mut idx = 0;
while idx < lines.len() {
let line = &lines[idx];
let fence = fence_tracker.observe_source_line(line);
let continuation_indent = if fence.is_fence_marker || fence.is_in_fence {
lists.reset();
None
} else {
let classified = classify_line_with_body(line, &ClassifyCtx::default());
lists.observe(line, &classified)
};
if !fence.is_fence_marker
&& !fence.is_in_fence
&& detect_verified_setext_heading(
line,
lines.get(idx + 1).map(String::as_str),
link_matcher,
continuation_indent,
)
.is_some()
{
setext_text_lines[idx] = true;
lists.reset();
idx += 2;
} else {
idx += 1;
}
}
setext_text_lines
}
fn detect_verified_setext_heading(
line: &str,
underline: Option<&str>,
link_matcher: LinkReferenceMatcher,
continuation_indent: Option<usize>,
) -> Option<String> {
let (level, prefix_len, text) =
detect_setext_heading(line, underline, link_matcher, continuation_indent)?;
let emitted = convert_setext(&line[..prefix_len], level, &text);
is_atx_heading_line(&emitted, &ClassifyCtx::default()).then_some(emitted)
}
pub(crate) fn setext_heading_lines(lines: &[String]) -> Vec<bool> {
let mut heading = vec![false; lines.len()];
for (index, is_text) in setext_text_lines(lines).into_iter().enumerate() {
if is_text {
for slot in heading.iter_mut().skip(index).take(2) {
*slot = true;
}
}
}
heading
}
fn detect_setext_heading(
line: &str,
underline: Option<&str>,
link_matcher: LinkReferenceMatcher,
continuation_indent: Option<usize>,
) -> Option<(usize, usize, String)> {
let underline = underline?;
if line.trim().is_empty() {
return None;
}
let prefix_len = shared_prefix_len(line, underline);
let prefixes_agree = !has_unmatched_prefix(line, underline);
if !prefixes_agree {
return None;
}
if continuation_indent.is_some_and(|indent| content_indent_width(underline) < indent) {
return None;
}
if prefix_len > 0
&& !line[..prefix_len]
.chars()
.all(|c| c.is_whitespace() || c == '>')
{
return None;
}
let indent_width = content_indent_width(line);
if indent_width >= 4 {
trace!(
indent_width,
"refusing a Setext candidate indented as an indented code block"
);
return None;
}
let candidate_is_paragraph = is_setext_text_line(line, &ClassifyCtx::default());
let text = line[prefix_len..].trim();
if text.is_empty() {
return None;
}
if !is_setext_text(text, candidate_is_paragraph, link_matcher) {
return None;
}
if !is_setext_underline_line(
underline,
&ClassifyCtx::following(LineClass::ParagraphText, prefixes_agree),
) {
return None;
}
let marker = underline[prefix_len..].trim().chars().next()?;
let level = if marker == '=' { 1 } else { 2 };
Some((level, prefix_len, text.to_string()))
}
fn is_setext_text(
text: &str,
candidate_is_paragraph: bool,
link_matcher: LinkReferenceMatcher,
) -> bool {
if !candidate_is_paragraph {
trace!(
candidate_is_paragraph,
payload_len = text.len(),
"refusing a Setext candidate with a structural line class"
);
return false;
}
match classify_residual_block(text, link_matcher) {
None => true,
Some(
kind @ (BlockKind::Blockquote
| BlockKind::FootnoteDefinition
| BlockKind::LinkReferenceDefinition
| BlockKind::MarkdownlintDirective),
) => {
trace!(
?kind,
"refusing a Setext candidate that is itself a block start"
);
false
}
Some(_) => false,
}
}
fn content_indent_width(line: &str) -> usize {
let mut rest = line;
while let Some(tail) = strip_blockquote_marker(rest) {
rest = tail;
}
leading_indent(rest).0
}
fn strip_blockquote_marker(line: &str) -> Option<&str> {
let indent = line.bytes().take_while(|byte| *byte == b' ').count().min(3);
let marker = line.get(indent..)?.strip_prefix('>')?;
Some(marker.strip_prefix(' ').unwrap_or(marker))
}
fn shared_prefix_len(a: &str, b: &str) -> usize {
let mut end = 0;
let mut iter_a = a.char_indices();
let mut iter_b = b.char_indices();
loop {
match (iter_a.next(), iter_b.next()) {
(Some((idx_a, ch_a)), Some((_, ch_b))) if ch_a == ch_b => {
end = idx_a + ch_a.len_utf8();
}
_ => break,
}
}
end
}
fn has_unmatched_prefix(line: &str, underline: &str) -> bool {
let line_prefix = prefix_of_indent_or_quote(line);
let underline_prefix = prefix_of_indent_or_quote(underline);
let line_depth = quote_depth(&line[..line_prefix]);
let underline_depth = quote_depth(&underline[..underline_prefix]);
line_depth != underline_depth
}
fn prefix_of_indent_or_quote(text: &str) -> usize {
let mut last = 0;
for (idx, ch) in text.char_indices() {
if ch.is_whitespace() || ch == '>' {
last = idx + ch.len_utf8();
continue;
}
break;
}
last
}
fn convert_setext(prefix: &str, level: usize, text: &str) -> String {
let mut heading = String::new();
heading.push_str(prefix);
if needs_space_after(prefix) {
heading.push(' ');
}
heading.push_str(&"#".repeat(level));
if !text.is_empty() {
heading.push(' ');
heading.push_str(text);
}
heading
}
fn needs_space_after(prefix: &str) -> bool {
!prefix.is_empty() && !prefix.chars().last().is_some_and(char::is_whitespace)
}
#[cfg(test)]
#[path = "headings_tests.rs"]
mod tests;