use std::collections::HashSet;
use std::ops::Range;
use pulldown_cmark::LinkType;
use crate::lint_context::LintContext;
use crate::lint_context::types::{ParsedImage, ParsedLink};
use super::label::{LabelChoice, LabelGenerator, normalize_label};
use super::md054_config::{MD054Config, PreferredStyle, PreferredStyles};
#[derive(Debug, Clone)]
pub(super) struct SpanEdit {
pub range: Range<usize>,
pub replacement: String,
}
#[derive(Debug, Clone)]
pub(super) struct RefDefInsert {
pub label: String,
pub url: String,
pub title: Option<String>,
}
#[derive(Debug, Clone)]
pub(super) struct PlannedEdit {
pub edit: SpanEdit,
pub new_ref: Option<RefDefInsert>,
}
#[derive(Debug, Default)]
pub(super) struct FixPlan {
pub entries: Vec<PlannedEdit>,
}
impl FixPlan {
pub(super) fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Style {
Autolink,
Inline,
UrlInline,
Full,
Collapsed,
Shortcut,
}
impl Style {
fn from_link_type(link_type: LinkType, text_equals_url: bool) -> Option<Self> {
match link_type {
LinkType::Autolink | LinkType::Email => Some(Self::Autolink),
LinkType::Inline if text_equals_url => Some(Self::UrlInline),
LinkType::Inline => Some(Self::Inline),
LinkType::Reference => Some(Self::Full),
LinkType::Collapsed => Some(Self::Collapsed),
LinkType::Shortcut => Some(Self::Shortcut),
_ => None,
}
}
fn allowed(self, cfg: &MD054Config) -> bool {
match self {
Self::Autolink => cfg.autolink,
Self::Inline => cfg.inline,
Self::UrlInline => cfg.url_inline,
Self::Full => cfg.full,
Self::Collapsed => cfg.collapsed,
Self::Shortcut => cfg.shortcut,
}
}
}
fn auto_candidates(source: Style) -> &'static [Style] {
match source {
Style::Inline => &[Style::Full, Style::Collapsed, Style::Shortcut, Style::UrlInline],
Style::UrlInline => &[
Style::Autolink,
Style::Full,
Style::Collapsed,
Style::Shortcut,
Style::Inline,
],
Style::Autolink => &[Style::UrlInline, Style::Inline, Style::Full],
Style::Full | Style::Collapsed | Style::Shortcut => {
&[Style::Inline, Style::Full, Style::Collapsed, Style::Shortcut]
}
}
}
fn entry_candidates(entry: PreferredStyle, source: Style) -> &'static [Style] {
match entry {
PreferredStyle::Auto => auto_candidates(source),
PreferredStyle::Full => &[Style::Full],
PreferredStyle::Collapsed => &[Style::Collapsed],
PreferredStyle::Shortcut => &[Style::Shortcut],
PreferredStyle::Inline => &[Style::Inline],
PreferredStyle::Autolink => &[Style::Autolink],
PreferredStyle::UrlInline => &[Style::UrlInline],
}
}
#[derive(Clone, Copy)]
struct LinkFacts {
text_eq_url: bool,
autolink_safe: bool,
is_image: bool,
has_title: bool,
}
fn target_style(source: Style, facts: LinkFacts, cfg: &MD054Config) -> Option<Style> {
pick_target(source, facts, &cfg.preferred_style, cfg)
}
fn pick_target(source: Style, facts: LinkFacts, prefs: &PreferredStyles, cfg: &MD054Config) -> Option<Style> {
prefs
.as_slice()
.iter()
.flat_map(|entry| entry_candidates(*entry, source).iter().copied())
.find(|t| *t != source && t.allowed(cfg) && reachable(source, *t, facts))
}
fn reachable(source: Style, target: Style, facts: LinkFacts) -> bool {
use Style::{Autolink, Collapsed, Full, Inline, Shortcut, UrlInline};
let target_ok = match target {
Autolink => !facts.is_image && facts.text_eq_url && facts.autolink_safe && !facts.has_title,
UrlInline => facts.text_eq_url,
Inline => !facts.text_eq_url,
Full | Collapsed | Shortcut => true,
};
if !target_ok {
return false;
}
matches!(
(source, target),
(
Inline | UrlInline,
Full | Collapsed | Shortcut | Autolink | Inline | UrlInline
) | (Autolink, Inline | UrlInline | Full)
| (Full | Collapsed | Shortcut, Inline | Full | Collapsed | Shortcut)
)
}
pub(super) fn plan(ctx: &LintContext, cfg: &MD054Config) -> FixPlan {
let mut labels = LabelGenerator::from_existing(
ctx.reference_defs
.iter()
.map(|d| (d.id.as_str(), d.url.as_str(), d.title.as_deref())),
);
let content = ctx.content;
let mut pending: Vec<(SpanEdit, Option<RefDefInsert>)> = Vec::new();
for link in &ctx.links {
if skip_link(ctx, link.line) {
continue;
}
let text_eq_url = link.text == link.url;
let Some(source) = Style::from_link_type(link.link_type, text_eq_url) else {
continue;
};
if source.allowed(cfg) {
continue;
}
if matches!(source, Style::Full | Style::Collapsed | Style::Shortcut) && link.url.is_empty() {
continue;
}
let displays_url = match source {
Style::Autolink => link.link_type == LinkType::Autolink,
_ => text_eq_url,
};
let facts = LinkFacts {
text_eq_url: displays_url,
autolink_safe: is_autolink_safe(&link.url),
is_image: false,
has_title: link.title.is_some(),
};
let Some(target) = target_style(source, facts, cfg) else {
continue;
};
if let Some((edit, new_ref)) = convert_link(content, link, source, target, &mut labels) {
pending.push((edit, new_ref));
}
}
for image in &ctx.images {
if skip_link(ctx, image.line) {
continue;
}
let text_eq_url = image.alt_text == image.url;
let Some(source) = Style::from_link_type(image.link_type, text_eq_url) else {
continue;
};
if source.allowed(cfg) {
continue;
}
if matches!(source, Style::Full | Style::Collapsed | Style::Shortcut) && image.url.is_empty() {
continue;
}
let facts = LinkFacts {
text_eq_url,
autolink_safe: is_autolink_safe(&image.url),
is_image: true,
has_title: image.title.is_some(),
};
let Some(target) = target_style(source, facts, cfg) else {
continue;
};
if let Some((edit, new_ref)) = convert_image(content, image, source, target, &mut labels) {
pending.push((edit, new_ref));
}
}
finalize_plan(pending)
}
fn finalize_plan(pending: Vec<(SpanEdit, Option<RefDefInsert>)>) -> FixPlan {
let mut keep = vec![true; pending.len()];
for i in 0..pending.len() {
for j in (i + 1)..pending.len() {
let a = &pending[i].0.range;
let b = &pending[j].0.range;
if a.start < b.end && b.start < a.end {
keep[i] = false;
keep[j] = false;
}
}
}
let mut plan = FixPlan::default();
for (idx, (edit, new_ref)) in pending.into_iter().enumerate() {
if !keep[idx] {
continue;
}
plan.entries.push(PlannedEdit { edit, new_ref });
}
plan
}
fn skip_link(ctx: &LintContext, line: usize) -> bool {
if ctx
.line_info(line)
.is_some_and(|info| info.in_front_matter || info.in_code_block)
{
return true;
}
ctx.is_rule_disabled("MD054", line)
}
fn convert_link(
content: &str,
link: &ParsedLink<'_>,
source: Style,
target: Style,
labels: &mut LabelGenerator,
) -> Option<(SpanEdit, Option<RefDefInsert>)> {
let span = link.byte_offset..link.byte_end;
let original = &content[span.clone()];
let is_email_autolink_source = matches!(source, Style::Autolink) && link.link_type == LinkType::Email;
let (url, title): (String, Option<String>) = match source {
Style::Autolink if is_email_autolink_source => (format!("mailto:{}", link.url), None),
Style::Autolink => (link.url.to_string(), None),
_ => (link.url.to_string(), link.title.as_deref().map(str::to_string)),
};
let text: &str = if matches!(source, Style::Autolink) && link.text.is_empty() {
link.url.as_ref()
} else {
link.text.as_ref()
};
let follower = content.as_bytes().get(span.end).copied();
build_replacement(
ReplacementInput {
text,
url: &url,
title: title.as_deref(),
original,
source,
target,
is_image: false,
follower,
},
labels,
)
.map(|(replacement, new_ref)| {
(
SpanEdit {
range: span,
replacement,
},
new_ref,
)
})
}
fn convert_image(
content: &str,
image: &ParsedImage<'_>,
source: Style,
target: Style,
labels: &mut LabelGenerator,
) -> Option<(SpanEdit, Option<RefDefInsert>)> {
let span = image.byte_offset..image.byte_end;
let original = &content[span.clone()];
let (url, title): (String, Option<String>) = match source {
Style::Autolink => return None, _ => (image.url.to_string(), image.title.as_deref().map(str::to_string)),
};
let alt = image.alt_text.as_ref();
let follower = content.as_bytes().get(span.end).copied();
build_replacement(
ReplacementInput {
text: alt,
url: &url,
title: title.as_deref(),
original,
source,
target,
is_image: true,
follower,
},
labels,
)
.map(|(replacement, new_ref)| {
(
SpanEdit {
range: span,
replacement,
},
new_ref,
)
})
}
#[derive(Clone, Copy)]
struct ReplacementInput<'a> {
text: &'a str,
url: &'a str,
title: Option<&'a str>,
original: &'a str,
source: Style,
target: Style,
is_image: bool,
follower: Option<u8>,
}
fn build_replacement(
input: ReplacementInput<'_>,
labels: &mut LabelGenerator,
) -> Option<(String, Option<RefDefInsert>)> {
let ReplacementInput {
text,
url,
title,
original,
source,
target,
is_image,
follower,
} = input;
let prefix = if is_image { "!" } else { "" };
match target {
Style::Inline | Style::UrlInline => {
let dest = format_url_destination(url)?;
let title_segment = format_title(title);
Some((format!("{prefix}[{text}]({dest}{title_segment})"), None))
}
Style::Autolink => {
if text != url || !is_autolink_safe(url) {
return None;
}
if is_image {
return None;
}
Some((format!("<{url}>"), None))
}
Style::Full => {
if !matches!(source, Style::Full | Style::Collapsed | Style::Shortcut)
&& format_url_destination(url).is_none()
{
return None;
}
let LabelChoice { label, is_new } = labels.label_for(text, url, title);
let need_def = !matches!(source, Style::Full | Style::Collapsed | Style::Shortcut) && is_new;
let new_ref = need_def.then(|| RefDefInsert {
label: label.clone(),
url: url.to_string(),
title: title.map(ToString::to_string),
});
Some((format!("{prefix}[{text}][{label}]"), new_ref))
}
Style::Collapsed => {
if !is_valid_label_text(text) {
return None;
}
if !label_matches_text(text, source, original) {
return None;
}
if !matches!(source, Style::Full | Style::Collapsed | Style::Shortcut)
&& format_url_destination(url).is_none()
{
return None;
}
let new_ref = match prepare_collapsed_or_shortcut_def(text, url, title, source, labels) {
RefPrep::Reuse => None,
RefPrep::Emit(def) => Some(def),
RefPrep::Unsafe => return None,
};
Some((format!("{prefix}[{text}][]"), new_ref))
}
Style::Shortcut => {
if !is_valid_label_text(text) {
return None;
}
if !label_matches_text(text, source, original) {
return None;
}
if !matches!(source, Style::Full | Style::Collapsed | Style::Shortcut)
&& format_url_destination(url).is_none()
{
return None;
}
if matches!(follower, Some(b'(' | b'[')) {
return None;
}
let new_ref = match prepare_collapsed_or_shortcut_def(text, url, title, source, labels) {
RefPrep::Reuse => None,
RefPrep::Emit(def) => Some(def),
RefPrep::Unsafe => return None,
};
Some((format!("{prefix}[{text}]"), new_ref))
}
}
}
enum RefPrep {
Reuse,
Emit(RefDefInsert),
Unsafe,
}
fn prepare_collapsed_or_shortcut_def(
text: &str,
url: &str,
title: Option<&str>,
source: Style,
labels: &mut LabelGenerator,
) -> RefPrep {
match source {
Style::Full | Style::Collapsed | Style::Shortcut => RefPrep::Reuse,
Style::Inline | Style::UrlInline | Style::Autolink => match labels.reserve_exact(text, url, title) {
None => RefPrep::Unsafe,
Some(LabelChoice { is_new: false, .. }) => RefPrep::Reuse,
Some(LabelChoice { label, is_new: true }) => RefPrep::Emit(RefDefInsert {
label,
url: url.to_string(),
title: title.map(ToString::to_string),
}),
},
}
}
fn is_valid_label_text(text: &str) -> bool {
if text.chars().all(char::is_whitespace) {
return false;
}
!text.contains(['[', ']'])
}
fn label_matches_text(text: &str, source: Style, original: &str) -> bool {
match source {
Style::Full => {
extract_full_ref(original).is_some_and(|r| normalize_label(&r) == normalize_label(text))
}
Style::Collapsed | Style::Shortcut => true, Style::Inline | Style::UrlInline | Style::Autolink => {
true
}
}
}
fn extract_full_ref(span: &str) -> Option<String> {
let bytes = span.as_bytes();
if bytes.last() != Some(&b']') {
return None;
}
let mut depth = 0i32;
for (i, &b) in bytes.iter().enumerate().rev() {
match b {
b']' => depth += 1,
b'[' => {
depth -= 1;
if depth == 0 {
let inner = &span[i + 1..bytes.len() - 1];
return Some(inner.to_string());
}
}
_ => {}
}
}
None
}
fn format_title(title: Option<&str>) -> String {
let Some(t) = title else {
return String::new();
};
let has_backslash = t.contains('\\');
let has_dq = t.contains('"');
let has_sq = t.contains('\'');
let has_paren = t.contains('(') || t.contains(')');
if !has_backslash {
if !has_dq {
return format!(" \"{t}\"");
}
if !has_sq {
return format!(" '{t}'");
}
if !has_paren {
return format!(" ({t})");
}
}
if !has_dq {
format!(" \"{}\"", escape_in_title(t, &['"']))
} else if !has_sq {
format!(" '{}'", escape_in_title(t, &['\'']))
} else if !has_paren {
format!(" ({})", escape_in_title(t, &['(', ')']))
} else {
format!(" \"{}\"", escape_in_title(t, &['"']))
}
}
fn escape_in_title(title: &str, delims: &[char]) -> String {
let mut out = String::with_capacity(title.len() + 4);
for ch in title.chars() {
if ch == '\\' || delims.contains(&ch) {
out.push('\\');
}
out.push(ch);
}
out
}
fn format_url_destination(url: &str) -> Option<String> {
if url.chars().any(|c| c == '\r' || c == '\n' || c.is_ascii_control()) {
return None;
}
let needs_angle = url.is_empty()
|| url.starts_with('<')
|| url.contains(' ')
|| url.contains('\t')
|| url.contains(['<', '>'])
|| !parens_balanced(url);
if !needs_angle {
return Some(url.to_string());
}
let mut out = String::with_capacity(url.len() + 4);
out.push('<');
for ch in url.chars() {
if ch == '\\' || ch == '<' || ch == '>' {
out.push('\\');
}
out.push(ch);
}
out.push('>');
Some(out)
}
fn parens_balanced(url: &str) -> bool {
let bytes = url.as_bytes();
let mut depth = 0i32;
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'\\' if i + 1 < bytes.len() => i += 2,
b'(' => {
depth += 1;
i += 1;
}
b')' => {
depth -= 1;
if depth < 0 {
return false;
}
i += 1;
}
_ => i += 1,
}
}
depth == 0
}
fn is_autolink_safe(url: &str) -> bool {
if url.is_empty() {
return false;
}
if url
.chars()
.any(|c| c.is_ascii_control() || c == ' ' || c == '<' || c == '>')
{
return false;
}
is_uri_autolink(url)
}
fn is_uri_autolink(url: &str) -> bool {
let bytes = url.as_bytes();
if !bytes.first().is_some_and(u8::is_ascii_alphabetic) {
return false;
}
let mut i = 1;
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || matches!(bytes[i], b'+' | b'-' | b'.')) {
i += 1;
}
if !(2..=32).contains(&i) {
return false;
}
i < bytes.len() && bytes[i] == b':'
}
pub(super) fn render_ref_def_line(content: &str, def: &RefDefInsert) -> Option<String> {
let dest = format_url_destination(&def.url)?;
let eol = crate::utils::line_ending::detect_line_ending(content);
let mut out = String::with_capacity(def.label.len() + dest.len() + 8);
out.push('[');
out.push_str(&def.label);
out.push_str("]: ");
out.push_str(&dest);
out.push_str(&format_title(def.title.as_deref()));
out.push_str(eol);
Some(out)
}
pub(super) fn render_ref_def_append(content: &str, def: &RefDefInsert) -> Option<String> {
let line = render_ref_def_line(content, def)?;
let eol = crate::utils::line_ending::detect_line_ending(content);
if content.is_empty() {
return Some(line);
}
let trailing = count_trailing_eol_sequences(content);
let mut prefix = String::new();
match trailing {
0 => {
prefix.push_str(eol);
prefix.push_str(eol);
}
1 => prefix.push_str(eol),
_ => {} }
Some(format!("{prefix}{line}"))
}
fn count_trailing_eol_sequences(s: &str) -> usize {
let bytes = s.as_bytes();
let mut count = 0;
let mut i = bytes.len();
while i > 0 {
match bytes[i - 1] {
b'\n' => {
count += 1;
i -= 1;
if i > 0 && bytes[i - 1] == b'\r' {
i -= 1;
}
}
b'\r' => {
count += 1;
i -= 1;
}
_ => break,
}
}
count
}
pub(super) fn apply(content: &str, plan: FixPlan) -> String {
if plan.is_empty() {
return content.to_string();
}
let mut edits: Vec<SpanEdit> = Vec::with_capacity(plan.entries.len());
let mut new_refs: Vec<RefDefInsert> = Vec::new();
for entry in plan.entries {
edits.push(entry.edit);
if let Some(r) = entry.new_ref {
new_refs.push(r);
}
}
edits.sort_by(|a, b| b.range.start.cmp(&a.range.start));
let mut out = content.to_string();
for edit in edits {
out.replace_range(edit.range, &edit.replacement);
}
if !new_refs.is_empty() {
let eol = crate::utils::line_ending::detect_line_ending(content);
let mut seen: HashSet<(String, String, Option<String>)> = HashSet::new();
let mut block = String::new();
for r in &new_refs {
let key = (r.label.clone(), r.url.clone(), r.title.clone());
if !seen.insert(key) {
continue;
}
let Some(dest) = format_url_destination(&r.url) else {
continue;
};
block.push('[');
block.push_str(&r.label);
block.push_str("]: ");
block.push_str(&dest);
block.push_str(&format_title(r.title.as_deref()));
block.push_str(eol);
}
if !block.is_empty() {
let trimmed_end = out.trim_end_matches(['\n', '\r']).len();
out.truncate(trimmed_end);
if !out.is_empty() {
out.push_str(eol);
out.push_str(eol);
}
out.push_str(&block);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_full_ref_simple() {
assert_eq!(extract_full_ref("[text][ref]"), Some("ref".into()));
assert_eq!(extract_full_ref("[a b c][some ref]"), Some("some ref".into()));
}
#[test]
fn extract_full_ref_with_brackets_in_text() {
assert_eq!(extract_full_ref("[`a[0]`][ref]"), Some("ref".into()));
}
#[test]
fn count_trailing_eol_sequences_handles_all_styles() {
assert_eq!(count_trailing_eol_sequences(""), 0);
assert_eq!(count_trailing_eol_sequences("abc"), 0);
assert_eq!(count_trailing_eol_sequences("abc\n"), 1);
assert_eq!(count_trailing_eol_sequences("abc\n\n"), 2);
assert_eq!(count_trailing_eol_sequences("abc\n\n\n"), 3);
assert_eq!(count_trailing_eol_sequences("abc\r\n"), 1);
assert_eq!(count_trailing_eol_sequences("abc\r\n\r\n"), 2);
assert_eq!(count_trailing_eol_sequences("abc\r"), 1);
assert_eq!(count_trailing_eol_sequences("abc\r\r"), 2);
assert_eq!(count_trailing_eol_sequences("abc\r\n\n"), 2);
assert_eq!(count_trailing_eol_sequences("abc\n\r\n"), 2);
}
#[test]
fn render_ref_def_append_pads_exactly_one_blank_line() {
let def = RefDefInsert {
label: "x".to_string(),
url: "https://example.com".to_string(),
title: None,
};
let appended = render_ref_def_append("body", &def).unwrap();
assert_eq!(appended, "\n\n[x]: https://example.com\n");
let appended = render_ref_def_append("body\n", &def).unwrap();
assert_eq!(appended, "\n[x]: https://example.com\n");
let appended = render_ref_def_append("body\n\n", &def).unwrap();
assert_eq!(appended, "[x]: https://example.com\n");
let appended = render_ref_def_append("body\r\n", &def).unwrap();
assert_eq!(appended, "\r\n[x]: https://example.com\r\n");
let appended = render_ref_def_append("body\r\n\n", &def).unwrap();
assert_eq!(appended, "[x]: https://example.com\n");
}
#[test]
fn format_title_picks_non_conflicting_delimiter() {
assert_eq!(format_title(Some("plain")), r#" "plain""#);
assert_eq!(format_title(Some(r#"has "double""#)), r#" 'has "double"'"#);
assert_eq!(format_title(Some("has 'single'")), r#" "has 'single'""#);
assert_eq!(format_title(Some(r#""and 'both'"#)), r#" ("and 'both')"#);
assert_eq!(
format_title(Some(r#""both' (and parens)"#)),
r#" "\"both' (and parens)""#
);
assert_eq!(format_title(None), "");
}
#[test]
fn format_title_escapes_backslashes_to_round_trip() {
assert_eq!(format_title(Some(r"\")), r#" "\\""#);
assert_eq!(format_title(Some("\\\"")), r#" '\\"'"#); assert_eq!(format_title(Some("\\'")), r#" "\\'""#); assert_eq!(format_title(Some("\\\"'(")), r#" "\\\"'(""#);
}
#[test]
fn format_title_round_trips_through_pulldown() {
use pulldown_cmark::{Event, Tag};
let cases = [
"plain",
r#"has "double""#,
"has 'single'",
r#""and 'both'"#,
r#""both' (and parens)"#,
r"\",
"\\\"",
"\\'",
"\\\"'(",
"ends with backslash\\",
"interior \\backslash inside",
];
for original in cases {
let formatted = format_title(Some(original));
let doc = format!("[id]: https://example.com{formatted}\n\n[id]\n");
let parser = pulldown_cmark::Parser::new(&doc);
let mut recovered: Option<String> = None;
for event in parser {
if let Event::Start(Tag::Link { title, .. }) = event {
recovered = Some(title.to_string());
break;
}
}
assert_eq!(
recovered.as_deref(),
Some(original),
"format_title({original:?}) did not round-trip; emitted={formatted:?}"
);
}
}
#[test]
fn is_autolink_safe_basic() {
assert!(is_autolink_safe("https://example.com"));
assert!(is_autolink_safe("ftp://x.org/a"));
assert!(!is_autolink_safe(""));
assert!(!is_autolink_safe("/relative"));
assert!(!is_autolink_safe("has space.com"));
assert!(!is_autolink_safe("<https://x>"));
}
#[test]
fn is_autolink_safe_rejects_control_characters() {
assert!(!is_autolink_safe("https://x.com/\t"));
assert!(!is_autolink_safe("https://x.com/\npath"));
assert!(!is_autolink_safe("https://x.com/\r"));
assert!(!is_autolink_safe("https://x.com/\u{7f}")); assert!(!is_autolink_safe("https://x.com/\u{0}"));
}
#[test]
fn is_autolink_safe_scheme_validation() {
assert!(!is_autolink_safe("a:b"));
assert!(!is_autolink_safe("1ftp://x"));
assert!(!is_autolink_safe("-bad:rest"));
assert!(is_autolink_safe("git+ssh://example.com/repo"));
assert!(is_autolink_safe("x-custom.scheme:rest"));
}
#[test]
fn is_autolink_safe_rejects_bare_emails() {
assert!(!is_autolink_safe("me@example.com"));
assert!(!is_autolink_safe("first.last@sub.example.co.uk"));
assert!(!is_autolink_safe("a+b@example.com"));
assert!(is_autolink_safe("mailto:me@example.com"));
assert!(is_autolink_safe("mailto:first.last@sub.example.co.uk"));
}
#[test]
fn parens_balanced_basic() {
assert!(parens_balanced("plain"));
assert!(parens_balanced("a(b)c"));
assert!(parens_balanced("a(b(c)d)e"));
assert!(!parens_balanced("a(b"));
assert!(!parens_balanced("a)b"));
assert!(!parens_balanced("a)b("));
assert!(parens_balanced(r"a\(b"));
assert!(parens_balanced(r"a\)b"));
}
#[test]
fn format_url_destination_uses_bare_when_safe() {
assert_eq!(
format_url_destination("https://example.com").as_deref(),
Some("https://example.com")
);
assert_eq!(
format_url_destination("https://x.com/a(b)c").as_deref(),
Some("https://x.com/a(b)c")
);
}
#[test]
fn format_url_destination_uses_angle_for_spaces_and_unbalanced_parens() {
assert_eq!(
format_url_destination("./has space.md").as_deref(),
Some("<./has space.md>")
);
assert_eq!(
format_url_destination("https://x.com/a)b").as_deref(),
Some("<https://x.com/a)b>")
);
assert_eq!(
format_url_destination("https://x.com/a(b").as_deref(),
Some("<https://x.com/a(b>")
);
}
#[test]
fn format_url_destination_escapes_brackets_inside_angles() {
assert_eq!(format_url_destination("a<b>c").as_deref(), Some(r"<a\<b\>c>"));
}
#[test]
fn format_url_destination_rejects_line_breaks() {
assert_eq!(format_url_destination("a\nb"), None);
assert_eq!(format_url_destination("a\rb"), None);
}
#[test]
fn format_url_destination_round_trips_through_pulldown() {
use pulldown_cmark::{Event, Tag};
let cases = [
"https://example.com",
"./relative/path.md",
"./has space.md",
"https://x.com/a(b)c",
"https://x.com/a)b",
"https://x.com/a(b",
"a<b>c",
];
for url in cases {
let dest = format_url_destination(url).expect("expected serializable URL");
let doc = format!("[t]({dest})\n");
let parser = pulldown_cmark::Parser::new(&doc);
let mut recovered: Option<String> = None;
for event in parser {
if let Event::Start(Tag::Link { dest_url, .. }) = event {
recovered = Some(dest_url.to_string());
break;
}
}
assert_eq!(
recovered.as_deref(),
Some(url),
"format_url_destination({url:?}) did not round-trip; emitted={dest:?}"
);
}
}
#[test]
fn is_autolink_safe_rejects_non_uri_strings() {
assert!(!is_autolink_safe(""));
assert!(!is_autolink_safe("plain text"));
assert!(!is_autolink_safe("./relative/path.md"));
assert!(!is_autolink_safe("a:short-scheme"));
let long_scheme = "a".repeat(33);
assert!(!is_autolink_safe(&format!("{long_scheme}:rest")));
}
}