Skip to main content

panache_parser/parser/inlines/
citations.rs

1//! Citation parsing for Pandoc's citations extension.
2//!
3//! Syntax:
4//! - Bracketed: `[@doe99]`, `[@doe99; @smith2000]`
5//! - With locator: `[see @doe99, pp. 33-35]`
6//! - Suppress author: `[-@doe99]`
7//! - Author-in-text: `@doe99` (bare, without brackets)
8
9use super::sink::InlineSink;
10use crate::syntax::SyntaxKind;
11
12/// Try to parse a bracketed citation starting at the current position.
13/// Returns Some((length, content)) if successful, None otherwise.
14///
15/// Bracketed citations have the syntax: [@key], [@key1; @key2], [see @key, pp. 1-10]
16pub(crate) fn try_parse_bracketed_citation(text: &str) -> Option<(usize, &str)> {
17    let bytes = text.as_bytes();
18
19    // Must start with [
20    if bytes.is_empty() || bytes[0] != b'[' {
21        return None;
22    }
23
24    // Look ahead to see if this contains a citation marker (@)
25    // We need to distinguish from regular links
26    let mut has_citation = false;
27    let mut pos = 1;
28    let mut bracket_depth = 0;
29
30    while pos < bytes.len() {
31        match bytes[pos] {
32            b'\\' => {
33                // Skip escaped character
34                pos += 2;
35                continue;
36            }
37            b'`' => {
38                // Skip verbatim code spans; markers inside don't count.
39                match code_span_end(bytes, pos) {
40                    Some(end) => pos = end,
41                    None => pos += 1,
42                }
43            }
44            b'[' => {
45                bracket_depth += 1;
46                pos += 1;
47            }
48            b']' => {
49                if bracket_depth == 0 {
50                    // Closing bracket of main citation - stop looking
51                    break;
52                }
53                bracket_depth -= 1;
54                pos += 1;
55                // A nested bracket group directly followed by `(dest)` is an
56                // inline link/image; its destination is verbatim, so `@`
57                // inside it must not count (pandoc parses
58                // `[![alt](https://x.io/@scope)](url)` as a link, not a
59                // citation).
60                if pos < bytes.len()
61                    && bytes[pos] == b'('
62                    && let Some(end) = inline_destination_end(bytes, pos)
63                {
64                    pos = end;
65                }
66            }
67            b'@' => {
68                // Found a citation marker - this is likely a citation
69                has_citation = true;
70                break;
71            }
72            _ => {
73                pos += 1;
74            }
75        }
76    }
77
78    if !has_citation {
79        return None;
80    }
81
82    // Now find the closing bracket
83    pos = 1;
84    bracket_depth = 1;
85
86    while pos < bytes.len() {
87        match bytes[pos] {
88            b'\\' => {
89                // Skip escaped character
90                pos += 2;
91                continue;
92            }
93            b'`' => {
94                // Skip verbatim code spans; brackets inside don't close.
95                match code_span_end(bytes, pos) {
96                    Some(end) => pos = end,
97                    None => pos += 1,
98                }
99            }
100            b'[' => {
101                bracket_depth += 1;
102                pos += 1;
103            }
104            b']' => {
105                bracket_depth -= 1;
106                if bracket_depth == 0 {
107                    // Found the closing bracket
108                    let content = &text[1..pos];
109                    return Some((pos + 1, content));
110                }
111                pos += 1;
112                // Skip nested link/image destinations so a `]` inside them
113                // does not terminate the citation bracket.
114                if pos < bytes.len()
115                    && bytes[pos] == b'('
116                    && let Some(end) = inline_destination_end(bytes, pos)
117                {
118                    pos = end;
119                }
120            }
121            _ => {
122                pos += 1;
123            }
124        }
125    }
126
127    // No closing bracket found
128    None
129}
130
131/// Try to parse a bare citation (author-in-text) starting at the current position.
132/// Returns Some((length, key, has_suppress)) if successful, None otherwise.
133///
134/// Bare citations have the syntax: @key or -@key
135pub(crate) fn try_parse_bare_citation(text: &str) -> Option<(usize, &str, bool)> {
136    let bytes = text.as_bytes();
137
138    if bytes.is_empty() {
139        return None;
140    }
141
142    let mut pos = 0;
143    let has_suppress = bytes[pos] == b'-';
144
145    if has_suppress {
146        pos += 1;
147        if pos >= bytes.len() {
148            return None;
149        }
150    }
151
152    // Must have @ next
153    if bytes[pos] != b'@' {
154        return None;
155    }
156    pos += 1;
157
158    if pos >= bytes.len() {
159        return None;
160    }
161
162    // Parse the citation key
163    let key_start = pos;
164    let key_len = parse_citation_key(&text[pos..])?;
165
166    if key_len == 0 {
167        return None;
168    }
169
170    let total_len = pos + key_len;
171    let key = &text[key_start..total_len];
172
173    Some((total_len, key, has_suppress))
174}
175
176/// Try to parse a Quarto cross-reference key (e.g., @fig-plot, @eq-energy).
177pub fn is_quarto_crossref_key(key: &str) -> bool {
178    let lower = key.to_ascii_lowercase();
179    let mut parts = lower.splitn(2, '-');
180    let prefix = parts.next().unwrap_or("");
181    let rest = parts.next().unwrap_or("");
182    if rest.is_empty() {
183        return false;
184    }
185    matches!(
186        prefix,
187        "fig"
188            | "tbl"
189            | "lst"
190            | "tip"
191            | "nte"
192            | "wrn"
193            | "imp"
194            | "cau"
195            | "thm"
196            | "lem"
197            | "cor"
198            | "prp"
199            | "cnj"
200            | "def"
201            | "exm"
202            | "exr"
203            | "sol"
204            | "rem"
205            | "alg"
206            | "eq"
207            | "sec"
208    )
209}
210
211/// Like [`is_quarto_crossref_key`], but also accepts any key whose prefix
212/// appears in `custom_prefixes`. Used to recognize cross-reference prefixes
213/// injected by Quarto extensions (e.g. pseudocode's `@algo-`) that aren't
214/// built in. Matching is case-insensitive on the prefix, consistent with the
215/// built-in check.
216pub fn is_crossref_key(key: &str, custom_prefixes: &[String]) -> bool {
217    is_quarto_crossref_key(key) || has_custom_crossref_prefix(key, custom_prefixes)
218}
219
220/// Whether `key`'s prefix (the segment before the first `-`) appears in
221/// `custom_prefixes`. Unlike [`is_quarto_crossref_key`], this matches *only*
222/// the configured extension prefixes, so callers can tell an extension-injected
223/// cross-reference (whose target panache can't resolve) apart from a built-in
224/// one (whose target it can and should validate).
225pub fn has_custom_crossref_prefix(key: &str, custom_prefixes: &[String]) -> bool {
226    if custom_prefixes.is_empty() {
227        return false;
228    }
229    let lower = key.to_ascii_lowercase();
230    let mut parts = lower.splitn(2, '-');
231    let prefix = parts.next().unwrap_or("");
232    let rest = parts.next().unwrap_or("");
233    if rest.is_empty() {
234        return false;
235    }
236    custom_prefixes
237        .iter()
238        .any(|candidate| candidate.eq_ignore_ascii_case(prefix))
239}
240
241pub const BOOKDOWN_LABEL_PREFIXES: &[&str] = &[
242    "eq", "fig", "tab", "thm", "lem", "cor", "prp", "cnj", "def", "exm", "exr", "sol", "rem",
243    "alg", "sec", "hyp",
244];
245
246pub fn is_bookdown_label(label: &str) -> bool {
247    BOOKDOWN_LABEL_PREFIXES.contains(&label)
248}
249
250pub fn has_bookdown_prefix(label: &str) -> bool {
251    let mut parts = label.splitn(2, ':');
252    let prefix = parts.next().unwrap_or("");
253    let rest = parts.next().unwrap_or("");
254    if rest.is_empty() {
255        return false;
256    }
257    is_bookdown_label(prefix)
258}
259
260pub(crate) fn emit_crossref(builder: &mut impl InlineSink, key: &str, has_suppress: bool) {
261    builder.start_node(SyntaxKind::CROSSREF.into());
262
263    if has_suppress {
264        builder.token(SyntaxKind::CROSSREF_MARKER.into(), "-@");
265    } else {
266        builder.token(SyntaxKind::CROSSREF_MARKER.into(), "@");
267    }
268
269    if key.starts_with('{') && key.ends_with('}') {
270        builder.token(SyntaxKind::CROSSREF_BRACE_OPEN.into(), "{");
271        builder.token(SyntaxKind::CROSSREF_KEY.into(), &key[1..key.len() - 1]);
272        builder.token(SyntaxKind::CROSSREF_BRACE_CLOSE.into(), "}");
273    } else {
274        builder.token(SyntaxKind::CROSSREF_KEY.into(), key);
275    }
276
277    builder.finish_node();
278}
279
280pub(crate) fn emit_bookdown_crossref(builder: &mut impl InlineSink, key: &str) {
281    builder.start_node(SyntaxKind::CROSSREF.into());
282    builder.token(SyntaxKind::CROSSREF_BOOKDOWN_OPEN.into(), "\\@ref(");
283    builder.token(SyntaxKind::CROSSREF_KEY.into(), key);
284    builder.token(SyntaxKind::CROSSREF_BOOKDOWN_CLOSE.into(), ")");
285    builder.finish_node();
286}
287
288/// Parse a citation key following Pandoc's rules.
289/// Returns the length of the key, or None if invalid.
290///
291/// Citation keys:
292/// - Must start with letter, digit, or _
293/// - Can contain alphanumerics and single internal punctuation: :.#$%&-+?<>~/
294/// - Keys in braces @{...} can contain anything
295/// - Double internal punctuation terminates key
296/// - Trailing punctuation not included
297fn parse_citation_key(text: &str) -> Option<usize> {
298    if text.is_empty() {
299        return None;
300    }
301
302    // Check for braced key: @{...}
303    if text.starts_with('{') {
304        // Find matching closing brace
305        let mut escape_next = false;
306
307        for (idx, ch) in text.char_indices().skip(1) {
308            if escape_next {
309                escape_next = false;
310                continue;
311            }
312
313            match ch {
314                '\\' => escape_next = true,
315                '}' => return Some(idx + ch.len_utf8()),
316                _ => {}
317            }
318        }
319
320        // No closing brace found
321        return None;
322    }
323
324    // Regular key: must start with letter, digit, or _
325    let mut iter = text.char_indices();
326    let (_, first_char) = iter.next()?;
327    if !first_char.is_alphanumeric() && first_char != '_' {
328        return None;
329    }
330
331    let mut last_alnum_end = first_char.len_utf8();
332    let mut last_included_end = last_alnum_end;
333    let mut last_punct_start: Option<usize> = None;
334    let mut prev_was_punct = false;
335
336    for (idx, ch) in iter {
337        if ch.is_alphanumeric() || ch == '_' {
338            prev_was_punct = false;
339            last_alnum_end = idx + ch.len_utf8();
340            last_included_end = last_alnum_end;
341            last_punct_start = None;
342        } else if is_internal_punctuation(ch) {
343            // Check if previous was also punctuation (double punct terminates)
344            if prev_was_punct {
345                // Double punctuation - terminate before the first punctuation
346                return Some(last_punct_start.unwrap_or(last_alnum_end));
347            }
348            prev_was_punct = true;
349            last_punct_start = Some(idx);
350            last_included_end = idx + ch.len_utf8();
351        } else {
352            // Not a valid key character - terminate here
353            break;
354        }
355    }
356
357    if prev_was_punct {
358        return Some(last_alnum_end);
359    }
360
361    if last_included_end == 0 {
362        None
363    } else {
364        Some(last_included_end)
365    }
366}
367
368/// If `bytes[pos]` begins a backtick code-span opener, return the index just
369/// past the matching closing run. Returns `None` when there is no closing run
370/// of equal length, in which case the backticks are literal text.
371///
372/// Code spans are verbatim, so citation markers (`@`), separators (`;`), and
373/// brackets (`]`) inside them must not influence citation detection — this
374/// matches pandoc, which parses `` [`@foo`] `` as a link, not a citation.
375fn code_span_end(bytes: &[u8], pos: usize) -> Option<usize> {
376    let mut open_end = pos;
377    while open_end < bytes.len() && bytes[open_end] == b'`' {
378        open_end += 1;
379    }
380    let run = open_end - pos;
381
382    let mut i = open_end;
383    while i < bytes.len() {
384        if bytes[i] == b'`' {
385            let close_start = i;
386            while i < bytes.len() && bytes[i] == b'`' {
387                i += 1;
388            }
389            if i - close_start == run {
390                return Some(i);
391            }
392        } else {
393            i += 1;
394        }
395    }
396
397    None
398}
399
400/// Given `bytes[pos] == b'('` immediately after a `]` that closed a nested
401/// bracket group, return the index just past the closing `)` when the group
402/// parses like an inline link/image destination: an optional angle-bracketed
403/// or bare (space-free, balanced-paren) destination followed by an optional
404/// quoted title. Returns `None` when the group is not destination-shaped, in
405/// which case its bytes stay visible to citation detection.
406fn inline_destination_end(bytes: &[u8], pos: usize) -> Option<usize> {
407    let mut i = pos + 1;
408    while i < bytes.len() && matches!(bytes[i], b' ' | b'\t') {
409        i += 1;
410    }
411    if i < bytes.len() && bytes[i] == b'<' {
412        // Angle-bracketed destination: runs to the closing `>`.
413        i += 1;
414        while i < bytes.len() && bytes[i] != b'>' {
415            i += if bytes[i] == b'\\' { 2 } else { 1 };
416        }
417        if i >= bytes.len() {
418            return None;
419        }
420        i += 1;
421    } else {
422        // Bare destination: no whitespace, balanced parens. A `]` is allowed
423        // here (pandoc links `[x](a]b)`).
424        let mut depth = 0usize;
425        while i < bytes.len() {
426            match bytes[i] {
427                b'\\' => i += 2,
428                b'(' => {
429                    depth += 1;
430                    i += 1;
431                }
432                b')' if depth == 0 => break,
433                b')' => {
434                    depth -= 1;
435                    i += 1;
436                }
437                b' ' | b'\t' | b'\n' => break,
438                _ => i += 1,
439            }
440        }
441    }
442    // Optional whitespace, which must introduce a quoted title or end the
443    // group; a bare space inside the parens is not a valid destination.
444    let mut j = i;
445    while j < bytes.len() && matches!(bytes[j], b' ' | b'\t' | b'\n') {
446        j += 1;
447    }
448    if j < bytes.len() && (bytes[j] == b'"' || bytes[j] == b'\'') {
449        let quote = bytes[j];
450        j += 1;
451        while j < bytes.len() && bytes[j] != quote {
452            j += if bytes[j] == b'\\' { 2 } else { 1 };
453        }
454        if j >= bytes.len() {
455            return None;
456        }
457        j += 1;
458        while j < bytes.len() && matches!(bytes[j], b' ' | b'\t' | b'\n') {
459            j += 1;
460        }
461    }
462    (j < bytes.len() && bytes[j] == b')').then(|| j + 1)
463}
464
465/// Check if a character is valid internal punctuation in citation keys.
466fn is_internal_punctuation(ch: char) -> bool {
467    matches!(
468        ch,
469        ':' | '.' | '#' | '$' | '%' | '&' | '-' | '+' | '?' | '<' | '>' | '~' | '/'
470    )
471}
472
473/// Emit a bracketed citation node to the builder.
474pub(crate) fn emit_bracketed_citation(builder: &mut impl InlineSink, content: &str) {
475    builder.start_node(SyntaxKind::CITATION.into());
476
477    // Opening bracket
478    builder.token(SyntaxKind::LINK_START.into(), "[");
479
480    // Emit prefix + citations + suffix with fine-grained tokens.
481    emit_bracketed_citation_content(builder, content);
482
483    // Closing bracket
484    builder.token(SyntaxKind::LINK_DEST.into(), "]");
485
486    builder.finish_node();
487}
488
489fn emit_bracketed_citation_content(builder: &mut impl InlineSink, content: &str) {
490    let mut text_start = 0;
491    let mut iter = content.char_indices().peekable();
492
493    while let Some((idx, ch)) = iter.next() {
494        // Backslash escapes (e.g. `\@`, `\[`, `\]`) suppress citation/separator
495        // recognition for the following character — matching Pandoc, which
496        // treats the escape as a literal in the citation prefix/suffix.
497        if ch == '\\' {
498            iter.next();
499            continue;
500        }
501
502        if ch == '`'
503            && let Some(end) = code_span_end(content.as_bytes(), idx)
504        {
505            // Verbatim code span: leave its bytes in the pending
506            // CITATION_CONTENT run and skip markers/separators inside it.
507            while matches!(iter.peek(), Some((next_idx, _)) if *next_idx < end) {
508                iter.next();
509            }
510            continue;
511        }
512
513        if ch == '@' || (ch == '-' && matches!(iter.peek(), Some((_, '@')))) {
514            if idx > text_start {
515                builder.token(
516                    SyntaxKind::CITATION_CONTENT.into(),
517                    &content[text_start..idx],
518                );
519            }
520
521            let mut marker_len = 1;
522            let marker_text = if ch == '-' {
523                iter.next();
524                marker_len = 2;
525                "-@"
526            } else {
527                "@"
528            };
529            builder.token(SyntaxKind::CITATION_MARKER.into(), marker_text);
530
531            let key_start = idx + marker_len;
532            if key_start >= content.len() {
533                text_start = key_start;
534                continue;
535            }
536
537            if let Some(key_len) = parse_citation_key(&content[key_start..]) {
538                let key_end = key_start + key_len;
539                let key = &content[key_start..key_end];
540                if key.starts_with('{') && key.ends_with('}') {
541                    builder.token(SyntaxKind::CITATION_BRACE_OPEN.into(), "{");
542                    if key.len() > 2 {
543                        builder.token(SyntaxKind::CITATION_KEY.into(), &key[1..key.len() - 1]);
544                    }
545                    builder.token(SyntaxKind::CITATION_BRACE_CLOSE.into(), "}");
546                } else {
547                    builder.token(SyntaxKind::CITATION_KEY.into(), key);
548                }
549                while matches!(iter.peek(), Some((next_idx, _)) if *next_idx < key_end) {
550                    iter.next();
551                }
552                text_start = key_end;
553                continue;
554            }
555
556            text_start = key_start;
557            continue;
558        }
559
560        if ch == ';' {
561            if idx > text_start {
562                builder.token(
563                    SyntaxKind::CITATION_CONTENT.into(),
564                    &content[text_start..idx],
565                );
566            }
567            builder.token(SyntaxKind::CITATION_SEPARATOR.into(), ";");
568            text_start = idx + ch.len_utf8();
569            continue;
570        }
571    }
572
573    if text_start < content.len() {
574        builder.token(SyntaxKind::CITATION_CONTENT.into(), &content[text_start..]);
575    }
576}
577
578/// Emit a bare citation node to the builder.
579pub(crate) fn emit_bare_citation(builder: &mut impl InlineSink, key: &str, has_suppress: bool) {
580    builder.start_node(SyntaxKind::CITATION.into());
581
582    // Emit marker (@ or -@)
583    if has_suppress {
584        builder.token(SyntaxKind::CITATION_MARKER.into(), "-@");
585    } else {
586        builder.token(SyntaxKind::CITATION_MARKER.into(), "@");
587    }
588
589    // Check if key is braced
590    if key.starts_with('{') && key.ends_with('}') {
591        builder.token(SyntaxKind::CITATION_BRACE_OPEN.into(), "{");
592        builder.token(SyntaxKind::CITATION_KEY.into(), &key[1..key.len() - 1]);
593        builder.token(SyntaxKind::CITATION_BRACE_CLOSE.into(), "}");
594    } else {
595        builder.token(SyntaxKind::CITATION_KEY.into(), key);
596    }
597
598    builder.finish_node();
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    // Citation key parsing tests
606    #[test]
607    fn test_parse_simple_citation_key() {
608        assert_eq!(parse_citation_key("doe99"), Some(5));
609        assert_eq!(parse_citation_key("smith2000"), Some(9));
610    }
611
612    #[test]
613    fn test_parse_citation_key_with_internal_punct() {
614        assert_eq!(parse_citation_key("Foo_bar.baz"), Some(11));
615        assert_eq!(parse_citation_key("author:2020"), Some(11));
616    }
617
618    #[test]
619    fn test_parse_citation_key_trailing_punct() {
620        // Trailing punctuation should be excluded
621        assert_eq!(parse_citation_key("Foo_bar.baz."), Some(11));
622        assert_eq!(parse_citation_key("key:value:"), Some(9));
623    }
624
625    #[test]
626    fn test_parse_citation_key_double_punct() {
627        // Double punctuation terminates key
628        assert_eq!(parse_citation_key("Foo_bar--baz"), Some(7)); // key is "Foo_bar"
629    }
630
631    #[test]
632    fn test_parse_citation_key_with_braces() {
633        assert_eq!(parse_citation_key("{https://example.com}"), Some(21));
634        assert_eq!(parse_citation_key("{Foo_bar.baz.}"), Some(14));
635    }
636
637    #[test]
638    fn test_parse_citation_key_invalid_start() {
639        assert_eq!(parse_citation_key(".invalid"), None);
640        assert_eq!(parse_citation_key(":invalid"), None);
641    }
642
643    #[test]
644    fn test_parse_citation_key_stops_at_space() {
645        assert_eq!(parse_citation_key("key rest"), Some(3));
646    }
647
648    #[test]
649    fn is_crossref_key_accepts_builtin_without_custom() {
650        assert!(is_crossref_key("fig-plot", &[]));
651        assert!(!is_crossref_key("algo-cd", &[]));
652    }
653
654    #[test]
655    fn is_crossref_key_accepts_custom_prefix() {
656        let custom = vec!["algo".to_string()];
657        assert!(is_crossref_key("algo-cd", &custom));
658        // Case-insensitive on the prefix, consistent with the built-in check.
659        assert!(is_crossref_key("ALGO-cd", &custom));
660        // Built-ins still match alongside the custom set.
661        assert!(is_crossref_key("tbl-x", &custom));
662        // A bare prefix with no `-suffix` is not a crossref.
663        assert!(!is_crossref_key("algo", &custom));
664        // Unrelated prefixes remain citations.
665        assert!(!is_crossref_key("doe99", &custom));
666    }
667
668    // Bare citation parsing tests
669    #[test]
670    fn test_parse_bare_citation_simple() {
671        let result = try_parse_bare_citation("@doe99");
672        assert_eq!(result, Some((6, "doe99", false)));
673    }
674
675    #[test]
676    fn test_parse_bare_citation_with_suppress() {
677        let result = try_parse_bare_citation("-@smith04");
678        assert_eq!(result, Some((9, "smith04", true)));
679    }
680
681    #[test]
682    fn test_parse_bare_citation_with_trailing_text() {
683        let result = try_parse_bare_citation("@doe99 says");
684        assert_eq!(result, Some((6, "doe99", false)));
685    }
686
687    #[test]
688    fn test_parse_bare_citation_braced_key() {
689        let result = try_parse_bare_citation("@{https://example.com}");
690        assert_eq!(result, Some((22, "{https://example.com}", false)));
691    }
692
693    #[test]
694    fn test_parse_bare_citation_not_citation() {
695        assert_eq!(try_parse_bare_citation("not a citation"), None);
696        assert_eq!(try_parse_bare_citation("@"), None);
697    }
698
699    // Bracketed citation parsing tests
700    #[test]
701    fn test_parse_bracketed_citation_simple() {
702        let result = try_parse_bracketed_citation("[@doe99]");
703        assert_eq!(result, Some((8, "@doe99")));
704    }
705
706    #[test]
707    fn test_parse_bracketed_citation_multiple() {
708        let result = try_parse_bracketed_citation("[@doe99; @smith2000]");
709        assert_eq!(result, Some((20, "@doe99; @smith2000")));
710    }
711
712    #[test]
713    fn test_parse_bracketed_citation_with_prefix() {
714        let result = try_parse_bracketed_citation("[see @doe99]");
715        assert_eq!(result, Some((12, "see @doe99")));
716    }
717
718    #[test]
719    fn test_parse_bracketed_citation_with_locator() {
720        let result = try_parse_bracketed_citation("[@doe99, pp. 33-35]");
721        assert_eq!(result, Some((19, "@doe99, pp. 33-35")));
722    }
723
724    #[test]
725    fn test_parse_bracketed_citation_complex() {
726        let result = try_parse_bracketed_citation("[see @doe99, pp. 33-35 and *passim*]");
727        assert_eq!(result, Some((36, "see @doe99, pp. 33-35 and *passim*")));
728    }
729
730    #[test]
731    fn test_parse_bracketed_citation_with_suppress() {
732        let result = try_parse_bracketed_citation("[-@doe99]");
733        assert_eq!(result, Some((9, "-@doe99")));
734    }
735
736    #[test]
737    fn test_parse_bracketed_citation_not_citation() {
738        // Regular link should not be parsed as citation
739        assert_eq!(try_parse_bracketed_citation("[text](url)"), None);
740        assert_eq!(try_parse_bracketed_citation("[just text]"), None);
741    }
742
743    #[test]
744    fn test_parse_bracketed_citation_nested_brackets() {
745        let result = try_parse_bracketed_citation("[see [nested] @doe99]");
746        assert_eq!(result, Some((21, "see [nested] @doe99")));
747    }
748
749    #[test]
750    fn test_parse_bracketed_citation_escaped_bracket() {
751        let result = try_parse_bracketed_citation(r"[@doe99 with \] escaped]");
752        assert_eq!(result, Some((24, r"@doe99 with \] escaped")));
753    }
754
755    #[test]
756    fn test_parse_bracketed_citation_paren_in_prefix() {
757        // Pandoc treats parens in the citation prefix as ordinary text;
758        // they must not abort citation detection.
759        let result = try_parse_bracketed_citation("[see (Smith 1999) and @doe99]");
760        assert_eq!(result, Some((29, "see (Smith 1999) and @doe99")));
761    }
762
763    #[test]
764    fn test_bracketed_citation_ignores_at_in_code_span() {
765        // `@foo` inside a code span is verbatim, so [`@foo`] is a link label,
766        // not a citation (matches pandoc).
767        assert_eq!(try_parse_bracketed_citation("[`@foo`]"), None);
768    }
769
770    #[test]
771    fn test_bracketed_citation_code_span_in_prefix() {
772        // A code span may appear in the citation prefix; @ inside it is verbatim
773        // and the real @key follows.
774        assert_eq!(
775            try_parse_bracketed_citation("[`x@y` @doe99]"),
776            Some((14, "`x@y` @doe99"))
777        );
778    }
779
780    #[test]
781    fn test_bracketed_citation_bracket_in_code_span() {
782        // A `]` inside a code span does not terminate the bracket.
783        assert_eq!(
784            try_parse_bracketed_citation("[`a]b` @doe99]"),
785            Some((14, "`a]b` @doe99"))
786        );
787    }
788
789    #[test]
790    fn test_bracketed_citation_unterminated_backtick() {
791        // An unterminated backtick run is literal, so @foo is still a citation.
792        assert_eq!(
793            try_parse_bracketed_citation("[`@foo bar]"),
794            Some((11, "`@foo bar"))
795        );
796    }
797
798    #[test]
799    fn test_bracketed_citation_ignores_at_in_nested_image_url() {
800        // The @ lives in the image destination, so the outer bracket is a link
801        // label, not a citation (matches pandoc's Link [ Image ... ] parse).
802        assert_eq!(
803            try_parse_bracketed_citation(
804                "[![npm version](https://badge.fury.io/js/@arity-cli%2Farity-cli.svg?icon=si%3Anpm)]"
805            ),
806            None
807        );
808    }
809
810    #[test]
811    fn test_bracketed_citation_ignores_at_in_nested_link_url() {
812        assert_eq!(
813            try_parse_bracketed_citation("[a [link](https://x.io/@scope) here]"),
814            None
815        );
816    }
817
818    #[test]
819    fn test_bracketed_citation_marker_after_nested_link_url() {
820        // A nested link's destination is skipped, but a top-level @key after
821        // it still makes the bracket a citation (matches pandoc).
822        assert_eq!(
823            try_parse_bracketed_citation("[see [foo](url@x) and @bar]"),
824            Some((27, "see [foo](url@x) and @bar"))
825        );
826    }
827
828    #[test]
829    fn test_parse_bracketed_citation_escaped_at_in_prefix() {
830        // Pandoc accepts \@ref(label) inside the citation prefix without
831        // mistaking it for a citation marker; the actual citation is the
832        // unescaped @key that follows.
833        let result =
834            try_parse_bracketed_citation(r"[see also \@ref(svm) and @bischl_applied_2024]");
835        assert_eq!(
836            result,
837            Some((46, r"see also \@ref(svm) and @bischl_applied_2024"))
838        );
839    }
840}