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, attribute_lookup_name,
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 replacer = SpecialCharacterReplacer { renderer };
91
92 let rendered = SPECIAL_CHARS
99 .replace_all(content.rendered.as_ref(), replacer)
100 .into_owned();
101
102 content.rendered = rendered.into();
103}
104
105static SPECIAL_CHARS: LazyLock<Regex> = LazyLock::new(|| {
106 #[allow(clippy::unwrap_used)]
107 Regex::new("[<>&]").unwrap()
108});
109
110#[derive(Debug)]
111struct SpecialCharacterReplacer<'r> {
112 renderer: &'r dyn InlineSubstitutionRenderer,
113}
114
115impl Replacer for SpecialCharacterReplacer<'_> {
116 fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
117 let ch = &caps[0];
120
121 if ch == "<" {
122 self.renderer
123 .render_special_character(SpecialCharacter::Lt, dest);
124 } else if ch == ">" {
125 self.renderer
126 .render_special_character(SpecialCharacter::Gt, dest);
127 } else if ch == "&" {
128 self.renderer
129 .render_special_character(SpecialCharacter::Ampersand, dest);
130 }
131
132 }
135}
136
137static QUOTED_TEXT_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
138 #[allow(clippy::unwrap_used)]
139 Regex::new("[*_`#^~]").unwrap()
140});
141
142struct QuoteSub {
143 type_: QuoteType,
144 scope: QuoteScope,
145 pattern: Regex,
146}
147
148static QUOTE_SUBS: LazyLock<Vec<QuoteSub>> = LazyLock::new(|| {
169 vec![
170 QuoteSub {
171 type_: QuoteType::Strong,
173 scope: QuoteScope::Unconstrained,
174 #[allow(clippy::unwrap_used)]
175 pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?\*\*(.+?)\*\*"#)
176 .dot_matches_new_line(true)
177 .build()
178 .unwrap(),
179 },
180 QuoteSub {
181 type_: QuoteType::Strong,
183 scope: QuoteScope::Constrained,
184 #[allow(clippy::unwrap_used)]
185 pattern: RegexBuilder::new(
186 r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?\*(\S|\S.*?\S)\*\b{end-half}"#,
187 )
188 .dot_matches_new_line(true)
189 .build()
190 .unwrap(),
191 },
192 QuoteSub {
193 type_: QuoteType::DoubleQuote,
195 scope: QuoteScope::Constrained,
196 #[allow(clippy::unwrap_used)]
197 pattern: RegexBuilder::new(
198 r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?"`(\S|\S.*?\S)`"\b{end-half}"#,
199 )
200 .dot_matches_new_line(true)
201 .build()
202 .unwrap(),
203 },
204 QuoteSub {
205 type_: QuoteType::SingleQuote,
207 scope: QuoteScope::Constrained,
208 #[allow(clippy::unwrap_used)]
209 pattern: RegexBuilder::new(
210 r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?'`(\S|\S.*?\S)`'\b{end-half}"#,
211 )
212 .dot_matches_new_line(true)
213 .build()
214 .unwrap(),
215 },
216 QuoteSub {
217 type_: QuoteType::Monospaced,
219 scope: QuoteScope::Unconstrained,
220 #[allow(clippy::unwrap_used)]
221 pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?``(.+?)``"#)
222 .dot_matches_new_line(true)
223 .build()
224 .unwrap(),
225 },
226 QuoteSub {
227 type_: QuoteType::Monospaced,
229 scope: QuoteScope::Constrained,
230 #[allow(clippy::unwrap_used)]
231 pattern: RegexBuilder::new(
232 r#"(^|[^\w&;:"'`}])(?:\[([^\[\]]+)\])?`(\S|\S.*?\S)`\b{end-half}"#,
233 )
237 .dot_matches_new_line(true)
238 .build()
239 .unwrap(),
240 },
241 QuoteSub {
242 type_: QuoteType::Emphasis,
244 scope: QuoteScope::Unconstrained,
245 #[allow(clippy::unwrap_used)]
246 pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?__(.+?)__"#)
247 .dot_matches_new_line(true)
248 .build()
249 .unwrap(),
250 },
251 QuoteSub {
252 type_: QuoteType::Emphasis,
254 scope: QuoteScope::Constrained,
255 #[allow(clippy::unwrap_used)]
256 pattern: RegexBuilder::new(
257 r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?_(\S|\S.*?\S)_\b{end-half}"#,
258 )
259 .dot_matches_new_line(true)
260 .build()
261 .unwrap(),
262 },
263 QuoteSub {
264 type_: QuoteType::Mark,
266 scope: QuoteScope::Unconstrained,
267 #[allow(clippy::unwrap_used)]
268 pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?##(.+?)##"#)
269 .dot_matches_new_line(true)
270 .build()
271 .unwrap(),
272 },
273 QuoteSub {
274 type_: QuoteType::Mark,
276 scope: QuoteScope::Constrained,
277 #[allow(clippy::unwrap_used)]
278 pattern: RegexBuilder::new(
279 r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?#(\S|\S.*?\S)#\b{end-half}"#,
280 )
281 .dot_matches_new_line(true)
282 .build()
283 .unwrap(),
284 },
285 QuoteSub {
286 type_: QuoteType::Superscript,
288 scope: QuoteScope::Unconstrained,
289 #[allow(clippy::unwrap_used)]
290 pattern: Regex::new(r#"\\?(?:\[([^\[\]]+)\])?\^(\S+?)\^"#).unwrap(),
291 },
292 QuoteSub {
293 type_: QuoteType::Subscript,
295 scope: QuoteScope::Unconstrained,
296 #[allow(clippy::unwrap_used)]
297 pattern: Regex::new(r#"\\?(?:\[([^\[\]]+)\])?~(\S+?)~"#).unwrap(),
298 },
299 ]
300});
301
302#[derive(Debug)]
303struct QuoteReplacer<'r> {
304 type_: QuoteType,
305 scope: QuoteScope,
306 parser: &'r Parser,
307}
308
309impl LookaheadReplacer for QuoteReplacer<'_> {
310 fn replace_append(
311 &mut self,
312 caps: &Captures<'_>,
313 dest: &mut String,
314 after: &str,
315 ) -> LookaheadResult {
316 if self.type_ == QuoteType::Monospaced
323 && self.scope == QuoteScope::Constrained
324 && after.starts_with(['"', '\'', '`'])
325 {
326 let skip_ahead = if caps[0].starts_with('\\') {
332 2
335 } else {
336 caps[0].chars().next().map_or(1, char::len_utf8)
337 };
338
339 dest.push_str(&caps[0][0..skip_ahead]);
340 return LookaheadResult::SkipAheadAndRetry(skip_ahead);
341 }
342
343 let unescaped_attrs: Option<String> = if caps[0].starts_with('\\') {
344 let maybe_attrs = caps.get(2).map(|a| a.as_str());
345 if self.scope == QuoteScope::Constrained && maybe_attrs.is_some() {
346 Some(format!(
347 "[{attrs}]",
348 attrs = maybe_attrs.unwrap_or_default()
349 ))
350 } else {
351 dest.push_str(&caps[0][1..]);
352 return LookaheadResult::Continue;
353 }
354 } else {
355 None
356 };
357
358 match self.scope {
359 QuoteScope::Constrained => {
360 if let Some(attrs) = unescaped_attrs {
361 dest.push_str(&attrs);
362 self.parser.renderer.render_quoted_substitution(
363 self.type_, self.scope, None, None, &caps[3], dest,
364 );
365 } else {
366 let (attrlist, type_): (Option<Attrlist<'_>>, QuoteType) =
367 if let Some(attrlist) = caps.get(2) {
368 let type_ = if self.type_ == QuoteType::Mark {
369 QuoteType::Unquoted
370 } else {
371 self.type_
372 };
373
374 (
375 Some(
376 Attrlist::parse(
377 crate::Span::new(attrlist.as_str()),
378 self.parser,
379 AttrlistContext::Inline,
380 )
381 .item
382 .item,
383 ),
384 type_,
385 )
386 } else {
387 (None, self.type_)
388 };
389
390 if let Some(prefix) = caps.get(1) {
391 dest.push_str(prefix.as_str());
392 }
393
394 let id = attrlist
395 .as_ref()
396 .and_then(|a| a.id().map(|s| s.to_string()));
397
398 if let Some(id) = &id {
403 let _ = self.parser.register_ref(id, None, RefType::Anchor);
404 }
405
406 self.parser.renderer.render_quoted_substitution(
407 type_, self.scope, attrlist, id, &caps[3], dest,
408 );
409 }
410 }
411
412 QuoteScope::Unconstrained => {
413 let (attrlist, type_): (Option<Attrlist<'_>>, QuoteType) =
414 if let Some(attrlist) = caps.get(1) {
415 let type_ = if self.type_ == QuoteType::Mark {
416 QuoteType::Unquoted
417 } else {
418 self.type_
419 };
420
421 (
422 Some(
423 Attrlist::parse(
424 crate::Span::new(attrlist.as_str()),
425 self.parser,
426 AttrlistContext::Inline,
427 )
428 .item
429 .item,
430 ),
431 type_,
432 )
433 } else {
434 (None, self.type_)
435 };
436
437 let id = attrlist
438 .as_ref()
439 .and_then(|a| a.id().map(|s| s.to_string()));
440
441 if let Some(id) = &id {
446 let _ = self.parser.register_ref(id, None, RefType::Anchor);
447 }
448
449 self.parser
450 .renderer
451 .render_quoted_substitution(type_, self.scope, attrlist, id, &caps[2], dest);
452 }
453 }
454
455 LookaheadResult::Continue
456 }
457}
458
459fn apply_quotes(content: &mut Content<'_>, parser: &Parser) {
460 if !QUOTED_TEXT_SNIFF.is_match(content.rendered.as_ref()) {
461 return;
462 }
463
464 let mut owned: Option<String> = None;
470
471 for sub in &*QUOTE_SUBS {
472 let replacer = QuoteReplacer {
473 type_: sub.type_,
474 scope: sub.scope,
475 parser,
476 };
477
478 let replaced = {
479 let haystack = owned
480 .as_deref()
481 .unwrap_or_else(|| content.rendered.as_ref());
482
483 match replace_with_lookahead(&sub.pattern, haystack, replacer) {
484 Cow::Owned(new_result) => Some(new_result),
485
486 Cow::Borrowed(_) => None,
489 }
490 };
491
492 if let Some(new_result) = replaced {
493 owned = Some(new_result);
494 }
495 }
496
497 if let Some(rendered) = owned {
498 content.rendered = rendered.into();
499 }
500}
501
502static ATTRIBUTE_REFERENCE: LazyLock<Regex> = LazyLock::new(|| {
503 #[allow(clippy::unwrap_used)]
513 Regex::new(r#"\\?\{(?:(counter2?):([^{}]+)|(\w[\w-]*))\}"#).unwrap()
514});
515
516#[derive(Clone, Copy, Debug, Eq, PartialEq)]
521pub(crate) enum AttributeMissing {
522 Skip,
524
525 Drop,
527
528 DropLine,
530
531 Warn,
533}
534
535impl AttributeMissing {
536 pub(crate) fn from_parser(parser: &Parser) -> Self {
540 match parser.attribute_value("attribute-missing").as_maybe_str() {
541 Some("drop") => Self::Drop,
542 Some("drop-line") => Self::DropLine,
543 Some("warn") => Self::Warn,
544 _ => Self::Skip,
545 }
546 }
547}
548
549#[derive(Debug)]
600struct AttributeReplacer<'p> {
601 parser: &'p Parser,
602
603 mode: AttributeMissing,
605
606 fallback_source: Span<'p>,
610
611 source_line: Option<Span<'p>>,
616
617 source_matches: Vec<Range<usize>>,
621
622 match_index: usize,
627
628 missing_on_line: bool,
634}
635
636impl<'p> AttributeReplacer<'p> {
637 fn new(
644 parser: &'p Parser,
645 mode: AttributeMissing,
646 fallback_source: Span<'p>,
647 source_line: Option<Span<'p>>,
648 ) -> Self {
649 let source_matches = match (mode, source_line) {
652 (AttributeMissing::Warn, Some(line)) => ATTRIBUTE_REFERENCE
653 .find_iter(line.data())
654 .map(|m| m.range())
655 .collect(),
656 _ => Vec::new(),
657 };
658
659 Self {
660 parser,
661 mode,
662 fallback_source,
663 source_line,
664 source_matches,
665 match_index: 0,
666 missing_on_line: false,
667 }
668 }
669
670 fn warning_source(&self, index: usize, matched: &str) -> Span<'p> {
679 if let Some(line) = self.source_line
680 && let Some(range) = self.source_matches.get(index)
681 && line.data().get(range.clone()) == Some(matched)
682 {
683 return line.slice(range.clone());
684 }
685
686 self.fallback_source
687 }
688}
689
690impl Replacer for AttributeReplacer<'_> {
691 fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
692 let match_index = self.match_index;
695 self.match_index += 1;
696
697 let escaped = caps[0].starts_with('\\');
698
699 if let Some(directive) = caps.get(1) {
702 if escaped {
703 dest.push_str(&caps[0][1..]);
706 return;
707 }
708
709 let mut parts = caps[2].splitn(2, ':');
712 let name = parts.next().unwrap_or_default();
713 let seed = parts.next();
714
715 let value = self.parser.counter(name, seed);
716
717 if directive.as_str() == "counter" {
719 dest.push_str(&value);
720 }
721 return;
722 }
723
724 let attr_name = &caps[3];
726
727 if escaped {
734 dest.push_str(&caps[0][1..]);
735 return;
736 }
737
738 let lookup_name = attribute_lookup_name(attr_name);
745
746 if !self.parser.has_attribute(&lookup_name) {
747 match self.mode {
748 AttributeMissing::Skip => dest.push_str(&caps[0]),
749 AttributeMissing::Drop => {
750 self.missing_on_line = true;
755 }
756 AttributeMissing::DropLine => {
757 self.missing_on_line = true;
760 }
761 AttributeMissing::Warn => {
762 dest.push_str(&caps[0]);
763 self.parser.record_substitution_warning(
764 self.warning_source(match_index, &caps[0]),
765 WarningType::SkippingReferenceToMissingAttribute(attr_name.to_string()),
766 );
767 }
768 }
769 return;
770 }
771
772 if let InterpretedValue::Value(value) = self.parser.attribute_value(&lookup_name) {
773 dest.push_str(value.as_ref());
774 }
775
776 }
779}
780
781fn drop_emptied_line(replaced: &str) -> bool {
791 replaced.strip_suffix('\r').unwrap_or(replaced).is_empty()
792}
793
794fn apply_attributes(content: &mut Content<'_>, parser: &Parser) {
795 if !content.rendered.contains('{') {
796 return;
797 }
798
799 let mode = AttributeMissing::from_parser(parser);
800 let source = content.original();
801
802 let source_lines = if mode == AttributeMissing::Warn {
809 content
810 .source_lines()
811 .filter(|lines| lines.len() == content.rendered.split('\n').count())
812 } else {
813 None
814 };
815
816 let mut out = String::with_capacity(content.rendered.len());
822 let mut changed = false;
823 let mut wrote_line = false;
824
825 for (index, line) in content.rendered.split('\n').enumerate() {
826 if !line.contains('{') {
827 if wrote_line {
828 out.push('\n');
829 }
830 out.push_str(line);
831 wrote_line = true;
832 continue;
833 }
834
835 let source_line = source_lines.and_then(|lines| lines.get(index).copied());
839 let mut replacer = AttributeReplacer::new(parser, mode, source, source_line);
840
841 let replaced = ATTRIBUTE_REFERENCE.replace_all(line, replacer.by_ref());
842
843 if replacer.missing_on_line
844 && (mode == AttributeMissing::DropLine
845 || (mode == AttributeMissing::Drop && drop_emptied_line(&replaced)))
846 {
847 changed = true;
852 continue;
853 }
854
855 if let Cow::Owned(_) = replaced {
856 changed = true;
857 }
858
859 if wrote_line {
860 out.push('\n');
861 }
862 out.push_str(&replaced);
863 wrote_line = true;
864 }
865
866 if changed {
869 content.rendered = out.into();
870 }
871}
872
873pub(crate) fn substitute_attributes_in_macro_target<'src>(
885 target: Span<'src>,
886 parser: &Parser,
887) -> Option<CowStr<'src>> {
888 let text = target.data();
889
890 if !text.contains('{') {
893 return Some(text.into());
894 }
895
896 let mode = AttributeMissing::from_parser(parser);
897
898 let mut replacer = AttributeReplacer::new(parser, mode, target, Some(target));
901
902 let replaced = ATTRIBUTE_REFERENCE.replace_all(text, replacer.by_ref());
903
904 if replacer.missing_on_line && mode == AttributeMissing::DropLine {
905 return None;
906 }
907
908 Some(replaced.into())
909}
910
911pub(crate) fn substitute_attributes_in_text(text: &str, parser: &Parser) -> String {
928 if !text.contains('{') {
929 return text.to_string();
930 }
931
932 let mode = AttributeMissing::from_parser(parser);
933 let source = Span::new(text);
934
935 let mut out = String::with_capacity(text.len());
936 let mut wrote_line = false;
937
938 for line in text.split('\n') {
939 if !line.contains('{') {
940 if wrote_line {
941 out.push('\n');
942 }
943 out.push_str(line);
944 wrote_line = true;
945 continue;
946 }
947
948 let mut replacer = AttributeReplacer::new(parser, mode, source, None);
952
953 let replaced = ATTRIBUTE_REFERENCE.replace_all(line, replacer.by_ref());
954
955 if replacer.missing_on_line
956 && (mode == AttributeMissing::DropLine
957 || (mode == AttributeMissing::Drop && drop_emptied_line(&replaced)))
958 {
959 continue;
964 }
965
966 if wrote_line {
967 out.push('\n');
968 }
969 out.push_str(&replaced);
970 wrote_line = true;
971 }
972
973 out
974}
975
976pub(crate) fn substitute_attributes_in_reftext<'src>(
987 reftext: Span<'src>,
988 parser: &Parser,
989) -> CowStr<'src> {
990 if !reftext.data().contains('{') {
991 return reftext.data().into();
992 }
993
994 let mut content = Content::from(reftext);
995 SubstitutionStep::AttributeReferences.apply(&mut content, parser, None);
996 CowStr::from(content.rendered.to_string())
997}
998
999fn apply_character_replacements(
1000 content: &mut Content<'_>,
1001 renderer: &dyn InlineSubstitutionRenderer,
1002) {
1003 if !REPLACEABLE_TEXT_SNIFF.is_match(content.rendered.as_ref()) {
1004 return;
1005 }
1006
1007 let mut owned: Option<String> = None;
1013
1014 for repl in &*REPLACEMENTS {
1015 let replacer = CharacterReplacer {
1016 type_: repl.type_.clone(),
1017 renderer,
1018 };
1019
1020 let replaced = {
1021 let haystack = owned
1022 .as_deref()
1023 .unwrap_or_else(|| content.rendered.as_ref());
1024
1025 match repl.pattern.replace_all(haystack, replacer) {
1026 Cow::Owned(new_result) => Some(new_result),
1027
1028 Cow::Borrowed(_) => None,
1031 }
1032 };
1033
1034 if let Some(new_result) = replaced {
1035 owned = Some(new_result);
1036 }
1037 }
1038
1039 if let Some(rendered) = owned {
1040 content.rendered = rendered.into();
1041 }
1042}
1043
1044struct CharacterReplacement {
1045 type_: CharacterReplacementType,
1046 pattern: Regex,
1047}
1048
1049static REPLACEABLE_TEXT_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
1050 #[allow(clippy::unwrap_used)]
1051 Regex::new(r#"[&']|--|\.\.\.|\([CRT]M?\)"#).unwrap()
1052});
1053
1054static REPLACEMENTS: LazyLock<Vec<CharacterReplacement>> = LazyLock::new(|| {
1060 vec![
1061 CharacterReplacement {
1062 type_: CharacterReplacementType::Copyright,
1064 #[allow(clippy::unwrap_used)]
1065 pattern: Regex::new(r#"\\?\(C\)"#).unwrap(),
1066 },
1067 CharacterReplacement {
1068 type_: CharacterReplacementType::Registered,
1070 #[allow(clippy::unwrap_used)]
1071 pattern: Regex::new(r#"\\?\(R\)"#).unwrap(),
1072 },
1073 CharacterReplacement {
1074 type_: CharacterReplacementType::Trademark,
1076 #[allow(clippy::unwrap_used)]
1077 pattern: Regex::new(r#"\\?\(TM\)"#).unwrap(),
1078 },
1079 CharacterReplacement {
1080 type_: CharacterReplacementType::EmDashSurroundedBySpaces,
1082 #[allow(clippy::unwrap_used)]
1083 pattern: Regex::new(r#"(?: |\n|^|\\)--(?: |\n|$)"#).unwrap(),
1084 },
1085 CharacterReplacement {
1086 type_: CharacterReplacementType::EmDashWithoutSpace,
1088 #[allow(clippy::unwrap_used)]
1089 pattern: Regex::new(r#"(\w)\\?--\b{start-half}"#).unwrap(),
1090 },
1091 CharacterReplacement {
1092 type_: CharacterReplacementType::Ellipsis,
1094 #[allow(clippy::unwrap_used)]
1095 pattern: Regex::new(r#"\\?\.\.\."#).unwrap(),
1096 },
1097 CharacterReplacement {
1098 type_: CharacterReplacementType::TypographicApostrophe,
1100 #[allow(clippy::unwrap_used)]
1101 pattern: Regex::new(r#"\\?`'"#).unwrap(),
1102 },
1103 CharacterReplacement {
1104 type_: CharacterReplacementType::TypographicApostrophe,
1106 #[allow(clippy::unwrap_used)]
1107 pattern: Regex::new(r#"([[:alnum:]])\\?'([[:alpha:]])"#).unwrap(),
1108 },
1109 CharacterReplacement {
1110 type_: CharacterReplacementType::SingleRightArrow,
1112 #[allow(clippy::unwrap_used)]
1113 pattern: Regex::new(r#"\\?->"#).unwrap(),
1114 },
1115 CharacterReplacement {
1116 type_: CharacterReplacementType::DoubleRightArrow,
1118 #[allow(clippy::unwrap_used)]
1119 pattern: Regex::new(r#"\\?=>"#).unwrap(),
1120 },
1121 CharacterReplacement {
1122 type_: CharacterReplacementType::SingleLeftArrow,
1124 #[allow(clippy::unwrap_used)]
1125 pattern: Regex::new(r#"\\?<-"#).unwrap(),
1126 },
1127 CharacterReplacement {
1128 type_: CharacterReplacementType::DoubleLeftArrow,
1130 #[allow(clippy::unwrap_used)]
1131 pattern: Regex::new(r#"\\?<="#).unwrap(),
1132 },
1133 CharacterReplacement {
1134 type_: CharacterReplacementType::CharacterReference("".to_owned()),
1136 #[allow(clippy::unwrap_used)]
1137 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(),
1138 },
1139 ]
1140});
1141
1142#[derive(Debug)]
1143struct CharacterReplacer<'r> {
1144 type_: CharacterReplacementType,
1145 renderer: &'r dyn InlineSubstitutionRenderer,
1146}
1147
1148impl Replacer for CharacterReplacer<'_> {
1149 fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
1150 if caps[0].contains('\\') {
1151 let unescaped = &caps[0].replace("\\", "");
1153 dest.push_str(unescaped);
1154 return;
1155 }
1156
1157 match self.type_ {
1158 CharacterReplacementType::Copyright
1159 | CharacterReplacementType::Registered
1160 | CharacterReplacementType::Trademark
1161 | CharacterReplacementType::EmDashSurroundedBySpaces
1162 | CharacterReplacementType::Ellipsis
1163 | CharacterReplacementType::SingleLeftArrow
1164 | CharacterReplacementType::DoubleLeftArrow
1165 | CharacterReplacementType::SingleRightArrow
1166 | CharacterReplacementType::DoubleRightArrow => {
1167 self.renderer
1168 .render_character_replacement(self.type_.clone(), dest);
1169 }
1170
1171 CharacterReplacementType::EmDashWithoutSpace => {
1172 dest.push_str(&caps[1]);
1173 self.renderer.render_character_replacement(
1174 CharacterReplacementType::EmDashWithoutSpace,
1175 dest,
1176 );
1177 }
1178
1179 CharacterReplacementType::TypographicApostrophe => {
1180 if let Some(before) = caps.get(1) {
1181 dest.push_str(before.as_str());
1182 }
1183
1184 self.renderer.render_character_replacement(
1185 CharacterReplacementType::TypographicApostrophe,
1186 dest,
1187 );
1188
1189 if let Some(after) = caps.get(2) {
1190 dest.push_str(after.as_str());
1191 }
1192 }
1193
1194 CharacterReplacementType::CharacterReference(_) => {
1195 self.renderer.render_character_replacement(
1196 CharacterReplacementType::CharacterReference(caps[1].to_string()),
1197 dest,
1198 );
1199 }
1200 }
1201 }
1202}
1203
1204fn apply_post_replacements(
1205 content: &mut Content<'_>,
1206 parser: &Parser,
1207 attrlist: Option<&Attrlist<'_>>,
1208) {
1209 if parser.is_attribute_set("hardbreaks-option")
1210 || attrlist.is_some_and(|attrlist| attrlist.has_option("hardbreaks"))
1211 {
1212 let text = content.rendered.as_ref();
1213 if !text.contains('\n') {
1214 return;
1215 }
1216
1217 let mut lines: Vec<&str> = content.rendered.as_ref().lines().collect();
1218 let last = lines.pop().unwrap_or_default();
1219
1220 let mut lines: Vec<String> = lines
1221 .iter()
1222 .map(|line| {
1223 let line = if line.ends_with(" +") {
1224 &line[0..line.len() - 2]
1225 } else {
1226 *line
1227 };
1228
1229 let mut line = line.to_owned();
1230 parser.renderer.render_line_break(&mut line);
1231 line
1232 })
1233 .collect();
1234
1235 lines.push(last.to_owned());
1236
1237 let new_result = lines.join("\n");
1238 content.rendered = new_result.into();
1239 } else {
1240 let rendered = content.rendered.as_ref();
1241 if !(rendered.contains('+') && rendered.contains('\n')) {
1242 return;
1243 }
1244
1245 let replacer = PostReplacementReplacer(&*parser.renderer);
1246
1247 if let Cow::Owned(new_result) = HARD_LINE_BREAK.replace_all(rendered, replacer) {
1248 content.rendered = new_result.into();
1249 }
1250 }
1251}
1252
1253#[derive(Debug)]
1254struct PostReplacementReplacer<'r>(&'r dyn InlineSubstitutionRenderer);
1255
1256impl Replacer for PostReplacementReplacer<'_> {
1257 fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
1258 dest.push_str(&caps[1]);
1259 self.0.render_line_break(dest);
1260 }
1261}
1262
1263static HARD_LINE_BREAK: LazyLock<Regex> = LazyLock::new(|| {
1264 #[allow(clippy::unwrap_used)]
1265 Regex::new(r#"(?m)^(.*) \+$"#).unwrap()
1266});
1267
1268fn apply_callouts(content: &mut Content<'_>, parser: &Parser, attrlist: Option<&Attrlist<'_>>) {
1284 if !content.rendered.contains("<") {
1287 return;
1288 }
1289
1290 let line_comment: Option<String> = attrlist
1300 .and_then(|a| a.named_attribute("line-comment"))
1301 .map(|a| a.value().to_string())
1302 .or_else(|| {
1303 if parser.has_attribute("line-comment") {
1304 Some(
1305 parser
1306 .attribute_value("line-comment")
1307 .as_maybe_str()
1308 .unwrap_or("")
1309 .to_string(),
1310 )
1311 } else {
1312 None
1313 }
1314 });
1315
1316 let (callout_rx, tail_rx) = build_callout_regexes(line_comment.as_deref());
1317
1318 let replacer = CalloutReplacer {
1319 renderer: &*parser.renderer,
1320 parser,
1321 autonum: 0,
1322 tail: tail_rx,
1323 };
1324
1325 if let Cow::Owned(new_result) =
1326 replace_with_lookahead(&callout_rx, content.rendered.as_ref(), replacer)
1327 {
1328 content.rendered = new_result.into();
1329 }
1330}
1331
1332static DEFAULT_CALLOUT_RX: LazyLock<Regex> = LazyLock::new(|| {
1335 #[allow(clippy::unwrap_used)]
1336 Regex::new(
1337 r"(?P<prefix>(?://|#|--|;;) ?)?(?P<esc>\\)?(?:<!--(?P<xnum>\d+|\.)-->|<(?P<num>\d+|\.)>)",
1338 )
1339 .unwrap()
1340});
1341
1342static DEFAULT_CALLOUT_TAIL_RX: LazyLock<Regex> = LazyLock::new(|| {
1344 #[allow(clippy::unwrap_used)]
1345 Regex::new(r"^(?: ?\\?(?:<!--(?:\d+|\.)-->|<(?:\d+|\.)>))*(?:\n|$)").unwrap()
1346});
1347
1348static CUSTOM_CALLOUT_TAIL_RX: LazyLock<Regex> = LazyLock::new(|| {
1351 #[allow(clippy::unwrap_used)]
1352 Regex::new(r"^(?: ?\\?<(?:\d+|\.)>)*(?:\n|$)").unwrap()
1353});
1354
1355fn build_callout_regexes(line_comment: Option<&str>) -> (Cow<'static, Regex>, &'static Regex) {
1368 match line_comment {
1369 None => (Cow::Borrowed(&DEFAULT_CALLOUT_RX), &DEFAULT_CALLOUT_TAIL_RX),
1371
1372 Some(prefix) => {
1375 let prefix_pattern = if prefix.is_empty() {
1376 String::new()
1377 } else {
1378 format!(r"(?P<prefix>{} ?)?", regex::escape(prefix))
1379 };
1380
1381 #[allow(clippy::unwrap_used)]
1382 let callout = Regex::new(&format!(
1383 r"{prefix_pattern}(?P<esc>\\)?<(?P<num>\d+|\.)>"
1384 ))
1385 .unwrap();
1386
1387 (Cow::Owned(callout), &CUSTOM_CALLOUT_TAIL_RX)
1388 }
1389 }
1390}
1391
1392struct CalloutReplacer<'r> {
1395 renderer: &'r dyn InlineSubstitutionRenderer,
1396 parser: &'r Parser,
1397
1398 autonum: u32,
1401
1402 tail: &'r Regex,
1404}
1405
1406impl LookaheadReplacer for CalloutReplacer<'_> {
1407 fn replace_append(
1408 &mut self,
1409 caps: &Captures<'_>,
1410 dest: &mut String,
1411 after: &str,
1412 ) -> LookaheadResult {
1413 if !self.tail.is_match(after) {
1416 dest.push_str(&caps[0]);
1417 return LookaheadResult::Continue;
1418 }
1419
1420 if caps.name("esc").is_some() {
1423 dest.push_str(&caps[0].replacen('\\', "", 1));
1424 return LookaheadResult::Continue;
1425 }
1426
1427 let (number_raw, is_xml) = if let Some(xnum) = caps.name("xnum") {
1428 (xnum.as_str(), true)
1429 } else {
1430 #[allow(clippy::unwrap_used)]
1432 (caps.name("num").unwrap().as_str(), false)
1433 };
1434
1435 let number = if number_raw == "." {
1436 self.autonum += 1;
1437 self.autonum.to_string()
1438 } else {
1439 number_raw.to_string()
1440 };
1441
1442 if let Ok(n) = number.parse::<u32>() {
1445 self.parser.register_callout(n);
1446 }
1447
1448 let guard = match caps.name("prefix") {
1452 Some(prefix) => CalloutGuard::LineComment(prefix.as_str()),
1453 None if is_xml => CalloutGuard::Xml,
1454 None => CalloutGuard::LineComment(""),
1455 };
1456
1457 self.renderer.render_callout(
1458 &CalloutRenderParams {
1459 number: &number,
1460 guard,
1461 parser: self.parser,
1462 },
1463 dest,
1464 );
1465
1466 LookaheadResult::Continue
1467 }
1468}
1469
1470#[cfg(test)]
1471mod tests {
1472 #![allow(clippy::unwrap_used)]
1473
1474 mod special_characters {
1475 use crate::{
1476 content::{Content, SubstitutionStep},
1477 strings::CowStr,
1478 tests::prelude::*,
1479 };
1480
1481 #[test]
1482 fn empty() {
1483 let mut content = Content::from(crate::Span::default());
1484 let p = Parser::default();
1485 SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1486 assert!(content.is_empty());
1487 assert_eq!(content.rendered, CowStr::Borrowed(""));
1488 }
1489
1490 #[test]
1491 fn basic_non_empty_span() {
1492 let mut content = Content::from(crate::Span::new("blah"));
1493 let p = Parser::default();
1494 SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1495 assert!(!content.is_empty());
1496 assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1497 }
1498
1499 #[test]
1500 fn match_lt_and_gt() {
1501 let mut content = Content::from(crate::Span::new("bl<ah>"));
1502 let p = Parser::default();
1503 SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1504 assert!(!content.is_empty());
1505 assert_eq!(
1506 content.rendered,
1507 CowStr::Boxed("bl<ah>".to_string().into_boxed_str())
1508 );
1509 }
1510
1511 #[test]
1512 fn match_amp() {
1513 let mut content = Content::from(crate::Span::new("bl<a&h>"));
1514 let p = Parser::default();
1515 SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1516 assert!(!content.is_empty());
1517 assert_eq!(
1518 content.rendered,
1519 CowStr::Boxed("bl<a&h>".to_string().into_boxed_str())
1520 );
1521 }
1522 }
1523
1524 mod quotes {
1525 use crate::{
1526 content::{Content, SubstitutionStep},
1527 strings::CowStr,
1528 tests::prelude::*,
1529 };
1530
1531 #[test]
1532 fn empty() {
1533 let mut content = Content::from(crate::Span::default());
1534 let p = Parser::default();
1535 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1536 assert!(content.is_empty());
1537 assert_eq!(content.rendered, CowStr::Borrowed(""));
1538 }
1539
1540 #[test]
1541 fn basic_non_empty_span() {
1542 let mut content = Content::from(crate::Span::new("blah"));
1543 let p = Parser::default();
1544 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1545 assert!(!content.is_empty());
1546 assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1547 }
1548
1549 #[test]
1550 fn ignore_lt_and_gt() {
1551 let mut content = Content::from(crate::Span::new("bl<ah>"));
1552 let p = Parser::default();
1553 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1554 assert!(!content.is_empty());
1555 assert_eq!(
1556 content.rendered,
1557 CowStr::Boxed("bl<ah>".to_string().into_boxed_str())
1558 );
1559 }
1560
1561 #[test]
1562 fn strong_word() {
1563 let mut content = Content::from(crate::Span::new("One *word* is strong."));
1564 let p = Parser::default();
1565 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1566 assert!(!content.is_empty());
1567 assert_eq!(
1568 content.rendered,
1569 CowStr::Boxed(
1570 "One <strong>word</strong> is strong."
1571 .to_string()
1572 .into_boxed_str()
1573 )
1574 );
1575 }
1576
1577 #[test]
1578 fn marked_string_with_id() {
1579 let mut content = Content::from(crate::Span::new(r#"[#id]#a few words#"#));
1580 let p = Parser::default();
1581 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1582 assert!(!content.is_empty());
1583 assert_eq!(
1584 content.rendered,
1585 CowStr::Boxed(r#"<span id="id">a few words</span>"#.to_string().into_boxed_str())
1586 );
1587 }
1588
1589 #[test]
1590 fn unconstrained_marked_string_with_id_is_registered() {
1591 let doc = Parser::default().parse(r#"[#the_id]##marked text##"#);
1595
1596 assert_eq!(
1597 doc.child_blocks()
1598 .next()
1599 .unwrap()
1600 .rendered_content()
1601 .unwrap(),
1602 r#"<span id="the_id">marked text</span>"#
1603 );
1604
1605 assert!(doc.catalog().contains_id("the_id"));
1606 }
1607
1608 #[test]
1609 fn multibyte_leading_char_before_constrained_monospace() {
1610 for leading in ["€", "中", "🎉"] {
1616 let source = format!("{leading}`code``");
1617 let mut content = Content::from(crate::Span::new(&source));
1618 let p = Parser::default();
1619
1620 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1622
1623 assert!(content.rendered.starts_with(leading));
1624 }
1625 }
1626
1627 #[test]
1628 fn escaped_leading_backtick_before_constrained_monospace() {
1629 let mut content = Content::from(crate::Span::new(r"\`code``"));
1634 let p = Parser::default();
1635
1636 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1637
1638 assert_eq!(
1639 content.rendered,
1640 CowStr::Boxed(r"\`code``".to_string().into_boxed_str())
1641 );
1642 }
1643 }
1644
1645 mod attribute_references {
1646 use crate::{
1647 content::{Content, SubstitutionStep},
1648 strings::CowStr,
1649 tests::prelude::*,
1650 };
1651
1652 #[test]
1653 fn empty() {
1654 let mut content = Content::from(crate::Span::default());
1655 let p = Parser::default();
1656 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1657 assert!(content.is_empty());
1658 assert_eq!(content.rendered, CowStr::Borrowed(""));
1659 }
1660
1661 #[test]
1662 fn basic_non_empty_span() {
1663 let mut content = Content::from(crate::Span::new("blah"));
1664 let p = Parser::default();
1665 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1666 assert!(!content.is_empty());
1667 assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1668 }
1669
1670 #[test]
1671 fn ignore_non_match() {
1672 let mut content = Content::from(crate::Span::new("bl{ah}"));
1673 let p = Parser::default();
1674 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1675 assert!(!content.is_empty());
1676 assert_eq!(
1677 content.rendered,
1678 CowStr::Boxed("bl{ah}".to_string().into_boxed_str())
1679 );
1680 }
1681
1682 #[test]
1683 fn escaped_reference_to_unset_attribute_drops_backslash() {
1684 let mut content = Content::from(crate::Span::new("bl\\{ah}"));
1688 let p = Parser::default();
1689 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1690 assert!(!content.is_empty());
1691 assert_eq!(
1692 content.rendered,
1693 CowStr::Boxed("bl{ah}".to_string().into_boxed_str())
1694 );
1695 }
1696
1697 #[test]
1698 fn replace_sp_match() {
1699 let mut content = Content::from(crate::Span::new("bl{sp}ah"));
1700 let p = Parser::default();
1701 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1702 assert!(!content.is_empty());
1703 assert_eq!(
1704 content.rendered,
1705 CowStr::Boxed("bl ah".to_string().into_boxed_str())
1706 );
1707 }
1708
1709 #[test]
1710 fn ignore_escaped_sp_match() {
1711 let mut content = Content::from(crate::Span::new("bl\\{sp}ah"));
1712 let p = Parser::default();
1713 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1714 assert!(!content.is_empty());
1715 assert_eq!(
1716 content.rendered,
1717 CowStr::Boxed("bl{sp}ah".to_string().into_boxed_str())
1718 );
1719 }
1720
1721 #[test]
1722 fn counter_directive_displays_and_advances() {
1723 let mut content = Content::from(crate::Span::new("{counter:n}-{counter:n}"));
1724 let p = Parser::default();
1725 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1726 assert_eq!(
1727 content.rendered,
1728 CowStr::Boxed("1-2".to_string().into_boxed_str())
1729 );
1730 }
1731
1732 #[test]
1733 fn counter2_directive_advances_silently() {
1734 let mut content = Content::from(crate::Span::new("{counter2:n}{counter:n}"));
1735 let p = Parser::default();
1736 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1737 assert_eq!(
1738 content.rendered,
1739 CowStr::Boxed("2".to_string().into_boxed_str())
1740 );
1741 }
1742
1743 #[test]
1744 fn escaped_counter_directive_is_literal_and_does_not_advance() {
1745 let mut content = Content::from(crate::Span::new("\\{counter:n} {counter:n}"));
1746 let p = Parser::default();
1747 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1748 assert_eq!(
1749 content.rendered,
1750 CowStr::Boxed("{counter:n} 1".to_string().into_boxed_str())
1751 );
1752 }
1753
1754 mod attribute_missing {
1755 #![allow(clippy::indexing_slicing)]
1756
1757 use crate::{
1758 Span,
1759 content::{Content, SubstitutionGroup, SubstitutionStep},
1760 parser::ModificationContext,
1761 tests::prelude::*,
1762 warnings::WarningType,
1763 };
1764
1765 fn parser_with_mode(mode: &str) -> Parser {
1766 Parser::default().with_intrinsic_attribute(
1767 "attribute-missing",
1768 mode,
1769 ModificationContext::Anywhere,
1770 )
1771 }
1772
1773 fn render(text: &str, parser: &Parser) -> String {
1774 let mut content = Content::from(crate::Span::new(text));
1775 SubstitutionStep::AttributeReferences.apply(&mut content, parser, None);
1776 content.rendered.to_string()
1777 }
1778
1779 fn content_with_source_lines(text: &'static str) -> Content<'static> {
1785 let root = Span::new(text);
1786 let lines: Vec<&str> = text.split('\n').collect();
1787
1788 let mut spans = Vec::with_capacity(lines.len());
1789 let mut offset = 0;
1790 for line in &lines {
1791 spans.push(root.slice(offset..offset + line.len()));
1792
1793 offset += line.len() + 1;
1795 }
1796
1797 Content::from_filtered_lines(root, &lines, spans)
1798 }
1799
1800 fn assert_spans(warning: &crate::parser::DeferredWarning, text: &str, expected: &str) {
1804 assert_eq!(
1805 &text[warning.offset..warning.offset + warning.len],
1806 expected
1807 );
1808 }
1809
1810 #[test]
1811 fn skip_is_default() {
1812 let p = Parser::default();
1813 assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1814 assert!(p.take_substitution_warnings().is_empty());
1815 }
1816
1817 #[test]
1818 fn skip_explicit() {
1819 let p = parser_with_mode("skip");
1820 assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1821 }
1822
1823 #[test]
1824 fn unknown_value_falls_back_to_skip() {
1825 let p = parser_with_mode("bogus");
1826 assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1827 }
1828
1829 #[test]
1830 fn drop_removes_only_the_reference() {
1831 let p = parser_with_mode("drop");
1832 assert_eq!(render("Hello, {name}!", &p), "Hello, !");
1833 }
1834
1835 #[test]
1836 fn drop_keeps_resolvable_references() {
1837 let p = parser_with_mode("drop");
1838 assert_eq!(render("a {sp}b {missing} c", &p), "a b c");
1839 }
1840
1841 #[test]
1842 fn drop_removes_line_that_only_contained_the_reference() {
1843 let p = parser_with_mode("drop");
1846 assert_eq!(render("Line 1\n{missing}\nLine 2", &p), "Line 1\nLine 2");
1847 }
1848
1849 #[test]
1850 fn drop_keeps_a_line_the_reference_did_not_empty() {
1851 let p = parser_with_mode("drop");
1854 assert_eq!(
1855 render("Line 1\ntext {missing}\nLine 2", &p),
1856 "Line 1\ntext \nLine 2"
1857 );
1858 }
1859
1860 #[test]
1861 fn drop_removes_a_leading_or_trailing_reference_only_line() {
1862 let p = parser_with_mode("drop");
1863 assert_eq!(render("{missing}\nLine 2", &p), "Line 2");
1864 assert_eq!(render("Line 1\n{missing}", &p), "Line 1");
1865 }
1866
1867 #[test]
1868 fn drop_can_empty_the_content() {
1869 let p = parser_with_mode("drop");
1872 assert_eq!(render("{missing}", &p), "");
1873 }
1874
1875 #[test]
1876 fn drop_keeps_a_line_emptied_by_a_resolvable_reference() {
1877 let p = parser_with_mode("drop");
1880 assert_eq!(render("Line 1\n{empty}\nLine 2", &p), "Line 1\n\nLine 2");
1881 }
1882
1883 #[test]
1884 fn drop_line_removes_the_whole_line() {
1885 let p = parser_with_mode("drop-line");
1886 assert_eq!(render("Hello, {name}!\nSecond line.", &p), "Second line.");
1887 }
1888
1889 #[test]
1890 fn drop_line_only_drops_lines_with_a_missing_reference() {
1891 let p = parser_with_mode("drop-line");
1892 assert_eq!(
1893 render("first {sp}line\nsecond {missing} line\nthird line", &p),
1894 "first line\nthird line"
1895 );
1896 }
1897
1898 #[test]
1899 fn drop_line_can_empty_the_content() {
1900 let p = parser_with_mode("drop-line");
1901 assert_eq!(render("{missing}", &p), "");
1902 }
1903
1904 mod free_standing_text {
1909 use super::parser_with_mode;
1910 use crate::content::substitute_attributes_in_text;
1911
1912 #[test]
1913 fn drop_removes_line_that_only_contained_the_reference() {
1914 let p = parser_with_mode("drop");
1915 assert_eq!(
1916 substitute_attributes_in_text("Line 1\n{missing}\nLine 2", &p),
1917 "Line 1\nLine 2"
1918 );
1919 }
1920
1921 #[test]
1922 fn drop_keeps_a_line_the_reference_did_not_empty() {
1923 let p = parser_with_mode("drop");
1924 assert_eq!(
1925 substitute_attributes_in_text("Line 1\ntext {missing}\nLine 2", &p),
1926 "Line 1\ntext \nLine 2"
1927 );
1928 }
1929
1930 #[test]
1931 fn drop_keeps_a_line_emptied_by_a_resolvable_reference() {
1932 let p = parser_with_mode("drop");
1933 assert_eq!(
1934 substitute_attributes_in_text("Line 1\n{empty}\nLine 2", &p),
1935 "Line 1\n\nLine 2"
1936 );
1937 }
1938
1939 #[test]
1940 fn drop_line_removes_the_whole_line() {
1941 let p = parser_with_mode("drop-line");
1942 assert_eq!(
1943 substitute_attributes_in_text("Line 1\n{missing} tail\nLine 2", &p),
1944 "Line 1\nLine 2"
1945 );
1946 }
1947
1948 #[test]
1949 fn drop_removes_a_crlf_reference_only_line() {
1950 let p = parser_with_mode("drop");
1954 assert_eq!(
1955 substitute_attributes_in_text("Line 1\r\n{missing}\r\nLine 2", &p),
1956 "Line 1\r\nLine 2"
1957 );
1958 }
1959
1960 #[test]
1961 fn drop_keeps_a_crlf_line_the_reference_did_not_empty() {
1962 let p = parser_with_mode("drop");
1963 assert_eq!(
1964 substitute_attributes_in_text("Line 1\r\ntext {missing}\r\nLine 2", &p),
1965 "Line 1\r\ntext \r\nLine 2"
1966 );
1967 }
1968 }
1969
1970 #[test]
1971 fn warn_leaves_the_reference_and_records_a_warning() {
1972 let p = parser_with_mode("warn");
1973 assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1974
1975 let warnings = p.take_substitution_warnings();
1976 assert_eq!(warnings.len(), 1);
1977 assert_eq!(
1978 warnings[0].warning,
1979 WarningType::SkippingReferenceToMissingAttribute("name".to_string())
1980 );
1981 }
1982
1983 #[test]
1984 fn warn_records_one_warning_per_missing_reference() {
1985 let p = parser_with_mode("warn");
1986 assert_eq!(render("a {x} b {y} c", &p), "a {x} b {y} c");
1987 assert_eq!(p.take_substitution_warnings().len(), 2);
1988 }
1989
1990 #[test]
1991 fn escaped_missing_reference_drops_the_backslash_and_never_drops_the_line() {
1992 let p = parser_with_mode("drop-line");
1997 assert_eq!(
1998 render("In the path /items/\\{id}, x.", &p),
1999 "In the path /items/{id}, x."
2000 );
2001 assert!(p.take_substitution_warnings().is_empty());
2002 }
2003
2004 #[test]
2011 fn warn_points_at_the_precise_reference() {
2012 let p = parser_with_mode("warn");
2013 let text = "Hello, {name}!";
2014 let mut content = content_with_source_lines(text);
2015 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2016
2017 let warnings = p.take_substitution_warnings();
2018 assert_eq!(warnings.len(), 1);
2019 assert_spans(&warnings[0], text, "{name}");
2020 }
2021
2022 #[test]
2023 fn warn_locates_multiple_references_on_one_line() {
2024 let p = parser_with_mode("warn");
2025 let text = "a {x} b {y} c";
2026 let mut content = content_with_source_lines(text);
2027 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2028
2029 let warnings = p.take_substitution_warnings();
2030 assert_eq!(warnings.len(), 2);
2031 assert_spans(&warnings[0], text, "{x}");
2032 assert_spans(&warnings[1], text, "{y}");
2033
2034 assert_ne!(warnings[0].offset, warnings[1].offset);
2036 }
2037
2038 #[test]
2039 fn warn_locates_references_across_multiple_lines() {
2040 let p = parser_with_mode("warn");
2044 let text = "first {alpha} line\nsecond {beta} line\nthird {gamma} line";
2045 let mut content = content_with_source_lines(text);
2046 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2047
2048 let warnings = p.take_substitution_warnings();
2049 assert_eq!(warnings.len(), 3);
2050 assert_spans(&warnings[0], text, "{alpha}");
2051 assert_spans(&warnings[1], text, "{beta}");
2052 assert_spans(&warnings[2], text, "{gamma}");
2053 }
2054
2055 #[test]
2056 fn warn_distinguishes_repeated_reference_occurrences() {
2057 let p = parser_with_mode("warn");
2058 let text = "{dup} and again {dup}";
2059 let mut content = content_with_source_lines(text);
2060 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2061
2062 let warnings = p.take_substitution_warnings();
2063 assert_eq!(warnings.len(), 2);
2064 assert_spans(&warnings[0], text, "{dup}");
2065 assert_spans(&warnings[1], text, "{dup}");
2066
2067 assert_eq!(warnings[0].offset, 0);
2069 assert_eq!(warnings[1].offset, text.rfind("{dup}").unwrap());
2070 }
2071
2072 #[test]
2073 fn warn_span_survives_earlier_special_character_expansion() {
2074 let p = parser_with_mode("warn");
2079 let text = "a < b {foo} c";
2080 let mut content = content_with_source_lines(text);
2081 SubstitutionGroup::Normal.apply(&mut content, &p, None);
2082
2083 assert!(content.rendered().contains("<"));
2085
2086 let warnings = p.take_substitution_warnings();
2087 assert_eq!(warnings.len(), 1);
2088 assert_spans(&warnings[0], text, "{foo}");
2089 assert_eq!(warnings[0].offset, text.find("{foo}").unwrap());
2090 }
2091
2092 #[test]
2093 fn warn_span_survives_earlier_quote_expansion() {
2094 let p = parser_with_mode("warn");
2097 let text = "*bold* {foo}";
2098 let mut content = content_with_source_lines(text);
2099 SubstitutionGroup::Normal.apply(&mut content, &p, None);
2100
2101 assert!(content.rendered().contains("<strong>"));
2102
2103 let warnings = p.take_substitution_warnings();
2104 assert_eq!(warnings.len(), 1);
2105 assert_spans(&warnings[0], text, "{foo}");
2106 assert_eq!(warnings[0].offset, text.find("{foo}").unwrap());
2107 }
2108
2109 #[test]
2110 fn warn_falls_back_to_whole_span_without_source_lines() {
2111 let p = parser_with_mode("warn");
2115 let text = "x {foo} y";
2116 let mut content = Content::from(Span::new(text));
2117 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2118
2119 let warnings = p.take_substitution_warnings();
2120 assert_eq!(warnings.len(), 1);
2121 assert_eq!(warnings[0].offset, 0);
2122 assert_eq!(warnings[0].len, text.len());
2123 }
2124 }
2125 }
2126
2127 mod callouts {
2128 use crate::{
2129 content::{Content, SubstitutionStep},
2130 parser::ModificationContext,
2131 strings::CowStr,
2132 tests::prelude::*,
2133 };
2134
2135 fn render_callouts(text: &str, parser: &Parser) -> String {
2139 let mut content = Content::from(crate::Span::new(text));
2140
2141 SubstitutionStep::Callouts.apply(&mut content, parser, None);
2144 content.rendered.to_string()
2145 }
2146
2147 #[test]
2148 fn empty() {
2149 let mut content = Content::from(crate::Span::default());
2150 let p = Parser::default();
2151 SubstitutionStep::Callouts.apply(&mut content, &p, None);
2152 assert!(content.is_empty());
2153 assert_eq!(content.rendered, CowStr::Borrowed(""));
2154 }
2155
2156 #[test]
2157 fn no_callouts() {
2158 let p = Parser::default();
2159 assert_eq!(render_callouts("just some text", &p), "just some text");
2160 }
2161
2162 #[test]
2163 fn lt_without_callout_is_untouched() {
2164 let p = Parser::default();
2165 assert_eq!(render_callouts("a <b> c", &p), "a <b> c");
2166 }
2167
2168 #[test]
2169 fn basic_explicit() {
2170 let p = Parser::default();
2171 assert_eq!(
2172 render_callouts("require 'x' <1>", &p),
2173 r#"require 'x' <b class="conum">(1)</b>"#
2174 );
2175 }
2176
2177 #[test]
2178 fn line_comment_prefix_preserved() {
2179 let p = Parser::default();
2180 assert_eq!(
2181 render_callouts("puts 'x' # <1>", &p),
2182 r#"puts 'x' # <b class="conum">(1)</b>"#
2183 );
2184 }
2185
2186 #[test]
2187 fn multiple_on_one_line() {
2188 let p = Parser::default();
2189 assert_eq!(
2190 render_callouts("puts x <5><6>", &p),
2191 r#"puts x <b class="conum">(5)</b><b class="conum">(6)</b>"#
2192 );
2193 }
2194
2195 #[test]
2196 fn not_at_end_of_line() {
2197 let p = Parser::default();
2198 assert_eq!(
2199 render_callouts("puts \"<1> in the middle\"", &p),
2200 "puts \"<1> in the middle\""
2201 );
2202 }
2203
2204 #[test]
2205 fn auto_numbering() {
2206 let p = Parser::default();
2207 assert_eq!(
2208 render_callouts("a <.>\nb <.>\nc <.>", &p),
2209 "a <b class=\"conum\">(1)</b>\nb <b class=\"conum\">(2)</b>\nc <b class=\"conum\">(3)</b>"
2210 );
2211 }
2212
2213 #[test]
2214 fn mixed_numbering_ignores_explicit() {
2215 let p = Parser::default();
2217 assert_eq!(
2218 render_callouts("a <.>\nb <1>\nc <.>", &p),
2219 "a <b class=\"conum\">(1)</b>\nb <b class=\"conum\">(1)</b>\nc <b class=\"conum\">(2)</b>"
2220 );
2221 }
2222
2223 #[test]
2224 fn xml_callout() {
2225 let p = Parser::default();
2226 assert_eq!(
2227 render_callouts("<child/> <!--1-->", &p),
2228 r#"<child/> <!--<b class="conum">(1)</b>-->"#
2229 );
2230 }
2231
2232 #[test]
2233 fn half_xml_comment_is_not_a_callout() {
2234 let p = Parser::default();
2235 assert_eq!(
2236 render_callouts("First line <1-->", &p),
2237 "First line <1-->"
2238 );
2239 }
2240
2241 #[test]
2242 fn escaped_callout() {
2243 let p = Parser::default();
2244 assert_eq!(
2245 render_callouts("require 'x' # \\<1>", &p),
2246 "require 'x' # <1>"
2247 );
2248 }
2249
2250 #[test]
2251 fn icons_font() {
2252 let p = Parser::default().with_intrinsic_attribute(
2253 "icons",
2254 "font",
2255 ModificationContext::Anywhere,
2256 );
2257 assert_eq!(
2258 render_callouts("puts x # <1>", &p),
2259 r#"puts x <i class="conum" data-value="1"></i><b>(1)</b>"#
2260 );
2261 }
2262
2263 #[test]
2264 fn icons_image() {
2265 let p = Parser::default().with_intrinsic_attribute(
2266 "icons",
2267 "",
2268 ModificationContext::Anywhere,
2269 );
2270 assert_eq!(
2271 render_callouts("puts x <1>", &p),
2272 r#"puts x <img src="./images/icons/callouts/1.png" alt="1">"#
2273 );
2274 }
2275
2276 #[test]
2277 fn custom_line_comment_prefix() {
2278 let mut content = Content::from(crate::Span::new("hello() -> % <1>"));
2280 let attrlist = crate::attributes::Attrlist::parse(
2281 crate::Span::new("source,erlang,line-comment=%"),
2282 &Parser::default(),
2283 crate::attributes::AttrlistContext::Block,
2284 )
2285 .item
2286 .item;
2287 let p = Parser::default();
2288 SubstitutionStep::Callouts.apply(&mut content, &p, Some(&attrlist));
2289 assert_eq!(
2290 content.rendered.to_string(),
2291 r#"hello() -> % <b class="conum">(1)</b>"#
2292 );
2293 }
2294
2295 #[test]
2296 fn disabled_line_comment_preserves_leading_chars() {
2297 let mut content = Content::from(crate::Span::new("-- <1>"));
2300 let attrlist = crate::attributes::Attrlist::parse(
2301 crate::Span::new("source,asciidoc,line-comment="),
2302 &Parser::default(),
2303 crate::attributes::AttrlistContext::Block,
2304 )
2305 .item
2306 .item;
2307 let p = Parser::default();
2308 SubstitutionStep::Callouts.apply(&mut content, &p, Some(&attrlist));
2309 assert_eq!(
2310 content.rendered.to_string(),
2311 r#"-- <b class="conum">(1)</b>"#
2312 );
2313 }
2314
2315 #[test]
2316 fn document_line_comment_attribute() {
2317 let p = Parser::default().with_intrinsic_attribute(
2320 "line-comment",
2321 "%",
2322 ModificationContext::Anywhere,
2323 );
2324 assert_eq!(
2325 render_callouts("hello() -> % <1>", &p),
2326 r#"hello() -> % <b class="conum">(1)</b>"#
2327 );
2328 }
2329 }
2330}