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. The underline is wrapped in a
46        // SETEXT_HEADING_UNDERLINE node containing a like-named token, so
47        // `support::token` (direct token children only) doesn't find it.
48        if let Some(text) = self
49            .0
50            .descendants_with_tokens()
51            .filter_map(|el| el.into_token())
52            .find(|t| t.kind() == SyntaxKind::SETEXT_HEADING_UNDERLINE)
53            .map(|t| t.text().to_string())
54        {
55            // Setext headings: '=' is level 1, '-' is level 2
56            if text.starts_with('=') { 1 } else { 2 }
57        } else {
58            1 // Default to level 1
59        }
60    }
61
62    /// Returns the heading content node if present.
63    pub fn content(&self) -> Option<HeadingContent> {
64        support::child(&self.0)
65    }
66
67    /// Returns the heading text as a string.
68    pub fn text(&self) -> String {
69        self.content().map(|c| c.text()).unwrap_or_default()
70    }
71
72    /// Returns the heading text range.
73    pub fn text_range(&self) -> rowan::TextRange {
74        self.0.text_range()
75    }
76
77    /// Returns heading text, or a placeholder when empty.
78    pub fn title_or(&self, placeholder: &str) -> String {
79        let text = self.text();
80        if text.is_empty() {
81            placeholder.to_string()
82        } else {
83            text
84        }
85    }
86
87    /// Returns the text range of the ATX marker token (e.g. `###`), if this is an ATX heading.
88    pub fn atx_marker_range(&self) -> Option<rowan::TextRange> {
89        self.0
90            .children()
91            .find(|child| child.kind() == SyntaxKind::ATX_HEADING_MARKER)
92            .and_then(|marker_node| {
93                marker_node
94                    .children_with_tokens()
95                    .find_map(|el| el.as_token().map(|token| token.text_range()))
96            })
97    }
98}
99
100pub struct HeadingContent(SyntaxNode);
101
102impl AstNode for HeadingContent {
103    type Language = PanacheLanguage;
104
105    fn can_cast(kind: SyntaxKind) -> bool {
106        kind == SyntaxKind::HEADING_CONTENT
107    }
108
109    fn cast(syntax: SyntaxNode) -> Option<Self> {
110        if Self::can_cast(syntax.kind()) {
111            Some(Self(syntax))
112        } else {
113            None
114        }
115    }
116
117    fn syntax(&self) -> &SyntaxNode {
118        &self.0
119    }
120}
121
122impl HeadingContent {
123    /// Returns the text content of the heading.
124    pub fn text(&self) -> String {
125        self.0
126            .descendants_with_tokens()
127            .filter_map(|it| it.into_token())
128            .filter(|token| {
129                matches!(
130                    token.kind(),
131                    SyntaxKind::TEXT
132                        | SyntaxKind::INLINE_CODE_CONTENT
133                        | SyntaxKind::INLINE_EXEC_CONTENT
134                )
135            })
136            .map(|token| token.text().to_string())
137            .collect()
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn heading_title_or_returns_placeholder_for_empty_heading() {
147        let tree = crate::parse("# \n", None);
148        let heading = tree.descendants().find_map(Heading::cast).expect("heading");
149        assert_eq!(heading.title_or("(empty)"), "(empty)");
150    }
151
152    #[test]
153    fn setext_heading_levels_are_one_and_two() {
154        let input = "Level 1\n=======\n\nLevel 2\n-------\n";
155        let tree = crate::parse(input, None);
156        let levels: Vec<usize> = tree
157            .descendants()
158            .filter_map(Heading::cast)
159            .map(|h| h.level())
160            .collect();
161        assert_eq!(levels, vec![1, 2]);
162    }
163
164    #[test]
165    fn heading_atx_marker_range_points_to_hashes() {
166        let input = "### Title\n";
167        let tree = crate::parse(input, None);
168        let heading = tree.descendants().find_map(Heading::cast).expect("heading");
169        let range = heading.atx_marker_range().expect("marker range");
170        let start: usize = range.start().into();
171        let end: usize = range.end().into();
172        assert_eq!(&input[start..end], "###");
173    }
174}