use std::{borrow::Cow, sync::LazyLock};
use regex::{Captures, Regex, RegexBuilder, Replacer};
use crate::{
Parser, Span,
attributes::{Attrlist, AttrlistContext},
content::Content,
document::{InterpretedValue, RefType},
internal::{LookaheadReplacer, LookaheadResult, replace_with_lookahead},
parser::{
CalloutGuard, CalloutRenderParams, CharacterReplacementType, InlineSubstitutionRenderer,
QuoteScope, QuoteType, SpecialCharacter,
},
strings::CowStr,
warnings::WarningType,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SubstitutionStep {
SpecialCharacters,
Quotes,
AttributeReferences,
CharacterReplacements,
Macros,
PostReplacement,
Callouts,
}
impl SubstitutionStep {
pub(crate) fn apply(
&self,
content: &mut Content<'_>,
parser: &Parser,
attrlist: Option<&Attrlist<'_>>,
) {
match self {
Self::SpecialCharacters => {
apply_special_characters(content, &*parser.renderer);
}
Self::Quotes => {
apply_quotes(content, parser);
}
Self::AttributeReferences => {
apply_attributes(content, parser);
}
Self::CharacterReplacements => {
apply_character_replacements(content, &*parser.renderer);
}
Self::Macros => {
super::macros::apply_macros(content, parser);
}
Self::PostReplacement => {
apply_post_replacements(content, parser, attrlist);
}
Self::Callouts => {
apply_callouts(content, parser, attrlist);
}
}
}
}
fn apply_special_characters(content: &mut Content<'_>, renderer: &dyn InlineSubstitutionRenderer) {
if !content.rendered.contains(['<', '>', '&']) {
return;
}
let mut result: Cow<'_, str> = content.rendered.to_string().into();
let replacer = SpecialCharacterReplacer { renderer };
if let Cow::Owned(new_result) = SPECIAL_CHARS.replace_all(&result, replacer) {
result = new_result.into();
}
content.rendered = result.into();
}
static SPECIAL_CHARS: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new("[<>&]").unwrap()
});
#[derive(Debug)]
struct SpecialCharacterReplacer<'r> {
renderer: &'r dyn InlineSubstitutionRenderer,
}
impl Replacer for SpecialCharacterReplacer<'_> {
fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
let ch = &caps[0];
if ch == "<" {
self.renderer
.render_special_character(SpecialCharacter::Lt, dest);
} else if ch == ">" {
self.renderer
.render_special_character(SpecialCharacter::Gt, dest);
} else if ch == "&" {
self.renderer
.render_special_character(SpecialCharacter::Ampersand, dest);
}
}
}
static QUOTED_TEXT_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new("[*_`#^~]").unwrap()
});
struct QuoteSub {
type_: QuoteType,
scope: QuoteScope,
pattern: Regex,
}
static QUOTE_SUBS: LazyLock<Vec<QuoteSub>> = LazyLock::new(|| {
vec![
QuoteSub {
type_: QuoteType::Strong,
scope: QuoteScope::Unconstrained,
#[allow(clippy::unwrap_used)]
pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?\*\*(.+?)\*\*"#)
.dot_matches_new_line(true)
.build()
.unwrap(),
},
QuoteSub {
type_: QuoteType::Strong,
scope: QuoteScope::Constrained,
#[allow(clippy::unwrap_used)]
pattern: RegexBuilder::new(
r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?\*(\S|\S.*?\S)\*\b{end-half}"#,
)
.dot_matches_new_line(true)
.build()
.unwrap(),
},
QuoteSub {
type_: QuoteType::DoubleQuote,
scope: QuoteScope::Constrained,
#[allow(clippy::unwrap_used)]
pattern: RegexBuilder::new(
r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?"`(\S|\S.*?\S)`"\b{end-half}"#,
)
.dot_matches_new_line(true)
.build()
.unwrap(),
},
QuoteSub {
type_: QuoteType::SingleQuote,
scope: QuoteScope::Constrained,
#[allow(clippy::unwrap_used)]
pattern: RegexBuilder::new(
r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?'`(\S|\S.*?\S)`'\b{end-half}"#,
)
.dot_matches_new_line(true)
.build()
.unwrap(),
},
QuoteSub {
type_: QuoteType::Monospaced,
scope: QuoteScope::Unconstrained,
#[allow(clippy::unwrap_used)]
pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?``(.+?)``"#)
.dot_matches_new_line(true)
.build()
.unwrap(),
},
QuoteSub {
type_: QuoteType::Monospaced,
scope: QuoteScope::Constrained,
#[allow(clippy::unwrap_used)]
pattern: RegexBuilder::new(
r#"(^|[^\w&;:"'`}])(?:\[([^\[\]]+)\])?`(\S|\S.*?\S)`\b{end-half}"#,
)
.dot_matches_new_line(true)
.build()
.unwrap(),
},
QuoteSub {
type_: QuoteType::Emphasis,
scope: QuoteScope::Unconstrained,
#[allow(clippy::unwrap_used)]
pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?__(.+?)__"#)
.dot_matches_new_line(true)
.build()
.unwrap(),
},
QuoteSub {
type_: QuoteType::Emphasis,
scope: QuoteScope::Constrained,
#[allow(clippy::unwrap_used)]
pattern: RegexBuilder::new(
r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?_(\S|\S.*?\S)_\b{end-half}"#,
)
.dot_matches_new_line(true)
.build()
.unwrap(),
},
QuoteSub {
type_: QuoteType::Mark,
scope: QuoteScope::Unconstrained,
#[allow(clippy::unwrap_used)]
pattern: RegexBuilder::new(r#"\\?(?:\[([^\[\]]+)\])?##(.+?)##"#)
.dot_matches_new_line(true)
.build()
.unwrap(),
},
QuoteSub {
type_: QuoteType::Mark,
scope: QuoteScope::Constrained,
#[allow(clippy::unwrap_used)]
pattern: RegexBuilder::new(
r#"(^|[^\w&;:}])(?:\[([^\[\]]+)\])?#(\S|\S.*?\S)#\b{end-half}"#,
)
.dot_matches_new_line(true)
.build()
.unwrap(),
},
QuoteSub {
type_: QuoteType::Superscript,
scope: QuoteScope::Unconstrained,
#[allow(clippy::unwrap_used)]
pattern: Regex::new(r#"\\?(?:\[([^\[\]]+)\])?\^(\S+?)\^"#).unwrap(),
},
QuoteSub {
type_: QuoteType::Subscript,
scope: QuoteScope::Unconstrained,
#[allow(clippy::unwrap_used)]
pattern: Regex::new(r#"\\?(?:\[([^\[\]]+)\])?~(\S+?)~"#).unwrap(),
},
]
});
#[derive(Debug)]
struct QuoteReplacer<'r> {
type_: QuoteType,
scope: QuoteScope,
parser: &'r Parser,
}
impl LookaheadReplacer for QuoteReplacer<'_> {
fn replace_append(
&mut self,
caps: &Captures<'_>,
dest: &mut String,
after: &str,
) -> LookaheadResult {
if self.type_ == QuoteType::Monospaced
&& self.scope == QuoteScope::Constrained
&& after.starts_with(['"', '\'', '`'])
{
let skip_ahead = if caps[0].starts_with('\\') { 2 } else { 1 };
dest.push_str(&caps[0][0..skip_ahead]);
return LookaheadResult::SkipAheadAndRetry(skip_ahead);
}
let unescaped_attrs: Option<String> = if caps[0].starts_with('\\') {
let maybe_attrs = caps.get(2).map(|a| a.as_str());
if self.scope == QuoteScope::Constrained && maybe_attrs.is_some() {
Some(format!(
"[{attrs}]",
attrs = maybe_attrs.unwrap_or_default()
))
} else {
dest.push_str(&caps[0][1..]);
return LookaheadResult::Continue;
}
} else {
None
};
match self.scope {
QuoteScope::Constrained => {
if let Some(attrs) = unescaped_attrs {
dest.push_str(&attrs);
self.parser.renderer.render_quoted_substitition(
self.type_, self.scope, None, None, &caps[3], dest,
);
} else {
let (attrlist, type_): (Option<Attrlist<'_>>, QuoteType) =
if let Some(attrlist) = caps.get(2) {
let type_ = if self.type_ == QuoteType::Mark {
QuoteType::Unquoted
} else {
self.type_
};
(
Some(
Attrlist::parse(
crate::Span::new(attrlist.as_str()),
self.parser,
AttrlistContext::Inline,
)
.item
.item,
),
type_,
)
} else {
(None, self.type_)
};
if let Some(prefix) = caps.get(1) {
dest.push_str(prefix.as_str());
}
let id = attrlist
.as_ref()
.and_then(|a| a.id().map(|s| s.to_string()));
if let Some(id) = &id {
let _ = self.parser.register_ref(id, None, RefType::Anchor);
}
self.parser.renderer.render_quoted_substitition(
type_, self.scope, attrlist, id, &caps[3], dest,
);
}
}
QuoteScope::Unconstrained => {
let (attrlist, type_): (Option<Attrlist<'_>>, QuoteType) =
if let Some(attrlist) = caps.get(1) {
let type_ = if self.type_ == QuoteType::Mark {
QuoteType::Unquoted
} else {
self.type_
};
(
Some(
Attrlist::parse(
crate::Span::new(attrlist.as_str()),
self.parser,
AttrlistContext::Inline,
)
.item
.item,
),
type_,
)
} else {
(None, self.type_)
};
let id = attrlist
.as_ref()
.and_then(|a| a.id().map(|s| s.to_string()));
if let Some(id) = &id {
let _ = self.parser.register_ref(id, None, RefType::Anchor);
}
self.parser
.renderer
.render_quoted_substitition(type_, self.scope, attrlist, id, &caps[2], dest);
}
}
LookaheadResult::Continue
}
}
fn apply_quotes(content: &mut Content<'_>, parser: &Parser) {
if !QUOTED_TEXT_SNIFF.is_match(content.rendered.as_ref()) {
return;
}
let mut result: Cow<'_, str> = content.rendered.to_string().into();
for sub in &*QUOTE_SUBS {
let replacer = QuoteReplacer {
type_: sub.type_,
scope: sub.scope,
parser,
};
if let Cow::Owned(new_result) = replace_with_lookahead(&sub.pattern, &result, replacer) {
result = new_result.into();
}
}
content.rendered = result.into();
}
static ATTRIBUTE_REFERENCE: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(r#"\\?\{(?:(counter2?):([^{}]+)|([A-Za-z0-9_][A-Za-z0-9_-]*))\}"#).unwrap()
});
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum AttributeMissing {
Skip,
Drop,
DropLine,
Warn,
}
impl AttributeMissing {
fn from_parser(parser: &Parser) -> Self {
match parser.attribute_value("attribute-missing").as_maybe_str() {
Some("drop") => Self::Drop,
Some("drop-line") => Self::DropLine,
Some("warn") => Self::Warn,
_ => Self::Skip,
}
}
}
#[derive(Debug)]
struct AttributeReplacer<'p> {
parser: &'p Parser,
mode: AttributeMissing,
source: Span<'p>,
missing_on_line: bool,
}
impl Replacer for AttributeReplacer<'_> {
fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
let escaped = caps[0].starts_with('\\');
if let Some(directive) = caps.get(1) {
if escaped {
dest.push_str(&caps[0][1..]);
return;
}
let mut parts = caps[2].splitn(2, ':');
let name = parts.next().unwrap_or_default();
let seed = parts.next();
let value = self.parser.counter(name, seed);
if directive.as_str() == "counter" {
dest.push_str(&value);
}
return;
}
let attr_name = &caps[3];
if !self.parser.has_attribute(attr_name) {
if escaped {
dest.push_str(&caps[0]);
return;
}
match self.mode {
AttributeMissing::Skip => dest.push_str(&caps[0]),
AttributeMissing::Drop => {
}
AttributeMissing::DropLine => {
self.missing_on_line = true;
}
AttributeMissing::Warn => {
dest.push_str(&caps[0]);
self.parser.record_substitution_warning(
self.source,
WarningType::SkippingReferenceToMissingAttribute(attr_name.to_string()),
);
}
}
return;
}
if escaped {
dest.push_str(&caps[0][1..]);
return;
}
if let InterpretedValue::Value(value) = self.parser.attribute_value(attr_name) {
dest.push_str(value.as_ref());
}
}
}
fn apply_attributes(content: &mut Content<'_>, parser: &Parser) {
if !content.rendered.contains('{') {
return;
}
let mode = AttributeMissing::from_parser(parser);
let source = content.original();
let mut out = String::with_capacity(content.rendered.len());
let mut changed = false;
let mut wrote_line = false;
for line in content.rendered.split('\n') {
if !line.contains('{') {
if wrote_line {
out.push('\n');
}
out.push_str(line);
wrote_line = true;
continue;
}
let mut replacer = AttributeReplacer {
parser,
mode,
source,
missing_on_line: false,
};
let replaced = ATTRIBUTE_REFERENCE.replace_all(line, replacer.by_ref());
if replacer.missing_on_line && mode == AttributeMissing::DropLine {
changed = true;
continue;
}
if let Cow::Owned(_) = replaced {
changed = true;
}
if wrote_line {
out.push('\n');
}
out.push_str(&replaced);
wrote_line = true;
}
if changed {
content.rendered = out.into();
}
}
pub(crate) fn substitute_attributes_in_macro_target<'src>(
target: Span<'src>,
parser: &Parser,
) -> Option<CowStr<'src>> {
let text = target.data();
if !text.contains('{') {
return Some(text.into());
}
let mode = AttributeMissing::from_parser(parser);
let mut replacer = AttributeReplacer {
parser,
mode,
source: target,
missing_on_line: false,
};
let replaced = ATTRIBUTE_REFERENCE.replace_all(text, replacer.by_ref());
if replacer.missing_on_line && mode == AttributeMissing::DropLine {
return None;
}
Some(replaced.into())
}
pub(crate) fn substitute_attributes_in_text(text: &str, parser: &Parser) -> String {
if !text.contains('{') {
return text.to_string();
}
let mode = AttributeMissing::from_parser(parser);
let source = Span::new(text);
let mut out = String::with_capacity(text.len());
let mut wrote_line = false;
for line in text.split('\n') {
if !line.contains('{') {
if wrote_line {
out.push('\n');
}
out.push_str(line);
wrote_line = true;
continue;
}
let mut replacer = AttributeReplacer {
parser,
mode,
source,
missing_on_line: false,
};
let replaced = ATTRIBUTE_REFERENCE.replace_all(line, replacer.by_ref());
if replacer.missing_on_line && mode == AttributeMissing::DropLine {
continue;
}
if wrote_line {
out.push('\n');
}
out.push_str(&replaced);
wrote_line = true;
}
out
}
fn apply_character_replacements(
content: &mut Content<'_>,
renderer: &dyn InlineSubstitutionRenderer,
) {
if !REPLACEABLE_TEXT_SNIFF.is_match(content.rendered.as_ref()) {
return;
}
let mut result: Cow<'_, str> = content.rendered.to_string().into();
for repl in &*REPLACEMENTS {
let replacer = CharacterReplacer {
type_: repl.type_.clone(),
renderer,
};
if let Cow::Owned(new_result) = repl.pattern.replace_all(&result, replacer) {
result = new_result.into();
}
}
content.rendered = result.into();
}
struct CharacterReplacement {
type_: CharacterReplacementType,
pattern: Regex,
}
static REPLACEABLE_TEXT_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(r#"[&']|--|\.\.\.|\([CRT]M?\)"#).unwrap()
});
static REPLACEMENTS: LazyLock<Vec<CharacterReplacement>> = LazyLock::new(|| {
vec![
CharacterReplacement {
type_: CharacterReplacementType::Copyright,
#[allow(clippy::unwrap_used)]
pattern: Regex::new(r#"\\?\(C\)"#).unwrap(),
},
CharacterReplacement {
type_: CharacterReplacementType::Registered,
#[allow(clippy::unwrap_used)]
pattern: Regex::new(r#"\\?\(R\)"#).unwrap(),
},
CharacterReplacement {
type_: CharacterReplacementType::Trademark,
#[allow(clippy::unwrap_used)]
pattern: Regex::new(r#"\\?\(TM\)"#).unwrap(),
},
CharacterReplacement {
type_: CharacterReplacementType::EmDashSurroundedBySpaces,
#[allow(clippy::unwrap_used)]
pattern: Regex::new(r#"(?: |\n|^|\\)--(?: |\n|$)"#).unwrap(),
},
CharacterReplacement {
type_: CharacterReplacementType::EmDashWithoutSpace,
#[allow(clippy::unwrap_used)]
pattern: Regex::new(r#"(\w)\\?--\b{start-half}"#).unwrap(),
},
CharacterReplacement {
type_: CharacterReplacementType::Ellipsis,
#[allow(clippy::unwrap_used)]
pattern: Regex::new(r#"\\?\.\.\."#).unwrap(),
},
CharacterReplacement {
type_: CharacterReplacementType::TypographicApostrophe,
#[allow(clippy::unwrap_used)]
pattern: Regex::new(r#"\\?`'"#).unwrap(),
},
CharacterReplacement {
type_: CharacterReplacementType::TypographicApostrophe,
#[allow(clippy::unwrap_used)]
pattern: Regex::new(r#"([[:alnum:]])\\?'([[:alpha:]])"#).unwrap(),
},
CharacterReplacement {
type_: CharacterReplacementType::SingleRightArrow,
#[allow(clippy::unwrap_used)]
pattern: Regex::new(r#"\\?->"#).unwrap(),
},
CharacterReplacement {
type_: CharacterReplacementType::DoubleRightArrow,
#[allow(clippy::unwrap_used)]
pattern: Regex::new(r#"\\?=>"#).unwrap(),
},
CharacterReplacement {
type_: CharacterReplacementType::SingleLeftArrow,
#[allow(clippy::unwrap_used)]
pattern: Regex::new(r#"\\?<-"#).unwrap(),
},
CharacterReplacement {
type_: CharacterReplacementType::DoubleLeftArrow,
#[allow(clippy::unwrap_used)]
pattern: Regex::new(r#"\\?<="#).unwrap(),
},
CharacterReplacement {
type_: CharacterReplacementType::CharacterReference("".to_owned()),
#[allow(clippy::unwrap_used)]
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(),
},
]
});
#[derive(Debug)]
struct CharacterReplacer<'r> {
type_: CharacterReplacementType,
renderer: &'r dyn InlineSubstitutionRenderer,
}
impl Replacer for CharacterReplacer<'_> {
fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
if caps[0].contains('\\') {
let unescaped = &caps[0].replace("\\", "");
dest.push_str(unescaped);
return;
}
match self.type_ {
CharacterReplacementType::Copyright
| CharacterReplacementType::Registered
| CharacterReplacementType::Trademark
| CharacterReplacementType::EmDashSurroundedBySpaces
| CharacterReplacementType::Ellipsis
| CharacterReplacementType::SingleLeftArrow
| CharacterReplacementType::DoubleLeftArrow
| CharacterReplacementType::SingleRightArrow
| CharacterReplacementType::DoubleRightArrow => {
self.renderer
.render_character_replacement(self.type_.clone(), dest);
}
CharacterReplacementType::EmDashWithoutSpace => {
dest.push_str(&caps[1]);
self.renderer.render_character_replacement(
CharacterReplacementType::EmDashWithoutSpace,
dest,
);
}
CharacterReplacementType::TypographicApostrophe => {
if let Some(before) = caps.get(1) {
dest.push_str(before.as_str());
}
self.renderer.render_character_replacement(
CharacterReplacementType::TypographicApostrophe,
dest,
);
if let Some(after) = caps.get(2) {
dest.push_str(after.as_str());
}
}
CharacterReplacementType::CharacterReference(_) => {
self.renderer.render_character_replacement(
CharacterReplacementType::CharacterReference(caps[1].to_string()),
dest,
);
}
}
}
}
fn apply_post_replacements(
content: &mut Content<'_>,
parser: &Parser,
attrlist: Option<&Attrlist<'_>>,
) {
if parser.is_attribute_set("hardbreaks-option")
|| attrlist.is_some_and(|attrlist| attrlist.has_option("hardbreaks"))
{
let text = content.rendered.as_ref();
if !text.contains('\n') {
return;
}
let mut lines: Vec<&str> = content.rendered.as_ref().lines().collect();
let last = lines.pop().unwrap_or_default();
let mut lines: Vec<String> = lines
.iter()
.map(|line| {
let line = if line.ends_with(" +") {
&line[0..line.len() - 2]
} else {
*line
};
let mut line = line.to_owned();
parser.renderer.render_line_break(&mut line);
line
})
.collect();
lines.push(last.to_owned());
let new_result = lines.join("\n");
content.rendered = new_result.into();
} else {
let rendered = content.rendered.as_ref();
if !(rendered.contains('+') && rendered.contains('\n')) {
return;
}
let replacer = PostReplacementReplacer(&*parser.renderer);
if let Cow::Owned(new_result) = HARD_LINE_BREAK.replace_all(rendered, replacer) {
content.rendered = new_result.into();
}
}
}
#[derive(Debug)]
struct PostReplacementReplacer<'r>(&'r dyn InlineSubstitutionRenderer);
impl Replacer for PostReplacementReplacer<'_> {
fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
dest.push_str(&caps[1]);
self.0.render_line_break(dest);
}
}
static HARD_LINE_BREAK: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(r#"(?m)^(.*) \+$"#).unwrap()
});
fn apply_callouts(content: &mut Content<'_>, parser: &Parser, attrlist: Option<&Attrlist<'_>>) {
if !content.rendered.contains("<") {
return;
}
let line_comment: Option<String> = attrlist
.and_then(|a| a.named_attribute("line-comment"))
.map(|a| a.value().to_string())
.or_else(|| {
if parser.has_attribute("line-comment") {
Some(
parser
.attribute_value("line-comment")
.as_maybe_str()
.unwrap_or("")
.to_string(),
)
} else {
None
}
});
let (callout_rx, tail_rx) = build_callout_regexes(line_comment.as_deref());
let replacer = CalloutReplacer {
renderer: &*parser.renderer,
parser,
autonum: 0,
tail: tail_rx,
};
if let Cow::Owned(new_result) =
replace_with_lookahead(&callout_rx, content.rendered.as_ref(), replacer)
{
content.rendered = new_result.into();
}
}
static DEFAULT_CALLOUT_RX: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(
r"(?P<prefix>(?://|#|--|;;) ?)?(?P<esc>\\)?(?:<!--(?P<xnum>\d+|\.)-->|<(?P<num>\d+|\.)>)",
)
.unwrap()
});
static DEFAULT_CALLOUT_TAIL_RX: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(r"^(?: ?\\?(?:<!--(?:\d+|\.)-->|<(?:\d+|\.)>))*(?:\n|$)").unwrap()
});
static CUSTOM_CALLOUT_TAIL_RX: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(r"^(?: ?\\?<(?:\d+|\.)>)*(?:\n|$)").unwrap()
});
fn build_callout_regexes(line_comment: Option<&str>) -> (Cow<'static, Regex>, &'static Regex) {
match line_comment {
None => (Cow::Borrowed(&DEFAULT_CALLOUT_RX), &DEFAULT_CALLOUT_TAIL_RX),
Some(prefix) => {
let prefix_pattern = if prefix.is_empty() {
String::new()
} else {
format!(r"(?P<prefix>{} ?)?", regex::escape(prefix))
};
#[allow(clippy::unwrap_used)]
let callout = Regex::new(&format!(
r"{prefix_pattern}(?P<esc>\\)?<(?P<num>\d+|\.)>"
))
.unwrap();
(Cow::Owned(callout), &CUSTOM_CALLOUT_TAIL_RX)
}
}
}
struct CalloutReplacer<'r> {
renderer: &'r dyn InlineSubstitutionRenderer,
parser: &'r Parser,
autonum: u32,
tail: &'r Regex,
}
impl LookaheadReplacer for CalloutReplacer<'_> {
fn replace_append(
&mut self,
caps: &Captures<'_>,
dest: &mut String,
after: &str,
) -> LookaheadResult {
if !self.tail.is_match(after) {
dest.push_str(&caps[0]);
return LookaheadResult::Continue;
}
if caps.name("esc").is_some() {
dest.push_str(&caps[0].replacen('\\', "", 1));
return LookaheadResult::Continue;
}
let (number_raw, is_xml) = if let Some(xnum) = caps.name("xnum") {
(xnum.as_str(), true)
} else {
#[allow(clippy::unwrap_used)]
(caps.name("num").unwrap().as_str(), false)
};
let number = if number_raw == "." {
self.autonum += 1;
self.autonum.to_string()
} else {
number_raw.to_string()
};
if let Ok(n) = number.parse::<u32>() {
self.parser.register_callout(n);
}
let guard = match caps.name("prefix") {
Some(prefix) => CalloutGuard::LineComment(prefix.as_str()),
None if is_xml => CalloutGuard::Xml,
None => CalloutGuard::LineComment(""),
};
self.renderer.render_callout(
&CalloutRenderParams {
number: &number,
guard,
parser: self.parser,
},
dest,
);
LookaheadResult::Continue
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
mod special_characters {
use crate::{
content::{Content, SubstitutionStep},
strings::CowStr,
tests::prelude::*,
};
#[test]
fn empty() {
let mut content = Content::from(crate::Span::default());
let p = Parser::default();
SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
assert!(content.is_empty());
assert_eq!(content.rendered, CowStr::Borrowed(""));
}
#[test]
fn basic_non_empty_span() {
let mut content = Content::from(crate::Span::new("blah"));
let p = Parser::default();
SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
assert!(!content.is_empty());
assert_eq!(content.rendered, CowStr::Borrowed("blah"));
}
#[test]
fn match_lt_and_gt() {
let mut content = Content::from(crate::Span::new("bl<ah>"));
let p = Parser::default();
SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
assert!(!content.is_empty());
assert_eq!(
content.rendered,
CowStr::Boxed("bl<ah>".to_string().into_boxed_str())
);
}
#[test]
fn match_amp() {
let mut content = Content::from(crate::Span::new("bl<a&h>"));
let p = Parser::default();
SubstitutionStep::SpecialCharacters.apply(&mut content, &p, None);
assert!(!content.is_empty());
assert_eq!(
content.rendered,
CowStr::Boxed("bl<a&h>".to_string().into_boxed_str())
);
}
}
mod quotes {
use crate::{
content::{Content, SubstitutionStep},
strings::CowStr,
tests::prelude::*,
};
#[test]
fn empty() {
let mut content = Content::from(crate::Span::default());
let p = Parser::default();
SubstitutionStep::Quotes.apply(&mut content, &p, None);
assert!(content.is_empty());
assert_eq!(content.rendered, CowStr::Borrowed(""));
}
#[test]
fn basic_non_empty_span() {
let mut content = Content::from(crate::Span::new("blah"));
let p = Parser::default();
SubstitutionStep::Quotes.apply(&mut content, &p, None);
assert!(!content.is_empty());
assert_eq!(content.rendered, CowStr::Borrowed("blah"));
}
#[test]
fn ignore_lt_and_gt() {
let mut content = Content::from(crate::Span::new("bl<ah>"));
let p = Parser::default();
SubstitutionStep::Quotes.apply(&mut content, &p, None);
assert!(!content.is_empty());
assert_eq!(
content.rendered,
CowStr::Boxed("bl<ah>".to_string().into_boxed_str())
);
}
#[test]
fn strong_word() {
let mut content = Content::from(crate::Span::new("One *word* is strong."));
let p = Parser::default();
SubstitutionStep::Quotes.apply(&mut content, &p, None);
assert!(!content.is_empty());
assert_eq!(
content.rendered,
CowStr::Boxed(
"One <strong>word</strong> is strong."
.to_string()
.into_boxed_str()
)
);
}
#[test]
fn marked_string_with_id() {
let mut content = Content::from(crate::Span::new(r#"[#id]#a few words#"#));
let p = Parser::default();
SubstitutionStep::Quotes.apply(&mut content, &p, None);
assert!(!content.is_empty());
assert_eq!(
content.rendered,
CowStr::Boxed(r#"<span id="id">a few words</span>"#.to_string().into_boxed_str())
);
}
#[test]
fn unconstrained_marked_string_with_id_is_registered() {
let doc = Parser::default().parse(r#"[#the_id]##marked text##"#);
assert_eq!(
doc.nested_blocks()
.next()
.unwrap()
.rendered_content()
.unwrap(),
r#"<span id="the_id">marked text</span>"#
);
assert!(doc.catalog().contains_id("the_id"));
}
}
mod attribute_references {
use crate::{
content::{Content, SubstitutionStep},
strings::CowStr,
tests::prelude::*,
};
#[test]
fn empty() {
let mut content = Content::from(crate::Span::default());
let p = Parser::default();
SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
assert!(content.is_empty());
assert_eq!(content.rendered, CowStr::Borrowed(""));
}
#[test]
fn basic_non_empty_span() {
let mut content = Content::from(crate::Span::new("blah"));
let p = Parser::default();
SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
assert!(!content.is_empty());
assert_eq!(content.rendered, CowStr::Borrowed("blah"));
}
#[test]
fn ignore_non_match() {
let mut content = Content::from(crate::Span::new("bl{ah}"));
let p = Parser::default();
SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
assert!(!content.is_empty());
assert_eq!(
content.rendered,
CowStr::Boxed("bl{ah}".to_string().into_boxed_str())
);
}
#[test]
fn ignore_escaped_non_match() {
let mut content = Content::from(crate::Span::new("bl\\{ah}"));
let p = Parser::default();
SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
assert!(!content.is_empty());
assert_eq!(
content.rendered,
CowStr::Boxed("bl\\{ah}".to_string().into_boxed_str())
);
}
#[test]
fn replace_sp_match() {
let mut content = Content::from(crate::Span::new("bl{sp}ah"));
let p = Parser::default();
SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
assert!(!content.is_empty());
assert_eq!(
content.rendered,
CowStr::Boxed("bl ah".to_string().into_boxed_str())
);
}
#[test]
fn ignore_escaped_sp_match() {
let mut content = Content::from(crate::Span::new("bl\\{sp}ah"));
let p = Parser::default();
SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
assert!(!content.is_empty());
assert_eq!(
content.rendered,
CowStr::Boxed("bl{sp}ah".to_string().into_boxed_str())
);
}
#[test]
fn counter_directive_displays_and_advances() {
let mut content = Content::from(crate::Span::new("{counter:n}-{counter:n}"));
let p = Parser::default();
SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
assert_eq!(
content.rendered,
CowStr::Boxed("1-2".to_string().into_boxed_str())
);
}
#[test]
fn counter2_directive_advances_silently() {
let mut content = Content::from(crate::Span::new("{counter2:n}{counter:n}"));
let p = Parser::default();
SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
assert_eq!(
content.rendered,
CowStr::Boxed("2".to_string().into_boxed_str())
);
}
#[test]
fn escaped_counter_directive_is_literal_and_does_not_advance() {
let mut content = Content::from(crate::Span::new("\\{counter:n} {counter:n}"));
let p = Parser::default();
SubstitutionStep::AttributeReferences.apply(&mut content, &p, None);
assert_eq!(
content.rendered,
CowStr::Boxed("{counter:n} 1".to_string().into_boxed_str())
);
}
mod attribute_missing {
#![allow(clippy::indexing_slicing)]
use crate::{
content::{Content, SubstitutionStep},
parser::ModificationContext,
tests::prelude::*,
warnings::WarningType,
};
fn parser_with_mode(mode: &str) -> Parser {
Parser::default().with_intrinsic_attribute(
"attribute-missing",
mode,
ModificationContext::Anywhere,
)
}
fn render(text: &str, parser: &Parser) -> String {
let mut content = Content::from(crate::Span::new(text));
SubstitutionStep::AttributeReferences.apply(&mut content, parser, None);
content.rendered.to_string()
}
#[test]
fn skip_is_default() {
let p = Parser::default();
assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
assert!(p.take_substitution_warnings().is_empty());
}
#[test]
fn skip_explicit() {
let p = parser_with_mode("skip");
assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
}
#[test]
fn unknown_value_falls_back_to_skip() {
let p = parser_with_mode("bogus");
assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
}
#[test]
fn drop_removes_only_the_reference() {
let p = parser_with_mode("drop");
assert_eq!(render("Hello, {name}!", &p), "Hello, !");
}
#[test]
fn drop_keeps_resolvable_references() {
let p = parser_with_mode("drop");
assert_eq!(render("a {sp}b {missing} c", &p), "a b c");
}
#[test]
fn drop_line_removes_the_whole_line() {
let p = parser_with_mode("drop-line");
assert_eq!(render("Hello, {name}!\nSecond line.", &p), "Second line.");
}
#[test]
fn drop_line_only_drops_lines_with_a_missing_reference() {
let p = parser_with_mode("drop-line");
assert_eq!(
render("first {sp}line\nsecond {missing} line\nthird line", &p),
"first line\nthird line"
);
}
#[test]
fn drop_line_can_empty_the_content() {
let p = parser_with_mode("drop-line");
assert_eq!(render("{missing}", &p), "");
}
#[test]
fn warn_leaves_the_reference_and_records_a_warning() {
let p = parser_with_mode("warn");
assert_eq!(render("Hello, {name}!", &p), "Hello, {name}!");
let warnings = p.take_substitution_warnings();
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0].warning,
WarningType::SkippingReferenceToMissingAttribute("name".to_string())
);
}
#[test]
fn warn_records_one_warning_per_missing_reference() {
let p = parser_with_mode("warn");
assert_eq!(render("a {x} b {y} c", &p), "a {x} b {y} c");
assert_eq!(p.take_substitution_warnings().len(), 2);
}
#[test]
fn escaped_missing_reference_is_left_verbatim_and_never_dropped() {
let p = parser_with_mode("drop-line");
assert_eq!(
render("In the path /items/\\{id}, x.", &p),
"In the path /items/\\{id}, x."
);
assert!(p.take_substitution_warnings().is_empty());
}
}
}
mod callouts {
use crate::{
content::{Content, SubstitutionStep},
parser::ModificationContext,
strings::CowStr,
tests::prelude::*,
};
fn render_callouts(text: &str, parser: &Parser) -> String {
let mut content = Content::from(crate::Span::new(text));
SubstitutionStep::Callouts.apply(&mut content, parser, None);
content.rendered.to_string()
}
#[test]
fn empty() {
let mut content = Content::from(crate::Span::default());
let p = Parser::default();
SubstitutionStep::Callouts.apply(&mut content, &p, None);
assert!(content.is_empty());
assert_eq!(content.rendered, CowStr::Borrowed(""));
}
#[test]
fn no_callouts() {
let p = Parser::default();
assert_eq!(render_callouts("just some text", &p), "just some text");
}
#[test]
fn lt_without_callout_is_untouched() {
let p = Parser::default();
assert_eq!(render_callouts("a <b> c", &p), "a <b> c");
}
#[test]
fn basic_explicit() {
let p = Parser::default();
assert_eq!(
render_callouts("require 'x' <1>", &p),
r#"require 'x' <b class="conum">(1)</b>"#
);
}
#[test]
fn line_comment_prefix_preserved() {
let p = Parser::default();
assert_eq!(
render_callouts("puts 'x' # <1>", &p),
r#"puts 'x' # <b class="conum">(1)</b>"#
);
}
#[test]
fn multiple_on_one_line() {
let p = Parser::default();
assert_eq!(
render_callouts("puts x <5><6>", &p),
r#"puts x <b class="conum">(5)</b><b class="conum">(6)</b>"#
);
}
#[test]
fn not_at_end_of_line() {
let p = Parser::default();
assert_eq!(
render_callouts("puts \"<1> in the middle\"", &p),
"puts \"<1> in the middle\""
);
}
#[test]
fn auto_numbering() {
let p = Parser::default();
assert_eq!(
render_callouts("a <.>\nb <.>\nc <.>", &p),
"a <b class=\"conum\">(1)</b>\nb <b class=\"conum\">(2)</b>\nc <b class=\"conum\">(3)</b>"
);
}
#[test]
fn mixed_numbering_ignores_explicit() {
let p = Parser::default();
assert_eq!(
render_callouts("a <.>\nb <1>\nc <.>", &p),
"a <b class=\"conum\">(1)</b>\nb <b class=\"conum\">(1)</b>\nc <b class=\"conum\">(2)</b>"
);
}
#[test]
fn xml_callout() {
let p = Parser::default();
assert_eq!(
render_callouts("<child/> <!--1-->", &p),
r#"<child/> <!--<b class="conum">(1)</b>-->"#
);
}
#[test]
fn half_xml_comment_is_not_a_callout() {
let p = Parser::default();
assert_eq!(
render_callouts("First line <1-->", &p),
"First line <1-->"
);
}
#[test]
fn escaped_callout() {
let p = Parser::default();
assert_eq!(
render_callouts("require 'x' # \\<1>", &p),
"require 'x' # <1>"
);
}
#[test]
fn icons_font() {
let p = Parser::default().with_intrinsic_attribute(
"icons",
"font",
ModificationContext::Anywhere,
);
assert_eq!(
render_callouts("puts x # <1>", &p),
r#"puts x <i class="conum" data-value="1"></i><b>(1)</b>"#
);
}
#[test]
fn icons_image() {
let p = Parser::default().with_intrinsic_attribute(
"icons",
"",
ModificationContext::Anywhere,
);
assert_eq!(
render_callouts("puts x <1>", &p),
r#"puts x <img src="./images/icons/callouts/1.png" alt="1">"#
);
}
#[test]
fn custom_line_comment_prefix() {
let mut content = Content::from(crate::Span::new("hello() -> % <1>"));
let attrlist = crate::attributes::Attrlist::parse(
crate::Span::new("source,erlang,line-comment=%"),
&Parser::default(),
crate::attributes::AttrlistContext::Block,
)
.item
.item;
let p = Parser::default();
SubstitutionStep::Callouts.apply(&mut content, &p, Some(&attrlist));
assert_eq!(
content.rendered.to_string(),
r#"hello() -> % <b class="conum">(1)</b>"#
);
}
#[test]
fn disabled_line_comment_preserves_leading_chars() {
let mut content = Content::from(crate::Span::new("-- <1>"));
let attrlist = crate::attributes::Attrlist::parse(
crate::Span::new("source,asciidoc,line-comment="),
&Parser::default(),
crate::attributes::AttrlistContext::Block,
)
.item
.item;
let p = Parser::default();
SubstitutionStep::Callouts.apply(&mut content, &p, Some(&attrlist));
assert_eq!(
content.rendered.to_string(),
r#"-- <b class="conum">(1)</b>"#
);
}
#[test]
fn document_line_comment_attribute() {
let p = Parser::default().with_intrinsic_attribute(
"line-comment",
"%",
ModificationContext::Anywhere,
);
assert_eq!(
render_callouts("hello() -> % <1>", &p),
r#"hello() -> % <b class="conum">(1)</b>"#
);
}
}
}