1use std::{borrow::Cow, sync::LazyLock};
2
3use regex::{Captures, Regex, RegexBuilder, Replacer};
4
5use crate::{
6 Parser,
7 attributes::{Attrlist, AttrlistContext},
8 content::Content,
9 document::InterpretedValue,
10 internal::{LookaheadReplacer, LookaheadResult, replace_with_lookahead},
11 parser::{
12 CalloutGuard, CalloutRenderParams, CharacterReplacementType, InlineSubstitutionRenderer,
13 QuoteScope, QuoteType, SpecialCharacter,
14 },
15};
16
17#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub enum SubstitutionStep {
24 SpecialCharacters,
27
28 Quotes,
30
31 AttributeReferences,
33
34 CharacterReplacements,
38
39 Macros,
42
43 PostReplacement,
45
46 Callouts,
48}
49
50impl SubstitutionStep {
51 pub(crate) fn apply(
52 &self,
53 content: &mut Content<'_>,
54 parser: &Parser,
55 attrlist: Option<&Attrlist<'_>>,
56 ) {
57 match self {
58 Self::SpecialCharacters => {
59 apply_special_characters(content, &*parser.renderer);
60 }
61 Self::Quotes => {
62 apply_quotes(content, parser);
63 }
64 Self::AttributeReferences => {
65 apply_attributes(content, parser);
66 }
67 Self::CharacterReplacements => {
68 apply_character_replacements(content, &*parser.renderer);
69 }
70 Self::Macros => {
71 super::macros::apply_macros(content, parser);
72 }
73 Self::PostReplacement => {
74 apply_post_replacements(content, parser, attrlist);
75 }
76 Self::Callouts => {
77 apply_callouts(content, parser, attrlist);
78 }
79 }
80 }
81}
82
83fn apply_special_characters(content: &mut Content<'_>, renderer: &dyn InlineSubstitutionRenderer) {
84 if !content.rendered.contains(['<', '>', '&']) {
85 return;
86 }
87
88 let mut result: Cow<'_, str> = content.rendered.to_string().into();
89 let replacer = SpecialCharacterReplacer { renderer };
90
91 if let Cow::Owned(new_result) = SPECIAL_CHARS.replace_all(&result, replacer) {
92 result = new_result.into();
93 }
94
95 content.rendered = result.into();
96}
97
98static SPECIAL_CHARS: LazyLock<Regex> = LazyLock::new(|| {
99 #[allow(clippy::unwrap_used)]
100 Regex::new("[<>&]").unwrap()
101});
102
103#[derive(Debug)]
104struct SpecialCharacterReplacer<'r> {
105 renderer: &'r dyn InlineSubstitutionRenderer,
106}
107
108impl Replacer for SpecialCharacterReplacer<'_> {
109 fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
110 let ch = &caps[0];
113
114 if ch == "<" {
115 self.renderer
116 .render_special_character(SpecialCharacter::Lt, dest);
117 } else if ch == ">" {
118 self.renderer
119 .render_special_character(SpecialCharacter::Gt, dest);
120 } else if ch == "&" {
121 self.renderer
122 .render_special_character(SpecialCharacter::Ampersand, dest);
123 }
124
125 }
128}
129
130static QUOTED_TEXT_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
131 #[allow(clippy::unwrap_used)]
132 Regex::new("[*_`#^~]").unwrap()
133});
134
135struct QuoteSub {
136 type_: QuoteType,
137 scope: QuoteScope,
138 pattern: Regex,
139}
140
141static QUOTE_SUBS: LazyLock<Vec<QuoteSub>> = LazyLock::new(|| {
162 vec![
163 QuoteSub {
164 type_: QuoteType::Strong,
166 scope: QuoteScope::Unconstrained,
167 #[allow(clippy::unwrap_used)]
168 pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?\*\*(.+?)\*\*"#)
169 .dot_matches_new_line(true)
170 .build()
171 .unwrap(),
172 },
173 QuoteSub {
174 type_: QuoteType::Strong,
176 scope: QuoteScope::Constrained,
177 #[allow(clippy::unwrap_used)]
178 pattern: RegexBuilder::new(
179 r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?\*(\S|\S.*?\S)\*\b{end-half}"#,
180 )
181 .dot_matches_new_line(true)
182 .build()
183 .unwrap(),
184 },
185 QuoteSub {
186 type_: QuoteType::DoubleQuote,
188 scope: QuoteScope::Constrained,
189 #[allow(clippy::unwrap_used)]
190 pattern: RegexBuilder::new(
191 r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?"`(\S|\S.*?\S)`"\b{end-half}"#,
192 )
193 .dot_matches_new_line(true)
194 .build()
195 .unwrap(),
196 },
197 QuoteSub {
198 type_: QuoteType::SingleQuote,
200 scope: QuoteScope::Constrained,
201 #[allow(clippy::unwrap_used)]
202 pattern: RegexBuilder::new(
203 r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?'`(\S|\S.*?\S)`'\b{end-half}"#,
204 )
205 .dot_matches_new_line(true)
206 .build()
207 .unwrap(),
208 },
209 QuoteSub {
210 type_: QuoteType::Monospaced,
212 scope: QuoteScope::Unconstrained,
213 #[allow(clippy::unwrap_used)]
214 pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?``(.+?)``"#)
215 .dot_matches_new_line(true)
216 .build()
217 .unwrap(),
218 },
219 QuoteSub {
220 type_: QuoteType::Monospaced,
222 scope: QuoteScope::Constrained,
223 #[allow(clippy::unwrap_used)]
224 pattern: RegexBuilder::new(
225 r#"(^|[^\w&;:"'`}])(?:\[([^\[\]]+)\])?`(\S|\S.*?\S)`\b{end-half}"#,
226 )
230 .dot_matches_new_line(true)
231 .build()
232 .unwrap(),
233 },
234 QuoteSub {
235 type_: QuoteType::Emphasis,
237 scope: QuoteScope::Unconstrained,
238 #[allow(clippy::unwrap_used)]
239 pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?__(.+?)__"#)
240 .dot_matches_new_line(true)
241 .build()
242 .unwrap(),
243 },
244 QuoteSub {
245 type_: QuoteType::Emphasis,
247 scope: QuoteScope::Constrained,
248 #[allow(clippy::unwrap_used)]
249 pattern: RegexBuilder::new(
250 r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?_(\S|\S.*?\S)_\b{end-half}"#,
251 )
252 .dot_matches_new_line(true)
253 .build()
254 .unwrap(),
255 },
256 QuoteSub {
257 type_: QuoteType::Mark,
259 scope: QuoteScope::Unconstrained,
260 #[allow(clippy::unwrap_used)]
261 pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?##(.+?)##"#)
262 .dot_matches_new_line(true)
263 .build()
264 .unwrap(),
265 },
266 QuoteSub {
267 type_: QuoteType::Mark,
269 scope: QuoteScope::Constrained,
270 #[allow(clippy::unwrap_used)]
271 pattern: RegexBuilder::new(
272 r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?#(\S|\S.*?\S)#\b{end-half}"#,
273 )
274 .dot_matches_new_line(true)
275 .build()
276 .unwrap(),
277 },
278 QuoteSub {
279 type_: QuoteType::Superscript,
281 scope: QuoteScope::Unconstrained,
282 #[allow(clippy::unwrap_used)]
283 pattern: Regex::new(r#"\\?(?:\[([^\[\]]+)\])?\^(\S+?)\^"#).unwrap(),
284 },
285 QuoteSub {
286 type_: QuoteType::Subscript,
288 scope: QuoteScope::Unconstrained,
289 #[allow(clippy::unwrap_used)]
290 pattern: Regex::new(r#"\\?(?:\[([^\[\]]+)\])?~(\S+?)~"#).unwrap(),
291 },
292 ]
293});
294
295#[derive(Debug)]
296struct QuoteReplacer<'r> {
297 type_: QuoteType,
298 scope: QuoteScope,
299 parser: &'r Parser,
300}
301
302impl LookaheadReplacer for QuoteReplacer<'_> {
303 fn replace_append(
304 &mut self,
305 caps: &Captures<'_>,
306 dest: &mut String,
307 after: &str,
308 ) -> LookaheadResult {
309 if self.type_ == QuoteType::Monospaced
316 && self.scope == QuoteScope::Constrained
317 && after.starts_with(['"', '\'', '`'])
318 {
319 let skip_ahead = if caps[0].starts_with('\\') { 2 } else { 1 };
320 dest.push_str(&caps[0][0..skip_ahead]);
321 return LookaheadResult::SkipAheadAndRetry(skip_ahead);
322 }
323
324 let unescaped_attrs: Option<String> = if caps[0].starts_with('\\') {
325 let maybe_attrs = caps.get(2).map(|a| a.as_str());
326 if self.scope == QuoteScope::Constrained && maybe_attrs.is_some() {
327 Some(format!(
328 "[{attrs}]",
329 attrs = maybe_attrs.unwrap_or_default()
330 ))
331 } else {
332 dest.push_str(&caps[0][1..]);
333 return LookaheadResult::Continue;
334 }
335 } else {
336 None
337 };
338
339 match self.scope {
340 QuoteScope::Constrained => {
341 if let Some(attrs) = unescaped_attrs {
342 dest.push_str(&attrs);
343 self.parser.renderer.render_quoted_substitition(
344 self.type_, self.scope, None, None, &caps[3], dest,
345 );
346 } else {
347 let (attrlist, type_): (Option<Attrlist<'_>>, QuoteType) =
348 if let Some(attrlist) = caps.get(2) {
349 let type_ = if self.type_ == QuoteType::Mark {
350 QuoteType::Unquoted
351 } else {
352 self.type_
353 };
354
355 (
356 Some(
357 Attrlist::parse(
358 crate::Span::new(attrlist.as_str()),
359 self.parser,
360 AttrlistContext::Inline,
361 )
362 .item
363 .item,
364 ),
365 type_,
366 )
367 } else {
368 (None, self.type_)
369 };
370
371 if let Some(prefix) = caps.get(1) {
372 dest.push_str(prefix.as_str());
373 }
374
375 let id = attrlist
376 .as_ref()
377 .and_then(|a| a.id().map(|s| s.to_string()));
378
379 self.parser.renderer.render_quoted_substitition(
380 type_, self.scope, attrlist, id, &caps[3], dest,
381 );
382 }
383 }
384
385 QuoteScope::Unconstrained => {
386 let (attrlist, type_): (Option<Attrlist<'_>>, QuoteType) =
387 if let Some(attrlist) = caps.get(1) {
388 let type_ = if self.type_ == QuoteType::Mark {
389 QuoteType::Unquoted
390 } else {
391 self.type_
392 };
393
394 (
395 Some(
396 Attrlist::parse(
397 crate::Span::new(attrlist.as_str()),
398 self.parser,
399 AttrlistContext::Inline,
400 )
401 .item
402 .item,
403 ),
404 type_,
405 )
406 } else {
407 (None, self.type_)
408 };
409
410 let id = attrlist
411 .as_ref()
412 .and_then(|a| a.id().map(|s| s.to_string()));
413
414 self.parser
415 .renderer
416 .render_quoted_substitition(type_, self.scope, attrlist, id, &caps[2], dest);
417 }
418 }
419
420 LookaheadResult::Continue
421 }
422}
423
424fn apply_quotes(content: &mut Content<'_>, parser: &Parser) {
425 if !QUOTED_TEXT_SNIFF.is_match(content.rendered.as_ref()) {
426 return;
427 }
428
429 let mut result: Cow<'_, str> = content.rendered.to_string().into();
430
431 for sub in &*QUOTE_SUBS {
432 let replacer = QuoteReplacer {
433 type_: sub.type_,
434 scope: sub.scope,
435 parser,
436 };
437
438 if let Cow::Owned(new_result) = replace_with_lookahead(&sub.pattern, &result, replacer) {
439 result = new_result.into();
440 }
441 }
444
445 content.rendered = result.into();
446}
447
448static ATTRIBUTE_REFERENCE: LazyLock<Regex> = LazyLock::new(|| {
449 #[allow(clippy::unwrap_used)]
450 Regex::new(r#"\\?\{([A-Za-z0-9_][A-Za-z0-9_-]*)\}"#).unwrap()
451});
452
453#[derive(Debug)]
454struct AttributeReplacer<'p>(&'p Parser);
455
456impl Replacer for AttributeReplacer<'_> {
457 fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
458 let attr_name = &caps[1];
459
460 if !self.0.has_attribute(attr_name) {
462 dest.push_str(&caps[0]);
463 return;
464 }
465
466 if caps[0].starts_with('\\') {
467 dest.push_str(&caps[0][1..]);
468 return;
469 }
470
471 if let InterpretedValue::Value(value) = self.0.attribute_value(attr_name) {
472 dest.push_str(value.as_ref());
473 }
474 }
477}
478
479fn apply_attributes(content: &mut Content<'_>, parser: &Parser) {
480 if !content.rendered.contains('{') {
481 return;
482 }
483
484 let mut result: Cow<'_, str> = content.rendered.to_string().into();
485
486 if let Cow::Owned(new_result) =
487 ATTRIBUTE_REFERENCE.replace_all(&result, AttributeReplacer(parser))
488 {
489 result = new_result.into();
490 }
491 content.rendered = result.into();
495}
496
497fn apply_character_replacements(
498 content: &mut Content<'_>,
499 renderer: &dyn InlineSubstitutionRenderer,
500) {
501 if !REPLACEABLE_TEXT_SNIFF.is_match(content.rendered.as_ref()) {
502 return;
503 }
504
505 let mut result: Cow<'_, str> = content.rendered.to_string().into();
506
507 for repl in &*REPLACEMENTS {
508 let replacer = CharacterReplacer {
509 type_: repl.type_.clone(),
510 renderer,
511 };
512
513 if let Cow::Owned(new_result) = repl.pattern.replace_all(&result, replacer) {
514 result = new_result.into();
515 }
516 }
519
520 content.rendered = result.into();
521}
522
523struct CharacterReplacement {
524 type_: CharacterReplacementType,
525 pattern: Regex,
526}
527
528static REPLACEABLE_TEXT_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
529 #[allow(clippy::unwrap_used)]
530 Regex::new(r#"[&']|--|\.\.\.|\([CRT]M?\)"#).unwrap()
531});
532
533static REPLACEMENTS: LazyLock<Vec<CharacterReplacement>> = LazyLock::new(|| {
539 vec![
540 CharacterReplacement {
541 type_: CharacterReplacementType::Copyright,
543 #[allow(clippy::unwrap_used)]
544 pattern: Regex::new(r#"\\?\(C\)"#).unwrap(),
545 },
546 CharacterReplacement {
547 type_: CharacterReplacementType::Registered,
549 #[allow(clippy::unwrap_used)]
550 pattern: Regex::new(r#"\\?\(R\)"#).unwrap(),
551 },
552 CharacterReplacement {
553 type_: CharacterReplacementType::Trademark,
555 #[allow(clippy::unwrap_used)]
556 pattern: Regex::new(r#"\\?\(TM\)"#).unwrap(),
557 },
558 CharacterReplacement {
559 type_: CharacterReplacementType::EmDashSurroundedBySpaces,
561 #[allow(clippy::unwrap_used)]
562 pattern: Regex::new(r#"(?: |\n|^|\\)--(?: |\n|$)"#).unwrap(),
563 },
564 CharacterReplacement {
565 type_: CharacterReplacementType::EmDashWithoutSpace,
567 #[allow(clippy::unwrap_used)]
568 pattern: Regex::new(r#"(\w)\\?--\b{start-half}"#).unwrap(),
569 },
570 CharacterReplacement {
571 type_: CharacterReplacementType::Ellipsis,
573 #[allow(clippy::unwrap_used)]
574 pattern: Regex::new(r#"\\?\.\.\."#).unwrap(),
575 },
576 CharacterReplacement {
577 type_: CharacterReplacementType::TypographicApostrophe,
579 #[allow(clippy::unwrap_used)]
580 pattern: Regex::new(r#"\\?`'"#).unwrap(),
581 },
582 CharacterReplacement {
583 type_: CharacterReplacementType::TypographicApostrophe,
585 #[allow(clippy::unwrap_used)]
586 pattern: Regex::new(r#"([[:alnum:]])\\?'([[:alpha:]])"#).unwrap(),
587 },
588 CharacterReplacement {
589 type_: CharacterReplacementType::SingleRightArrow,
591 #[allow(clippy::unwrap_used)]
592 pattern: Regex::new(r#"\\?->"#).unwrap(),
593 },
594 CharacterReplacement {
595 type_: CharacterReplacementType::DoubleRightArrow,
597 #[allow(clippy::unwrap_used)]
598 pattern: Regex::new(r#"\\?=>"#).unwrap(),
599 },
600 CharacterReplacement {
601 type_: CharacterReplacementType::SingleLeftArrow,
603 #[allow(clippy::unwrap_used)]
604 pattern: Regex::new(r#"\\?<-"#).unwrap(),
605 },
606 CharacterReplacement {
607 type_: CharacterReplacementType::DoubleLeftArrow,
609 #[allow(clippy::unwrap_used)]
610 pattern: Regex::new(r#"\\?<="#).unwrap(),
611 },
612 CharacterReplacement {
613 type_: CharacterReplacementType::CharacterReference("".to_owned()),
615 #[allow(clippy::unwrap_used)]
616 pattern: Regex::new(r#"\\?&((?:[a-zA-Z][a-zA-Z]+\d{0,2}|#\d\d\d{0,4}|#x[\da-fA-F][\da-fA-F][\da-fA-F]{0,3}));"#).unwrap(),
617 },
618 ]
619});
620
621#[derive(Debug)]
622struct CharacterReplacer<'r> {
623 type_: CharacterReplacementType,
624 renderer: &'r dyn InlineSubstitutionRenderer,
625}
626
627impl Replacer for CharacterReplacer<'_> {
628 fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
629 if caps[0].contains('\\') {
630 let unescaped = &caps[0].replace("\\", "");
632 dest.push_str(unescaped);
633 return;
634 }
635
636 match self.type_ {
637 CharacterReplacementType::Copyright
638 | CharacterReplacementType::Registered
639 | CharacterReplacementType::Trademark
640 | CharacterReplacementType::EmDashSurroundedBySpaces
641 | CharacterReplacementType::Ellipsis
642 | CharacterReplacementType::SingleLeftArrow
643 | CharacterReplacementType::DoubleLeftArrow
644 | CharacterReplacementType::SingleRightArrow
645 | CharacterReplacementType::DoubleRightArrow => {
646 self.renderer
647 .render_character_replacement(self.type_.clone(), dest);
648 }
649
650 CharacterReplacementType::EmDashWithoutSpace => {
651 dest.push_str(&caps[1]);
652 self.renderer.render_character_replacement(
653 CharacterReplacementType::EmDashWithoutSpace,
654 dest,
655 );
656 }
657
658 CharacterReplacementType::TypographicApostrophe => {
659 if let Some(before) = caps.get(1) {
660 dest.push_str(before.as_str());
661 }
662
663 self.renderer.render_character_replacement(
664 CharacterReplacementType::TypographicApostrophe,
665 dest,
666 );
667
668 if let Some(after) = caps.get(2) {
669 dest.push_str(after.as_str());
670 }
671 }
672
673 CharacterReplacementType::CharacterReference(_) => {
674 self.renderer.render_character_replacement(
675 CharacterReplacementType::CharacterReference(caps[1].to_string()),
676 dest,
677 );
678 }
679 }
680 }
681}
682
683fn apply_post_replacements(
684 content: &mut Content<'_>,
685 parser: &Parser,
686 attrlist: Option<&Attrlist<'_>>,
687) {
688 if parser.is_attribute_set("hardbreaks-option")
689 || attrlist.is_some_and(|attrlist| attrlist.has_option("hardbreaks"))
690 {
691 let text = content.rendered.as_ref();
692 if !text.contains('\n') {
693 return;
694 }
695
696 let mut lines: Vec<&str> = content.rendered.as_ref().lines().collect();
697 let last = lines.pop().unwrap_or_default();
698
699 let mut lines: Vec<String> = lines
700 .iter()
701 .map(|line| {
702 let line = if line.ends_with(" +") {
703 &line[0..line.len() - 2]
704 } else {
705 *line
706 };
707
708 let mut line = line.to_owned();
709 parser.renderer.render_line_break(&mut line);
710 line
711 })
712 .collect();
713
714 lines.push(last.to_owned());
715
716 let new_result = lines.join("\n");
717 content.rendered = new_result.into();
718 } else {
719 let rendered = content.rendered.as_ref();
720 if !(rendered.contains('+') && rendered.contains('\n')) {
721 return;
722 }
723
724 let replacer = PostReplacementReplacer(&*parser.renderer);
725
726 if let Cow::Owned(new_result) = HARD_LINE_BREAK.replace_all(rendered, replacer) {
727 content.rendered = new_result.into();
728 }
729 }
730}
731
732#[derive(Debug)]
733struct PostReplacementReplacer<'r>(&'r dyn InlineSubstitutionRenderer);
734
735impl Replacer for PostReplacementReplacer<'_> {
736 fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
737 dest.push_str(&caps[1]);
738 self.0.render_line_break(dest);
739 }
740}
741
742static HARD_LINE_BREAK: LazyLock<Regex> = LazyLock::new(|| {
743 #[allow(clippy::unwrap_used)]
744 Regex::new(r#"(?m)^(.*) \+$"#).unwrap()
745});
746
747fn apply_callouts(content: &mut Content<'_>, parser: &Parser, attrlist: Option<&Attrlist<'_>>) {
763 if !content.rendered.contains("<") {
766 return;
767 }
768
769 let line_comment: Option<String> = attrlist
779 .and_then(|a| a.named_attribute("line-comment"))
780 .map(|a| a.value().to_string())
781 .or_else(|| {
782 if parser.has_attribute("line-comment") {
783 Some(
784 parser
785 .attribute_value("line-comment")
786 .as_maybe_str()
787 .unwrap_or("")
788 .to_string(),
789 )
790 } else {
791 None
792 }
793 });
794
795 let (callout_rx, tail_rx) = build_callout_regexes(line_comment.as_deref());
796
797 let replacer = CalloutReplacer {
798 renderer: &*parser.renderer,
799 parser,
800 autonum: 0,
801 tail: tail_rx,
802 };
803
804 if let Cow::Owned(new_result) =
805 replace_with_lookahead(&callout_rx, content.rendered.as_ref(), replacer)
806 {
807 content.rendered = new_result.into();
808 }
809}
810
811static DEFAULT_CALLOUT_RX: LazyLock<Regex> = LazyLock::new(|| {
814 #[allow(clippy::unwrap_used)]
815 Regex::new(
816 r"(?P<prefix>(?://|#|--|;;) ?)?(?P<esc>\\)?(?:<!--(?P<xnum>\d+|\.)-->|<(?P<num>\d+|\.)>)",
817 )
818 .unwrap()
819});
820
821static DEFAULT_CALLOUT_TAIL_RX: LazyLock<Regex> = LazyLock::new(|| {
823 #[allow(clippy::unwrap_used)]
824 Regex::new(r"^(?: ?\\?(?:<!--(?:\d+|\.)-->|<(?:\d+|\.)>))*(?:\n|$)").unwrap()
825});
826
827static CUSTOM_CALLOUT_TAIL_RX: LazyLock<Regex> = LazyLock::new(|| {
830 #[allow(clippy::unwrap_used)]
831 Regex::new(r"^(?: ?\\?<(?:\d+|\.)>)*(?:\n|$)").unwrap()
832});
833
834fn build_callout_regexes(line_comment: Option<&str>) -> (Cow<'static, Regex>, &'static Regex) {
847 match line_comment {
848 None => (Cow::Borrowed(&DEFAULT_CALLOUT_RX), &DEFAULT_CALLOUT_TAIL_RX),
850
851 Some(prefix) => {
854 let prefix_pattern = if prefix.is_empty() {
855 String::new()
856 } else {
857 format!(r"(?P<prefix>{} ?)?", regex::escape(prefix))
858 };
859
860 #[allow(clippy::unwrap_used)]
861 let callout = Regex::new(&format!(
862 r"{prefix_pattern}(?P<esc>\\)?<(?P<num>\d+|\.)>"
863 ))
864 .unwrap();
865
866 (Cow::Owned(callout), &CUSTOM_CALLOUT_TAIL_RX)
867 }
868 }
869}
870
871struct CalloutReplacer<'r> {
874 renderer: &'r dyn InlineSubstitutionRenderer,
875 parser: &'r Parser,
876
877 autonum: u32,
880
881 tail: &'r Regex,
883}
884
885impl LookaheadReplacer for CalloutReplacer<'_> {
886 fn replace_append(
887 &mut self,
888 caps: &Captures<'_>,
889 dest: &mut String,
890 after: &str,
891 ) -> LookaheadResult {
892 if !self.tail.is_match(after) {
895 dest.push_str(&caps[0]);
896 return LookaheadResult::Continue;
897 }
898
899 if caps.name("esc").is_some() {
902 dest.push_str(&caps[0].replacen('\\', "", 1));
903 return LookaheadResult::Continue;
904 }
905
906 let (number_raw, is_xml) = if let Some(xnum) = caps.name("xnum") {
907 (xnum.as_str(), true)
908 } else {
909 #[allow(clippy::unwrap_used)]
911 (caps.name("num").unwrap().as_str(), false)
912 };
913
914 let number = if number_raw == "." {
915 self.autonum += 1;
916 self.autonum.to_string()
917 } else {
918 number_raw.to_string()
919 };
920
921 if let Ok(n) = number.parse::<u32>() {
924 self.parser.register_callout(n);
925 }
926
927 let guard = match caps.name("prefix") {
931 Some(prefix) => CalloutGuard::LineComment(prefix.as_str()),
932 None if is_xml => CalloutGuard::Xml,
933 None => CalloutGuard::LineComment(""),
934 };
935
936 self.renderer.render_callout(
937 &CalloutRenderParams {
938 number: &number,
939 guard,
940 parser: self.parser,
941 },
942 dest,
943 );
944
945 LookaheadResult::Continue
946 }
947}
948
949#[cfg(test)]
950mod tests {
951 #![allow(clippy::unwrap_used)]
952
953 mod special_characters {
954 use crate::{
955 content::{Content, SubstitutionStep},
956 strings::CowStr,
957 tests::prelude::*,
958 };
959
960 #[test]
961 fn empty() {
962 let mut content = Content::from(crate::Span::default());
963 let p = Parser::default();
964 SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
965 assert!(content.is_empty());
966 assert_eq!(content.rendered, CowStr::Borrowed(""));
967 }
968
969 #[test]
970 fn basic_non_empty_span() {
971 let mut content = Content::from(crate::Span::new("blah"));
972 let p = Parser::default();
973 SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
974 assert!(!content.is_empty());
975 assert_eq!(content.rendered, CowStr::Borrowed("blah"));
976 }
977
978 #[test]
979 fn match_lt_and_gt() {
980 let mut content = Content::from(crate::Span::new("bl<ah>"));
981 let p = Parser::default();
982 SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
983 assert!(!content.is_empty());
984 assert_eq!(
985 content.rendered,
986 CowStr::Boxed("bl<ah>".to_string().into_boxed_str())
987 );
988 }
989
990 #[test]
991 fn match_amp() {
992 let mut content = Content::from(crate::Span::new("bl<a&h>"));
993 let p = Parser::default();
994 SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
995 assert!(!content.is_empty());
996 assert_eq!(
997 content.rendered,
998 CowStr::Boxed("bl<a&h>".to_string().into_boxed_str())
999 );
1000 }
1001 }
1002
1003 mod quotes {
1004 use crate::{
1005 content::{Content, SubstitutionStep},
1006 strings::CowStr,
1007 tests::prelude::*,
1008 };
1009
1010 #[test]
1011 fn empty() {
1012 let mut content = Content::from(crate::Span::default());
1013 let p = Parser::default();
1014 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1015 assert!(content.is_empty());
1016 assert_eq!(content.rendered, CowStr::Borrowed(""));
1017 }
1018
1019 #[test]
1020 fn basic_non_empty_span() {
1021 let mut content = Content::from(crate::Span::new("blah"));
1022 let p = Parser::default();
1023 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1024 assert!(!content.is_empty());
1025 assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1026 }
1027
1028 #[test]
1029 fn ignore_lt_and_gt() {
1030 let mut content = Content::from(crate::Span::new("bl<ah>"));
1031 let p = Parser::default();
1032 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1033 assert!(!content.is_empty());
1034 assert_eq!(
1035 content.rendered,
1036 CowStr::Boxed("bl<ah>".to_string().into_boxed_str())
1037 );
1038 }
1039
1040 #[test]
1041 fn strong_word() {
1042 let mut content = Content::from(crate::Span::new("One *word* is strong."));
1043 let p = Parser::default();
1044 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1045 assert!(!content.is_empty());
1046 assert_eq!(
1047 content.rendered,
1048 CowStr::Boxed(
1049 "One <strong>word</strong> is strong."
1050 .to_string()
1051 .into_boxed_str()
1052 )
1053 );
1054 }
1055
1056 #[test]
1057 fn marked_string_with_id() {
1058 let mut content = Content::from(crate::Span::new(r#"[#id]#a few words#"#));
1059 let p = Parser::default();
1060 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1061 assert!(!content.is_empty());
1062 assert_eq!(
1063 content.rendered,
1064 CowStr::Boxed(r#"<span id="id">a few words</span>"#.to_string().into_boxed_str())
1065 );
1066 }
1067 }
1068
1069 mod attribute_references {
1070 use crate::{
1071 content::{Content, SubstitutionStep},
1072 strings::CowStr,
1073 tests::prelude::*,
1074 };
1075
1076 #[test]
1077 fn empty() {
1078 let mut content = Content::from(crate::Span::default());
1079 let p = Parser::default();
1080 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1081 assert!(content.is_empty());
1082 assert_eq!(content.rendered, CowStr::Borrowed(""));
1083 }
1084
1085 #[test]
1086 fn basic_non_empty_span() {
1087 let mut content = Content::from(crate::Span::new("blah"));
1088 let p = Parser::default();
1089 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1090 assert!(!content.is_empty());
1091 assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1092 }
1093
1094 #[test]
1095 fn ignore_non_match() {
1096 let mut content = Content::from(crate::Span::new("bl{ah}"));
1097 let p = Parser::default();
1098 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1099 assert!(!content.is_empty());
1100 assert_eq!(
1101 content.rendered,
1102 CowStr::Boxed("bl{ah}".to_string().into_boxed_str())
1103 );
1104 }
1105
1106 #[test]
1107 fn ignore_escaped_non_match() {
1108 let mut content = Content::from(crate::Span::new("bl\\{ah}"));
1109 let p = Parser::default();
1110 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1111 assert!(!content.is_empty());
1112 assert_eq!(
1113 content.rendered,
1114 CowStr::Boxed("bl\\{ah}".to_string().into_boxed_str())
1115 );
1116 }
1117
1118 #[test]
1119 fn replace_sp_match() {
1120 let mut content = Content::from(crate::Span::new("bl{sp}ah"));
1121 let p = Parser::default();
1122 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1123 assert!(!content.is_empty());
1124 assert_eq!(
1125 content.rendered,
1126 CowStr::Boxed("bl ah".to_string().into_boxed_str())
1127 );
1128 }
1129
1130 #[test]
1131 fn ignore_escaped_sp_match() {
1132 let mut content = Content::from(crate::Span::new("bl\\{sp}ah"));
1133 let p = Parser::default();
1134 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1135 assert!(!content.is_empty());
1136 assert_eq!(
1137 content.rendered,
1138 CowStr::Boxed("bl{sp}ah".to_string().into_boxed_str())
1139 );
1140 }
1141 }
1142
1143 mod callouts {
1144 use crate::{
1145 content::{Content, SubstitutionStep},
1146 parser::ModificationContext,
1147 strings::CowStr,
1148 tests::prelude::*,
1149 };
1150
1151 fn render_callouts(text: &str, parser: &Parser) -> String {
1155 let mut content = Content::from(crate::Span::new(text));
1156 SubstitutionStep::Callouts.apply(&mut content, parser, None);
1159 content.rendered.to_string()
1160 }
1161
1162 #[test]
1163 fn empty() {
1164 let mut content = Content::from(crate::Span::default());
1165 let p = Parser::default();
1166 SubstitutionStep::Callouts.apply(&mut content, &p, None);
1167 assert!(content.is_empty());
1168 assert_eq!(content.rendered, CowStr::Borrowed(""));
1169 }
1170
1171 #[test]
1172 fn no_callouts() {
1173 let p = Parser::default();
1174 assert_eq!(render_callouts("just some text", &p), "just some text");
1175 }
1176
1177 #[test]
1178 fn lt_without_callout_is_untouched() {
1179 let p = Parser::default();
1180 assert_eq!(render_callouts("a <b> c", &p), "a <b> c");
1181 }
1182
1183 #[test]
1184 fn basic_explicit() {
1185 let p = Parser::default();
1186 assert_eq!(
1187 render_callouts("require 'x' <1>", &p),
1188 r#"require 'x' <b class="conum">(1)</b>"#
1189 );
1190 }
1191
1192 #[test]
1193 fn line_comment_prefix_preserved() {
1194 let p = Parser::default();
1195 assert_eq!(
1196 render_callouts("puts 'x' # <1>", &p),
1197 r#"puts 'x' # <b class="conum">(1)</b>"#
1198 );
1199 }
1200
1201 #[test]
1202 fn multiple_on_one_line() {
1203 let p = Parser::default();
1204 assert_eq!(
1205 render_callouts("puts x <5><6>", &p),
1206 r#"puts x <b class="conum">(5)</b><b class="conum">(6)</b>"#
1207 );
1208 }
1209
1210 #[test]
1211 fn not_at_end_of_line() {
1212 let p = Parser::default();
1213 assert_eq!(
1214 render_callouts("puts \"<1> in the middle\"", &p),
1215 "puts \"<1> in the middle\""
1216 );
1217 }
1218
1219 #[test]
1220 fn auto_numbering() {
1221 let p = Parser::default();
1222 assert_eq!(
1223 render_callouts("a <.>\nb <.>\nc <.>", &p),
1224 "a <b class=\"conum\">(1)</b>\nb <b class=\"conum\">(2)</b>\nc <b class=\"conum\">(3)</b>"
1225 );
1226 }
1227
1228 #[test]
1229 fn mixed_numbering_ignores_explicit() {
1230 let p = Parser::default();
1232 assert_eq!(
1233 render_callouts("a <.>\nb <1>\nc <.>", &p),
1234 "a <b class=\"conum\">(1)</b>\nb <b class=\"conum\">(1)</b>\nc <b class=\"conum\">(2)</b>"
1235 );
1236 }
1237
1238 #[test]
1239 fn xml_callout() {
1240 let p = Parser::default();
1241 assert_eq!(
1242 render_callouts("<child/> <!--1-->", &p),
1243 r#"<child/> <!--<b class="conum">(1)</b>-->"#
1244 );
1245 }
1246
1247 #[test]
1248 fn half_xml_comment_is_not_a_callout() {
1249 let p = Parser::default();
1250 assert_eq!(
1251 render_callouts("First line <1-->", &p),
1252 "First line <1-->"
1253 );
1254 }
1255
1256 #[test]
1257 fn escaped_callout() {
1258 let p = Parser::default();
1259 assert_eq!(
1260 render_callouts("require 'x' # \\<1>", &p),
1261 "require 'x' # <1>"
1262 );
1263 }
1264
1265 #[test]
1266 fn icons_font() {
1267 let p = Parser::default().with_intrinsic_attribute(
1268 "icons",
1269 "font",
1270 ModificationContext::Anywhere,
1271 );
1272 assert_eq!(
1273 render_callouts("puts x # <1>", &p),
1274 r#"puts x <i class="conum" data-value="1"></i><b>(1)</b>"#
1275 );
1276 }
1277
1278 #[test]
1279 fn icons_image() {
1280 let p = Parser::default().with_intrinsic_attribute(
1281 "icons",
1282 "",
1283 ModificationContext::Anywhere,
1284 );
1285 assert_eq!(
1286 render_callouts("puts x <1>", &p),
1287 r#"puts x <img src="./images/icons/callouts/1.png" alt="1">"#
1288 );
1289 }
1290
1291 #[test]
1292 fn custom_line_comment_prefix() {
1293 let mut content = Content::from(crate::Span::new("hello() -> % <1>"));
1295 let attrlist = crate::attributes::Attrlist::parse(
1296 crate::Span::new("source,erlang,line-comment=%"),
1297 &Parser::default(),
1298 crate::attributes::AttrlistContext::Block,
1299 )
1300 .item
1301 .item;
1302 let p = Parser::default();
1303 SubstitutionStep::Callouts.apply(&mut content, &p, Some(&attrlist));
1304 assert_eq!(
1305 content.rendered.to_string(),
1306 r#"hello() -> % <b class="conum">(1)</b>"#
1307 );
1308 }
1309
1310 #[test]
1311 fn disabled_line_comment_preserves_leading_chars() {
1312 let mut content = Content::from(crate::Span::new("-- <1>"));
1315 let attrlist = crate::attributes::Attrlist::parse(
1316 crate::Span::new("source,asciidoc,line-comment="),
1317 &Parser::default(),
1318 crate::attributes::AttrlistContext::Block,
1319 )
1320 .item
1321 .item;
1322 let p = Parser::default();
1323 SubstitutionStep::Callouts.apply(&mut content, &p, Some(&attrlist));
1324 assert_eq!(
1325 content.rendered.to_string(),
1326 r#"-- <b class="conum">(1)</b>"#
1327 );
1328 }
1329
1330 #[test]
1331 fn document_line_comment_attribute() {
1332 let p = Parser::default().with_intrinsic_attribute(
1335 "line-comment",
1336 "%",
1337 ModificationContext::Anywhere,
1338 );
1339 assert_eq!(
1340 render_callouts("hello() -> % <1>", &p),
1341 r#"hello() -> % <b class="conum">(1)</b>"#
1342 );
1343 }
1344 }
1345}