use crate::grammar::shared::Span;
use nom::{bytes::complete::take_while, character::complete::line_ending, IResult, Input};
use crate::grammar::blocks::cm_link_reference::link_reference_definition;
pub fn setext_heading(input: Span) -> IResult<Span, (u8, Span)> {
log::debug!("Parsing Setext heading: {:?}", input.fragment());
let start = input;
let start_offset = start.location_offset();
let first_line_end = input
.fragment()
.find('\n')
.unwrap_or(input.fragment().len());
let first_line = &input.fragment()[..first_line_end];
let trimmed_first = first_line.trim_start_matches(' ');
if trimmed_first.starts_with('[') && trimmed_first.contains("]:") {
if link_reference_definition(input).is_ok() {
log::debug!("Setext heading rejected: content is a link reference definition");
return Err(nom::Err::Error(nom::error::Error::new(
start,
nom::error::ErrorKind::Tag,
)));
}
}
fn has_blockquote_marker(line: &str) -> bool {
let trimmed = line.trim_start_matches(' ');
let leading_spaces = line.len() - trimmed.len();
leading_spaces <= 3 && trimmed.starts_with('>')
}
let mut content_end_offset;
let mut current_input = input;
let mut has_content = false;
let mut first_line_in_blockquote: Option<bool> = None;
loop {
let (after_spaces, leading_spaces) = take_while(|c| c == ' ')(current_input)?;
if leading_spaces.fragment().len() > 3 {
if !has_content {
return Err(nom::Err::Error(nom::error::Error::new(
start,
nom::error::ErrorKind::Tag,
)));
}
break;
}
let (after_line, text_line) = take_while(|c| c != '\n' && c != '\r')(after_spaces)?;
if !has_content && text_line.fragment().trim().is_empty() {
return Err(nom::Err::Error(nom::error::Error::new(
start,
nom::error::ErrorKind::Tag,
)));
}
let line_start = current_input.location_offset();
let line_end = text_line.location_offset() + text_line.fragment().len();
let full_line_len = line_end - line_start;
let full_line =
¤t_input.fragment()[..full_line_len.min(current_input.fragment().len())];
let this_line_in_blockquote = has_blockquote_marker(full_line);
if first_line_in_blockquote.is_none() {
first_line_in_blockquote = Some(this_line_in_blockquote);
} else if let Some(first_context) = first_line_in_blockquote {
if first_context != this_line_in_blockquote {
log::debug!("Setext heading rejected: content crosses blockquote boundary");
return Err(nom::Err::Error(nom::error::Error::new(
start,
nom::error::ErrorKind::Tag,
)));
}
}
has_content = true;
content_end_offset = text_line.location_offset() + text_line.fragment().len();
let (after_newline, _) = line_ending(after_line)?;
if after_newline.fragment().starts_with('\n') || after_newline.fragment().starts_with('\r')
{
return Err(nom::Err::Error(nom::error::Error::new(
start,
nom::error::ErrorKind::Tag,
)));
}
let (peek_after_spaces, underline_spaces) = take_while(|c| c == ' ')(after_newline)?;
if underline_spaces.fragment().len() > 3 {
current_input = after_newline;
continue;
}
let _underline_line_start = after_newline.location_offset();
let underline_peek_len = after_newline
.fragment()
.find('\n')
.unwrap_or(after_newline.fragment().len());
let underline_full_line =
&after_newline.fragment()[..underline_peek_len.min(after_newline.fragment().len())];
let underline_in_blockquote = has_blockquote_marker(underline_full_line);
if first_line_in_blockquote.unwrap() != underline_in_blockquote {
log::debug!("Setext heading rejected: underline crosses blockquote boundary");
return Err(nom::Err::Error(nom::error::Error::new(
start,
nom::error::ErrorKind::Tag,
)));
}
if let Ok((peek_after_char, first_char)) =
nom::character::complete::one_of::<_, _, nom::error::Error<_>>("=-")(peek_after_spaces)
{
let (after_underline, _) = take_while(|c| c == first_char)(peek_after_char)?;
let underline_offset = peek_after_spaces.location_offset();
let underline_len = after_underline.location_offset() - underline_offset;
let underline_str = &peek_after_spaces.fragment()[..underline_len];
if underline_str.chars().all(|c| c == first_char) && !underline_str.is_empty() {
let (after_trailing_ws, _) =
take_while(|c| c == ' ' || c == '\t')(after_underline)?;
let remaining = if let Ok((r, _)) =
line_ending::<Span, nom::error::Error<Span>>(after_trailing_ws)
{
r
} else if let Ok((r, _)) =
nom::combinator::eof::<Span, nom::error::Error<Span>>(after_trailing_ws)
{
r
} else {
current_input = after_newline;
continue;
};
{
let level = if first_char == '=' { 1 } else { 2 };
let content_len = content_end_offset - start_offset;
let content_span = start.take(content_len);
log::debug!(
"Setext heading parsed: level={}, text={:?}",
level,
content_span.fragment()
);
return Ok((remaining, (level, content_span)));
}
}
}
current_input = after_newline;
}
Err(nom::Err::Error(nom::error::Error::new(
start,
nom::error::ErrorKind::Tag,
)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn smoke_test_setext_level_1() {
let input = Span::new("Heading 1\n===\n");
let result = setext_heading(input);
assert!(result.is_ok());
let (_, (level, content)) = result.unwrap();
assert_eq!(level, 1);
assert_eq!(*content.fragment(), "Heading 1");
}
#[test]
fn smoke_test_setext_level_2() {
let input = Span::new("Heading 2\n---\n");
let result = setext_heading(input);
assert!(result.is_ok());
let (_, (level, content)) = result.unwrap();
assert_eq!(level, 2);
assert_eq!(*content.fragment(), "Heading 2");
}
#[test]
fn smoke_test_setext_minimal_underline() {
let input = Span::new("Title\n=\n");
let result = setext_heading(input);
assert!(result.is_ok());
let (_, (level, _)) = result.unwrap();
assert_eq!(level, 1);
}
#[test]
fn smoke_test_setext_multiline_text() {
let input = Span::new("First line\nSecond line\n===\n");
let result = setext_heading(input);
assert!(result.is_ok());
let (_, (level, content)) = result.unwrap();
assert_eq!(level, 1);
assert!(content.fragment().contains("First line"));
assert!(content.fragment().contains("Second line"));
}
#[test]
fn smoke_test_setext_leading_spaces() {
let input = Span::new(" Heading\n ===\n");
let result = setext_heading(input);
assert!(result.is_ok());
}
#[test]
fn smoke_test_setext_blank_line_before_underline_fails() {
let input = Span::new("Heading\n\n===\n");
let result = setext_heading(input);
assert!(result.is_err());
}
#[test]
fn smoke_test_setext_four_space_indent_fails() {
let input = Span::new(" Heading\n===\n");
let result = setext_heading(input);
assert!(result.is_err());
}
#[test]
fn smoke_test_setext_empty_first_line_fails() {
let input = Span::new("\n===\n");
let result = setext_heading(input);
assert!(result.is_err());
}
}