use tracing::trace;
use crate::wrap::{
BlockKind,
FenceTracker,
LinkReferenceMatcher,
classify_block,
is_fence,
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((level, prefix_len, text)) =
detect_setext_heading(line, lines.get(idx + 1).map(String::as_str), link_matcher)
{
let prefix = &line[..prefix_len];
out.push(build_heading_line(prefix, level, &text));
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 idx = 0;
while idx < lines.len() {
let line = &lines[idx];
let fence = fence_tracker.observe_source_line(line);
if !fence.is_fence_marker
&& !fence.is_in_fence
&& detect_setext_heading(line, lines.get(idx + 1).map(String::as_str), link_matcher)
.is_some()
{
setext_text_lines[idx] = true;
idx += 2;
} else {
idx += 1;
}
}
setext_text_lines
}
fn detect_setext_heading(
line: &str,
underline: Option<&str>,
link_matcher: LinkReferenceMatcher,
) -> Option<(usize, usize, String)> {
let underline = underline?;
if line.trim().is_empty() {
return None;
}
let prefix_len = shared_prefix_len(line, underline);
if has_unmatched_prefix(line, underline) {
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 text = line[prefix_len..].trim();
if text.is_empty() {
return None;
}
if !is_setext_text(text, link_matcher) {
return None;
}
let underline_body = underline[prefix_len..].trim();
if underline_body.is_empty() {
return None;
}
let marker = underline_body.chars().next()?;
if marker != '=' && marker != '-' {
return None;
}
if !underline_body.chars().all(|c| c == marker) {
return None;
}
if underline_body.len() < 3 {
return None;
}
let level = if marker == '=' { 1 } else { 2 };
Some((level, prefix_len, text.to_string()))
}
fn is_setext_text(text: &str, link_matcher: LinkReferenceMatcher) -> bool {
if is_fence(text).is_some() {
trace!(
payload_len = text.len(),
"refusing a Setext candidate that is a fence marker"
);
return false;
}
if is_table_syntax(text) {
trace!(
payload_len = text.len(),
"refusing a Setext candidate that is table syntax"
);
return false;
}
match classify_block(text, link_matcher) {
None | Some(BlockKind::DigitPrefix) => true,
Some(
kind @ (BlockKind::Heading
| BlockKind::ThematicBreak
| BlockKind::Bullet
| BlockKind::Blockquote
| BlockKind::FootnoteDefinition
| BlockKind::LinkReferenceDefinition
| BlockKind::MarkdownlintDirective),
) => {
trace!(
?kind,
"refusing a Setext candidate that is itself a block start"
);
false
}
}
}
fn is_table_syntax(text: &str) -> bool { is_table_row(text) || is_table_delimiter_row(text) }
fn is_table_row(text: &str) -> bool { text.starts_with('|') }
fn is_table_delimiter_row(text: &str) -> bool {
text.contains('|') && crate::table::SEP_RE.is_match(text)
}
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);
line_prefix != underline_prefix && (line_prefix > 0 || underline_prefix > 0)
}
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 build_heading_line(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;