1use regex::Regex;
2use std::fmt;
3use std::str::FromStr;
4use std::sync::LazyLock;
5
6static ATX_PATTERN: LazyLock<Regex> =
7 LazyLock::new(|| Regex::new(r"^(\s*)(#{1,6})(\s*)([^#\n]*?)(?:\s+(#{1,6}))?\s*$").unwrap());
8static SETEXT_HEADING_1: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\s*)(=+)(\s*)$").unwrap());
9static SETEXT_HEADING_2: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\s*)(-+)(\s*)$").unwrap());
10static HTML_TAG_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<[^>]*>").unwrap());
11
12#[derive(Debug, Clone, PartialEq, Eq, Hash, Copy)]
14pub enum HeadingStyle {
15 Atx, AtxClosed, Setext1, Setext2, Consistent, SetextWithAtx, SetextWithAtxClosed, }
25
26impl fmt::Display for HeadingStyle {
27 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28 let s = match self {
29 HeadingStyle::Atx => "atx",
30 HeadingStyle::AtxClosed => "atx-closed",
31 HeadingStyle::Setext1 => "setext1",
32 HeadingStyle::Setext2 => "setext2",
33 HeadingStyle::Consistent => "consistent",
34 HeadingStyle::SetextWithAtx => "setext-with-atx",
35 HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed",
36 };
37 write!(f, "{s}")
38 }
39}
40
41impl FromStr for HeadingStyle {
42 type Err = ();
43 fn from_str(s: &str) -> Result<Self, Self::Err> {
44 let normalized = s.trim().to_ascii_lowercase().replace('-', "_");
45 match normalized.as_str() {
46 "atx" => Ok(HeadingStyle::Atx),
47 "atx_closed" => Ok(HeadingStyle::AtxClosed),
48 "setext1" | "setext" => Ok(HeadingStyle::Setext1),
49 "setext2" => Ok(HeadingStyle::Setext2),
50 "consistent" => Ok(HeadingStyle::Consistent),
51 "setext_with_atx" => Ok(HeadingStyle::SetextWithAtx),
52 "setext_with_atx_closed" => Ok(HeadingStyle::SetextWithAtxClosed),
53 _ => Err(()),
54 }
55 }
56}
57
58pub struct HeadingUtils;
60
61impl HeadingUtils {
62 pub fn convert_heading_style(text_content: &str, level: u32, style: HeadingStyle) -> String {
64 let level = level.clamp(1, 6);
66
67 if text_content.trim().is_empty() {
68 return match style {
70 HeadingStyle::Atx => "#".repeat(level as usize),
71 HeadingStyle::AtxClosed => {
72 let hashes = "#".repeat(level as usize);
73 format!("{hashes} {hashes}")
74 }
75 HeadingStyle::Setext1 | HeadingStyle::Setext2 => String::new(),
76 HeadingStyle::Consistent | HeadingStyle::SetextWithAtx | HeadingStyle::SetextWithAtxClosed => {
78 "#".repeat(level as usize)
79 }
80 };
81 }
82
83 let indentation = text_content
84 .chars()
85 .take_while(|c| c.is_whitespace())
86 .collect::<String>();
87 let text_content = text_content.trim();
88
89 match style {
90 HeadingStyle::Atx => {
91 format!("{}{} {}", indentation, "#".repeat(level as usize), text_content)
92 }
93 HeadingStyle::AtxClosed => {
94 format!(
95 "{}{} {} {}",
96 indentation,
97 "#".repeat(level as usize),
98 text_content,
99 "#".repeat(level as usize)
100 )
101 }
102 HeadingStyle::Setext1 | HeadingStyle::Setext2 => {
103 if level > 2 {
104 format!("{}{} {}", indentation, "#".repeat(level as usize), text_content)
106 } else {
107 let underline_char = if level == 1 || style == HeadingStyle::Setext1 {
108 '='
109 } else {
110 '-'
111 };
112 let visible_length = text_content.chars().count();
113 let underline_length = visible_length.max(1); format!(
115 "{}{}\n{}{}",
116 indentation,
117 text_content,
118 indentation,
119 underline_char.to_string().repeat(underline_length)
120 )
121 }
122 }
123 HeadingStyle::Consistent => {
124 format!("{}{} {}", indentation, "#".repeat(level as usize), text_content)
126 }
127 HeadingStyle::SetextWithAtx => {
128 if level <= 2 {
129 let underline_char = if level == 1 { '=' } else { '-' };
131 let visible_length = text_content.chars().count();
132 let underline_length = visible_length.max(1);
133 format!(
134 "{}{}\n{}{}",
135 indentation,
136 text_content,
137 indentation,
138 underline_char.to_string().repeat(underline_length)
139 )
140 } else {
141 format!("{}{} {}", indentation, "#".repeat(level as usize), text_content)
143 }
144 }
145 HeadingStyle::SetextWithAtxClosed => {
146 if level <= 2 {
147 let underline_char = if level == 1 { '=' } else { '-' };
149 let visible_length = text_content.chars().count();
150 let underline_length = visible_length.max(1);
151 format!(
152 "{}{}\n{}{}",
153 indentation,
154 text_content,
155 indentation,
156 underline_char.to_string().repeat(underline_length)
157 )
158 } else {
159 format!(
161 "{}{} {} {}",
162 indentation,
163 "#".repeat(level as usize),
164 text_content,
165 "#".repeat(level as usize)
166 )
167 }
168 }
169 }
170 }
171
172 pub fn heading_to_fragment(text: &str) -> String {
174 let text_no_html = HTML_TAG_REGEX.replace_all(text, "");
176
177 let text_lower = text_no_html.trim().to_lowercase();
179
180 let text_with_hyphens = text_lower
182 .chars()
183 .map(|c| if c.is_alphanumeric() { c } else { '-' })
184 .collect::<String>();
185
186 let text_clean = text_with_hyphens
188 .split('-')
189 .filter(|s| !s.is_empty())
190 .collect::<Vec<_>>()
191 .join("-");
192
193 text_clean.trim_matches('-').to_string()
195 }
196}
197
198#[inline]
200pub fn is_heading(line: &str) -> bool {
201 let trimmed = line.trim();
203 if trimmed.is_empty() {
204 return false;
205 }
206
207 if trimmed.starts_with('#') {
208 ATX_PATTERN.is_match(line)
210 } else {
211 false
213 }
214}
215
216#[inline]
218pub fn is_setext_heading_marker(line: &str) -> bool {
219 SETEXT_HEADING_1.is_match(line) || SETEXT_HEADING_2.is_match(line)
220}
221
222#[inline]
224pub fn get_heading_level(lines: &[&str], index: usize) -> u32 {
225 if index >= lines.len() {
226 return 0;
227 }
228
229 let line = lines[index];
230
231 if let Some(captures) = ATX_PATTERN.captures(line) {
233 let hashes = captures.get(2).map_or("", |m| m.as_str());
234 return hashes.len() as u32;
235 }
236
237 if index < lines.len() - 1 {
239 let next_line = lines[index + 1];
240
241 if SETEXT_HEADING_1.is_match(next_line) {
242 return 1;
243 }
244
245 if SETEXT_HEADING_2.is_match(next_line) {
246 return 2;
247 }
248 }
249
250 0
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[test]
258 fn test_heading_style_conversion() {
259 assert_eq!(
260 HeadingUtils::convert_heading_style("Heading 1", 1, HeadingStyle::Atx),
261 "# Heading 1"
262 );
263 assert_eq!(
264 HeadingUtils::convert_heading_style("Heading 2", 2, HeadingStyle::AtxClosed),
265 "## Heading 2 ##"
266 );
267 assert_eq!(
268 HeadingUtils::convert_heading_style("Heading 1", 1, HeadingStyle::Setext1),
269 "Heading 1\n========="
270 );
271 assert_eq!(
272 HeadingUtils::convert_heading_style("Heading 2", 2, HeadingStyle::Setext2),
273 "Heading 2\n---------"
274 );
275 }
276
277 #[test]
278 fn test_convert_heading_style_edge_cases() {
279 assert_eq!(HeadingUtils::convert_heading_style("", 1, HeadingStyle::Atx), "#");
281 assert_eq!(HeadingUtils::convert_heading_style(" ", 1, HeadingStyle::Atx), "#");
282 assert_eq!(HeadingUtils::convert_heading_style("", 2, HeadingStyle::Atx), "##");
283 assert_eq!(
284 HeadingUtils::convert_heading_style("", 1, HeadingStyle::AtxClosed),
285 "# #"
286 );
287 assert_eq!(HeadingUtils::convert_heading_style("", 1, HeadingStyle::Setext1), "");
289
290 assert_eq!(
292 HeadingUtils::convert_heading_style("Text", 0, HeadingStyle::Atx),
293 "# Text"
294 );
295 assert_eq!(
296 HeadingUtils::convert_heading_style("Text", 10, HeadingStyle::Atx),
297 "###### Text"
298 );
299
300 assert_eq!(
302 HeadingUtils::convert_heading_style("Text", 3, HeadingStyle::Setext1),
303 "### Text"
304 );
305
306 assert_eq!(
308 HeadingUtils::convert_heading_style(" Text", 1, HeadingStyle::Atx),
309 " # Text"
310 );
311
312 assert_eq!(
314 HeadingUtils::convert_heading_style("Hi", 1, HeadingStyle::Setext1),
315 "Hi\n=="
316 );
317 }
318
319 #[test]
320 fn test_heading_to_fragment() {
321 assert_eq!(HeadingUtils::heading_to_fragment("Simple Heading"), "simple-heading");
322 assert_eq!(
323 HeadingUtils::heading_to_fragment("Heading with Numbers 123"),
324 "heading-with-numbers-123"
325 );
326 assert_eq!(
327 HeadingUtils::heading_to_fragment("Special!@#$%Characters"),
328 "special-characters"
329 );
330 assert_eq!(HeadingUtils::heading_to_fragment(" Trimmed "), "trimmed");
331 assert_eq!(
332 HeadingUtils::heading_to_fragment("Multiple Spaces"),
333 "multiple-spaces"
334 );
335 assert_eq!(
336 HeadingUtils::heading_to_fragment("Heading <em>with HTML</em>"),
337 "heading-with-html"
338 );
339 assert_eq!(
340 HeadingUtils::heading_to_fragment("---Leading-Dashes---"),
341 "leading-dashes"
342 );
343 assert_eq!(HeadingUtils::heading_to_fragment(""), "");
344 }
345
346 #[test]
347 fn test_module_level_functions() {
348 assert!(is_heading("# Heading"));
350 assert!(is_heading(" ## Indented"));
351 assert!(!is_heading("Not a heading"));
352 assert!(!is_heading(""));
353
354 assert!(is_setext_heading_marker("========"));
356 assert!(is_setext_heading_marker("--------"));
357 assert!(is_setext_heading_marker(" ======"));
358 assert!(!is_setext_heading_marker("# Heading"));
359 assert!(is_setext_heading_marker("---")); let lines = vec!["# H1", "## H2", "### H3"];
363 assert_eq!(get_heading_level(&lines, 0), 1);
364 assert_eq!(get_heading_level(&lines, 1), 2);
365 assert_eq!(get_heading_level(&lines, 2), 3);
366 assert_eq!(get_heading_level(&lines, 10), 0);
367 }
368
369 #[test]
370 fn test_heading_style_from_str() {
371 assert_eq!(HeadingStyle::from_str("atx"), Ok(HeadingStyle::Atx));
372 assert_eq!(HeadingStyle::from_str("ATX"), Ok(HeadingStyle::Atx));
373 assert_eq!(HeadingStyle::from_str("atx_closed"), Ok(HeadingStyle::AtxClosed));
374 assert_eq!(HeadingStyle::from_str("atx-closed"), Ok(HeadingStyle::AtxClosed));
375 assert_eq!(HeadingStyle::from_str("ATX-CLOSED"), Ok(HeadingStyle::AtxClosed));
376 assert_eq!(HeadingStyle::from_str("setext1"), Ok(HeadingStyle::Setext1));
377 assert_eq!(HeadingStyle::from_str("setext"), Ok(HeadingStyle::Setext1));
378 assert_eq!(HeadingStyle::from_str("setext2"), Ok(HeadingStyle::Setext2));
379 assert_eq!(HeadingStyle::from_str("consistent"), Ok(HeadingStyle::Consistent));
380 assert_eq!(
381 HeadingStyle::from_str("setext_with_atx"),
382 Ok(HeadingStyle::SetextWithAtx)
383 );
384 assert_eq!(
385 HeadingStyle::from_str("setext-with-atx"),
386 Ok(HeadingStyle::SetextWithAtx)
387 );
388 assert_eq!(
389 HeadingStyle::from_str("setext_with_atx_closed"),
390 Ok(HeadingStyle::SetextWithAtxClosed)
391 );
392 assert_eq!(
393 HeadingStyle::from_str("setext-with-atx-closed"),
394 Ok(HeadingStyle::SetextWithAtxClosed)
395 );
396 assert_eq!(HeadingStyle::from_str("invalid"), Err(()));
397 }
398
399 #[test]
400 fn test_heading_style_display() {
401 assert_eq!(HeadingStyle::Atx.to_string(), "atx");
402 assert_eq!(HeadingStyle::AtxClosed.to_string(), "atx-closed");
403 assert_eq!(HeadingStyle::Setext1.to_string(), "setext1");
404 assert_eq!(HeadingStyle::Setext2.to_string(), "setext2");
405 assert_eq!(HeadingStyle::Consistent.to_string(), "consistent");
406 }
407
408 #[test]
409 fn test_unicode_heading_fragments() {
410 assert_eq!(HeadingUtils::heading_to_fragment("你好世界"), "你好世界");
411 assert_eq!(HeadingUtils::heading_to_fragment("Café René"), "café-rené");
412 }
413}