1use std::{borrow::Cow, sync::LazyLock};
2
3use regex::{Captures, Regex, RegexBuilder, Replacer};
4
5use crate::{
6 Parser, Span,
7 attributes::{Attrlist, AttrlistContext},
8 content::Content,
9 document::{InterpretedValue, RefType},
10 internal::{LookaheadReplacer, LookaheadResult, replace_with_lookahead},
11 parser::{
12 CalloutGuard, CalloutRenderParams, CharacterReplacementType, InlineSubstitutionRenderer,
13 QuoteScope, QuoteType, SpecialCharacter,
14 },
15 strings::CowStr,
16 warnings::WarningType,
17};
18
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25pub enum SubstitutionStep {
26 SpecialCharacters,
29
30 Quotes,
32
33 AttributeReferences,
35
36 CharacterReplacements,
40
41 Macros,
44
45 PostReplacement,
47
48 Callouts,
50}
51
52impl SubstitutionStep {
53 pub(crate) fn apply(
54 &self,
55 content: &mut Content<'_>,
56 parser: &Parser,
57 attrlist: Option<&Attrlist<'_>>,
58 ) {
59 match self {
60 Self::SpecialCharacters => {
61 apply_special_characters(content, &*parser.renderer);
62 }
63 Self::Quotes => {
64 apply_quotes(content, parser);
65 }
66 Self::AttributeReferences => {
67 apply_attributes(content, parser);
68 }
69 Self::CharacterReplacements => {
70 apply_character_replacements(content, &*parser.renderer);
71 }
72 Self::Macros => {
73 super::macros::apply_macros(content, parser);
74 }
75 Self::PostReplacement => {
76 apply_post_replacements(content, parser, attrlist);
77 }
78 Self::Callouts => {
79 apply_callouts(content, parser, attrlist);
80 }
81 }
82 }
83}
84
85fn apply_special_characters(content: &mut Content<'_>, renderer: &dyn InlineSubstitutionRenderer) {
86 if !content.rendered.contains(['<', '>', '&']) {
87 return;
88 }
89
90 let mut result: Cow<'_, str> = content.rendered.to_string().into();
91 let replacer = SpecialCharacterReplacer { renderer };
92
93 if let Cow::Owned(new_result) = SPECIAL_CHARS.replace_all(&result, replacer) {
94 result = new_result.into();
95 }
96
97 content.rendered = result.into();
98}
99
100static SPECIAL_CHARS: LazyLock<Regex> = LazyLock::new(|| {
101 #[allow(clippy::unwrap_used)]
102 Regex::new("[<>&]").unwrap()
103});
104
105#[derive(Debug)]
106struct SpecialCharacterReplacer<'r> {
107 renderer: &'r dyn InlineSubstitutionRenderer,
108}
109
110impl Replacer for SpecialCharacterReplacer<'_> {
111 fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
112 let ch = &caps[0];
115
116 if ch == "<" {
117 self.renderer
118 .render_special_character(SpecialCharacter::Lt, dest);
119 } else if ch == ">" {
120 self.renderer
121 .render_special_character(SpecialCharacter::Gt, dest);
122 } else if ch == "&" {
123 self.renderer
124 .render_special_character(SpecialCharacter::Ampersand, dest);
125 }
126
127 }
130}
131
132static QUOTED_TEXT_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
133 #[allow(clippy::unwrap_used)]
134 Regex::new("[*_`#^~]").unwrap()
135});
136
137struct QuoteSub {
138 type_: QuoteType,
139 scope: QuoteScope,
140 pattern: Regex,
141}
142
143static QUOTE_SUBS: LazyLock<Vec<QuoteSub>> = LazyLock::new(|| {
164 vec![
165 QuoteSub {
166 type_: QuoteType::Strong,
168 scope: QuoteScope::Unconstrained,
169 #[allow(clippy::unwrap_used)]
170 pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?\*\*(.+?)\*\*"#)
171 .dot_matches_new_line(true)
172 .build()
173 .unwrap(),
174 },
175 QuoteSub {
176 type_: QuoteType::Strong,
178 scope: QuoteScope::Constrained,
179 #[allow(clippy::unwrap_used)]
180 pattern: RegexBuilder::new(
181 r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?\*(\S|\S.*?\S)\*\b{end-half}"#,
182 )
183 .dot_matches_new_line(true)
184 .build()
185 .unwrap(),
186 },
187 QuoteSub {
188 type_: QuoteType::DoubleQuote,
190 scope: QuoteScope::Constrained,
191 #[allow(clippy::unwrap_used)]
192 pattern: RegexBuilder::new(
193 r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?"`(\S|\S.*?\S)`"\b{end-half}"#,
194 )
195 .dot_matches_new_line(true)
196 .build()
197 .unwrap(),
198 },
199 QuoteSub {
200 type_: QuoteType::SingleQuote,
202 scope: QuoteScope::Constrained,
203 #[allow(clippy::unwrap_used)]
204 pattern: RegexBuilder::new(
205 r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?'`(\S|\S.*?\S)`'\b{end-half}"#,
206 )
207 .dot_matches_new_line(true)
208 .build()
209 .unwrap(),
210 },
211 QuoteSub {
212 type_: QuoteType::Monospaced,
214 scope: QuoteScope::Unconstrained,
215 #[allow(clippy::unwrap_used)]
216 pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?``(.+?)``"#)
217 .dot_matches_new_line(true)
218 .build()
219 .unwrap(),
220 },
221 QuoteSub {
222 type_: QuoteType::Monospaced,
224 scope: QuoteScope::Constrained,
225 #[allow(clippy::unwrap_used)]
226 pattern: RegexBuilder::new(
227 r#"(^|[^\w&;:"'`}])(?:\[([^\[\]]+)\])?`(\S|\S.*?\S)`\b{end-half}"#,
228 )
232 .dot_matches_new_line(true)
233 .build()
234 .unwrap(),
235 },
236 QuoteSub {
237 type_: QuoteType::Emphasis,
239 scope: QuoteScope::Unconstrained,
240 #[allow(clippy::unwrap_used)]
241 pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?__(.+?)__"#)
242 .dot_matches_new_line(true)
243 .build()
244 .unwrap(),
245 },
246 QuoteSub {
247 type_: QuoteType::Emphasis,
249 scope: QuoteScope::Constrained,
250 #[allow(clippy::unwrap_used)]
251 pattern: RegexBuilder::new(
252 r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?_(\S|\S.*?\S)_\b{end-half}"#,
253 )
254 .dot_matches_new_line(true)
255 .build()
256 .unwrap(),
257 },
258 QuoteSub {
259 type_: QuoteType::Mark,
261 scope: QuoteScope::Unconstrained,
262 #[allow(clippy::unwrap_used)]
263 pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?##(.+?)##"#)
264 .dot_matches_new_line(true)
265 .build()
266 .unwrap(),
267 },
268 QuoteSub {
269 type_: QuoteType::Mark,
271 scope: QuoteScope::Constrained,
272 #[allow(clippy::unwrap_used)]
273 pattern: RegexBuilder::new(
274 r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?#(\S|\S.*?\S)#\b{end-half}"#,
275 )
276 .dot_matches_new_line(true)
277 .build()
278 .unwrap(),
279 },
280 QuoteSub {
281 type_: QuoteType::Superscript,
283 scope: QuoteScope::Unconstrained,
284 #[allow(clippy::unwrap_used)]
285 pattern: Regex::new(r#"\\?(?:\[([^\[\]]+)\])?\^(\S+?)\^"#).unwrap(),
286 },
287 QuoteSub {
288 type_: QuoteType::Subscript,
290 scope: QuoteScope::Unconstrained,
291 #[allow(clippy::unwrap_used)]
292 pattern: Regex::new(r#"\\?(?:\[([^\[\]]+)\])?~(\S+?)~"#).unwrap(),
293 },
294 ]
295});
296
297#[derive(Debug)]
298struct QuoteReplacer<'r> {
299 type_: QuoteType,
300 scope: QuoteScope,
301 parser: &'r Parser,
302}
303
304impl LookaheadReplacer for QuoteReplacer<'_> {
305 fn replace_append(
306 &mut self,
307 caps: &Captures<'_>,
308 dest: &mut String,
309 after: &str,
310 ) -> LookaheadResult {
311 if self.type_ == QuoteType::Monospaced
318 && self.scope == QuoteScope::Constrained
319 && after.starts_with(['"', '\'', '`'])
320 {
321 let skip_ahead = if caps[0].starts_with('\\') { 2 } else { 1 };
322 dest.push_str(&caps[0][0..skip_ahead]);
323 return LookaheadResult::SkipAheadAndRetry(skip_ahead);
324 }
325
326 let unescaped_attrs: Option<String> = if caps[0].starts_with('\\') {
327 let maybe_attrs = caps.get(2).map(|a| a.as_str());
328 if self.scope == QuoteScope::Constrained && maybe_attrs.is_some() {
329 Some(format!(
330 "[{attrs}]",
331 attrs = maybe_attrs.unwrap_or_default()
332 ))
333 } else {
334 dest.push_str(&caps[0][1..]);
335 return LookaheadResult::Continue;
336 }
337 } else {
338 None
339 };
340
341 match self.scope {
342 QuoteScope::Constrained => {
343 if let Some(attrs) = unescaped_attrs {
344 dest.push_str(&attrs);
345 self.parser.renderer.render_quoted_substitition(
346 self.type_, self.scope, None, None, &caps[3], dest,
347 );
348 } else {
349 let (attrlist, type_): (Option<Attrlist<'_>>, QuoteType) =
350 if let Some(attrlist) = caps.get(2) {
351 let type_ = if self.type_ == QuoteType::Mark {
352 QuoteType::Unquoted
353 } else {
354 self.type_
355 };
356
357 (
358 Some(
359 Attrlist::parse(
360 crate::Span::new(attrlist.as_str()),
361 self.parser,
362 AttrlistContext::Inline,
363 )
364 .item
365 .item,
366 ),
367 type_,
368 )
369 } else {
370 (None, self.type_)
371 };
372
373 if let Some(prefix) = caps.get(1) {
374 dest.push_str(prefix.as_str());
375 }
376
377 let id = attrlist
378 .as_ref()
379 .and_then(|a| a.id().map(|s| s.to_string()));
380
381 if let Some(id) = &id {
386 let _ = self.parser.register_ref(id, None, RefType::Anchor);
387 }
388
389 self.parser.renderer.render_quoted_substitition(
390 type_, self.scope, attrlist, id, &caps[3], dest,
391 );
392 }
393 }
394
395 QuoteScope::Unconstrained => {
396 let (attrlist, type_): (Option<Attrlist<'_>>, QuoteType) =
397 if let Some(attrlist) = caps.get(1) {
398 let type_ = if self.type_ == QuoteType::Mark {
399 QuoteType::Unquoted
400 } else {
401 self.type_
402 };
403
404 (
405 Some(
406 Attrlist::parse(
407 crate::Span::new(attrlist.as_str()),
408 self.parser,
409 AttrlistContext::Inline,
410 )
411 .item
412 .item,
413 ),
414 type_,
415 )
416 } else {
417 (None, self.type_)
418 };
419
420 let id = attrlist
421 .as_ref()
422 .and_then(|a| a.id().map(|s| s.to_string()));
423
424 if let Some(id) = &id {
429 let _ = self.parser.register_ref(id, None, RefType::Anchor);
430 }
431
432 self.parser
433 .renderer
434 .render_quoted_substitition(type_, self.scope, attrlist, id, &caps[2], dest);
435 }
436 }
437
438 LookaheadResult::Continue
439 }
440}
441
442fn apply_quotes(content: &mut Content<'_>, parser: &Parser) {
443 if !QUOTED_TEXT_SNIFF.is_match(content.rendered.as_ref()) {
444 return;
445 }
446
447 let mut result: Cow<'_, str> = content.rendered.to_string().into();
448
449 for sub in &*QUOTE_SUBS {
450 let replacer = QuoteReplacer {
451 type_: sub.type_,
452 scope: sub.scope,
453 parser,
454 };
455
456 if let Cow::Owned(new_result) = replace_with_lookahead(&sub.pattern, &result, replacer) {
457 result = new_result.into();
458 }
459 }
462
463 content.rendered = result.into();
464}
465
466static ATTRIBUTE_REFERENCE: LazyLock<Regex> = LazyLock::new(|| {
467 #[allow(clippy::unwrap_used)]
471 Regex::new(r#"\\?\{(?:(counter2?):([^{}]+)|([A-Za-z0-9_][A-Za-z0-9_-]*))\}"#).unwrap()
472});
473
474#[derive(Clone, Copy, Debug, Eq, PartialEq)]
479enum AttributeMissing {
480 Skip,
482
483 Drop,
485
486 DropLine,
488
489 Warn,
491}
492
493impl AttributeMissing {
494 fn from_parser(parser: &Parser) -> Self {
498 match parser.attribute_value("attribute-missing").as_maybe_str() {
499 Some("drop") => Self::Drop,
500 Some("drop-line") => Self::DropLine,
501 Some("warn") => Self::Warn,
502 _ => Self::Skip,
503 }
504 }
505}
506
507#[derive(Debug)]
508struct AttributeReplacer<'p> {
509 parser: &'p Parser,
510
511 mode: AttributeMissing,
513
514 source: Span<'p>,
523
524 missing_on_line: bool,
528}
529
530impl Replacer for AttributeReplacer<'_> {
531 fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
532 let escaped = caps[0].starts_with('\\');
533
534 if let Some(directive) = caps.get(1) {
537 if escaped {
538 dest.push_str(&caps[0][1..]);
541 return;
542 }
543
544 let mut parts = caps[2].splitn(2, ':');
547 let name = parts.next().unwrap_or_default();
548 let seed = parts.next();
549
550 let value = self.parser.counter(name, seed);
551
552 if directive.as_str() == "counter" {
554 dest.push_str(&value);
555 }
556 return;
557 }
558
559 let attr_name = &caps[3];
561
562 if !self.parser.has_attribute(attr_name) {
563 if escaped {
567 dest.push_str(&caps[0]);
568 return;
569 }
570
571 match self.mode {
572 AttributeMissing::Skip => dest.push_str(&caps[0]),
573 AttributeMissing::Drop => {
574 }
576 AttributeMissing::DropLine => {
577 self.missing_on_line = true;
580 }
581 AttributeMissing::Warn => {
582 dest.push_str(&caps[0]);
583 self.parser.record_substitution_warning(
584 self.source,
585 WarningType::SkippingReferenceToMissingAttribute(attr_name.to_string()),
586 );
587 }
588 }
589 return;
590 }
591
592 if escaped {
593 dest.push_str(&caps[0][1..]);
594 return;
595 }
596
597 if let InterpretedValue::Value(value) = self.parser.attribute_value(attr_name) {
598 dest.push_str(value.as_ref());
599 }
600 }
603}
604
605fn apply_attributes(content: &mut Content<'_>, parser: &Parser) {
606 if !content.rendered.contains('{') {
607 return;
608 }
609
610 let mode = AttributeMissing::from_parser(parser);
611 let source = content.original();
612
613 let mut out = String::with_capacity(content.rendered.len());
619 let mut changed = false;
620 let mut wrote_line = false;
621
622 for line in content.rendered.split('\n') {
623 if !line.contains('{') {
624 if wrote_line {
625 out.push('\n');
626 }
627 out.push_str(line);
628 wrote_line = true;
629 continue;
630 }
631
632 let mut replacer = AttributeReplacer {
633 parser,
634 mode,
635 source,
636 missing_on_line: false,
637 };
638
639 let replaced = ATTRIBUTE_REFERENCE.replace_all(line, replacer.by_ref());
640
641 if replacer.missing_on_line && mode == AttributeMissing::DropLine {
642 changed = true;
644 continue;
645 }
646
647 if let Cow::Owned(_) = replaced {
648 changed = true;
649 }
650
651 if wrote_line {
652 out.push('\n');
653 }
654 out.push_str(&replaced);
655 wrote_line = true;
656 }
657
658 if changed {
661 content.rendered = out.into();
662 }
663}
664
665pub(crate) fn substitute_attributes_in_macro_target<'src>(
677 target: Span<'src>,
678 parser: &Parser,
679) -> Option<CowStr<'src>> {
680 let text = target.data();
681
682 if !text.contains('{') {
685 return Some(text.into());
686 }
687
688 let mode = AttributeMissing::from_parser(parser);
689
690 let mut replacer = AttributeReplacer {
691 parser,
692 mode,
693 source: target,
694 missing_on_line: false,
695 };
696
697 let replaced = ATTRIBUTE_REFERENCE.replace_all(text, replacer.by_ref());
698
699 if replacer.missing_on_line && mode == AttributeMissing::DropLine {
700 return None;
701 }
702
703 Some(replaced.into())
704}
705
706pub(crate) fn substitute_attributes_in_text(text: &str, parser: &Parser) -> String {
723 if !text.contains('{') {
724 return text.to_string();
725 }
726
727 let mode = AttributeMissing::from_parser(parser);
728 let source = Span::new(text);
729
730 let mut out = String::with_capacity(text.len());
731 let mut wrote_line = false;
732
733 for line in text.split('\n') {
734 if !line.contains('{') {
735 if wrote_line {
736 out.push('\n');
737 }
738 out.push_str(line);
739 wrote_line = true;
740 continue;
741 }
742
743 let mut replacer = AttributeReplacer {
744 parser,
745 mode,
746 source,
747 missing_on_line: false,
748 };
749
750 let replaced = ATTRIBUTE_REFERENCE.replace_all(line, replacer.by_ref());
751
752 if replacer.missing_on_line && mode == AttributeMissing::DropLine {
753 continue;
755 }
756
757 if wrote_line {
758 out.push('\n');
759 }
760 out.push_str(&replaced);
761 wrote_line = true;
762 }
763
764 out
765}
766
767fn apply_character_replacements(
768 content: &mut Content<'_>,
769 renderer: &dyn InlineSubstitutionRenderer,
770) {
771 if !REPLACEABLE_TEXT_SNIFF.is_match(content.rendered.as_ref()) {
772 return;
773 }
774
775 let mut result: Cow<'_, str> = content.rendered.to_string().into();
776
777 for repl in &*REPLACEMENTS {
778 let replacer = CharacterReplacer {
779 type_: repl.type_.clone(),
780 renderer,
781 };
782
783 if let Cow::Owned(new_result) = repl.pattern.replace_all(&result, replacer) {
784 result = new_result.into();
785 }
786 }
789
790 content.rendered = result.into();
791}
792
793struct CharacterReplacement {
794 type_: CharacterReplacementType,
795 pattern: Regex,
796}
797
798static REPLACEABLE_TEXT_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
799 #[allow(clippy::unwrap_used)]
800 Regex::new(r#"[&']|--|\.\.\.|\([CRT]M?\)"#).unwrap()
801});
802
803static REPLACEMENTS: LazyLock<Vec<CharacterReplacement>> = LazyLock::new(|| {
809 vec![
810 CharacterReplacement {
811 type_: CharacterReplacementType::Copyright,
813 #[allow(clippy::unwrap_used)]
814 pattern: Regex::new(r#"\\?\(C\)"#).unwrap(),
815 },
816 CharacterReplacement {
817 type_: CharacterReplacementType::Registered,
819 #[allow(clippy::unwrap_used)]
820 pattern: Regex::new(r#"\\?\(R\)"#).unwrap(),
821 },
822 CharacterReplacement {
823 type_: CharacterReplacementType::Trademark,
825 #[allow(clippy::unwrap_used)]
826 pattern: Regex::new(r#"\\?\(TM\)"#).unwrap(),
827 },
828 CharacterReplacement {
829 type_: CharacterReplacementType::EmDashSurroundedBySpaces,
831 #[allow(clippy::unwrap_used)]
832 pattern: Regex::new(r#"(?: |\n|^|\\)--(?: |\n|$)"#).unwrap(),
833 },
834 CharacterReplacement {
835 type_: CharacterReplacementType::EmDashWithoutSpace,
837 #[allow(clippy::unwrap_used)]
838 pattern: Regex::new(r#"(\w)\\?--\b{start-half}"#).unwrap(),
839 },
840 CharacterReplacement {
841 type_: CharacterReplacementType::Ellipsis,
843 #[allow(clippy::unwrap_used)]
844 pattern: Regex::new(r#"\\?\.\.\."#).unwrap(),
845 },
846 CharacterReplacement {
847 type_: CharacterReplacementType::TypographicApostrophe,
849 #[allow(clippy::unwrap_used)]
850 pattern: Regex::new(r#"\\?`'"#).unwrap(),
851 },
852 CharacterReplacement {
853 type_: CharacterReplacementType::TypographicApostrophe,
855 #[allow(clippy::unwrap_used)]
856 pattern: Regex::new(r#"([[:alnum:]])\\?'([[:alpha:]])"#).unwrap(),
857 },
858 CharacterReplacement {
859 type_: CharacterReplacementType::SingleRightArrow,
861 #[allow(clippy::unwrap_used)]
862 pattern: Regex::new(r#"\\?->"#).unwrap(),
863 },
864 CharacterReplacement {
865 type_: CharacterReplacementType::DoubleRightArrow,
867 #[allow(clippy::unwrap_used)]
868 pattern: Regex::new(r#"\\?=>"#).unwrap(),
869 },
870 CharacterReplacement {
871 type_: CharacterReplacementType::SingleLeftArrow,
873 #[allow(clippy::unwrap_used)]
874 pattern: Regex::new(r#"\\?<-"#).unwrap(),
875 },
876 CharacterReplacement {
877 type_: CharacterReplacementType::DoubleLeftArrow,
879 #[allow(clippy::unwrap_used)]
880 pattern: Regex::new(r#"\\?<="#).unwrap(),
881 },
882 CharacterReplacement {
883 type_: CharacterReplacementType::CharacterReference("".to_owned()),
885 #[allow(clippy::unwrap_used)]
886 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(),
887 },
888 ]
889});
890
891#[derive(Debug)]
892struct CharacterReplacer<'r> {
893 type_: CharacterReplacementType,
894 renderer: &'r dyn InlineSubstitutionRenderer,
895}
896
897impl Replacer for CharacterReplacer<'_> {
898 fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
899 if caps[0].contains('\\') {
900 let unescaped = &caps[0].replace("\\", "");
902 dest.push_str(unescaped);
903 return;
904 }
905
906 match self.type_ {
907 CharacterReplacementType::Copyright
908 | CharacterReplacementType::Registered
909 | CharacterReplacementType::Trademark
910 | CharacterReplacementType::EmDashSurroundedBySpaces
911 | CharacterReplacementType::Ellipsis
912 | CharacterReplacementType::SingleLeftArrow
913 | CharacterReplacementType::DoubleLeftArrow
914 | CharacterReplacementType::SingleRightArrow
915 | CharacterReplacementType::DoubleRightArrow => {
916 self.renderer
917 .render_character_replacement(self.type_.clone(), dest);
918 }
919
920 CharacterReplacementType::EmDashWithoutSpace => {
921 dest.push_str(&caps[1]);
922 self.renderer.render_character_replacement(
923 CharacterReplacementType::EmDashWithoutSpace,
924 dest,
925 );
926 }
927
928 CharacterReplacementType::TypographicApostrophe => {
929 if let Some(before) = caps.get(1) {
930 dest.push_str(before.as_str());
931 }
932
933 self.renderer.render_character_replacement(
934 CharacterReplacementType::TypographicApostrophe,
935 dest,
936 );
937
938 if let Some(after) = caps.get(2) {
939 dest.push_str(after.as_str());
940 }
941 }
942
943 CharacterReplacementType::CharacterReference(_) => {
944 self.renderer.render_character_replacement(
945 CharacterReplacementType::CharacterReference(caps[1].to_string()),
946 dest,
947 );
948 }
949 }
950 }
951}
952
953fn apply_post_replacements(
954 content: &mut Content<'_>,
955 parser: &Parser,
956 attrlist: Option<&Attrlist<'_>>,
957) {
958 if parser.is_attribute_set("hardbreaks-option")
959 || attrlist.is_some_and(|attrlist| attrlist.has_option("hardbreaks"))
960 {
961 let text = content.rendered.as_ref();
962 if !text.contains('\n') {
963 return;
964 }
965
966 let mut lines: Vec<&str> = content.rendered.as_ref().lines().collect();
967 let last = lines.pop().unwrap_or_default();
968
969 let mut lines: Vec<String> = lines
970 .iter()
971 .map(|line| {
972 let line = if line.ends_with(" +") {
973 &line[0..line.len() - 2]
974 } else {
975 *line
976 };
977
978 let mut line = line.to_owned();
979 parser.renderer.render_line_break(&mut line);
980 line
981 })
982 .collect();
983
984 lines.push(last.to_owned());
985
986 let new_result = lines.join("\n");
987 content.rendered = new_result.into();
988 } else {
989 let rendered = content.rendered.as_ref();
990 if !(rendered.contains('+') && rendered.contains('\n')) {
991 return;
992 }
993
994 let replacer = PostReplacementReplacer(&*parser.renderer);
995
996 if let Cow::Owned(new_result) = HARD_LINE_BREAK.replace_all(rendered, replacer) {
997 content.rendered = new_result.into();
998 }
999 }
1000}
1001
1002#[derive(Debug)]
1003struct PostReplacementReplacer<'r>(&'r dyn InlineSubstitutionRenderer);
1004
1005impl Replacer for PostReplacementReplacer<'_> {
1006 fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
1007 dest.push_str(&caps[1]);
1008 self.0.render_line_break(dest);
1009 }
1010}
1011
1012static HARD_LINE_BREAK: LazyLock<Regex> = LazyLock::new(|| {
1013 #[allow(clippy::unwrap_used)]
1014 Regex::new(r#"(?m)^(.*) \+$"#).unwrap()
1015});
1016
1017fn apply_callouts(content: &mut Content<'_>, parser: &Parser, attrlist: Option<&Attrlist<'_>>) {
1033 if !content.rendered.contains("<") {
1036 return;
1037 }
1038
1039 let line_comment: Option<String> = attrlist
1049 .and_then(|a| a.named_attribute("line-comment"))
1050 .map(|a| a.value().to_string())
1051 .or_else(|| {
1052 if parser.has_attribute("line-comment") {
1053 Some(
1054 parser
1055 .attribute_value("line-comment")
1056 .as_maybe_str()
1057 .unwrap_or("")
1058 .to_string(),
1059 )
1060 } else {
1061 None
1062 }
1063 });
1064
1065 let (callout_rx, tail_rx) = build_callout_regexes(line_comment.as_deref());
1066
1067 let replacer = CalloutReplacer {
1068 renderer: &*parser.renderer,
1069 parser,
1070 autonum: 0,
1071 tail: tail_rx,
1072 };
1073
1074 if let Cow::Owned(new_result) =
1075 replace_with_lookahead(&callout_rx, content.rendered.as_ref(), replacer)
1076 {
1077 content.rendered = new_result.into();
1078 }
1079}
1080
1081static DEFAULT_CALLOUT_RX: LazyLock<Regex> = LazyLock::new(|| {
1084 #[allow(clippy::unwrap_used)]
1085 Regex::new(
1086 r"(?P<prefix>(?://|#|--|;;) ?)?(?P<esc>\\)?(?:<!--(?P<xnum>\d+|\.)-->|<(?P<num>\d+|\.)>)",
1087 )
1088 .unwrap()
1089});
1090
1091static DEFAULT_CALLOUT_TAIL_RX: LazyLock<Regex> = LazyLock::new(|| {
1093 #[allow(clippy::unwrap_used)]
1094 Regex::new(r"^(?: ?\\?(?:<!--(?:\d+|\.)-->|<(?:\d+|\.)>))*(?:\n|$)").unwrap()
1095});
1096
1097static CUSTOM_CALLOUT_TAIL_RX: LazyLock<Regex> = LazyLock::new(|| {
1100 #[allow(clippy::unwrap_used)]
1101 Regex::new(r"^(?: ?\\?<(?:\d+|\.)>)*(?:\n|$)").unwrap()
1102});
1103
1104fn build_callout_regexes(line_comment: Option<&str>) -> (Cow<'static, Regex>, &'static Regex) {
1117 match line_comment {
1118 None => (Cow::Borrowed(&DEFAULT_CALLOUT_RX), &DEFAULT_CALLOUT_TAIL_RX),
1120
1121 Some(prefix) => {
1124 let prefix_pattern = if prefix.is_empty() {
1125 String::new()
1126 } else {
1127 format!(r"(?P<prefix>{} ?)?", regex::escape(prefix))
1128 };
1129
1130 #[allow(clippy::unwrap_used)]
1131 let callout = Regex::new(&format!(
1132 r"{prefix_pattern}(?P<esc>\\)?<(?P<num>\d+|\.)>"
1133 ))
1134 .unwrap();
1135
1136 (Cow::Owned(callout), &CUSTOM_CALLOUT_TAIL_RX)
1137 }
1138 }
1139}
1140
1141struct CalloutReplacer<'r> {
1144 renderer: &'r dyn InlineSubstitutionRenderer,
1145 parser: &'r Parser,
1146
1147 autonum: u32,
1150
1151 tail: &'r Regex,
1153}
1154
1155impl LookaheadReplacer for CalloutReplacer<'_> {
1156 fn replace_append(
1157 &mut self,
1158 caps: &Captures<'_>,
1159 dest: &mut String,
1160 after: &str,
1161 ) -> LookaheadResult {
1162 if !self.tail.is_match(after) {
1165 dest.push_str(&caps[0]);
1166 return LookaheadResult::Continue;
1167 }
1168
1169 if caps.name("esc").is_some() {
1172 dest.push_str(&caps[0].replacen('\\', "", 1));
1173 return LookaheadResult::Continue;
1174 }
1175
1176 let (number_raw, is_xml) = if let Some(xnum) = caps.name("xnum") {
1177 (xnum.as_str(), true)
1178 } else {
1179 #[allow(clippy::unwrap_used)]
1181 (caps.name("num").unwrap().as_str(), false)
1182 };
1183
1184 let number = if number_raw == "." {
1185 self.autonum += 1;
1186 self.autonum.to_string()
1187 } else {
1188 number_raw.to_string()
1189 };
1190
1191 if let Ok(n) = number.parse::<u32>() {
1194 self.parser.register_callout(n);
1195 }
1196
1197 let guard = match caps.name("prefix") {
1201 Some(prefix) => CalloutGuard::LineComment(prefix.as_str()),
1202 None if is_xml => CalloutGuard::Xml,
1203 None => CalloutGuard::LineComment(""),
1204 };
1205
1206 self.renderer.render_callout(
1207 &CalloutRenderParams {
1208 number: &number,
1209 guard,
1210 parser: self.parser,
1211 },
1212 dest,
1213 );
1214
1215 LookaheadResult::Continue
1216 }
1217}
1218
1219#[cfg(test)]
1220mod tests {
1221 #![allow(clippy::unwrap_used)]
1222
1223 mod special_characters {
1224 use crate::{
1225 content::{Content, SubstitutionStep},
1226 strings::CowStr,
1227 tests::prelude::*,
1228 };
1229
1230 #[test]
1231 fn empty() {
1232 let mut content = Content::from(crate::Span::default());
1233 let p = Parser::default();
1234 SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1235 assert!(content.is_empty());
1236 assert_eq!(content.rendered, CowStr::Borrowed(""));
1237 }
1238
1239 #[test]
1240 fn basic_non_empty_span() {
1241 let mut content = Content::from(crate::Span::new("blah"));
1242 let p = Parser::default();
1243 SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1244 assert!(!content.is_empty());
1245 assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1246 }
1247
1248 #[test]
1249 fn match_lt_and_gt() {
1250 let mut content = Content::from(crate::Span::new("bl<ah>"));
1251 let p = Parser::default();
1252 SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1253 assert!(!content.is_empty());
1254 assert_eq!(
1255 content.rendered,
1256 CowStr::Boxed("bl<ah>".to_string().into_boxed_str())
1257 );
1258 }
1259
1260 #[test]
1261 fn match_amp() {
1262 let mut content = Content::from(crate::Span::new("bl<a&h>"));
1263 let p = Parser::default();
1264 SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
1265 assert!(!content.is_empty());
1266 assert_eq!(
1267 content.rendered,
1268 CowStr::Boxed("bl<a&h>".to_string().into_boxed_str())
1269 );
1270 }
1271 }
1272
1273 mod quotes {
1274 use crate::{
1275 content::{Content, SubstitutionStep},
1276 strings::CowStr,
1277 tests::prelude::*,
1278 };
1279
1280 #[test]
1281 fn empty() {
1282 let mut content = Content::from(crate::Span::default());
1283 let p = Parser::default();
1284 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1285 assert!(content.is_empty());
1286 assert_eq!(content.rendered, CowStr::Borrowed(""));
1287 }
1288
1289 #[test]
1290 fn basic_non_empty_span() {
1291 let mut content = Content::from(crate::Span::new("blah"));
1292 let p = Parser::default();
1293 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1294 assert!(!content.is_empty());
1295 assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1296 }
1297
1298 #[test]
1299 fn ignore_lt_and_gt() {
1300 let mut content = Content::from(crate::Span::new("bl<ah>"));
1301 let p = Parser::default();
1302 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1303 assert!(!content.is_empty());
1304 assert_eq!(
1305 content.rendered,
1306 CowStr::Boxed("bl<ah>".to_string().into_boxed_str())
1307 );
1308 }
1309
1310 #[test]
1311 fn strong_word() {
1312 let mut content = Content::from(crate::Span::new("One *word* is strong."));
1313 let p = Parser::default();
1314 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1315 assert!(!content.is_empty());
1316 assert_eq!(
1317 content.rendered,
1318 CowStr::Boxed(
1319 "One <strong>word</strong> is strong."
1320 .to_string()
1321 .into_boxed_str()
1322 )
1323 );
1324 }
1325
1326 #[test]
1327 fn marked_string_with_id() {
1328 let mut content = Content::from(crate::Span::new(r#"[#id]#a few words#"#));
1329 let p = Parser::default();
1330 SubstitutionStep::Quotes.apply(&mut content, &p, None);
1331 assert!(!content.is_empty());
1332 assert_eq!(
1333 content.rendered,
1334 CowStr::Boxed(r#"<span id="id">a few words</span>"#.to_string().into_boxed_str())
1335 );
1336 }
1337
1338 #[test]
1339 fn unconstrained_marked_string_with_id_is_registered() {
1340 let doc = Parser::default().parse(r#"[#the_id]##marked text##"#);
1344
1345 assert_eq!(
1346 doc.nested_blocks()
1347 .next()
1348 .unwrap()
1349 .rendered_content()
1350 .unwrap(),
1351 r#"<span id="the_id">marked text</span>"#
1352 );
1353
1354 assert!(doc.catalog().contains_id("the_id"));
1355 }
1356 }
1357
1358 mod attribute_references {
1359 use crate::{
1360 content::{Content, SubstitutionStep},
1361 strings::CowStr,
1362 tests::prelude::*,
1363 };
1364
1365 #[test]
1366 fn empty() {
1367 let mut content = Content::from(crate::Span::default());
1368 let p = Parser::default();
1369 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1370 assert!(content.is_empty());
1371 assert_eq!(content.rendered, CowStr::Borrowed(""));
1372 }
1373
1374 #[test]
1375 fn basic_non_empty_span() {
1376 let mut content = Content::from(crate::Span::new("blah"));
1377 let p = Parser::default();
1378 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1379 assert!(!content.is_empty());
1380 assert_eq!(content.rendered, CowStr::Borrowed("blah"));
1381 }
1382
1383 #[test]
1384 fn ignore_non_match() {
1385 let mut content = Content::from(crate::Span::new("bl{ah}"));
1386 let p = Parser::default();
1387 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1388 assert!(!content.is_empty());
1389 assert_eq!(
1390 content.rendered,
1391 CowStr::Boxed("bl{ah}".to_string().into_boxed_str())
1392 );
1393 }
1394
1395 #[test]
1396 fn ignore_escaped_non_match() {
1397 let mut content = Content::from(crate::Span::new("bl\\{ah}"));
1398 let p = Parser::default();
1399 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1400 assert!(!content.is_empty());
1401 assert_eq!(
1402 content.rendered,
1403 CowStr::Boxed("bl\\{ah}".to_string().into_boxed_str())
1404 );
1405 }
1406
1407 #[test]
1408 fn replace_sp_match() {
1409 let mut content = Content::from(crate::Span::new("bl{sp}ah"));
1410 let p = Parser::default();
1411 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1412 assert!(!content.is_empty());
1413 assert_eq!(
1414 content.rendered,
1415 CowStr::Boxed("bl ah".to_string().into_boxed_str())
1416 );
1417 }
1418
1419 #[test]
1420 fn ignore_escaped_sp_match() {
1421 let mut content = Content::from(crate::Span::new("bl\\{sp}ah"));
1422 let p = Parser::default();
1423 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1424 assert!(!content.is_empty());
1425 assert_eq!(
1426 content.rendered,
1427 CowStr::Boxed("bl{sp}ah".to_string().into_boxed_str())
1428 );
1429 }
1430
1431 #[test]
1432 fn counter_directive_displays_and_advances() {
1433 let mut content = Content::from(crate::Span::new("{counter:n}-{counter:n}"));
1434 let p = Parser::default();
1435 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1436 assert_eq!(
1437 content.rendered,
1438 CowStr::Boxed("1-2".to_string().into_boxed_str())
1439 );
1440 }
1441
1442 #[test]
1443 fn counter2_directive_advances_silently() {
1444 let mut content = Content::from(crate::Span::new("{counter2:n}{counter:n}"));
1445 let p = Parser::default();
1446 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1447 assert_eq!(
1448 content.rendered,
1449 CowStr::Boxed("2".to_string().into_boxed_str())
1450 );
1451 }
1452
1453 #[test]
1454 fn escaped_counter_directive_is_literal_and_does_not_advance() {
1455 let mut content = Content::from(crate::Span::new("\\{counter:n} {counter:n}"));
1456 let p = Parser::default();
1457 SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
1458 assert_eq!(
1459 content.rendered,
1460 CowStr::Boxed("{counter:n} 1".to_string().into_boxed_str())
1461 );
1462 }
1463
1464 mod attribute_missing {
1465 #![allow(clippy::indexing_slicing)]
1466
1467 use crate::{
1468 content::{Content, SubstitutionStep},
1469 parser::ModificationContext,
1470 tests::prelude::*,
1471 warnings::WarningType,
1472 };
1473
1474 fn parser_with_mode(mode: &str) -> Parser {
1475 Parser::default().with_intrinsic_attribute(
1476 "attribute-missing",
1477 mode,
1478 ModificationContext::Anywhere,
1479 )
1480 }
1481
1482 fn render(text: &str, parser: &Parser) -> String {
1483 let mut content = Content::from(crate::Span::new(text));
1484 SubstitutionStep::AttributeReferences.apply(&mut content, parser, None);
1485 content.rendered.to_string()
1486 }
1487
1488 #[test]
1489 fn skip_is_default() {
1490 let p = Parser::default();
1491 assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1492 assert!(p.take_substitution_warnings().is_empty());
1493 }
1494
1495 #[test]
1496 fn skip_explicit() {
1497 let p = parser_with_mode("skip");
1498 assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1499 }
1500
1501 #[test]
1502 fn unknown_value_falls_back_to_skip() {
1503 let p = parser_with_mode("bogus");
1504 assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1505 }
1506
1507 #[test]
1508 fn drop_removes_only_the_reference() {
1509 let p = parser_with_mode("drop");
1510 assert_eq!(render("Hello, {name}!", &p), "Hello, !");
1511 }
1512
1513 #[test]
1514 fn drop_keeps_resolvable_references() {
1515 let p = parser_with_mode("drop");
1516 assert_eq!(render("a {sp}b {missing} c", &p), "a b c");
1517 }
1518
1519 #[test]
1520 fn drop_line_removes_the_whole_line() {
1521 let p = parser_with_mode("drop-line");
1522 assert_eq!(render("Hello, {name}!\nSecond line.", &p), "Second line.");
1523 }
1524
1525 #[test]
1526 fn drop_line_only_drops_lines_with_a_missing_reference() {
1527 let p = parser_with_mode("drop-line");
1528 assert_eq!(
1529 render("first {sp}line\nsecond {missing} line\nthird line", &p),
1530 "first line\nthird line"
1531 );
1532 }
1533
1534 #[test]
1535 fn drop_line_can_empty_the_content() {
1536 let p = parser_with_mode("drop-line");
1537 assert_eq!(render("{missing}", &p), "");
1538 }
1539
1540 #[test]
1541 fn warn_leaves_the_reference_and_records_a_warning() {
1542 let p = parser_with_mode("warn");
1543 assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
1544
1545 let warnings = p.take_substitution_warnings();
1546 assert_eq!(warnings.len(), 1);
1547 assert_eq!(
1548 warnings[0].warning,
1549 WarningType::SkippingReferenceToMissingAttribute("name".to_string())
1550 );
1551 }
1552
1553 #[test]
1554 fn warn_records_one_warning_per_missing_reference() {
1555 let p = parser_with_mode("warn");
1556 assert_eq!(render("a {x} b {y} c", &p), "a {x} b {y} c");
1557 assert_eq!(p.take_substitution_warnings().len(), 2);
1558 }
1559
1560 #[test]
1561 fn escaped_missing_reference_is_left_verbatim_and_never_dropped() {
1562 let p = parser_with_mode("drop-line");
1563 assert_eq!(
1564 render("In the path /items/\\{id}, x.", &p),
1565 "In the path /items/\\{id}, x."
1566 );
1567 assert!(p.take_substitution_warnings().is_empty());
1568 }
1569 }
1570 }
1571
1572 mod callouts {
1573 use crate::{
1574 content::{Content, SubstitutionStep},
1575 parser::ModificationContext,
1576 strings::CowStr,
1577 tests::prelude::*,
1578 };
1579
1580 fn render_callouts(text: &str, parser: &Parser) -> String {
1584 let mut content = Content::from(crate::Span::new(text));
1585 SubstitutionStep::Callouts.apply(&mut content, parser, None);
1588 content.rendered.to_string()
1589 }
1590
1591 #[test]
1592 fn empty() {
1593 let mut content = Content::from(crate::Span::default());
1594 let p = Parser::default();
1595 SubstitutionStep::Callouts.apply(&mut content, &p, None);
1596 assert!(content.is_empty());
1597 assert_eq!(content.rendered, CowStr::Borrowed(""));
1598 }
1599
1600 #[test]
1601 fn no_callouts() {
1602 let p = Parser::default();
1603 assert_eq!(render_callouts("just some text", &p), "just some text");
1604 }
1605
1606 #[test]
1607 fn lt_without_callout_is_untouched() {
1608 let p = Parser::default();
1609 assert_eq!(render_callouts("a <b> c", &p), "a <b> c");
1610 }
1611
1612 #[test]
1613 fn basic_explicit() {
1614 let p = Parser::default();
1615 assert_eq!(
1616 render_callouts("require 'x' <1>", &p),
1617 r#"require 'x' <b class="conum">(1)</b>"#
1618 );
1619 }
1620
1621 #[test]
1622 fn line_comment_prefix_preserved() {
1623 let p = Parser::default();
1624 assert_eq!(
1625 render_callouts("puts 'x' # <1>", &p),
1626 r#"puts 'x' # <b class="conum">(1)</b>"#
1627 );
1628 }
1629
1630 #[test]
1631 fn multiple_on_one_line() {
1632 let p = Parser::default();
1633 assert_eq!(
1634 render_callouts("puts x <5><6>", &p),
1635 r#"puts x <b class="conum">(5)</b><b class="conum">(6)</b>"#
1636 );
1637 }
1638
1639 #[test]
1640 fn not_at_end_of_line() {
1641 let p = Parser::default();
1642 assert_eq!(
1643 render_callouts("puts \"<1> in the middle\"", &p),
1644 "puts \"<1> in the middle\""
1645 );
1646 }
1647
1648 #[test]
1649 fn auto_numbering() {
1650 let p = Parser::default();
1651 assert_eq!(
1652 render_callouts("a <.>\nb <.>\nc <.>", &p),
1653 "a <b class=\"conum\">(1)</b>\nb <b class=\"conum\">(2)</b>\nc <b class=\"conum\">(3)</b>"
1654 );
1655 }
1656
1657 #[test]
1658 fn mixed_numbering_ignores_explicit() {
1659 let p = Parser::default();
1661 assert_eq!(
1662 render_callouts("a <.>\nb <1>\nc <.>", &p),
1663 "a <b class=\"conum\">(1)</b>\nb <b class=\"conum\">(1)</b>\nc <b class=\"conum\">(2)</b>"
1664 );
1665 }
1666
1667 #[test]
1668 fn xml_callout() {
1669 let p = Parser::default();
1670 assert_eq!(
1671 render_callouts("<child/> <!--1-->", &p),
1672 r#"<child/> <!--<b class="conum">(1)</b>-->"#
1673 );
1674 }
1675
1676 #[test]
1677 fn half_xml_comment_is_not_a_callout() {
1678 let p = Parser::default();
1679 assert_eq!(
1680 render_callouts("First line <1-->", &p),
1681 "First line <1-->"
1682 );
1683 }
1684
1685 #[test]
1686 fn escaped_callout() {
1687 let p = Parser::default();
1688 assert_eq!(
1689 render_callouts("require 'x' # \\<1>", &p),
1690 "require 'x' # <1>"
1691 );
1692 }
1693
1694 #[test]
1695 fn icons_font() {
1696 let p = Parser::default().with_intrinsic_attribute(
1697 "icons",
1698 "font",
1699 ModificationContext::Anywhere,
1700 );
1701 assert_eq!(
1702 render_callouts("puts x # <1>", &p),
1703 r#"puts x <i class="conum" data-value="1"></i><b>(1)</b>"#
1704 );
1705 }
1706
1707 #[test]
1708 fn icons_image() {
1709 let p = Parser::default().with_intrinsic_attribute(
1710 "icons",
1711 "",
1712 ModificationContext::Anywhere,
1713 );
1714 assert_eq!(
1715 render_callouts("puts x <1>", &p),
1716 r#"puts x <img src="./images/icons/callouts/1.png" alt="1">"#
1717 );
1718 }
1719
1720 #[test]
1721 fn custom_line_comment_prefix() {
1722 let mut content = Content::from(crate::Span::new("hello() -> % <1>"));
1724 let attrlist = crate::attributes::Attrlist::parse(
1725 crate::Span::new("source,erlang,line-comment=%"),
1726 &Parser::default(),
1727 crate::attributes::AttrlistContext::Block,
1728 )
1729 .item
1730 .item;
1731 let p = Parser::default();
1732 SubstitutionStep::Callouts.apply(&mut content, &p, Some(&attrlist));
1733 assert_eq!(
1734 content.rendered.to_string(),
1735 r#"hello() -> % <b class="conum">(1)</b>"#
1736 );
1737 }
1738
1739 #[test]
1740 fn disabled_line_comment_preserves_leading_chars() {
1741 let mut content = Content::from(crate::Span::new("-- <1>"));
1744 let attrlist = crate::attributes::Attrlist::parse(
1745 crate::Span::new("source,asciidoc,line-comment="),
1746 &Parser::default(),
1747 crate::attributes::AttrlistContext::Block,
1748 )
1749 .item
1750 .item;
1751 let p = Parser::default();
1752 SubstitutionStep::Callouts.apply(&mut content, &p, Some(&attrlist));
1753 assert_eq!(
1754 content.rendered.to_string(),
1755 r#"-- <b class="conum">(1)</b>"#
1756 );
1757 }
1758
1759 #[test]
1760 fn document_line_comment_attribute() {
1761 let p = Parser::default().with_intrinsic_attribute(
1764 "line-comment",
1765 "%",
1766 ModificationContext::Anywhere,
1767 );
1768 assert_eq!(
1769 render_callouts("hello() -> % <1>", &p),
1770 r#"hello() -> % <b class="conum">(1)</b>"#
1771 );
1772 }
1773 }
1774}