Skip to main content

marco_core/parser/blocks/
cm_heading_parser.rs

1//! Heading parser - converts grammar output to AST nodes
2//!
3//! Handles conversion of both ATX headings (# Header) and Setext headings (underline style)
4//! from grammar layer to parser AST representation.
5
6use super::shared::{opt_span, opt_span_range, GrammarSpan};
7use crate::parser::ast::{Node, NodeKind};
8
9/// Parse an ATX heading (# Header) into an AST node.
10///
11/// # Arguments
12/// * `level` - Heading level (1-6)
13/// * `content` - The heading text content from grammar layer
14///
15/// # Returns
16/// A Node with NodeKind::Heading
17///
18/// # Note
19/// The span includes only the heading text content, not the # markers.
20/// For full-line highlighting including markers, the intelligence layer should use
21/// the full line span.
22///
23/// # Example
24/// ```ignore
25/// let content = GrammarSpan::new("Hello World");
26/// let node = parse_atx_heading(1, content);
27/// assert!(matches!(node.kind, NodeKind::Heading { level: 1, .. }));
28/// ```
29pub 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
40/// Parse a Setext heading (underline style) into an AST node.
41///
42/// # Arguments
43/// * `level` - Heading level (1 for === underline, 2 for --- underline)
44/// * `content` - The heading text content from grammar layer
45///
46/// # Returns
47/// A Node with NodeKind::Heading
48///
49/// # Example
50/// ```ignore
51/// let content = GrammarSpan::new("Hello\n===");
52/// let node = parse_setext_heading(1, content);
53/// assert!(matches!(node.kind, NodeKind::Heading { level: 1, .. }));
54/// ```
55pub fn parse_setext_heading(
56    level: u8,
57    content: GrammarSpan,
58    full_start: GrammarSpan,
59    full_end: GrammarSpan,
60) -> Node {
61    // NOTE:
62    // - `content` is the heading text *without* the underline.
63    // - `full_start..full_end` covers the entire setext construct including the underline,
64    //   which is what we want for highlighting.
65    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
75/// Split a heading's text from an optional extended id suffix.
76///
77/// Supported syntax (Markdown Guide "extended" style):
78/// - `### Title {#custom-id}`
79///
80/// Rules (intentionally strict):
81/// - The suffix must be at the end of the heading.
82/// - The opening must be exactly `{#` (no whitespace between).
83/// - The id must be non-empty and contain no whitespace.
84/// - There must be at least one whitespace character before the `{`.
85fn 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    // Require at least one whitespace char right before the `{`.
97    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        // Should span across at least 2 lines (content + underline).
189        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}