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 if !(rendered.contains('+') && rendered.contains('\n')) {
1272 return;
1273 }
1274
1275 let replacer = PostReplacementReplacer(&*parser.renderer);
1276
1277 if let Cow::Owned(new_result) = HARD_LINE_BREAK.replace_all(rendered, replacer) {
1278 content.rendered = new_result.into();
1279 }
1280 }
1281}
1282
1283#[derive(Debug)]
1284struct PostReplacementReplacer<'r>(&'r dyn InlineSubstitutionRenderer);
1285
1286impl Replacer for PostReplacementReplacer<'_> {
1287 fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
1288 dest.push_str(&caps[1]);
1289 self.0.render_line_break(dest);
1290 }
1291}
1292
1293static HARD_LINE_BREAK: LazyLock<Regex> = LazyLock::new(|| {
1294 #[allow(clippy::unwrap_used)]
1295 Regex::new(r#"(?m)^(.*) \+$"#).unwrap()
1296});
1297
1298fn apply_callouts(content: &mut Content<'_>, parser: &Parser, attrlist: Option<&Attrlist<'_>>) {
1314 if !content.rendered.contains("<") {
1317 return;
1318 }
1319
1320 let line_comment: Option<String> = attrlist
1330 .and_then(|a| a.named_attribute("line-comment"))
1331 .map(|a| a.value().to_string())
1332 .or_else(|| {
1333 if parser.has_attribute("line-comment") {
1334 Some(
1335 parser
1336 .attribute_value("line-comment")
1337 .as_maybe_str()
1338 .unwrap_or("")
1339 .to_string(),
1340 )
1341 } else {
1342 None
1343 }
1344 });
1345
1346 let (callout_rx, tail_rx) = build_callout_regexes(line_comment.as_deref());
1347
1348 let replacer = CalloutReplacer {
1349 renderer: &*parser.renderer,
1350 parser,
1351 autonum: 0,
1352 tail: tail_rx,
1353 };
1354
1355 if let Cow::Owned(new_result) =
1356 replace_with_lookahead(&callout_rx, content.rendered.as_ref(), replacer)
1357 {
1358 content.rendered = new_result.into();
1359 }
1360}
1361
1362static DEFAULT_CALLOUT_RX: LazyLock<Regex> = LazyLock::new(|| {
1365 #[allow(clippy::unwrap_used)]
1366 Regex::new(
1367 r"(?P<prefix>(?://|#|--|;;) ?)?(?P<esc>\\)?(?:<!--(?P<xnum>\d+|\.)-->|<(?P<num>\d+|\.)>)",
1368 )
1369 .unwrap()
1370});
1371
1372static DEFAULT_CALLOUT_TAIL_RX: LazyLock<Regex> = LazyLock::new(|| {
1374 #[allow(clippy::unwrap_used)]
1375 Regex::new(r"^(?: ?\\?(?:<!--(?:\d+|\.)-->|<(?:\d+|\.)>))*(?:\n|$)").unwrap()
1376});
1377
1378static CUSTOM_CALLOUT_TAIL_RX: LazyLock<Regex> = LazyLock::new(|| {
1381 #[allow(clippy::unwrap_used)]
1382 Regex::new(r"^(?: ?\\?<(?:\d+|\.)>)*(?:\n|$)").unwrap()
1383});
1384
1385fn build_callout_regexes(line_comment: Option<&str>) -> (Cow<'static, Regex>, &'static Regex) {
1398 match line_comment {
1399 None => (Cow::Borrowed(&DEFAULT_CALLOUT_RX), &DEFAULT_CALLOUT_TAIL_RX),
1401
1402 Some(prefix) => {
1405 let prefix_pattern = if prefix.is_empty() {
1406 String::new()
1407 } else {
1408 format!(r"(?P<prefix>{} ?)?", regex::escape(prefix))
1409 };
1410
1411 #[allow(clippy::unwrap_used)]
1412 let callout = Regex::new(&format!(
1413 r"{prefix_pattern}(?P<esc>\\)?<(?P<num>\d+|\.)>"
1414 ))
1415 .unwrap();
1416
1417 (Cow::Owned(callout), &CUSTOM_CALLOUT_TAIL_RX)
1418 }
1419 }
1420}
1421
1422struct CalloutReplacer<'r> {
1425 renderer: &'r dyn InlineSubstitutionRenderer,
1426 parser: &'r Parser,
1427
1428 autonum: u32,
1431
1432 tail: &'r Regex,
1434}
1435
1436impl LookaheadReplacer for CalloutReplacer<'_> {
1437 fn replace_append(
1438 &mut self,
1439 caps: &Captures<'_>,
1440 dest: &mut String,
1441 after: &str,
1442 ) -> LookaheadResult {
1443 if !self.tail.is_match(after) {
1446 dest.push_str(&caps[0]);
1447 return LookaheadResult::Continue;
1448 }
1449
1450 if caps.name("esc").is_some() {
1453 dest.push_str(&caps[0].replacen('\\', "", 1));
1454 return LookaheadResult::Continue;
1455 }
1456
1457 let (number_raw, is_xml) = if let Some(xnum) = caps.name("xnum") {
1458 (xnum.as_str(), true)
1459 } else {
1460 #[allow(clippy::unwrap_used)]
1462 (caps.name("num").unwrap().as_str(), false)
1463 };
1464
1465 let number = if number_raw == "." {
1466 self.autonum += 1;
1467 self.autonum.to_string()
1468 } else {
1469 number_raw.to_string()
1470 };
1471
1472 if let Ok(n) = number.parse::<u32>() {
1475 self.parser.register_callout(n);
1476 }
1477
1478 let guard = match caps.name("prefix") {
1482 Some(prefix) => CalloutGuard::LineComment(prefix.as_str()),
1483 None if is_xml => CalloutGuard::Xml,
1484 None => CalloutGuard::LineComment(""),
1485 };
1486
1487 self.renderer.render_callout(
1488 &CalloutRenderParams {
1489 number: &number,
1490 guard,
1491 parser: self.parser,
1492 },
1493 dest,
1494 );
1495
1496 LookaheadResult::Continue
1497 }
1498}
1499
1500#[cfg(test)]
1501mod tests {
1502 #![allow(clippy::unwrap_used)]
1503
1504 mod special_characters {
1505 use crate::{
1506 content::{Content, SubstitutionStep},
1507 strings::CowStr,
1508 tests::prelude::*,
1509 };
1510
1511 #[test]
1512 fn empty() {
1513 let mut content = Content::from(crate::Span::default());
1514 let p = Parser::default();
1515 SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1516 assert!(content.is_empty());
1517 assert_eq!(content.rendered, CowStr::Borrowed(""));
1518 }
1519
1520 #[test]
1521 fn basic_non_empty_span() {
1522 let mut content = Content::from(crate::Span::new("blah"));
1523 let p = Parser::default();
1524 SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1525 assert!(!content.is_empty());
1526 assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1527 }
1528
1529 #[test]
1530 fn match_lt_and_gt() {
1531 let mut content = Content::from(crate::Span::new("bl<ah>"));
1532 let p = Parser::default();
1533 SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1534 assert!(!content.is_empty());
1535 assert_eq!(
1536 content.rendered,
1537 CowStr::Boxed("bl<ah>".to_string().into_boxed_str())
1538 );
1539 }
1540
1541 #[test]
1542 fn match_amp() {
1543 let mut content = Content::from(crate::Span::new("bl<a&h>"));
1544 let p = Parser::default();
1545 SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1546 assert!(!content.is_empty());
1547 assert_eq!(
1548 content.rendered,
1549 CowStr::Boxed("bl<a&h>".to_string().into_boxed_str())
1550 );
1551 }
1552 }
1553
1554 mod quotes {
1555 use crate::{
1556 content::{Content, SubstitutionStep},
1557 strings::CowStr,
1558 tests::prelude::*,
1559 };
1560
1561 #[test]
1562 fn empty() {
1563 let mut content = Content::from(crate::Span::default());
1564 let p = Parser::default();
1565 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1566 assert!(content.is_empty());
1567 assert_eq!(content.rendered, CowStr::Borrowed(""));
1568 }
1569
1570 #[test]
1571 fn basic_non_empty_span() {
1572 let mut content = Content::from(crate::Span::new("blah"));
1573 let p = Parser::default();
1574 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1575 assert!(!content.is_empty());
1576 assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1577 }
1578
1579 #[test]
1580 fn ignore_lt_and_gt() {
1581 let mut content = Content::from(crate::Span::new("bl<ah>"));
1582 let p = Parser::default();
1583 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1584 assert!(!content.is_empty());
1585 assert_eq!(
1586 content.rendered,
1587 CowStr::Boxed("bl<ah>".to_string().into_boxed_str())
1588 );
1589 }
1590
1591 #[test]
1592 fn strong_word() {
1593 let mut content = Content::from(crate::Span::new("One *word* is strong."));
1594 let p = Parser::default();
1595 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1596 assert!(!content.is_empty());
1597 assert_eq!(
1598 content.rendered,
1599 CowStr::Boxed(
1600 "One <strong>word</strong> is strong."
1601 .to_string()
1602 .into_boxed_str()
1603 )
1604 );
1605 }
1606
1607 #[test]
1608 fn marked_string_with_id() {
1609 let mut content = Content::from(crate::Span::new(r#"[#id]#a few words#"#));
1610 let p = Parser::default();
1611 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1612 assert!(!content.is_empty());
1613 assert_eq!(
1614 content.rendered,
1615 CowStr::Boxed(r#"<span id="id">a few words</span>"#.to_string().into_boxed_str())
1616 );
1617 }
1618
1619 #[test]
1620 fn unconstrained_marked_string_with_id_is_registered() {
1621 let doc = Parser::default().parse(r#"[#the_id]##marked text##"#);
1625
1626 assert_eq!(
1627 doc.child_blocks()
1628 .next()
1629 .unwrap()
1630 .rendered_content()
1631 .unwrap(),
1632 r#"<span id="the_id">marked text</span>"#
1633 );
1634
1635 assert!(doc.catalog().contains_id("the_id"));
1636 }
1637
1638 #[test]
1639 fn multibyte_leading_char_before_constrained_monospace() {
1640 for leading in ["€", "中", "🎉"] {
1646 let source = format!("{leading}`code``");
1647 let mut content = Content::from(crate::Span::new(&source));
1648 let p = Parser::default();
1649
1650 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1652
1653 assert!(content.rendered.starts_with(leading));
1654 }
1655 }
1656
1657 #[test]
1658 fn escaped_leading_backtick_before_constrained_monospace() {
1659 let mut content = Content::from(crate::Span::new(r"\`code``"));
1664 let p = Parser::default();
1665
1666 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1667
1668 assert_eq!(
1669 content.rendered,
1670 CowStr::Boxed(r"\`code``".to_string().into_boxed_str())
1671 );
1672 }
1673 }
1674
1675 mod attribute_references {
1676 use crate::{
1677 content::{Content, SubstitutionStep},
1678 strings::CowStr,
1679 tests::prelude::*,
1680 };
1681
1682 #[test]
1683 fn empty() {
1684 let mut content = Content::from(crate::Span::default());
1685 let p = Parser::default();
1686 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1687 assert!(content.is_empty());
1688 assert_eq!(content.rendered, CowStr::Borrowed(""));
1689 }
1690
1691 #[test]
1692 fn basic_non_empty_span() {
1693 let mut content = Content::from(crate::Span::new("blah"));
1694 let p = Parser::default();
1695 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1696 assert!(!content.is_empty());
1697 assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1698 }
1699
1700 #[test]
1701 fn ignore_non_match() {
1702 let mut content = Content::from(crate::Span::new("bl{ah}"));
1703 let p = Parser::default();
1704 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1705 assert!(!content.is_empty());
1706 assert_eq!(
1707 content.rendered,
1708 CowStr::Boxed("bl{ah}".to_string().into_boxed_str())
1709 );
1710 }
1711
1712 #[test]
1713 fn escaped_reference_to_unset_attribute_drops_backslash() {
1714 let mut content = Content::from(crate::Span::new("bl\\{ah}"));
1718 let p = Parser::default();
1719 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1720 assert!(!content.is_empty());
1721 assert_eq!(
1722 content.rendered,
1723 CowStr::Boxed("bl{ah}".to_string().into_boxed_str())
1724 );
1725 }
1726
1727 #[test]
1728 fn replace_sp_match() {
1729 let mut content = Content::from(crate::Span::new("bl{sp}ah"));
1730 let p = Parser::default();
1731 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1732 assert!(!content.is_empty());
1733 assert_eq!(
1734 content.rendered,
1735 CowStr::Boxed("bl ah".to_string().into_boxed_str())
1736 );
1737 }
1738
1739 #[test]
1740 fn ignore_escaped_sp_match() {
1741 let mut content = Content::from(crate::Span::new("bl\\{sp}ah"));
1742 let p = Parser::default();
1743 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1744 assert!(!content.is_empty());
1745 assert_eq!(
1746 content.rendered,
1747 CowStr::Boxed("bl{sp}ah".to_string().into_boxed_str())
1748 );
1749 }
1750
1751 #[test]
1752 fn counter_directive_displays_and_advances() {
1753 let mut content = Content::from(crate::Span::new("{counter:n}-{counter:n}"));
1754 let p = Parser::default();
1755 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1756 assert_eq!(
1757 content.rendered,
1758 CowStr::Boxed("1-2".to_string().into_boxed_str())
1759 );
1760 }
1761
1762 #[test]
1763 fn counter2_directive_advances_silently() {
1764 let mut content = Content::from(crate::Span::new("{counter2:n}{counter:n}"));
1765 let p = Parser::default();
1766 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1767 assert_eq!(
1768 content.rendered,
1769 CowStr::Boxed("2".to_string().into_boxed_str())
1770 );
1771 }
1772
1773 #[test]
1774 fn escaped_counter_directive_is_literal_and_does_not_advance() {
1775 let mut content = Content::from(crate::Span::new("\\{counter:n} {counter:n}"));
1776 let p = Parser::default();
1777 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1778 assert_eq!(
1779 content.rendered,
1780 CowStr::Boxed("{counter:n} 1".to_string().into_boxed_str())
1781 );
1782 }
1783
1784 #[test]
1785 fn escaped_reference_with_both_braces_escaped_drops_backslashes() {
1786 let p = Parser::default().with_intrinsic_attribute(
1791 "group-id",
1792 "42",
1793 crate::parser::ModificationContext::Anywhere,
1794 );
1795
1796 let mut content = Content::from(crate::Span::new("\\{group-id\\}"));
1797 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1798 assert_eq!(
1799 content.rendered,
1800 CowStr::Boxed("{group-id}".to_string().into_boxed_str())
1801 );
1802 }
1803
1804 #[test]
1805 fn escaped_reference_with_only_trailing_brace_escaped_drops_backslash() {
1806 let p = Parser::default().with_intrinsic_attribute(
1809 "group-id",
1810 "42",
1811 crate::parser::ModificationContext::Anywhere,
1812 );
1813
1814 let mut content = Content::from(crate::Span::new("{group-id\\}"));
1815 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1816 assert_eq!(
1817 content.rendered,
1818 CowStr::Boxed("{group-id}".to_string().into_boxed_str())
1819 );
1820 }
1821
1822 #[test]
1823 fn escaped_counter_with_trailing_backslash_is_literal_and_does_not_advance() {
1824 let mut content = Content::from(crate::Span::new("{counter:n\\} {counter:n}"));
1828 let p = Parser::default();
1829 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1830 assert_eq!(
1831 content.rendered,
1832 CowStr::Boxed("{counter:n} 1".to_string().into_boxed_str())
1833 );
1834 }
1835
1836 mod attribute_missing {
1837 #![allow(clippy::indexing_slicing)]
1838
1839 use crate::{
1840 Span,
1841 content::{Content, SubstitutionGroup, SubstitutionStep},
1842 parser::ModificationContext,
1843 tests::prelude::*,
1844 warnings::WarningType,
1845 };
1846
1847 fn parser_with_mode(mode: &str) -> Parser {
1848 Parser::default().with_intrinsic_attribute(
1849 "attribute-missing",
1850 mode,
1851 ModificationContext::Anywhere,
1852 )
1853 }
1854
1855 fn render(text: &str, parser: &Parser) -> String {
1856 let mut content = Content::from(crate::Span::new(text));
1857 SubstitutionStep::AttributeReferences.apply(&mut content, parser, None);
1858 content.rendered.to_string()
1859 }
1860
1861 fn content_with_source_lines(text: &'static str) -> Content<'static> {
1867 let root = Span::new(text);
1868 let lines: Vec<&str> = text.split('\n').collect();
1869
1870 let mut spans = Vec::with_capacity(lines.len());
1871 let mut offset = 0;
1872 for line in &lines {
1873 spans.push(root.slice(offset..offset + line.len()));
1874
1875 offset += line.len() + 1;
1877 }
1878
1879 Content::from_filtered_lines(root, &lines, spans)
1880 }
1881
1882 fn assert_spans(warning: &crate::parser::DeferredWarning, text: &str, expected: &str) {
1886 assert_eq!(
1887 &text[warning.offset..warning.offset + warning.len],
1888 expected
1889 );
1890 }
1891
1892 #[test]
1893 fn skip_is_default() {
1894 let p = Parser::default();
1895 assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1896 assert!(p.take_substitution_warnings().is_empty());
1897 }
1898
1899 #[test]
1900 fn skip_explicit() {
1901 let p = parser_with_mode("skip");
1902 assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1903 }
1904
1905 #[test]
1906 fn unknown_value_falls_back_to_skip() {
1907 let p = parser_with_mode("bogus");
1908 assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1909 }
1910
1911 #[test]
1912 fn drop_removes_only_the_reference() {
1913 let p = parser_with_mode("drop");
1914 assert_eq!(render("Hello, {name}!", &p), "Hello, !");
1915 }
1916
1917 #[test]
1918 fn drop_keeps_resolvable_references() {
1919 let p = parser_with_mode("drop");
1920 assert_eq!(render("a {sp}b {missing} c", &p), "a b c");
1921 }
1922
1923 #[test]
1924 fn drop_removes_line_that_only_contained_the_reference() {
1925 let p = parser_with_mode("drop");
1928 assert_eq!(render("Line 1\n{missing}\nLine 2", &p), "Line 1\nLine 2");
1929 }
1930
1931 #[test]
1932 fn drop_keeps_a_line_the_reference_did_not_empty() {
1933 let p = parser_with_mode("drop");
1936 assert_eq!(
1937 render("Line 1\ntext {missing}\nLine 2", &p),
1938 "Line 1\ntext \nLine 2"
1939 );
1940 }
1941
1942 #[test]
1943 fn drop_removes_a_leading_or_trailing_reference_only_line() {
1944 let p = parser_with_mode("drop");
1945 assert_eq!(render("{missing}\nLine 2", &p), "Line 2");
1946 assert_eq!(render("Line 1\n{missing}", &p), "Line 1");
1947 }
1948
1949 #[test]
1950 fn drop_can_empty_the_content() {
1951 let p = parser_with_mode("drop");
1954 assert_eq!(render("{missing}", &p), "");
1955 }
1956
1957 #[test]
1958 fn drop_keeps_a_line_emptied_by_a_resolvable_reference() {
1959 let p = parser_with_mode("drop");
1962 assert_eq!(render("Line 1\n{empty}\nLine 2", &p), "Line 1\n\nLine 2");
1963 }
1964
1965 #[test]
1966 fn drop_line_removes_the_whole_line() {
1967 let p = parser_with_mode("drop-line");
1968 assert_eq!(render("Hello, {name}!\nSecond line.", &p), "Second line.");
1969 }
1970
1971 #[test]
1972 fn drop_line_only_drops_lines_with_a_missing_reference() {
1973 let p = parser_with_mode("drop-line");
1974 assert_eq!(
1975 render("first {sp}line\nsecond {missing} line\nthird line", &p),
1976 "first line\nthird line"
1977 );
1978 }
1979
1980 #[test]
1981 fn drop_line_can_empty_the_content() {
1982 let p = parser_with_mode("drop-line");
1983 assert_eq!(render("{missing}", &p), "");
1984 }
1985
1986 #[test]
1987 fn drop_line_records_a_warning_for_the_dropped_reference() {
1988 let p = parser_with_mode("drop-line");
1992 assert_eq!(render("Hello, {name}!\nSecond line.", &p), "Second line.");
1993
1994 let warnings = p.take_substitution_warnings();
1995 assert_eq!(warnings.len(), 1);
1996 assert_eq!(
1997 warnings[0].warning,
1998 WarningType::SkippingReferenceToMissingAttribute("name".to_string())
1999 );
2000 }
2001
2002 #[test]
2003 fn drop_line_records_one_warning_per_missing_reference() {
2004 let p = parser_with_mode("drop-line");
2007 assert_eq!(render("a {x} b {y} c\ntail", &p), "tail");
2008 assert_eq!(p.take_substitution_warnings().len(), 2);
2009 }
2010
2011 #[test]
2012 fn drop_line_does_not_warn_for_a_line_without_a_missing_reference() {
2013 let p = parser_with_mode("drop-line");
2016 assert_eq!(
2017 render("first {sp}line\nsecond {missing} line\nthird line", &p),
2018 "first line\nthird line"
2019 );
2020
2021 let warnings = p.take_substitution_warnings();
2022 assert_eq!(warnings.len(), 1);
2023 assert_eq!(
2024 warnings[0].warning,
2025 WarningType::SkippingReferenceToMissingAttribute("missing".to_string())
2026 );
2027 }
2028
2029 #[test]
2030 fn drop_line_points_at_the_precise_reference() {
2031 let p = parser_with_mode("drop-line");
2034 let text = "first {alpha} line\nsecond {beta} line";
2035 let mut content = content_with_source_lines(text);
2036 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2037
2038 let warnings = p.take_substitution_warnings();
2039 assert_eq!(warnings.len(), 2);
2040 assert_spans(&warnings[0], text, "{alpha}");
2041 assert_spans(&warnings[1], text, "{beta}");
2042 }
2043
2044 #[test]
2045 fn drop_line_falls_back_to_whole_span_without_source_lines() {
2046 let p = parser_with_mode("drop-line");
2050 let text = "x {foo} y";
2051 let mut content = Content::from(Span::new(text));
2052 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2053
2054 let warnings = p.take_substitution_warnings();
2055 assert_eq!(warnings.len(), 1);
2056 assert_eq!(warnings[0].offset, 0);
2057 assert_eq!(warnings[0].len, text.len());
2058 }
2059
2060 mod free_standing_text {
2065 use super::parser_with_mode;
2066 use crate::content::substitute_attributes_in_text;
2067
2068 #[test]
2069 fn drop_removes_line_that_only_contained_the_reference() {
2070 let p = parser_with_mode("drop");
2071 assert_eq!(
2072 substitute_attributes_in_text("Line 1\n{missing}\nLine 2", &p),
2073 "Line 1\nLine 2"
2074 );
2075 }
2076
2077 #[test]
2078 fn drop_keeps_a_line_the_reference_did_not_empty() {
2079 let p = parser_with_mode("drop");
2080 assert_eq!(
2081 substitute_attributes_in_text("Line 1\ntext {missing}\nLine 2", &p),
2082 "Line 1\ntext \nLine 2"
2083 );
2084 }
2085
2086 #[test]
2087 fn drop_keeps_a_line_emptied_by_a_resolvable_reference() {
2088 let p = parser_with_mode("drop");
2089 assert_eq!(
2090 substitute_attributes_in_text("Line 1\n{empty}\nLine 2", &p),
2091 "Line 1\n\nLine 2"
2092 );
2093 }
2094
2095 #[test]
2096 fn drop_line_removes_the_whole_line() {
2097 let p = parser_with_mode("drop-line");
2098 assert_eq!(
2099 substitute_attributes_in_text("Line 1\n{missing} tail\nLine 2", &p),
2100 "Line 1\nLine 2"
2101 );
2102 }
2103
2104 #[test]
2105 fn drop_line_records_a_warning() {
2106 use crate::warnings::WarningType;
2110
2111 let p = parser_with_mode("drop-line");
2112 assert_eq!(
2113 substitute_attributes_in_text("Line 1\n{missing} tail\nLine 2", &p),
2114 "Line 1\nLine 2"
2115 );
2116
2117 let warnings = p.take_substitution_warnings();
2118 assert_eq!(warnings.len(), 1);
2119 assert_eq!(
2120 warnings[0].warning,
2121 WarningType::SkippingReferenceToMissingAttribute("missing".to_string())
2122 );
2123 }
2124
2125 #[test]
2126 fn drop_removes_a_crlf_reference_only_line() {
2127 let p = parser_with_mode("drop");
2131 assert_eq!(
2132 substitute_attributes_in_text("Line 1\r\n{missing}\r\nLine 2", &p),
2133 "Line 1\r\nLine 2"
2134 );
2135 }
2136
2137 #[test]
2138 fn drop_keeps_a_crlf_line_the_reference_did_not_empty() {
2139 let p = parser_with_mode("drop");
2140 assert_eq!(
2141 substitute_attributes_in_text("Line 1\r\ntext {missing}\r\nLine 2", &p),
2142 "Line 1\r\ntext \r\nLine 2"
2143 );
2144 }
2145 }
2146
2147 #[test]
2148 fn warn_leaves_the_reference_and_records_a_warning() {
2149 let p = parser_with_mode("warn");
2150 assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
2151
2152 let warnings = p.take_substitution_warnings();
2153 assert_eq!(warnings.len(), 1);
2154 assert_eq!(
2155 warnings[0].warning,
2156 WarningType::SkippingReferenceToMissingAttribute("name".to_string())
2157 );
2158 }
2159
2160 #[test]
2161 fn warn_records_one_warning_per_missing_reference() {
2162 let p = parser_with_mode("warn");
2163 assert_eq!(render("a {x} b {y} c", &p), "a {x} b {y} c");
2164 assert_eq!(p.take_substitution_warnings().len(), 2);
2165 }
2166
2167 #[test]
2168 fn escaped_missing_reference_drops_the_backslash_and_never_drops_the_line() {
2169 let p = parser_with_mode("drop-line");
2174 assert_eq!(
2175 render("In the path /items/\\{id}, x.", &p),
2176 "In the path /items/{id}, x."
2177 );
2178 assert!(p.take_substitution_warnings().is_empty());
2179 }
2180
2181 #[test]
2188 fn warn_points_at_the_precise_reference() {
2189 let p = parser_with_mode("warn");
2190 let text = "Hello, {name}!";
2191 let mut content = content_with_source_lines(text);
2192 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2193
2194 let warnings = p.take_substitution_warnings();
2195 assert_eq!(warnings.len(), 1);
2196 assert_spans(&warnings[0], text, "{name}");
2197 }
2198
2199 #[test]
2200 fn warn_locates_multiple_references_on_one_line() {
2201 let p = parser_with_mode("warn");
2202 let text = "a {x} b {y} c";
2203 let mut content = content_with_source_lines(text);
2204 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2205
2206 let warnings = p.take_substitution_warnings();
2207 assert_eq!(warnings.len(), 2);
2208 assert_spans(&warnings[0], text, "{x}");
2209 assert_spans(&warnings[1], text, "{y}");
2210
2211 assert_ne!(warnings[0].offset, warnings[1].offset);
2213 }
2214
2215 #[test]
2216 fn warn_locates_references_across_multiple_lines() {
2217 let p = parser_with_mode("warn");
2221 let text = "first {alpha} line\nsecond {beta} line\nthird {gamma} line";
2222 let mut content = content_with_source_lines(text);
2223 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2224
2225 let warnings = p.take_substitution_warnings();
2226 assert_eq!(warnings.len(), 3);
2227 assert_spans(&warnings[0], text, "{alpha}");
2228 assert_spans(&warnings[1], text, "{beta}");
2229 assert_spans(&warnings[2], text, "{gamma}");
2230 }
2231
2232 #[test]
2233 fn warn_distinguishes_repeated_reference_occurrences() {
2234 let p = parser_with_mode("warn");
2235 let text = "{dup} and again {dup}";
2236 let mut content = content_with_source_lines(text);
2237 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2238
2239 let warnings = p.take_substitution_warnings();
2240 assert_eq!(warnings.len(), 2);
2241 assert_spans(&warnings[0], text, "{dup}");
2242 assert_spans(&warnings[1], text, "{dup}");
2243
2244 assert_eq!(warnings[0].offset, 0);
2246 assert_eq!(warnings[1].offset, text.rfind("{dup}").unwrap());
2247 }
2248
2249 #[test]
2250 fn warn_span_survives_earlier_special_character_expansion() {
2251 let p = parser_with_mode("warn");
2256 let text = "a < b {foo} c";
2257 let mut content = content_with_source_lines(text);
2258 SubstitutionGroup::Normal.apply(&mut content, &p, None);
2259
2260 assert!(content.rendered().contains("<"));
2262
2263 let warnings = p.take_substitution_warnings();
2264 assert_eq!(warnings.len(), 1);
2265 assert_spans(&warnings[0], text, "{foo}");
2266 assert_eq!(warnings[0].offset, text.find("{foo}").unwrap());
2267 }
2268
2269 #[test]
2270 fn warn_span_survives_earlier_quote_expansion() {
2271 let p = parser_with_mode("warn");
2274 let text = "*bold* {foo}";
2275 let mut content = content_with_source_lines(text);
2276 SubstitutionGroup::Normal.apply(&mut content, &p, None);
2277
2278 assert!(content.rendered().contains("<strong>"));
2279
2280 let warnings = p.take_substitution_warnings();
2281 assert_eq!(warnings.len(), 1);
2282 assert_spans(&warnings[0], text, "{foo}");
2283 assert_eq!(warnings[0].offset, text.find("{foo}").unwrap());
2284 }
2285
2286 #[test]
2287 fn warn_falls_back_to_whole_span_without_source_lines() {
2288 let p = parser_with_mode("warn");
2292 let text = "x {foo} y";
2293 let mut content = Content::from(Span::new(text));
2294 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2295
2296 let warnings = p.take_substitution_warnings();
2297 assert_eq!(warnings.len(), 1);
2298 assert_eq!(warnings[0].offset, 0);
2299 assert_eq!(warnings[0].len, text.len());
2300 }
2301 }
2302 }
2303
2304 mod callouts {
2305 use crate::{
2306 content::{Content, SubstitutionStep},
2307 parser::ModificationContext,
2308 strings::CowStr,
2309 tests::prelude::*,
2310 };
2311
2312 fn render_callouts(text: &str, parser: &Parser) -> String {
2316 let mut content = Content::from(crate::Span::new(text));
2317
2318 SubstitutionStep::Callouts.apply(&mut content, parser, None);
2321 content.rendered.to_string()
2322 }
2323
2324 #[test]
2325 fn empty() {
2326 let mut content = Content::from(crate::Span::default());
2327 let p = Parser::default();
2328 SubstitutionStep::Callouts.apply(&mut content, &p, None);
2329 assert!(content.is_empty());
2330 assert_eq!(content.rendered, CowStr::Borrowed(""));
2331 }
2332
2333 #[test]
2334 fn no_callouts() {
2335 let p = Parser::default();
2336 assert_eq!(render_callouts("just some text", &p), "just some text");
2337 }
2338
2339 #[test]
2340 fn lt_without_callout_is_untouched() {
2341 let p = Parser::default();
2342 assert_eq!(render_callouts("a <b> c", &p), "a <b> c");
2343 }
2344
2345 #[test]
2346 fn basic_explicit() {
2347 let p = Parser::default();
2348 assert_eq!(
2349 render_callouts("require 'x' <1>", &p),
2350 r#"require 'x' <b class="conum">(1)</b>"#
2351 );
2352 }
2353
2354 #[test]
2355 fn line_comment_prefix_preserved() {
2356 let p = Parser::default();
2357 assert_eq!(
2358 render_callouts("puts 'x' # <1>", &p),
2359 r#"puts 'x' # <b class="conum">(1)</b>"#
2360 );
2361 }
2362
2363 #[test]
2364 fn multiple_on_one_line() {
2365 let p = Parser::default();
2366 assert_eq!(
2367 render_callouts("puts x <5><6>", &p),
2368 r#"puts x <b class="conum">(5)</b><b class="conum">(6)</b>"#
2369 );
2370 }
2371
2372 #[test]
2373 fn not_at_end_of_line() {
2374 let p = Parser::default();
2375 assert_eq!(
2376 render_callouts("puts \"<1> in the middle\"", &p),
2377 "puts \"<1> in the middle\""
2378 );
2379 }
2380
2381 #[test]
2382 fn auto_numbering() {
2383 let p = Parser::default();
2384 assert_eq!(
2385 render_callouts("a <.>\nb <.>\nc <.>", &p),
2386 "a <b class=\"conum\">(1)</b>\nb <b class=\"conum\">(2)</b>\nc <b class=\"conum\">(3)</b>"
2387 );
2388 }
2389
2390 #[test]
2391 fn mixed_numbering_ignores_explicit() {
2392 let p = Parser::default();
2394 assert_eq!(
2395 render_callouts("a <.>\nb <1>\nc <.>", &p),
2396 "a <b class=\"conum\">(1)</b>\nb <b class=\"conum\">(1)</b>\nc <b class=\"conum\">(2)</b>"
2397 );
2398 }
2399
2400 #[test]
2401 fn xml_callout() {
2402 let p = Parser::default();
2403 assert_eq!(
2404 render_callouts("<child/> <!--1-->", &p),
2405 r#"<child/> <!--<b class="conum">(1)</b>-->"#
2406 );
2407 }
2408
2409 #[test]
2410 fn half_xml_comment_is_not_a_callout() {
2411 let p = Parser::default();
2412 assert_eq!(
2413 render_callouts("First line <1-->", &p),
2414 "First line <1-->"
2415 );
2416 }
2417
2418 #[test]
2419 fn escaped_callout() {
2420 let p = Parser::default();
2421 assert_eq!(
2422 render_callouts("require 'x' # \\<1>", &p),
2423 "require 'x' # <1>"
2424 );
2425 }
2426
2427 #[test]
2428 fn icons_font() {
2429 let p = Parser::default().with_intrinsic_attribute(
2430 "icons",
2431 "font",
2432 ModificationContext::Anywhere,
2433 );
2434 assert_eq!(
2435 render_callouts("puts x # <1>", &p),
2436 r#"puts x <i class="conum" data-value="1"></i><b>(1)</b>"#
2437 );
2438 }
2439
2440 #[test]
2441 fn icons_image() {
2442 let p = Parser::default().with_intrinsic_attribute(
2443 "icons",
2444 "",
2445 ModificationContext::Anywhere,
2446 );
2447 assert_eq!(
2448 render_callouts("puts x <1>", &p),
2449 r#"puts x <img src="./images/icons/callouts/1.png" alt="1">"#
2450 );
2451 }
2452
2453 #[test]
2454 fn custom_line_comment_prefix() {
2455 let mut content = Content::from(crate::Span::new("hello() -> % <1>"));
2457 let attrlist = crate::attributes::Attrlist::parse(
2458 crate::Span::new("source,erlang,line-comment=%"),
2459 &Parser::default(),
2460 crate::attributes::AttrlistContext::Block,
2461 )
2462 .item
2463 .item;
2464 let p = Parser::default();
2465 SubstitutionStep::Callouts.apply(&mut content, &p, Some(&attrlist));
2466 assert_eq!(
2467 content.rendered.to_string(),
2468 r#"hello() -> % <b class="conum">(1)</b>"#
2469 );
2470 }
2471
2472 #[test]
2473 fn disabled_line_comment_preserves_leading_chars() {
2474 let mut content = Content::from(crate::Span::new("-- <1>"));
2477 let attrlist = crate::attributes::Attrlist::parse(
2478 crate::Span::new("source,asciidoc,line-comment="),
2479 &Parser::default(),
2480 crate::attributes::AttrlistContext::Block,
2481 )
2482 .item
2483 .item;
2484 let p = Parser::default();
2485 SubstitutionStep::Callouts.apply(&mut content, &p, Some(&attrlist));
2486 assert_eq!(
2487 content.rendered.to_string(),
2488 r#"-- <b class="conum">(1)</b>"#
2489 );
2490 }
2491
2492 #[test]
2493 fn document_line_comment_attribute() {
2494 let p = Parser::default().with_intrinsic_attribute(
2497 "line-comment",
2498 "%",
2499 ModificationContext::Anywhere,
2500 );
2501 assert_eq!(
2502 render_callouts("hello() -> % <1>", &p),
2503 r#"hello() -> % <b class="conum">(1)</b>"#
2504 );
2505 }
2506 }
2507}