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
113pub struct LinkDest(SyntaxNode);
114
115impl AstNode for LinkDest {
116    type Language = PanacheLanguage;
117
118    fn can_cast(kind: SyntaxKind) -> bool {
119        kind == SyntaxKind::LINK_DEST
120    }
121
122    fn cast(syntax: SyntaxNode) -> Option<Self> {
123        if Self::can_cast(syntax.kind()) {
124            Some(Self(syntax))
125        } else {
126            None
127        }
128    }
129
130    fn syntax(&self) -> &SyntaxNode {
131        &self.0
132    }
133}
134
135impl LinkDest {
136    /// Returns the URL/destination as a string (with surrounding parentheses).
137    pub fn url(&self) -> String {
138        self.0.text().to_string()
139    }
140
141    /// Returns the URL without parentheses.
142    pub fn url_content(&self) -> String {
143        let text = self.0.text().to_string();
144        text.trim_start_matches('(')
145            .trim_end_matches(')')
146            .to_string()
147    }
148
149    /// Returns the range for a hash-anchor id within destination text (without '#').
150    pub fn hash_anchor_id_range(&self) -> Option<rowan::TextRange> {
151        let text = self.0.text().to_string();
152        let hash_idx = text.find('#')?;
153        let after_hash = &text[hash_idx + 1..];
154        let id_len = after_hash
155            .chars()
156            .take_while(|ch| !ch.is_whitespace() && *ch != ')')
157            .map(char::len_utf8)
158            .sum::<usize>();
159        if id_len == 0 {
160            return None;
161        }
162        let node_start: usize = self.0.text_range().start().into();
163        let start = rowan::TextSize::from((node_start + hash_idx + 1) as u32);
164        let end = rowan::TextSize::from((node_start + hash_idx + 1 + id_len) as u32);
165        Some(rowan::TextRange::new(start, end))
166    }
167
168    /// Returns the hash-anchor id within destination text (without '#').
169    pub fn hash_anchor_id(&self) -> Option<String> {
170        let text = self.0.text().to_string();
171        let hash_idx = text.find('#')?;
172        let after_hash = &text[hash_idx + 1..];
173        let id_len = after_hash
174            .chars()
175            .take_while(|ch| !ch.is_whitespace() && *ch != ')')
176            .map(char::len_utf8)
177            .sum::<usize>();
178        if id_len == 0 {
179            return None;
180        }
181        Some(after_hash[..id_len].to_string())
182    }
183}
184
185pub struct LinkRef(SyntaxNode);
186
187impl AstNode for LinkRef {
188    type Language = PanacheLanguage;
189
190    fn can_cast(kind: SyntaxKind) -> bool {
191        kind == SyntaxKind::LINK_REF
192    }
193
194    fn cast(syntax: SyntaxNode) -> Option<Self> {
195        if Self::can_cast(syntax.kind()) {
196            Some(Self(syntax))
197        } else {
198            None
199        }
200    }
201
202    fn syntax(&self) -> &SyntaxNode {
203        &self.0
204    }
205}
206
207impl LinkRef {
208    /// Returns the reference label text.
209    pub fn label(&self) -> String {
210        self.0
211            .children_with_tokens()
212            .filter_map(|it| it.into_token())
213            .filter(|token| token.kind() == SyntaxKind::TEXT)
214            .map(|token| token.text().to_string())
215            .collect()
216    }
217
218    /// Returns the text range for the reference label (without brackets).
219    pub fn label_range(&self) -> Option<rowan::TextRange> {
220        self.0
221            .children_with_tokens()
222            .filter_map(|it| it.into_token())
223            .find(|token| token.kind() == SyntaxKind::TEXT)
224            .map(|token| token.text_range())
225    }
226
227    /// Returns the text range for the label value (without brackets).
228    pub fn label_value_range(&self) -> Option<rowan::TextRange> {
229        self.label_range()
230    }
231}
232
233pub struct ImageLink(SyntaxNode);
234
235impl AstNode for ImageLink {
236    type Language = PanacheLanguage;
237
238    fn can_cast(kind: SyntaxKind) -> bool {
239        kind == SyntaxKind::IMAGE_LINK
240    }
241
242    fn cast(syntax: SyntaxNode) -> Option<Self> {
243        if Self::can_cast(syntax.kind()) {
244            Some(Self(syntax))
245        } else {
246            None
247        }
248    }
249
250    fn syntax(&self) -> &SyntaxNode {
251        &self.0
252    }
253}
254
255impl ImageLink {
256    /// Returns the alt text node.
257    pub fn alt(&self) -> Option<ImageAlt> {
258        support::child(&self.0)
259    }
260
261    /// Returns the image destination.
262    pub fn dest(&self) -> Option<LinkDest> {
263        support::child(&self.0)
264    }
265
266    /// Returns the reference label for reference-style images.
267    pub fn reference(&self) -> Option<LinkRef> {
268        support::child(&self.0)
269    }
270
271    /// Returns the reference label text for reference-style images.
272    pub fn reference_label(&self) -> Option<String> {
273        self.reference().map(|link_ref| link_ref.label())
274    }
275
276    /// Returns the text range for the reference label in reference-style images.
277    pub fn reference_label_range(&self) -> Option<rowan::TextRange> {
278        self.reference().and_then(|link_ref| link_ref.label_range())
279    }
280}
281
282pub struct ImageAlt(SyntaxNode);
283
284impl AstNode for ImageAlt {
285    type Language = PanacheLanguage;
286
287    fn can_cast(kind: SyntaxKind) -> bool {
288        kind == SyntaxKind::IMAGE_ALT
289    }
290
291    fn cast(syntax: SyntaxNode) -> Option<Self> {
292        if Self::can_cast(syntax.kind()) {
293            Some(Self(syntax))
294        } else {
295            None
296        }
297    }
298
299    fn syntax(&self) -> &SyntaxNode {
300        &self.0
301    }
302}
303
304impl ImageAlt {
305    /// Returns the alt text content.
306    pub fn text(&self) -> String {
307        self.0
308            .descendants_with_tokens()
309            .filter_map(|it| it.into_token())
310            .filter(|token| token.kind() == SyntaxKind::TEXT)
311            .map(|token| token.text().to_string())
312            .collect()
313    }
314}
315
316pub struct Figure(SyntaxNode);
317
318impl AstNode for Figure {
319    type Language = PanacheLanguage;
320
321    fn can_cast(kind: SyntaxKind) -> bool {
322        kind == SyntaxKind::FIGURE
323    }
324
325    fn cast(syntax: SyntaxNode) -> Option<Self> {
326        if Self::can_cast(syntax.kind()) {
327            Some(Self(syntax))
328        } else {
329            None
330        }
331    }
332
333    fn syntax(&self) -> &SyntaxNode {
334        &self.0
335    }
336}
337
338impl Figure {
339    /// Returns the image link within the figure.
340    pub fn image(&self) -> Option<ImageLink> {
341        support::child(&self.0)
342    }
343}
344
345/// A bracket-shape pattern (`[foo]`, `[text][label]`, `[text][]`,
346/// `![alt]`, ...) that did not resolve as a link or image — i.e. no
347/// matching reference definition was found.
348///
349/// Distinct from `Link` / `ImageLink` so downstream tools (linter, LSP,
350/// formatter, salsa, pandoc-ast projector) can attach behavior to
351/// unresolved bracket-shape patterns without the parser having to lie
352/// about resolution. Use `is_image()` to discriminate `[foo]` from
353/// `![foo]` shapes.
354pub struct UnresolvedReference(SyntaxNode);
355
356impl AstNode for UnresolvedReference {
357    type Language = PanacheLanguage;
358
359    fn can_cast(kind: SyntaxKind) -> bool {
360        kind == SyntaxKind::UNRESOLVED_REFERENCE
361    }
362
363    fn cast(syntax: SyntaxNode) -> Option<Self> {
364        if Self::can_cast(syntax.kind()) {
365            Some(Self(syntax))
366        } else {
367            None
368        }
369    }
370
371    fn syntax(&self) -> &SyntaxNode {
372        &self.0
373    }
374}
375
376impl UnresolvedReference {
377    /// `true` if this is an image-shape reference (`![alt]...`),
378    /// `false` for a link-shape reference (`[text]...`). Determined
379    /// from the leading byte of the node's source text.
380    pub fn is_image(&self) -> bool {
381        self.0.text().to_string().as_bytes().first() == Some(&b'!')
382    }
383
384    /// The bracket-text content (the bytes between the outer `[` and
385    /// `]`). For `[foo]` this is `"foo"`; for `[text][label]` this is
386    /// `"text"`.
387    pub fn text(&self) -> String {
388        // Mirror Link::text behavior: collect TEXT tokens from the
389        // primary text wrapper if present, falling back to all TEXT
390        // tokens under the node.
391        if let Some(link_text) = support::child::<LinkText>(&self.0) {
392            return link_text.text_content();
393        }
394        if let Some(image_alt) = support::child::<ImageAlt>(&self.0) {
395            return image_alt.text();
396        }
397        self.0
398            .descendants_with_tokens()
399            .filter_map(|it| it.into_token())
400            .filter(|token| token.kind() == SyntaxKind::TEXT)
401            .map(|token| token.text().to_string())
402            .collect()
403    }
404
405    /// The reference label for full / collapsed forms
406    /// (`[text][label]` → `Some("label")`; `[text][]` → `Some("text")`;
407    /// `[text]` shortcut → `None`).
408    pub fn label(&self) -> Option<String> {
409        support::child::<LinkRef>(&self.0).map(|r| r.label())
410    }
411
412    /// Source range of the node.
413    pub fn text_range(&self) -> rowan::TextRange {
414        self.0.text_range()
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::{AstNode, ImageLink, UnresolvedReference};
421
422    #[test]
423    fn image_reference_label_and_range_are_extracted() {
424        // Refdef present: parses as ImageLink so the wrapper accessors apply.
425        let input = "![Alt text][img]\n\n[img]: /url\n";
426        let tree = crate::parse(input, None);
427        let image = tree
428            .descendants()
429            .find_map(ImageLink::cast)
430            .expect("image link");
431
432        assert_eq!(image.reference_label().as_deref(), Some("img"));
433
434        let range = image.reference_label_range().expect("label range");
435        let start: usize = range.start().into();
436        let end: usize = range.end().into();
437        assert_eq!(&input[start..end], "img");
438    }
439
440    #[test]
441    fn unresolved_image_reference_label_is_extracted() {
442        // No matching refdef: parses as UnresolvedReference under Pandoc.
443        // Confirms `is_image()` and `label()` accessors.
444        let input = "![Alt text][img]";
445        let tree = crate::parse(input, None);
446        let unresolved = tree
447            .descendants()
448            .find_map(UnresolvedReference::cast)
449            .expect("unresolved reference");
450
451        assert!(unresolved.is_image(), "expected image-shape unresolved ref");
452        assert_eq!(unresolved.label().as_deref(), Some("img"));
453    }
454
455    #[test]
456    fn unresolved_link_reference_label_is_extracted() {
457        let input = "[link text][missing]";
458        let tree = crate::parse(input, None);
459        let unresolved = tree
460            .descendants()
461            .find_map(UnresolvedReference::cast)
462            .expect("unresolved reference");
463
464        assert!(!unresolved.is_image(), "expected link-shape unresolved ref");
465        assert_eq!(unresolved.label().as_deref(), Some("missing"));
466    }
467
468    #[test]
469    fn unresolved_shortcut_reference_has_no_label() {
470        let input = "[no refdef]";
471        let tree = crate::parse(input, None);
472        let unresolved = tree
473            .descendants()
474            .find_map(UnresolvedReference::cast)
475            .expect("unresolved reference");
476
477        assert!(!unresolved.is_image());
478        assert!(unresolved.label().is_none());
479    }
480}