use std::{borrow::Cow, path::Path, sync::LazyLock};
use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
use regex::{Captures, Regex, Replacer};
use crate::{
Parser, Span,
attributes::{Attrlist, AttrlistContext},
content::{Content, content::XrefSegment},
internal::{LookaheadReplacer, LookaheadResult, replace_with_lookahead},
parser::{
FootnoteRenderParams, IconRenderParams, ImageRenderParams, IndexTermRenderParams,
LinkRenderParams, LinkRenderType, MenuRenderParams,
},
warnings::WarningType,
};
pub(super) fn apply_macros(content: &mut Content<'_>, parser: &Parser) {
let text = content.rendered().to_string();
let found_square_bracket = text.contains('[');
let found_colon = text.contains(':');
let found_macroish = found_square_bracket && found_colon;
let found_macroish_short = found_macroish && text.contains(":[");
if found_square_bracket && text.contains("[[[") && parser.in_bibliography_list_item.get() {
let replacer = InlineBiblioAnchorReplacer {
parser,
source: content.original(),
};
if let Cow::Owned(new_result) =
INLINE_BIBLIO_ANCHOR.replace_all(content.rendered(), replacer)
{
content.rendered = new_result.into();
}
}
if parser.is_attribute_set("experimental") {
if found_macroish_short && (text.contains("kbd:") || text.contains("btn:")) {
let replacer = InlineKbdBtnMacroReplacer(parser);
if let Cow::Owned(new_result) =
INLINE_KBD_BTN_MACRO.replace_all(content.rendered(), replacer)
{
content.rendered = new_result.into();
}
}
if found_macroish && text.contains("menu:") {
let replacer = InlineMenuMacroReplacer(parser);
if let Cow::Owned(new_result) =
INLINE_MENU_MACRO.replace_all(content.rendered(), replacer)
{
content.rendered = new_result.into();
}
}
}
if found_macroish && (text.contains("image:") || text.contains("icon:")) {
let replacer = InlineImageMacroReplacer(parser);
if let Cow::Owned(new_result) = INLINE_IMAGE_MACRO.replace_all(content.rendered(), replacer)
{
content.rendered = new_result.into();
}
}
if (text.contains("((") && text.contains("))"))
|| (found_macroish_short && text.contains("dexterm"))
{
let replacer = InlineIndextermReplacer(parser);
if let Cow::Owned(new_result) =
replace_with_lookahead(&INLINE_INDEXTERM, content.rendered(), replacer)
{
content.rendered = new_result.into();
}
}
if found_colon && text.contains("://") {
let replacer = InlineLinkReplacer(parser);
if let Cow::Owned(new_result) = INLINE_LINK.replace_all(content.rendered(), replacer) {
content.rendered = new_result.into();
}
}
if found_macroish && (text.contains("link:") || text.contains("ilto:")) {
let replacer = InlineLinkMacroReplacer(parser);
if let Cow::Owned(new_result) = INLINE_LINK_MACRO.replace_all(content.rendered(), replacer)
{
content.rendered = new_result.into();
}
}
if text.contains('@') {
let replacer = InlineEmailReplacer(parser);
if let Cow::Owned(new_result) = INLINE_EMAIL.replace_all(content.rendered(), replacer) {
content.rendered = new_result.into();
}
}
if (found_square_bracket && text.contains("[[")) || (found_macroish && text.contains("or:")) {
let replacer = InlineAnchorReplacer(parser);
if let Cow::Owned(new_result) = INLINE_ANCHOR.replace_all(content.rendered(), replacer) {
content.rendered = new_result.into();
}
}
let mut xrefs: Vec<XrefSegment> = vec![];
if (text.contains("<<") || (found_macroish && text.contains("xref:")))
&& let Cow::Owned(new_result) = INLINE_XREF.replace_all(
content.rendered(),
InlineXrefReplacer {
parser,
xrefs: &mut xrefs,
},
)
{
content.rendered = new_result.into();
}
if found_macroish && text.contains("tnote") {
let replacer = InlineFootnoteMacroReplacer {
parser,
source: content.original(),
all_xrefs: &xrefs,
};
if let Cow::Owned(new_result) =
replace_with_lookahead(&INLINE_FOOTNOTE_MACRO, content.rendered(), replacer)
{
content.rendered = new_result.into();
}
}
content.set_deferred_xrefs(xrefs);
}
static INLINE_IMAGE_MACRO: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(
r#"(?xs)
\\? # Optional escape: literal backslash
i(?:mage|con): # 'image:' or 'icon:' prefix
( # Group 1: the target
[^:\s\[\n] # First char: not colon, whitespace, [, or newline
[^\[\n]*? # Middle chars: any except [ or newline, lazily
[^\s\[\n] # Last char: not whitespace, [, or newline
)? # Entire target group is optional
\[ # Opening square bracket
( # Group 2: bracketed text
| # EITHER: empty alt text
.*?[^\\] # OR: content ending in a non-backslash
)
\] # Closing square bracket
"#,
)
.unwrap()
});
#[derive(Debug)]
struct InlineImageMacroReplacer<'p>(&'p Parser);
impl Replacer for InlineImageMacroReplacer<'_> {
fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
if caps[0].starts_with('\\') {
dest.push_str(&caps[0][1..]);
return;
}
let target = &caps[1];
let span = Span::new(&caps[2]);
let attrlist = Attrlist::parse(span, self.0, AttrlistContext::Inline)
.item
.item;
let default_alt = basename(&target.replace(['_', '-'], " "));
if caps[0].starts_with("image:") {
let params = ImageRenderParams {
target,
alt: attrlist
.named_or_positional_attribute("alt", 1)
.map_or(default_alt, |a| {
normalize_text_lf_escaped_bracket(a.value())
}),
width: attrlist
.named_or_positional_attribute("width", 2)
.map(|a| a.value()),
height: attrlist
.named_or_positional_attribute("height", 3)
.map(|a| a.value()),
attrlist: &attrlist,
parser: self.0,
};
self.0.renderer.render_image(¶ms, dest);
} else {
let params = IconRenderParams {
target,
alt: attrlist.named_attribute("alt").map_or(default_alt, |a| {
normalize_text_lf_escaped_bracket(a.value())
}),
size: attrlist
.named_or_positional_attribute("size", 1)
.map(|a| a.value()),
attrlist: &attrlist,
parser: self.0,
};
self.0.renderer.render_icon(¶ms, dest);
}
}
}
fn basename(path: &str) -> String {
Path::new(path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string()
}
static INLINE_KBD_BTN_MACRO: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(
r#"(?xs) # extended mode; dot matches newline
(\\)? # (1) optional escape backslash
(kbd|btn): # (2) macro name
\[
( .*?[^\\] ) # (3) bracketed content, ending in a non-backslash
\]
"#,
)
.unwrap()
});
#[derive(Debug)]
struct InlineKbdBtnMacroReplacer<'p>(&'p Parser);
impl Replacer for InlineKbdBtnMacroReplacer<'_> {
fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
if caps.get(1).is_some() {
dest.push_str(&caps[0][1..]);
return;
}
if &caps[2] == "kbd" {
let keys = split_kbd_keys(&caps[3]);
self.0.renderer.render_keyboard(&keys, dest);
} else {
let text = normalize_index_text(&caps[3], true);
self.0.renderer.render_button(&text, dest);
}
}
}
fn split_kbd_keys(raw: &str) -> Vec<String> {
let mut keys = raw.trim().to_string();
if keys.contains(']') {
keys = keys.replace("\\]", "]");
}
let delim = keys.chars().skip(1).find(|c| *c == ',' || *c == '+');
if let Some(delim) = delim {
let ends_with_delim = keys.ends_with(delim);
let split_source = if ends_with_delim {
&keys[..keys.len() - delim.len_utf8()]
} else {
keys.as_str()
};
let mut parts: Vec<String> = split_source
.split(delim)
.map(|k| k.trim().to_string())
.collect();
if ends_with_delim && let Some(last) = parts.last_mut() {
last.push(delim);
}
parts
} else {
vec![keys]
}
}
static INLINE_MENU_MACRO: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(
r#"(?xs) # extended mode; dot matches newline
\\? # optional escape backslash (not captured)
menu:
( # (1) menu name
\w # a single word character
| # or
[\w&] [^\n\[]* [^\s\[] # first word/ampersand char, then any run
# not containing a newline or '[', ending
# in a non-space, non-'[' character
)
\[ \x20* # opening '[' then optional spaces
(?: # menu items (optional)
| # empty
( .*?[^\\] ) # (2) items, ending in a non-backslash
)
\]
"#,
)
.unwrap()
});
#[derive(Debug)]
struct InlineMenuMacroReplacer<'p>(&'p Parser);
impl Replacer for InlineMenuMacroReplacer<'_> {
fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
if caps[0].starts_with('\\') {
dest.push_str(&caps[0][1..]);
return;
}
let menu = &caps[1];
let (submenus, menuitem): (Vec<String>, Option<String>) = if let Some(items) = caps.get(2) {
let mut items = items.as_str().to_string();
if items.contains(']') {
items = items.replace("\\]", "]");
}
let delim = if items.contains(">") {
Some(">")
} else if items.contains(',') {
Some(",")
} else {
None
};
if let Some(delim) = delim {
let mut parts: Vec<String> =
items.split(delim).map(|i| i.trim().to_string()).collect();
let menuitem = parts.pop();
(parts, menuitem)
} else {
(vec![], Some(items.trim_end().to_string()))
}
} else {
(vec![], None)
};
let params = MenuRenderParams {
menu,
submenus: &submenus,
menuitem: menuitem.as_deref(),
parser: self.0,
};
self.0.renderer.render_menu(¶ms, dest);
}
}
fn normalize_text_lf_escaped_bracket(text: &str) -> String {
text.replace("\n", " ").replace("\\]", "]")
}
static INLINE_INDEXTERM: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(
r#"(?xs) # extended mode; dot matches newline
\\? # optional escaping backslash
(?:
(indexterm2?):\[ (.*?[^\\]) \] # (1) macro name, (2) macro argument
|
\(\( (.+?) \)\) # (3) shorthand enclosed text
)
"#,
)
.unwrap()
});
#[derive(Debug)]
struct InlineIndextermReplacer<'p>(&'p Parser);
impl LookaheadReplacer for InlineIndextermReplacer<'_> {
fn replace_append(
&mut self,
caps: &Captures<'_>,
dest: &mut String,
after: &str,
) -> LookaheadResult {
let parser = self.0;
if let Some(name) = caps.get(1) {
if caps[0].starts_with('\\') {
dest.push_str(&caps[0][1..]);
return LookaheadResult::Continue;
}
if name.as_str() == "indexterm2" {
let arg = normalize_index_text(&caps[2], true);
let term = if arg.contains('=') {
Attrlist::parse(Span::new(&arg), parser, AttrlistContext::Inline)
.item
.item
.nth_attribute(1)
.map(|a| a.value().to_string())
.unwrap_or(arg)
} else {
arg
};
parser.renderer.render_index_term(
&IndexTermRenderParams {
visible_term: Some(&term),
},
dest,
);
} else {
parser
.renderer
.render_index_term(&IndexTermRenderParams { visible_term: None }, dest);
}
return LookaheadResult::Continue;
}
let extra = after.bytes().take_while(|b| *b == b')').count();
let advance = if extra > 0 {
LookaheadResult::SkipAheadAndRetry(caps[0].len() + extra)
} else {
LookaheadResult::Continue
};
let mut encl_text = String::with_capacity(caps[3].len() + extra);
encl_text.push_str(&caps[3]);
for _ in 0..extra {
encl_text.push(')');
}
let escaped = caps[0].starts_with('\\');
let (inner, visible, before, trailing): (&str, bool, &str, &str) = if escaped {
if encl_text.starts_with('(') && encl_text.ends_with(')') {
(&encl_text[1..encl_text.len() - 1], true, "(", ")")
} else {
dest.push_str(&caps[0][1..]);
for _ in 0..extra {
dest.push(')');
}
return advance;
}
} else if let Some(without_open) = encl_text.strip_prefix('(') {
if let Some(inner) = without_open.strip_suffix(')') {
(inner, false, "", "")
} else {
(without_open, true, "(", "")
}
} else if let Some(inner) = encl_text.strip_suffix(')') {
(inner, true, "", ")")
} else {
(&encl_text[..], true, "", "")
};
dest.push_str(before);
if visible {
let term = strip_see_and_seealso(&normalize_index_text(inner, false));
parser.renderer.render_index_term(
&IndexTermRenderParams {
visible_term: Some(&term),
},
dest,
);
} else {
parser
.renderer
.render_index_term(&IndexTermRenderParams { visible_term: None }, dest);
}
dest.push_str(trailing);
advance
}
}
fn normalize_index_text(text: &str, unescape_brackets: bool) -> String {
let normalized = text.trim().replace('\n', " ");
if unescape_brackets {
normalized.replace("\\]", "]")
} else {
normalized
}
}
fn strip_see_and_seealso(term: &str) -> String {
if term.contains(";&") {
if let Some((primary, _see)) = term.split_once(" >> ") {
return primary.to_string();
}
if let Some((primary, _see_also)) = term.split_once(" &> ") {
return primary.to_string();
}
}
term.to_string()
}
static INLINE_LINK: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(
r#"(?msx)
( ^ | link: | [\ \t] | \\?<() | [>\(\)\[\];"'] ) # capture group 1: prefix
# capture group 2: flag for prefix == "<"
( \\? (?: https? | file | ftp | irc ):// ) # capture group 3: scheme
(?:
( [^\s\[\]]+ ) # capture group 4: target
\[ ( | .*?[^\\] ) \] # capture group 5: attrlist
| ( [^\s]+? ) > # capture group 6: URL inside <>
# (Ruby gates this with a `\2` back-ref to
# group 2; unsupported here - see issue #503)
| ( [^\s\[\]<]* ( [^\s,.?!\[\]<\)] ) ) # capture group 7: bare link,
# capture group 8: trailing char
)
"#,
)
.unwrap()
});
#[derive(Debug)]
struct InlineLinkReplacer<'p>(&'p Parser);
impl Replacer for InlineLinkReplacer<'_> {
fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
let mut attrlist = Attrlist::parse(Span::default(), self.0, AttrlistContext::Inline)
.item
.item;
if caps.get(2).is_some() && caps.get(5).is_none() {
if caps[1].starts_with('\\') {
dest.push_str(&caps[0][1..]);
return;
}
if caps[3].starts_with('\\') {
dest.push_str(&caps[1]);
dest.push_str(&caps[0][caps[1].len() + 1..]);
return;
}
let Some(link_suffix) = caps.get(6) else {
dest.push_str(&caps[0]);
return;
};
let target = format!(
"{scheme}{link_suffix}",
scheme = &caps[3],
link_suffix = link_suffix.as_str()
);
let link_text = if self.0.is_attribute_set("hide-uri-scheme") {
URI_SNIFF.replace_all(&target, "").into_owned()
} else {
target.clone()
};
let params = LinkRenderParams {
target,
link_text,
extra_roles: vec!["bare"],
window: None,
type_: LinkRenderType::Link,
attrlist: &attrlist,
parser: self.0,
};
self.0.renderer.render_link(¶ms, dest);
return;
}
let mut prefix = caps[1].to_string();
let scheme = &caps[3];
if scheme.starts_with('\\') {
dest.push_str(&prefix);
dest.push_str(&caps[0][prefix.len() + 1..]);
return;
}
let url_part = caps
.get(4)
.map(|m| m.as_str().to_owned())
.or_else(|| caps.get(7).map(|m| m.as_str().to_owned()))
.or_else(|| caps.get(6).map(|m| format!("{}>", m.as_str())))
.unwrap_or_default();
let mut target = format!("{scheme}{url_part}");
let mut suffix = "".to_owned();
let mut link_text: Option<String> = None;
if let Some(attrlist) = caps.get(5) {
if prefix == "link:" {
prefix = "".to_owned();
}
if !attrlist.is_empty() {
link_text = Some(attrlist.as_str().to_owned());
}
} else {
if prefix == "link" || prefix == "\"" || prefix == "'" {
dest.push_str(&caps[0]);
return;
}
if let Some(tail) = target.chars().last().filter(|c| *c == ';' || *c == ':') {
target.truncate(target.len() - 1);
suffix = tail.to_string();
if target.ends_with(')') {
target.truncate(target.len() - 1);
suffix = format!("){suffix}");
}
}
}
let mut bare = false;
let link_text_for_attrlist = link_text.clone().unwrap_or_default();
let span_for_attrlist = Span::new(&link_text_for_attrlist);
let mut window: Option<&'static str> = None;
let link_text = if let Some(mut link_text) = link_text {
link_text = link_text.replace("\\]", "]");
if link_text.contains('=') {
let (lt, attrs) = extract_attributes_from_text(&span_for_attrlist, self.0, None);
link_text = lt.replace("\\\"", "\"");
attrlist = attrs; }
if link_text.ends_with('^') {
link_text.truncate(link_text.len() - 1);
window = Some("_blank");
}
if link_text.is_empty() {
bare = true;
if self.0.is_attribute_set("hide-uri-scheme") {
URI_SNIFF.replace_all(&target, "").into_owned()
} else {
target.clone()
}
} else {
link_text
}
} else {
bare = true;
if self.0.is_attribute_set("hide-uri-scheme") {
URI_SNIFF.replace_all(&target, "").into_owned()
} else {
target.clone()
}
};
let extra_roles = if bare { vec!["bare"] } else { vec![] };
dest.push_str(&prefix);
let params = LinkRenderParams {
target,
link_text,
extra_roles,
window,
type_: LinkRenderType::Link,
attrlist: &attrlist,
parser: self.0,
};
self.0.renderer.render_link(¶ms, dest);
dest.push_str(&suffix);
}
}
static INLINE_LINK_MACRO: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(
r#"(?xs) # (?x) extended mode, (?s) dot matches newline
\\? # Optional backslash escape before macro
(?: # Non-capturing group for macro name
link # 'link'
| (mailto) # capture group 1: 'mailto'
)
: # Colon after macro name
(?: # Non-capturing outer group
(). # capture group 2: empty target
| ([^:\s\[] [^\s\[]*) # capture group 3: valid target (no colon/space/'[')
)
\[ # Opening square bracket
(?: # Non-capturing outer group
() # capture group 4: empty label
| (.*?[^\\]) # capture group 5: minimally match anything, not ending in '\'
)
\] # Closing square bracket
"#,
)
.unwrap()
});
#[derive(Debug)]
struct InlineLinkMacroReplacer<'p>(&'p Parser);
impl Replacer for InlineLinkMacroReplacer<'_> {
fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
if caps[0].starts_with('\\') {
dest.push_str(&caps[0][1..]);
return;
}
let (mailto, mailto_text, mut target) = if caps.get(1).is_some() {
let mailto_text = &caps[3];
(
caps.get(1).map(|c| c.as_str()),
Some(mailto_text),
format!("mailto:{mailto_text}"),
)
} else {
(None, None, caps[3].to_string())
};
let mut attrlist: Option<Attrlist<'_>> = None;
let link_type = LinkRenderType::Link;
let mut link_text = caps
.get(5)
.map(|c| c.as_str().to_string())
.unwrap_or_default();
let link_text_for_attrlist = link_text.replace("\n", " ");
let span_for_attrlist = Span::new(&link_text_for_attrlist);
let mut window: Option<&'static str> = None;
if !link_text.is_empty() {
link_text = link_text.replace("\\]", "]");
if let Some(_mailto) = mailto {
if link_text.contains(',') {
let (lt, attrs) =
extract_attributes_from_text(&span_for_attrlist, self.0, None);
link_text = lt;
if let Some(target_attr) = attrs.nth_attribute(2) {
target = format!(
"{target}?subject={subject}",
subject = encode_uri_component(target_attr.value())
);
if let Some(body) = attrs.nth_attribute(3) {
target = format!(
"{target}&body={body}",
body = encode_uri_component(body.value())
);
}
}
attrlist = Some(attrs);
}
} else if link_text.contains('=') {
let (lt, attrs) = extract_attributes_from_text(&span_for_attrlist, self.0, None);
link_text = lt;
attrlist = Some(attrs);
}
if link_text.ends_with('^') {
link_text.truncate(link_text.len() - 1);
window = Some("_blank");
}
}
let attrlist = if let Some(attrlist) = attrlist {
attrlist
} else {
Attrlist::parse(Span::default(), self.0, AttrlistContext::Inline)
.item
.item
};
let mut extra_roles: Vec<&str> = vec![];
if link_text.is_empty() {
if let Some(_mailto) = mailto {
link_text = mailto_text.map(|s| s.to_owned()).unwrap_or_default();
} else {
link_text = if self.0.is_attribute_set("hide-uri-scheme") {
let lt = URI_SNIFF.replace_all(&target, "").into_owned();
if lt.is_empty() { target.clone() } else { lt }
} else {
target.clone()
};
extra_roles.push("bare");
}
}
let params = LinkRenderParams {
target,
link_text: link_text.clone(),
extra_roles,
window,
type_: link_type,
attrlist: &attrlist,
parser: self.0,
};
self.0.renderer.render_link(¶ms, dest);
}
}
fn extract_attributes_from_text<'src>(
text: &'src Span<'src>,
parser: &Parser,
default_text: Option<&str>,
) -> (String, Attrlist<'src>) {
let attrlist_maw = Attrlist::parse(*text, parser, AttrlistContext::Inline);
let attrs = attrlist_maw.item.item;
if let Some(resolved_text) = attrs.nth_attribute(1) {
(resolved_text.value().to_owned(), attrs)
} else {
let default_text = default_text.map(|s| s.to_string());
(default_text.unwrap_or_default(), attrs)
}
}
const CGI_ESCAPE_SET: &AsciiSet = &CONTROLS
.add(b' ') .add(b'!')
.add(b'"')
.add(b'#')
.add(b'$')
.add(b'%')
.add(b'&')
.add(b'\'')
.add(b'(')
.add(b')')
.add(b'+') .add(b',')
.add(b'/')
.add(b':')
.add(b';')
.add(b'<')
.add(b'=')
.add(b'>')
.add(b'?')
.add(b'@')
.add(b'[')
.add(b'\\')
.add(b']')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}');
fn encode_uri_component(s: &str) -> String {
let encoded = utf8_percent_encode(s, CGI_ESCAPE_SET).to_string();
let with_plus = encoded.replace("%20", "+");
with_plus.replace('+', "%20")
}
static INLINE_EMAIL: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(
r#"(?x) # verbose mode (ignore whitespace & comments)
([\\>:/]?) # capture group 1: prefix that causes mismatch: \, >, :, or /
( # capture group 2: actual e-mail address
[\w_] # leading word character
(?: & | [\w\-.%+] )* # subsequent word chars or symbols (&, ., -, %, +)
@ # at sign
[\p{L}\p{Nd}] # leading letter or digit in domain
[\p{L}\p{Nd}_\-.]* # rest of domain
\.[a-zA-Z]{2,5} # dot + TLD (2–5 ASCII letters)
)
\b # word boundary
"#,
)
.unwrap()
});
#[derive(Debug)]
struct InlineEmailReplacer<'p>(&'p Parser);
impl Replacer for InlineEmailReplacer<'_> {
fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
if let Some(escape) = &caps.get(1)
&& !escape.is_empty()
{
if escape.as_str() == "\\" {
dest.push_str(&caps[0][1..]);
} else {
dest.push_str(&caps[0]);
}
return;
}
let target = format!("mailto:{mailto}", mailto = &caps[2]);
let attrlist = Attrlist::parse(Span::default(), self.0, AttrlistContext::Inline)
.item
.item;
let params = LinkRenderParams {
target: target.clone(),
link_text: caps[2].to_owned(),
extra_roles: vec![],
window: None,
type_: LinkRenderType::Link,
attrlist: &attrlist,
parser: self.0,
};
self.0.renderer.render_link(¶ms, dest);
}
}
static INLINE_BIBLIO_ANCHOR: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(
r#"(?x)
^ # the anchor must prefix the entry
\[\[\[ # opening triple bracket
( # (1) bibliography label
[\p{Alphabetic}_:] # first char: letter, '_' or ':' (never a digit)
[\p{Alphabetic}\p{Nd}_\-:.]* # rest: letters/digits/_/-/:/.
)
(?: , \s* (.+?) )? # (2) optional xreftext after a comma
\]\]\] # closing triple bracket
"#,
)
.unwrap()
});
#[derive(Debug)]
struct InlineBiblioAnchorReplacer<'p, 's> {
parser: &'p Parser,
source: Span<'s>,
}
impl Replacer for InlineBiblioAnchorReplacer<'_, '_> {
fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
let id = &caps[1];
let label = caps.get(2).map(|m| m.as_str()).unwrap_or(id);
let reftext = format!("[{label}]");
if self
.parser
.register_ref(id, Some(&reftext), crate::document::RefType::Bibliography)
.is_err()
{
self.parser.record_substitution_warning(
self.source,
crate::warnings::WarningType::DuplicateId(id.to_string()),
);
}
self.parser.renderer.render_anchor(id, None, dest);
dest.push_str(&reftext);
}
}
static INLINE_ANCHOR: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(
r#"(?x)
(\\)? # (1) optional escape backslash before the anchor
(?: # either [[id[, reftext]]] OR anchor:id[reftext]
\[\[ # [[
( # (2) anchor id for [[...]]
[\p{Alphabetic}_:] # first char: letter, '_' or ':'
[\p{Alphabetic}\p{Nd}_\-:.]* # rest: letters/digits/_ or '-', ':', '.'
)
(?: , \s* (.+?) )? # (3) optional reftext after comma (lazy)
\]\] # ]]
|
anchor: # 'anchor:' prefix
( # (4) anchor id for anchor:...[]
[\p{Alphabetic}_:] # first char: letter, '_' or ':'
[\p{Alphabetic}\p{Nd}_\-:.]* # rest: letters/digits/_ or '-', ':', '.'
) # end (4)
\[ # opening '[' for reftext
(?: # either empty [] or a non-empty reftext
\] # empty -> immediate ']'
| # OR
(.*?[^\\]) # (5) non-empty reftext (ends with a non-escaped char)
\] # closing ']'
)
) # end alternation
"#,
)
.unwrap()
});
#[derive(Debug)]
struct InlineAnchorReplacer<'p>(&'p Parser);
impl Replacer for InlineAnchorReplacer<'_> {
fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
if caps.get(1).is_some() {
dest.push_str(&caps[0][1..]);
return;
}
let (id, reftext) = if let Some(id) = caps.get(2) {
(id.as_str(), caps.get(3).map(|m| m.as_str().to_string()))
} else {
(
&caps[4],
caps.get(5)
.map(|m| m.as_str().to_string().replace("\\]", "]")),
)
};
let _ = self
.0
.register_ref(id, reftext.as_deref(), crate::document::RefType::Anchor);
self.0.renderer.render_anchor(id, reftext, dest);
}
}
static INLINE_XREF: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(
r#"(?xs)
(\\)? # (1) optional escape backslash
(?:
<< # shorthand: << (post special-chars)
( .*? ) # (2) refid plus optional ", reftext"
>> # >>
|
xref: # 'xref:' macro form
( [^:\s\[] [^\s\[]* ) # (3) target
\[ # opening '['
( | .*?[^\\] ) # (4) reftext: empty or ends non-escaped
\] # closing ']'
)
"#,
)
.unwrap()
});
#[derive(Debug)]
struct InlineXrefReplacer<'p, 'x> {
parser: &'p Parser,
xrefs: &'x mut Vec<XrefSegment>,
}
impl Replacer for InlineXrefReplacer<'_, '_> {
fn replace_append(&mut self, caps: &Captures<'_>, dest: &mut String) {
if caps.get(1).is_some() {
dest.push_str(&caps[0][1..]);
return;
}
let mut window: Option<String> = None;
let mut roles: Vec<String> = vec![];
let (target, provided_text) = if let Some(inner) = caps.get(2) {
match inner.as_str().split_once(',') {
Some((id, text)) => (id.trim().to_string(), Some(text.trim().to_string())),
None => (inner.as_str().trim().to_string(), None),
}
} else {
let raw_target = &caps[3];
let target = raw_target
.strip_prefix('#')
.unwrap_or(raw_target)
.to_string();
let raw_text = caps.get(4).map(|m| m.as_str()).unwrap_or_default();
let provided_text = if raw_text.is_empty() {
None
} else if raw_text.contains('=') {
let normalized = raw_text.replace('\n', " ");
let attrlist =
Attrlist::parse(Span::new(&normalized), self.parser, AttrlistContext::Inline)
.item
.item;
window = attrlist
.named_attribute("window")
.map(|a| a.value().to_string());
roles = attrlist.roles().iter().map(|r| r.to_string()).collect();
attrlist
.nth_attribute(1)
.map(|a| a.value().to_string())
.filter(|s| !s.is_empty())
} else {
Some(raw_text.replace("\\]", "]"))
};
(target, provided_text)
};
let index = self.xrefs.len();
self.xrefs.push(XrefSegment {
target,
provided_text,
window,
roles,
resolved: None,
});
dest.push_str(&Content::xref_placeholder(index));
}
}
static INLINE_FOOTNOTE_MACRO: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(
r#"(?xs) # extended mode; dot matches newline
\\? # optional escaping backslash
footnote
(?:
(ref): # (1) the deprecated 'footnoteref:' form
|
: ([\w-]+)? # (2) optional id for the 'footnote:id' form
)
\[
(?: | (.*?[^\\]) ) # (3) text: empty, or ends in a non-backslash
\]
"#,
)
.unwrap()
});
#[derive(Debug)]
struct InlineFootnoteMacroReplacer<'p, 's, 'x> {
parser: &'p Parser,
source: Span<'s>,
all_xrefs: &'x [XrefSegment],
}
impl LookaheadReplacer for InlineFootnoteMacroReplacer<'_, '_, '_> {
fn replace_append(
&mut self,
caps: &Captures<'_>,
dest: &mut String,
after: &str,
) -> LookaheadResult {
if caps[0].starts_with('\\') {
dest.push_str(&caps[0][1..]);
return LookaheadResult::Continue;
}
if after.starts_with("</a>") {
dest.push_str(&caps[0]);
return LookaheadResult::Continue;
}
let parser = self.parser;
let (id, content): (Option<String>, Option<String>) = if caps.get(1).is_some() {
let Some(raw) = caps.get(3).map(|m| m.as_str()) else {
dest.push_str(&caps[0]);
return LookaheadResult::Continue;
};
if !parser.is_attribute_set("compat-mode") {
parser.record_substitution_warning(
self.source,
WarningType::DeprecatedFootnorefMacro(caps[0].to_string()),
);
}
match raw.split_once(',') {
Some((id, content)) => (Some(id.to_string()), Some(content.to_string())),
None => (Some(raw.to_string()), None),
}
} else {
(
caps.get(2).map(|m| m.as_str().to_string()),
caps.get(3).map(|m| m.as_str().to_string()),
)
};
if let Some(id) = id {
if let Some(index) = parser.footnote_index_for_id(&id) {
parser.renderer.render_footnote(
&FootnoteRenderParams {
index: Some(index.as_str()),
id: None,
is_reference: true,
text: "",
},
dest,
);
} else if let Some(content) = content {
let (template, xrefs) = crate::content::rehome_xref_placeholders(
&normalize_footnote_text(&content),
self.all_xrefs,
);
let index = parser.define_footnote(Some(&id), template, xrefs);
parser.renderer.render_footnote(
&FootnoteRenderParams {
index: Some(index.as_str()),
id: Some(&id),
is_reference: false,
text: "",
},
dest,
);
} else {
parser.record_substitution_warning(
self.source,
WarningType::InvalidFootnoteReference(id.clone()),
);
parser.renderer.render_footnote(
&FootnoteRenderParams {
index: None,
id: None,
is_reference: true,
text: &id,
},
dest,
);
}
} else if let Some(content) = content {
let (template, xrefs) = crate::content::rehome_xref_placeholders(
&normalize_footnote_text(&content),
self.all_xrefs,
);
let index = parser.define_footnote(None, template, xrefs);
parser.renderer.render_footnote(
&FootnoteRenderParams {
index: Some(index.as_str()),
id: None,
is_reference: false,
text: "",
},
dest,
);
} else {
dest.push_str(&caps[0]);
}
LookaheadResult::Continue
}
}
fn normalize_footnote_text(content: &str) -> String {
content.trim().replace('\n', " ").replace("\\]", "]")
}
static URI_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
#[allow(clippy::unwrap_used)]
Regex::new(r#"^\p{alpha}[\p{alpha}\p{digit}.+-]+:/{0,2}"#).unwrap()
});
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
mod inline_link {
use crate::tests::prelude::*;
#[test]
fn escape_angle_bracket_autolink_before_lt() {
let doc = Parser::default()
.parse("You'll often see \\<https://example.org> used in examples.");
assert_eq!(
doc,
Document {
header: Header {
title_source: None,
title: None,
attributes: &[],
author_line: None,
revision_line: None,
comments: &[],
source: Span {
data: "",
line: 1,
col: 1,
offset: 0,
},
},
blocks: &[Block::Simple(SimpleBlock {
content: Content {
original: Span {
data: "You'll often see \\<https://example.org> used in examples.",
line: 1,
col: 1,
offset: 0,
},
rendered: "You’ll often see <https://example.org> used in examples.",
},
source: Span {
data: "You'll often see \\<https://example.org> used in examples.",
line: 1,
col: 1,
offset: 0,
},
style: SimpleBlockStyle::Paragraph,
title_source: None,
title: None,
caption: None,
number: None,
anchor: None,
anchor_reftext: None,
attrlist: None,
},),],
source: Span {
data: "You'll often see \\<https://example.org> used in examples.",
line: 1,
col: 1,
offset: 0,
},
warnings: &[],
source_map: SourceMap(&[]),
catalog: Catalog::default(),
}
);
}
#[test]
fn escape_angle_bracket_autolink_before_scheme() {
let doc = Parser::default()
.parse("You'll often see <\\https://example.org> used in examples.");
assert_eq!(
doc,
Document {
header: Header {
title_source: None,
title: None,
attributes: &[],
author_line: None,
revision_line: None,
comments: &[],
source: Span {
data: "",
line: 1,
col: 1,
offset: 0,
},
},
blocks: &[Block::Simple(SimpleBlock {
content: Content {
original: Span {
data: "You'll often see <\\https://example.org> used in examples.",
line: 1,
col: 1,
offset: 0,
},
rendered: "You’ll often see <https://example.org> used in examples.",
},
source: Span {
data: "You'll often see <\\https://example.org> used in examples.",
line: 1,
col: 1,
offset: 0,
},
style: SimpleBlockStyle::Paragraph,
title_source: None,
title: None,
caption: None,
number: None,
anchor: None,
anchor_reftext: None,
attrlist: None,
},),],
source: Span {
data: "You'll often see <\\https://example.org> used in examples.",
line: 1,
col: 1,
offset: 0,
},
warnings: &[],
source_map: SourceMap(&[]),
catalog: Catalog::default(),
}
);
}
#[test]
fn empty_inside_angle_brackets() {
let doc = Parser::default().parse("There's no actual link <https://> in here.");
assert_eq!(
doc,
Document {
header: Header {
title_source: None,
title: None,
attributes: &[],
author_line: None,
revision_line: None,
comments: &[],
source: Span {
data: "",
line: 1,
col: 1,
offset: 0,
},
},
blocks: &[Block::Simple(SimpleBlock {
content: Content {
original: Span {
data: "There's no actual link <https://> in here.",
line: 1,
col: 1,
offset: 0,
},
rendered: "There’s no actual link <https://> in here.",
},
source: Span {
data: "There's no actual link <https://> in here.",
line: 1,
col: 1,
offset: 0,
},
style: SimpleBlockStyle::Paragraph,
title_source: None,
title: None,
caption: None,
number: None,
anchor: None,
anchor_reftext: None,
attrlist: None,
},),],
source: Span {
data: "There's no actual link <https://> in here.",
line: 1,
col: 1,
offset: 0,
},
warnings: &[],
source_map: SourceMap(&[]),
catalog: Catalog::default(),
}
);
}
#[test]
fn hide_uri_scheme() {
let doc = Parser::default().parse("= Test Page\n:hide-uri-scheme:\n\nWe don't want you to know that this is HTTP: <https://example.com> just now.");
assert_eq!(
doc,
Document {
header: Header {
title_source: Some(Span {
data: "Test Page",
line: 1,
col: 3,
offset: 2,
},),
title: Some("Test Page",),
attributes: &[Attribute {
name: Span {
data: "hide-uri-scheme",
line: 2,
col: 2,
offset: 13,
},
value_source: None,
value: InterpretedValue::Set,
source: Span {
data: ":hide-uri-scheme:",
line: 2,
col: 1,
offset: 12,
},
},],
author_line: None,
revision_line: None,
comments: &[],
source: Span {
data: "= Test Page\n:hide-uri-scheme:",
line: 1,
col: 1,
offset: 0,
},
},
blocks: &[Block::Simple(SimpleBlock {
content: Content {
original: Span {
data: "We don't want you to know that this is HTTP: <https://example.com> just now.",
line: 4,
col: 1,
offset: 31,
},
rendered: "We don’t want you to know that this is HTTP: <a href=\"https://example.com\" class=\"bare\">example.com</a> just now.",
},
source: Span {
data: "We don't want you to know that this is HTTP: <https://example.com> just now.",
line: 4,
col: 1,
offset: 31,
},
style: SimpleBlockStyle::Paragraph,
title_source: None,
title: None,
caption: None,
number: None,
anchor: None,
anchor_reftext: None,
attrlist: None,
},),],
source: Span {
data: "= Test Page\n:hide-uri-scheme:\n\nWe don't want you to know that this is HTTP: <https://example.com> just now.",
line: 1,
col: 1,
offset: 0,
},
warnings: &[],
source_map: SourceMap(&[]),
catalog: Catalog::default(),
}
);
}
#[test]
fn link_with_semicolon_suffix() {
let doc = Parser::default().parse(
"You shouldn't visit https://example.com; it's just there to illustrate examples.",
);
assert_eq!(
doc,
Document {
header: Header {
title_source: None,
title: None,
attributes: &[],
author_line: None,
revision_line: None,
comments: &[],
source: Span {
data: "",
line: 1,
col: 1,
offset: 0,
},
},
blocks: &[Block::Simple(SimpleBlock {
content: Content {
original: Span {
data: "You shouldn't visit https://example.com; it's just there to illustrate examples.",
line: 1,
col: 1,
offset: 0,
},
rendered: "You shouldn’t visit <a href=\"https://example.com\" class=\"bare\">https://example.com</a>; it’s just there to illustrate examples.",
},
source: Span {
data: "You shouldn't visit https://example.com; it's just there to illustrate examples.",
line: 1,
col: 1,
offset: 0,
},
style: SimpleBlockStyle::Paragraph,
title_source: None,
title: None,
caption: None,
number: None,
anchor: None,
anchor_reftext: None,
attrlist: None,
},),],
source: Span {
data: "You shouldn't visit https://example.com; it's just there to illustrate examples.",
line: 1,
col: 1,
offset: 0,
},
warnings: &[],
source_map: SourceMap(&[]),
catalog: Catalog::default(),
}
);
}
#[test]
fn link_with_paren_and_colon_suffix() {
let doc = Parser::default().parse(
"You shouldn't visit that site (https://example.com): it's just there to illustrate examples.",
);
assert_eq!(
doc,
Document {
header: Header {
title_source: None,
title: None,
attributes: &[],
author_line: None,
revision_line: None,
comments: &[],
source: Span {
data: "",
line: 1,
col: 1,
offset: 0,
},
},
blocks: &[Block::Simple(SimpleBlock {
content: Content {
original: Span {
data: "You shouldn't visit that site (https://example.com): it's just there to illustrate examples.",
line: 1,
col: 1,
offset: 0,
},
rendered: "You shouldn’t visit that site (<a href=\"https://example.com\" class=\"bare\">https://example.com</a>): it’s just there to illustrate examples.",
},
source: Span {
data: "You shouldn't visit that site (https://example.com): it's just there to illustrate examples.",
line: 1,
col: 1,
offset: 0,
},
style: SimpleBlockStyle::Paragraph,
title_source: None,
title: None,
caption: None,
number: None,
anchor: None,
anchor_reftext: None,
attrlist: None,
},),],
source: Span {
data: "You shouldn't visit that site (https://example.com): it's just there to illustrate examples.",
line: 1,
col: 1,
offset: 0,
},
warnings: &[],
source_map: SourceMap(&[]),
catalog: Catalog::default(),
}
);
}
#[test]
fn named_attributes_without_link_text_and_hide_uri_scheme() {
let doc = Parser::default()
.parse("= Test\n:hide-uri-scheme:\n\nhttps://chat.asciidoc.org[role=button,window=_blank,opts=nofollow]");
assert_eq!(
doc,
Document {
header: Header {
title_source: Some(Span {
data: "Test",
line: 1,
col: 3,
offset: 2,
},),
title: Some("Test",),
attributes: &[Attribute {
name: Span {
data: "hide-uri-scheme",
line: 2,
col: 2,
offset: 8,
},
value_source: None,
value: InterpretedValue::Set,
source: Span {
data: ":hide-uri-scheme:",
line: 2,
col: 1,
offset: 7,
},
},],
author_line: None,
revision_line: None,
comments: &[],
source: Span {
data: "= Test\n:hide-uri-scheme:",
line: 1,
col: 1,
offset: 0,
},
},
blocks: &[Block::Simple(SimpleBlock {
content: Content {
original: Span {
data: "https://chat.asciidoc.org[role=button,window=_blank,opts=nofollow]",
line: 4,
col: 1,
offset: 26,
},
rendered: "<a href=\"https://chat.asciidoc.org\" class=\"bare button\" target=\"_blank\" rel=\"nofollow\" noopener>chat.asciidoc.org</a>",
},
source: Span {
data: "https://chat.asciidoc.org[role=button,window=_blank,opts=nofollow]",
line: 4,
col: 1,
offset: 26,
},
style: SimpleBlockStyle::Paragraph,
title_source: None,
title: None,
caption: None,
number: None,
anchor: None,
anchor_reftext: None,
attrlist: None,
},),],
source: Span {
data: "= Test\n:hide-uri-scheme:\n\nhttps://chat.asciidoc.org[role=button,window=_blank,opts=nofollow]",
line: 1,
col: 1,
offset: 0,
},
warnings: &[],
source_map: SourceMap(&[]),
catalog: Catalog::default(),
}
);
}
}
mod link_macro {
use crate::tests::prelude::*;
#[test]
fn escape_link_macro() {
let doc =
Parser::default().parse("A link macro looks like this: \\link:target[link text].");
assert_eq!(
doc,
Document {
header: Header {
title_source: None,
title: None,
attributes: &[],
author_line: None,
revision_line: None,
comments: &[],
source: Span {
data: "",
line: 1,
col: 1,
offset: 0,
},
},
blocks: &[Block::Simple(SimpleBlock {
content: Content {
original: Span {
data: "A link macro looks like this: \\link:target[link text].",
line: 1,
col: 1,
offset: 0,
},
rendered: "A link macro looks like this: link:target[link text].",
},
source: Span {
data: "A link macro looks like this: \\link:target[link text].",
line: 1,
col: 1,
offset: 0,
},
style: SimpleBlockStyle::Paragraph,
title_source: None,
title: None,
caption: None,
number: None,
anchor: None,
anchor_reftext: None,
attrlist: None,
},),],
source: Span {
data: "A link macro looks like this: \\link:target[link text].",
line: 1,
col: 1,
offset: 0,
},
warnings: &[],
source_map: SourceMap(&[]),
catalog: Catalog::default(),
}
);
}
#[test]
fn empty_mailto_link() {
let doc = Parser::default().parse("mailto:[,Subscribe me]");
assert_eq!(
doc,
Document {
header: Header {
title_source: None,
title: None,
attributes: &[],
author_line: None,
revision_line: None,
comments: &[],
source: Span {
data: "",
line: 1,
col: 1,
offset: 0,
},
},
blocks: &[Block::Simple(SimpleBlock {
content: Content {
original: Span {
data: "mailto:[,Subscribe me]",
line: 1,
col: 1,
offset: 0,
},
rendered: "mailto:[,Subscribe me]",
},
source: Span {
data: "mailto:[,Subscribe me]",
line: 1,
col: 1,
offset: 0,
},
style: SimpleBlockStyle::Paragraph,
title_source: None,
title: None,
caption: None,
number: None,
anchor: None,
anchor_reftext: None,
attrlist: None,
},),],
source: Span {
data: "mailto:[,Subscribe me]",
line: 1,
col: 1,
offset: 0,
},
warnings: &[],
source_map: SourceMap(&[]),
catalog: Catalog::default(),
}
);
}
#[test]
fn empty_link_text_with_hide_uri_scheme() {
let doc = Parser::default()
.parse("= Test Document\n:hide-uri-scheme:\n\nlink:https://example.com[]");
assert_eq!(
doc,
Document {
header: Header {
title_source: Some(Span {
data: "Test Document",
line: 1,
col: 3,
offset: 2,
},),
title: Some("Test Document",),
attributes: &[Attribute {
name: Span {
data: "hide-uri-scheme",
line: 2,
col: 2,
offset: 17,
},
value_source: None,
value: InterpretedValue::Set,
source: Span {
data: ":hide-uri-scheme:",
line: 2,
col: 1,
offset: 16,
},
},],
author_line: None,
revision_line: None,
comments: &[],
source: Span {
data: "= Test Document\n:hide-uri-scheme:",
line: 1,
col: 1,
offset: 0,
},
},
blocks: &[Block::Simple(SimpleBlock {
content: Content {
original: Span {
data: "link:https://example.com[]",
line: 4,
col: 1,
offset: 35,
},
rendered: "<a href=\"https://example.com\" class=\"bare\">example.com</a>",
},
source: Span {
data: "link:https://example.com[]",
line: 4,
col: 1,
offset: 35,
},
style: SimpleBlockStyle::Paragraph,
title_source: None,
title: None,
caption: None,
number: None,
anchor: None,
anchor_reftext: None,
attrlist: None,
},),],
source: Span {
data: "= Test Document\n:hide-uri-scheme:\n\nlink:https://example.com[]",
line: 1,
col: 1,
offset: 0,
},
warnings: &[],
source_map: SourceMap(&[]),
catalog: Catalog::default(),
}
);
}
#[test]
fn empty_mailto_link_text_with_hide_uri_scheme() {
let doc = Parser::default()
.parse("= Test Document\n:hide-uri-scheme:\n\nlink:mailto:fred@example.com[]");
assert_eq!(
doc,
Document {
header: Header {
title_source: Some(Span {
data: "Test Document",
line: 1,
col: 3,
offset: 2,
},),
title: Some("Test Document",),
attributes: &[Attribute {
name: Span {
data: "hide-uri-scheme",
line: 2,
col: 2,
offset: 17,
},
value_source: None,
value: InterpretedValue::Set,
source: Span {
data: ":hide-uri-scheme:",
line: 2,
col: 1,
offset: 16,
},
},],
author_line: None,
revision_line: None,
comments: &[],
source: Span {
data: "= Test Document\n:hide-uri-scheme:",
line: 1,
col: 1,
offset: 0,
},
},
blocks: &[Block::Simple(SimpleBlock {
content: Content {
original: Span {
data: "link:mailto:fred@example.com[]",
line: 4,
col: 1,
offset: 35,
},
rendered: "<a href=\"mailto:fred@example.com\" class=\"bare\">fred@example.com</a>",
},
source: Span {
data: "link:mailto:fred@example.com[]",
line: 4,
col: 1,
offset: 35,
},
style: SimpleBlockStyle::Paragraph,
title_source: None,
title: None,
caption: None,
number: None,
anchor: None,
anchor_reftext: None,
attrlist: None,
},),],
source: Span {
data: "= Test Document\n:hide-uri-scheme:\n\nlink:mailto:fred@example.com[]",
line: 1,
col: 1,
offset: 0,
},
warnings: &[],
source_map: SourceMap(&[]),
catalog: Catalog::default(),
}
);
}
}
mod inline_anchor {
use crate::tests::prelude::*;
#[test]
fn inline_ref_double_brackets() {
let doc = Parser::default().parse("Here you can read about tigers.[[tigers]]");
assert_eq!(
doc,
Document {
header: Header {
title_source: None,
title: None,
attributes: &[],
author_line: None,
revision_line: None,
comments: &[],
source: Span {
data: "",
line: 1,
col: 1,
offset: 0,
},
},
blocks: &[Block::Simple(SimpleBlock {
content: Content {
original: Span {
data: "Here you can read about tigers.[[tigers]]",
line: 1,
col: 1,
offset: 0,
},
rendered: "Here you can read about tigers.<a id=\"tigers\"></a>",
},
source: Span {
data: "Here you can read about tigers.[[tigers]]",
line: 1,
col: 1,
offset: 0,
},
style: SimpleBlockStyle::Paragraph,
title_source: None,
title: None,
caption: None,
number: None,
anchor: None,
anchor_reftext: None,
attrlist: None,
},),],
source: Span {
data: "Here you can read about tigers.[[tigers]]",
line: 1,
col: 1,
offset: 0,
},
warnings: &[],
source_map: SourceMap(&[]),
catalog: Catalog {
refs: HashMap::from([(
"tigers",
RefEntry {
id: "tigers",
reftext: None,
ref_type: crate::document::RefType::Anchor,
},
)]),
reftext_to_id: HashMap::new(),
},
}
);
}
#[test]
fn inline_ref_macro() {
let doc = Parser::default().parse("Here you can read about tigers.anchor:tigers[]");
assert_eq!(
doc,
Document {
header: Header {
title_source: None,
title: None,
attributes: &[],
author_line: None,
revision_line: None,
comments: &[],
source: Span {
data: "",
line: 1,
col: 1,
offset: 0,
},
},
blocks: &[Block::Simple(SimpleBlock {
content: Content {
original: Span {
data: "Here you can read about tigers.anchor:tigers[]",
line: 1,
col: 1,
offset: 0,
},
rendered: "Here you can read about tigers.<a id=\"tigers\"></a>",
},
source: Span {
data: "Here you can read about tigers.anchor:tigers[]",
line: 1,
col: 1,
offset: 0,
},
style: SimpleBlockStyle::Paragraph,
title_source: None,
title: None,
caption: None,
number: None,
anchor: None,
anchor_reftext: None,
attrlist: None,
},),],
source: Span {
data: "Here you can read about tigers.anchor:tigers[]",
line: 1,
col: 1,
offset: 0,
},
warnings: &[],
source_map: SourceMap(&[]),
catalog: Catalog {
refs: HashMap::from([(
"tigers",
RefEntry {
id: "tigers",
reftext: None,
ref_type: crate::document::RefType::Anchor,
},
)]),
reftext_to_id: HashMap::new(),
},
}
);
}
#[test]
fn inline_ref_with_reftext_double_brackets() {
let doc = Parser::default().parse("Here you can read about tigers.[[tigers,Tigers]]");
assert_eq!(
doc,
Document {
header: Header {
title_source: None,
title: None,
attributes: &[],
author_line: None,
revision_line: None,
comments: &[],
source: Span {
data: "",
line: 1,
col: 1,
offset: 0,
},
},
blocks: &[Block::Simple(SimpleBlock {
content: Content {
original: Span {
data: "Here you can read about tigers.[[tigers,Tigers]]",
line: 1,
col: 1,
offset: 0,
},
rendered: "Here you can read about tigers.<a id=\"tigers\"></a>",
},
source: Span {
data: "Here you can read about tigers.[[tigers,Tigers]]",
line: 1,
col: 1,
offset: 0,
},
style: SimpleBlockStyle::Paragraph,
title_source: None,
title: None,
caption: None,
number: None,
anchor: None,
anchor_reftext: None,
attrlist: None,
},),],
source: Span {
data: "Here you can read about tigers.[[tigers,Tigers]]",
line: 1,
col: 1,
offset: 0,
},
warnings: &[],
source_map: SourceMap(&[]),
catalog: Catalog {
refs: HashMap::from([(
"tigers",
RefEntry {
id: "tigers",
reftext: Some("Tigers"),
ref_type: crate::document::RefType::Anchor,
},
)]),
reftext_to_id: HashMap::from([("Tigers", "tigers")]),
},
}
);
}
#[test]
fn inline_ref_with_reftext_macro() {
let doc =
Parser::default().parse("Here you can read about tigers.anchor:tigers[Tigers]");
assert_eq!(
doc,
Document {
header: Header {
title_source: None,
title: None,
attributes: &[],
author_line: None,
revision_line: None,
comments: &[],
source: Span {
data: "",
line: 1,
col: 1,
offset: 0,
},
},
blocks: &[Block::Simple(SimpleBlock {
content: Content {
original: Span {
data: "Here you can read about tigers.anchor:tigers[Tigers]",
line: 1,
col: 1,
offset: 0,
},
rendered: "Here you can read about tigers.<a id=\"tigers\"></a>",
},
source: Span {
data: "Here you can read about tigers.anchor:tigers[Tigers]",
line: 1,
col: 1,
offset: 0,
},
style: SimpleBlockStyle::Paragraph,
title_source: None,
title: None,
caption: None,
number: None,
anchor: None,
anchor_reftext: None,
attrlist: None,
},),],
source: Span {
data: "Here you can read about tigers.anchor:tigers[Tigers]",
line: 1,
col: 1,
offset: 0,
},
warnings: &[],
source_map: SourceMap(&[]),
catalog: Catalog {
refs: HashMap::from([(
"tigers",
RefEntry {
id: "tigers",
reftext: Some("Tigers"),
ref_type: crate::document::RefType::Anchor,
},
)]),
reftext_to_id: HashMap::from([("Tigers", "tigers")]),
},
}
);
}
#[test]
fn mixed_inline_anchor_macro_and_anchor_shorthand_with_empty_reftext() {
let doc =
Parser::default().parse("anchor:one[][[two]]anchor:three[][[four]]anchor:five[]");
assert_eq!(
doc,
Document {
header: Header {
title_source: None,
title: None,
attributes: &[],
author_line: None,
revision_line: None,
comments: &[],
source: Span {
data: "",
line: 1,
col: 1,
offset: 0,
},
},
blocks: &[Block::Simple(SimpleBlock {
content: Content {
original: Span {
data: "anchor:one[][[two]]anchor:three[][[four]]anchor:five[]",
line: 1,
col: 1,
offset: 0,
},
rendered: r#"<a id="one"></a><a id="two"></a><a id="three"></a><a id="four"></a><a id="five"></a>"#,
},
source: Span {
data: "anchor:one[][[two]]anchor:three[][[four]]anchor:five[]",
line: 1,
col: 1,
offset: 0,
},
style: SimpleBlockStyle::Paragraph,
title_source: None,
title: None,
caption: None,
number: None,
anchor: None,
anchor_reftext: None,
attrlist: None,
},),],
source: Span {
data: "anchor:one[][[two]]anchor:three[][[four]]anchor:five[]",
line: 1,
col: 1,
offset: 0,
},
warnings: &[],
source_map: SourceMap(&[]),
catalog: Catalog {
refs: HashMap::from([
(
"one",
RefEntry {
id: "one",
reftext: None,
ref_type: crate::document::RefType::Anchor,
},
),
(
"two",
RefEntry {
id: "two",
reftext: None,
ref_type: crate::document::RefType::Anchor,
},
),
(
"three",
RefEntry {
id: "three",
reftext: None,
ref_type: crate::document::RefType::Anchor,
},
),
(
"four",
RefEntry {
id: "four",
reftext: None,
ref_type: crate::document::RefType::Anchor,
},
),
(
"five",
RefEntry {
id: "five",
reftext: None,
ref_type: crate::document::RefType::Anchor,
},
),
]),
reftext_to_id: HashMap::new(),
},
}
);
}
#[test]
fn inline_ref_can_start_with_colon() {
let doc = Parser::default().parse("[[:idname]] text");
assert_eq!(
doc,
Document {
header: Header {
title_source: None,
title: None,
attributes: &[],
author_line: None,
revision_line: None,
comments: &[],
source: Span {
data: "",
line: 1,
col: 1,
offset: 0,
},
},
blocks: &[Block::Simple(SimpleBlock {
content: Content {
original: Span {
data: "[[:idname]] text",
line: 1,
col: 1,
offset: 0,
},
rendered: "<a id=\":idname\"></a> text",
},
source: Span {
data: "[[:idname]] text",
line: 1,
col: 1,
offset: 0,
},
style: SimpleBlockStyle::Paragraph,
title_source: None,
title: None,
caption: None,
number: None,
anchor: None,
anchor_reftext: None,
attrlist: None,
},),],
source: Span {
data: "[[:idname]] text",
line: 1,
col: 1,
offset: 0,
},
warnings: &[],
source_map: SourceMap(&[]),
catalog: Catalog {
refs: HashMap::from([(
":idname",
RefEntry {
id: ":idname",
reftext: None,
ref_type: crate::document::RefType::Anchor,
},
)]),
reftext_to_id: HashMap::new(),
},
}
);
}
#[test]
fn inline_ref_cannot_start_with_digit() {
let doc = Parser::default().parse("[[1-install]] text");
assert_eq!(
doc,
Document {
header: Header {
title_source: None,
title: None,
attributes: &[],
author_line: None,
revision_line: None,
comments: &[],
source: Span {
data: "",
line: 1,
col: 1,
offset: 0,
},
},
blocks: &[Block::Simple(SimpleBlock {
content: Content {
original: Span {
data: "[[1-install]] text",
line: 1,
col: 1,
offset: 0,
},
rendered: "[[1-install]] text",
},
source: Span {
data: "[[1-install]] text",
line: 1,
col: 1,
offset: 0,
},
style: SimpleBlockStyle::Paragraph,
title_source: None,
title: None,
caption: None,
number: None,
anchor: None,
anchor_reftext: None,
attrlist: None,
},),],
source: Span {
data: "[[1-install]] text",
line: 1,
col: 1,
offset: 0,
},
warnings: &[],
source_map: SourceMap(&[]),
catalog: Catalog::default(),
}
);
}
#[test]
fn escaped_inline_ref_square_brackets() {
let doc = Parser::default().parse("Here you can read about tigers.\\[[tigers]]");
assert_eq!(
doc,
Document {
header: Header {
title_source: None,
title: None,
attributes: &[],
author_line: None,
revision_line: None,
comments: &[],
source: Span {
data: "",
line: 1,
col: 1,
offset: 0,
},
},
blocks: &[Block::Simple(SimpleBlock {
content: Content {
original: Span {
data: "Here you can read about tigers.\\[[tigers]]",
line: 1,
col: 1,
offset: 0,
},
rendered: "Here you can read about tigers.[[tigers]]",
},
source: Span {
data: "Here you can read about tigers.\\[[tigers]]",
line: 1,
col: 1,
offset: 0,
},
style: SimpleBlockStyle::Paragraph,
title_source: None,
title: None,
caption: None,
number: None,
anchor: None,
anchor_reftext: None,
attrlist: None,
},),],
source: Span {
data: "Here you can read about tigers.\\[[tigers]]",
line: 1,
col: 1,
offset: 0,
},
warnings: &[],
source_map: SourceMap(&[]),
catalog: Catalog::default(),
}
);
}
#[test]
fn escaped_inline_ref_macro() {
let doc = Parser::default().parse("Here you can read about tigers.\\anchor:tigers[]");
assert_eq!(
doc,
Document {
header: Header {
title_source: None,
title: None,
attributes: &[],
author_line: None,
revision_line: None,
comments: &[],
source: Span {
data: "",
line: 1,
col: 1,
offset: 0,
},
},
blocks: &[Block::Simple(SimpleBlock {
content: Content {
original: Span {
data: "Here you can read about tigers.\\anchor:tigers[]",
line: 1,
col: 1,
offset: 0,
},
rendered: "Here you can read about tigers.anchor:tigers[]",
},
source: Span {
data: "Here you can read about tigers.\\anchor:tigers[]",
line: 1,
col: 1,
offset: 0,
},
style: SimpleBlockStyle::Paragraph,
title_source: None,
title: None,
caption: None,
number: None,
anchor: None,
anchor_reftext: None,
attrlist: None,
},),],
source: Span {
data: "Here you can read about tigers.\\anchor:tigers[]",
line: 1,
col: 1,
offset: 0,
},
warnings: &[],
source_map: SourceMap(&[]),
catalog: Catalog::default(),
}
);
}
}
mod bibliography_anchor {
#![allow(clippy::indexing_slicing)]
use crate::tests::prelude::*;
#[test]
fn recognized_only_when_it_prefixes_the_entry() {
let doc = Parser::default().parse("[bibliography]\n* Smith. See [[[mid]]] inline.\n");
let rendered = &rendered_paragraphs(&doc)[0];
assert!(
rendered.contains("[<a id=\"mid\"></a>]"),
"unexpected: {rendered}"
);
assert!(!rendered.contains("<a id=\"mid\"></a>[mid]"));
assert_eq!(
doc.catalog().get_ref("mid").map(|e| e.ref_type.clone()),
Some(crate::document::RefType::Anchor)
);
}
#[test]
fn leading_backslash_is_not_a_bibliography_escape() {
let doc = Parser::default().parse("[bibliography]\n* \\[[[x]]] Leading backslash.\n");
let rendered = &rendered_paragraphs(&doc)[0];
assert!(
rendered.starts_with("\\[<a id=\"x\"></a>]"),
"unexpected: {rendered}"
);
}
#[test]
fn explicit_style_applies_to_an_ordered_list() {
let doc = Parser::default().parse("[bibliography]\n. [[[ord]]] Ordered entry.\n");
assert_css(&doc, ".olist.bibliography", 1);
assert!(rendered_paragraphs(&doc)[0].starts_with("<a id=\"ord\"></a>[ord] "));
}
#[test]
fn section_style_does_not_apply_to_an_ordered_list() {
let doc = Parser::default()
.parse("[bibliography]\n== References\n\n. [[[ord]]] Ordered entry.\n");
assert_css(&doc, ".bibliography", 0);
assert!(rendered_paragraphs(&doc)[0].starts_with("[<a id=\"ord\"></a>] "));
}
}
}