use crate::grammar::shared::Span;
use nom::{
bytes::complete::{tag, take_while},
character::complete::line_ending,
combinator::{opt, recognize},
multi::many1_count,
IResult, Input, Parser,
};
pub fn heading(input: Span) -> IResult<Span, (u8, Span)> {
log::debug!("Parsing ATX heading: {:?}", input.fragment());
let start = input;
let (input, leading_spaces) = take_while(|c| c == ' ').parse(input)?;
if leading_spaces.fragment().len() > 3 {
return Err(nom::Err::Error(nom::error::Error::new(
start,
nom::error::ErrorKind::Tag,
)));
}
let (input, hashes) = recognize(many1_count(tag("#"))).parse(input)?;
let level = hashes.fragment().len();
if level > 6 {
return Err(nom::Err::Error(nom::error::Error::new(
start,
nom::error::ErrorKind::Tag,
)));
}
let next_char = input.fragment().chars().next();
let is_valid_separator = match next_char {
None => true, Some('\n') | Some('\r') => true, Some(' ') | Some('\t') => true, Some(_) => false, };
if !is_valid_separator {
return Err(nom::Err::Error(nom::error::Error::new(
start,
nom::error::ErrorKind::Tag,
)));
}
let (input, _) = take_while(|c| c == ' ' || c == '\t').parse(input)?;
let (input, content) = take_while(|c| c != '\n' && c != '\r').parse(input)?;
let content_str = content.fragment();
let trimmed = content_str.trim_end();
let final_content_str = if let Some(hash_pos) = trimmed.rfind(|c: char| c != '#' && c != ' ') {
let char_at_pos = trimmed[hash_pos..].chars().next().unwrap();
let char_len = char_at_pos.len_utf8();
let after_pos = hash_pos + char_len;
let after_content = &trimmed[after_pos..];
if after_content.chars().all(|c| c == ' ' || c == '#') {
&trimmed[..after_pos]
} else {
trimmed
}
} else {
""
};
let final_content_str = final_content_str.trim_end();
let content_len = final_content_str.len();
let content_span = content.take(content_len);
let (remaining, _) = opt(line_ending).parse(input)?;
log::debug!("Parsed heading level {}: {:?}", level, final_content_str);
Ok((remaining, (level as u8, content_span)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn smoke_test_heading_level_1() {
let input = Span::new("# Hello World\n");
let result = heading(input);
assert!(result.is_ok());
let (_, (level, content)) = result.unwrap();
assert_eq!(level, 1);
assert_eq!(*content.fragment(), "Hello World");
}
#[test]
fn smoke_test_heading_level_6() {
let input = Span::new("###### Heading 6\n");
let result = heading(input);
assert!(result.is_ok());
let (_, (level, content)) = result.unwrap();
assert_eq!(level, 6);
assert_eq!(*content.fragment(), "Heading 6");
}
#[test]
fn smoke_test_heading_trailing_hashes() {
let input = Span::new("## Heading ##\n");
let result = heading(input);
assert!(result.is_ok());
let (_, (_, content)) = result.unwrap();
assert_eq!(*content.fragment(), "Heading");
}
#[test]
fn smoke_test_heading_leading_spaces() {
let input = Span::new(" # Heading\n");
let result = heading(input);
assert!(result.is_ok());
let (_, (level, _)) = result.unwrap();
assert_eq!(level, 1);
}
#[test]
fn smoke_test_heading_empty_content() {
let input = Span::new("# \n");
let result = heading(input);
assert!(result.is_ok());
let (_, (level, content)) = result.unwrap();
assert_eq!(level, 1);
assert_eq!(*content.fragment(), "");
}
#[test]
fn smoke_test_heading_seven_hashes_fails() {
let input = Span::new("####### Not a heading\n");
let result = heading(input);
assert!(result.is_err());
}
#[test]
fn smoke_test_heading_no_space_after_hash() {
let input = Span::new("#NoSpace\n");
let result = heading(input);
assert!(result.is_err());
}
#[test]
fn smoke_test_heading_four_space_indent_fails() {
let input = Span::new(" # Not a heading\n");
let result = heading(input);
assert!(result.is_err());
}
}