1use super::sink::InlineSink;
10use crate::syntax::SyntaxKind;
11
12pub(crate) fn try_parse_bracketed_citation(text: &str) -> Option<(usize, &str)> {
17 let bytes = text.as_bytes();
18
19 if bytes.is_empty() || bytes[0] != b'[' {
21 return None;
22 }
23
24 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 pos += 2;
35 continue;
36 }
37 b'`' => {
38 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 break;
52 }
53 bracket_depth -= 1;
54 pos += 1;
55 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 has_citation = true;
70 break;
71 }
72 _ => {
73 pos += 1;
74 }
75 }
76 }
77
78 if !has_citation {
79 return None;
80 }
81
82 pos = 1;
84 bracket_depth = 1;
85
86 while pos < bytes.len() {
87 match bytes[pos] {
88 b'\\' => {
89 pos += 2;
91 continue;
92 }
93 b'`' => {
94 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 let content = &text[1..pos];
109 return Some((pos + 1, content));
110 }
111 pos += 1;
112 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 None
129}
130
131pub(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 if bytes[pos] != b'@' {
154 return None;
155 }
156 pos += 1;
157
158 if pos >= bytes.len() {
159 return None;
160 }
161
162 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
176pub 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
211pub 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
220pub 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
288fn parse_citation_key(text: &str) -> Option<usize> {
298 if text.is_empty() {
299 return None;
300 }
301
302 if text.starts_with('{') {
304 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 return None;
322 }
323
324 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 if prev_was_punct {
345 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 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
368fn 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
400fn 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 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 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 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
465fn is_internal_punctuation(ch: char) -> bool {
467 matches!(
468 ch,
469 ':' | '.' | '#' | '$' | '%' | '&' | '-' | '+' | '?' | '<' | '>' | '~' | '/'
470 )
471}
472
473pub(crate) fn emit_bracketed_citation(builder: &mut impl InlineSink, content: &str) {
475 builder.start_node(SyntaxKind::CITATION.into());
476
477 builder.token(SyntaxKind::LINK_START.into(), "[");
479
480 emit_bracketed_citation_content(builder, content);
482
483 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 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 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
578pub(crate) fn emit_bare_citation(builder: &mut impl InlineSink, key: &str, has_suppress: bool) {
580 builder.start_node(SyntaxKind::CITATION.into());
581
582 if has_suppress {
584 builder.token(SyntaxKind::CITATION_MARKER.into(), "-@");
585 } else {
586 builder.token(SyntaxKind::CITATION_MARKER.into(), "@");
587 }
588
589 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 #[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 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 assert_eq!(parse_citation_key("Foo_bar--baz"), Some(7)); }
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 assert!(is_crossref_key("ALGO-cd", &custom));
660 assert!(is_crossref_key("tbl-x", &custom));
662 assert!(!is_crossref_key("algo", &custom));
664 assert!(!is_crossref_key("doe99", &custom));
666 }
667
668 #[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 #[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 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 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 assert_eq!(try_parse_bracketed_citation("[`@foo`]"), None);
768 }
769
770 #[test]
771 fn test_bracketed_citation_code_span_in_prefix() {
772 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 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 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 assert_eq!(
803 try_parse_bracketed_citation(
804 "[]"
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 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 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}