use crate::utils::regex_cache::get_cached_regex;
use std::fmt;
use std::str::FromStr;
const ATX_PATTERN_STR: &str = r"^(\s*)(#{1,6})(\s*)([^#\n]*?)(?:\s+(#{1,6}))?\s*$";
const SETEXT_HEADING_1_STR: &str = r"^(\s*)(=+)(\s*)$";
const SETEXT_HEADING_2_STR: &str = r"^(\s*)(-+)(\s*)$";
const FENCED_CODE_BLOCK_START_STR: &str = r"^(\s*)(`{3,}|~{3,}).*$";
const FENCED_CODE_BLOCK_END_STR: &str = r"^(\s*)(`{3,}|~{3,})\s*$";
const FRONT_MATTER_DELIMITER_STR: &str = r"^---\s*$";
const HTML_TAG_REGEX_STR: &str = r"<[^>]*>";
const SINGLE_LINE_ASTERISK_EMPHASIS_STR: &str = r"^\s*\*([^*\n]+)\*\s*$";
const SINGLE_LINE_UNDERSCORE_EMPHASIS_STR: &str = r"^\s*_([^_\n]+)_\s*$";
const SINGLE_LINE_DOUBLE_ASTERISK_EMPHASIS_STR: &str = r"^\s*\*\*([^*\n]+)\*\*\s*$";
const SINGLE_LINE_DOUBLE_UNDERSCORE_EMPHASIS_STR: &str = r"^\s*__([^_\n]+)__\s*$";
#[derive(Debug, Clone, PartialEq, Eq, Hash, Copy)]
pub enum HeadingStyle {
Atx, AtxClosed, Setext1, Setext2, Consistent, SetextWithAtx, SetextWithAtxClosed, }
impl fmt::Display for HeadingStyle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
HeadingStyle::Atx => "atx",
HeadingStyle::AtxClosed => "atx-closed",
HeadingStyle::Setext1 => "setext1",
HeadingStyle::Setext2 => "setext2",
HeadingStyle::Consistent => "consistent",
HeadingStyle::SetextWithAtx => "setext-with-atx",
HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed",
};
write!(f, "{s}")
}
}
impl FromStr for HeadingStyle {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
let normalized = s.trim().to_ascii_lowercase().replace('-', "_");
match normalized.as_str() {
"atx" => Ok(HeadingStyle::Atx),
"atx_closed" => Ok(HeadingStyle::AtxClosed),
"setext1" | "setext" => Ok(HeadingStyle::Setext1),
"setext2" => Ok(HeadingStyle::Setext2),
"consistent" => Ok(HeadingStyle::Consistent),
"setext_with_atx" => Ok(HeadingStyle::SetextWithAtx),
"setext_with_atx_closed" => Ok(HeadingStyle::SetextWithAtxClosed),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Heading {
pub text: String,
pub level: u32,
pub style: HeadingStyle,
pub line_number: usize,
pub original_text: String,
pub indentation: String,
}
pub struct HeadingUtils;
impl HeadingUtils {
pub fn is_atx_heading(line: &str) -> bool {
get_cached_regex(ATX_PATTERN_STR)
.map(|re| re.is_match(line))
.unwrap_or(false)
}
pub fn is_in_code_block(content: &str, line_number: usize) -> bool {
let mut in_code_block = false;
let mut fence_char = None;
let mut line_count = 0;
for line in content.lines() {
line_count += 1;
if line_count > line_number {
break;
}
let trimmed = line.trim();
if trimmed.len() >= 3 {
let first_chars: Vec<char> = trimmed.chars().take(3).collect();
if first_chars.iter().all(|&c| c == '`' || c == '~') {
if let Some(current_fence) = fence_char {
if first_chars[0] == current_fence && first_chars.iter().all(|&c| c == current_fence) {
in_code_block = false;
fence_char = None;
}
} else {
in_code_block = true;
fence_char = Some(first_chars[0]);
}
}
}
}
in_code_block
}
pub fn parse_heading(content: &str, line_num: usize) -> Option<Heading> {
let lines: Vec<&str> = content.lines().collect();
if line_num == 0 || line_num > lines.len() {
return None;
}
let line = lines[line_num - 1];
if Self::is_in_code_block(content, line_num) {
return None;
}
if let Some(captures) = get_cached_regex(ATX_PATTERN_STR).ok().and_then(|re| re.captures(line)) {
let indentation = captures.get(1).map_or("", |m| m.as_str()).to_string();
let opening_hashes = captures.get(2).map_or("", |m| m.as_str());
let level = opening_hashes.len() as u32;
let text = captures.get(4).map_or("", |m| m.as_str()).to_string();
let style = if let Some(closing) = captures.get(5) {
let closing_hashes = closing.as_str();
if closing_hashes.len() == opening_hashes.len() {
HeadingStyle::AtxClosed
} else {
HeadingStyle::Atx
}
} else {
HeadingStyle::Atx
};
let heading = Heading {
text: text.clone(),
level,
style,
line_number: line_num,
original_text: line.to_string(),
indentation: indentation.clone(),
};
return Some(heading);
}
if line_num < lines.len() {
let next_line = lines[line_num];
let line_indentation = line.chars().take_while(|c| c.is_whitespace()).collect::<String>();
if line.trim().is_empty() {
return None;
}
if line.trim_start().starts_with('-')
|| line.trim_start().starts_with('*')
|| line.trim_start().starts_with('+')
|| line.trim_start().starts_with("1.")
{
return None;
}
if line.trim() == "---" || Self::is_in_front_matter(content, line_num - 1) {
return None;
}
if let Some(captures) = get_cached_regex(SETEXT_HEADING_1_STR)
.ok()
.and_then(|re| re.captures(next_line))
{
let underline_indent = captures.get(1).map_or("", |m| m.as_str());
if underline_indent == line_indentation {
let heading = Heading {
text: line[line_indentation.len()..].to_string(),
level: 1,
style: HeadingStyle::Setext1,
line_number: line_num,
original_text: format!("{line}\n{next_line}"),
indentation: line_indentation.clone(),
};
return Some(heading);
}
} else if let Some(captures) = get_cached_regex(SETEXT_HEADING_2_STR)
.ok()
.and_then(|re| re.captures(next_line))
{
let underline_indent = captures.get(1).map_or("", |m| m.as_str());
if underline_indent == line_indentation {
let heading = Heading {
text: line[line_indentation.len()..].to_string(),
level: 2,
style: HeadingStyle::Setext2,
line_number: line_num,
original_text: format!("{line}\n{next_line}"),
indentation: line_indentation.clone(),
};
return Some(heading);
}
}
}
None
}
pub fn get_indentation(line: &str) -> usize {
line.len() - line.trim_start().len()
}
pub fn convert_heading_style(text_content: &str, level: u32, style: HeadingStyle) -> String {
let level = level.clamp(1, 6);
if text_content.trim().is_empty() {
return match style {
HeadingStyle::Atx => "#".repeat(level as usize),
HeadingStyle::AtxClosed => {
let hashes = "#".repeat(level as usize);
format!("{hashes} {hashes}")
}
HeadingStyle::Setext1 | HeadingStyle::Setext2 => String::new(),
HeadingStyle::Consistent | HeadingStyle::SetextWithAtx | HeadingStyle::SetextWithAtxClosed => {
"#".repeat(level as usize)
}
};
}
let indentation = text_content
.chars()
.take_while(|c| c.is_whitespace())
.collect::<String>();
let text_content = text_content.trim();
match style {
HeadingStyle::Atx => {
format!("{}{} {}", indentation, "#".repeat(level as usize), text_content)
}
HeadingStyle::AtxClosed => {
format!(
"{}{} {} {}",
indentation,
"#".repeat(level as usize),
text_content,
"#".repeat(level as usize)
)
}
HeadingStyle::Setext1 | HeadingStyle::Setext2 => {
if level > 2 {
format!("{}{} {}", indentation, "#".repeat(level as usize), text_content)
} else {
let underline_char = if level == 1 || style == HeadingStyle::Setext1 {
'='
} else {
'-'
};
let visible_length = text_content.chars().count();
let underline_length = visible_length.max(1); format!(
"{}{}\n{}{}",
indentation,
text_content,
indentation,
underline_char.to_string().repeat(underline_length)
)
}
}
HeadingStyle::Consistent => {
format!("{}{} {}", indentation, "#".repeat(level as usize), text_content)
}
HeadingStyle::SetextWithAtx => {
if level <= 2 {
let underline_char = if level == 1 { '=' } else { '-' };
let visible_length = text_content.chars().count();
let underline_length = visible_length.max(1);
format!(
"{}{}\n{}{}",
indentation,
text_content,
indentation,
underline_char.to_string().repeat(underline_length)
)
} else {
format!("{}{} {}", indentation, "#".repeat(level as usize), text_content)
}
}
HeadingStyle::SetextWithAtxClosed => {
if level <= 2 {
let underline_char = if level == 1 { '=' } else { '-' };
let visible_length = text_content.chars().count();
let underline_length = visible_length.max(1);
format!(
"{}{}\n{}{}",
indentation,
text_content,
indentation,
underline_char.to_string().repeat(underline_length)
)
} else {
format!(
"{}{} {} {}",
indentation,
"#".repeat(level as usize),
text_content,
"#".repeat(level as usize)
)
}
}
}
}
pub fn get_heading_text(line: &str) -> Option<String> {
get_cached_regex(ATX_PATTERN_STR)
.ok()
.and_then(|re| re.captures(line))
.map(|captures| captures.get(4).map_or("", |m| m.as_str()).trim().to_string())
}
pub fn is_emphasis_only_line(line: &str) -> bool {
let trimmed = line.trim();
get_cached_regex(SINGLE_LINE_ASTERISK_EMPHASIS_STR)
.map(|re| re.is_match(trimmed))
.unwrap_or(false)
|| get_cached_regex(SINGLE_LINE_UNDERSCORE_EMPHASIS_STR)
.map(|re| re.is_match(trimmed))
.unwrap_or(false)
|| get_cached_regex(SINGLE_LINE_DOUBLE_ASTERISK_EMPHASIS_STR)
.map(|re| re.is_match(trimmed))
.unwrap_or(false)
|| get_cached_regex(SINGLE_LINE_DOUBLE_UNDERSCORE_EMPHASIS_STR)
.map(|re| re.is_match(trimmed))
.unwrap_or(false)
}
pub fn extract_emphasis_text(line: &str) -> Option<(String, u32)> {
let trimmed = line.trim();
if let Some(caps) = get_cached_regex(SINGLE_LINE_ASTERISK_EMPHASIS_STR)
.ok()
.and_then(|re| re.captures(trimmed))
{
return Some((caps.get(1).unwrap().as_str().trim().to_string(), 1));
}
if let Some(caps) = get_cached_regex(SINGLE_LINE_UNDERSCORE_EMPHASIS_STR)
.ok()
.and_then(|re| re.captures(trimmed))
{
return Some((caps.get(1).unwrap().as_str().trim().to_string(), 1));
}
if let Some(caps) = get_cached_regex(SINGLE_LINE_DOUBLE_ASTERISK_EMPHASIS_STR)
.ok()
.and_then(|re| re.captures(trimmed))
{
return Some((caps.get(1).unwrap().as_str().trim().to_string(), 2));
}
if let Some(caps) = get_cached_regex(SINGLE_LINE_DOUBLE_UNDERSCORE_EMPHASIS_STR)
.ok()
.and_then(|re| re.captures(trimmed))
{
return Some((caps.get(1).unwrap().as_str().trim().to_string(), 2));
}
None
}
pub fn convert_emphasis_to_heading(line: &str) -> Option<String> {
let indentation = line.chars().take_while(|c| c.is_whitespace()).collect::<String>();
let trailing = if line.ends_with(" ") {
line.chars().rev().take_while(|c| c.is_whitespace()).collect::<String>()
} else {
String::new()
};
if let Some((text, level)) = Self::extract_emphasis_text(line) {
Some(format!(
"{}{} {}{}",
indentation,
"#".repeat(level as usize),
text,
trailing
))
} else {
None
}
}
pub fn heading_to_fragment(text: &str) -> String {
let text_no_html = get_cached_regex(HTML_TAG_REGEX_STR)
.map(|re| re.replace_all(text, ""))
.unwrap_or_else(|_| text.into());
let text_lower = text_no_html.trim().to_lowercase();
let text_with_hyphens = text_lower
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>();
let text_clean = text_with_hyphens
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-");
text_clean.trim_matches('-').to_string()
}
pub fn is_in_front_matter(content: &str, line_number: usize) -> bool {
let lines: Vec<&str> = content.lines().collect();
if lines.is_empty() || line_number >= lines.len() {
return false;
}
if !lines[0].trim_start().eq("---") {
return false;
}
let mut in_front_matter = true;
let mut found_closing = false;
for (i, line) in lines.iter().enumerate().skip(1) {
if i > line_number {
break;
}
if line.trim_start().eq("---") {
found_closing = true;
in_front_matter = i > line_number;
break;
}
}
in_front_matter && !found_closing
}
}
#[inline]
pub fn is_heading(line: &str) -> bool {
let trimmed = line.trim();
if trimmed.is_empty() {
return false;
}
if trimmed.starts_with('#') {
get_cached_regex(ATX_PATTERN_STR)
.map(|re| re.is_match(line))
.unwrap_or(false)
} else {
false
}
}
#[inline]
pub fn is_setext_heading_marker(line: &str) -> bool {
get_cached_regex(SETEXT_HEADING_1_STR)
.map(|re| re.is_match(line))
.unwrap_or(false)
|| get_cached_regex(SETEXT_HEADING_2_STR)
.map(|re| re.is_match(line))
.unwrap_or(false)
}
#[inline]
pub fn is_setext_heading(lines: &[&str], index: usize) -> bool {
if index >= lines.len() - 1 {
return false;
}
let current_line = lines[index];
let next_line = lines[index + 1];
if current_line.trim().is_empty() {
return false;
}
let current_indentation = current_line
.chars()
.take_while(|c| c.is_whitespace())
.collect::<String>();
if let Some(captures) = get_cached_regex(SETEXT_HEADING_1_STR)
.ok()
.and_then(|re| re.captures(next_line))
{
let underline_indent = captures.get(1).map_or("", |m| m.as_str());
return underline_indent == current_indentation;
}
if let Some(captures) = get_cached_regex(SETEXT_HEADING_2_STR)
.ok()
.and_then(|re| re.captures(next_line))
{
let underline_indent = captures.get(1).map_or("", |m| m.as_str());
return underline_indent == current_indentation;
}
false
}
#[inline]
pub fn get_heading_level(lines: &[&str], index: usize) -> u32 {
if index >= lines.len() {
return 0;
}
let line = lines[index];
if let Some(captures) = get_cached_regex(ATX_PATTERN_STR).ok().and_then(|re| re.captures(line)) {
let hashes = captures.get(2).map_or("", |m| m.as_str());
return hashes.len() as u32;
}
if index < lines.len() - 1 {
let next_line = lines[index + 1];
if get_cached_regex(SETEXT_HEADING_1_STR)
.map(|re| re.is_match(next_line))
.unwrap_or(false)
{
return 1;
}
if get_cached_regex(SETEXT_HEADING_2_STR)
.map(|re| re.is_match(next_line))
.unwrap_or(false)
{
return 2;
}
}
0
}
#[inline]
pub fn extract_heading_text(lines: &[&str], index: usize) -> String {
if index >= lines.len() {
return String::new();
}
let line = lines[index];
if let Some(captures) = get_cached_regex(ATX_PATTERN_STR).ok().and_then(|re| re.captures(line)) {
return captures.get(4).map_or("", |m| m.as_str()).trim().to_string();
}
if index < lines.len() - 1 {
let next_line = lines[index + 1];
let line_indentation = line.chars().take_while(|c| c.is_whitespace()).collect::<String>();
if let Some(captures) = get_cached_regex(SETEXT_HEADING_1_STR)
.ok()
.and_then(|re| re.captures(next_line))
{
let underline_indent = captures.get(1).map_or("", |m| m.as_str());
if underline_indent == line_indentation {
return line[line_indentation.len()..].trim().to_string();
}
}
if let Some(captures) = get_cached_regex(SETEXT_HEADING_2_STR)
.ok()
.and_then(|re| re.captures(next_line))
{
let underline_indent = captures.get(1).map_or("", |m| m.as_str());
if underline_indent == line_indentation {
return line[line_indentation.len()..].trim().to_string();
}
}
}
line.trim().to_string()
}
#[inline]
pub fn get_heading_indentation(lines: &[&str], index: usize) -> usize {
if index >= lines.len() {
return 0;
}
let line = lines[index];
line.len() - line.trim_start().len()
}
#[inline]
pub fn is_code_block_delimiter(line: &str) -> bool {
get_cached_regex(FENCED_CODE_BLOCK_START_STR)
.map(|re| re.is_match(line))
.unwrap_or(false)
|| get_cached_regex(FENCED_CODE_BLOCK_END_STR)
.map(|re| re.is_match(line))
.unwrap_or(false)
}
#[inline]
pub fn is_front_matter_delimiter(line: &str) -> bool {
get_cached_regex(FRONT_MATTER_DELIMITER_STR)
.map(|re| re.is_match(line))
.unwrap_or(false)
}
#[inline]
pub fn remove_trailing_hashes(text: &str) -> String {
let trimmed = text.trim_end();
if let Some(last_hash_index) = trimmed.rfind('#') {
if trimmed[last_hash_index..]
.chars()
.all(|c| c == '#' || c.is_whitespace())
{
let mut first_hash_index = last_hash_index;
let trimmed_chars: Vec<char> = trimmed.chars().collect();
while first_hash_index > 0 {
let prev_index = first_hash_index - 1;
if prev_index < trimmed_chars.len() && trimmed_chars[prev_index] == '#' {
first_hash_index = prev_index;
} else {
break;
}
}
return trimmed[..first_hash_index].trim_end().to_string();
}
}
trimmed.to_string()
}
#[inline]
pub fn normalize_heading(line: &str, level: u32) -> String {
let indentation = line.chars().take_while(|c| c.is_whitespace()).collect::<String>();
let trimmed = line.trim_start();
if trimmed.starts_with('#') {
if let Some(text) = HeadingUtils::get_heading_text(line) {
format!("{}{} {}", indentation, "#".repeat(level as usize), text)
} else {
line.to_string()
}
} else {
format!("{}{} {}", indentation, "#".repeat(level as usize), trimmed)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_atx_heading_parsing() {
let content = "# Heading 1\n## Heading 2\n### Heading 3";
assert!(HeadingUtils::parse_heading(content, 1).is_some());
assert_eq!(HeadingUtils::parse_heading(content, 1).unwrap().level, 1);
assert_eq!(HeadingUtils::parse_heading(content, 2).unwrap().level, 2);
assert_eq!(HeadingUtils::parse_heading(content, 3).unwrap().level, 3);
}
#[test]
fn test_setext_heading_parsing() {
let content = "Heading 1\n=========\nHeading 2\n---------";
assert!(HeadingUtils::parse_heading(content, 1).is_some());
assert_eq!(HeadingUtils::parse_heading(content, 1).unwrap().level, 1);
assert_eq!(HeadingUtils::parse_heading(content, 3).unwrap().level, 2);
}
#[test]
fn test_heading_style_conversion() {
assert_eq!(
HeadingUtils::convert_heading_style("Heading 1", 1, HeadingStyle::Atx),
"# Heading 1"
);
assert_eq!(
HeadingUtils::convert_heading_style("Heading 2", 2, HeadingStyle::AtxClosed),
"## Heading 2 ##"
);
assert_eq!(
HeadingUtils::convert_heading_style("Heading 1", 1, HeadingStyle::Setext1),
"Heading 1\n========="
);
assert_eq!(
HeadingUtils::convert_heading_style("Heading 2", 2, HeadingStyle::Setext2),
"Heading 2\n---------"
);
}
#[test]
fn test_code_block_detection() {
let content = "# Heading\n```\n# Not a heading\n```\n# Another heading";
assert!(!HeadingUtils::is_in_code_block(content, 0));
assert!(HeadingUtils::is_in_code_block(content, 2));
assert!(!HeadingUtils::is_in_code_block(content, 4));
}
#[test]
fn test_empty_line_with_dashes() {
let content = "\n---";
assert_eq!(
HeadingUtils::parse_heading(content, 1),
None,
"Empty line followed by dashes should not be detected as a heading"
);
let content2 = "Some content\n\n---\nMore content";
assert_eq!(
HeadingUtils::parse_heading(content2, 2),
None,
"Empty line followed by horizontal rule should not be detected as a heading"
);
}
#[test]
fn test_is_atx_heading() {
assert!(HeadingUtils::is_atx_heading("# Heading"));
assert!(HeadingUtils::is_atx_heading("## Heading"));
assert!(HeadingUtils::is_atx_heading("### Heading"));
assert!(HeadingUtils::is_atx_heading("#### Heading"));
assert!(HeadingUtils::is_atx_heading("##### Heading"));
assert!(HeadingUtils::is_atx_heading("###### Heading"));
assert!(HeadingUtils::is_atx_heading(" # Indented"));
assert!(HeadingUtils::is_atx_heading("# Heading #"));
assert!(HeadingUtils::is_atx_heading("## Heading ###"));
assert!(!HeadingUtils::is_atx_heading("####### Too many"));
assert!(!HeadingUtils::is_atx_heading("Not a heading"));
assert!(HeadingUtils::is_atx_heading("#")); assert!(!HeadingUtils::is_atx_heading(""));
}
#[test]
fn test_heading_edge_cases() {
let content = "# Heading";
assert!(HeadingUtils::parse_heading(content, 0).is_none());
assert!(HeadingUtils::parse_heading(content, 10).is_none());
let content = "```\n# Not a heading\n```";
assert!(HeadingUtils::parse_heading(content, 2).is_none());
let content = "~~~\n# Not a heading\n~~~";
assert!(HeadingUtils::is_in_code_block(content, 2));
let content = "```\n# Content\n~~~"; assert!(HeadingUtils::is_in_code_block(content, 2));
}
#[test]
fn test_atx_closed_heading_variations() {
let content = "# Heading #\n## Heading ##\n### Heading ####\n#### Heading ##";
let h1 = HeadingUtils::parse_heading(content, 1).unwrap();
assert_eq!(h1.style, HeadingStyle::AtxClosed);
assert_eq!(h1.text, "Heading");
let h2 = HeadingUtils::parse_heading(content, 2).unwrap();
assert_eq!(h2.style, HeadingStyle::AtxClosed);
let h3 = HeadingUtils::parse_heading(content, 3).unwrap();
assert_eq!(h3.style, HeadingStyle::Atx);
let h4 = HeadingUtils::parse_heading(content, 4).unwrap();
assert_eq!(h4.style, HeadingStyle::Atx);
}
#[test]
fn test_setext_heading_edge_cases() {
let content = "- List item\n---------";
assert!(HeadingUtils::parse_heading(content, 1).is_none());
let content = "---\ntitle: test\n---";
assert!(HeadingUtils::parse_heading(content, 1).is_none());
let content = " Indented\n ========";
let heading = HeadingUtils::parse_heading(content, 1).unwrap();
assert_eq!(heading.indentation, " ");
assert_eq!(heading.text, "Indented");
let content = " Text\n========"; assert!(HeadingUtils::parse_heading(content, 1).is_none());
}
#[test]
fn test_get_indentation() {
assert_eq!(HeadingUtils::get_indentation("# Heading"), 0);
assert_eq!(HeadingUtils::get_indentation(" # Heading"), 2);
assert_eq!(HeadingUtils::get_indentation(" # Heading"), 4);
assert_eq!(HeadingUtils::get_indentation("\t# Heading"), 1);
assert_eq!(HeadingUtils::get_indentation(""), 0);
}
#[test]
fn test_convert_heading_style_edge_cases() {
assert_eq!(HeadingUtils::convert_heading_style("", 1, HeadingStyle::Atx), "#");
assert_eq!(HeadingUtils::convert_heading_style(" ", 1, HeadingStyle::Atx), "#");
assert_eq!(HeadingUtils::convert_heading_style("", 2, HeadingStyle::Atx), "##");
assert_eq!(
HeadingUtils::convert_heading_style("", 1, HeadingStyle::AtxClosed),
"# #"
);
assert_eq!(HeadingUtils::convert_heading_style("", 1, HeadingStyle::Setext1), "");
assert_eq!(
HeadingUtils::convert_heading_style("Text", 0, HeadingStyle::Atx),
"# Text"
);
assert_eq!(
HeadingUtils::convert_heading_style("Text", 10, HeadingStyle::Atx),
"###### Text"
);
assert_eq!(
HeadingUtils::convert_heading_style("Text", 3, HeadingStyle::Setext1),
"### Text"
);
assert_eq!(
HeadingUtils::convert_heading_style(" Text", 1, HeadingStyle::Atx),
" # Text"
);
assert_eq!(
HeadingUtils::convert_heading_style("Hi", 1, HeadingStyle::Setext1),
"Hi\n=="
);
}
#[test]
fn test_get_heading_text() {
assert_eq!(HeadingUtils::get_heading_text("# Heading"), Some("Heading".to_string()));
assert_eq!(
HeadingUtils::get_heading_text("## Heading ##"),
Some("Heading".to_string())
);
assert_eq!(
HeadingUtils::get_heading_text("### Spaces "),
Some("Spaces".to_string())
);
assert_eq!(HeadingUtils::get_heading_text("Not a heading"), None);
assert_eq!(HeadingUtils::get_heading_text(""), None);
}
#[test]
fn test_emphasis_detection() {
assert!(HeadingUtils::is_emphasis_only_line("*emphasis*"));
assert!(HeadingUtils::is_emphasis_only_line("_emphasis_"));
assert!(HeadingUtils::is_emphasis_only_line("**strong**"));
assert!(HeadingUtils::is_emphasis_only_line("__strong__"));
assert!(HeadingUtils::is_emphasis_only_line(" *emphasis* "));
assert!(!HeadingUtils::is_emphasis_only_line("*not* emphasis"));
assert!(!HeadingUtils::is_emphasis_only_line("text *emphasis*"));
assert!(!HeadingUtils::is_emphasis_only_line("**"));
assert!(!HeadingUtils::is_emphasis_only_line(""));
}
#[test]
fn test_extract_emphasis_text() {
assert_eq!(
HeadingUtils::extract_emphasis_text("*text*"),
Some(("text".to_string(), 1))
);
assert_eq!(
HeadingUtils::extract_emphasis_text("_text_"),
Some(("text".to_string(), 1))
);
assert_eq!(
HeadingUtils::extract_emphasis_text("**text**"),
Some(("text".to_string(), 2))
);
assert_eq!(
HeadingUtils::extract_emphasis_text("__text__"),
Some(("text".to_string(), 2))
);
assert_eq!(
HeadingUtils::extract_emphasis_text(" *spaced* "),
Some(("spaced".to_string(), 1))
);
assert_eq!(HeadingUtils::extract_emphasis_text("not emphasis"), None);
assert_eq!(HeadingUtils::extract_emphasis_text("*not* complete"), None);
}
#[test]
fn test_convert_emphasis_to_heading() {
assert_eq!(
HeadingUtils::convert_emphasis_to_heading("*text*"),
Some("# text".to_string())
);
assert_eq!(
HeadingUtils::convert_emphasis_to_heading("**text**"),
Some("## text".to_string())
);
assert_eq!(
HeadingUtils::convert_emphasis_to_heading(" *text*"),
Some(" # text".to_string())
);
assert_eq!(
HeadingUtils::convert_emphasis_to_heading("*text* "),
Some("# text ".to_string())
);
assert_eq!(HeadingUtils::convert_emphasis_to_heading("not emphasis"), None);
}
#[test]
fn test_heading_to_fragment() {
assert_eq!(HeadingUtils::heading_to_fragment("Simple Heading"), "simple-heading");
assert_eq!(
HeadingUtils::heading_to_fragment("Heading with Numbers 123"),
"heading-with-numbers-123"
);
assert_eq!(
HeadingUtils::heading_to_fragment("Special!@#$%Characters"),
"special-characters"
);
assert_eq!(HeadingUtils::heading_to_fragment(" Trimmed "), "trimmed");
assert_eq!(
HeadingUtils::heading_to_fragment("Multiple Spaces"),
"multiple-spaces"
);
assert_eq!(
HeadingUtils::heading_to_fragment("Heading <em>with HTML</em>"),
"heading-with-html"
);
assert_eq!(
HeadingUtils::heading_to_fragment("---Leading-Dashes---"),
"leading-dashes"
);
assert_eq!(HeadingUtils::heading_to_fragment(""), "");
}
#[test]
fn test_is_in_front_matter() {
let content = "---\ntitle: Test\n---\n# Content";
assert!(HeadingUtils::is_in_front_matter(content, 1));
assert!(!HeadingUtils::is_in_front_matter(content, 2)); assert!(!HeadingUtils::is_in_front_matter(content, 3));
assert!(!HeadingUtils::is_in_front_matter(content, 4));
let content = "# Just content";
assert!(!HeadingUtils::is_in_front_matter(content, 0));
let content = "---\ntitle: Test\n# No closing";
assert!(HeadingUtils::is_in_front_matter(content, 1));
assert!(HeadingUtils::is_in_front_matter(content, 2));
let content = "# Heading\n---\ntitle: Test\n---";
assert!(!HeadingUtils::is_in_front_matter(content, 2));
}
#[test]
fn test_module_level_functions() {
assert!(is_heading("# Heading"));
assert!(is_heading(" ## Indented"));
assert!(!is_heading("Not a heading"));
assert!(!is_heading(""));
assert!(is_setext_heading_marker("========"));
assert!(is_setext_heading_marker("--------"));
assert!(is_setext_heading_marker(" ======"));
assert!(!is_setext_heading_marker("# Heading"));
assert!(is_setext_heading_marker("---"));
let lines = vec!["Title", "====="];
assert!(is_setext_heading(&lines, 0));
let lines = vec!["", "====="];
assert!(!is_setext_heading(&lines, 0));
let lines = vec!["# H1", "## H2", "### H3"];
assert_eq!(get_heading_level(&lines, 0), 1);
assert_eq!(get_heading_level(&lines, 1), 2);
assert_eq!(get_heading_level(&lines, 2), 3);
assert_eq!(get_heading_level(&lines, 10), 0);
let lines = vec!["# Heading Text", "## Another ###"];
assert_eq!(extract_heading_text(&lines, 0), "Heading Text");
assert_eq!(extract_heading_text(&lines, 1), "Another");
let lines = vec!["# No indent", " ## Two spaces", " ### Four spaces"];
assert_eq!(get_heading_indentation(&lines, 0), 0);
assert_eq!(get_heading_indentation(&lines, 1), 2);
assert_eq!(get_heading_indentation(&lines, 2), 4);
}
#[test]
fn test_is_code_block_delimiter() {
assert!(is_code_block_delimiter("```"));
assert!(is_code_block_delimiter("~~~"));
assert!(is_code_block_delimiter("````"));
assert!(is_code_block_delimiter("```rust"));
assert!(is_code_block_delimiter(" ```"));
assert!(!is_code_block_delimiter("``")); assert!(!is_code_block_delimiter("# Heading"));
}
#[test]
fn test_is_front_matter_delimiter() {
assert!(is_front_matter_delimiter("---"));
assert!(is_front_matter_delimiter("--- "));
assert!(!is_front_matter_delimiter("----"));
assert!(!is_front_matter_delimiter("--"));
assert!(!is_front_matter_delimiter("# ---"));
}
#[test]
fn test_remove_trailing_hashes() {
assert_eq!(remove_trailing_hashes("Heading ###"), "Heading");
assert_eq!(remove_trailing_hashes("Heading ## "), "Heading");
assert_eq!(remove_trailing_hashes("Heading #not trailing"), "Heading #not trailing");
assert_eq!(remove_trailing_hashes("No hashes"), "No hashes");
assert_eq!(remove_trailing_hashes(""), "");
assert_eq!(remove_trailing_hashes("Heading ##"), "Heading");
assert_eq!(remove_trailing_hashes("Heading #"), "Heading");
assert_eq!(remove_trailing_hashes("Heading ####"), "Heading");
assert_eq!(remove_trailing_hashes("#"), "");
assert_eq!(remove_trailing_hashes("##"), "");
assert_eq!(remove_trailing_hashes("###"), "");
assert_eq!(remove_trailing_hashes("Text#"), "Text");
assert_eq!(remove_trailing_hashes("Text ##"), "Text");
}
#[test]
fn test_normalize_heading() {
assert_eq!(normalize_heading("# Old Level", 3), "### Old Level");
assert_eq!(normalize_heading("## Heading ##", 1), "# Heading");
assert_eq!(normalize_heading(" # Indented", 2), " ## Indented");
assert_eq!(normalize_heading("Plain text", 1), "# Plain text");
}
#[test]
fn test_heading_style_from_str() {
assert_eq!(HeadingStyle::from_str("atx"), Ok(HeadingStyle::Atx));
assert_eq!(HeadingStyle::from_str("ATX"), Ok(HeadingStyle::Atx));
assert_eq!(HeadingStyle::from_str("atx_closed"), Ok(HeadingStyle::AtxClosed));
assert_eq!(HeadingStyle::from_str("atx-closed"), Ok(HeadingStyle::AtxClosed));
assert_eq!(HeadingStyle::from_str("ATX-CLOSED"), Ok(HeadingStyle::AtxClosed));
assert_eq!(HeadingStyle::from_str("setext1"), Ok(HeadingStyle::Setext1));
assert_eq!(HeadingStyle::from_str("setext"), Ok(HeadingStyle::Setext1));
assert_eq!(HeadingStyle::from_str("setext2"), Ok(HeadingStyle::Setext2));
assert_eq!(HeadingStyle::from_str("consistent"), Ok(HeadingStyle::Consistent));
assert_eq!(
HeadingStyle::from_str("setext_with_atx"),
Ok(HeadingStyle::SetextWithAtx)
);
assert_eq!(
HeadingStyle::from_str("setext-with-atx"),
Ok(HeadingStyle::SetextWithAtx)
);
assert_eq!(
HeadingStyle::from_str("setext_with_atx_closed"),
Ok(HeadingStyle::SetextWithAtxClosed)
);
assert_eq!(
HeadingStyle::from_str("setext-with-atx-closed"),
Ok(HeadingStyle::SetextWithAtxClosed)
);
assert_eq!(HeadingStyle::from_str("invalid"), Err(()));
}
#[test]
fn test_heading_style_display() {
assert_eq!(HeadingStyle::Atx.to_string(), "atx");
assert_eq!(HeadingStyle::AtxClosed.to_string(), "atx-closed");
assert_eq!(HeadingStyle::Setext1.to_string(), "setext1");
assert_eq!(HeadingStyle::Setext2.to_string(), "setext2");
assert_eq!(HeadingStyle::Consistent.to_string(), "consistent");
}
#[test]
fn test_unicode_headings() {
let content = "# 你好世界\n## Ñoño\n### 🚀 Emoji";
assert_eq!(HeadingUtils::parse_heading(content, 1).unwrap().text, "你好世界");
assert_eq!(HeadingUtils::parse_heading(content, 2).unwrap().text, "Ñoño");
assert_eq!(HeadingUtils::parse_heading(content, 3).unwrap().text, "🚀 Emoji");
assert_eq!(HeadingUtils::heading_to_fragment("你好世界"), "你好世界");
assert_eq!(HeadingUtils::heading_to_fragment("Café René"), "café-rené");
}
#[test]
fn test_complex_nested_structures() {
let content = "---\ncode: |\n ```\n # Not a heading\n ```\n---\n# Real heading";
assert!(HeadingUtils::is_in_code_block(content, 4)); assert!(HeadingUtils::parse_heading(content, 7).is_some());
let content = "```\ncode\n```\n# Heading\n~~~\nmore code\n~~~";
assert!(!HeadingUtils::is_in_code_block(content, 4));
assert!(HeadingUtils::parse_heading(content, 4).is_some());
}
}