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>,
619
620 source_line: Option<Span<'p>>,
625
626 source_matches: Vec<Range<usize>>,
630
631 match_index: usize,
636
637 missing_on_line: bool,
643}
644
645impl<'p> AttributeReplacer<'p> {
646 fn new(
653 parser: &'p Parser,
654 mode: AttributeMissing,
655 fallback_source: Span<'p>,
656 source_line: Option<Span<'p>>,
657 ) -> Self {
658 let source_matches = match (mode, source_line) {
661 (AttributeMissing::Warn, Some(line)) => ATTRIBUTE_REFERENCE
662 .find_iter(line.data())
663 .map(|m| m.range())
664 .collect(),
665 _ => Vec::new(),
666 };
667
668 Self {
669 parser,
670 mode,
671 fallback_source,
672 source_line,
673 source_matches,
674 match_index: 0,
675 missing_on_line: false,
676 }
677 }
678
679 fn warning_source(&self, index: usize, matched: &str) -> Span<'p> {
688 if let Some(line) = self.source_line
689 && let Some(range) = self.source_matches.get(index)
690 && line.data().get(range.clone()) == Some(matched)
691 {
692 return line.slice(range.clone());
693 }
694
695 self.fallback_source
696 }
697}
698
699impl Replacer for AttributeReplacer<'_> {
700 fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
701 let match_index = self.match_index;
704 self.match_index += 1;
705
706 if caps.get(1).is_some() || caps.get(5).is_some() {
717 dest.push('{');
718
719 if let Some(directive) = caps.get(2) {
722 dest.push_str(directive.as_str());
723 dest.push(':');
724 dest.push_str(&caps[3]);
725 } else {
726 dest.push_str(&caps[4]);
727 }
728
729 dest.push('}');
730 return;
731 }
732
733 if let Some(directive) = caps.get(2) {
736 let mut parts = caps[3].splitn(2, ':');
739 let name = parts.next().unwrap_or_default();
740 let seed = parts.next();
741
742 let value = self.parser.counter(name, seed);
743
744 if directive.as_str() == "counter" {
746 dest.push_str(&value);
747 }
748 return;
749 }
750
751 let attr_name = &caps[4];
753
754 let lookup_name = attribute_lookup_name(attr_name);
761
762 if !self.parser.has_attribute(&lookup_name) {
763 match self.mode {
764 AttributeMissing::Skip => dest.push_str(&caps[0]),
765 AttributeMissing::Drop => {
766 self.missing_on_line = true;
771 }
772 AttributeMissing::DropLine => {
773 self.missing_on_line = true;
776 }
777 AttributeMissing::Warn => {
778 dest.push_str(&caps[0]);
779 self.parser.record_substitution_warning(
780 self.warning_source(match_index, &caps[0]),
781 WarningType::SkippingReferenceToMissingAttribute(attr_name.to_string()),
782 );
783 }
784 }
785 return;
786 }
787
788 if let InterpretedValue::Value(value) = self.parser.attribute_value(&lookup_name) {
789 dest.push_str(value.as_ref());
790 }
791
792 }
795}
796
797fn drop_emptied_line(replaced: &str) -> bool {
807 replaced.strip_suffix('\r').unwrap_or(replaced).is_empty()
808}
809
810fn apply_attributes(content: &mut Content<'_>, parser: &Parser) {
811 if !content.rendered.contains('{') {
812 return;
813 }
814
815 let mode = AttributeMissing::from_parser(parser);
816 let source = content.original();
817
818 let source_lines = if mode == AttributeMissing::Warn {
825 content
826 .source_lines()
827 .filter(|lines| lines.len() == content.rendered.split('\n').count())
828 } else {
829 None
830 };
831
832 let mut out = String::with_capacity(content.rendered.len());
838 let mut changed = false;
839 let mut wrote_line = false;
840
841 for (index, line) in content.rendered.split('\n').enumerate() {
842 if !line.contains('{') {
843 if wrote_line {
844 out.push('\n');
845 }
846 out.push_str(line);
847 wrote_line = true;
848 continue;
849 }
850
851 let source_line = source_lines.and_then(|lines| lines.get(index).copied());
855 let mut replacer = AttributeReplacer::new(parser, mode, source, source_line);
856
857 let replaced = ATTRIBUTE_REFERENCE.replace_all(line, replacer.by_ref());
858
859 if replacer.missing_on_line
860 && (mode == AttributeMissing::DropLine
861 || (mode == AttributeMissing::Drop && drop_emptied_line(&replaced)))
862 {
863 changed = true;
868 continue;
869 }
870
871 if let Cow::Owned(_) = replaced {
872 changed = true;
873 }
874
875 if wrote_line {
876 out.push('\n');
877 }
878 out.push_str(&replaced);
879 wrote_line = true;
880 }
881
882 if changed {
885 content.rendered = out.into();
886 }
887}
888
889pub(crate) fn substitute_attributes_in_macro_target<'src>(
901 target: Span<'src>,
902 parser: &Parser,
903) -> Option<CowStr<'src>> {
904 let text = target.data();
905
906 if !text.contains('{') {
909 return Some(text.into());
910 }
911
912 let mode = AttributeMissing::from_parser(parser);
913
914 let mut replacer = AttributeReplacer::new(parser, mode, target, Some(target));
917
918 let replaced = ATTRIBUTE_REFERENCE.replace_all(text, replacer.by_ref());
919
920 if replacer.missing_on_line && mode == AttributeMissing::DropLine {
921 return None;
922 }
923
924 Some(replaced.into())
925}
926
927pub(crate) fn substitute_attributes_in_text(text: &str, parser: &Parser) -> String {
944 if !text.contains('{') {
945 return text.to_string();
946 }
947
948 let mode = AttributeMissing::from_parser(parser);
949 let source = Span::new(text);
950
951 let mut out = String::with_capacity(text.len());
952 let mut wrote_line = false;
953
954 for line in text.split('\n') {
955 if !line.contains('{') {
956 if wrote_line {
957 out.push('\n');
958 }
959 out.push_str(line);
960 wrote_line = true;
961 continue;
962 }
963
964 let mut replacer = AttributeReplacer::new(parser, mode, source, None);
968
969 let replaced = ATTRIBUTE_REFERENCE.replace_all(line, replacer.by_ref());
970
971 if replacer.missing_on_line
972 && (mode == AttributeMissing::DropLine
973 || (mode == AttributeMissing::Drop && drop_emptied_line(&replaced)))
974 {
975 continue;
980 }
981
982 if wrote_line {
983 out.push('\n');
984 }
985 out.push_str(&replaced);
986 wrote_line = true;
987 }
988
989 out
990}
991
992pub(crate) fn substitute_attributes_in_reftext<'src>(
1003 reftext: Span<'src>,
1004 parser: &Parser,
1005) -> CowStr<'src> {
1006 if !reftext.data().contains('{') {
1007 return reftext.data().into();
1008 }
1009
1010 let mut content = Content::from(reftext);
1011 SubstitutionStep::AttributeReferences.apply(&mut content, parser, None);
1012 CowStr::from(content.rendered.to_string())
1013}
1014
1015fn apply_character_replacements(
1016 content: &mut Content<'_>,
1017 renderer: &dyn InlineSubstitutionRenderer,
1018) {
1019 if !REPLACEABLE_TEXT_SNIFF.is_match(content.rendered.as_ref()) {
1020 return;
1021 }
1022
1023 let mut owned: Option<String> = None;
1029
1030 for repl in &*REPLACEMENTS {
1031 let replacer = CharacterReplacer {
1032 type_: repl.type_.clone(),
1033 renderer,
1034 };
1035
1036 let replaced = {
1037 let haystack = owned
1038 .as_deref()
1039 .unwrap_or_else(|| content.rendered.as_ref());
1040
1041 match repl.pattern.replace_all(haystack, replacer) {
1042 Cow::Owned(new_result) => Some(new_result),
1043
1044 Cow::Borrowed(_) => None,
1047 }
1048 };
1049
1050 if let Some(new_result) = replaced {
1051 owned = Some(new_result);
1052 }
1053 }
1054
1055 if let Some(rendered) = owned {
1056 content.rendered = rendered.into();
1057 }
1058}
1059
1060struct CharacterReplacement {
1061 type_: CharacterReplacementType,
1062 pattern: Regex,
1063}
1064
1065static REPLACEABLE_TEXT_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
1066 #[allow(clippy::unwrap_used)]
1067 Regex::new(r#"[&']|--|\.\.\.|\([CRT]M?\)"#).unwrap()
1068});
1069
1070static REPLACEMENTS: LazyLock<Vec<CharacterReplacement>> = LazyLock::new(|| {
1076 vec![
1077 CharacterReplacement {
1078 type_: CharacterReplacementType::Copyright,
1080 #[allow(clippy::unwrap_used)]
1081 pattern: Regex::new(r#"\\?\(C\)"#).unwrap(),
1082 },
1083 CharacterReplacement {
1084 type_: CharacterReplacementType::Registered,
1086 #[allow(clippy::unwrap_used)]
1087 pattern: Regex::new(r#"\\?\(R\)"#).unwrap(),
1088 },
1089 CharacterReplacement {
1090 type_: CharacterReplacementType::Trademark,
1092 #[allow(clippy::unwrap_used)]
1093 pattern: Regex::new(r#"\\?\(TM\)"#).unwrap(),
1094 },
1095 CharacterReplacement {
1096 type_: CharacterReplacementType::EmDashSurroundedBySpaces,
1098 #[allow(clippy::unwrap_used)]
1099 pattern: Regex::new(r#"(?: |\n|^|\\)--(?: |\n|$)"#).unwrap(),
1100 },
1101 CharacterReplacement {
1102 type_: CharacterReplacementType::EmDashWithoutSpace,
1104 #[allow(clippy::unwrap_used)]
1105 pattern: Regex::new(r#"(\w)\\?--\b{start-half}"#).unwrap(),
1106 },
1107 CharacterReplacement {
1108 type_: CharacterReplacementType::Ellipsis,
1110 #[allow(clippy::unwrap_used)]
1111 pattern: Regex::new(r#"\\?\.\.\."#).unwrap(),
1112 },
1113 CharacterReplacement {
1114 type_: CharacterReplacementType::TypographicApostrophe,
1116 #[allow(clippy::unwrap_used)]
1117 pattern: Regex::new(r#"\\?`'"#).unwrap(),
1118 },
1119 CharacterReplacement {
1120 type_: CharacterReplacementType::TypographicApostrophe,
1122 #[allow(clippy::unwrap_used)]
1123 pattern: Regex::new(r#"([[:alnum:]])\\?'([[:alpha:]])"#).unwrap(),
1124 },
1125 CharacterReplacement {
1126 type_: CharacterReplacementType::SingleRightArrow,
1128 #[allow(clippy::unwrap_used)]
1129 pattern: Regex::new(r#"\\?->"#).unwrap(),
1130 },
1131 CharacterReplacement {
1132 type_: CharacterReplacementType::DoubleRightArrow,
1134 #[allow(clippy::unwrap_used)]
1135 pattern: Regex::new(r#"\\?=>"#).unwrap(),
1136 },
1137 CharacterReplacement {
1138 type_: CharacterReplacementType::SingleLeftArrow,
1140 #[allow(clippy::unwrap_used)]
1141 pattern: Regex::new(r#"\\?<-"#).unwrap(),
1142 },
1143 CharacterReplacement {
1144 type_: CharacterReplacementType::DoubleLeftArrow,
1146 #[allow(clippy::unwrap_used)]
1147 pattern: Regex::new(r#"\\?<="#).unwrap(),
1148 },
1149 CharacterReplacement {
1150 type_: CharacterReplacementType::CharacterReference("".to_owned()),
1152 #[allow(clippy::unwrap_used)]
1153 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(),
1154 },
1155 ]
1156});
1157
1158#[derive(Debug)]
1159struct CharacterReplacer<'r> {
1160 type_: CharacterReplacementType,
1161 renderer: &'r dyn InlineSubstitutionRenderer,
1162}
1163
1164impl Replacer for CharacterReplacer<'_> {
1165 fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
1166 if caps[0].contains('\\') {
1167 let unescaped = &caps[0].replace("\\", "");
1169 dest.push_str(unescaped);
1170 return;
1171 }
1172
1173 match self.type_ {
1174 CharacterReplacementType::Copyright
1175 | CharacterReplacementType::Registered
1176 | CharacterReplacementType::Trademark
1177 | CharacterReplacementType::EmDashSurroundedBySpaces
1178 | CharacterReplacementType::Ellipsis
1179 | CharacterReplacementType::SingleLeftArrow
1180 | CharacterReplacementType::DoubleLeftArrow
1181 | CharacterReplacementType::SingleRightArrow
1182 | CharacterReplacementType::DoubleRightArrow => {
1183 self.renderer
1184 .render_character_replacement(self.type_.clone(), dest);
1185 }
1186
1187 CharacterReplacementType::EmDashWithoutSpace => {
1188 dest.push_str(&caps[1]);
1189 self.renderer.render_character_replacement(
1190 CharacterReplacementType::EmDashWithoutSpace,
1191 dest,
1192 );
1193 }
1194
1195 CharacterReplacementType::TypographicApostrophe => {
1196 if let Some(before) = caps.get(1) {
1197 dest.push_str(before.as_str());
1198 }
1199
1200 self.renderer.render_character_replacement(
1201 CharacterReplacementType::TypographicApostrophe,
1202 dest,
1203 );
1204
1205 if let Some(after) = caps.get(2) {
1206 dest.push_str(after.as_str());
1207 }
1208 }
1209
1210 CharacterReplacementType::CharacterReference(_) => {
1211 self.renderer.render_character_replacement(
1212 CharacterReplacementType::CharacterReference(caps[1].to_string()),
1213 dest,
1214 );
1215 }
1216 }
1217 }
1218}
1219
1220fn apply_post_replacements(
1221 content: &mut Content<'_>,
1222 parser: &Parser,
1223 attrlist: Option<&Attrlist<'_>>,
1224) {
1225 if parser.is_attribute_set("hardbreaks-option")
1226 || attrlist.is_some_and(|attrlist| attrlist.has_option("hardbreaks"))
1227 {
1228 let text = content.rendered.as_ref();
1229 if !text.contains('\n') {
1230 return;
1231 }
1232
1233 let mut lines: Vec<&str> = content.rendered.as_ref().lines().collect();
1234 let last = lines.pop().unwrap_or_default();
1235
1236 let mut lines: Vec<String> = lines
1237 .iter()
1238 .map(|line| {
1239 let line = if line.ends_with(" +") {
1240 &line[0..line.len() - 2]
1241 } else {
1242 *line
1243 };
1244
1245 let mut line = line.to_owned();
1246 parser.renderer.render_line_break(&mut line);
1247 line
1248 })
1249 .collect();
1250
1251 lines.push(last.to_owned());
1252
1253 let new_result = lines.join("\n");
1254 content.rendered = new_result.into();
1255 } else {
1256 let rendered = content.rendered.as_ref();
1257 if !(rendered.contains('+') && rendered.contains('\n')) {
1258 return;
1259 }
1260
1261 let replacer = PostReplacementReplacer(&*parser.renderer);
1262
1263 if let Cow::Owned(new_result) = HARD_LINE_BREAK.replace_all(rendered, replacer) {
1264 content.rendered = new_result.into();
1265 }
1266 }
1267}
1268
1269#[derive(Debug)]
1270struct PostReplacementReplacer<'r>(&'r dyn InlineSubstitutionRenderer);
1271
1272impl Replacer for PostReplacementReplacer<'_> {
1273 fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
1274 dest.push_str(&caps[1]);
1275 self.0.render_line_break(dest);
1276 }
1277}
1278
1279static HARD_LINE_BREAK: LazyLock<Regex> = LazyLock::new(|| {
1280 #[allow(clippy::unwrap_used)]
1281 Regex::new(r#"(?m)^(.*) \+$"#).unwrap()
1282});
1283
1284fn apply_callouts(content: &mut Content<'_>, parser: &Parser, attrlist: Option<&Attrlist<'_>>) {
1300 if !content.rendered.contains("<") {
1303 return;
1304 }
1305
1306 let line_comment: Option<String> = attrlist
1316 .and_then(|a| a.named_attribute("line-comment"))
1317 .map(|a| a.value().to_string())
1318 .or_else(|| {
1319 if parser.has_attribute("line-comment") {
1320 Some(
1321 parser
1322 .attribute_value("line-comment")
1323 .as_maybe_str()
1324 .unwrap_or("")
1325 .to_string(),
1326 )
1327 } else {
1328 None
1329 }
1330 });
1331
1332 let (callout_rx, tail_rx) = build_callout_regexes(line_comment.as_deref());
1333
1334 let replacer = CalloutReplacer {
1335 renderer: &*parser.renderer,
1336 parser,
1337 autonum: 0,
1338 tail: tail_rx,
1339 };
1340
1341 if let Cow::Owned(new_result) =
1342 replace_with_lookahead(&callout_rx, content.rendered.as_ref(), replacer)
1343 {
1344 content.rendered = new_result.into();
1345 }
1346}
1347
1348static DEFAULT_CALLOUT_RX: LazyLock<Regex> = LazyLock::new(|| {
1351 #[allow(clippy::unwrap_used)]
1352 Regex::new(
1353 r"(?P<prefix>(?://|#|--|;;) ?)?(?P<esc>\\)?(?:<!--(?P<xnum>\d+|\.)-->|<(?P<num>\d+|\.)>)",
1354 )
1355 .unwrap()
1356});
1357
1358static DEFAULT_CALLOUT_TAIL_RX: LazyLock<Regex> = LazyLock::new(|| {
1360 #[allow(clippy::unwrap_used)]
1361 Regex::new(r"^(?: ?\\?(?:<!--(?:\d+|\.)-->|<(?:\d+|\.)>))*(?:\n|$)").unwrap()
1362});
1363
1364static CUSTOM_CALLOUT_TAIL_RX: LazyLock<Regex> = LazyLock::new(|| {
1367 #[allow(clippy::unwrap_used)]
1368 Regex::new(r"^(?: ?\\?<(?:\d+|\.)>)*(?:\n|$)").unwrap()
1369});
1370
1371fn build_callout_regexes(line_comment: Option<&str>) -> (Cow<'static, Regex>, &'static Regex) {
1384 match line_comment {
1385 None => (Cow::Borrowed(&DEFAULT_CALLOUT_RX), &DEFAULT_CALLOUT_TAIL_RX),
1387
1388 Some(prefix) => {
1391 let prefix_pattern = if prefix.is_empty() {
1392 String::new()
1393 } else {
1394 format!(r"(?P<prefix>{} ?)?", regex::escape(prefix))
1395 };
1396
1397 #[allow(clippy::unwrap_used)]
1398 let callout = Regex::new(&format!(
1399 r"{prefix_pattern}(?P<esc>\\)?<(?P<num>\d+|\.)>"
1400 ))
1401 .unwrap();
1402
1403 (Cow::Owned(callout), &CUSTOM_CALLOUT_TAIL_RX)
1404 }
1405 }
1406}
1407
1408struct CalloutReplacer<'r> {
1411 renderer: &'r dyn InlineSubstitutionRenderer,
1412 parser: &'r Parser,
1413
1414 autonum: u32,
1417
1418 tail: &'r Regex,
1420}
1421
1422impl LookaheadReplacer for CalloutReplacer<'_> {
1423 fn replace_append(
1424 &mut self,
1425 caps: &Captures<'_>,
1426 dest: &mut String,
1427 after: &str,
1428 ) -> LookaheadResult {
1429 if !self.tail.is_match(after) {
1432 dest.push_str(&caps[0]);
1433 return LookaheadResult::Continue;
1434 }
1435
1436 if caps.name("esc").is_some() {
1439 dest.push_str(&caps[0].replacen('\\', "", 1));
1440 return LookaheadResult::Continue;
1441 }
1442
1443 let (number_raw, is_xml) = if let Some(xnum) = caps.name("xnum") {
1444 (xnum.as_str(), true)
1445 } else {
1446 #[allow(clippy::unwrap_used)]
1448 (caps.name("num").unwrap().as_str(), false)
1449 };
1450
1451 let number = if number_raw == "." {
1452 self.autonum += 1;
1453 self.autonum.to_string()
1454 } else {
1455 number_raw.to_string()
1456 };
1457
1458 if let Ok(n) = number.parse::<u32>() {
1461 self.parser.register_callout(n);
1462 }
1463
1464 let guard = match caps.name("prefix") {
1468 Some(prefix) => CalloutGuard::LineComment(prefix.as_str()),
1469 None if is_xml => CalloutGuard::Xml,
1470 None => CalloutGuard::LineComment(""),
1471 };
1472
1473 self.renderer.render_callout(
1474 &CalloutRenderParams {
1475 number: &number,
1476 guard,
1477 parser: self.parser,
1478 },
1479 dest,
1480 );
1481
1482 LookaheadResult::Continue
1483 }
1484}
1485
1486#[cfg(test)]
1487mod tests {
1488 #![allow(clippy::unwrap_used)]
1489
1490 mod special_characters {
1491 use crate::{
1492 content::{Content, SubstitutionStep},
1493 strings::CowStr,
1494 tests::prelude::*,
1495 };
1496
1497 #[test]
1498 fn empty() {
1499 let mut content = Content::from(crate::Span::default());
1500 let p = Parser::default();
1501 SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1502 assert!(content.is_empty());
1503 assert_eq!(content.rendered, CowStr::Borrowed(""));
1504 }
1505
1506 #[test]
1507 fn basic_non_empty_span() {
1508 let mut content = Content::from(crate::Span::new("blah"));
1509 let p = Parser::default();
1510 SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1511 assert!(!content.is_empty());
1512 assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1513 }
1514
1515 #[test]
1516 fn match_lt_and_gt() {
1517 let mut content = Content::from(crate::Span::new("bl<ah>"));
1518 let p = Parser::default();
1519 SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1520 assert!(!content.is_empty());
1521 assert_eq!(
1522 content.rendered,
1523 CowStr::Boxed("bl<ah>".to_string().into_boxed_str())
1524 );
1525 }
1526
1527 #[test]
1528 fn match_amp() {
1529 let mut content = Content::from(crate::Span::new("bl<a&h>"));
1530 let p = Parser::default();
1531 SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1532 assert!(!content.is_empty());
1533 assert_eq!(
1534 content.rendered,
1535 CowStr::Boxed("bl<a&h>".to_string().into_boxed_str())
1536 );
1537 }
1538 }
1539
1540 mod quotes {
1541 use crate::{
1542 content::{Content, SubstitutionStep},
1543 strings::CowStr,
1544 tests::prelude::*,
1545 };
1546
1547 #[test]
1548 fn empty() {
1549 let mut content = Content::from(crate::Span::default());
1550 let p = Parser::default();
1551 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1552 assert!(content.is_empty());
1553 assert_eq!(content.rendered, CowStr::Borrowed(""));
1554 }
1555
1556 #[test]
1557 fn basic_non_empty_span() {
1558 let mut content = Content::from(crate::Span::new("blah"));
1559 let p = Parser::default();
1560 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1561 assert!(!content.is_empty());
1562 assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1563 }
1564
1565 #[test]
1566 fn ignore_lt_and_gt() {
1567 let mut content = Content::from(crate::Span::new("bl<ah>"));
1568 let p = Parser::default();
1569 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1570 assert!(!content.is_empty());
1571 assert_eq!(
1572 content.rendered,
1573 CowStr::Boxed("bl<ah>".to_string().into_boxed_str())
1574 );
1575 }
1576
1577 #[test]
1578 fn strong_word() {
1579 let mut content = Content::from(crate::Span::new("One *word* is strong."));
1580 let p = Parser::default();
1581 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1582 assert!(!content.is_empty());
1583 assert_eq!(
1584 content.rendered,
1585 CowStr::Boxed(
1586 "One <strong>word</strong> is strong."
1587 .to_string()
1588 .into_boxed_str()
1589 )
1590 );
1591 }
1592
1593 #[test]
1594 fn marked_string_with_id() {
1595 let mut content = Content::from(crate::Span::new(r#"[#id]#a few words#"#));
1596 let p = Parser::default();
1597 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1598 assert!(!content.is_empty());
1599 assert_eq!(
1600 content.rendered,
1601 CowStr::Boxed(r#"<span id="id">a few words</span>"#.to_string().into_boxed_str())
1602 );
1603 }
1604
1605 #[test]
1606 fn unconstrained_marked_string_with_id_is_registered() {
1607 let doc = Parser::default().parse(r#"[#the_id]##marked text##"#);
1611
1612 assert_eq!(
1613 doc.child_blocks()
1614 .next()
1615 .unwrap()
1616 .rendered_content()
1617 .unwrap(),
1618 r#"<span id="the_id">marked text</span>"#
1619 );
1620
1621 assert!(doc.catalog().contains_id("the_id"));
1622 }
1623
1624 #[test]
1625 fn multibyte_leading_char_before_constrained_monospace() {
1626 for leading in ["€", "中", "🎉"] {
1632 let source = format!("{leading}`code``");
1633 let mut content = Content::from(crate::Span::new(&source));
1634 let p = Parser::default();
1635
1636 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1638
1639 assert!(content.rendered.starts_with(leading));
1640 }
1641 }
1642
1643 #[test]
1644 fn escaped_leading_backtick_before_constrained_monospace() {
1645 let mut content = Content::from(crate::Span::new(r"\`code``"));
1650 let p = Parser::default();
1651
1652 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1653
1654 assert_eq!(
1655 content.rendered,
1656 CowStr::Boxed(r"\`code``".to_string().into_boxed_str())
1657 );
1658 }
1659 }
1660
1661 mod attribute_references {
1662 use crate::{
1663 content::{Content, SubstitutionStep},
1664 strings::CowStr,
1665 tests::prelude::*,
1666 };
1667
1668 #[test]
1669 fn empty() {
1670 let mut content = Content::from(crate::Span::default());
1671 let p = Parser::default();
1672 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1673 assert!(content.is_empty());
1674 assert_eq!(content.rendered, CowStr::Borrowed(""));
1675 }
1676
1677 #[test]
1678 fn basic_non_empty_span() {
1679 let mut content = Content::from(crate::Span::new("blah"));
1680 let p = Parser::default();
1681 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1682 assert!(!content.is_empty());
1683 assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1684 }
1685
1686 #[test]
1687 fn ignore_non_match() {
1688 let mut content = Content::from(crate::Span::new("bl{ah}"));
1689 let p = Parser::default();
1690 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1691 assert!(!content.is_empty());
1692 assert_eq!(
1693 content.rendered,
1694 CowStr::Boxed("bl{ah}".to_string().into_boxed_str())
1695 );
1696 }
1697
1698 #[test]
1699 fn escaped_reference_to_unset_attribute_drops_backslash() {
1700 let mut content = Content::from(crate::Span::new("bl\\{ah}"));
1704 let p = Parser::default();
1705 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1706 assert!(!content.is_empty());
1707 assert_eq!(
1708 content.rendered,
1709 CowStr::Boxed("bl{ah}".to_string().into_boxed_str())
1710 );
1711 }
1712
1713 #[test]
1714 fn replace_sp_match() {
1715 let mut content = Content::from(crate::Span::new("bl{sp}ah"));
1716 let p = Parser::default();
1717 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1718 assert!(!content.is_empty());
1719 assert_eq!(
1720 content.rendered,
1721 CowStr::Boxed("bl ah".to_string().into_boxed_str())
1722 );
1723 }
1724
1725 #[test]
1726 fn ignore_escaped_sp_match() {
1727 let mut content = Content::from(crate::Span::new("bl\\{sp}ah"));
1728 let p = Parser::default();
1729 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1730 assert!(!content.is_empty());
1731 assert_eq!(
1732 content.rendered,
1733 CowStr::Boxed("bl{sp}ah".to_string().into_boxed_str())
1734 );
1735 }
1736
1737 #[test]
1738 fn counter_directive_displays_and_advances() {
1739 let mut content = Content::from(crate::Span::new("{counter:n}-{counter:n}"));
1740 let p = Parser::default();
1741 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1742 assert_eq!(
1743 content.rendered,
1744 CowStr::Boxed("1-2".to_string().into_boxed_str())
1745 );
1746 }
1747
1748 #[test]
1749 fn counter2_directive_advances_silently() {
1750 let mut content = Content::from(crate::Span::new("{counter2:n}{counter:n}"));
1751 let p = Parser::default();
1752 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1753 assert_eq!(
1754 content.rendered,
1755 CowStr::Boxed("2".to_string().into_boxed_str())
1756 );
1757 }
1758
1759 #[test]
1760 fn escaped_counter_directive_is_literal_and_does_not_advance() {
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("{counter:n} 1".to_string().into_boxed_str())
1767 );
1768 }
1769
1770 #[test]
1771 fn escaped_reference_with_both_braces_escaped_drops_backslashes() {
1772 let p = Parser::default().with_intrinsic_attribute(
1777 "group-id",
1778 "42",
1779 crate::parser::ModificationContext::Anywhere,
1780 );
1781
1782 let mut content = Content::from(crate::Span::new("\\{group-id\\}"));
1783 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1784 assert_eq!(
1785 content.rendered,
1786 CowStr::Boxed("{group-id}".to_string().into_boxed_str())
1787 );
1788 }
1789
1790 #[test]
1791 fn escaped_reference_with_only_trailing_brace_escaped_drops_backslash() {
1792 let p = Parser::default().with_intrinsic_attribute(
1795 "group-id",
1796 "42",
1797 crate::parser::ModificationContext::Anywhere,
1798 );
1799
1800 let mut content = Content::from(crate::Span::new("{group-id\\}"));
1801 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1802 assert_eq!(
1803 content.rendered,
1804 CowStr::Boxed("{group-id}".to_string().into_boxed_str())
1805 );
1806 }
1807
1808 #[test]
1809 fn escaped_counter_with_trailing_backslash_is_literal_and_does_not_advance() {
1810 let mut content = Content::from(crate::Span::new("{counter:n\\} {counter:n}"));
1814 let p = Parser::default();
1815 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1816 assert_eq!(
1817 content.rendered,
1818 CowStr::Boxed("{counter:n} 1".to_string().into_boxed_str())
1819 );
1820 }
1821
1822 mod attribute_missing {
1823 #![allow(clippy::indexing_slicing)]
1824
1825 use crate::{
1826 Span,
1827 content::{Content, SubstitutionGroup, SubstitutionStep},
1828 parser::ModificationContext,
1829 tests::prelude::*,
1830 warnings::WarningType,
1831 };
1832
1833 fn parser_with_mode(mode: &str) -> Parser {
1834 Parser::default().with_intrinsic_attribute(
1835 "attribute-missing",
1836 mode,
1837 ModificationContext::Anywhere,
1838 )
1839 }
1840
1841 fn render(text: &str, parser: &Parser) -> String {
1842 let mut content = Content::from(crate::Span::new(text));
1843 SubstitutionStep::AttributeReferences.apply(&mut content, parser, None);
1844 content.rendered.to_string()
1845 }
1846
1847 fn content_with_source_lines(text: &'static str) -> Content<'static> {
1853 let root = Span::new(text);
1854 let lines: Vec<&str> = text.split('\n').collect();
1855
1856 let mut spans = Vec::with_capacity(lines.len());
1857 let mut offset = 0;
1858 for line in &lines {
1859 spans.push(root.slice(offset..offset + line.len()));
1860
1861 offset += line.len() + 1;
1863 }
1864
1865 Content::from_filtered_lines(root, &lines, spans)
1866 }
1867
1868 fn assert_spans(warning: &crate::parser::DeferredWarning, text: &str, expected: &str) {
1872 assert_eq!(
1873 &text[warning.offset..warning.offset + warning.len],
1874 expected
1875 );
1876 }
1877
1878 #[test]
1879 fn skip_is_default() {
1880 let p = Parser::default();
1881 assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1882 assert!(p.take_substitution_warnings().is_empty());
1883 }
1884
1885 #[test]
1886 fn skip_explicit() {
1887 let p = parser_with_mode("skip");
1888 assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1889 }
1890
1891 #[test]
1892 fn unknown_value_falls_back_to_skip() {
1893 let p = parser_with_mode("bogus");
1894 assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1895 }
1896
1897 #[test]
1898 fn drop_removes_only_the_reference() {
1899 let p = parser_with_mode("drop");
1900 assert_eq!(render("Hello, {name}!", &p), "Hello, !");
1901 }
1902
1903 #[test]
1904 fn drop_keeps_resolvable_references() {
1905 let p = parser_with_mode("drop");
1906 assert_eq!(render("a {sp}b {missing} c", &p), "a b c");
1907 }
1908
1909 #[test]
1910 fn drop_removes_line_that_only_contained_the_reference() {
1911 let p = parser_with_mode("drop");
1914 assert_eq!(render("Line 1\n{missing}\nLine 2", &p), "Line 1\nLine 2");
1915 }
1916
1917 #[test]
1918 fn drop_keeps_a_line_the_reference_did_not_empty() {
1919 let p = parser_with_mode("drop");
1922 assert_eq!(
1923 render("Line 1\ntext {missing}\nLine 2", &p),
1924 "Line 1\ntext \nLine 2"
1925 );
1926 }
1927
1928 #[test]
1929 fn drop_removes_a_leading_or_trailing_reference_only_line() {
1930 let p = parser_with_mode("drop");
1931 assert_eq!(render("{missing}\nLine 2", &p), "Line 2");
1932 assert_eq!(render("Line 1\n{missing}", &p), "Line 1");
1933 }
1934
1935 #[test]
1936 fn drop_can_empty_the_content() {
1937 let p = parser_with_mode("drop");
1940 assert_eq!(render("{missing}", &p), "");
1941 }
1942
1943 #[test]
1944 fn drop_keeps_a_line_emptied_by_a_resolvable_reference() {
1945 let p = parser_with_mode("drop");
1948 assert_eq!(render("Line 1\n{empty}\nLine 2", &p), "Line 1\n\nLine 2");
1949 }
1950
1951 #[test]
1952 fn drop_line_removes_the_whole_line() {
1953 let p = parser_with_mode("drop-line");
1954 assert_eq!(render("Hello, {name}!\nSecond line.", &p), "Second line.");
1955 }
1956
1957 #[test]
1958 fn drop_line_only_drops_lines_with_a_missing_reference() {
1959 let p = parser_with_mode("drop-line");
1960 assert_eq!(
1961 render("first {sp}line\nsecond {missing} line\nthird line", &p),
1962 "first line\nthird line"
1963 );
1964 }
1965
1966 #[test]
1967 fn drop_line_can_empty_the_content() {
1968 let p = parser_with_mode("drop-line");
1969 assert_eq!(render("{missing}", &p), "");
1970 }
1971
1972 mod free_standing_text {
1977 use super::parser_with_mode;
1978 use crate::content::substitute_attributes_in_text;
1979
1980 #[test]
1981 fn drop_removes_line_that_only_contained_the_reference() {
1982 let p = parser_with_mode("drop");
1983 assert_eq!(
1984 substitute_attributes_in_text("Line 1\n{missing}\nLine 2", &p),
1985 "Line 1\nLine 2"
1986 );
1987 }
1988
1989 #[test]
1990 fn drop_keeps_a_line_the_reference_did_not_empty() {
1991 let p = parser_with_mode("drop");
1992 assert_eq!(
1993 substitute_attributes_in_text("Line 1\ntext {missing}\nLine 2", &p),
1994 "Line 1\ntext \nLine 2"
1995 );
1996 }
1997
1998 #[test]
1999 fn drop_keeps_a_line_emptied_by_a_resolvable_reference() {
2000 let p = parser_with_mode("drop");
2001 assert_eq!(
2002 substitute_attributes_in_text("Line 1\n{empty}\nLine 2", &p),
2003 "Line 1\n\nLine 2"
2004 );
2005 }
2006
2007 #[test]
2008 fn drop_line_removes_the_whole_line() {
2009 let p = parser_with_mode("drop-line");
2010 assert_eq!(
2011 substitute_attributes_in_text("Line 1\n{missing} tail\nLine 2", &p),
2012 "Line 1\nLine 2"
2013 );
2014 }
2015
2016 #[test]
2017 fn drop_removes_a_crlf_reference_only_line() {
2018 let p = parser_with_mode("drop");
2022 assert_eq!(
2023 substitute_attributes_in_text("Line 1\r\n{missing}\r\nLine 2", &p),
2024 "Line 1\r\nLine 2"
2025 );
2026 }
2027
2028 #[test]
2029 fn drop_keeps_a_crlf_line_the_reference_did_not_empty() {
2030 let p = parser_with_mode("drop");
2031 assert_eq!(
2032 substitute_attributes_in_text("Line 1\r\ntext {missing}\r\nLine 2", &p),
2033 "Line 1\r\ntext \r\nLine 2"
2034 );
2035 }
2036 }
2037
2038 #[test]
2039 fn warn_leaves_the_reference_and_records_a_warning() {
2040 let p = parser_with_mode("warn");
2041 assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
2042
2043 let warnings = p.take_substitution_warnings();
2044 assert_eq!(warnings.len(), 1);
2045 assert_eq!(
2046 warnings[0].warning,
2047 WarningType::SkippingReferenceToMissingAttribute("name".to_string())
2048 );
2049 }
2050
2051 #[test]
2052 fn warn_records_one_warning_per_missing_reference() {
2053 let p = parser_with_mode("warn");
2054 assert_eq!(render("a {x} b {y} c", &p), "a {x} b {y} c");
2055 assert_eq!(p.take_substitution_warnings().len(), 2);
2056 }
2057
2058 #[test]
2059 fn escaped_missing_reference_drops_the_backslash_and_never_drops_the_line() {
2060 let p = parser_with_mode("drop-line");
2065 assert_eq!(
2066 render("In the path /items/\\{id}, x.", &p),
2067 "In the path /items/{id}, x."
2068 );
2069 assert!(p.take_substitution_warnings().is_empty());
2070 }
2071
2072 #[test]
2079 fn warn_points_at_the_precise_reference() {
2080 let p = parser_with_mode("warn");
2081 let text = "Hello, {name}!";
2082 let mut content = content_with_source_lines(text);
2083 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2084
2085 let warnings = p.take_substitution_warnings();
2086 assert_eq!(warnings.len(), 1);
2087 assert_spans(&warnings[0], text, "{name}");
2088 }
2089
2090 #[test]
2091 fn warn_locates_multiple_references_on_one_line() {
2092 let p = parser_with_mode("warn");
2093 let text = "a {x} b {y} c";
2094 let mut content = content_with_source_lines(text);
2095 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2096
2097 let warnings = p.take_substitution_warnings();
2098 assert_eq!(warnings.len(), 2);
2099 assert_spans(&warnings[0], text, "{x}");
2100 assert_spans(&warnings[1], text, "{y}");
2101
2102 assert_ne!(warnings[0].offset, warnings[1].offset);
2104 }
2105
2106 #[test]
2107 fn warn_locates_references_across_multiple_lines() {
2108 let p = parser_with_mode("warn");
2112 let text = "first {alpha} line\nsecond {beta} line\nthird {gamma} line";
2113 let mut content = content_with_source_lines(text);
2114 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2115
2116 let warnings = p.take_substitution_warnings();
2117 assert_eq!(warnings.len(), 3);
2118 assert_spans(&warnings[0], text, "{alpha}");
2119 assert_spans(&warnings[1], text, "{beta}");
2120 assert_spans(&warnings[2], text, "{gamma}");
2121 }
2122
2123 #[test]
2124 fn warn_distinguishes_repeated_reference_occurrences() {
2125 let p = parser_with_mode("warn");
2126 let text = "{dup} and again {dup}";
2127 let mut content = content_with_source_lines(text);
2128 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2129
2130 let warnings = p.take_substitution_warnings();
2131 assert_eq!(warnings.len(), 2);
2132 assert_spans(&warnings[0], text, "{dup}");
2133 assert_spans(&warnings[1], text, "{dup}");
2134
2135 assert_eq!(warnings[0].offset, 0);
2137 assert_eq!(warnings[1].offset, text.rfind("{dup}").unwrap());
2138 }
2139
2140 #[test]
2141 fn warn_span_survives_earlier_special_character_expansion() {
2142 let p = parser_with_mode("warn");
2147 let text = "a < b {foo} c";
2148 let mut content = content_with_source_lines(text);
2149 SubstitutionGroup::Normal.apply(&mut content, &p, None);
2150
2151 assert!(content.rendered().contains("<"));
2153
2154 let warnings = p.take_substitution_warnings();
2155 assert_eq!(warnings.len(), 1);
2156 assert_spans(&warnings[0], text, "{foo}");
2157 assert_eq!(warnings[0].offset, text.find("{foo}").unwrap());
2158 }
2159
2160 #[test]
2161 fn warn_span_survives_earlier_quote_expansion() {
2162 let p = parser_with_mode("warn");
2165 let text = "*bold* {foo}";
2166 let mut content = content_with_source_lines(text);
2167 SubstitutionGroup::Normal.apply(&mut content, &p, None);
2168
2169 assert!(content.rendered().contains("<strong>"));
2170
2171 let warnings = p.take_substitution_warnings();
2172 assert_eq!(warnings.len(), 1);
2173 assert_spans(&warnings[0], text, "{foo}");
2174 assert_eq!(warnings[0].offset, text.find("{foo}").unwrap());
2175 }
2176
2177 #[test]
2178 fn warn_falls_back_to_whole_span_without_source_lines() {
2179 let p = parser_with_mode("warn");
2183 let text = "x {foo} y";
2184 let mut content = Content::from(Span::new(text));
2185 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
2186
2187 let warnings = p.take_substitution_warnings();
2188 assert_eq!(warnings.len(), 1);
2189 assert_eq!(warnings[0].offset, 0);
2190 assert_eq!(warnings[0].len, text.len());
2191 }
2192 }
2193 }
2194
2195 mod callouts {
2196 use crate::{
2197 content::{Content, SubstitutionStep},
2198 parser::ModificationContext,
2199 strings::CowStr,
2200 tests::prelude::*,
2201 };
2202
2203 fn render_callouts(text: &str, parser: &Parser) -> String {
2207 let mut content = Content::from(crate::Span::new(text));
2208
2209 SubstitutionStep::Callouts.apply(&mut content, parser, None);
2212 content.rendered.to_string()
2213 }
2214
2215 #[test]
2216 fn empty() {
2217 let mut content = Content::from(crate::Span::default());
2218 let p = Parser::default();
2219 SubstitutionStep::Callouts.apply(&mut content, &p, None);
2220 assert!(content.is_empty());
2221 assert_eq!(content.rendered, CowStr::Borrowed(""));
2222 }
2223
2224 #[test]
2225 fn no_callouts() {
2226 let p = Parser::default();
2227 assert_eq!(render_callouts("just some text", &p), "just some text");
2228 }
2229
2230 #[test]
2231 fn lt_without_callout_is_untouched() {
2232 let p = Parser::default();
2233 assert_eq!(render_callouts("a <b> c", &p), "a <b> c");
2234 }
2235
2236 #[test]
2237 fn basic_explicit() {
2238 let p = Parser::default();
2239 assert_eq!(
2240 render_callouts("require 'x' <1>", &p),
2241 r#"require 'x' <b class="conum">(1)</b>"#
2242 );
2243 }
2244
2245 #[test]
2246 fn line_comment_prefix_preserved() {
2247 let p = Parser::default();
2248 assert_eq!(
2249 render_callouts("puts 'x' # <1>", &p),
2250 r#"puts 'x' # <b class="conum">(1)</b>"#
2251 );
2252 }
2253
2254 #[test]
2255 fn multiple_on_one_line() {
2256 let p = Parser::default();
2257 assert_eq!(
2258 render_callouts("puts x <5><6>", &p),
2259 r#"puts x <b class="conum">(5)</b><b class="conum">(6)</b>"#
2260 );
2261 }
2262
2263 #[test]
2264 fn not_at_end_of_line() {
2265 let p = Parser::default();
2266 assert_eq!(
2267 render_callouts("puts \"<1> in the middle\"", &p),
2268 "puts \"<1> in the middle\""
2269 );
2270 }
2271
2272 #[test]
2273 fn auto_numbering() {
2274 let p = Parser::default();
2275 assert_eq!(
2276 render_callouts("a <.>\nb <.>\nc <.>", &p),
2277 "a <b class=\"conum\">(1)</b>\nb <b class=\"conum\">(2)</b>\nc <b class=\"conum\">(3)</b>"
2278 );
2279 }
2280
2281 #[test]
2282 fn mixed_numbering_ignores_explicit() {
2283 let p = Parser::default();
2285 assert_eq!(
2286 render_callouts("a <.>\nb <1>\nc <.>", &p),
2287 "a <b class=\"conum\">(1)</b>\nb <b class=\"conum\">(1)</b>\nc <b class=\"conum\">(2)</b>"
2288 );
2289 }
2290
2291 #[test]
2292 fn xml_callout() {
2293 let p = Parser::default();
2294 assert_eq!(
2295 render_callouts("<child/> <!--1-->", &p),
2296 r#"<child/> <!--<b class="conum">(1)</b>-->"#
2297 );
2298 }
2299
2300 #[test]
2301 fn half_xml_comment_is_not_a_callout() {
2302 let p = Parser::default();
2303 assert_eq!(
2304 render_callouts("First line <1-->", &p),
2305 "First line <1-->"
2306 );
2307 }
2308
2309 #[test]
2310 fn escaped_callout() {
2311 let p = Parser::default();
2312 assert_eq!(
2313 render_callouts("require 'x' # \\<1>", &p),
2314 "require 'x' # <1>"
2315 );
2316 }
2317
2318 #[test]
2319 fn icons_font() {
2320 let p = Parser::default().with_intrinsic_attribute(
2321 "icons",
2322 "font",
2323 ModificationContext::Anywhere,
2324 );
2325 assert_eq!(
2326 render_callouts("puts x # <1>", &p),
2327 r#"puts x <i class="conum" data-value="1"></i><b>(1)</b>"#
2328 );
2329 }
2330
2331 #[test]
2332 fn icons_image() {
2333 let p = Parser::default().with_intrinsic_attribute(
2334 "icons",
2335 "",
2336 ModificationContext::Anywhere,
2337 );
2338 assert_eq!(
2339 render_callouts("puts x <1>", &p),
2340 r#"puts x <img src="./images/icons/callouts/1.png" alt="1">"#
2341 );
2342 }
2343
2344 #[test]
2345 fn custom_line_comment_prefix() {
2346 let mut content = Content::from(crate::Span::new("hello() -> % <1>"));
2348 let attrlist = crate::attributes::Attrlist::parse(
2349 crate::Span::new("source,erlang,line-comment=%"),
2350 &Parser::default(),
2351 crate::attributes::AttrlistContext::Block,
2352 )
2353 .item
2354 .item;
2355 let p = Parser::default();
2356 SubstitutionStep::Callouts.apply(&mut content, &p, Some(&attrlist));
2357 assert_eq!(
2358 content.rendered.to_string(),
2359 r#"hello() -> % <b class="conum">(1)</b>"#
2360 );
2361 }
2362
2363 #[test]
2364 fn disabled_line_comment_preserves_leading_chars() {
2365 let mut content = Content::from(crate::Span::new("-- <1>"));
2368 let attrlist = crate::attributes::Attrlist::parse(
2369 crate::Span::new("source,asciidoc,line-comment="),
2370 &Parser::default(),
2371 crate::attributes::AttrlistContext::Block,
2372 )
2373 .item
2374 .item;
2375 let p = Parser::default();
2376 SubstitutionStep::Callouts.apply(&mut content, &p, Some(&attrlist));
2377 assert_eq!(
2378 content.rendered.to_string(),
2379 r#"-- <b class="conum">(1)</b>"#
2380 );
2381 }
2382
2383 #[test]
2384 fn document_line_comment_attribute() {
2385 let p = Parser::default().with_intrinsic_attribute(
2388 "line-comment",
2389 "%",
2390 ModificationContext::Anywhere,
2391 );
2392 assert_eq!(
2393 render_callouts("hello() -> % <1>", &p),
2394 r#"hello() -> % <b class="conum">(1)</b>"#
2395 );
2396 }
2397 }
2398}