1use std::{borrow::Cow, fmt::Write as _, sync::LazyLock};
2
3use regex::{Captures, Regex, Replacer};
4
5use crate::{
6 Parser, Span,
7 attributes::{Attrlist, AttrlistContext},
8 content::{Content, SubstitutionGroup, substitution_step::substitute_attributes_in_text},
9 parser::{QuoteScope, QuoteType},
10 warnings::WarningType,
11};
12
13#[derive(Clone, Debug, Eq, Hash, PartialEq)]
24pub struct Passthrough {
25 pub(crate) text: String,
26 pub(crate) subs: SubstitutionGroup,
27 pub(crate) type_: Option<QuoteType>,
28 pub(crate) attrlist: Option<String>,
29}
30
31impl Passthrough {
32 pub fn text(&self) -> &str {
39 &self.text
40 }
41
42 pub fn subs(&self) -> &SubstitutionGroup {
51 &self.subs
52 }
53}
54
55#[derive(Clone, Debug, Eq, PartialEq)]
58pub(crate) struct Passthroughs(pub(crate) Vec<Passthrough>);
59
60impl Passthroughs {
61 pub(crate) fn extract_from(content: &mut Content<'_>, parser: &Parser) -> Self {
62 let mut passthroughs = Self(vec![]);
63
64 {
70 let text = content.rendered.as_ref();
71 if text.contains("++") || text.contains("$$") || text.contains("ss:") {
72 let source = content.original();
73 let replacer = InlinePassMacroReplacer {
74 passthroughs: &mut passthroughs,
75 parser,
76 source,
77 };
78
79 if let Cow::Owned(new_result) = INLINE_PASS_MACRO.replace_all(text, replacer) {
80 content.rendered = new_result.into();
81 }
82 }
83 }
84
85 {
86 let text = content.rendered.as_ref();
87 if text.contains('+') || text.contains("-]") {
88 let replacer = InlinePassReplacer(&mut passthroughs);
89
90 if let Cow::Owned(new_result) = INLINE_PASS.replace_all(text, replacer) {
91 content.rendered = new_result.into();
92 }
93 }
94 }
95
96 {
102 let text = content.rendered.as_ref();
103 if text.contains(':') && (text.contains("stem:") || text.contains("math:")) {
104 let original = content.original();
105 let replacer = InlineStemMacroReplacer {
106 passthroughs: &mut passthroughs,
107 parser,
108 source: original,
109 };
110
111 if let Cow::Owned(new_result) = INLINE_STEM_MACRO.replace_all(text, replacer) {
112 content.rendered = new_result.into();
113 }
114 }
115 }
116
117 passthroughs
118 }
119
120 pub(crate) fn restore_to(&self, content: &mut Content<'_>, parser: &Parser) {
121 if self.0.is_empty() {
122 return;
123 }
124
125 if let Cow::Owned(new_result) = PASS_WITH_INDEX.replace_all(
126 content.rendered().as_ref(),
127 PassthroughRestoreReplacer(self, parser),
128 ) {
129 content.rendered = new_result.into();
130 }
131
132 content.restore_deferred_xref_passthroughs(|text| {
137 if let Cow::Owned(restored) =
138 PASS_WITH_INDEX.replace_all(text, PassthroughRestoreReplacer(self, parser))
139 {
140 *text = restored;
141 }
142 });
143 }
144
145 pub(super) fn push(&mut self, passthrough: Passthrough, dest: &mut String) {
146 let index = self.0.len();
147 self.0.push(passthrough);
148
149 dest.push('\u{96}');
150
151 let _ = write!(dest, "{index}");
154
155 dest.push('\u{97}');
156 }
157}
158
159static INLINE_PASS_MACRO: LazyLock<Regex> = LazyLock::new(|| {
171 #[allow(clippy::unwrap_used)]
172 Regex::new(
173 r#"(?xs)
174 (?:
175 # Optional: attrlist
176 (?:
177 (\\?) # Group 1: optional backslash before [
178 \[
179 ([^\[\]]+) # Group 2: attrlist contents
180 \]
181 )?
182
183 (\\{0,2}) # Group 3: optional escape prefix (e.g., \ or \\)
184
185 # Passthrough span delimiters: +++, ++, or $$
186 (?:
187 (\+\+\+) (.*?) (\+\+\+) | # Groups 4,5,6: triple plus
188 (\+\+) (.*?) (\+\+) | # Groups 7,8,9: double plus
189 (\$\$) (.*?) (\$\$) # Groups 10,11,12: double dollar
190 )
191
192 |
193
194 # Alternative: pass-through directive
195 (\\?) # Group 13: optional escape before pass
196 pass:
197 ([a-z]+(?:,[a-z-]+)*)? # Group 14: optional substitution step list
198 \[
199 (|.*?[^\\]) # Group 15: optional content
200 # (avoiding escape of trailing bracket)
201 \]
202 )"#,
203 )
204 .unwrap()
205});
206
207#[derive(Debug)]
208struct InlinePassMacroReplacer<'p> {
209 passthroughs: &'p mut Passthroughs,
210 parser: &'p Parser,
211 source: Span<'p>,
212}
213
214impl Replacer for InlinePassMacroReplacer<'_> {
215 fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
216 if caps.get(4).is_some() {
217 self.handle_quoted_text(caps, 5, dest);
219 } else if caps.get(7).is_some() {
220 self.handle_quoted_text(caps, 8, dest);
222 } else if caps.get(10).is_some() {
223 self.handle_quoted_text(caps, 11, dest);
225 } else {
226 if caps.get(13).is_some_and(|m| !m.as_str().is_empty()) {
229 dest.push_str("pass:");
231 if let Some(subs) = caps.get(14) {
232 dest.push_str(subs.as_str());
233 }
234 dest.push('[');
235 dest.push_str(&caps[15]);
236 dest.push(']');
237 return;
238 }
239
240 let subs = match caps.get(14).map(|m| m.as_str()) {
245 None => SubstitutionGroup::None,
246 Some(subs_list) => {
247 let (group, invalid) = SubstitutionGroup::from_custom_string(None, subs_list);
248
249 if !invalid.is_empty() {
250 self.parser.record_substitution_warning(
251 self.source,
252 WarningType::InvalidSubstitutionTypeForPassthroughMacro(
253 invalid.join(", "),
254 ),
255 );
256 }
257
258 group
259 }
260 };
261
262 let mut text = caps[15].to_string();
263 if !text.is_empty() {
264 text = text.replace("\\]", "]");
265 }
266
267 self.passthroughs.push(
268 Passthrough {
269 text,
270 subs,
271 type_: None,
272 attrlist: None,
273 },
274 dest,
275 );
276 }
277 }
278}
279
280impl InlinePassMacroReplacer<'_> {
281 fn handle_quoted_text(
282 &mut self,
283 caps: &Captures<'_>,
284 quoted_text_index: usize,
285 dest: &mut String,
286 ) {
287 let escape_count = caps.get(3).map_or(0, |m| m.len());
288
289 let boundary = caps.get(4).or_else(|| caps.get(7)).or_else(|| caps.get(10));
290 let boundary = boundary.map(|m| m.as_str()).unwrap_or_default();
291
292 let quoted_text = caps.get(5).or_else(|| caps.get(8)).or_else(|| caps.get(11));
293 let quoted_text = quoted_text.map(|m| m.as_str()).unwrap_or_default();
294
295 let mut old_behavior = false;
296
297 let attrlist: Option<String> = if let Some(attrlist) = caps.get(2) {
298 let attrlist = attrlist.as_str();
299
300 if escape_count > 0 {
301 dest.push_str(caps[1].as_ref());
302 dest.push('[');
303 dest.push_str(caps[2].as_ref());
304 dest.push(']');
305 dest.push_str(&("\\".repeat(escape_count - 1)));
306 dest.push_str(caps[quoted_text_index - 1].as_ref());
307 dest.push_str(caps[quoted_text_index].as_ref());
308 dest.push_str(caps[quoted_text_index - 1].as_ref());
309 return;
310 }
311
312 if &caps[1] == "\\" {
313 dest.push_str(&format!("[{attrlist}]", attrlist = &caps[2]));
314 None
315 } else if boundary == "++" {
316 if attrlist == "x-" {
317 old_behavior = true;
318 Some("".to_owned())
319 } else if attrlist.ends_with(" x-") {
320 old_behavior = true;
321 Some(attrlist[0..attrlist.len() - 3].to_owned())
322 } else {
323 Some(attrlist.to_owned())
324 }
325 } else {
326 Some(attrlist.to_owned())
327 }
328 } else if escape_count > 0 {
329 dest.push_str(&("\\".repeat(escape_count - 1)));
331 dest.push_str(boundary);
332 dest.push_str(quoted_text);
333 dest.push_str(boundary);
334 return;
335 } else {
336 None
337 };
338
339 let passthrough = if let Some(attrlist) = attrlist {
340 if old_behavior {
341 Passthrough {
342 text: caps
343 .get(quoted_text_index)
344 .map(|m| m.as_str().to_owned())
345 .unwrap_or_default(),
346 subs: SubstitutionGroup::Normal,
347 type_: Some(QuoteType::Monospaced),
348 attrlist: Some(attrlist),
349 }
350 } else {
351 Passthrough {
352 text: caps
353 .get(quoted_text_index)
354 .map(|m| m.as_str().to_owned())
355 .unwrap_or_default(),
356 subs: if boundary == "+++" {
357 SubstitutionGroup::None
358 } else {
359 SubstitutionGroup::Verbatim
360 },
361 type_: Some(QuoteType::Unquoted),
362 attrlist: Some(attrlist),
363 }
364 }
365 } else {
366 Passthrough {
367 text: caps
368 .get(quoted_text_index)
369 .map(|m| m.as_str().to_owned())
370 .unwrap_or_default(),
371 subs: if boundary == "+++" {
372 SubstitutionGroup::None
373 } else {
374 SubstitutionGroup::Verbatim
375 },
376 type_: None,
377 attrlist: None,
378 }
379 };
380
381 self.passthroughs.push(passthrough, dest);
382 }
383}
384
385static PASS_WITH_INDEX: LazyLock<Regex> = LazyLock::new(|| {
386 #[allow(clippy::unwrap_used)]
387 Regex::new("\u{96}(\\d+)\u{97}").unwrap()
388});
389
390static INLINE_PASS: LazyLock<Regex> = LazyLock::new(|| {
400 #[allow(clippy::unwrap_used)]
401 Regex::new(
402 r#"(?xs)
403 (?:
404 # Option 1: [... x-] followed by `xxx`
405 \b{start-half} # Must not follow a word
406 \[(x-|[^\[\]]+\ x-)\] # Group 1: [attrlist] with x- suffix
407 \`(\S(?:.*?\S)??)\` # Group 2: `...` content
408
409 | # --OR--
410 # Option 2: [...] followed by +xxx+
411 \b{start-half} # Must not follow a word
412 \[([^\[\]]+)\] # Group 3: [attrlist]
413 (\\{0,2}) # Group 4: optional escapes
414 \+(\S(?:.*?\S)??)\+ # Group 5: +...+ content (surrounded by non-space)
415
416 | # --OR--
417 # Option 3: +xxx+ without attrlist
418 (?:^|([^\w;:\\])) # Group 6: consume a preceding char, so a run
419 # of `+` tokenizes like Asciidoctor's `gsub`
420 # (which consumes one char before each match)
421 (\\)? # Group 7: optional escape
422 \+(\S(?:.*?\S)??)\+ # Group 8: +...+ content (surrounded by non-space)
423
424 )
425
426 \b{end-half} # Must not be followed by a word character
427 "#,
428 )
429 .unwrap()
430});
431
432#[derive(Debug)]
433struct InlinePassReplacer<'p>(&'p mut Passthroughs);
434
435impl Replacer for InlinePassReplacer<'_> {
436 fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
437 if dest.ends_with('\\') || dest.ends_with(':') || dest.ends_with(';') {
438 let replacer = InlinePassReplacer(self.0);
444
445 let first_len = caps[0].chars().next().map_or(0, char::len_utf8);
449 let (first, rem) = &caps[0].split_at(first_len);
450 dest.push_str(first);
451
452 let new_result = INLINE_PASS.replace_all(rem, replacer);
453 dest.push_str(&new_result);
454
455 return;
456 }
457
458 let preceding = caps.get(6).map_or("", |m| m.as_str());
463 dest.push_str(preceding);
464
465 let escapes = caps.get(4).or_else(|| caps.get(7));
466 let escape_count = escapes.map_or(0, |m| m.len());
467
468 let format_mark = if caps.get(2).is_some() { '`' } else { '+' };
469 let orig_attrlist_body = caps.get(1).or_else(|| caps.get(3)).map(|m| m.as_str());
470
471 let (attrlist_body, old_behavior) = orig_attrlist_body.map_or((None, false), |m| {
472 if m == "x-" {
473 (Some("".to_string()), true)
474 } else if m.ends_with(" x-") {
475 (Some(m[0..m.len() - 3].to_string()), true)
476 } else {
477 (Some(m.to_string()), false)
478 }
479 });
480
481 let quoted_text = caps.get(2).or_else(|| caps.get(5)).or_else(|| caps.get(8));
482 let quoted_text = quoted_text.map_or("", |m| m.as_str());
483
484 if let Some(orig_attrlist_body) = orig_attrlist_body {
485 if escape_count > 0 {
486 dest.push('[');
488 dest.push_str(orig_attrlist_body);
489 dest.push(']');
490 dest.push_str(&("\\".repeat(escape_count - 1)));
491 dest.push(format_mark);
492 dest.push_str(quoted_text);
493 dest.push(format_mark);
494 return;
495 }
496 } else if escape_count > 0 {
497 dest.push_str(&("\\".repeat(escape_count - 1)));
499 dest.push(format_mark);
500 dest.push_str(quoted_text);
501 dest.push(format_mark);
502 return;
503 };
504
505 let subs = if attrlist_body.is_some() && old_behavior && format_mark != '`' {
506 SubstitutionGroup::Normal
507 } else {
508 SubstitutionGroup::Verbatim
509 };
510
511 let type_ = if attrlist_body.is_some() {
512 if old_behavior {
513 Some(QuoteType::Monospaced)
514 } else {
515 Some(QuoteType::Unquoted)
516 }
517 } else {
518 None
519 };
520
521 self.0.push(
522 Passthrough {
523 text: quoted_text.to_string(),
524 subs,
525 type_,
526 attrlist: attrlist_body,
527 },
528 dest,
529 );
530 }
531}
532
533static INLINE_STEM_MACRO: LazyLock<Regex> = LazyLock::new(|| {
545 #[allow(clippy::unwrap_used)]
546 Regex::new(
547 r#"(?xs)
548 (\\?) # Group 1: optional escape
549 (stem|latexmath|asciimath) # Group 2: notation
550 :
551 ([a-z]+(?:,[a-z-]+)*)? # Group 3: optional substitution list
552 \[
553 (.*?[^\\]) # Group 4: expression (last char not a backslash)
554 \]
555 "#,
556 )
557 .unwrap()
558});
559
560#[derive(Debug)]
561struct InlineStemMacroReplacer<'p> {
562 passthroughs: &'p mut Passthroughs,
563 parser: &'p Parser,
564 source: Span<'p>,
565}
566
567impl Replacer for InlineStemMacroReplacer<'_> {
568 fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
569 if caps.get(1).is_some_and(|m| !m.as_str().is_empty()) {
572 dest.push_str(&caps[0][1..]);
573 return;
574 }
575
576 let type_ = match &caps[2] {
577 "latexmath" => QuoteType::LatexMath,
578 "asciimath" => QuoteType::AsciiMath,
579
580 _ => stem_notation(self.parser),
583 };
584
585 let mut content = caps[4].to_string();
587 if content.contains("\\]") {
588 content = content.replace("\\]", "]");
589 }
590
591 if type_ == QuoteType::LatexMath
594 && content.len() >= 2
595 && content.starts_with('$')
596 && content.ends_with('$')
597 {
598 content = content[1..content.len() - 1].to_string();
599 }
600
601 let subs = match caps.get(3).map(|m| m.as_str()) {
607 None => SubstitutionGroup::Stem,
608 Some(subs_list) => {
609 let (group, invalid) = SubstitutionGroup::from_custom_string(None, subs_list);
610
611 if !invalid.is_empty() {
612 self.parser.record_substitution_warning(
613 self.source,
614 WarningType::InvalidSubstitutionTypeForStemMacro(invalid.join(", ")),
615 );
616 }
617
618 group
619 }
620 };
621
622 self.passthroughs.push(
623 Passthrough {
624 text: content,
625 subs,
626 type_: Some(type_),
627 attrlist: None,
628 },
629 dest,
630 );
631 }
632}
633
634fn stem_notation(parser: &Parser) -> QuoteType {
638 match parser.attribute_value("stem").as_maybe_str() {
639 Some("latexmath") | Some("latex") | Some("tex") => QuoteType::LatexMath,
640 _ => QuoteType::AsciiMath,
641 }
642}
643
644#[derive(Debug)]
645struct PassthroughRestoreReplacer<'p>(&'p Passthroughs, &'p Parser);
646
647impl Replacer for PassthroughRestoreReplacer<'_> {
648 fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
649 let index = caps[1].parse::<usize>().unwrap_or_default();
650
651 let Some(pass) = self.0.0.get(index) else {
652 dest.push_str(&format!(
653 "(INTERNAL ERROR: Unresolved passthrough index {index})"
654 ));
655 return;
656 };
657
658 let span = Span::new(&pass.text);
659
660 let mut subbed_text = Content::from(span);
661 pass.subs.apply(&mut subbed_text, self.1, None);
662
663 if let Some(type_) = pass.type_ {
664 let attrlist_body = pass.attrlist.as_ref().map(|attrlist_body| {
673 let saved = self.1.substitution_warnings_len();
679 let substituted = substitute_attributes_in_text(attrlist_body, self.1);
680 self.1.truncate_substitution_warnings(saved);
681 substituted
682 });
683
684 let attrlist = attrlist_body.as_ref().map(|attrlist_body| {
685 let span = Span::new(attrlist_body);
686 let maw = Attrlist::parse(span, self.1, AttrlistContext::Inline);
687 maw.item.item
688 });
689
690 let id = attrlist
691 .as_ref()
692 .and_then(|attrlist| attrlist.id().map(|id| id.to_string()));
693
694 let mut new_text = String::default();
695 self.1.renderer.render_quoted_substitution(
696 type_,
697 QuoteScope::Unconstrained,
698 attrlist,
699 id,
700 subbed_text.rendered(),
701 &mut new_text,
702 );
703
704 subbed_text.rendered = new_text.into();
705 }
706
707 if subbed_text.rendered().contains('\u{96}') {
708 let replacer = PassthroughRestoreReplacer(self.0, self.1);
710
711 let new_result = PASS_WITH_INDEX.replace_all(subbed_text.rendered().as_ref(), replacer);
712
713 dest.push_str(new_result.as_ref());
714 } else {
715 dest.push_str(subbed_text.rendered());
716 }
717 }
718}
719
720#[cfg(test)]
721mod tests {
722 #![allow(clippy::indexing_slicing)]
723 #![allow(clippy::panic)]
724 #![allow(clippy::unwrap_used)]
725
726 use crate::{
727 content::{Passthroughs, SubstitutionStep, passthroughs::Passthrough},
728 tests::prelude::*,
729 };
730
731 #[test]
732 fn inline_double_plus_with_escaped_attrlist() {
733 let mut p = Parser::default();
734 let maw = crate::blocks::Block::parse(crate::Span::new(r#"abc \[attrs]++text++"#), &mut p);
735
736 let block = maw.item.unwrap().item;
737
738 assert_eq!(
739 block,
740 Block::Simple(SimpleBlock {
741 content: Content {
742 original: Span {
743 data: r#"abc \[attrs]++text++"#,
744 line: 1,
745 col: 1,
746 offset: 0,
747 },
748 rendered: "abc [attrs]text",
749 },
750 source: Span {
751 data: r#"abc \[attrs]++text++"#,
752 line: 1,
753 col: 1,
754 offset: 0,
755 },
756 style: SimpleBlockStyle::Paragraph,
757 title_source: None,
758 title: None,
759 caption: None,
760 number: None,
761 anchor: None,
762 anchor_reftext: None,
763 attrlist: None,
764 },)
765 );
766 }
767
768 #[test]
769 fn content_exposes_extracted_passthrough_collection() {
770 let mut p = Parser::default();
777
778 let maw = crate::blocks::Block::parse(
779 crate::Span::new("some ++<code>{code}</code>++ and +++{raw}+++ text"),
780 &mut p,
781 );
782
783 let crate::blocks::Block::Simple(block) = maw.item.unwrap().item else {
784 panic!("expected a simple block");
785 };
786
787 let passthroughs = block.content().passthroughs();
788
789 assert_eq!(passthroughs.len(), 2);
790
791 assert_eq!(passthroughs[0].text(), "<code>{code}</code>");
794 assert_eq!(passthroughs[0].subs(), &SubstitutionGroup::Verbatim);
795
796 assert_eq!(passthroughs[1].text(), "{raw}");
798 assert_eq!(passthroughs[1].subs(), &SubstitutionGroup::None);
799 }
800
801 #[test]
802 fn passthrough_attrlist_drop_line_does_not_leak_a_mislocated_warning() {
803 let mut p = Parser::default().with_intrinsic_attribute(
810 "attribute-missing",
811 "drop-line",
812 ModificationContext::ApiOnly,
813 );
814
815 let doc = p.parse("['{missing}']++x++");
816
817 assert_eq!(doc.warnings().count(), 0);
818 }
819
820 #[test]
821 fn content_without_passthroughs_exposes_an_empty_collection() {
822 let mut p = Parser::default();
825
826 let maw = crate::blocks::Block::parse(crate::Span::new("just plain prose"), &mut p);
827
828 let crate::blocks::Block::Simple(block) = maw.item.unwrap().item else {
829 panic!("expected a simple block");
830 };
831
832 assert!(block.content().passthroughs().is_empty());
833 }
834
835 #[test]
836 fn adds_warning_text_for_unresolved_passthrough_id() {
837 let mut content =
838 crate::content::Content::from(crate::Span::new("pass:q,a[*<{backend}>*]"));
839 let parser_for_extract = Parser::default();
840 let pt = Passthroughs::extract_from(&mut content, &parser_for_extract);
841
842 assert_eq!(
843 content,
844 Content {
845 original: Span {
846 data: "pass:q,a[*<{backend}>*]",
847 line: 1,
848 col: 1,
849 offset: 0,
850 },
851 rendered: "\u{96}0\u{97}",
852 }
853 );
854
855 assert_eq!(
856 pt,
857 Passthroughs(vec![Passthrough {
858 text: "*<{backend}>*".to_owned(),
859 subs: SubstitutionGroup::Custom(vec![
860 SubstitutionStep::Quotes,
861 SubstitutionStep::AttributeReferences,
862 ]),
863 type_: None,
864 attrlist: None,
865 },],)
866 );
867
868 let parser = Parser::default().with_intrinsic_attribute(
869 "backend",
870 "html5",
871 ModificationContext::ApiOnly,
872 );
873
874 pt.0[0].subs.apply(&mut content, &parser, None);
875
876 content.rendered = "\u{96}99\u{97}".into();
877
878 pt.restore_to(&mut content, &parser);
879
880 assert_eq!(
881 content,
882 Content {
883 original: Span {
884 data: "pass:q,a[*<{backend}>*]",
885 line: 1,
886 col: 1,
887 offset: 0,
888 },
889 rendered: "(INTERNAL ERROR: Unresolved passthrough index 99)",
890 }
891 );
892 }
893}