Skip to main content

panache_parser/syntax/
headings.rs

1//! Heading AST node wrappers.
2
3use super::ast::support;
4use super::{AstNode, PanacheLanguage, SyntaxKind, SyntaxNode};
5
6pub struct Heading(SyntaxNode);
7
8impl AstNode for Heading {
9    type Language = PanacheLanguage;
10
11    fn can_cast(kind: SyntaxKind) -> bool {
12        kind == SyntaxKind::HEADING
13    }
14
15    fn cast(syntax: SyntaxNode) -> Option<Self> {
16        if Self::can_cast(syntax.kind()) {
17            Some(Self(syntax))
18        } else {
19            None
20        }
21    }
22
23    fn syntax(&self) -> &SyntaxNode {
24        &self.0
25    }
26}
27
28impl Heading {
29    /// Returns the heading level (1-6).
30    pub fn level(&self) -> usize {
31        // Look for ATX_HEADING_MARKER node which contains the token
32        for child in self.0.children() {
33            if child.kind() == SyntaxKind::ATX_HEADING_MARKER {
34                // Count '#' in the token child
35                for token in child.children_with_tokens() {
36                    if let Some(t) = token.as_token()
37                        && t.kind() == SyntaxKind::ATX_HEADING_MARKER
38                    {
39                        return t.text().chars().filter(|&c| c == '#').count();
40                    }
41                }
42            }
43        }
44
45        // Check for setext underline
46        if let Some(underline) = support::token(&self.0, SyntaxKind::SETEXT_HEADING_UNDERLINE) {
47            // Setext headings: '=' is level 1, '-' is level 2
48            if underline.text().starts_with('=') {
49                1
50            } else {
51                2
52            }
53        } else {
54            1 // Default to level 1
55        }
56    }
57
58    /// Returns the heading content node if present.
59    pub fn content(&self) -> Option<HeadingContent> {
60        support::child(&self.0)
61    }
62
63    /// Returns the heading text as a string.
64    pub fn text(&self) -> String {
65        self.content().map(|c| c.text()).unwrap_or_default()
66    }
67
68    /// Returns the heading text range.
69    pub fn text_range(&self) -> rowan::TextRange {
70        self.0.text_range()
71    }
72
73    /// Returns heading text, or a placeholder when empty.
74    pub fn title_or(&self, placeholder: &str) -> String {
75        let text = self.text();
76        if text.is_empty() {
77            placeholder.to_string()
78        } else {
79            text
80        }
81    }
82
83    /// Returns the text range of the ATX marker token (e.g. `###`), if this is an ATX heading.
84    pub fn atx_marker_range(&self) -> Option<rowan::TextRange> {
85        self.0
86            .children()
87            .find(|child| child.kind() == SyntaxKind::ATX_HEADING_MARKER)
88            .and_then(|marker_node| {
89                marker_node
90                    .children_with_tokens()
91                    .find_map(|el| el.as_token().map(|token| token.text_range()))
92            })
93    }
94}
95
96pub struct HeadingContent(SyntaxNode);
97
98impl AstNode for HeadingContent {
99    type Language = PanacheLanguage;
100
101    fn can_cast(kind: SyntaxKind) -> bool {
102        kind == SyntaxKind::HEADING_CONTENT
103    }
104
105    fn cast(syntax: SyntaxNode) -> Option<Self> {
106        if Self::can_cast(syntax.kind()) {
107            Some(Self(syntax))
108        } else {
109            None
110        }
111    }
112
113    fn syntax(&self) -> &SyntaxNode {
114        &self.0
115    }
116}
117
118impl HeadingContent {
119    /// Returns the text content of the heading.
120    pub fn text(&self) -> String {
121        self.0
122            .descendants_with_tokens()
123            .filter_map(|it| it.into_token())
124            .filter(|token| {
125                matches!(
126                    token.kind(),
127                    SyntaxKind::TEXT
128                        | SyntaxKind::INLINE_CODE_CONTENT
129                        | SyntaxKind::INLINE_EXEC_CONTENT
130                )
131            })
132            .map(|token| token.text().to_string())
133            .collect()
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn heading_title_or_returns_placeholder_for_empty_heading() {
143        let tree = crate::parse("# \n", None);
144        let heading = tree.descendants().find_map(Heading::cast).expect("heading");
145        assert_eq!(heading.title_or("(empty)"), "(empty)");
146    }
147
148    #[test]
149    fn heading_atx_marker_range_points_to_hashes() {
150        let input = "### Title\n";
151        let tree = crate::parse(input, None);
152        let heading = tree.descendants().find_map(Heading::cast).expect("heading");
153        let range = heading.atx_marker_range().expect("marker range");
154        let start: usize = range.start().into();
155        let end: usize = range.end().into();
156        assert_eq!(&input[start..end], "###");
157    }
158}