Skip to main content

panache_parser/parser/inlines/
inline_footnotes.rs

1//! Inline footnote parsing for Pandoc's inline_notes extension.
2//!
3//! Syntax: `^[footnote text]` for inline footnotes
4//! Syntax: `[^id]` for reference footnotes
5
6use crate::syntax::SyntaxKind;
7use rowan::GreenNodeBuilder;
8
9use super::core::parse_inline_text;
10use crate::options::ParserOptions;
11
12/// Try to parse an inline footnote starting at the current position.
13/// Returns Some((length, content)) if successful, None otherwise.
14///
15/// Inline footnotes have the syntax: ^[text]
16/// The text can contain most inline elements but not nested footnotes.
17pub(crate) fn try_parse_inline_footnote(text: &str) -> Option<(usize, &str)> {
18    let bytes = text.as_bytes();
19
20    // Must start with ^[
21    if bytes.len() < 3 || bytes[0] != b'^' || bytes[1] != b'[' {
22        return None;
23    }
24
25    // Find the closing ]
26    let mut pos = 2;
27    let mut bracket_depth = 1; // Already opened one bracket
28
29    while pos < bytes.len() {
30        match bytes[pos] {
31            b'\\' => {
32                // Skip escaped character
33                pos += 2;
34                continue;
35            }
36            b'[' => {
37                bracket_depth += 1;
38                pos += 1;
39            }
40            b']' => {
41                bracket_depth -= 1;
42                if bracket_depth == 0 {
43                    // Found the closing bracket
44                    let content = &text[2..pos];
45                    return Some((pos + 1, content));
46                }
47                pos += 1;
48            }
49            _ => {
50                pos += 1;
51            }
52        }
53    }
54
55    // No closing bracket found
56    None
57}
58
59/// Emit an inline footnote node to the builder.
60///
61/// `suppress_footnote_refs` cascades through to the recursive inline parse:
62/// if the caller is already inside a footnote-definition body (where pandoc
63/// silently drops nested refs), inline footnotes nested in that body also
64/// drop their inner refs. At the top level the flag is `false` and `[^id]`
65/// inside `^[...]` resolves normally per pandoc-native.
66pub(crate) fn emit_inline_footnote(
67    builder: &mut GreenNodeBuilder,
68    content: &str,
69    config: &ParserOptions,
70    suppress_footnote_refs: bool,
71) {
72    builder.start_node(SyntaxKind::INLINE_FOOTNOTE.into());
73
74    // Opening marker
75    builder.token(SyntaxKind::INLINE_FOOTNOTE_START.into(), "^[");
76
77    // Parse the content recursively for nested inline elements
78    parse_inline_text(builder, content, config, false, suppress_footnote_refs);
79
80    // Closing marker
81    builder.token(SyntaxKind::INLINE_FOOTNOTE_END.into(), "]");
82
83    builder.finish_node();
84}
85
86/// Try to parse a footnote reference: [^id]
87/// Returns Some((length, id)) if successful.
88pub(crate) fn try_parse_footnote_reference(text: &str) -> Option<(usize, String)> {
89    let bytes = text.as_bytes();
90
91    // Must start with [^
92    if bytes.len() < 4 || bytes[0] != b'[' || bytes[1] != b'^' {
93        return None;
94    }
95
96    // Find the closing ]
97    let mut pos = 2;
98    while pos < bytes.len() && bytes[pos] != b']' && bytes[pos] != b'\n' && bytes[pos] != b'\r' {
99        pos += 1;
100    }
101
102    if pos >= bytes.len() || bytes[pos] != b']' {
103        return None;
104    }
105
106    let id = &text[2..pos];
107    if id.is_empty() {
108        return None;
109    }
110
111    Some((pos + 1, id.to_string()))
112}
113
114/// Emit a footnote reference node to the builder.
115pub(crate) fn emit_footnote_reference(builder: &mut GreenNodeBuilder, id: &str) {
116    builder.start_node(SyntaxKind::FOOTNOTE_REFERENCE.into());
117    builder.token(SyntaxKind::FOOTNOTE_LABEL_START.into(), "[^");
118    builder.token(SyntaxKind::FOOTNOTE_LABEL_ID.into(), id);
119    builder.token(SyntaxKind::FOOTNOTE_LABEL_END.into(), "]");
120    builder.finish_node();
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn test_parse_simple_inline_footnote() {
129        let result = try_parse_inline_footnote("^[This is a note]");
130        assert_eq!(result, Some((17, "This is a note")));
131    }
132
133    #[test]
134    fn test_parse_inline_footnote_with_trailing_text() {
135        let result = try_parse_inline_footnote("^[Note text] and more");
136        assert_eq!(result, Some((12, "Note text")));
137    }
138
139    #[test]
140    fn test_parse_inline_footnote_with_brackets_inside() {
141        let result = try_parse_inline_footnote("^[Text with [nested] brackets]");
142        assert_eq!(result, Some((30, "Text with [nested] brackets")));
143    }
144
145    #[test]
146    fn test_parse_inline_footnote_with_escaped_bracket() {
147        let result = try_parse_inline_footnote("^[Text with \\] escaped]");
148        assert_eq!(result, Some((23, "Text with \\] escaped")));
149    }
150
151    #[test]
152    fn test_not_inline_footnote_no_opening() {
153        let result = try_parse_inline_footnote("[Not a footnote]");
154        assert_eq!(result, None);
155    }
156
157    #[test]
158    fn test_not_inline_footnote_no_closing() {
159        let result = try_parse_inline_footnote("^[No closing bracket");
160        assert_eq!(result, None);
161    }
162
163    #[test]
164    fn test_not_inline_footnote_just_caret() {
165        let result = try_parse_inline_footnote("^Not a footnote");
166        assert_eq!(result, None);
167    }
168
169    #[test]
170    fn test_empty_inline_footnote() {
171        let result = try_parse_inline_footnote("^[]");
172        assert_eq!(result, Some((3, "")));
173    }
174
175    #[test]
176    fn test_inline_footnote_multiline() {
177        // Inline footnotes can span multiple lines in the source
178        let result = try_parse_inline_footnote("^[This is\na multiline\nnote]");
179        assert_eq!(result, Some((27, "This is\na multiline\nnote")));
180    }
181
182    #[test]
183    fn test_inline_footnote_with_code() {
184        let result = try_parse_inline_footnote("^[Contains `code` inside]");
185        assert_eq!(result, Some((25, "Contains `code` inside")));
186    }
187
188    #[test]
189    fn test_footnote_reference_with_crlf() {
190        // Footnote reference IDs should not span lines with CRLF
191        let input = "[^foo\r\nbar]";
192        let result = try_parse_footnote_reference(input);
193
194        // Should fail to parse because ID contains line break
195        assert_eq!(
196            result, None,
197            "Should not parse footnote reference with CRLF in ID"
198        );
199    }
200
201    #[test]
202    fn test_footnote_reference_with_lf() {
203        // Footnote reference IDs should not span lines with LF either
204        let input = "[^foo\nbar]";
205        let result = try_parse_footnote_reference(input);
206
207        // Should fail to parse because ID contains line break
208        assert_eq!(
209            result, None,
210            "Should not parse footnote reference with LF in ID"
211        );
212    }
213}