Skip to main content

panache_parser/syntax/
references.rs

1//! Reference link and footnote AST node wrappers.
2
3use super::ast::support;
4use super::links::Link;
5use super::{AstNode, PanacheLanguage, SyntaxKind, SyntaxNode};
6
7pub struct ReferenceDefinition(SyntaxNode);
8
9impl AstNode for ReferenceDefinition {
10    type Language = PanacheLanguage;
11
12    fn can_cast(kind: SyntaxKind) -> bool {
13        kind == SyntaxKind::REFERENCE_DEFINITION
14    }
15
16    fn cast(syntax: SyntaxNode) -> Option<Self> {
17        if Self::can_cast(syntax.kind()) {
18            Some(Self(syntax))
19        } else {
20            None
21        }
22    }
23
24    fn syntax(&self) -> &SyntaxNode {
25        &self.0
26    }
27}
28
29impl ReferenceDefinition {
30    /// Returns the link containing the label and URL.
31    pub fn link(&self) -> Option<Link> {
32        support::child(&self.0)
33    }
34
35    /// Extracts the label text.
36    pub fn label(&self) -> String {
37        self.link()
38            .and_then(|link| link.text())
39            .map(|text| text.text_content())
40            .unwrap_or_default()
41    }
42
43    /// Extracts raw destination text from a reference definition body.
44    pub fn destination(&self) -> Option<String> {
45        let tail = self
46            .0
47            .children_with_tokens()
48            .filter_map(|child| child.into_token())
49            .find(|token| token.kind() == SyntaxKind::TEXT)?
50            .text()
51            .to_string();
52
53        let after_colon = tail.trim_start().strip_prefix(':')?.trim_start();
54        if after_colon.is_empty() {
55            return None;
56        }
57
58        Some(after_colon.to_string())
59    }
60
61    /// Returns the text range for the definition label value.
62    pub fn label_value_range(&self) -> Option<rowan::TextRange> {
63        let link = self.link()?;
64
65        if let Some(range) = link
66            .reference()
67            .and_then(|reference| reference.label_value_range())
68        {
69            return Some(range);
70        }
71
72        link.text()?
73            .syntax()
74            .descendants_with_tokens()
75            .find_map(|elem| {
76                elem.into_token()
77                    .filter(|token| token.kind() == SyntaxKind::TEXT)
78                    .map(|token| token.text_range())
79            })
80    }
81}
82
83pub struct FootnoteReference(SyntaxNode);
84
85impl AstNode for FootnoteReference {
86    type Language = PanacheLanguage;
87
88    fn can_cast(kind: SyntaxKind) -> bool {
89        kind == SyntaxKind::FOOTNOTE_REFERENCE
90    }
91
92    fn cast(syntax: SyntaxNode) -> Option<Self> {
93        if Self::can_cast(syntax.kind()) {
94            Some(Self(syntax))
95        } else {
96            None
97        }
98    }
99
100    fn syntax(&self) -> &SyntaxNode {
101        &self.0
102    }
103}
104
105impl FootnoteReference {
106    /// Extracts the footnote ID (e.g., "1" from a footnote reference).
107    pub fn id(&self) -> String {
108        if let Some(id) = self
109            .0
110            .children_with_tokens()
111            .filter_map(|child| child.into_token())
112            .find(|token| token.kind() == SyntaxKind::FOOTNOTE_LABEL_ID)
113        {
114            return id.text().to_string();
115        }
116
117        let tokens: Vec<_> = self
118            .0
119            .children_with_tokens()
120            .filter_map(|child| child.into_token())
121            .filter(|token| token.kind() == SyntaxKind::TEXT)
122            .map(|token| token.text().to_string())
123            .collect();
124
125        if tokens.len() >= 2 && tokens[0] == "[^" {
126            tokens[1].clone()
127        } else {
128            String::new()
129        }
130    }
131
132    /// Returns the full text range of this reference token.
133    pub fn id_range(&self) -> rowan::TextRange {
134        self.0.text_range()
135    }
136
137    /// Returns the text range for the footnote ID only (excluding `[^` and `]`).
138    pub fn id_value_range(&self) -> Option<rowan::TextRange> {
139        if let Some(id) = self
140            .0
141            .children_with_tokens()
142            .filter_map(|child| child.into_token())
143            .find(|token| token.kind() == SyntaxKind::FOOTNOTE_LABEL_ID)
144        {
145            return Some(id.text_range());
146        }
147
148        let tokens: Vec<_> = self
149            .0
150            .children_with_tokens()
151            .filter_map(|child| child.into_token())
152            .filter(|token| token.kind() == SyntaxKind::TEXT)
153            .collect();
154
155        if tokens.len() >= 2 && tokens[0].text() == "[^" {
156            Some(tokens[1].text_range())
157        } else {
158            None
159        }
160    }
161}
162
163pub struct FootnoteDefinition(SyntaxNode);
164
165impl AstNode for FootnoteDefinition {
166    type Language = PanacheLanguage;
167
168    fn can_cast(kind: SyntaxKind) -> bool {
169        kind == SyntaxKind::FOOTNOTE_DEFINITION
170    }
171
172    fn cast(syntax: SyntaxNode) -> Option<Self> {
173        if Self::can_cast(syntax.kind()) {
174            Some(Self(syntax))
175        } else {
176            None
177        }
178    }
179
180    fn syntax(&self) -> &SyntaxNode {
181        &self.0
182    }
183}
184
185impl FootnoteDefinition {
186    /// Extracts the footnote ID from the definition marker.
187    pub fn id(&self) -> String {
188        if let Some(id) = self
189            .0
190            .children_with_tokens()
191            .filter_map(|child| child.into_token())
192            .find(|token| token.kind() == SyntaxKind::FOOTNOTE_LABEL_ID)
193        {
194            return id.text().to_string();
195        }
196
197        self.0
198            .children_with_tokens()
199            .filter_map(|child| child.into_token())
200            .find(|token| token.kind() == SyntaxKind::FOOTNOTE_REFERENCE)
201            .and_then(|token| {
202                let text = token.text();
203                if text.starts_with("[^") && text.contains("]:") {
204                    text.trim_start_matches("[^")
205                        .split(']')
206                        .next()
207                        .map(String::from)
208                } else {
209                    None
210                }
211            })
212            .unwrap_or_default()
213    }
214
215    /// Returns the text range for the footnote ID only (excluding `[^`, `]`, and `:`).
216    pub fn id_value_range(&self) -> Option<rowan::TextRange> {
217        if let Some(id) = self
218            .0
219            .children_with_tokens()
220            .filter_map(|child| child.into_token())
221            .find(|token| token.kind() == SyntaxKind::FOOTNOTE_LABEL_ID)
222        {
223            return Some(id.text_range());
224        }
225
226        let marker = self
227            .0
228            .children_with_tokens()
229            .filter_map(|child| child.into_token())
230            .find(|token| token.kind() == SyntaxKind::FOOTNOTE_REFERENCE)?;
231
232        let marker_text = marker.text();
233        if !marker_text.starts_with("[^") {
234            return None;
235        }
236
237        let close_bracket = marker_text.find(']')?;
238        if close_bracket <= 2 {
239            return None;
240        }
241
242        if marker_text.as_bytes().get(close_bracket + 1) != Some(&b':') {
243            return None;
244        }
245
246        let token_start = marker.text_range().start();
247        let id_start = token_start + rowan::TextSize::from(2);
248        let id_end = token_start + rowan::TextSize::from(close_bracket as u32);
249        Some(rowan::TextRange::new(id_start, id_end))
250    }
251
252    /// Extracts the content of the footnote definition.
253    /// Returns the text content after the `[^id]:` marker.
254    pub fn content(&self) -> String {
255        // Skip the definition marker tokens and collect all other content
256        self.0
257            .children_with_tokens()
258            .filter_map(|child| match child {
259                rowan::NodeOrToken::Node(node) => Some(node.text().to_string()),
260                rowan::NodeOrToken::Token(token)
261                    if !matches!(
262                        token.kind(),
263                        SyntaxKind::FOOTNOTE_REFERENCE
264                            | SyntaxKind::FOOTNOTE_LABEL_START
265                            | SyntaxKind::FOOTNOTE_LABEL_ID
266                            | SyntaxKind::FOOTNOTE_LABEL_END
267                            | SyntaxKind::FOOTNOTE_LABEL_COLON
268                    ) =>
269                {
270                    Some(token.text().to_string())
271                }
272                _ => None,
273            })
274            .collect::<Vec<_>>()
275            .join("")
276    }
277
278    /// Check if this footnote definition is simple (single paragraph, no complex blocks).
279    /// Simple footnotes can be converted to inline style.
280    pub fn is_simple(&self) -> bool {
281        // Simple footnote has:
282        // - No blank lines in content (single paragraph)
283        // - No code blocks, lists, or other block elements
284        let content = self.content();
285
286        // Check for blank lines (indicates multi-paragraph)
287        if content.contains("\n\n") {
288            return false;
289        }
290
291        // Check for code blocks (need to distinguish from continuation lines)
292        // Code blocks have 8+ spaces (4 for footnote + 4 for code)
293        if content
294            .lines()
295            .skip(1)
296            .any(|line| line.len() > 8 && line.starts_with("        "))
297        {
298            return false;
299        }
300
301        // Check for list markers in continuation lines (after first line)
302        for line in content.lines().skip(1) {
303            let trimmed = line.trim_start();
304            if trimmed.starts_with("- ")
305                || trimmed.starts_with("* ")
306                || trimmed.starts_with("+ ")
307                || (trimmed
308                    .chars()
309                    .next()
310                    .map(|c| c.is_ascii_digit())
311                    .unwrap_or(false)
312                    && trimmed.chars().skip(1).any(|c| c == '.'))
313            {
314                return false;
315            }
316        }
317
318        // Check for list nodes in the CST (handles nested lists reliably).
319        if self
320            .0
321            .descendants()
322            .any(|node| node.kind() == SyntaxKind::LIST)
323        {
324            return false;
325        }
326
327        true
328    }
329}
330
331pub struct InlineFootnote(SyntaxNode);
332
333impl AstNode for InlineFootnote {
334    type Language = PanacheLanguage;
335
336    fn can_cast(kind: SyntaxKind) -> bool {
337        kind == SyntaxKind::INLINE_FOOTNOTE
338    }
339
340    fn cast(syntax: SyntaxNode) -> Option<Self> {
341        if Self::can_cast(syntax.kind()) {
342            Some(Self(syntax))
343        } else {
344            None
345        }
346    }
347
348    fn syntax(&self) -> &SyntaxNode {
349        &self.0
350    }
351}
352
353impl InlineFootnote {
354    /// Extracts the content of the inline footnote (text between ^[ and ]).
355    pub fn content(&self) -> String {
356        self.0
357            .children_with_tokens()
358            .filter_map(|child| {
359                if let Some(token) = child.as_token() {
360                    // Skip the start and end markers
361                    if token.kind() != SyntaxKind::INLINE_FOOTNOTE_START
362                        && token.kind() != SyntaxKind::INLINE_FOOTNOTE_END
363                    {
364                        Some(token.text().to_string())
365                    } else {
366                        None
367                    }
368                } else {
369                    // Include nested nodes (emphasis, code, etc.)
370                    child.as_node().map(|node| node.text().to_string())
371                }
372            })
373            .collect::<Vec<_>>()
374            .join("")
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use crate::parse;
382
383    #[test]
384    fn test_reference_definition_destination() {
385        let input = "[ref]: https://example.com \"Title\"";
386        let root = parse(input, None);
387        let def = root
388            .descendants()
389            .find_map(ReferenceDefinition::cast)
390            .expect("Should find ReferenceDefinition");
391
392        assert_eq!(def.label(), "ref");
393        assert_eq!(
394            def.destination().as_deref(),
395            Some("https://example.com \"Title\"")
396        );
397        assert!(def.label_value_range().is_some());
398    }
399
400    #[test]
401    fn test_footnote_definition_single_line() {
402        let input = "[^1]: This is a simple footnote.";
403        let root = parse(input, None);
404        let def = root
405            .descendants()
406            .find_map(FootnoteDefinition::cast)
407            .expect("Should find FootnoteDefinition");
408
409        assert_eq!(def.id(), "1");
410        assert_eq!(
411            def.id_value_range()
412                .map(|range| {
413                    let start: usize = range.start().into();
414                    let end: usize = range.end().into();
415                    input[start..end].to_string()
416                })
417                .as_deref(),
418            Some("1")
419        );
420        assert_eq!(def.content().trim(), "This is a simple footnote.");
421        assert!(def.is_simple(), "Single line footnote should be simple");
422    }
423
424    #[test]
425    fn test_footnote_definition_multiline() {
426        let input = "[^1]: First line\n    Second line";
427        let root = parse(input, None);
428        let def = root
429            .descendants()
430            .find_map(FootnoteDefinition::cast)
431            .expect("Should find FootnoteDefinition");
432
433        assert_eq!(def.id(), "1");
434        let content = def.content();
435        assert!(content.contains("First line"));
436        assert!(content.contains("Second line"));
437        assert!(def.is_simple(), "Continuation lines should still be simple");
438    }
439
440    #[test]
441    fn test_footnote_definition_with_formatting() {
442        let input = "[^note]: Text with *emphasis* and `code`.";
443        let root = parse(input, None);
444        let def = root
445            .descendants()
446            .find_map(FootnoteDefinition::cast)
447            .expect("Should find FootnoteDefinition");
448
449        assert_eq!(def.id(), "note");
450        assert_eq!(
451            def.id_value_range()
452                .map(|range| {
453                    let start: usize = range.start().into();
454                    let end: usize = range.end().into();
455                    input[start..end].to_string()
456                })
457                .as_deref(),
458            Some("note")
459        );
460        let content = def.content();
461        assert!(content.contains("*emphasis*"));
462        assert!(content.contains("`code`"));
463    }
464
465    #[test]
466    fn test_footnote_definition_empty() {
467        let input = "[^1]: ";
468        let root = parse(input, None);
469        let def = root
470            .descendants()
471            .find_map(FootnoteDefinition::cast)
472            .expect("Should find FootnoteDefinition");
473
474        assert_eq!(def.id(), "1");
475        assert!(def.content().trim().is_empty());
476    }
477
478    #[test]
479    fn test_footnote_reference_id() {
480        let input = "[^test]";
481        let root = parse(input, None);
482        let ref_node = root
483            .descendants()
484            .find_map(FootnoteReference::cast)
485            .expect("Should find FootnoteReference");
486
487        assert_eq!(ref_node.id(), "test");
488        assert_eq!(
489            ref_node
490                .id_value_range()
491                .map(|range| {
492                    let start: usize = range.start().into();
493                    let end: usize = range.end().into();
494                    input[start..end].to_string()
495                })
496                .as_deref(),
497            Some("test")
498        );
499    }
500
501    #[test]
502    fn test_footnote_definition_is_simple() {
503        // Simple single-line
504        let input = "[^1]: Simple text.";
505        let root = parse(input, None);
506        let def = root
507            .descendants()
508            .find_map(FootnoteDefinition::cast)
509            .unwrap();
510        assert!(def.is_simple());
511
512        // Simple with continuation
513        let input2 = "[^1]: First line\n    continuation.";
514        let root2 = parse(input2, None);
515        let def2 = root2
516            .descendants()
517            .find_map(FootnoteDefinition::cast)
518            .unwrap();
519        assert!(def2.is_simple());
520    }
521
522    #[test]
523    fn test_footnote_definition_is_complex() {
524        // Multi-paragraph (blank line)
525        let input = "[^1]: First para.\n\n    Second para.";
526        let root = parse(input, None);
527        let def = root
528            .descendants()
529            .find_map(FootnoteDefinition::cast)
530            .unwrap();
531        assert!(!def.is_simple(), "Multi-paragraph should not be simple");
532
533        // With list
534        let input2 = "[^1]: Text\n    - Item 1\n    - Item 2";
535        let root2 = parse(input2, None);
536        let def2 = root2
537            .descendants()
538            .find_map(FootnoteDefinition::cast)
539            .unwrap();
540        assert!(!def2.is_simple(), "Footnote with list should not be simple");
541
542        // With code block
543        let input3 = "[^1]: Text\n\n        code block";
544        let root3 = parse(input3, None);
545        let def3 = root3
546            .descendants()
547            .find_map(FootnoteDefinition::cast)
548            .unwrap();
549        assert!(
550            !def3.is_simple(),
551            "Footnote with code block should not be simple"
552        );
553    }
554
555    #[test]
556    fn test_inline_footnote_content() {
557        let input = "Text^[This is an inline note] more text.";
558        let root = parse(input, None);
559        let inline = root
560            .descendants()
561            .find_map(InlineFootnote::cast)
562            .expect("Should find InlineFootnote");
563
564        assert_eq!(inline.content(), "This is an inline note");
565    }
566
567    #[test]
568    fn test_inline_footnote_with_formatting() {
569        let input = "Text^[Note with *emphasis* and `code`] more.";
570        let root = parse(input, None);
571        let inline = root
572            .descendants()
573            .find_map(InlineFootnote::cast)
574            .expect("Should find InlineFootnote");
575
576        let content = inline.content();
577        assert!(content.contains("emphasis"));
578        assert!(content.contains("code"));
579    }
580}