Skip to main content

panache_parser/syntax/
links.rs

1//! Link and image AST node wrappers.
2
3use super::ast::support;
4use super::{AstNode, PanacheLanguage, SyntaxKind, SyntaxNode};
5
6pub struct Link(SyntaxNode);
7
8impl AstNode for Link {
9    type Language = PanacheLanguage;
10
11    fn can_cast(kind: SyntaxKind) -> bool {
12        kind == SyntaxKind::LINK
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 Link {
29    /// Returns the link text node.
30    pub fn text(&self) -> Option<LinkText> {
31        support::child(&self.0)
32    }
33
34    /// Returns the link destination node.
35    pub fn dest(&self) -> Option<LinkDest> {
36        support::child(&self.0)
37    }
38
39    /// Returns the reference label for reference-style links.
40    pub fn reference(&self) -> Option<LinkRef> {
41        support::child(&self.0)
42    }
43}
44
45pub struct AutoLink(SyntaxNode);
46
47impl AstNode for AutoLink {
48    type Language = PanacheLanguage;
49
50    fn can_cast(kind: SyntaxKind) -> bool {
51        kind == SyntaxKind::AUTO_LINK
52    }
53
54    fn cast(syntax: SyntaxNode) -> Option<Self> {
55        if Self::can_cast(syntax.kind()) {
56            Some(Self(syntax))
57        } else {
58            None
59        }
60    }
61
62    fn syntax(&self) -> &SyntaxNode {
63        &self.0
64    }
65}
66
67impl AutoLink {
68    /// Returns the autolink target text without angle brackets.
69    pub fn target(&self) -> String {
70        self.0
71            .children_with_tokens()
72            .filter_map(|it| it.into_token())
73            .filter(|token| token.kind() == SyntaxKind::TEXT)
74            .map(|token| token.text().to_string())
75            .collect()
76    }
77}
78
79pub struct LinkText(SyntaxNode);
80
81impl AstNode for LinkText {
82    type Language = PanacheLanguage;
83
84    fn can_cast(kind: SyntaxKind) -> bool {
85        kind == SyntaxKind::LINK_TEXT
86    }
87
88    fn cast(syntax: SyntaxNode) -> Option<Self> {
89        if Self::can_cast(syntax.kind()) {
90            Some(Self(syntax))
91        } else {
92            None
93        }
94    }
95
96    fn syntax(&self) -> &SyntaxNode {
97        &self.0
98    }
99}
100
101impl LinkText {
102    /// Returns the text content.
103    pub fn text_content(&self) -> String {
104        self.0
105            .descendants_with_tokens()
106            .filter_map(|it| it.into_token())
107            .filter(|token| token.kind() == SyntaxKind::TEXT)
108            .map(|token| token.text().to_string())
109            .collect()
110    }
111
112    /// Returns the raw source text of the label (every byte between the
113    /// brackets), used for CommonMark reference-label matching.
114    ///
115    /// Unlike [`text_content`](Self::text_content), which collects only `TEXT`
116    /// tokens and therefore drops inline markup, this preserves the label
117    /// verbatim. That matters for shortcut/collapsed reference links whose
118    /// label parses as inline structure (e.g. a code span `` [`insta`] ``):
119    /// the reference definition stores its label as raw text, and the parser's
120    /// refdef map matches on raw text, so usage-side label extraction must do
121    /// the same or the labels won't compare equal.
122    pub fn raw_label(&self) -> String {
123        self.0.text().to_string()
124    }
125}
126
127pub struct LinkDest(SyntaxNode);
128
129impl AstNode for LinkDest {
130    type Language = PanacheLanguage;
131
132    fn can_cast(kind: SyntaxKind) -> bool {
133        kind == SyntaxKind::LINK_DEST
134    }
135
136    fn cast(syntax: SyntaxNode) -> Option<Self> {
137        if Self::can_cast(syntax.kind()) {
138            Some(Self(syntax))
139        } else {
140            None
141        }
142    }
143
144    fn syntax(&self) -> &SyntaxNode {
145        &self.0
146    }
147}
148
149impl LinkDest {
150    /// Returns the URL/destination as a string (with surrounding parentheses).
151    pub fn url(&self) -> String {
152        self.0.text().to_string()
153    }
154
155    /// Returns the URL without parentheses.
156    pub fn url_content(&self) -> String {
157        let text = self.0.text().to_string();
158        text.trim_start_matches('(')
159            .trim_end_matches(')')
160            .to_string()
161    }
162
163    /// Returns the range for a hash-anchor id within destination text (without '#').
164    pub fn hash_anchor_id_range(&self) -> Option<rowan::TextRange> {
165        let text = self.0.text().to_string();
166        let hash_idx = text.find('#')?;
167        let after_hash = &text[hash_idx + 1..];
168        let id_len = after_hash
169            .chars()
170            .take_while(|ch| !ch.is_whitespace() && *ch != ')')
171            .map(char::len_utf8)
172            .sum::<usize>();
173        if id_len == 0 {
174            return None;
175        }
176        let node_start: usize = self.0.text_range().start().into();
177        let start = rowan::TextSize::from((node_start + hash_idx + 1) as u32);
178        let end = rowan::TextSize::from((node_start + hash_idx + 1 + id_len) as u32);
179        Some(rowan::TextRange::new(start, end))
180    }
181
182    /// Returns the hash-anchor id within destination text (without '#').
183    pub fn hash_anchor_id(&self) -> Option<String> {
184        let text = self.0.text().to_string();
185        let hash_idx = text.find('#')?;
186        let after_hash = &text[hash_idx + 1..];
187        let id_len = after_hash
188            .chars()
189            .take_while(|ch| !ch.is_whitespace() && *ch != ')')
190            .map(char::len_utf8)
191            .sum::<usize>();
192        if id_len == 0 {
193            return None;
194        }
195        Some(after_hash[..id_len].to_string())
196    }
197}
198
199pub struct LinkRef(SyntaxNode);
200
201impl AstNode for LinkRef {
202    type Language = PanacheLanguage;
203
204    fn can_cast(kind: SyntaxKind) -> bool {
205        kind == SyntaxKind::LINK_REF
206    }
207
208    fn cast(syntax: SyntaxNode) -> Option<Self> {
209        if Self::can_cast(syntax.kind()) {
210            Some(Self(syntax))
211        } else {
212            None
213        }
214    }
215
216    fn syntax(&self) -> &SyntaxNode {
217        &self.0
218    }
219}
220
221impl LinkRef {
222    /// Returns the reference label text.
223    pub fn label(&self) -> String {
224        self.0
225            .children_with_tokens()
226            .filter_map(|it| it.into_token())
227            .filter(|token| token.kind() == SyntaxKind::TEXT)
228            .map(|token| token.text().to_string())
229            .collect()
230    }
231
232    /// Returns the text range for the reference label (without brackets).
233    pub fn label_range(&self) -> Option<rowan::TextRange> {
234        self.0
235            .children_with_tokens()
236            .filter_map(|it| it.into_token())
237            .find(|token| token.kind() == SyntaxKind::TEXT)
238            .map(|token| token.text_range())
239    }
240
241    /// Returns the text range for the label value (without brackets).
242    pub fn label_value_range(&self) -> Option<rowan::TextRange> {
243        self.label_range()
244    }
245}
246
247pub struct ImageLink(SyntaxNode);
248
249impl AstNode for ImageLink {
250    type Language = PanacheLanguage;
251
252    fn can_cast(kind: SyntaxKind) -> bool {
253        kind == SyntaxKind::IMAGE_LINK
254    }
255
256    fn cast(syntax: SyntaxNode) -> Option<Self> {
257        if Self::can_cast(syntax.kind()) {
258            Some(Self(syntax))
259        } else {
260            None
261        }
262    }
263
264    fn syntax(&self) -> &SyntaxNode {
265        &self.0
266    }
267}
268
269impl ImageLink {
270    /// Returns the alt text node.
271    pub fn alt(&self) -> Option<ImageAlt> {
272        support::child(&self.0)
273    }
274
275    /// Returns the image destination.
276    pub fn dest(&self) -> Option<LinkDest> {
277        support::child(&self.0)
278    }
279
280    /// Returns the reference label for reference-style images.
281    pub fn reference(&self) -> Option<LinkRef> {
282        support::child(&self.0)
283    }
284
285    /// Returns the reference label text for reference-style images.
286    pub fn reference_label(&self) -> Option<String> {
287        self.reference().map(|link_ref| link_ref.label())
288    }
289
290    /// Returns the text range for the reference label in reference-style images.
291    pub fn reference_label_range(&self) -> Option<rowan::TextRange> {
292        self.reference().and_then(|link_ref| link_ref.label_range())
293    }
294}
295
296pub struct ImageAlt(SyntaxNode);
297
298impl AstNode for ImageAlt {
299    type Language = PanacheLanguage;
300
301    fn can_cast(kind: SyntaxKind) -> bool {
302        kind == SyntaxKind::IMAGE_ALT
303    }
304
305    fn cast(syntax: SyntaxNode) -> Option<Self> {
306        if Self::can_cast(syntax.kind()) {
307            Some(Self(syntax))
308        } else {
309            None
310        }
311    }
312
313    fn syntax(&self) -> &SyntaxNode {
314        &self.0
315    }
316}
317
318impl ImageAlt {
319    /// Returns the alt text content.
320    pub fn text(&self) -> String {
321        self.0
322            .descendants_with_tokens()
323            .filter_map(|it| it.into_token())
324            .filter(|token| token.kind() == SyntaxKind::TEXT)
325            .map(|token| token.text().to_string())
326            .collect()
327    }
328}
329
330pub struct Figure(SyntaxNode);
331
332impl AstNode for Figure {
333    type Language = PanacheLanguage;
334
335    fn can_cast(kind: SyntaxKind) -> bool {
336        kind == SyntaxKind::FIGURE
337    }
338
339    fn cast(syntax: SyntaxNode) -> Option<Self> {
340        if Self::can_cast(syntax.kind()) {
341            Some(Self(syntax))
342        } else {
343            None
344        }
345    }
346
347    fn syntax(&self) -> &SyntaxNode {
348        &self.0
349    }
350}
351
352impl Figure {
353    /// Returns the image link within the figure.
354    pub fn image(&self) -> Option<ImageLink> {
355        support::child(&self.0)
356    }
357}
358
359/// A bracket-shape pattern (`[foo]`, `[text][label]`, `[text][]`,
360/// `![alt]`, ...) that did not resolve as a link or image — i.e. no
361/// matching reference definition was found.
362///
363/// Distinct from `Link` / `ImageLink` so downstream tools (linter, LSP,
364/// formatter, salsa, pandoc-ast projector) can attach behavior to
365/// unresolved bracket-shape patterns without the parser having to lie
366/// about resolution. Use `is_image()` to discriminate `[foo]` from
367/// `![foo]` shapes.
368pub struct UnresolvedReference(SyntaxNode);
369
370impl AstNode for UnresolvedReference {
371    type Language = PanacheLanguage;
372
373    fn can_cast(kind: SyntaxKind) -> bool {
374        kind == SyntaxKind::UNRESOLVED_REFERENCE
375    }
376
377    fn cast(syntax: SyntaxNode) -> Option<Self> {
378        if Self::can_cast(syntax.kind()) {
379            Some(Self(syntax))
380        } else {
381            None
382        }
383    }
384
385    fn syntax(&self) -> &SyntaxNode {
386        &self.0
387    }
388}
389
390impl UnresolvedReference {
391    /// `true` if this is an image-shape reference (`![alt]...`),
392    /// `false` for a link-shape reference (`[text]...`). Determined
393    /// from the leading byte of the node's source text.
394    pub fn is_image(&self) -> bool {
395        self.0.text().to_string().as_bytes().first() == Some(&b'!')
396    }
397
398    /// The bracket-text content (the bytes between the outer `[` and
399    /// `]`). For `[foo]` this is `"foo"`; for `[text][label]` this is
400    /// `"text"`.
401    pub fn text(&self) -> String {
402        // Mirror Link::text behavior: collect TEXT tokens from the
403        // primary text wrapper if present, falling back to all TEXT
404        // tokens under the node.
405        if let Some(link_text) = support::child::<LinkText>(&self.0) {
406            return link_text.text_content();
407        }
408        if let Some(image_alt) = support::child::<ImageAlt>(&self.0) {
409            return image_alt.text();
410        }
411        self.0
412            .descendants_with_tokens()
413            .filter_map(|it| it.into_token())
414            .filter(|token| token.kind() == SyntaxKind::TEXT)
415            .map(|token| token.text().to_string())
416            .collect()
417    }
418
419    /// The reference label for full / collapsed forms
420    /// (`[text][label]` → `Some("label")`; `[text][]` → `Some("text")`;
421    /// `[text]` shortcut → `None`).
422    pub fn label(&self) -> Option<String> {
423        support::child::<LinkRef>(&self.0).map(|r| r.label())
424    }
425
426    /// Source range of the node.
427    pub fn text_range(&self) -> rowan::TextRange {
428        self.0.text_range()
429    }
430}
431
432/// A Pandoc wikilink: `[[url]]`, `[[url|title]]`, `![[url]]`,
433/// `![[url|title]]`. Both `WIKI_LINK` and `IMAGE_WIKI_LINK` cast to this
434/// wrapper; discriminate with [`WikiLink::is_image`]. The URL and (when
435/// present) title are flat raw-text spans — wikilink children are not
436/// recursively parsed for inline markup, matching pandoc behavior.
437pub struct WikiLink(SyntaxNode);
438
439impl AstNode for WikiLink {
440    type Language = PanacheLanguage;
441
442    fn can_cast(kind: SyntaxKind) -> bool {
443        matches!(kind, SyntaxKind::WIKI_LINK | SyntaxKind::IMAGE_WIKI_LINK)
444    }
445
446    fn cast(syntax: SyntaxNode) -> Option<Self> {
447        if Self::can_cast(syntax.kind()) {
448            Some(Self(syntax))
449        } else {
450            None
451        }
452    }
453
454    fn syntax(&self) -> &SyntaxNode {
455        &self.0
456    }
457}
458
459impl WikiLink {
460    /// `true` if this is an image wikilink (`![[...]]`), `false` for a
461    /// regular wikilink (`[[...]]`).
462    pub fn is_image(&self) -> bool {
463        self.0.kind() == SyntaxKind::IMAGE_WIKI_LINK
464    }
465
466    /// The URL slot text. Always present in a well-formed wikilink.
467    pub fn url(&self) -> Option<String> {
468        self.0
469            .children()
470            .find(|n| n.kind() == SyntaxKind::WIKI_LINK_URL)
471            .map(|n| n.text().to_string())
472    }
473
474    /// The title slot text. `None` for the pipe-less `[[url]]` form.
475    pub fn title(&self) -> Option<String> {
476        self.0
477            .children()
478            .find(|n| n.kind() == SyntaxKind::WIKI_LINK_TITLE)
479            .map(|n| n.text().to_string())
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::{AstNode, ImageLink, UnresolvedReference};
486
487    #[test]
488    fn image_reference_label_and_range_are_extracted() {
489        // Refdef present: parses as ImageLink so the wrapper accessors apply.
490        let input = "![Alt text][img]\n\n[img]: /url\n";
491        let tree = crate::parse(input, None);
492        let image = tree
493            .descendants()
494            .find_map(ImageLink::cast)
495            .expect("image link");
496
497        assert_eq!(image.reference_label().as_deref(), Some("img"));
498
499        let range = image.reference_label_range().expect("label range");
500        let start: usize = range.start().into();
501        let end: usize = range.end().into();
502        assert_eq!(&input[start..end], "img");
503    }
504
505    #[test]
506    fn unresolved_image_reference_label_is_extracted() {
507        // No matching refdef: parses as UnresolvedReference under Pandoc.
508        // Confirms `is_image()` and `label()` accessors.
509        let input = "![Alt text][img]";
510        let tree = crate::parse(input, None);
511        let unresolved = tree
512            .descendants()
513            .find_map(UnresolvedReference::cast)
514            .expect("unresolved reference");
515
516        assert!(unresolved.is_image(), "expected image-shape unresolved ref");
517        assert_eq!(unresolved.label().as_deref(), Some("img"));
518    }
519
520    #[test]
521    fn unresolved_link_reference_label_is_extracted() {
522        let input = "[link text][missing]";
523        let tree = crate::parse(input, None);
524        let unresolved = tree
525            .descendants()
526            .find_map(UnresolvedReference::cast)
527            .expect("unresolved reference");
528
529        assert!(!unresolved.is_image(), "expected link-shape unresolved ref");
530        assert_eq!(unresolved.label().as_deref(), Some("missing"));
531    }
532
533    #[test]
534    fn unresolved_shortcut_reference_has_no_label() {
535        let input = "[no refdef]";
536        let tree = crate::parse(input, None);
537        let unresolved = tree
538            .descendants()
539            .find_map(UnresolvedReference::cast)
540            .expect("unresolved reference");
541
542        assert!(!unresolved.is_image());
543        assert!(unresolved.label().is_none());
544    }
545}