1use std::{borrow::Cow, ops::Range, sync::LazyLock};
2
3use regex::{Captures, Regex, RegexBuilder, Replacer};
4
5use crate::{
6 Parser, Span,
7 attributes::{Attrlist, AttrlistContext},
8 content::Content,
9 document::{InterpretedValue, RefType},
10 internal::{LookaheadReplacer, LookaheadResult, replace_with_lookahead},
11 parser::{
12 CalloutGuard, CalloutRenderParams, CharacterReplacementType, InlineSubstitutionRenderer,
13 QuoteScope, QuoteType, SpecialCharacter,
14 },
15 strings::CowStr,
16 warnings::WarningType,
17};
18
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25pub enum SubstitutionStep {
26 SpecialCharacters,
29
30 Quotes,
32
33 AttributeReferences,
35
36 CharacterReplacements,
40
41 Macros,
44
45 PostReplacement,
47
48 Callouts,
50}
51
52impl SubstitutionStep {
53 pub(crate) fn apply(
54 &self,
55 content: &mut Content<'_>,
56 parser: &Parser,
57 attrlist: Option<&Attrlist<'_>>,
58 ) {
59 match self {
60 Self::SpecialCharacters => {
61 apply_special_characters(content, &*parser.renderer);
62 }
63 Self::Quotes => {
64 apply_quotes(content, parser);
65 }
66 Self::AttributeReferences => {
67 apply_attributes(content, parser);
68 }
69 Self::CharacterReplacements => {
70 apply_character_replacements(content, &*parser.renderer);
71 }
72 Self::Macros => {
73 super::macros::apply_macros(content, parser);
74 }
75 Self::PostReplacement => {
76 apply_post_replacements(content, parser, attrlist);
77 }
78 Self::Callouts => {
79 apply_callouts(content, parser, attrlist);
80 }
81 }
82 }
83}
84
85fn apply_special_characters(content: &mut Content<'_>, renderer: &dyn InlineSubstitutionRenderer) {
86 if !content.rendered.contains(['<', '>', '&']) {
87 return;
88 }
89
90 let mut result: Cow<'_, str> = content.rendered.to_string().into();
91 let replacer = SpecialCharacterReplacer { renderer };
92
93 if let Cow::Owned(new_result) = SPECIAL_CHARS.replace_all(&result, replacer) {
94 result = new_result.into();
95 }
96
97 content.rendered = result.into();
98}
99
100static SPECIAL_CHARS: LazyLock<Regex> = LazyLock::new(|| {
101 #[allow(clippy::unwrap_used)]
102 Regex::new("[<>&]").unwrap()
103});
104
105#[derive(Debug)]
106struct SpecialCharacterReplacer<'r> {
107 renderer: &'r dyn InlineSubstitutionRenderer,
108}
109
110impl Replacer for SpecialCharacterReplacer<'_> {
111 fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
112 let ch = &caps[0];
115
116 if ch == "<" {
117 self.renderer
118 .render_special_character(SpecialCharacter::Lt, dest);
119 } else if ch == ">" {
120 self.renderer
121 .render_special_character(SpecialCharacter::Gt, dest);
122 } else if ch == "&" {
123 self.renderer
124 .render_special_character(SpecialCharacter::Ampersand, dest);
125 }
126
127 }
130}
131
132static QUOTED_TEXT_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
133 #[allow(clippy::unwrap_used)]
134 Regex::new("[*_`#^~]").unwrap()
135});
136
137struct QuoteSub {
138 type_: QuoteType,
139 scope: QuoteScope,
140 pattern: Regex,
141}
142
143static QUOTE_SUBS: LazyLock<Vec<QuoteSub>> = LazyLock::new(|| {
164 vec![
165 QuoteSub {
166 type_: QuoteType::Strong,
168 scope: QuoteScope::Unconstrained,
169 #[allow(clippy::unwrap_used)]
170 pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?\*\*(.+?)\*\*"#)
171 .dot_matches_new_line(true)
172 .build()
173 .unwrap(),
174 },
175 QuoteSub {
176 type_: QuoteType::Strong,
178 scope: QuoteScope::Constrained,
179 #[allow(clippy::unwrap_used)]
180 pattern: RegexBuilder::new(
181 r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?\*(\S|\S.*?\S)\*\b{end-half}"#,
182 )
183 .dot_matches_new_line(true)
184 .build()
185 .unwrap(),
186 },
187 QuoteSub {
188 type_: QuoteType::DoubleQuote,
190 scope: QuoteScope::Constrained,
191 #[allow(clippy::unwrap_used)]
192 pattern: RegexBuilder::new(
193 r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?"`(\S|\S.*?\S)`"\b{end-half}"#,
194 )
195 .dot_matches_new_line(true)
196 .build()
197 .unwrap(),
198 },
199 QuoteSub {
200 type_: QuoteType::SingleQuote,
202 scope: QuoteScope::Constrained,
203 #[allow(clippy::unwrap_used)]
204 pattern: RegexBuilder::new(
205 r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?'`(\S|\S.*?\S)`'\b{end-half}"#,
206 )
207 .dot_matches_new_line(true)
208 .build()
209 .unwrap(),
210 },
211 QuoteSub {
212 type_: QuoteType::Monospaced,
214 scope: QuoteScope::Unconstrained,
215 #[allow(clippy::unwrap_used)]
216 pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?``(.+?)``"#)
217 .dot_matches_new_line(true)
218 .build()
219 .unwrap(),
220 },
221 QuoteSub {
222 type_: QuoteType::Monospaced,
224 scope: QuoteScope::Constrained,
225 #[allow(clippy::unwrap_used)]
226 pattern: RegexBuilder::new(
227 r#"(^|[^\w&;:"'`}])(?:\[([^\[\]]+)\])?`(\S|\S.*?\S)`\b{end-half}"#,
228 )
232 .dot_matches_new_line(true)
233 .build()
234 .unwrap(),
235 },
236 QuoteSub {
237 type_: QuoteType::Emphasis,
239 scope: QuoteScope::Unconstrained,
240 #[allow(clippy::unwrap_used)]
241 pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?__(.+?)__"#)
242 .dot_matches_new_line(true)
243 .build()
244 .unwrap(),
245 },
246 QuoteSub {
247 type_: QuoteType::Emphasis,
249 scope: QuoteScope::Constrained,
250 #[allow(clippy::unwrap_used)]
251 pattern: RegexBuilder::new(
252 r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?_(\S|\S.*?\S)_\b{end-half}"#,
253 )
254 .dot_matches_new_line(true)
255 .build()
256 .unwrap(),
257 },
258 QuoteSub {
259 type_: QuoteType::Mark,
261 scope: QuoteScope::Unconstrained,
262 #[allow(clippy::unwrap_used)]
263 pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?##(.+?)##"#)
264 .dot_matches_new_line(true)
265 .build()
266 .unwrap(),
267 },
268 QuoteSub {
269 type_: QuoteType::Mark,
271 scope: QuoteScope::Constrained,
272 #[allow(clippy::unwrap_used)]
273 pattern: RegexBuilder::new(
274 r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?#(\S|\S.*?\S)#\b{end-half}"#,
275 )
276 .dot_matches_new_line(true)
277 .build()
278 .unwrap(),
279 },
280 QuoteSub {
281 type_: QuoteType::Superscript,
283 scope: QuoteScope::Unconstrained,
284 #[allow(clippy::unwrap_used)]
285 pattern: Regex::new(r#"\\?(?:\[([^\[\]]+)\])?\^(\S+?)\^"#).unwrap(),
286 },
287 QuoteSub {
288 type_: QuoteType::Subscript,
290 scope: QuoteScope::Unconstrained,
291 #[allow(clippy::unwrap_used)]
292 pattern: Regex::new(r#"\\?(?:\[([^\[\]]+)\])?~(\S+?)~"#).unwrap(),
293 },
294 ]
295});
296
297#[derive(Debug)]
298struct QuoteReplacer<'r> {
299 type_: QuoteType,
300 scope: QuoteScope,
301 parser: &'r Parser,
302}
303
304impl LookaheadReplacer for QuoteReplacer<'_> {
305 fn replace_append(
306 &mut self,
307 caps: &Captures<'_>,
308 dest: &mut String,
309 after: &str,
310 ) -> LookaheadResult {
311 if self.type_ == QuoteType::Monospaced
318 && self.scope == QuoteScope::Constrained
319 && after.starts_with(['"', '\'', '`'])
320 {
321 let skip_ahead = if caps[0].starts_with('\\') { 2 } else { 1 };
322 dest.push_str(&caps[0][0..skip_ahead]);
323 return LookaheadResult::SkipAheadAndRetry(skip_ahead);
324 }
325
326 let unescaped_attrs: Option<String> = if caps[0].starts_with('\\') {
327 let maybe_attrs = caps.get(2).map(|a| a.as_str());
328 if self.scope == QuoteScope::Constrained && maybe_attrs.is_some() {
329 Some(format!(
330 "[{attrs}]",
331 attrs = maybe_attrs.unwrap_or_default()
332 ))
333 } else {
334 dest.push_str(&caps[0][1..]);
335 return LookaheadResult::Continue;
336 }
337 } else {
338 None
339 };
340
341 match self.scope {
342 QuoteScope::Constrained => {
343 if let Some(attrs) = unescaped_attrs {
344 dest.push_str(&attrs);
345 self.parser.renderer.render_quoted_substitition(
346 self.type_, self.scope, None, None, &caps[3], dest,
347 );
348 } else {
349 let (attrlist, type_): (Option<Attrlist<'_>>, QuoteType) =
350 if let Some(attrlist) = caps.get(2) {
351 let type_ = if self.type_ == QuoteType::Mark {
352 QuoteType::Unquoted
353 } else {
354 self.type_
355 };
356
357 (
358 Some(
359 Attrlist::parse(
360 crate::Span::new(attrlist.as_str()),
361 self.parser,
362 AttrlistContext::Inline,
363 )
364 .item
365 .item,
366 ),
367 type_,
368 )
369 } else {
370 (None, self.type_)
371 };
372
373 if let Some(prefix) = caps.get(1) {
374 dest.push_str(prefix.as_str());
375 }
376
377 let id = attrlist
378 .as_ref()
379 .and_then(|a| a.id().map(|s| s.to_string()));
380
381 if let Some(id) = &id {
386 let _ = self.parser.register_ref(id, None, RefType::Anchor);
387 }
388
389 self.parser.renderer.render_quoted_substitition(
390 type_, self.scope, attrlist, id, &caps[3], dest,
391 );
392 }
393 }
394
395 QuoteScope::Unconstrained => {
396 let (attrlist, type_): (Option<Attrlist<'_>>, QuoteType) =
397 if let Some(attrlist) = caps.get(1) {
398 let type_ = if self.type_ == QuoteType::Mark {
399 QuoteType::Unquoted
400 } else {
401 self.type_
402 };
403
404 (
405 Some(
406 Attrlist::parse(
407 crate::Span::new(attrlist.as_str()),
408 self.parser,
409 AttrlistContext::Inline,
410 )
411 .item
412 .item,
413 ),
414 type_,
415 )
416 } else {
417 (None, self.type_)
418 };
419
420 let id = attrlist
421 .as_ref()
422 .and_then(|a| a.id().map(|s| s.to_string()));
423
424 if let Some(id) = &id {
429 let _ = self.parser.register_ref(id, None, RefType::Anchor);
430 }
431
432 self.parser
433 .renderer
434 .render_quoted_substitition(type_, self.scope, attrlist, id, &caps[2], dest);
435 }
436 }
437
438 LookaheadResult::Continue
439 }
440}
441
442fn apply_quotes(content: &mut Content<'_>, parser: &Parser) {
443 if !QUOTED_TEXT_SNIFF.is_match(content.rendered.as_ref()) {
444 return;
445 }
446
447 let mut result: Cow<'_, str> = content.rendered.to_string().into();
448
449 for sub in &*QUOTE_SUBS {
450 let replacer = QuoteReplacer {
451 type_: sub.type_,
452 scope: sub.scope,
453 parser,
454 };
455
456 if let Cow::Owned(new_result) = replace_with_lookahead(&sub.pattern, &result, replacer) {
457 result = new_result.into();
458 }
459 }
462
463 content.rendered = result.into();
464}
465
466static ATTRIBUTE_REFERENCE: LazyLock<Regex> = LazyLock::new(|| {
467 #[allow(clippy::unwrap_used)]
471 Regex::new(r#"\\?\{(?:(counter2?):([^{}]+)|([A-Za-z0-9_][A-Za-z0-9_-]*))\}"#).unwrap()
472});
473
474#[derive(Clone, Copy, Debug, Eq, PartialEq)]
479enum AttributeMissing {
480 Skip,
482
483 Drop,
485
486 DropLine,
488
489 Warn,
491}
492
493impl AttributeMissing {
494 fn from_parser(parser: &Parser) -> Self {
498 match parser.attribute_value("attribute-missing").as_maybe_str() {
499 Some("drop") => Self::Drop,
500 Some("drop-line") => Self::DropLine,
501 Some("warn") => Self::Warn,
502 _ => Self::Skip,
503 }
504 }
505}
506
507#[derive(Debug)]
558struct AttributeReplacer<'p> {
559 parser: &'p Parser,
560
561 mode: AttributeMissing,
563
564 fallback_source: Span<'p>,
568
569 source_line: Option<Span<'p>>,
574
575 source_matches: Vec<Range<usize>>,
579
580 match_index: usize,
585
586 missing_on_line: bool,
590}
591
592impl<'p> AttributeReplacer<'p> {
593 fn new(
600 parser: &'p Parser,
601 mode: AttributeMissing,
602 fallback_source: Span<'p>,
603 source_line: Option<Span<'p>>,
604 ) -> Self {
605 let source_matches = match (mode, source_line) {
608 (AttributeMissing::Warn, Some(line)) => ATTRIBUTE_REFERENCE
609 .find_iter(line.data())
610 .map(|m| m.range())
611 .collect(),
612 _ => Vec::new(),
613 };
614
615 Self {
616 parser,
617 mode,
618 fallback_source,
619 source_line,
620 source_matches,
621 match_index: 0,
622 missing_on_line: false,
623 }
624 }
625
626 fn warning_source(&self, index: usize, matched: &str) -> Span<'p> {
635 if let Some(line) = self.source_line
636 && let Some(range) = self.source_matches.get(index)
637 && line.data().get(range.clone()) == Some(matched)
638 {
639 return line.slice(range.clone());
640 }
641
642 self.fallback_source
643 }
644}
645
646impl Replacer for AttributeReplacer<'_> {
647 fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
648 let match_index = self.match_index;
651 self.match_index += 1;
652
653 let escaped = caps[0].starts_with('\\');
654
655 if let Some(directive) = caps.get(1) {
658 if escaped {
659 dest.push_str(&caps[0][1..]);
662 return;
663 }
664
665 let mut parts = caps[2].splitn(2, ':');
668 let name = parts.next().unwrap_or_default();
669 let seed = parts.next();
670
671 let value = self.parser.counter(name, seed);
672
673 if directive.as_str() == "counter" {
675 dest.push_str(&value);
676 }
677 return;
678 }
679
680 let attr_name = &caps[3];
682
683 if escaped {
690 dest.push_str(&caps[0][1..]);
691 return;
692 }
693
694 if !self.parser.has_attribute(attr_name) {
695 match self.mode {
696 AttributeMissing::Skip => dest.push_str(&caps[0]),
697 AttributeMissing::Drop => {
698 }
700 AttributeMissing::DropLine => {
701 self.missing_on_line = true;
704 }
705 AttributeMissing::Warn => {
706 dest.push_str(&caps[0]);
707 self.parser.record_substitution_warning(
708 self.warning_source(match_index, &caps[0]),
709 WarningType::SkippingReferenceToMissingAttribute(attr_name.to_string()),
710 );
711 }
712 }
713 return;
714 }
715
716 if let InterpretedValue::Value(value) = self.parser.attribute_value(attr_name) {
717 dest.push_str(value.as_ref());
718 }
719 }
722}
723
724fn apply_attributes(content: &mut Content<'_>, parser: &Parser) {
725 if !content.rendered.contains('{') {
726 return;
727 }
728
729 let mode = AttributeMissing::from_parser(parser);
730 let source = content.original();
731
732 let source_lines = if mode == AttributeMissing::Warn {
739 content
740 .source_lines()
741 .filter(|lines| lines.len() == content.rendered.split('\n').count())
742 } else {
743 None
744 };
745
746 let mut out = String::with_capacity(content.rendered.len());
752 let mut changed = false;
753 let mut wrote_line = false;
754
755 for (index, line) in content.rendered.split('\n').enumerate() {
756 if !line.contains('{') {
757 if wrote_line {
758 out.push('\n');
759 }
760 out.push_str(line);
761 wrote_line = true;
762 continue;
763 }
764
765 let source_line = source_lines.and_then(|lines| lines.get(index).copied());
769 let mut replacer = AttributeReplacer::new(parser, mode, source, source_line);
770
771 let replaced = ATTRIBUTE_REFERENCE.replace_all(line, replacer.by_ref());
772
773 if replacer.missing_on_line && mode == AttributeMissing::DropLine {
774 changed = true;
776 continue;
777 }
778
779 if let Cow::Owned(_) = replaced {
780 changed = true;
781 }
782
783 if wrote_line {
784 out.push('\n');
785 }
786 out.push_str(&replaced);
787 wrote_line = true;
788 }
789
790 if changed {
793 content.rendered = out.into();
794 }
795}
796
797pub(crate) fn substitute_attributes_in_macro_target<'src>(
809 target: Span<'src>,
810 parser: &Parser,
811) -> Option<CowStr<'src>> {
812 let text = target.data();
813
814 if !text.contains('{') {
817 return Some(text.into());
818 }
819
820 let mode = AttributeMissing::from_parser(parser);
821
822 let mut replacer = AttributeReplacer::new(parser, mode, target, Some(target));
825
826 let replaced = ATTRIBUTE_REFERENCE.replace_all(text, replacer.by_ref());
827
828 if replacer.missing_on_line && mode == AttributeMissing::DropLine {
829 return None;
830 }
831
832 Some(replaced.into())
833}
834
835pub(crate) fn substitute_attributes_in_text(text: &str, parser: &Parser) -> String {
852 if !text.contains('{') {
853 return text.to_string();
854 }
855
856 let mode = AttributeMissing::from_parser(parser);
857 let source = Span::new(text);
858
859 let mut out = String::with_capacity(text.len());
860 let mut wrote_line = false;
861
862 for line in text.split('\n') {
863 if !line.contains('{') {
864 if wrote_line {
865 out.push('\n');
866 }
867 out.push_str(line);
868 wrote_line = true;
869 continue;
870 }
871
872 let mut replacer = AttributeReplacer::new(parser, mode, source, None);
876
877 let replaced = ATTRIBUTE_REFERENCE.replace_all(line, replacer.by_ref());
878
879 if replacer.missing_on_line && mode == AttributeMissing::DropLine {
880 continue;
882 }
883
884 if wrote_line {
885 out.push('\n');
886 }
887 out.push_str(&replaced);
888 wrote_line = true;
889 }
890
891 out
892}
893
894fn apply_character_replacements(
895 content: &mut Content<'_>,
896 renderer: &dyn InlineSubstitutionRenderer,
897) {
898 if !REPLACEABLE_TEXT_SNIFF.is_match(content.rendered.as_ref()) {
899 return;
900 }
901
902 let mut result: Cow<'_, str> = content.rendered.to_string().into();
903
904 for repl in &*REPLACEMENTS {
905 let replacer = CharacterReplacer {
906 type_: repl.type_.clone(),
907 renderer,
908 };
909
910 if let Cow::Owned(new_result) = repl.pattern.replace_all(&result, replacer) {
911 result = new_result.into();
912 }
913 }
916
917 content.rendered = result.into();
918}
919
920struct CharacterReplacement {
921 type_: CharacterReplacementType,
922 pattern: Regex,
923}
924
925static REPLACEABLE_TEXT_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
926 #[allow(clippy::unwrap_used)]
927 Regex::new(r#"[&']|--|\.\.\.|\([CRT]M?\)"#).unwrap()
928});
929
930static REPLACEMENTS: LazyLock<Vec<CharacterReplacement>> = LazyLock::new(|| {
936 vec![
937 CharacterReplacement {
938 type_: CharacterReplacementType::Copyright,
940 #[allow(clippy::unwrap_used)]
941 pattern: Regex::new(r#"\\?\(C\)"#).unwrap(),
942 },
943 CharacterReplacement {
944 type_: CharacterReplacementType::Registered,
946 #[allow(clippy::unwrap_used)]
947 pattern: Regex::new(r#"\\?\(R\)"#).unwrap(),
948 },
949 CharacterReplacement {
950 type_: CharacterReplacementType::Trademark,
952 #[allow(clippy::unwrap_used)]
953 pattern: Regex::new(r#"\\?\(TM\)"#).unwrap(),
954 },
955 CharacterReplacement {
956 type_: CharacterReplacementType::EmDashSurroundedBySpaces,
958 #[allow(clippy::unwrap_used)]
959 pattern: Regex::new(r#"(?: |\n|^|\\)--(?: |\n|$)"#).unwrap(),
960 },
961 CharacterReplacement {
962 type_: CharacterReplacementType::EmDashWithoutSpace,
964 #[allow(clippy::unwrap_used)]
965 pattern: Regex::new(r#"(\w)\\?--\b{start-half}"#).unwrap(),
966 },
967 CharacterReplacement {
968 type_: CharacterReplacementType::Ellipsis,
970 #[allow(clippy::unwrap_used)]
971 pattern: Regex::new(r#"\\?\.\.\."#).unwrap(),
972 },
973 CharacterReplacement {
974 type_: CharacterReplacementType::TypographicApostrophe,
976 #[allow(clippy::unwrap_used)]
977 pattern: Regex::new(r#"\\?`'"#).unwrap(),
978 },
979 CharacterReplacement {
980 type_: CharacterReplacementType::TypographicApostrophe,
982 #[allow(clippy::unwrap_used)]
983 pattern: Regex::new(r#"([[:alnum:]])\\?'([[:alpha:]])"#).unwrap(),
984 },
985 CharacterReplacement {
986 type_: CharacterReplacementType::SingleRightArrow,
988 #[allow(clippy::unwrap_used)]
989 pattern: Regex::new(r#"\\?->"#).unwrap(),
990 },
991 CharacterReplacement {
992 type_: CharacterReplacementType::DoubleRightArrow,
994 #[allow(clippy::unwrap_used)]
995 pattern: Regex::new(r#"\\?=>"#).unwrap(),
996 },
997 CharacterReplacement {
998 type_: CharacterReplacementType::SingleLeftArrow,
1000 #[allow(clippy::unwrap_used)]
1001 pattern: Regex::new(r#"\\?<-"#).unwrap(),
1002 },
1003 CharacterReplacement {
1004 type_: CharacterReplacementType::DoubleLeftArrow,
1006 #[allow(clippy::unwrap_used)]
1007 pattern: Regex::new(r#"\\?<="#).unwrap(),
1008 },
1009 CharacterReplacement {
1010 type_: CharacterReplacementType::CharacterReference("".to_owned()),
1012 #[allow(clippy::unwrap_used)]
1013 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(),
1014 },
1015 ]
1016});
1017
1018#[derive(Debug)]
1019struct CharacterReplacer<'r> {
1020 type_: CharacterReplacementType,
1021 renderer: &'r dyn InlineSubstitutionRenderer,
1022}
1023
1024impl Replacer for CharacterReplacer<'_> {
1025 fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
1026 if caps[0].contains('\\') {
1027 let unescaped = &caps[0].replace("\\", "");
1029 dest.push_str(unescaped);
1030 return;
1031 }
1032
1033 match self.type_ {
1034 CharacterReplacementType::Copyright
1035 | CharacterReplacementType::Registered
1036 | CharacterReplacementType::Trademark
1037 | CharacterReplacementType::EmDashSurroundedBySpaces
1038 | CharacterReplacementType::Ellipsis
1039 | CharacterReplacementType::SingleLeftArrow
1040 | CharacterReplacementType::DoubleLeftArrow
1041 | CharacterReplacementType::SingleRightArrow
1042 | CharacterReplacementType::DoubleRightArrow => {
1043 self.renderer
1044 .render_character_replacement(self.type_.clone(), dest);
1045 }
1046
1047 CharacterReplacementType::EmDashWithoutSpace => {
1048 dest.push_str(&caps[1]);
1049 self.renderer.render_character_replacement(
1050 CharacterReplacementType::EmDashWithoutSpace,
1051 dest,
1052 );
1053 }
1054
1055 CharacterReplacementType::TypographicApostrophe => {
1056 if let Some(before) = caps.get(1) {
1057 dest.push_str(before.as_str());
1058 }
1059
1060 self.renderer.render_character_replacement(
1061 CharacterReplacementType::TypographicApostrophe,
1062 dest,
1063 );
1064
1065 if let Some(after) = caps.get(2) {
1066 dest.push_str(after.as_str());
1067 }
1068 }
1069
1070 CharacterReplacementType::CharacterReference(_) => {
1071 self.renderer.render_character_replacement(
1072 CharacterReplacementType::CharacterReference(caps[1].to_string()),
1073 dest,
1074 );
1075 }
1076 }
1077 }
1078}
1079
1080fn apply_post_replacements(
1081 content: &mut Content<'_>,
1082 parser: &Parser,
1083 attrlist: Option<&Attrlist<'_>>,
1084) {
1085 if parser.is_attribute_set("hardbreaks-option")
1086 || attrlist.is_some_and(|attrlist| attrlist.has_option("hardbreaks"))
1087 {
1088 let text = content.rendered.as_ref();
1089 if !text.contains('\n') {
1090 return;
1091 }
1092
1093 let mut lines: Vec<&str> = content.rendered.as_ref().lines().collect();
1094 let last = lines.pop().unwrap_or_default();
1095
1096 let mut lines: Vec<String> = lines
1097 .iter()
1098 .map(|line| {
1099 let line = if line.ends_with(" +") {
1100 &line[0..line.len() - 2]
1101 } else {
1102 *line
1103 };
1104
1105 let mut line = line.to_owned();
1106 parser.renderer.render_line_break(&mut line);
1107 line
1108 })
1109 .collect();
1110
1111 lines.push(last.to_owned());
1112
1113 let new_result = lines.join("\n");
1114 content.rendered = new_result.into();
1115 } else {
1116 let rendered = content.rendered.as_ref();
1117 if !(rendered.contains('+') && rendered.contains('\n')) {
1118 return;
1119 }
1120
1121 let replacer = PostReplacementReplacer(&*parser.renderer);
1122
1123 if let Cow::Owned(new_result) = HARD_LINE_BREAK.replace_all(rendered, replacer) {
1124 content.rendered = new_result.into();
1125 }
1126 }
1127}
1128
1129#[derive(Debug)]
1130struct PostReplacementReplacer<'r>(&'r dyn InlineSubstitutionRenderer);
1131
1132impl Replacer for PostReplacementReplacer<'_> {
1133 fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
1134 dest.push_str(&caps[1]);
1135 self.0.render_line_break(dest);
1136 }
1137}
1138
1139static HARD_LINE_BREAK: LazyLock<Regex> = LazyLock::new(|| {
1140 #[allow(clippy::unwrap_used)]
1141 Regex::new(r#"(?m)^(.*) \+$"#).unwrap()
1142});
1143
1144fn apply_callouts(content: &mut Content<'_>, parser: &Parser, attrlist: Option<&Attrlist<'_>>) {
1160 if !content.rendered.contains("<") {
1163 return;
1164 }
1165
1166 let line_comment: Option<String> = attrlist
1176 .and_then(|a| a.named_attribute("line-comment"))
1177 .map(|a| a.value().to_string())
1178 .or_else(|| {
1179 if parser.has_attribute("line-comment") {
1180 Some(
1181 parser
1182 .attribute_value("line-comment")
1183 .as_maybe_str()
1184 .unwrap_or("")
1185 .to_string(),
1186 )
1187 } else {
1188 None
1189 }
1190 });
1191
1192 let (callout_rx, tail_rx) = build_callout_regexes(line_comment.as_deref());
1193
1194 let replacer = CalloutReplacer {
1195 renderer: &*parser.renderer,
1196 parser,
1197 autonum: 0,
1198 tail: tail_rx,
1199 };
1200
1201 if let Cow::Owned(new_result) =
1202 replace_with_lookahead(&callout_rx, content.rendered.as_ref(), replacer)
1203 {
1204 content.rendered = new_result.into();
1205 }
1206}
1207
1208static DEFAULT_CALLOUT_RX: LazyLock<Regex> = LazyLock::new(|| {
1211 #[allow(clippy::unwrap_used)]
1212 Regex::new(
1213 r"(?P<prefix>(?://|#|--|;;) ?)?(?P<esc>\\)?(?:<!--(?P<xnum>\d+|\.)-->|<(?P<num>\d+|\.)>)",
1214 )
1215 .unwrap()
1216});
1217
1218static DEFAULT_CALLOUT_TAIL_RX: LazyLock<Regex> = LazyLock::new(|| {
1220 #[allow(clippy::unwrap_used)]
1221 Regex::new(r"^(?: ?\\?(?:<!--(?:\d+|\.)-->|<(?:\d+|\.)>))*(?:\n|$)").unwrap()
1222});
1223
1224static CUSTOM_CALLOUT_TAIL_RX: LazyLock<Regex> = LazyLock::new(|| {
1227 #[allow(clippy::unwrap_used)]
1228 Regex::new(r"^(?: ?\\?<(?:\d+|\.)>)*(?:\n|$)").unwrap()
1229});
1230
1231fn build_callout_regexes(line_comment: Option<&str>) -> (Cow<'static, Regex>, &'static Regex) {
1244 match line_comment {
1245 None => (Cow::Borrowed(&DEFAULT_CALLOUT_RX), &DEFAULT_CALLOUT_TAIL_RX),
1247
1248 Some(prefix) => {
1251 let prefix_pattern = if prefix.is_empty() {
1252 String::new()
1253 } else {
1254 format!(r"(?P<prefix>{} ?)?", regex::escape(prefix))
1255 };
1256
1257 #[allow(clippy::unwrap_used)]
1258 let callout = Regex::new(&format!(
1259 r"{prefix_pattern}(?P<esc>\\)?<(?P<num>\d+|\.)>"
1260 ))
1261 .unwrap();
1262
1263 (Cow::Owned(callout), &CUSTOM_CALLOUT_TAIL_RX)
1264 }
1265 }
1266}
1267
1268struct CalloutReplacer<'r> {
1271 renderer: &'r dyn InlineSubstitutionRenderer,
1272 parser: &'r Parser,
1273
1274 autonum: u32,
1277
1278 tail: &'r Regex,
1280}
1281
1282impl LookaheadReplacer for CalloutReplacer<'_> {
1283 fn replace_append(
1284 &mut self,
1285 caps: &Captures<'_>,
1286 dest: &mut String,
1287 after: &str,
1288 ) -> LookaheadResult {
1289 if !self.tail.is_match(after) {
1292 dest.push_str(&caps[0]);
1293 return LookaheadResult::Continue;
1294 }
1295
1296 if caps.name("esc").is_some() {
1299 dest.push_str(&caps[0].replacen('\\', "", 1));
1300 return LookaheadResult::Continue;
1301 }
1302
1303 let (number_raw, is_xml) = if let Some(xnum) = caps.name("xnum") {
1304 (xnum.as_str(), true)
1305 } else {
1306 #[allow(clippy::unwrap_used)]
1308 (caps.name("num").unwrap().as_str(), false)
1309 };
1310
1311 let number = if number_raw == "." {
1312 self.autonum += 1;
1313 self.autonum.to_string()
1314 } else {
1315 number_raw.to_string()
1316 };
1317
1318 if let Ok(n) = number.parse::<u32>() {
1321 self.parser.register_callout(n);
1322 }
1323
1324 let guard = match caps.name("prefix") {
1328 Some(prefix) => CalloutGuard::LineComment(prefix.as_str()),
1329 None if is_xml => CalloutGuard::Xml,
1330 None => CalloutGuard::LineComment(""),
1331 };
1332
1333 self.renderer.render_callout(
1334 &CalloutRenderParams {
1335 number: &number,
1336 guard,
1337 parser: self.parser,
1338 },
1339 dest,
1340 );
1341
1342 LookaheadResult::Continue
1343 }
1344}
1345
1346#[cfg(test)]
1347mod tests {
1348 #![allow(clippy::unwrap_used)]
1349
1350 mod special_characters {
1351 use crate::{
1352 content::{Content, SubstitutionStep},
1353 strings::CowStr,
1354 tests::prelude::*,
1355 };
1356
1357 #[test]
1358 fn empty() {
1359 let mut content = Content::from(crate::Span::default());
1360 let p = Parser::default();
1361 SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1362 assert!(content.is_empty());
1363 assert_eq!(content.rendered, CowStr::Borrowed(""));
1364 }
1365
1366 #[test]
1367 fn basic_non_empty_span() {
1368 let mut content = Content::from(crate::Span::new("blah"));
1369 let p = Parser::default();
1370 SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1371 assert!(!content.is_empty());
1372 assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1373 }
1374
1375 #[test]
1376 fn match_lt_and_gt() {
1377 let mut content = Content::from(crate::Span::new("bl<ah>"));
1378 let p = Parser::default();
1379 SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1380 assert!(!content.is_empty());
1381 assert_eq!(
1382 content.rendered,
1383 CowStr::Boxed("bl<ah>".to_string().into_boxed_str())
1384 );
1385 }
1386
1387 #[test]
1388 fn match_amp() {
1389 let mut content = Content::from(crate::Span::new("bl<a&h>"));
1390 let p = Parser::default();
1391 SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1392 assert!(!content.is_empty());
1393 assert_eq!(
1394 content.rendered,
1395 CowStr::Boxed("bl<a&h>".to_string().into_boxed_str())
1396 );
1397 }
1398 }
1399
1400 mod quotes {
1401 use crate::{
1402 content::{Content, SubstitutionStep},
1403 strings::CowStr,
1404 tests::prelude::*,
1405 };
1406
1407 #[test]
1408 fn empty() {
1409 let mut content = Content::from(crate::Span::default());
1410 let p = Parser::default();
1411 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1412 assert!(content.is_empty());
1413 assert_eq!(content.rendered, CowStr::Borrowed(""));
1414 }
1415
1416 #[test]
1417 fn basic_non_empty_span() {
1418 let mut content = Content::from(crate::Span::new("blah"));
1419 let p = Parser::default();
1420 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1421 assert!(!content.is_empty());
1422 assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1423 }
1424
1425 #[test]
1426 fn ignore_lt_and_gt() {
1427 let mut content = Content::from(crate::Span::new("bl<ah>"));
1428 let p = Parser::default();
1429 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1430 assert!(!content.is_empty());
1431 assert_eq!(
1432 content.rendered,
1433 CowStr::Boxed("bl<ah>".to_string().into_boxed_str())
1434 );
1435 }
1436
1437 #[test]
1438 fn strong_word() {
1439 let mut content = Content::from(crate::Span::new("One *word* is strong."));
1440 let p = Parser::default();
1441 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1442 assert!(!content.is_empty());
1443 assert_eq!(
1444 content.rendered,
1445 CowStr::Boxed(
1446 "One <strong>word</strong> is strong."
1447 .to_string()
1448 .into_boxed_str()
1449 )
1450 );
1451 }
1452
1453 #[test]
1454 fn marked_string_with_id() {
1455 let mut content = Content::from(crate::Span::new(r#"[#id]#a few words#"#));
1456 let p = Parser::default();
1457 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1458 assert!(!content.is_empty());
1459 assert_eq!(
1460 content.rendered,
1461 CowStr::Boxed(r#"<span id="id">a few words</span>"#.to_string().into_boxed_str())
1462 );
1463 }
1464
1465 #[test]
1466 fn unconstrained_marked_string_with_id_is_registered() {
1467 let doc = Parser::default().parse(r#"[#the_id]##marked text##"#);
1471
1472 assert_eq!(
1473 doc.nested_blocks()
1474 .next()
1475 .unwrap()
1476 .rendered_content()
1477 .unwrap(),
1478 r#"<span id="the_id">marked text</span>"#
1479 );
1480
1481 assert!(doc.catalog().contains_id("the_id"));
1482 }
1483 }
1484
1485 mod attribute_references {
1486 use crate::{
1487 content::{Content, SubstitutionStep},
1488 strings::CowStr,
1489 tests::prelude::*,
1490 };
1491
1492 #[test]
1493 fn empty() {
1494 let mut content = Content::from(crate::Span::default());
1495 let p = Parser::default();
1496 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1497 assert!(content.is_empty());
1498 assert_eq!(content.rendered, CowStr::Borrowed(""));
1499 }
1500
1501 #[test]
1502 fn basic_non_empty_span() {
1503 let mut content = Content::from(crate::Span::new("blah"));
1504 let p = Parser::default();
1505 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1506 assert!(!content.is_empty());
1507 assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1508 }
1509
1510 #[test]
1511 fn ignore_non_match() {
1512 let mut content = Content::from(crate::Span::new("bl{ah}"));
1513 let p = Parser::default();
1514 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1515 assert!(!content.is_empty());
1516 assert_eq!(
1517 content.rendered,
1518 CowStr::Boxed("bl{ah}".to_string().into_boxed_str())
1519 );
1520 }
1521
1522 #[test]
1523 fn escaped_reference_to_unset_attribute_drops_backslash() {
1524 let mut content = Content::from(crate::Span::new("bl\\{ah}"));
1528 let p = Parser::default();
1529 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1530 assert!(!content.is_empty());
1531 assert_eq!(
1532 content.rendered,
1533 CowStr::Boxed("bl{ah}".to_string().into_boxed_str())
1534 );
1535 }
1536
1537 #[test]
1538 fn replace_sp_match() {
1539 let mut content = Content::from(crate::Span::new("bl{sp}ah"));
1540 let p = Parser::default();
1541 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1542 assert!(!content.is_empty());
1543 assert_eq!(
1544 content.rendered,
1545 CowStr::Boxed("bl ah".to_string().into_boxed_str())
1546 );
1547 }
1548
1549 #[test]
1550 fn ignore_escaped_sp_match() {
1551 let mut content = Content::from(crate::Span::new("bl\\{sp}ah"));
1552 let p = Parser::default();
1553 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1554 assert!(!content.is_empty());
1555 assert_eq!(
1556 content.rendered,
1557 CowStr::Boxed("bl{sp}ah".to_string().into_boxed_str())
1558 );
1559 }
1560
1561 #[test]
1562 fn counter_directive_displays_and_advances() {
1563 let mut content = Content::from(crate::Span::new("{counter:n}-{counter:n}"));
1564 let p = Parser::default();
1565 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1566 assert_eq!(
1567 content.rendered,
1568 CowStr::Boxed("1-2".to_string().into_boxed_str())
1569 );
1570 }
1571
1572 #[test]
1573 fn counter2_directive_advances_silently() {
1574 let mut content = Content::from(crate::Span::new("{counter2:n}{counter:n}"));
1575 let p = Parser::default();
1576 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1577 assert_eq!(
1578 content.rendered,
1579 CowStr::Boxed("2".to_string().into_boxed_str())
1580 );
1581 }
1582
1583 #[test]
1584 fn escaped_counter_directive_is_literal_and_does_not_advance() {
1585 let mut content = Content::from(crate::Span::new("\\{counter:n} {counter:n}"));
1586 let p = Parser::default();
1587 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1588 assert_eq!(
1589 content.rendered,
1590 CowStr::Boxed("{counter:n} 1".to_string().into_boxed_str())
1591 );
1592 }
1593
1594 mod attribute_missing {
1595 #![allow(clippy::indexing_slicing)]
1596
1597 use crate::{
1598 Span,
1599 content::{Content, SubstitutionGroup, SubstitutionStep},
1600 parser::ModificationContext,
1601 tests::prelude::*,
1602 warnings::WarningType,
1603 };
1604
1605 fn parser_with_mode(mode: &str) -> Parser {
1606 Parser::default().with_intrinsic_attribute(
1607 "attribute-missing",
1608 mode,
1609 ModificationContext::Anywhere,
1610 )
1611 }
1612
1613 fn render(text: &str, parser: &Parser) -> String {
1614 let mut content = Content::from(crate::Span::new(text));
1615 SubstitutionStep::AttributeReferences.apply(&mut content, parser, None);
1616 content.rendered.to_string()
1617 }
1618
1619 fn content_with_source_lines(text: &'static str) -> Content<'static> {
1625 let root = Span::new(text);
1626 let lines: Vec<&str> = text.split('\n').collect();
1627
1628 let mut spans = Vec::with_capacity(lines.len());
1629 let mut offset = 0;
1630 for line in &lines {
1631 spans.push(root.slice(offset..offset + line.len()));
1632 offset += line.len() + 1;
1634 }
1635
1636 Content::from_filtered_lines(root, &lines, spans)
1637 }
1638
1639 fn assert_spans(warning: &crate::parser::DeferredWarning, text: &str, expected: &str) {
1643 assert_eq!(
1644 &text[warning.offset..warning.offset + warning.len],
1645 expected
1646 );
1647 }
1648
1649 #[test]
1650 fn skip_is_default() {
1651 let p = Parser::default();
1652 assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1653 assert!(p.take_substitution_warnings().is_empty());
1654 }
1655
1656 #[test]
1657 fn skip_explicit() {
1658 let p = parser_with_mode("skip");
1659 assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1660 }
1661
1662 #[test]
1663 fn unknown_value_falls_back_to_skip() {
1664 let p = parser_with_mode("bogus");
1665 assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1666 }
1667
1668 #[test]
1669 fn drop_removes_only_the_reference() {
1670 let p = parser_with_mode("drop");
1671 assert_eq!(render("Hello, {name}!", &p), "Hello, !");
1672 }
1673
1674 #[test]
1675 fn drop_keeps_resolvable_references() {
1676 let p = parser_with_mode("drop");
1677 assert_eq!(render("a {sp}b {missing} c", &p), "a b c");
1678 }
1679
1680 #[test]
1681 fn drop_line_removes_the_whole_line() {
1682 let p = parser_with_mode("drop-line");
1683 assert_eq!(render("Hello, {name}!\nSecond line.", &p), "Second line.");
1684 }
1685
1686 #[test]
1687 fn drop_line_only_drops_lines_with_a_missing_reference() {
1688 let p = parser_with_mode("drop-line");
1689 assert_eq!(
1690 render("first {sp}line\nsecond {missing} line\nthird line", &p),
1691 "first line\nthird line"
1692 );
1693 }
1694
1695 #[test]
1696 fn drop_line_can_empty_the_content() {
1697 let p = parser_with_mode("drop-line");
1698 assert_eq!(render("{missing}", &p), "");
1699 }
1700
1701 #[test]
1702 fn warn_leaves_the_reference_and_records_a_warning() {
1703 let p = parser_with_mode("warn");
1704 assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1705
1706 let warnings = p.take_substitution_warnings();
1707 assert_eq!(warnings.len(), 1);
1708 assert_eq!(
1709 warnings[0].warning,
1710 WarningType::SkippingReferenceToMissingAttribute("name".to_string())
1711 );
1712 }
1713
1714 #[test]
1715 fn warn_records_one_warning_per_missing_reference() {
1716 let p = parser_with_mode("warn");
1717 assert_eq!(render("a {x} b {y} c", &p), "a {x} b {y} c");
1718 assert_eq!(p.take_substitution_warnings().len(), 2);
1719 }
1720
1721 #[test]
1722 fn escaped_missing_reference_drops_the_backslash_and_never_drops_the_line() {
1723 let p = parser_with_mode("drop-line");
1728 assert_eq!(
1729 render("In the path /items/\\{id}, x.", &p),
1730 "In the path /items/{id}, x."
1731 );
1732 assert!(p.take_substitution_warnings().is_empty());
1733 }
1734
1735 #[test]
1742 fn warn_points_at_the_precise_reference() {
1743 let p = parser_with_mode("warn");
1744 let text = "Hello, {name}!";
1745 let mut content = content_with_source_lines(text);
1746 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1747
1748 let warnings = p.take_substitution_warnings();
1749 assert_eq!(warnings.len(), 1);
1750 assert_spans(&warnings[0], text, "{name}");
1751 }
1752
1753 #[test]
1754 fn warn_locates_multiple_references_on_one_line() {
1755 let p = parser_with_mode("warn");
1756 let text = "a {x} b {y} c";
1757 let mut content = content_with_source_lines(text);
1758 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1759
1760 let warnings = p.take_substitution_warnings();
1761 assert_eq!(warnings.len(), 2);
1762 assert_spans(&warnings[0], text, "{x}");
1763 assert_spans(&warnings[1], text, "{y}");
1764 assert_ne!(warnings[0].offset, warnings[1].offset);
1766 }
1767
1768 #[test]
1769 fn warn_locates_references_across_multiple_lines() {
1770 let p = parser_with_mode("warn");
1774 let text = "first {alpha} line\nsecond {beta} line\nthird {gamma} line";
1775 let mut content = content_with_source_lines(text);
1776 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1777
1778 let warnings = p.take_substitution_warnings();
1779 assert_eq!(warnings.len(), 3);
1780 assert_spans(&warnings[0], text, "{alpha}");
1781 assert_spans(&warnings[1], text, "{beta}");
1782 assert_spans(&warnings[2], text, "{gamma}");
1783 }
1784
1785 #[test]
1786 fn warn_distinguishes_repeated_reference_occurrences() {
1787 let p = parser_with_mode("warn");
1788 let text = "{dup} and again {dup}";
1789 let mut content = content_with_source_lines(text);
1790 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1791
1792 let warnings = p.take_substitution_warnings();
1793 assert_eq!(warnings.len(), 2);
1794 assert_spans(&warnings[0], text, "{dup}");
1795 assert_spans(&warnings[1], text, "{dup}");
1796 assert_eq!(warnings[0].offset, 0);
1798 assert_eq!(warnings[1].offset, text.rfind("{dup}").unwrap());
1799 }
1800
1801 #[test]
1802 fn warn_span_survives_earlier_special_character_expansion() {
1803 let p = parser_with_mode("warn");
1808 let text = "a < b {foo} c";
1809 let mut content = content_with_source_lines(text);
1810 SubstitutionGroup::Normal.apply(&mut content, &p, None);
1811
1812 assert!(content.rendered().contains("<"));
1814
1815 let warnings = p.take_substitution_warnings();
1816 assert_eq!(warnings.len(), 1);
1817 assert_spans(&warnings[0], text, "{foo}");
1818 assert_eq!(warnings[0].offset, text.find("{foo}").unwrap());
1819 }
1820
1821 #[test]
1822 fn warn_span_survives_earlier_quote_expansion() {
1823 let p = parser_with_mode("warn");
1826 let text = "*bold* {foo}";
1827 let mut content = content_with_source_lines(text);
1828 SubstitutionGroup::Normal.apply(&mut content, &p, None);
1829
1830 assert!(content.rendered().contains("<strong>"));
1831
1832 let warnings = p.take_substitution_warnings();
1833 assert_eq!(warnings.len(), 1);
1834 assert_spans(&warnings[0], text, "{foo}");
1835 assert_eq!(warnings[0].offset, text.find("{foo}").unwrap());
1836 }
1837
1838 #[test]
1839 fn warn_falls_back_to_whole_span_without_source_lines() {
1840 let p = parser_with_mode("warn");
1844 let text = "x {foo} y";
1845 let mut content = Content::from(Span::new(text));
1846 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1847
1848 let warnings = p.take_substitution_warnings();
1849 assert_eq!(warnings.len(), 1);
1850 assert_eq!(warnings[0].offset, 0);
1851 assert_eq!(warnings[0].len, text.len());
1852 }
1853 }
1854 }
1855
1856 mod callouts {
1857 use crate::{
1858 content::{Content, SubstitutionStep},
1859 parser::ModificationContext,
1860 strings::CowStr,
1861 tests::prelude::*,
1862 };
1863
1864 fn render_callouts(text: &str, parser: &Parser) -> String {
1868 let mut content = Content::from(crate::Span::new(text));
1869 SubstitutionStep::Callouts.apply(&mut content, parser, None);
1872 content.rendered.to_string()
1873 }
1874
1875 #[test]
1876 fn empty() {
1877 let mut content = Content::from(crate::Span::default());
1878 let p = Parser::default();
1879 SubstitutionStep::Callouts.apply(&mut content, &p, None);
1880 assert!(content.is_empty());
1881 assert_eq!(content.rendered, CowStr::Borrowed(""));
1882 }
1883
1884 #[test]
1885 fn no_callouts() {
1886 let p = Parser::default();
1887 assert_eq!(render_callouts("just some text", &p), "just some text");
1888 }
1889
1890 #[test]
1891 fn lt_without_callout_is_untouched() {
1892 let p = Parser::default();
1893 assert_eq!(render_callouts("a <b> c", &p), "a <b> c");
1894 }
1895
1896 #[test]
1897 fn basic_explicit() {
1898 let p = Parser::default();
1899 assert_eq!(
1900 render_callouts("require 'x' <1>", &p),
1901 r#"require 'x' <b class="conum">(1)</b>"#
1902 );
1903 }
1904
1905 #[test]
1906 fn line_comment_prefix_preserved() {
1907 let p = Parser::default();
1908 assert_eq!(
1909 render_callouts("puts 'x' # <1>", &p),
1910 r#"puts 'x' # <b class="conum">(1)</b>"#
1911 );
1912 }
1913
1914 #[test]
1915 fn multiple_on_one_line() {
1916 let p = Parser::default();
1917 assert_eq!(
1918 render_callouts("puts x <5><6>", &p),
1919 r#"puts x <b class="conum">(5)</b><b class="conum">(6)</b>"#
1920 );
1921 }
1922
1923 #[test]
1924 fn not_at_end_of_line() {
1925 let p = Parser::default();
1926 assert_eq!(
1927 render_callouts("puts \"<1> in the middle\"", &p),
1928 "puts \"<1> in the middle\""
1929 );
1930 }
1931
1932 #[test]
1933 fn auto_numbering() {
1934 let p = Parser::default();
1935 assert_eq!(
1936 render_callouts("a <.>\nb <.>\nc <.>", &p),
1937 "a <b class=\"conum\">(1)</b>\nb <b class=\"conum\">(2)</b>\nc <b class=\"conum\">(3)</b>"
1938 );
1939 }
1940
1941 #[test]
1942 fn mixed_numbering_ignores_explicit() {
1943 let p = Parser::default();
1945 assert_eq!(
1946 render_callouts("a <.>\nb <1>\nc <.>", &p),
1947 "a <b class=\"conum\">(1)</b>\nb <b class=\"conum\">(1)</b>\nc <b class=\"conum\">(2)</b>"
1948 );
1949 }
1950
1951 #[test]
1952 fn xml_callout() {
1953 let p = Parser::default();
1954 assert_eq!(
1955 render_callouts("<child/> <!--1-->", &p),
1956 r#"<child/> <!--<b class="conum">(1)</b>-->"#
1957 );
1958 }
1959
1960 #[test]
1961 fn half_xml_comment_is_not_a_callout() {
1962 let p = Parser::default();
1963 assert_eq!(
1964 render_callouts("First line <1-->", &p),
1965 "First line <1-->"
1966 );
1967 }
1968
1969 #[test]
1970 fn escaped_callout() {
1971 let p = Parser::default();
1972 assert_eq!(
1973 render_callouts("require 'x' # \\<1>", &p),
1974 "require 'x' # <1>"
1975 );
1976 }
1977
1978 #[test]
1979 fn icons_font() {
1980 let p = Parser::default().with_intrinsic_attribute(
1981 "icons",
1982 "font",
1983 ModificationContext::Anywhere,
1984 );
1985 assert_eq!(
1986 render_callouts("puts x # <1>", &p),
1987 r#"puts x <i class="conum" data-value="1"></i><b>(1)</b>"#
1988 );
1989 }
1990
1991 #[test]
1992 fn icons_image() {
1993 let p = Parser::default().with_intrinsic_attribute(
1994 "icons",
1995 "",
1996 ModificationContext::Anywhere,
1997 );
1998 assert_eq!(
1999 render_callouts("puts x <1>", &p),
2000 r#"puts x <img src="./images/icons/callouts/1.png" alt="1">"#
2001 );
2002 }
2003
2004 #[test]
2005 fn custom_line_comment_prefix() {
2006 let mut content = Content::from(crate::Span::new("hello() -> % <1>"));
2008 let attrlist = crate::attributes::Attrlist::parse(
2009 crate::Span::new("source,erlang,line-comment=%"),
2010 &Parser::default(),
2011 crate::attributes::AttrlistContext::Block,
2012 )
2013 .item
2014 .item;
2015 let p = Parser::default();
2016 SubstitutionStep::Callouts.apply(&mut content, &p, Some(&attrlist));
2017 assert_eq!(
2018 content.rendered.to_string(),
2019 r#"hello() -> % <b class="conum">(1)</b>"#
2020 );
2021 }
2022
2023 #[test]
2024 fn disabled_line_comment_preserves_leading_chars() {
2025 let mut content = Content::from(crate::Span::new("-- <1>"));
2028 let attrlist = crate::attributes::Attrlist::parse(
2029 crate::Span::new("source,asciidoc,line-comment="),
2030 &Parser::default(),
2031 crate::attributes::AttrlistContext::Block,
2032 )
2033 .item
2034 .item;
2035 let p = Parser::default();
2036 SubstitutionStep::Callouts.apply(&mut content, &p, Some(&attrlist));
2037 assert_eq!(
2038 content.rendered.to_string(),
2039 r#"-- <b class="conum">(1)</b>"#
2040 );
2041 }
2042
2043 #[test]
2044 fn document_line_comment_attribute() {
2045 let p = Parser::default().with_intrinsic_attribute(
2048 "line-comment",
2049 "%",
2050 ModificationContext::Anywhere,
2051 );
2052 assert_eq!(
2053 render_callouts("hello() -> % <1>", &p),
2054 r#"hello() -> % <b class="conum">(1)</b>"#
2055 );
2056 }
2057 }
2058}