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, Hash, 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)]
522 Regex::new(r#"(\\)?\{(?:(counter2?):([^{}]+?)|(\w[\w-]*))(\\)?\}"#).unwrap()
523});
524
525#[derive(Clone, Copy, Debug, Eq, PartialEq)]
530pub(crate) enum AttributeMissing {
531 Skip,
533
534 Drop,
536
537 DropLine,
539
540 Warn,
542}
543
544impl AttributeMissing {
545 pub(crate) fn from_parser(parser: &Parser) -> Self {
549 match parser.attribute_value("attribute-missing").as_maybe_str() {
550 Some("drop") => Self::Drop,
551 Some("drop-line") => Self::DropLine,
552 Some("warn") => Self::Warn,
553 _ => Self::Skip,
554 }
555 }
556}
557
558#[derive(Debug)]
609struct AttributeReplacer<'p> {
610 parser: &'p Parser,
611
612 mode: AttributeMissing,
614
615 fallback_source: Span<'p>,
620
621 source_line: Option<Span<'p>>,
626
627 source_matches: Vec<Range<usize>>,
632
633 match_index: usize,
638
639 missing_on_line: bool,
645}
646
647impl<'p> AttributeReplacer<'p> {
648 fn new(
655 parser: &'p Parser,
656 mode: AttributeMissing,
657 fallback_source: Span<'p>,
658 source_line: Option<Span<'p>>,
659 ) -> Self {
660 let source_matches = match (mode, source_line) {
665 (AttributeMissing::Warn | AttributeMissing::DropLine, Some(line)) => {
666 ATTRIBUTE_REFERENCE
667 .find_iter(line.data())
668 .map(|m| m.range())
669 .collect()
670 }
671 _ => Vec::new(),
672 };
673
674 Self {
675 parser,
676 mode,
677 fallback_source,
678 source_line,
679 source_matches,
680 match_index: 0,
681 missing_on_line: false,
682 }
683 }
684
685 fn warning_source(&self, index: usize, matched: &str) -> Span<'p> {
694 if let Some(line) = self.source_line
695 && let Some(range) = self.source_matches.get(index)
696 && line.data().get(range.clone()) == Some(matched)
697 {
698 return line.slice(range.clone());
699 }
700
701 self.fallback_source
702 }
703}
704
705impl Replacer for AttributeReplacer<'_> {
706 fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
707 let match_index = self.match_index;
710 self.match_index += 1;
711
712 if caps.get(1).is_some() || caps.get(5).is_some() {
723 dest.push('{');
724
725 if let Some(directive) = caps.get(2) {
728 dest.push_str(directive.as_str());
729 dest.push(':');
730 dest.push_str(&caps[3]);
731 } else {
732 dest.push_str(&caps[4]);
733 }
734
735 dest.push('}');
736 return;
737 }
738
739 if let Some(directive) = caps.get(2) {
742 let mut parts = caps[3].splitn(2, ':');
745 let name = parts.next().unwrap_or_default();
746 let seed = parts.next();
747
748 let value = self.parser.counter(name, seed);
749
750 if directive.as_str() == "counter" {
752 dest.push_str(&value);
753 }
754 return;
755 }
756
757 let attr_name = &caps[4];
759
760 let lookup_name = attribute_lookup_name(attr_name);
767
768 if !self.parser.has_attribute(&lookup_name) {
769 match self.mode {
770 AttributeMissing::Skip => dest.push_str(&caps[0]),
771 AttributeMissing::Drop => {
772 self.missing_on_line = true;
777 }
778 AttributeMissing::DropLine => {
779 self.missing_on_line = true;
785 self.parser.record_substitution_warning(
786 self.warning_source(match_index, &caps[0]),
787 WarningType::SkippingReferenceToMissingAttribute(attr_name.to_string()),
788 );
789 }
790 AttributeMissing::Warn => {
791 dest.push_str(&caps[0]);
792 self.parser.record_substitution_warning(
793 self.warning_source(match_index, &caps[0]),
794 WarningType::SkippingReferenceToMissingAttribute(attr_name.to_string()),
795 );
796 }
797 }
798 return;
799 }
800
801 if let InterpretedValue::Value(value) = self.parser.attribute_value(&lookup_name) {
802 dest.push_str(value.as_ref());
803 }
804
805 }
808}
809
810fn drop_emptied_line(replaced: &str) -> bool {
820 replaced.strip_suffix('\r').unwrap_or(replaced).is_empty()
821}
822
823fn apply_attributes(content: &mut Content<'_>, parser: &Parser) {
824 if !content.rendered.contains('{') {
825 return;
826 }
827
828 let mode = AttributeMissing::from_parser(parser);
829 let source = content.original();
830
831 let source_lines = if mode == AttributeMissing::Warn || mode == AttributeMissing::DropLine {
839 content
840 .source_lines()
841 .filter(|lines| lines.len() == content.rendered.split('\n').count())
842 } else {
843 None
844 };
845
846 let mut out = String::with_capacity(content.rendered.len());
852 let mut changed = false;
853 let mut wrote_line = false;
854
855 for (index, line) in content.rendered.split('\n').enumerate() {
856 if !line.contains('{') {
857 if wrote_line {
858 out.push('\n');
859 }
860 out.push_str(line);
861 wrote_line = true;
862 continue;
863 }
864
865 let source_line = source_lines.and_then(|lines| lines.get(index).copied());
869 let mut replacer = AttributeReplacer::new(parser, mode, source, source_line);
870
871 let replaced = ATTRIBUTE_REFERENCE.replace_all(line, replacer.by_ref());
872
873 if replacer.missing_on_line
874 && (mode == AttributeMissing::DropLine
875 || (mode == AttributeMissing::Drop && drop_emptied_line(&replaced)))
876 {
877 changed = true;
882 continue;
883 }
884
885 if let Cow::Owned(_) = replaced {
886 changed = true;
887 }
888
889 if wrote_line {
890 out.push('\n');
891 }
892 out.push_str(&replaced);
893 wrote_line = true;
894 }
895
896 if changed {
899 content.rendered = out.into();
900 }
901}
902
903pub(crate) fn substitute_attributes_in_macro_target<'src>(
915 target: Span<'src>,
916 parser: &Parser,
917) -> Option<CowStr<'src>> {
918 let text = target.data();
919
920 if !text.contains('{') {
923 return Some(text.into());
924 }
925
926 let mode = AttributeMissing::from_parser(parser);
927
928 let mut replacer = AttributeReplacer::new(parser, mode, target, Some(target));
931
932 let replaced = ATTRIBUTE_REFERENCE.replace_all(text, replacer.by_ref());
933
934 if replacer.missing_on_line && mode == AttributeMissing::DropLine {
935 return None;
936 }
937
938 Some(replaced.into())
939}
940
941pub(crate) fn substitute_attributes_in_text(text: &str, parser: &Parser) -> String {
958 if !text.contains('{') {
959 return text.to_string();
960 }
961
962 let mode = AttributeMissing::from_parser(parser);
963 let source = Span::new(text);
964
965 let mut out = String::with_capacity(text.len());
966 let mut wrote_line = false;
967
968 for line in text.split('\n') {
969 if !line.contains('{') {
970 if wrote_line {
971 out.push('\n');
972 }
973 out.push_str(line);
974 wrote_line = true;
975 continue;
976 }
977
978 let mut replacer = AttributeReplacer::new(parser, mode, source, None);
982
983 let replaced = ATTRIBUTE_REFERENCE.replace_all(line, replacer.by_ref());
984
985 if replacer.missing_on_line
986 && (mode == AttributeMissing::DropLine
987 || (mode == AttributeMissing::Drop && drop_emptied_line(&replaced)))
988 {
989 continue;
994 }
995
996 if wrote_line {
997 out.push('\n');
998 }
999 out.push_str(&replaced);
1000 wrote_line = true;
1001 }
1002
1003 out
1004}
1005
1006pub(crate) fn substitute_attributes_in_reftext<'src>(
1017 reftext: Span<'src>,
1018 parser: &Parser,
1019) -> CowStr<'src> {
1020 if !reftext.data().contains('{') {
1021 return reftext.data().into();
1022 }
1023
1024 let mut content = Content::from(reftext);
1025 SubstitutionStep::AttributeReferences.apply(&mut content, parser, None);
1026 CowStr::from(content.rendered.to_string())
1027}
1028
1029fn apply_character_replacements(
1030 content: &mut Content<'_>,
1031 renderer: &dyn InlineSubstitutionRenderer,
1032) {
1033 if !REPLACEABLE_TEXT_SNIFF.is_match(content.rendered.as_ref()) {
1034 return;
1035 }
1036
1037 let mut owned: Option<String> = None;
1043
1044 for repl in &*REPLACEMENTS {
1045 let replacer = CharacterReplacer {
1046 type_: repl.type_.clone(),
1047 renderer,
1048 };
1049
1050 let replaced = {
1051 let haystack = owned
1052 .as_deref()
1053 .unwrap_or_else(|| content.rendered.as_ref());
1054
1055 match repl.pattern.replace_all(haystack, replacer) {
1056 Cow::Owned(new_result) => Some(new_result),
1057
1058 Cow::Borrowed(_) => None,
1061 }
1062 };
1063
1064 if let Some(new_result) = replaced {
1065 owned = Some(new_result);
1066 }
1067 }
1068
1069 if let Some(rendered) = owned {
1070 content.rendered = rendered.into();
1071 }
1072}
1073
1074struct CharacterReplacement {
1075 type_: CharacterReplacementType,
1076 pattern: Regex,
1077}
1078
1079static REPLACEABLE_TEXT_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
1080 #[allow(clippy::unwrap_used)]
1081 Regex::new(r#"[&']|--|\.\.\.|\([CRT]M?\)"#).unwrap()
1082});
1083
1084static REPLACEMENTS: LazyLock<Vec<CharacterReplacement>> = LazyLock::new(|| {
1090 vec![
1091 CharacterReplacement {
1092 type_: CharacterReplacementType::Copyright,
1094 #[allow(clippy::unwrap_used)]
1095 pattern: Regex::new(r#"\\?\(C\)"#).unwrap(),
1096 },
1097 CharacterReplacement {
1098 type_: CharacterReplacementType::Registered,
1100 #[allow(clippy::unwrap_used)]
1101 pattern: Regex::new(r#"\\?\(R\)"#).unwrap(),
1102 },
1103 CharacterReplacement {
1104 type_: CharacterReplacementType::Trademark,
1106 #[allow(clippy::unwrap_used)]
1107 pattern: Regex::new(r#"\\?\(TM\)"#).unwrap(),
1108 },
1109 CharacterReplacement {
1110 type_: CharacterReplacementType::EmDashSurroundedBySpaces,
1112 #[allow(clippy::unwrap_used)]
1113 pattern: Regex::new(r#"(?: |\n|^|\\)--(?: |\n|$)"#).unwrap(),
1114 },
1115 CharacterReplacement {
1116 type_: CharacterReplacementType::EmDashWithoutSpace,
1118 #[allow(clippy::unwrap_used)]
1119 pattern: Regex::new(r#"(\w)\\?--\b{start-half}"#).unwrap(),
1120 },
1121 CharacterReplacement {
1122 type_: CharacterReplacementType::Ellipsis,
1124 #[allow(clippy::unwrap_used)]
1125 pattern: Regex::new(r#"\\?\.\.\."#).unwrap(),
1126 },
1127 CharacterReplacement {
1128 type_: CharacterReplacementType::TypographicApostrophe,
1130 #[allow(clippy::unwrap_used)]
1131 pattern: Regex::new(r#"\\?`'"#).unwrap(),
1132 },
1133 CharacterReplacement {
1134 type_: CharacterReplacementType::TypographicApostrophe,
1136 #[allow(clippy::unwrap_used)]
1137 pattern: Regex::new(r#"([[:alnum:]])\\?'([[:alpha:]])"#).unwrap(),
1138 },
1139 CharacterReplacement {
1140 type_: CharacterReplacementType::SingleRightArrow,
1142 #[allow(clippy::unwrap_used)]
1143 pattern: Regex::new(r#"\\?->"#).unwrap(),
1144 },
1145 CharacterReplacement {
1146 type_: CharacterReplacementType::DoubleRightArrow,
1148 #[allow(clippy::unwrap_used)]
1149 pattern: Regex::new(r#"\\?=>"#).unwrap(),
1150 },
1151 CharacterReplacement {
1152 type_: CharacterReplacementType::SingleLeftArrow,
1154 #[allow(clippy::unwrap_used)]
1155 pattern: Regex::new(r#"\\?<-"#).unwrap(),
1156 },
1157 CharacterReplacement {
1158 type_: CharacterReplacementType::DoubleLeftArrow,
1160 #[allow(clippy::unwrap_used)]
1161 pattern: Regex::new(r#"\\?<="#).unwrap(),
1162 },
1163 CharacterReplacement {
1164 type_: CharacterReplacementType::CharacterReference("".to_owned()),
1166 #[allow(clippy::unwrap_used)]
1167 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(),
1168 },
1169 ]
1170});
1171
1172#[derive(Debug)]
1173struct CharacterReplacer<'r> {
1174 type_: CharacterReplacementType,
1175 renderer: &'r dyn InlineSubstitutionRenderer,
1176}
1177
1178impl Replacer for CharacterReplacer<'_> {
1179 fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
1180 if caps[0].contains('\\') {
1181 let unescaped = &caps[0].replace("\\", "");
1183 dest.push_str(unescaped);
1184 return;
1185 }
1186
1187 match self.type_ {
1188 CharacterReplacementType::Copyright
1189 | CharacterReplacementType::Registered
1190 | CharacterReplacementType::Trademark
1191 | CharacterReplacementType::EmDashSurroundedBySpaces
1192 | CharacterReplacementType::Ellipsis
1193 | CharacterReplacementType::SingleLeftArrow
1194 | CharacterReplacementType::DoubleLeftArrow
1195 | CharacterReplacementType::SingleRightArrow
1196 | CharacterReplacementType::DoubleRightArrow => {
1197 self.renderer
1198 .render_character_replacement(self.type_.clone(), dest);
1199 }
1200
1201 CharacterReplacementType::EmDashWithoutSpace => {
1202 dest.push_str(&caps[1]);
1203 self.renderer.render_character_replacement(
1204 CharacterReplacementType::EmDashWithoutSpace,
1205 dest,
1206 );
1207 }
1208
1209 CharacterReplacementType::TypographicApostrophe => {
1210 if let Some(before) = caps.get(1) {
1211 dest.push_str(before.as_str());
1212 }
1213
1214 self.renderer.render_character_replacement(
1215 CharacterReplacementType::TypographicApostrophe,
1216 dest,
1217 );
1218
1219 if let Some(after) = caps.get(2) {
1220 dest.push_str(after.as_str());
1221 }
1222 }
1223
1224 CharacterReplacementType::CharacterReference(_) => {
1225 self.renderer.render_character_replacement(
1226 CharacterReplacementType::CharacterReference(caps[1].to_string()),
1227 dest,
1228 );
1229 }
1230 }
1231 }
1232}
1233
1234fn apply_post_replacements(
1235 content: &mut Content<'_>,
1236 parser: &Parser,
1237 attrlist: Option<&Attrlist<'_>>,
1238) {
1239 if parser.is_attribute_set("hardbreaks-option")
1240 || attrlist.is_some_and(|attrlist| attrlist.has_option("hardbreaks"))
1241 {
1242 let text = content.rendered.as_ref();
1243 if !text.contains('\n') {
1244 return;
1245 }
1246
1247 let mut lines: Vec<&str> = content.rendered.as_ref().lines().collect();
1248 let last = lines.pop().unwrap_or_default();
1249
1250 let mut lines: Vec<String> = lines
1251 .iter()
1252 .map(|line| {
1253 let line = if line.ends_with(" +") {
1254 &line[0..line.len() - 2]
1255 } else {
1256 *line
1257 };
1258
1259 let mut line = line.to_owned();
1260 parser.renderer.render_line_break(&mut line);
1261 line
1262 })
1263 .collect();
1264
1265 lines.push(last.to_owned());
1266
1267 let new_result = lines.join("\n");
1268 content.rendered = new_result.into();
1269 } else {
1270 let rendered = content.rendered.as_ref();
1271
1272 if !rendered.contains('+') {
1280 return;
1281 }
1282
1283 let replacer = PostReplacementReplacer(&*parser.renderer);
1284
1285 if let Cow::Owned(new_result) = HARD_LINE_BREAK.replace_all(rendered, replacer) {
1286 content.rendered = new_result.into();
1287 }
1288 }
1289}
1290
1291#[derive(Debug)]
1292struct PostReplacementReplacer<'r>(&'r dyn InlineSubstitutionRenderer);
1293
1294impl Replacer for PostReplacementReplacer<'_> {
1295 fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
1296 dest.push_str(&caps[1]);
1297 self.0.render_line_break(dest);
1298 }
1299}
1300
1301static HARD_LINE_BREAK: LazyLock<Regex> = LazyLock::new(|| {
1302 #[allow(clippy::unwrap_used)]
1303 Regex::new(r#"(?m)^(.*) \+$"#).unwrap()
1304});
1305
1306fn apply_callouts(content: &mut Content<'_>, parser: &Parser, attrlist: Option<&Attrlist<'_>>) {
1322 if !content.rendered.contains("<") {
1325 return;
1326 }
1327
1328 let line_comment: Option<String> = attrlist
1338 .and_then(|a| a.named_attribute("line-comment"))
1339 .map(|a| a.value().to_string())
1340 .or_else(|| {
1341 if parser.has_attribute("line-comment") {
1342 Some(
1343 parser
1344 .attribute_value("line-comment")
1345 .as_maybe_str()
1346 .unwrap_or("")
1347 .to_string(),
1348 )
1349 } else {
1350 None
1351 }
1352 });
1353
1354 let (callout_rx, tail_rx) = build_callout_regexes(line_comment.as_deref());
1355
1356 let replacer = CalloutReplacer {
1357 renderer: &*parser.renderer,
1358 parser,
1359 autonum: 0,
1360 tail: tail_rx,
1361 };
1362
1363 if let Cow::Owned(new_result) =
1364 replace_with_lookahead(&callout_rx, content.rendered.as_ref(), replacer)
1365 {
1366 content.rendered = new_result.into();
1367 }
1368}
1369
1370static DEFAULT_CALLOUT_RX: LazyLock<Regex> = LazyLock::new(|| {
1373 #[allow(clippy::unwrap_used)]
1374 Regex::new(
1375 r"(?P<prefix>(?://|#|--|;;) ?)?(?P<esc>\\)?(?:<!--(?P<xnum>\d+|\.)-->|<(?P<num>\d+|\.)>)",
1376 )
1377 .unwrap()
1378});
1379
1380static DEFAULT_CALLOUT_TAIL_RX: LazyLock<Regex> = LazyLock::new(|| {
1382 #[allow(clippy::unwrap_used)]
1383 Regex::new(r"^(?: ?\\?(?:<!--(?:\d+|\.)-->|<(?:\d+|\.)>))*(?:\n|$)").unwrap()
1384});
1385
1386static CUSTOM_CALLOUT_TAIL_RX: LazyLock<Regex> = LazyLock::new(|| {
1389 #[allow(clippy::unwrap_used)]
1390 Regex::new(r"^(?: ?\\?<(?:\d+|\.)>)*(?:\n|$)").unwrap()
1391});
1392
1393fn build_callout_regexes(line_comment: Option<&str>) -> (Cow<'static, Regex>, &'static Regex) {
1406 match line_comment {
1407 None => (Cow::Borrowed(&DEFAULT_CALLOUT_RX), &DEFAULT_CALLOUT_TAIL_RX),
1409
1410 Some(prefix) => {
1413 let prefix_pattern = if prefix.is_empty() {
1414 String::new()
1415 } else {
1416 format!(r"(?P<prefix>{} ?)?", regex::escape(prefix))
1417 };
1418
1419 #[allow(clippy::unwrap_used)]
1420 let callout = Regex::new(&format!(
1421 r"{prefix_pattern}(?P<esc>\\)?<(?P<num>\d+|\.)>"
1422 ))
1423 .unwrap();
1424
1425 (Cow::Owned(callout), &CUSTOM_CALLOUT_TAIL_RX)
1426 }
1427 }
1428}
1429
1430struct CalloutReplacer<'r> {
1433 renderer: &'r dyn InlineSubstitutionRenderer,
1434 parser: &'r Parser,
1435
1436 autonum: u32,
1439
1440 tail: &'r Regex,
1442}
1443
1444impl LookaheadReplacer for CalloutReplacer<'_> {
1445 fn replace_append(
1446 &mut self,
1447 caps: &Captures<'_>,
1448 dest: &mut String,
1449 after: &str,
1450 ) -> LookaheadResult {
1451 if !self.tail.is_match(after) {
1454 dest.push_str(&caps[0]);
1455 return LookaheadResult::Continue;
1456 }
1457
1458 if caps.name("esc").is_some() {
1461 dest.push_str(&caps[0].replacen('\\', "", 1));
1462 return LookaheadResult::Continue;
1463 }
1464
1465 let (number_raw, is_xml) = if let Some(xnum) = caps.name("xnum") {
1466 (xnum.as_str(), true)
1467 } else {
1468 #[allow(clippy::unwrap_used)]
1470 (caps.name("num").unwrap().as_str(), false)
1471 };
1472
1473 let number = if number_raw == "." {
1474 self.autonum += 1;
1475 self.autonum.to_string()
1476 } else {
1477 number_raw.to_string()
1478 };
1479
1480 if let Ok(n) = number.parse::<u32>() {
1483 self.parser.register_callout(n);
1484 }
1485
1486 let guard = match caps.name("prefix") {
1490 Some(prefix) => CalloutGuard::LineComment(prefix.as_str()),
1491 None if is_xml => CalloutGuard::Xml,
1492 None => CalloutGuard::LineComment(""),
1493 };
1494
1495 self.renderer.render_callout(
1496 &CalloutRenderParams {
1497 number: &number,
1498 guard,
1499 parser: self.parser,
1500 },
1501 dest,
1502 );
1503
1504 LookaheadResult::Continue
1505 }
1506}
1507
1508#[cfg(test)]
1509mod tests {
1510 #![allow(clippy::unwrap_used)]
1511
1512 mod special_characters {
1513 use crate::{
1514 content::{Content, SubstitutionStep},
1515 strings::CowStr,
1516 tests::prelude::*,
1517 };
1518
1519 #[test]
1520 fn empty() {
1521 let mut content = Content::from(crate::Span::default());
1522 let p = Parser::default();
1523 SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1524 assert!(content.is_empty());
1525 assert_eq!(content.rendered, CowStr::Borrowed(""));
1526 }
1527
1528 #[test]
1529 fn basic_non_empty_span() {
1530 let mut content = Content::from(crate::Span::new("blah"));
1531 let p = Parser::default();
1532 SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1533 assert!(!content.is_empty());
1534 assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1535 }
1536
1537 #[test]
1538 fn match_lt_and_gt() {
1539 let mut content = Content::from(crate::Span::new("bl<ah>"));
1540 let p = Parser::default();
1541 SubstitutionStep::SpecialCharacters.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 match_amp() {
1551 let mut content = Content::from(crate::Span::new("bl<a&h>"));
1552 let p = Parser::default();
1553 SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1554 assert!(!content.is_empty());
1555 assert_eq!(
1556 content.rendered,
1557 CowStr::Boxed("bl<a&h>".to_string().into_boxed_str())
1558 );
1559 }
1560 }
1561
1562 mod quotes {
1563 use crate::{
1564 content::{Content, SubstitutionStep},
1565 strings::CowStr,
1566 tests::prelude::*,
1567 };
1568
1569 #[test]
1570 fn empty() {
1571 let mut content = Content::from(crate::Span::default());
1572 let p = Parser::default();
1573 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1574 assert!(content.is_empty());
1575 assert_eq!(content.rendered, CowStr::Borrowed(""));
1576 }
1577
1578 #[test]
1579 fn basic_non_empty_span() {
1580 let mut content = Content::from(crate::Span::new("blah"));
1581 let p = Parser::default();
1582 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1583 assert!(!content.is_empty());
1584 assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1585 }
1586
1587 #[test]
1588 fn ignore_lt_and_gt() {
1589 let mut content = Content::from(crate::Span::new("bl<ah>"));
1590 let p = Parser::default();
1591 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1592 assert!(!content.is_empty());
1593 assert_eq!(
1594 content.rendered,
1595 CowStr::Boxed("bl<ah>".to_string().into_boxed_str())
1596 );
1597 }
1598
1599 #[test]
1600 fn strong_word() {
1601 let mut content = Content::from(crate::Span::new("One *word* is strong."));
1602 let p = Parser::default();
1603 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1604 assert!(!content.is_empty());
1605 assert_eq!(
1606 content.rendered,
1607 CowStr::Boxed(
1608 "One <strong>word</strong> is strong."
1609 .to_string()
1610 .into_boxed_str()
1611 )
1612 );
1613 }
1614
1615 #[test]
1616 fn marked_string_with_id() {
1617 let mut content = Content::from(crate::Span::new(r#"[#id]#a few words#"#));
1618 let p = Parser::default();
1619 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1620 assert!(!content.is_empty());
1621 assert_eq!(
1622 content.rendered,
1623 CowStr::Boxed(r#"<span id="id">a few words</span>"#.to_string().into_boxed_str())
1624 );
1625 }
1626
1627 #[test]
1628 fn unconstrained_marked_string_with_id_is_registered() {
1629 let doc = Parser::default().parse(r#"[#the_id]##marked text##"#);
1633
1634 assert_eq!(
1635 doc.child_blocks()
1636 .next()
1637 .unwrap()
1638 .rendered_content()
1639 .unwrap(),
1640 r#"<span id="the_id">marked text</span>"#
1641 );
1642
1643 assert!(doc.catalog().contains_id("the_id"));
1644 }
1645
1646 #[test]
1647 fn multibyte_leading_char_before_constrained_monospace() {
1648 for leading in ["€", "中", "🎉"] {
1654 let source = format!("{leading}`code``");
1655 let mut content = Content::from(crate::Span::new(&source));
1656 let p = Parser::default();
1657
1658 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1660
1661 assert!(content.rendered.starts_with(leading));
1662 }
1663 }
1664
1665 #[test]
1666 fn escaped_leading_backtick_before_constrained_monospace() {
1667 let mut content = Content::from(crate::Span::new(r"\`code``"));
1672 let p = Parser::default();
1673
1674 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1675
1676 assert_eq!(
1677 content.rendered,
1678 CowStr::Boxed(r"\`code``".to_string().into_boxed_str())
1679 );
1680 }
1681 }
1682
1683 mod attribute_references {
1684 use crate::{
1685 content::{Content, SubstitutionStep},
1686 strings::CowStr,
1687 tests::prelude::*,
1688 };
1689
1690 #[test]
1691 fn empty() {
1692 let mut content = Content::from(crate::Span::default());
1693 let p = Parser::default();
1694 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1695 assert!(content.is_empty());
1696 assert_eq!(content.rendered, CowStr::Borrowed(""));
1697 }
1698
1699 #[test]
1700 fn basic_non_empty_span() {
1701 let mut content = Content::from(crate::Span::new("blah"));
1702 let p = Parser::default();
1703 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1704 assert!(!content.is_empty());
1705 assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1706 }
1707
1708 #[test]
1709 fn ignore_non_match() {
1710 let mut content = Content::from(crate::Span::new("bl{ah}"));
1711 let p = Parser::default();
1712 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1713 assert!(!content.is_empty());
1714 assert_eq!(
1715 content.rendered,
1716 CowStr::Boxed("bl{ah}".to_string().into_boxed_str())
1717 );
1718 }
1719
1720 #[test]
1721 fn escaped_reference_to_unset_attribute_drops_backslash() {
1722 let mut content = Content::from(crate::Span::new("bl\\{ah}"));
1726 let p = Parser::default();
1727 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1728 assert!(!content.is_empty());
1729 assert_eq!(
1730 content.rendered,
1731 CowStr::Boxed("bl{ah}".to_string().into_boxed_str())
1732 );
1733 }
1734
1735 #[test]
1736 fn replace_sp_match() {
1737 let mut content = Content::from(crate::Span::new("bl{sp}ah"));
1738 let p = Parser::default();
1739 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1740 assert!(!content.is_empty());
1741 assert_eq!(
1742 content.rendered,
1743 CowStr::Boxed("bl ah".to_string().into_boxed_str())
1744 );
1745 }
1746
1747 #[test]
1748 fn ignore_escaped_sp_match() {
1749 let mut content = Content::from(crate::Span::new("bl\\{sp}ah"));
1750 let p = Parser::default();
1751 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1752 assert!(!content.is_empty());
1753 assert_eq!(
1754 content.rendered,
1755 CowStr::Boxed("bl{sp}ah".to_string().into_boxed_str())
1756 );
1757 }
1758
1759 #[test]
1760 fn counter_directive_displays_and_advances() {
1761 let mut content = Content::from(crate::Span::new("{counter:n}-{counter:n}"));
1762 let p = Parser::default();
1763 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1764 assert_eq!(
1765 content.rendered,
1766 CowStr::Boxed("1-2".to_string().into_boxed_str())
1767 );
1768 }
1769
1770 #[test]
1771 fn counter2_directive_advances_silently() {
1772 let mut content = Content::from(crate::Span::new("{counter2:n}{counter:n}"));
1773 let p = Parser::default();
1774 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1775 assert_eq!(
1776 content.rendered,
1777 CowStr::Boxed("2".to_string().into_boxed_str())
1778 );
1779 }
1780
1781 #[test]
1782 fn escaped_counter_directive_is_literal_and_does_not_advance() {
1783 let mut content = Content::from(crate::Span::new("\\{counter:n} {counter:n}"));
1784 let p = Parser::default();
1785 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1786 assert_eq!(
1787 content.rendered,
1788 CowStr::Boxed("{counter:n} 1".to_string().into_boxed_str())
1789 );
1790 }
1791
1792 #[test]
1793 fn escaped_reference_with_both_braces_escaped_drops_backslashes() {
1794 let p = Parser::default().with_intrinsic_attribute(
1799 "group-id",
1800 "42",
1801 crate::parser::ModificationContext::Anywhere,
1802 );
1803
1804 let mut content = Content::from(crate::Span::new("\\{group-id\\}"));
1805 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1806 assert_eq!(
1807 content.rendered,
1808 CowStr::Boxed("{group-id}".to_string().into_boxed_str())
1809 );
1810 }
1811
1812 #[test]
1813 fn escaped_reference_with_only_trailing_brace_escaped_drops_backslash() {
1814 let p = Parser::default().with_intrinsic_attribute(
1817 "group-id",
1818 "42",
1819 crate::parser::ModificationContext::Anywhere,
1820 );
1821
1822 let mut content = Content::from(crate::Span::new("{group-id\\}"));
1823 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1824 assert_eq!(
1825 content.rendered,
1826 CowStr::Boxed("{group-id}".to_string().into_boxed_str())
1827 );
1828 }
1829
1830 #[test]
1831 fn escaped_counter_with_trailing_backslash_is_literal_and_does_not_advance() {
1832 let mut content = Content::from(crate::Span::new("{counter:n\\} {counter:n}"));
1836 let p = Parser::default();
1837 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1838 assert_eq!(
1839 content.rendered,
1840 CowStr::Boxed("{counter:n} 1".to_string().into_boxed_str())
1841 );
1842 }
1843
1844 mod attribute_missing {
1845 #![allow(clippy::indexing_slicing)]
1846
1847 use crate::{
1848 Span,
1849 content::{Content, SubstitutionGroup, SubstitutionStep},
1850 parser::ModificationContext,
1851 tests::prelude::*,
1852 warnings::WarningType,
1853 };
1854
1855 fn parser_with_mode(mode: &str) -> Parser {
1856 Parser::default().with_intrinsic_attribute(
1857 "attribute-missing",
1858 mode,
1859 ModificationContext::Anywhere,
1860 )
1861 }
1862
1863 fn render(text: &str, parser: &Parser) -> String {
1864 let mut content = Content::from(crate::Span::new(text));
1865 SubstitutionStep::AttributeReferences.apply(&mut content, parser, None);
1866 content.rendered.to_string()
1867 }
1868
1869 fn content_with_source_lines(text: &'static str) -> Content<'static> {
1875 let root = Span::new(text);
1876 let lines: Vec<&str> = text.split('\n').collect();
1877
1878 let mut spans = Vec::with_capacity(lines.len());
1879 let mut offset = 0;
1880 for line in &lines {
1881 spans.push(root.slice(offset..offset + line.len()));
1882
1883 offset += line.len() + 1;
1885 }
1886
1887 Content::from_filtered_lines(root, &lines, spans)
1888 }
1889
1890 fn assert_spans(warning: &crate::parser::DeferredWarning, text: &str, expected: &str) {
1894 assert_eq!(
1895 &text[warning.offset..warning.offset + warning.len],
1896 expected
1897 );
1898 }
1899
1900 #[test]
1901 fn skip_is_default() {
1902 let p = Parser::default();
1903 assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1904 assert!(p.take_substitution_warnings().is_empty());
1905 }
1906
1907 #[test]
1908 fn skip_explicit() {
1909 let p = parser_with_mode("skip");
1910 assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1911 }
1912
1913 #[test]
1914 fn unknown_value_falls_back_to_skip() {
1915 let p = parser_with_mode("bogus");
1916 assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1917 }
1918
1919 #[test]
1920 fn drop_removes_only_the_reference() {
1921 let p = parser_with_mode("drop");
1922 assert_eq!(render("Hello, {name}!", &p), "Hello, !");
1923 }
1924
1925 #[test]
1926 fn drop_keeps_resolvable_references() {
1927 let p = parser_with_mode("drop");
1928 assert_eq!(render("a {sp}b {missing} c", &p), "a b c");
1929 }
1930
1931 #[test]
1932 fn drop_removes_line_that_only_contained_the_reference() {
1933 let p = parser_with_mode("drop");
1936 assert_eq!(render("Line 1\n{missing}\nLine 2", &p), "Line 1\nLine 2");
1937 }
1938
1939 #[test]
1940 fn drop_keeps_a_line_the_reference_did_not_empty() {
1941 let p = parser_with_mode("drop");
1944 assert_eq!(
1945 render("Line 1\ntext {missing}\nLine 2", &p),
1946 "Line 1\ntext \nLine 2"
1947 );
1948 }
1949
1950 #[test]
1951 fn drop_removes_a_leading_or_trailing_reference_only_line() {
1952 let p = parser_with_mode("drop");
1953 assert_eq!(render("{missing}\nLine 2", &p), "Line 2");
1954 assert_eq!(render("Line 1\n{missing}", &p), "Line 1");
1955 }
1956
1957 #[test]
1958 fn drop_can_empty_the_content() {
1959 let p = parser_with_mode("drop");
1962 assert_eq!(render("{missing}", &p), "");
1963 }
1964
1965 #[test]
1966 fn drop_keeps_a_line_emptied_by_a_resolvable_reference() {
1967 let p = parser_with_mode("drop");
1970 assert_eq!(render("Line 1\n{empty}\nLine 2", &p), "Line 1\n\nLine 2");
1971 }
1972
1973 #[test]
1974 fn drop_line_removes_the_whole_line() {
1975 let p = parser_with_mode("drop-line");
1976 assert_eq!(render("Hello, {name}!\nSecond line.", &p), "Second line.");
1977 }
1978
1979 #[test]
1980 fn drop_line_only_drops_lines_with_a_missing_reference() {
1981 let p = parser_with_mode("drop-line");
1982 assert_eq!(
1983 render("first {sp}line\nsecond {missing} line\nthird line", &p),
1984 "first line\nthird line"
1985 );
1986 }
1987
1988 #[test]
1989 fn drop_line_can_empty_the_content() {
1990 let p = parser_with_mode("drop-line");
1991 assert_eq!(render("{missing}", &p), "");
1992 }
1993
1994 #[test]
1995 fn drop_line_records_a_warning_for_the_dropped_reference() {
1996 let p = parser_with_mode("drop-line");
2000 assert_eq!(render("Hello, {name}!\nSecond line.", &p), "Second line.");
2001
2002 let warnings = p.take_substitution_warnings();
2003 assert_eq!(warnings.len(), 1);
2004 assert_eq!(
2005 warnings[0].warning,
2006 WarningType::SkippingReferenceToMissingAttribute("name".to_string())
2007 );
2008 }
2009
2010 #[test]
2011 fn drop_line_records_one_warning_per_missing_reference() {
2012 let p = parser_with_mode("drop-line");
2015 assert_eq!(render("a {x} b {y} c\ntail", &p), "tail");
2016 assert_eq!(p.take_substitution_warnings().len(), 2);
2017 }
2018
2019 #[test]
2020 fn drop_line_does_not_warn_for_a_line_without_a_missing_reference() {
2021 let p = parser_with_mode("drop-line");
2024 assert_eq!(
2025 render("first {sp}line\nsecond {missing} line\nthird line", &p),
2026 "first line\nthird line"
2027 );
2028
2029 let warnings = p.take_substitution_warnings();
2030 assert_eq!(warnings.len(), 1);
2031 assert_eq!(
2032 warnings[0].warning,
2033 WarningType::SkippingReferenceToMissingAttribute("missing".to_string())
2034 );
2035 }
2036
2037 #[test]
2038 fn drop_line_points_at_the_precise_reference() {
2039 let p = parser_with_mode("drop-line");
2042 let text = "first {alpha} line\nsecond {beta} line";
2043 let mut content = content_with_source_lines(text);
2044 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2045
2046 let warnings = p.take_substitution_warnings();
2047 assert_eq!(warnings.len(), 2);
2048 assert_spans(&warnings[0], text, "{alpha}");
2049 assert_spans(&warnings[1], text, "{beta}");
2050 }
2051
2052 #[test]
2053 fn drop_line_falls_back_to_whole_span_without_source_lines() {
2054 let p = parser_with_mode("drop-line");
2058 let text = "x {foo} y";
2059 let mut content = Content::from(Span::new(text));
2060 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2061
2062 let warnings = p.take_substitution_warnings();
2063 assert_eq!(warnings.len(), 1);
2064 assert_eq!(warnings[0].offset, 0);
2065 assert_eq!(warnings[0].len, text.len());
2066 }
2067
2068 mod free_standing_text {
2073 use super::parser_with_mode;
2074 use crate::content::substitute_attributes_in_text;
2075
2076 #[test]
2077 fn drop_removes_line_that_only_contained_the_reference() {
2078 let p = parser_with_mode("drop");
2079 assert_eq!(
2080 substitute_attributes_in_text("Line 1\n{missing}\nLine 2", &p),
2081 "Line 1\nLine 2"
2082 );
2083 }
2084
2085 #[test]
2086 fn drop_keeps_a_line_the_reference_did_not_empty() {
2087 let p = parser_with_mode("drop");
2088 assert_eq!(
2089 substitute_attributes_in_text("Line 1\ntext {missing}\nLine 2", &p),
2090 "Line 1\ntext \nLine 2"
2091 );
2092 }
2093
2094 #[test]
2095 fn drop_keeps_a_line_emptied_by_a_resolvable_reference() {
2096 let p = parser_with_mode("drop");
2097 assert_eq!(
2098 substitute_attributes_in_text("Line 1\n{empty}\nLine 2", &p),
2099 "Line 1\n\nLine 2"
2100 );
2101 }
2102
2103 #[test]
2104 fn drop_line_removes_the_whole_line() {
2105 let p = parser_with_mode("drop-line");
2106 assert_eq!(
2107 substitute_attributes_in_text("Line 1\n{missing} tail\nLine 2", &p),
2108 "Line 1\nLine 2"
2109 );
2110 }
2111
2112 #[test]
2113 fn drop_line_records_a_warning() {
2114 use crate::warnings::WarningType;
2118
2119 let p = parser_with_mode("drop-line");
2120 assert_eq!(
2121 substitute_attributes_in_text("Line 1\n{missing} tail\nLine 2", &p),
2122 "Line 1\nLine 2"
2123 );
2124
2125 let warnings = p.take_substitution_warnings();
2126 assert_eq!(warnings.len(), 1);
2127 assert_eq!(
2128 warnings[0].warning,
2129 WarningType::SkippingReferenceToMissingAttribute("missing".to_string())
2130 );
2131 }
2132
2133 #[test]
2134 fn drop_removes_a_crlf_reference_only_line() {
2135 let p = parser_with_mode("drop");
2139 assert_eq!(
2140 substitute_attributes_in_text("Line 1\r\n{missing}\r\nLine 2", &p),
2141 "Line 1\r\nLine 2"
2142 );
2143 }
2144
2145 #[test]
2146 fn drop_keeps_a_crlf_line_the_reference_did_not_empty() {
2147 let p = parser_with_mode("drop");
2148 assert_eq!(
2149 substitute_attributes_in_text("Line 1\r\ntext {missing}\r\nLine 2", &p),
2150 "Line 1\r\ntext \r\nLine 2"
2151 );
2152 }
2153 }
2154
2155 #[test]
2156 fn warn_leaves_the_reference_and_records_a_warning() {
2157 let p = parser_with_mode("warn");
2158 assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
2159
2160 let warnings = p.take_substitution_warnings();
2161 assert_eq!(warnings.len(), 1);
2162 assert_eq!(
2163 warnings[0].warning,
2164 WarningType::SkippingReferenceToMissingAttribute("name".to_string())
2165 );
2166 }
2167
2168 #[test]
2169 fn warn_records_one_warning_per_missing_reference() {
2170 let p = parser_with_mode("warn");
2171 assert_eq!(render("a {x} b {y} c", &p), "a {x} b {y} c");
2172 assert_eq!(p.take_substitution_warnings().len(), 2);
2173 }
2174
2175 #[test]
2176 fn escaped_missing_reference_drops_the_backslash_and_never_drops_the_line() {
2177 let p = parser_with_mode("drop-line");
2182 assert_eq!(
2183 render("In the path /items/\\{id}, x.", &p),
2184 "In the path /items/{id}, x."
2185 );
2186 assert!(p.take_substitution_warnings().is_empty());
2187 }
2188
2189 #[test]
2196 fn warn_points_at_the_precise_reference() {
2197 let p = parser_with_mode("warn");
2198 let text = "Hello, {name}!";
2199 let mut content = content_with_source_lines(text);
2200 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2201
2202 let warnings = p.take_substitution_warnings();
2203 assert_eq!(warnings.len(), 1);
2204 assert_spans(&warnings[0], text, "{name}");
2205 }
2206
2207 #[test]
2208 fn warn_locates_multiple_references_on_one_line() {
2209 let p = parser_with_mode("warn");
2210 let text = "a {x} b {y} c";
2211 let mut content = content_with_source_lines(text);
2212 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2213
2214 let warnings = p.take_substitution_warnings();
2215 assert_eq!(warnings.len(), 2);
2216 assert_spans(&warnings[0], text, "{x}");
2217 assert_spans(&warnings[1], text, "{y}");
2218
2219 assert_ne!(warnings[0].offset, warnings[1].offset);
2221 }
2222
2223 #[test]
2224 fn warn_locates_references_across_multiple_lines() {
2225 let p = parser_with_mode("warn");
2229 let text = "first {alpha} line\nsecond {beta} line\nthird {gamma} line";
2230 let mut content = content_with_source_lines(text);
2231 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2232
2233 let warnings = p.take_substitution_warnings();
2234 assert_eq!(warnings.len(), 3);
2235 assert_spans(&warnings[0], text, "{alpha}");
2236 assert_spans(&warnings[1], text, "{beta}");
2237 assert_spans(&warnings[2], text, "{gamma}");
2238 }
2239
2240 #[test]
2241 fn warn_distinguishes_repeated_reference_occurrences() {
2242 let p = parser_with_mode("warn");
2243 let text = "{dup} and again {dup}";
2244 let mut content = content_with_source_lines(text);
2245 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2246
2247 let warnings = p.take_substitution_warnings();
2248 assert_eq!(warnings.len(), 2);
2249 assert_spans(&warnings[0], text, "{dup}");
2250 assert_spans(&warnings[1], text, "{dup}");
2251
2252 assert_eq!(warnings[0].offset, 0);
2254 assert_eq!(warnings[1].offset, text.rfind("{dup}").unwrap());
2255 }
2256
2257 #[test]
2258 fn warn_span_survives_earlier_special_character_expansion() {
2259 let p = parser_with_mode("warn");
2264 let text = "a < b {foo} c";
2265 let mut content = content_with_source_lines(text);
2266 SubstitutionGroup::Normal.apply(&mut content, &p, None);
2267
2268 assert!(content.rendered().contains("<"));
2270
2271 let warnings = p.take_substitution_warnings();
2272 assert_eq!(warnings.len(), 1);
2273 assert_spans(&warnings[0], text, "{foo}");
2274 assert_eq!(warnings[0].offset, text.find("{foo}").unwrap());
2275 }
2276
2277 #[test]
2278 fn warn_span_survives_earlier_quote_expansion() {
2279 let p = parser_with_mode("warn");
2282 let text = "*bold* {foo}";
2283 let mut content = content_with_source_lines(text);
2284 SubstitutionGroup::Normal.apply(&mut content, &p, None);
2285
2286 assert!(content.rendered().contains("<strong>"));
2287
2288 let warnings = p.take_substitution_warnings();
2289 assert_eq!(warnings.len(), 1);
2290 assert_spans(&warnings[0], text, "{foo}");
2291 assert_eq!(warnings[0].offset, text.find("{foo}").unwrap());
2292 }
2293
2294 #[test]
2295 fn warn_falls_back_to_whole_span_without_source_lines() {
2296 let p = parser_with_mode("warn");
2300 let text = "x {foo} y";
2301 let mut content = Content::from(Span::new(text));
2302 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2303
2304 let warnings = p.take_substitution_warnings();
2305 assert_eq!(warnings.len(), 1);
2306 assert_eq!(warnings[0].offset, 0);
2307 assert_eq!(warnings[0].len, text.len());
2308 }
2309 }
2310 }
2311
2312 mod callouts {
2313 use crate::{
2314 content::{Content, SubstitutionStep},
2315 parser::ModificationContext,
2316 strings::CowStr,
2317 tests::prelude::*,
2318 };
2319
2320 fn render_callouts(text: &str, parser: &Parser) -> String {
2324 let mut content = Content::from(crate::Span::new(text));
2325
2326 SubstitutionStep::Callouts.apply(&mut content, parser, None);
2329 content.rendered.to_string()
2330 }
2331
2332 #[test]
2333 fn empty() {
2334 let mut content = Content::from(crate::Span::default());
2335 let p = Parser::default();
2336 SubstitutionStep::Callouts.apply(&mut content, &p, None);
2337 assert!(content.is_empty());
2338 assert_eq!(content.rendered, CowStr::Borrowed(""));
2339 }
2340
2341 #[test]
2342 fn no_callouts() {
2343 let p = Parser::default();
2344 assert_eq!(render_callouts("just some text", &p), "just some text");
2345 }
2346
2347 #[test]
2348 fn lt_without_callout_is_untouched() {
2349 let p = Parser::default();
2350 assert_eq!(render_callouts("a <b> c", &p), "a <b> c");
2351 }
2352
2353 #[test]
2354 fn basic_explicit() {
2355 let p = Parser::default();
2356 assert_eq!(
2357 render_callouts("require 'x' <1>", &p),
2358 r#"require 'x' <b class="conum">(1)</b>"#
2359 );
2360 }
2361
2362 #[test]
2363 fn line_comment_prefix_preserved() {
2364 let p = Parser::default();
2365 assert_eq!(
2366 render_callouts("puts 'x' # <1>", &p),
2367 r#"puts 'x' # <b class="conum">(1)</b>"#
2368 );
2369 }
2370
2371 #[test]
2372 fn multiple_on_one_line() {
2373 let p = Parser::default();
2374 assert_eq!(
2375 render_callouts("puts x <5><6>", &p),
2376 r#"puts x <b class="conum">(5)</b><b class="conum">(6)</b>"#
2377 );
2378 }
2379
2380 #[test]
2381 fn not_at_end_of_line() {
2382 let p = Parser::default();
2383 assert_eq!(
2384 render_callouts("puts \"<1> in the middle\"", &p),
2385 "puts \"<1> in the middle\""
2386 );
2387 }
2388
2389 #[test]
2390 fn auto_numbering() {
2391 let p = Parser::default();
2392 assert_eq!(
2393 render_callouts("a <.>\nb <.>\nc <.>", &p),
2394 "a <b class=\"conum\">(1)</b>\nb <b class=\"conum\">(2)</b>\nc <b class=\"conum\">(3)</b>"
2395 );
2396 }
2397
2398 #[test]
2399 fn mixed_numbering_ignores_explicit() {
2400 let p = Parser::default();
2402 assert_eq!(
2403 render_callouts("a <.>\nb <1>\nc <.>", &p),
2404 "a <b class=\"conum\">(1)</b>\nb <b class=\"conum\">(1)</b>\nc <b class=\"conum\">(2)</b>"
2405 );
2406 }
2407
2408 #[test]
2409 fn xml_callout() {
2410 let p = Parser::default();
2411 assert_eq!(
2412 render_callouts("<child/> <!--1-->", &p),
2413 r#"<child/> <!--<b class="conum">(1)</b>-->"#
2414 );
2415 }
2416
2417 #[test]
2418 fn half_xml_comment_is_not_a_callout() {
2419 let p = Parser::default();
2420 assert_eq!(
2421 render_callouts("First line <1-->", &p),
2422 "First line <1-->"
2423 );
2424 }
2425
2426 #[test]
2427 fn escaped_callout() {
2428 let p = Parser::default();
2429 assert_eq!(
2430 render_callouts("require 'x' # \\<1>", &p),
2431 "require 'x' # <1>"
2432 );
2433 }
2434
2435 #[test]
2436 fn icons_font() {
2437 let p = Parser::default().with_intrinsic_attribute(
2438 "icons",
2439 "font",
2440 ModificationContext::Anywhere,
2441 );
2442 assert_eq!(
2443 render_callouts("puts x # <1>", &p),
2444 r#"puts x <i class="conum" data-value="1"></i><b>(1)</b>"#
2445 );
2446 }
2447
2448 #[test]
2449 fn icons_image() {
2450 let p = Parser::default().with_intrinsic_attribute(
2451 "icons",
2452 "",
2453 ModificationContext::Anywhere,
2454 );
2455 assert_eq!(
2456 render_callouts("puts x <1>", &p),
2457 r#"puts x <img src="./images/icons/callouts/1.png" alt="1">"#
2458 );
2459 }
2460
2461 #[test]
2462 fn custom_line_comment_prefix() {
2463 let mut content = Content::from(crate::Span::new("hello() -> % <1>"));
2465 let attrlist = crate::attributes::Attrlist::parse(
2466 crate::Span::new("source,erlang,line-comment=%"),
2467 &Parser::default(),
2468 crate::attributes::AttrlistContext::Block,
2469 )
2470 .item
2471 .item;
2472 let p = Parser::default();
2473 SubstitutionStep::Callouts.apply(&mut content, &p, Some(&attrlist));
2474 assert_eq!(
2475 content.rendered.to_string(),
2476 r#"hello() -> % <b class="conum">(1)</b>"#
2477 );
2478 }
2479
2480 #[test]
2481 fn disabled_line_comment_preserves_leading_chars() {
2482 let mut content = Content::from(crate::Span::new("-- <1>"));
2485 let attrlist = crate::attributes::Attrlist::parse(
2486 crate::Span::new("source,asciidoc,line-comment="),
2487 &Parser::default(),
2488 crate::attributes::AttrlistContext::Block,
2489 )
2490 .item
2491 .item;
2492 let p = Parser::default();
2493 SubstitutionStep::Callouts.apply(&mut content, &p, Some(&attrlist));
2494 assert_eq!(
2495 content.rendered.to_string(),
2496 r#"-- <b class="conum">(1)</b>"#
2497 );
2498 }
2499
2500 #[test]
2501 fn document_line_comment_attribute() {
2502 let p = Parser::default().with_intrinsic_attribute(
2505 "line-comment",
2506 "%",
2507 ModificationContext::Anywhere,
2508 );
2509 assert_eq!(
2510 render_callouts("hello() -> % <1>", &p),
2511 r#"hello() -> % <b class="conum">(1)</b>"#
2512 );
2513 }
2514 }
2515}