Skip to main content

lepiter_core/
inline_link.rs

1//! Shared scanner for inline `[[wikilink]]` and `[label](target)` syntax.
2//!
3//! A single byte-level walk underlies both link-target extraction (in
4//! [`crate::util`]) and the export/import link rewriters in the tui crate, so
5//! the grammar's edge cases live in one place instead of being re-implemented
6//! per caller.
7//!
8//! This is distinct from the char-based styling parser in the tui crate's
9//! `inline` module, which exists to render display spans and is intentionally
10//! left separate.
11
12use std::ops::Range;
13
14/// Which inline-link syntax produced an [`InlineLink`].
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum LinkKind {
17    /// `[[target]]`
18    Wiki,
19    /// `[label](target)`
20    Markdown,
21}
22
23/// A single inline link located within a text run.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct InlineLink<'a> {
26    pub kind: LinkKind,
27    /// The display label. For [`LinkKind::Markdown`] this is the raw slice
28    /// between `[` and `]`; for [`LinkKind::Wiki`] it equals `target`.
29    pub label: &'a str,
30    /// The link target, trimmed of surrounding whitespace. Never empty.
31    pub target: &'a str,
32    /// Byte range of the whole construct within the source text.
33    pub range: Range<usize>,
34}
35
36/// Walks `text` and yields each `[[wikilink]]` / `[label](target)` it contains,
37/// in source order.
38///
39/// Links whose target is empty (or whitespace-only) are skipped, and `[[wiki]]`
40/// is matched before `[label](target)` at each position — matching the
41/// behaviour every caller historically hand-rolled. All delimiters are ASCII,
42/// so multi-byte UTF-8 content in labels and targets is handled correctly.
43pub fn scan_inline_links(text: &str) -> InlineLinks<'_> {
44    InlineLinks { text, i: 0 }
45}
46
47/// Iterator returned by [`scan_inline_links`].
48pub struct InlineLinks<'a> {
49    text: &'a str,
50    i: usize,
51}
52
53impl<'a> Iterator for InlineLinks<'a> {
54    type Item = InlineLink<'a>;
55
56    fn next(&mut self) -> Option<Self::Item> {
57        let bytes = self.text.as_bytes();
58        while self.i < bytes.len() {
59            let i = self.i;
60            // [[wikilink]] — tried first so a `[[…]]` is never mistaken for a
61            // markdown link opened by its first bracket.
62            if i + 1 < bytes.len()
63                && bytes[i] == b'['
64                && bytes[i + 1] == b'['
65                && let Some(end) = find_closing_double_bracket(bytes, i + 2)
66            {
67                let target = self.text[i + 2..end].trim();
68                if !target.is_empty() {
69                    self.i = end + 2;
70                    return Some(InlineLink {
71                        kind: LinkKind::Wiki,
72                        label: target,
73                        target,
74                        range: i..end + 2,
75                    });
76                }
77            }
78            // [label](target)
79            if bytes[i] == b'['
80                && let Some(label_end) = find_byte(bytes, b']', i + 1)
81                && label_end + 1 < bytes.len()
82                && bytes[label_end + 1] == b'('
83                && let Some(target_end) = find_byte(bytes, b')', label_end + 2)
84            {
85                let target = self.text[label_end + 2..target_end].trim();
86                if !target.is_empty() {
87                    self.i = target_end + 1;
88                    return Some(InlineLink {
89                        kind: LinkKind::Markdown,
90                        label: &self.text[i + 1..label_end],
91                        target,
92                        range: i..target_end + 1,
93                    });
94                }
95            }
96            self.i += 1;
97        }
98        None
99    }
100}
101
102/// Rewrites the inline links in `text`, leaving everything else byte-for-byte
103/// intact.
104///
105/// For each link, `rewrite(kind, target)` may return `Some(new_target)` to
106/// replace it — rendered as `[label](new_target)`, where wiki links use their
107/// target as the label — or `None` to leave the original span verbatim
108/// (preserving any internal whitespace).
109pub fn rewrite_inline_links(
110    text: &str,
111    mut rewrite: impl FnMut(LinkKind, &str) -> Option<String>,
112) -> String {
113    let mut out = String::with_capacity(text.len());
114    let mut cursor = 0;
115    for link in scan_inline_links(text) {
116        out.push_str(&text[cursor..link.range.start]);
117        match rewrite(link.kind, link.target) {
118            Some(new_target) => {
119                out.push('[');
120                out.push_str(link.label);
121                out.push_str("](");
122                out.push_str(&new_target);
123                out.push(')');
124            }
125            None => out.push_str(&text[link.range.clone()]),
126        }
127        cursor = link.range.end;
128    }
129    out.push_str(&text[cursor..]);
130    out
131}
132
133fn find_closing_double_bracket(bytes: &[u8], start: usize) -> Option<usize> {
134    let mut i = start;
135    while i + 1 < bytes.len() {
136        if bytes[i] == b']' && bytes[i + 1] == b']' {
137            return Some(i);
138        }
139        i += 1;
140    }
141    None
142}
143
144fn find_byte(bytes: &[u8], target: u8, start: usize) -> Option<usize> {
145    (start..bytes.len()).find(|&i| bytes[i] == target)
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    fn scan(text: &str) -> Vec<InlineLink<'_>> {
153        scan_inline_links(text).collect()
154    }
155
156    #[test]
157    fn scans_wiki_kind_label_target_range() {
158        let links = scan("see [[My Page]] here");
159        assert_eq!(links.len(), 1);
160        let link = &links[0];
161        assert_eq!(link.kind, LinkKind::Wiki);
162        assert_eq!(link.label, "My Page");
163        assert_eq!(link.target, "My Page");
164        // "see " is 4 bytes; "[[My Page]]" is 11 bytes
165        assert_eq!(link.range, 4..15);
166        assert_eq!(&"see [[My Page]] here"[link.range.clone()], "[[My Page]]");
167    }
168
169    #[test]
170    fn scans_markdown_kind_label_target_range() {
171        let links = scan("a [label](page:abc) b");
172        assert_eq!(links.len(), 1);
173        let link = &links[0];
174        assert_eq!(link.kind, LinkKind::Markdown);
175        assert_eq!(link.label, "label");
176        assert_eq!(link.target, "page:abc");
177        assert_eq!(link.range, 2..19);
178        assert_eq!(
179            &"a [label](page:abc) b"[link.range.clone()],
180            "[label](page:abc)"
181        );
182    }
183
184    #[test]
185    fn scans_mixed_in_source_order() {
186        let links = scan("see [[wiki]] and [md](target) done");
187        assert_eq!(links.len(), 2);
188        assert_eq!(links[0].kind, LinkKind::Wiki);
189        assert_eq!(links[0].target, "wiki");
190        assert_eq!(links[1].kind, LinkKind::Markdown);
191        assert_eq!(links[1].label, "md");
192        assert_eq!(links[1].target, "target");
193    }
194
195    #[test]
196    fn target_is_trimmed_but_range_covers_whitespace() {
197        let links = scan("[label](  url  )");
198        assert_eq!(links.len(), 1);
199        assert_eq!(links[0].target, "url");
200        assert_eq!(links[0].range, 0..16);
201    }
202
203    #[test]
204    fn empty_targets_are_skipped() {
205        assert!(scan("[]()").is_empty());
206        assert!(scan("[[]]").is_empty());
207        assert!(scan("[label](   )").is_empty());
208        assert!(scan("[[   ]]").is_empty());
209    }
210
211    #[test]
212    fn wiki_takes_precedence_over_markdown() {
213        // A complete [[…]] is reported as Wiki even when it also contains `](`.
214        let links = scan("[[x](y)]]");
215        assert_eq!(links.len(), 1);
216        assert_eq!(links[0].kind, LinkKind::Wiki);
217        assert_eq!(links[0].target, "x](y)");
218    }
219
220    #[test]
221    fn unclosed_wiki_falls_through_to_markdown() {
222        // No closing `]]`, so the leading `[` opens a markdown link instead.
223        let links = scan("[[text](url)");
224        assert_eq!(links.len(), 1);
225        assert_eq!(links[0].kind, LinkKind::Markdown);
226        assert_eq!(links[0].label, "[text");
227        assert_eq!(links[0].target, "url");
228    }
229
230    #[test]
231    fn unicode_content_in_labels_and_targets() {
232        let links = scan("[名前](ページ) [[日本語]]");
233        assert_eq!(links.len(), 2);
234        assert_eq!(links[0].target, "ページ");
235        assert_eq!(links[1].target, "日本語");
236    }
237
238    #[test]
239    fn rewrite_replaces_with_callback_target() {
240        let out = rewrite_inline_links("see [a](b) and [[c]]", |kind, target| match kind {
241            LinkKind::Markdown => Some(format!("md:{target}")),
242            LinkKind::Wiki => Some(format!("wiki:{target}")),
243        });
244        assert_eq!(out, "see [a](md:b) and [c](wiki:c)");
245    }
246
247    #[test]
248    fn rewrite_none_leaves_span_verbatim() {
249        // Whitespace inside an untouched span is preserved exactly.
250        let out = rewrite_inline_links("[a](  keep  ) and [[ keep ]]", |_, _| None);
251        assert_eq!(out, "[a](  keep  ) and [[ keep ]]");
252    }
253
254    #[test]
255    fn rewrite_callback_sees_kind_and_trimmed_target() {
256        let mut seen = Vec::new();
257        let _ = rewrite_inline_links("[a]( b ) [[ c ]]", |kind, target| {
258            seen.push((kind, target.to_string()));
259            None
260        });
261        assert_eq!(
262            seen,
263            vec![
264                (LinkKind::Markdown, "b".to_string()),
265                (LinkKind::Wiki, "c".to_string()),
266            ]
267        );
268    }
269
270    #[test]
271    fn rewrite_is_identity_on_plain_text() {
272        let out = rewrite_inline_links("no links here at all", |_, _| Some("x".to_string()));
273        assert_eq!(out, "no links here at all");
274    }
275}