use super::*;
pub(super) fn inline_source_skeleton(body: &[Inline]) -> String {
let mut s = String::new();
for inl in body {
s.push_str(&inline_skeleton_fragment(inl));
}
s
}
pub(super) fn inline_skeleton_fragment(inl: &Inline) -> Cow<'_, str> {
match inl {
Inline::Text(t) => Cow::Borrowed(t),
Inline::MdInlineLink { display, .. } => {
Cow::Owned(format!("[{}] ", inline_plain_text(display)))
}
Inline::MdImage(raw) => match image_skeleton_fragment(raw) {
Some(fragment) => Cow::Owned(fragment),
None => Cow::Borrowed(SKELETON_STAND_IN_STR),
},
Inline::MdLink(raw) => match opaque_inline_link_display(raw) {
Some(display) => Cow::Owned(format!("[{display}] ")),
None => Cow::Borrowed(SKELETON_STAND_IN_STR),
},
Inline::MdList(node) => {
let mut s = String::new();
for item in node
.children()
.filter(|n| n.kind() == SyntaxKind::ROXYGEN_MD_LIST_ITEM)
{
s.push(' ');
for child in md_list_item_inlines(&item) {
s.push_str(&inline_skeleton_fragment(&child));
}
}
s.push(' ');
Cow::Owned(s)
}
Inline::MdListResolved { items, .. } => {
let mut s = String::new();
for item in items {
s.push(' ');
for child in item {
s.push_str(&inline_skeleton_fragment(child));
}
}
s.push(' ');
Cow::Owned(s)
}
_ => Cow::Borrowed(SKELETON_STAND_IN_STR),
}
}
const SKELETON_STAND_IN_STR: &str = "\u{1}";
pub(super) fn opaque_inline_link_display(raw: &str) -> Option<&str> {
let bytes = raw.as_bytes();
if bytes.first() == Some(&b'<') {
return None; }
let text_end = scan_delimited(bytes, 0, b'[', b']')?;
(bytes.get(text_end) == Some(&b'(')).then(|| &raw[1..text_end - 1])
}
pub(super) fn image_alt_text(raw: &str) -> Option<&str> {
let bytes = raw.as_bytes();
let alt_end = scan_delimited(bytes, 1, b'[', b']')?;
Some(&raw[2..alt_end - 1])
}
pub(super) fn image_is_collapsed(raw: &str) -> bool {
let bytes = raw.as_bytes();
let Some(alt_end) = scan_delimited(bytes, 1, b'[', b']') else {
return false;
};
alt_end + 2 == bytes.len() && bytes[alt_end..] == *b"[]"
}
fn image_skeleton_fragment(raw: &str) -> Option<String> {
let alt = image_alt_text(raw)?;
Some(if image_is_collapsed(raw) {
format!("[{alt}][]")
} else {
format!("[{alt}] ")
})
}
pub(super) fn leaked_linkref_text(source: &str) -> Vec<String> {
let escaped = double_escape_md(&source.replace(SOFT_BREAK, "\n"));
let labels = md_linkref_labels(&escaped);
let Some(first_invalid) = labels.iter().position(|label| {
!linkref_label_closes(label)
|| linkref_label_is_blank(label)
|| linkref_label_has_blank_line(label)
}) else {
return Vec::new();
};
labels[first_invalid..]
.iter()
.map(|label| format!("[{label}]: R:{}", url_encode(label)))
.collect()
}
pub(super) fn demote_poisoned_links(body: &[Inline]) -> Option<Vec<Inline>> {
let skeleton = inline_source_skeleton(body);
let boundary = first_invalid_linkref_offset(&skeleton)?;
let mut offset = 0;
let mut changed = false;
let out = demote_poisoned_walk(body, boundary, &mut offset, &mut changed);
Some(relink_demoted_inline_links(out))
}
fn demote_poisoned_walk(
body: &[Inline],
boundary: usize,
offset: &mut usize,
changed: &mut bool,
) -> Vec<Inline> {
let mut out = Vec::with_capacity(body.len());
for inl in body {
match inl {
Inline::MdList(node) => {
let items: Vec<Vec<Inline>> = node
.children()
.filter(|n| n.kind() == SyntaxKind::ROXYGEN_MD_LIST_ITEM)
.map(|item| md_list_item_inlines(&item))
.collect();
let mut item_changed = false;
let new_items = demote_poisoned_items(&items, boundary, offset, &mut item_changed);
if item_changed {
*changed = true;
out.push(Inline::MdListResolved {
ordered: md_list_is_ordered(node),
items: new_items,
});
} else {
out.push(inl.clone());
}
}
Inline::MdListResolved { ordered, items } => {
let mut item_changed = false;
let new_items = demote_poisoned_items(items, boundary, offset, &mut item_changed);
if item_changed {
*changed = true;
out.push(Inline::MdListResolved {
ordered: *ordered,
items: new_items,
});
} else {
out.push(inl.clone());
}
}
_ => {
let start = *offset;
*offset += skeleton_len(inl);
if start > boundary
&& let Some(text) = demoted_link_source(inl)
{
*changed = true;
out.push(Inline::Text(text));
} else {
out.push(inl.clone());
}
}
}
}
out
}
fn demote_poisoned_items(
items: &[Vec<Inline>],
boundary: usize,
offset: &mut usize,
item_changed: &mut bool,
) -> Vec<Vec<Inline>> {
let mut new_items = Vec::with_capacity(items.len());
for item in items {
*offset += 1; new_items.push(demote_poisoned_walk(item, boundary, offset, item_changed));
}
*offset += 1; new_items
}
fn relink_demoted_inline_links(body: Vec<Inline>) -> Vec<Inline> {
let mut out = Vec::with_capacity(body.len());
let mut text_run = String::new();
for inl in body {
match inl {
Inline::Text(s) => text_run.push_str(&s),
other => {
relink_text_run(&text_run, &mut out);
text_run.clear();
out.push(other);
}
}
}
relink_text_run(&text_run, &mut out);
out
}
fn relink_text_run(s: &str, out: &mut Vec<Inline>) {
if s.is_empty() {
return;
}
let bytes = s.as_bytes();
let mut i = 0;
let mut run_start = 0;
while i < bytes.len() {
if bytes[i] == b'['
&& !(i > 0 && bytes[i - 1] == b'\\')
&& let Some(text_end) = scan_delimited(bytes, i, b'[', b']')
&& bytes.get(text_end) == Some(&b'(')
&& let Some(url_end) = scan_delimited(bytes, text_end, b'(', b')')
{
if run_start < i {
out.push(Inline::Text(s[run_start..i].to_string()));
}
let display = s[i + 1..text_end - 1].to_string();
let url = s[text_end + 1..url_end - 1].to_string();
out.push(Inline::MdInlineLink {
url,
display: vec![Inline::Text(display)],
});
i = url_end;
run_start = i;
continue;
}
i += 1;
}
if run_start < s.len() {
out.push(Inline::Text(s[run_start..].to_string()));
}
}
pub(super) fn skeleton_len(inl: &Inline) -> usize {
inline_skeleton_fragment(inl).len()
}
pub(super) fn demoted_link_source(inl: &Inline) -> Option<String> {
match inl {
Inline::MdShortcutLink { display } => Some(format!("[{}]", link_label_text(display))),
Inline::MdRefLink { dest, display } => {
Some(format!("[{}][{}]", link_label_text(display), dest))
}
Inline::MdLink(raw) if opaque_link_is_shortcut_or_ref(raw) => Some(raw.clone()),
_ => None,
}
}
fn opaque_link_is_shortcut_or_ref(raw: &str) -> bool {
let bytes = raw.as_bytes();
if bytes.first() == Some(&b'<') {
return false; }
let Some(text_end) = scan_delimited(bytes, 0, b'[', b']') else {
return false;
};
!matches!(bytes.get(text_end), Some(&b'('))
}
pub(super) fn linkref_keys(body: &[Inline]) -> std::collections::HashSet<String> {
md_linkref_scan(&linkref_source_skeleton(body))
.into_iter()
.map(|(label, _)| normalize_linkref_label(&label))
.collect()
}
fn linkref_source_skeleton(body: &[Inline]) -> String {
let mut s = String::new();
for inl in body {
linkref_skeleton_push_exact(inl, &mut s, false);
}
s
}
pub(super) fn leak_source_skeleton(body: &[Inline]) -> String {
let mut s = String::new();
for inl in body {
linkref_skeleton_push_exact(inl, &mut s, true);
}
s
}
fn display_source_text(display: &[Inline]) -> String {
let mut s = String::new();
for inl in display {
match inl {
Inline::Text(t) => s.push_str(t),
Inline::MdEmphasis { strong, children } => {
let d = if *strong { "**" } else { "*" };
s.push_str(d);
s.push_str(&display_source_text(children));
s.push_str(d);
}
Inline::MdCode(t) => {
s.push('`');
s.push_str(t);
s.push('`');
}
other => s.push_str(&inline_plain_text(std::slice::from_ref(other))),
}
}
s
}
pub(super) fn linkref_skeleton_push_exact(inl: &Inline, s: &mut String, exact: bool) {
let label = |display: &[Inline]| {
if exact {
display_source_text(display)
} else {
link_label_text(display)
}
};
match inl {
Inline::Text(t) => s.push_str(t),
Inline::MdShortcutLink { display } => {
s.push('[');
s.push_str(&label(display));
s.push(']');
}
Inline::MdRefLink { dest, display } => {
s.push('[');
s.push_str(&label(display));
s.push_str("][");
s.push_str(dest);
s.push(']');
}
Inline::MdInlineLink { display, .. } => {
s.push('[');
s.push_str(&inline_plain_text(display));
s.push_str("] ");
}
Inline::MdImage(raw) => match image_skeleton_fragment(raw) {
Some(fragment) => s.push_str(&fragment),
None => s.push(' '),
},
Inline::MdLink(raw) => s.push_str(raw),
Inline::MdEmphasis { children, .. } => {
s.push(' ');
for child in children {
linkref_skeleton_push_exact(child, s, exact);
}
s.push(' ');
}
Inline::MdList(node) => {
for item in node
.children()
.filter(|n| n.kind() == SyntaxKind::ROXYGEN_MD_LIST_ITEM)
{
s.push(' ');
for child in md_list_item_inlines(&item) {
linkref_skeleton_push_exact(&child, s, exact);
}
}
s.push(' ');
}
Inline::MdListResolved { items, .. } => {
for item in items {
s.push(' ');
for child in item {
linkref_skeleton_push_exact(child, s, exact);
}
}
s.push(' ');
}
Inline::MdHtmlBlock(node) => s.push_str(&md_html_block_field_text(node)),
_ => s.push(' '),
}
}
fn normalize_linkref_label(label: &str) -> String {
let collapsed = label
.split(|c: char| c.is_ascii_whitespace())
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" ");
crate::roxygen::casefold::case_fold(&collapsed)
}
pub(super) fn link_ref_label(inl: &Inline) -> Option<String> {
match inl {
Inline::MdShortcutLink { display } => Some(link_label_text(display)),
Inline::MdRefLink { dest, display } => Some(if dest.is_empty() {
link_label_text(display)
} else {
dest.clone()
}),
Inline::MdLink(raw) => opaque_link_ref_label(raw),
_ => None,
}
}
fn link_ref_label_unusable(inl: &Inline) -> bool {
let display_unusable = |display: &[Inline]| {
display.iter().all(|d| matches!(d, Inline::Text(_)))
&& !linkref_label_is_usable(&link_label_text(display))
};
match inl {
Inline::MdShortcutLink { display } => display_unusable(display),
Inline::MdRefLink { dest, display } => {
if dest.is_empty() {
display_unusable(display)
} else {
!linkref_label_is_usable(dest)
}
}
Inline::MdLink(raw) => {
opaque_link_ref_label(raw).is_some_and(|label| !linkref_label_is_usable(&label))
}
_ => false,
}
}
fn opaque_link_ref_label(raw: &str) -> Option<String> {
let bytes = raw.as_bytes();
if bytes.first() == Some(&b'<') {
return None; }
let text_end = scan_delimited(bytes, 0, b'[', b']')?;
match bytes.get(text_end) {
Some(&b'(') => None, Some(&b'[') => {
let ref_end = scan_delimited(bytes, text_end, b'[', b']')?;
Some(raw[text_end + 1..ref_end - 1].to_string())
}
_ => Some(raw[1..text_end - 1].to_string()), }
}
struct ChainUnit {
display: Vec<Inline>,
label: Option<String>,
node: usize,
is_label_slot: bool,
}
pub(super) fn repair_ref_link_chains(
body: &[Inline],
keys: &std::collections::HashSet<String>,
) -> Option<Vec<Inline>> {
let is_chain_node = |inl: &Inline| {
matches!(inl, Inline::MdShortcutLink { .. })
|| matches!(inl, Inline::MdRefLink { dest, .. } if !dest.is_empty())
};
let mut out = Vec::with_capacity(body.len());
let mut changed = false;
let mut i = 0;
while i < body.len() {
if is_chain_node(&body[i]) {
let mut j = i + 1;
while j < body.len() && is_chain_node(&body[j]) {
j += 1;
}
match (j - i >= 2)
.then(|| repair_chain_run(&body[i..j], keys))
.flatten()
{
Some(rewritten) => {
out.extend(rewritten);
changed = true;
}
None => out.extend(body[i..j].iter().cloned()),
}
i = j;
continue;
}
if let Inline::MdEmphasis { strong, children } = &body[i]
&& let Some(rewritten) = repair_ref_link_chains(children, keys)
{
out.push(Inline::MdEmphasis {
strong: *strong,
children: rewritten,
});
changed = true;
} else {
out.push(body[i].clone());
}
i += 1;
}
changed.then_some(out)
}
fn repair_chain_run(
run: &[Inline],
keys: &std::collections::HashSet<String>,
) -> Option<Vec<Inline>> {
let mut units = Vec::new();
for (n, inl) in run.iter().enumerate() {
match inl {
Inline::MdShortcutLink { display } => units.push(ChainUnit {
label: display_chain_label(display),
display: display.clone(),
node: n,
is_label_slot: false,
}),
Inline::MdRefLink { dest, display } if !dest.is_empty() => {
units.push(ChainUnit {
label: display_chain_label(display),
display: display.clone(),
node: n,
is_label_slot: false,
});
units.push(ChainUnit {
display: vec![Inline::Text(dest.clone())],
label: linkref_label_is_usable(dest).then(|| dest.clone()),
node: n,
is_label_slot: true,
});
}
_ => return None,
}
}
let defined = |u: &ChainUnit| {
u.label
.as_ref()
.is_some_and(|l| keys.contains(&normalize_linkref_label(l)))
};
let mut out = Vec::new();
let mut changed = false;
let mut i = 0;
while i < units.len() {
if i + 1 < units.len() {
if defined(&units[i + 1]) {
let aligned = units[i + 1].is_label_slot
&& !units[i].is_label_slot
&& units[i].node == units[i + 1].node;
if aligned {
out.push(run[units[i].node].clone());
} else {
out.push(Inline::MdRefLink {
dest: units[i + 1].label.clone().unwrap_or_default(),
display: units[i].display.clone(),
});
changed = true;
}
i += 2;
} else {
out.push(Inline::Text(format!(
"[{}]",
link_label_text(&units[i].display)
)));
changed = true;
i += 1;
}
} else {
if units[i].is_label_slot {
if defined(&units[i]) {
out.push(Inline::MdShortcutLink {
display: units[i].display.clone(),
});
} else {
out.push(Inline::Text(format!(
"[{}]",
link_label_text(&units[i].display)
)));
}
changed = true;
} else {
out.push(run[units[i].node].clone());
}
i += 1;
}
}
changed.then_some(out)
}
fn display_chain_label(display: &[Inline]) -> Option<String> {
let flat = link_label_text(display);
let source_exact = display.iter().all(|d| matches!(d, Inline::Text(_)));
(!source_exact || linkref_label_is_usable(&flat)).then_some(flat)
}
pub(super) fn demote_undefined_links(
body: &[Inline],
keys: &std::collections::HashSet<String>,
) -> Option<Vec<Inline>> {
let mut changed = false;
let out: Vec<Inline> = body
.iter()
.map(|inl| {
if let Some(label) = link_ref_label(inl)
&& (!keys.contains(&normalize_linkref_label(&label))
|| link_ref_label_unusable(inl))
&& let Some(text) = demoted_link_source(inl)
{
changed = true;
return Inline::Text(text);
}
match demote_undefined_in_list(inl, keys) {
Some(resolved) => {
changed = true;
resolved
}
None => inl.clone(),
}
})
.collect();
changed.then_some(out)
}
fn demote_undefined_in_list(
inl: &Inline,
keys: &std::collections::HashSet<String>,
) -> Option<Inline> {
let (ordered, items): (bool, Vec<Vec<Inline>>) = match inl {
Inline::MdList(node) => (
md_list_is_ordered(node),
node.children()
.filter(|n| n.kind() == SyntaxKind::ROXYGEN_MD_LIST_ITEM)
.map(|item| md_list_item_inlines(&item))
.collect(),
),
Inline::MdListResolved { ordered, items } => (*ordered, items.clone()),
_ => return None,
};
let mut new_items = Vec::with_capacity(items.len());
let mut item_changed = false;
for item in &items {
match demote_undefined_links(item, keys) {
Some(rewritten) => {
new_items.push(rewritten);
item_changed = true;
}
None => new_items.push(item.clone()),
}
}
item_changed.then_some(Inline::MdListResolved {
ordered,
items: new_items,
})
}
pub(super) fn collect_user_linkrefs_tree(
body: &[Inline],
urls: &mut std::collections::HashMap<String, UserLinkDef>,
) {
let (level, _dropped) = collect_user_linkrefs(body);
for (label, def) in level {
urls.entry(label).or_insert(def);
}
for inl in body {
match inl {
Inline::MdList(node) => {
for item in node
.children()
.filter(|n| n.kind() == SyntaxKind::ROXYGEN_MD_LIST_ITEM)
{
collect_user_linkrefs_tree(&md_list_item_inlines(&item), urls);
}
}
Inline::MdListResolved { items, .. } => {
for item in items {
collect_user_linkrefs_tree(item, urls);
}
}
Inline::MdBlockQuote(node) => {
if let Some(block) = quote_synthesized_block("e_stripped_lines(node)) {
for section in block.sections() {
for group in section_body_parts(§ion) {
collect_user_linkrefs_tree(&group, urls);
}
}
}
}
_ => {}
}
}
}
pub(super) fn apply_user_linkrefs(
body: &[Inline],
urls: &std::collections::HashMap<String, UserLinkDef>,
consume_defs: bool,
) -> Option<Vec<Inline>> {
let dropped = if consume_defs {
collect_user_linkrefs(body).1
} else {
std::collections::BTreeMap::new()
};
let mut out = Vec::with_capacity(body.len());
let mut changed = !dropped.is_empty();
for (i, inl) in body.iter().enumerate() {
match dropped.get(&i) {
Some(None) => continue,
Some(Some(leftover)) => {
out.push(Inline::Text(leftover.clone()));
continue;
}
None => {}
}
if let Some(label) = link_ref_label(inl)
&& let Some(def) = urls.get(&normalize_linkref_label(&label))
&& let Some(display) = link_display_inlines(inl)
{
out.push(Inline::MdInlineLink {
url: def.url.clone(),
display,
});
changed = true;
continue;
}
if let Inline::MdImage(raw) = inl
&& let Some((label, alt)) = image_user_ref(raw)
&& let Some(def) = urls.get(&normalize_linkref_label(&md_label_flatten(label)))
{
out.push(Inline::MdImage(rebuilt_inline_image(alt, def)));
changed = true;
continue;
}
if let Inline::MdEmphasis { strong, children } = inl
&& let Some(rewritten) = apply_user_linkrefs(children, urls, false)
{
out.push(Inline::MdEmphasis {
strong: *strong,
children: rewritten,
});
changed = true;
continue;
}
if let Inline::MdList(node) = inl {
let items: Vec<Vec<Inline>> = node
.children()
.filter(|n| n.kind() == SyntaxKind::ROXYGEN_MD_LIST_ITEM)
.map(|item| md_list_item_inlines(&item))
.collect();
let mut new_items = Vec::with_capacity(items.len());
let mut item_changed = false;
for item in &items {
match apply_user_linkrefs(item, urls, true) {
Some(rewritten) => {
new_items.push(rewritten);
item_changed = true;
}
None => new_items.push(item.clone()),
}
}
if item_changed {
out.push(Inline::MdListResolved {
ordered: md_list_is_ordered(node),
items: new_items,
});
changed = true;
continue;
}
}
out.push(inl.clone());
}
changed.then_some(out)
}
#[derive(Clone)]
pub(super) struct UserLinkDef {
pub(super) url: String,
pub(super) title: String,
}
pub(super) type LinkDefs = std::collections::HashMap<String, UserLinkDef>;
fn rebuilt_inline_image(alt: &str, def: &UserLinkDef) -> String {
let dest = if def.url.contains('>') {
def.url.clone()
} else {
format!("<{}>", def.url)
};
if def.title.is_empty() {
format!("!{alt}({dest})")
} else {
format!("!{alt}({dest} \"{}\")", def.title)
}
}
pub(super) fn collect_user_linkrefs(
body: &[Inline],
) -> (
std::collections::HashMap<String, UserLinkDef>,
std::collections::BTreeMap<usize, Option<String>>,
) {
let is_soft_break = |inl: &Inline| matches!(inl, Inline::Text(t) if !t.is_empty() && t.chars().all(|c| c == SOFT_BREAK));
let mut urls: std::collections::HashMap<String, UserLinkDef> = std::collections::HashMap::new();
let mut dropped = std::collections::BTreeMap::new();
let mut i = 0;
let mut block_start = true;
while i < body.len() {
if block_start && let Some(end) = scan_linkref_run(body, i, &mut urls, &mut dropped) {
i = end;
block_start = false;
continue;
}
block_start = match &body[i] {
Inline::Text(t) if t.contains('\n') => true,
inl if is_soft_break(inl) => i > 0 && is_soft_break(&body[i - 1]),
_ => false,
};
i += 1;
}
(urls, dropped)
}
pub(super) fn scan_linkref_run(
body: &[Inline],
start: usize,
urls: &mut std::collections::HashMap<String, UserLinkDef>,
dropped: &mut std::collections::BTreeMap<usize, Option<String>>,
) -> Option<usize> {
let mut end = start;
let mut any = false;
loop {
let mut k = end;
while let Some(Inline::Text(t)) = body.get(k) {
if t.is_empty() || t.contains('\n') || !t.chars().all(char::is_whitespace) {
break;
}
k += 1;
}
let Some((label, def, def_end, leftover)) = match_linkref_def(body, k) else {
break;
};
urls.entry(normalize_linkref_label(&label)).or_insert(def);
for idx in end..def_end {
dropped.insert(idx, None);
}
any = true;
end = def_end;
if let Some(left) = leftover {
dropped.insert(end, Some(left));
break;
}
}
any.then_some(end)
}
fn match_linkref_def(
body: &[Inline],
j: usize,
) -> Option<(String, UserLinkDef, usize, Option<String>)> {
let inl = body.get(j)?;
let label = linkref_def_label(inl)?;
if link_ref_label_unusable(inl) {
return None;
}
let mut text = String::new();
let mut piece_ends: Vec<usize> = Vec::new();
let mut k = j + 1;
while let Some(frag) = body.get(k).and_then(linkref_raw_fragment) {
text.push_str(&frag);
piece_ends.push(text.len());
k += 1;
}
let (def, consumed) = parse_linkref_def_tail(&text)?;
if consumed == text.len() && body.get(k).is_some() && !text[..consumed].ends_with(SOFT_BREAK) {
return None;
}
let mut piece_start = 0;
for (offset, end) in piece_ends.iter().enumerate() {
let idx = j + 1 + offset;
if consumed >= *end {
piece_start = *end;
continue;
}
if consumed == piece_start {
return Some((label, def, idx, None));
}
let Some(Inline::Text(t)) = body.get(idx) else {
return None;
};
let leftover = t[consumed - piece_start..].to_string();
return Some((label, def, idx, Some(leftover)));
}
Some((label, def, k, None))
}
fn linkref_raw_fragment(inl: &Inline) -> Option<Cow<'_, str>> {
match inl {
Inline::Text(t) => Some(Cow::Borrowed(t)),
Inline::MdHtml(raw) => Some(Cow::Borrowed(raw)),
Inline::Macro(node) => Some(Cow::Owned(node.text().to_string())),
_ => None,
}
}
fn linkref_def_label(inl: &Inline) -> Option<String> {
match inl {
Inline::MdShortcutLink { display } => Some(inline_plain_text(display)),
Inline::MdLink(raw) => {
let bytes = raw.as_bytes();
if bytes.first() != Some(&b'[') {
return None;
}
let end = scan_delimited(bytes, 0, b'[', b']')?;
(end == bytes.len()).then(|| raw[1..end - 1].to_string())
}
_ => None,
}
}
fn parse_linkref_def_tail(text: &str) -> Option<(UserLinkDef, usize)> {
const SB: u8 = 0x0C; let bytes = text.as_bytes();
if bytes.first() != Some(&b':') {
return None;
}
let mut i = 1;
let mut broke = false;
loop {
match bytes.get(i) {
Some(&b' ' | &b'\t') => i += 1,
Some(&SB) if !broke => {
broke = true;
i += 1;
}
_ => break,
}
}
let (url, p) = if bytes.get(i) == Some(&b'<') {
let mut q = i + 1;
loop {
match bytes.get(q) {
None | Some(&(b'<' | b'\n' | SB)) => return None,
Some(&b'>') => break,
Some(_) => q += 1,
}
}
(&text[i + 1..q], q + 1)
} else {
let mut q = i;
let mut depth = 0usize;
loop {
match bytes.get(q) {
None => break,
Some(&b) if b.is_ascii_whitespace() => break,
Some(&b'(') => {
depth += 1;
q += 1;
}
Some(&b')') if depth == 0 => return None,
Some(&b')') => {
depth -= 1;
q += 1;
}
Some(_) => q += 1,
}
}
if depth != 0 || q == i {
return None;
}
(&text[i..q], q)
};
let url = decode_html_entities(url);
let mut q = p;
while matches!(bytes.get(q), Some(&b' ' | &b'\t')) {
q += 1;
}
let dest_only = |consumed: usize| {
Some((
UserLinkDef {
url: url.clone(),
title: String::new(),
},
consumed,
))
};
let dest_line_end = match bytes.get(q) {
None => return dest_only(bytes.len()),
Some(&b'\n') => return dest_only(q),
Some(&SB) => Some(q + 1),
_ if q == p => return None, _ => None,
};
let fallback = || dest_line_end.and_then(dest_only);
let title_start = match dest_line_end {
Some(next) => {
let mut t = next;
while matches!(bytes.get(t), Some(&b' ' | &b'\t')) {
t += 1;
}
t
}
None => q,
};
let close = match bytes.get(title_start) {
Some(&b'"') => b'"',
Some(&b'\'') => b'\'',
Some(&b'(') => b')',
_ => return fallback(),
};
let nested_open = bytes[title_start];
let mut m = title_start + 1;
let mut escapable_close = None;
let title_end;
loop {
match bytes.get(m) {
None | Some(&b'\n') => match escapable_close {
Some(e) => {
title_end = e;
break;
}
None => return fallback(),
},
Some(&c) if c == close => {
if bytes[m - 1] == b'\\' {
escapable_close = Some(m);
m += 1;
} else {
title_end = m;
break;
}
}
Some(&c) if nested_open == b'(' && c == b'(' => {
if bytes[m - 1] == b'\\' {
m += 1;
} else {
return fallback();
}
}
Some(_) => m += 1,
}
}
let mut r = title_end + 1;
while matches!(bytes.get(r), Some(&b' ' | &b'\t')) {
r += 1;
}
let consumed = match bytes.get(r) {
None => bytes.len(),
Some(&b'\n') => r,
Some(&SB) => r + 1,
_ => return fallback(),
};
let title = decode_html_entities(&text[title_start + 1..title_end]).replace(SOFT_BREAK, " ");
Some((UserLinkDef { url, title }, consumed))
}
pub(super) fn decode_html_entities(s: &str) -> String {
if !s.contains('&') {
return s.to_string();
}
let mut out = String::with_capacity(s.len());
let mut i = 0;
while i < s.len() {
if s.as_bytes()[i] == b'&'
&& let Some(rel) = s[i + 1..].find(';')
&& decode_entity(&s[i + 1..i + 1 + rel], &mut out)
{
i += 1 + rel + 1;
continue;
}
let ch = s[i..].chars().next().unwrap();
out.push(ch);
i += ch.len_utf8();
}
out
}
fn decode_entity(body: &str, out: &mut String) -> bool {
if let Some(num) = body.strip_prefix('#') {
let code = match num.strip_prefix(['x', 'X']) {
Some(hex) => u32::from_str_radix(hex, 16).ok(),
None => num.parse::<u32>().ok(),
};
let Some(code) = code else { return false };
out.push(
char::from_u32(code)
.filter(|&c| c != '\0')
.unwrap_or('\u{FFFD}'),
);
return true;
}
match entities::HTML5_ENTITIES.binary_search_by_key(&body, |&(name, _)| name) {
Ok(idx) => {
out.push_str(entities::HTML5_ENTITIES[idx].1);
true
}
Err(_) => false,
}
}
pub(super) fn md_linkref_labels(text: &str) -> Vec<String> {
md_linkref_scan(text).into_iter().map(|(l, _)| l).collect()
}
fn md_linkref_scan(text: &str) -> Vec<(String, usize)> {
let bytes = text.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'[' || (i > 0 && matches!(bytes[i - 1], b']' | b'\\')) {
i += 1;
continue;
}
let Some((content, content_end)) = bracket_free_group(bytes, i) else {
i += 1;
continue;
};
let (label, match_end) = match bracket_free_group(bytes, content_end) {
Some((reff, ref_end)) => (reff, ref_end),
None => (content, content_end),
};
if matches!(bytes.get(match_end), Some(b'[' | b'{')) {
i += 1;
continue;
}
out.push((String::from_utf8_lossy(label).into_owned(), i));
i = match_end;
}
out
}
pub(super) fn first_invalid_linkref_offset(skeleton: &str) -> Option<usize> {
md_linkref_scan(skeleton)
.into_iter()
.find(|(label, _)| !linkref_label_is_usable(label))
.map(|(_, start)| start)
}
pub(super) fn bracket_free_group(bytes: &[u8], open: usize) -> Option<(&[u8], usize)> {
if bytes.get(open) != Some(&b'[') {
return None;
}
let start = open + 1;
let mut j = start;
while j < bytes.len() && !matches!(bytes[j], b'[' | b']') {
j += 1;
}
(bytes.get(j) == Some(&b']') && j > start).then_some((&bytes[start..j], j + 1))
}
pub(super) fn linkref_label_closes(label: &str) -> bool {
label.bytes().rev().take_while(|&b| b == b'\\').count() % 2 == 0
}
fn linkref_label_is_blank(label: &str) -> bool {
label.chars().all(|c| c.is_ascii_whitespace())
}
fn linkref_label_has_blank_line(label: &str) -> bool {
let mut after_break = false;
for ch in label.chars() {
match ch {
'\n' | SOFT_BREAK => {
if after_break {
return true;
}
after_break = true;
}
' ' | '\t' => {}
_ => after_break = false,
}
}
false
}
fn linkref_label_is_usable(label: &str) -> bool {
!label.ends_with('\\') && !linkref_label_is_blank(label) && !linkref_label_has_blank_line(label)
}
pub(super) fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for &b in s.as_bytes() {
if b.is_ascii_alphanumeric()
|| matches!(
b,
b'!' | b'#'
| b'$'
| b'&'
| b'\''
| b'('
| b')'
| b'*'
| b'+'
| b','
| b'-'
| b'.'
| b'/'
| b':'
| b';'
| b'='
| b'?'
| b'@'
| b'['
| b']'
| b'_'
| b'~'
)
{
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}