marco_core/parser/blocks/
cm_heading_parser.rs1use super::shared::{opt_span, opt_span_range, GrammarSpan};
7use crate::parser::ast::{Node, NodeKind};
8
9pub fn parse_atx_heading(level: u8, content: GrammarSpan) -> Node {
30 let span = opt_span(content);
31 let (text, id) = split_extended_heading_id(content.fragment());
32
33 Node {
34 kind: NodeKind::Heading { level, text, id },
35 span,
36 children: Vec::new(),
37 }
38}
39
40pub fn parse_setext_heading(
56 level: u8,
57 content: GrammarSpan,
58 full_start: GrammarSpan,
59 full_end: GrammarSpan,
60) -> Node {
61 let span = opt_span_range(full_start, full_end);
66 let (text, id) = split_extended_heading_id(content.fragment());
67
68 Node {
69 kind: NodeKind::Heading { level, text, id },
70 span,
71 children: Vec::new(),
72 }
73}
74
75fn split_extended_heading_id(input: &str) -> (String, Option<String>) {
86 let trimmed = input.trim_end();
87 if !trimmed.ends_with('}') {
88 return (input.to_string(), None);
89 }
90
91 let start = match trimmed.rfind("{#") {
92 Some(pos) => pos,
93 None => return (input.to_string(), None),
94 };
95
96 if start == 0 {
98 return (input.to_string(), None);
99 }
100 let before = &trimmed[..start];
101 if !before.chars().last().is_some_and(|c| c.is_whitespace()) {
102 return (input.to_string(), None);
103 }
104
105 let id = &trimmed[start + 2..trimmed.len() - 1];
106 if id.is_empty()
107 || id
108 .chars()
109 .any(|c| c.is_whitespace() || c == '{' || c == '}')
110 {
111 return (input.to_string(), None);
112 }
113
114 let text = before.trim_end().to_string();
115 (text, Some(id.to_string()))
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121 use crate::grammar::blocks as grammar;
122
123 #[test]
124 fn smoke_test_parse_atx_heading_level_1() {
125 let content = GrammarSpan::new("Hello World");
126 let node = parse_atx_heading(1, content);
127
128 if let NodeKind::Heading { level, text, id } = node.kind {
129 assert_eq!(level, 1);
130 assert_eq!(text, "Hello World");
131 assert!(id.is_none());
132 } else {
133 panic!("Expected Heading node");
134 }
135 }
136
137 #[test]
138 fn smoke_test_parse_atx_heading_level_6() {
139 let content = GrammarSpan::new("Small heading");
140 let node = parse_atx_heading(6, content);
141
142 if let NodeKind::Heading { level, text, id } = node.kind {
143 assert_eq!(level, 6);
144 assert_eq!(text, "Small heading");
145 assert!(id.is_none());
146 } else {
147 panic!("Expected Heading node");
148 }
149 }
150
151 #[test]
152 fn smoke_test_parse_setext_heading_level_1() {
153 let start = GrammarSpan::new("Main Title\n===\n");
154 let (rest, (level, content)) = grammar::setext_heading(start).unwrap();
155 let node = parse_setext_heading(level, content, start, rest);
156
157 if let NodeKind::Heading { level, text, id } = node.kind {
158 assert_eq!(level, 1);
159 assert_eq!(text, "Main Title");
160 assert!(id.is_none());
161 } else {
162 panic!("Expected Heading node");
163 }
164 }
165
166 #[test]
167 fn smoke_test_parse_setext_heading_level_2() {
168 let start = GrammarSpan::new("Subtitle\n---\n");
169 let (rest, (level, content)) = grammar::setext_heading(start).unwrap();
170 let node = parse_setext_heading(level, content, start, rest);
171
172 if let NodeKind::Heading { level, text, id } = node.kind {
173 assert_eq!(level, 2);
174 assert_eq!(text, "Subtitle");
175 assert!(id.is_none());
176 } else {
177 panic!("Expected Heading node");
178 }
179 }
180
181 #[test]
182 fn smoke_test_setext_heading_span_includes_underline_line() {
183 let start = GrammarSpan::new("Title\n===\nNext\n");
184 let (rest, (level, content)) = grammar::setext_heading(start).unwrap();
185 let node = parse_setext_heading(level, content, start, rest);
186
187 let span = node.span.expect("setext heading should have span");
188 assert_eq!(span.start.line, 1);
190 assert!(
191 span.end.line >= 2,
192 "expected underline line to be included in span"
193 );
194 }
195
196 #[test]
197 fn smoke_test_heading_span_tracking() {
198 let content = GrammarSpan::new("Test");
199 let node = parse_atx_heading(3, content);
200
201 assert!(node.span.is_some());
202 let span = node.span.unwrap();
203 assert_eq!(span.start.line, 1);
204 assert_eq!(span.start.column, 1);
205 }
206
207 #[test]
208 fn smoke_test_heading_no_children() {
209 let content = GrammarSpan::new("Test");
210 let node = parse_atx_heading(2, content);
211
212 assert!(node.children.is_empty());
213 }
214
215 #[test]
216 fn smoke_test_heading_empty_text() {
217 let content = GrammarSpan::new("");
218 let node = parse_atx_heading(1, content);
219
220 if let NodeKind::Heading { text, .. } = node.kind {
221 assert_eq!(text, "");
222 } else {
223 panic!("Expected Heading node");
224 }
225 }
226
227 #[test]
228 fn smoke_test_parse_extended_heading_id_suffix() {
229 let content = GrammarSpan::new("Title {#custom-id}");
230 let node = parse_atx_heading(3, content);
231
232 match node.kind {
233 NodeKind::Heading { level, text, id } => {
234 assert_eq!(level, 3);
235 assert_eq!(text, "Title");
236 assert_eq!(id.as_deref(), Some("custom-id"));
237 }
238 _ => panic!("Expected Heading node"),
239 }
240 }
241}