use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use std::collections::BTreeMap;
use std::ops::Range;
#[must_use]
pub fn slugify(s: &str) -> String {
let mut out = String::new();
let mut prev_dash = false;
for c in s.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
prev_dash = false;
} else if !prev_dash {
out.push('-');
prev_dash = true;
}
}
out.trim_matches('-').to_owned()
}
#[must_use]
pub fn markdown_dialect() -> Options {
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
opts
}
#[must_use]
pub fn first_h1(md: &str) -> Option<String> {
let mut text: Option<String> = None;
for event in Parser::new_ext(md, markdown_dialect()) {
match event {
Event::Start(Tag::Heading {
level: HeadingLevel::H1,
..
}) => text = Some(String::new()),
Event::Text(t) | Event::Code(t) => {
if let Some(text) = text.as_mut() {
text.push_str(&t);
}
}
Event::End(TagEnd::Heading(HeadingLevel::H1)) => break,
_ => {}
}
}
text.map(|t| t.trim().to_owned()).filter(|t| !t.is_empty())
}
#[must_use]
pub fn heading_text(source: &str) -> String {
first_h1(&format!("# {source}")).unwrap_or_default()
}
#[must_use]
pub fn heading_id_from(explicit: Option<&str>, text: &str) -> String {
explicit
.map(str::trim)
.filter(|e| !e.is_empty())
.map_or_else(|| slugify(text), ToOwned::to_owned)
}
#[must_use]
pub fn heading_id(source: &str) -> String {
let md = format!("# {source}");
let (mut explicit, mut text) = (None, String::new());
let mut open = false;
for event in Parser::new_ext(&md, markdown_dialect()) {
match event {
Event::Start(Tag::Heading { id, .. }) => {
explicit = id.map(|i| i.to_string());
open = true;
}
Event::Text(t) | Event::Code(t) if open => text.push_str(&t),
Event::End(TagEnd::Heading(_)) => break,
_ => {}
}
}
heading_id_from(explicit.as_deref(), text.trim())
}
#[cfg(test)]
mod tests {
use super::{first_h1, heading_id, heading_text, headings, slugify};
#[test]
fn a_heading_is_more_than_a_line_starting_with_two_hashes() {
let md = "# Title\n\n\
> ## Quoted\n\n\
\u{20}\u{20}## Indented\n\n\
Setext\n---\n\n\
```\n## Not a heading\n```\n\n\
## Plain\n";
let hs = headings(md);
let ids: Vec<&str> = hs.iter().map(|h| h.id.as_str()).collect();
assert_eq!(
ids,
["title", "quoted", "indented", "setext", "plain"],
"blockquoted, indented and setext headings are headings; a fenced \
`## ` is not"
);
}
#[test]
fn heading_offsets_ascend_and_point_at_the_heading_not_its_container() {
let md = "## First\n\ntext\n\n> ## Quoted\n\nmore\n";
let hs = headings(md);
assert_eq!(hs.len(), 2);
assert!(hs[0].start < hs[1].start, "document order: {hs:?}");
assert!(
md[hs[1].start..].starts_with("## Quoted"),
"offset points at the heading, not its container: {:?}",
&md[hs[1].start..]
);
}
#[test]
fn a_repeated_id_is_suffixed_and_the_count_spans_every_level() {
let ids = |md: &str| -> Vec<String> { headings(md).into_iter().map(|h| h.id).collect() };
assert_eq!(ids("## A {#same}\n\n## B {#same}\n"), ["same", "same-2"]);
assert_eq!(
ids("## Dup\n\n## Dup\n\n## Dup\n"),
["dup", "dup-2", "dup-3"]
);
assert_eq!(ids("# Same\n\n## Same\n"), ["same", "same-2"]);
assert_eq!(ids("### Same\n\n## Same\n"), ["same", "same-2"]);
assert_eq!(ids("> ## X\n\n## X\n"), ["x", "x-2"]);
}
#[test]
fn a_heading_that_names_nothing_falls_back_to_its_position() {
let hs = headings("# Title\n\n## ###\n\n## Real\n");
let ids: Vec<&str> = hs.iter().map(|h| h.id.as_str()).collect();
assert_eq!(ids, ["title", "section-2", "real"]);
let hs = headings("## ###\n\n## ###\n");
let ids: Vec<&str> = hs.iter().map(|h| h.id.as_str()).collect();
assert_eq!(ids, ["section-1", "section-2"]);
}
#[test]
fn a_heading_carries_its_level_text_and_declared_id() {
let hs = headings("### Design *notes* {#arch}\n");
assert_eq!(hs.len(), 1);
assert_eq!(hs[0].level, 3);
assert_eq!(hs[0].text, "Design notes", "markup reduced");
assert_eq!(hs[0].id, "arch", "the declared anchor, not the slug");
}
#[test]
fn a_reference_style_link_cannot_resolve_without_its_document() {
assert_eq!(heading_id("See [the plan][plan]"), "see-the-plan-plan");
assert_eq!(heading_id("See [the plan](plan.md)"), "see-the-plan");
assert_eq!(heading_id("See [the plan]"), "see-the-plan");
}
#[test]
fn collapses_punctuation_and_trims() {
assert_eq!(slugify("Install & build"), "install-build");
assert_eq!(
slugify("The five ways to run it"),
"the-five-ways-to-run-it"
);
assert_eq!(slugify(" §2 — Context! "), "2-context");
assert_eq!(
slugify("Cross-repo: a hub and its spokes"),
"cross-repo-a-hub-and-its-spokes"
);
assert_eq!(slugify("!!!"), "");
}
#[test]
fn an_attribute_block_is_markup_not_part_of_the_title() {
let title = first_h1("# The five ways to run it {#modes}\n").expect("an h1");
assert_eq!(title, "The five ways to run it");
assert!(
!title.contains("{#"),
"an attribute block must not survive into a title: {title:?}"
);
}
#[test]
fn both_entry_points_agree_on_where_a_heading_ends() {
for source in [
"The five ways to run it {#modes}",
"Sets like {#1, #2}",
"The `--json` flag",
"See [the docs](x.md)",
"A ~~retracted~~ claim",
"Install & build",
"",
] {
assert_eq!(
first_h1(&format!("# {source}")).unwrap_or_default(),
heading_text(source),
"the two entry points disagreed about {source:?}"
);
}
}
#[test]
fn a_heading_inside_a_fence_is_a_code_sample() {
let md = "```\n# Widget — Technical Implementation Plan\n```\n\n# Real title\n";
assert_eq!(first_h1(md).as_deref(), Some("Real title"));
assert_eq!(
first_h1("```\n# Fenced only\n```\n"),
None,
"a fenced `#` is not a heading at all"
);
}
#[test]
fn a_setext_heading_is_an_h1() {
assert_eq!(
first_h1("Underlined title\n===\n").as_deref(),
Some("Underlined title")
);
}
#[test]
fn an_empty_heading_names_nothing() {
assert_eq!(first_h1("#\n\n# Second\n"), None);
assert_eq!(heading_text(""), "");
}
#[test]
fn heading_text_feeds_slugify_the_text_a_reader_sees() {
assert_eq!(
slugify(&heading_text("1 · Offline mode — the default {#offline}")),
"1-offline-mode-the-default"
);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Heading {
pub level: u8,
pub id: String,
pub text: String,
pub start: usize,
}
#[must_use]
pub fn headings(md: &str) -> Vec<Heading> {
let mut out: Vec<Heading> = Vec::new();
let mut seen: BTreeMap<String, usize> = BTreeMap::new();
let mut open: Option<(u8, Option<String>, String, usize)> = None;
for (event, range) in Parser::new_ext(md, markdown_dialect()).into_offset_iter() {
match event {
Event::Start(Tag::Heading { level, id, .. }) => {
let level = match level {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
};
open = Some((level, id.map(|i| i.to_string()), String::new(), range.start));
}
Event::Text(t) | Event::Code(t) => {
if let Some((_, _, text, _)) = open.as_mut() {
text.push_str(&t);
}
}
Event::End(TagEnd::Heading(_)) => {
if let Some((level, explicit, text, start)) = open.take() {
let text = text.trim().to_owned();
let claimed = heading_id_from(explicit.as_deref(), &text);
let claimed = if claimed.is_empty() {
format!("section-{}", out.len() + 1)
} else {
claimed
};
let n = seen.entry(claimed.clone()).or_insert(0);
*n += 1;
let id = if *n == 1 {
claimed
} else {
format!("{claimed}-{n}")
};
out.push(Heading {
level,
id,
text,
start,
});
}
}
_ => {}
}
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum LinkKind {
Wiki,
Inline,
Image,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum LinkScope {
Internal,
External,
}
impl LinkScope {
#[must_use]
pub fn is_external(self) -> bool {
matches!(self, Self::External)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct MarkdownLink {
kind: LinkKind,
target: String,
text: String,
scope: LinkScope,
span: Range<usize>,
}
impl MarkdownLink {
#[must_use]
pub fn kind(&self) -> LinkKind {
self.kind
}
#[must_use]
pub fn target(&self) -> &str {
&self.target
}
#[must_use]
pub fn text(&self) -> &str {
&self.text
}
#[must_use]
pub fn scope(&self) -> LinkScope {
self.scope
}
#[must_use]
pub fn span(&self) -> Range<usize> {
self.span.clone()
}
fn new(
kind: LinkKind,
target: &str,
text: &str,
span: Range<usize>,
line: &str,
) -> Option<Self> {
let target = target.trim();
if target.is_empty() {
return None;
}
debug_assert!(
span.start < span.end
&& span.end <= line.len()
&& line.is_char_boundary(span.start)
&& line.is_char_boundary(span.end),
"{kind:?} link span {span:?} does not address {line:?}"
);
let wiki = kind == LinkKind::Wiki;
Some(Self {
kind,
text: if wiki { target } else { text }.to_owned(),
scope: if wiki {
LinkScope::Internal
} else {
link_scope(target)
},
target: target.to_owned(),
span,
})
}
}
#[must_use]
pub fn markdown_links(line: &str) -> Vec<MarkdownLink> {
let (stripped, map) = strip_and_map(line);
let wiki = wiki_spans(&stripped);
let mut out: Vec<MarkdownLink> = wiki
.iter()
.filter_map(|(range, target)| {
MarkdownLink::new(
LinkKind::Wiki,
target,
target,
map.start(range.start)..map.end(range.end),
line,
)
})
.collect();
out.extend(
inline_spans(line, &stripped, &wiki, &map)
.into_iter()
.filter_map(|(kind, span, text, destination)| {
MarkdownLink::new(
kind,
&destination,
&line[text],
span,
line,
)
}),
);
out.sort_by_key(|l| l.span.start);
out
}
#[must_use]
pub fn wiki_link_targets(line: &str) -> Vec<String> {
markdown_links(line)
.into_iter()
.filter(|l| l.kind == LinkKind::Wiki)
.map(|l| l.target)
.collect()
}
#[must_use]
pub fn link_scope(destination: &str) -> LinkScope {
let destination = destination.trim();
if destination.starts_with("//") {
return LinkScope::External;
}
let Some((scheme, _)) = destination.split_once(':') else {
return LinkScope::Internal;
};
let mut chars = scheme.chars();
let valid = chars.next().is_some_and(|c| c.is_ascii_alphabetic())
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
if valid {
LinkScope::External
} else {
LinkScope::Internal
}
}
#[must_use]
pub fn is_code_fence(line: &str) -> bool {
let trimmed = line.trim_start();
trimmed.starts_with("```") || trimmed.starts_with("~~~")
}
#[must_use]
pub fn strip_code_spans(line: &str) -> String {
strip_and_map(line).0
}
#[must_use]
pub fn code_spans(line: &str) -> Vec<(usize, usize)> {
let bytes = line.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'`' || is_escaped(bytes, i) {
i += 1;
continue;
}
let run_start = i;
while i < bytes.len() && bytes[i] == b'`' {
i += 1;
}
let run = i - run_start;
let mut j = i;
let mut close = None;
while j < bytes.len() {
if bytes[j] == b'`' {
let s = j;
while j < bytes.len() && bytes[j] == b'`' {
j += 1;
}
if j - s == run {
close = Some(j);
break;
}
} else {
j += 1;
}
}
if let Some(end) = close {
out.push((run_start, end));
i = end;
}
}
out
}
struct SpanMap(
)`, in
Vec<(usize, usize, usize)>,
);
impl SpanMap {
fn start(&self, at: usize) -> usize {
self.at(at, |chunk_start| at >= chunk_start)
}
fn end(&self, at: usize) -> usize {
self.at(at, |chunk_start| at > chunk_start)
}
fn widest(&self, range: &Range<usize>) -> Range<usize> {
self.end(range.start)..self.start(range.end)
}
fn stripped(&self, at: usize) -> usize {
self.0
.iter()
.rev()
.find(|(_, source, _)| *source <= at)
.map_or(at, |(start, source, len)| start + (at - source).min(*len))
}
fn at(&self, at: usize, keep: impl Fn(usize) -> bool) -> usize {
self.0
.iter()
.rev()
.find(|(start, _, _)| keep(*start))
.map_or(at, |(start, source, _)| source + (at - start))
}
}
fn strip_and_map(line: &str) -> (String, SpanMap) {
let mut stripped = String::with_capacity(line.len());
let mut chunks = Vec::new();
let mut at = 0;
for (start, end) in code_spans(line) {
if start > at {
chunks.push((stripped.len(), at, start - at));
stripped.push_str(&line[at..start]);
}
at = end;
}
if at < line.len() {
chunks.push((stripped.len(), at, line.len() - at));
stripped.push_str(&line[at..]);
}
(stripped, SpanMap(chunks))
}
fn wiki_spans(stripped: &str) -> Vec<(Range<usize>, String)> {
let mut out = Vec::new();
let mut base = 0usize;
let mut rest = stripped;
while let Some(open) = rest.find("[[") {
let after = &rest[open + 2..];
let Some(close) = after.find("]]") else {
break;
};
let inner = after[..close].trim();
let start = base + open;
let end = start + 2 + close + 2;
if !inner.is_empty() {
out.push((start..end, inner.to_owned()));
}
base = end;
rest = &after[close + 2..];
}
out
}
fn inline_spans(
line: &str,
stripped: &str,
wiki: &[(Range<usize>, String)],
map: &SpanMap,
) -> Vec<(LinkKind, Range<usize>, Range<usize>, String)> {
let bytes = stripped.as_bytes();
let source = line.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\\' {
i += 2;
continue;
}
if bytes[i] != b'[' {
i += 1;
continue;
}
let step = wiki
.iter()
.find(|(r, _)| r.contains(&i))
.map_or(i + 1, |(r, _)| r.end);
let Some((text, destination, end)) = inline_at(line, stripped, map, i) else {
i = step;
continue;
};
let open = map.start(i);
let image = open > 0 && source[open - 1] == b'!' && !is_escaped(source, open - 1);
let start = if image { open - 1 } else { open };
let kind = if image {
LinkKind::Image
} else {
LinkKind::Inline
};
out.push((kind, start..end, map.widest(&text), destination));
i = map.stripped(end).max(i + 1);
}
out
}
fn inline_at(
line: &str,
stripped: &str,
map: &SpanMap,
open: usize,
) -> Option<(Range<usize>, String, usize)> {
let bytes = stripped.as_bytes();
let close = matching(bytes, open, b'[', b']')?;
if bytes.get(close + 1) != Some(&b'(') {
return None;
}
let paren = map.start(close + 1);
if paren != map.start(close) + 1 {
return None;
}
let dest_end = destination_end(line.as_bytes(), paren)?;
let destination = destination_of(&line[paren + 1..dest_end])?;
Some((open + 1..close, destination, dest_end + 1))
}
fn unescaped(s: &str, byte: u8) -> Option<usize> {
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\\' {
i += 2;
continue;
}
if bytes[i] == byte {
return Some(i);
}
i += 1;
}
None
}
fn is_escaped(bytes: &[u8], at: usize) -> bool {
bytes[..at]
.iter()
.rev()
.take_while(|b| **b == b'\\')
.count()
% 2
== 1
}
fn destination_end(bytes: &[u8], from: usize) -> Option<usize> {
let mut i = from + 1;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
let mut angle = false;
if bytes.get(i) == Some(&b'<') {
i += 1;
loop {
let byte = *bytes.get(i)?;
i += 1;
if byte == b'\\' {
i += 1;
} else if byte == b'>' {
break;
}
}
angle = true;
}
let mut depth = 1usize;
let mut quote: Option<u8> = None;
let mut after_space = angle;
while i < bytes.len() {
if bytes[i] == b'\\' {
i += 2;
continue;
}
match quote {
Some(open) if bytes[i] == open => quote = None,
Some(_) => {}
None => match bytes[i] {
b'"' | b'\'' if after_space && depth == 1 => quote = Some(bytes[i]),
b'(' => depth += 1,
b')' => {
depth = depth.checked_sub(1)?;
if depth == 0 {
return Some(i);
}
}
b if b.is_ascii_whitespace() => after_space = true,
_ => {}
},
}
i += 1;
}
None
}
fn matching(bytes: &[u8], from: usize, open: u8, shut: u8) -> Option<usize> {
let mut depth = 0usize;
let mut i = from;
while i < bytes.len() {
if bytes[i] == b'\\' {
i += 2;
continue;
}
if bytes[i] == open {
depth += 1;
} else if bytes[i] == shut {
depth = depth.checked_sub(1)?;
if depth == 0 {
return Some(i);
}
}
i += 1;
}
None
}
fn destination_of(raw: &str) -> Option<String> {
let raw = raw.trim();
let (destination, rest) = if let Some(rest) = raw.strip_prefix('<') {
let end = unescaped(rest, b'>')?;
if unescaped(&rest[..end], b'<').is_some() {
return None;
}
(&rest[..end], rest[end + 1..].trim_start())
} else {
let end = raw.find(char::is_whitespace).unwrap_or(raw.len());
(&raw[..end], raw[end..].trim_start())
};
if destination.is_empty() || !(rest.is_empty() || is_title(rest)) {
return None;
}
Some(destination.to_owned())
}
fn is_title(rest: &str) -> bool {
let Some(shut) = rest.as_bytes().first().and_then(|b| match b {
b'"' => Some(b'"'),
b'\'' => Some(b'\''),
b'(' => Some(b')'),
_ => None,
}) else {
return false;
};
if rest.len() < 2 || rest.as_bytes()[rest.len() - 1] != shut {
return false;
}
let inner = &rest[1..rest.len() - 1];
unescaped(inner, shut).is_none() && (shut != b')' || unescaped(inner, b'(').is_none())
}
#[cfg(test)]
mod link_tests {
use super::{
LinkKind, LinkScope, code_spans, is_code_fence, link_scope, markdown_links,
strip_code_spans, wiki_link_targets,
};
fn scanned(line: &str) -> Vec<(LinkKind, String, String, bool)> {
markdown_links(line)
.into_iter()
.map(|l| (l.kind, l.target, l.text, l.scope.is_external()))
.collect()
}
#[test]
fn an_escaped_backtick_opens_no_span() {
assert_eq!(code_spans(r"a \` b `code` c").len(), 1);
assert_eq!(strip_code_spans(r"a \` b `code` c"), r"a \` b c");
assert_eq!(code_spans(r"a \\`code` b").len(), 1);
assert_eq!(wiki_link_targets(r"\` [[docs/x.md]]"), vec!["docs/x.md"]);
}
#[test]
fn an_escape_before_a_closing_run_still_closes_the_span() {
assert_eq!(code_spans(r"`a\` and [[docs/x.md]]").len(), 1);
assert_eq!(
wiki_link_targets(r"`a\` and [[docs/x.md]]"),
vec!["docs/x.md"]
);
assert_eq!(strip_code_spans(r"`a\` rest"), " rest");
}
#[test]
fn removes_single_and_multi_backtick_spans() {
assert_eq!(strip_code_spans("a `code` b"), "a b");
assert_eq!(strip_code_spans("see ``@rto:0001`` here"), "see here");
assert_eq!(strip_code_spans("x ```fenced inline``` y"), "x y");
}
#[test]
fn keeps_unmatched_backticks_and_plain_text() {
assert_eq!(strip_code_spans("no code here"), "no code here");
assert_eq!(strip_code_spans("unmatched ` tick"), "unmatched ` tick");
assert_eq!(strip_code_spans("``open ` mid"), "``open ` mid");
}
#[test]
fn preserves_utf8_outside_spans() {
assert_eq!(strip_code_spans("café `x` — ok"), "café — ok");
}
#[test]
fn a_link_inside_a_code_span_is_not_a_link() {
assert!(scanned("see `[[docs/x.md#Sym]]` for the form").is_empty());
assert!(scanned("write `[label](target.md)` like this").is_empty());
assert!(scanned("``[[a/b.md]]`` and ```[c](d.md)```").is_empty());
assert_eq!(
scanned("`[[example]]` but [[docs/real.md]] resolves")
.iter()
.map(|(_, t, _, _)| t.as_str())
.collect::<Vec<_>>(),
vec!["docs/real.md"]
);
}
#[test]
fn an_unclosed_code_span_hides_nothing() {
assert_eq!(wiki_link_targets("` [[docs/x.md]]"), vec!["docs/x.md"]);
assert_eq!(
scanned("`` [text](docs/x.md)")
.iter()
.map(|(_, t, _, _)| t.as_str())
.collect::<Vec<_>>(),
vec!["docs/x.md"]
);
}
#[test]
fn a_link_straddling_a_code_span_is_one_link() {
let line = "[[docs/`x`.md]]";
assert_eq!(wiki_link_targets(line), vec!["docs/.md"]);
assert_eq!(markdown_links(line)[0].span, 0..line.len());
}
#[test]
fn a_link_ending_where_a_code_span_begins_stops_at_its_own_close() {
let line = "[[crates/x.rs#Sym]]`::field` and prose";
let links = markdown_links(line);
assert_eq!(&line[links[0].span.clone()], "[[crates/x.rs#Sym]]");
let line = "[t](docs/x.md)`::field`";
assert_eq!(
&line[markdown_links(line)[0].span.clone()],
"[t](docs/x.md)"
);
}
#[test]
fn a_fence_is_not_visible_from_one_line() {
assert!(is_code_fence("```"));
assert!(is_code_fence(" ```rust"));
assert!(is_code_fence("~~~"));
assert!(!is_code_fence("a ``` b"));
assert!(is_code_fence(" ```json"));
assert!(is_code_fence("\t```"));
assert_eq!(
wiki_link_targets("[[docs/fenced.md]]"),
vec!["docs/fenced.md"]
);
assert_eq!(
wiki_link_targets(" [[docs/indented.md]]"),
vec!["docs/indented.md"]
);
assert!(is_code_fence("``` [[docs/straddle.md]]"));
assert_eq!(
wiki_link_targets("``` [[docs/straddle.md]]"),
vec!["docs/straddle.md"]
);
}
#[test]
fn wiki_links_are_trimmed_and_never_empty() {
assert_eq!(
wiki_link_targets("[[ docs/x.md#Sym ]]"),
vec!["docs/x.md#Sym"]
);
assert!(wiki_link_targets("[[]] and [[ ]]").is_empty());
assert_eq!(
wiki_link_targets("[[a.md]] then [[b.md]]"),
vec!["a.md", "b.md"]
);
}
#[test]
fn an_unclosed_wiki_link_ends_the_scan() {
assert_eq!(
wiki_link_targets("[[unclosed and [[docs/x.md]]"),
vec!["unclosed and [[docs/x.md"]
);
assert_eq!(
wiki_link_targets("[[docs/x.md]] then [[unclosed"),
vec!["docs/x.md"]
);
}
#[test]
fn a_wiki_link_is_internal_and_labels_itself() {
assert_eq!(
scanned("[[docs/adr/0001-x.md#Design]]"),
vec![(
LinkKind::Wiki,
"docs/adr/0001-x.md#Design".to_owned(),
"docs/adr/0001-x.md#Design".to_owned(),
false,
)]
);
}
#[test]
fn an_inline_link_yields_its_text_and_destination() {
assert_eq!(
scanned("see [the ADR](docs/adr/0026-x.md) for why"),
vec![(
LinkKind::Inline,
"docs/adr/0026-x.md".to_owned(),
"the ADR".to_owned(),
false,
)]
);
}
#[test]
fn a_destination_is_internal_or_external_by_its_scheme() {
for internal in [
"docs/x.md",
"../README.md",
"/docs/x.md",
"#a-section",
"x.md#a:b",
"C/x.md",
] {
assert_eq!(
link_scope(internal),
LinkScope::Internal,
"{internal} is in this repository"
);
}
for external in [
"https://example.org/a",
"http://example.org",
"mailto:a@b.c",
"//example.org/a",
"ftp://example.org",
"tel:+441234",
] {
assert_eq!(
link_scope(external),
LinkScope::External,
"{external} is somebody else's"
);
}
}
#[test]
fn nesting_and_titles_are_read_the_way_commonmark_writes_them() {
assert_eq!(
scanned("[see [x]](docs/y.md)"),
vec![(
LinkKind::Inline,
"docs/y.md".to_owned(),
"see [x]".to_owned(),
false,
)]
);
assert_eq!(
scanned(r#"[t](docs/y.md "A title")"#)
.iter()
.map(|(_, t, _, _)| t.as_str())
.collect::<Vec<_>>(),
vec!["docs/y.md"]
);
assert_eq!(
scanned("[t](https://e.org/A_(b))")
.iter()
.map(|(_, t, _, _)| t.as_str())
.collect::<Vec<_>>(),
vec!["https://e.org/A_(b)"]
);
assert_eq!(
scanned("[t](<a b.md>)")
.iter()
.map(|(_, t, _, _)| t.as_str())
.collect::<Vec<_>>(),
vec!["a b.md"]
);
}
#[test]
fn malformed_and_empty_inline_links_are_not_links() {
assert!(scanned("[text](unclosed").is_empty());
assert!(scanned("[text without a destination]").is_empty());
assert!(scanned("[text]()").is_empty());
assert!(scanned(r"\[not a link](docs/x.md)").is_empty());
assert!(scanned("[t](<unclosed)").is_empty());
assert!(scanned("[t](foo bar)").is_empty());
assert!(scanned("[t](docs/x.md not-a-title)").is_empty());
}
#[test]
fn an_image_is_its_own_kind_and_starts_at_the_bang() {
let line = "";
assert_eq!(
scanned(line),
vec![(
LinkKind::Image,
"docs/x.png".to_owned(),
"a diagram".to_owned(),
false,
)]
);
assert_eq!(&line[markdown_links(line)[0].span.clone()], line);
assert_eq!(
scanned(r"\")
.iter()
.map(|(_, t, _, _)| t.as_str())
.collect::<Vec<_>>(),
vec!["docs/x.md"]
);
assert_eq!(
scanned(" and [t](docs/x.md)")
.iter()
.map(|(k, t, _, _)| (*k, t.as_str()))
.collect::<Vec<_>>(),
vec![(LinkKind::Image, "a.png"), (LinkKind::Inline, "docs/x.md")]
);
}
#[test]
fn a_wiki_link_in_alt_text_is_reported_because_the_gate_counts_it() {
assert_eq!(
scanned("![alt [[docs/x.md]]](i.png)")
.iter()
.map(|(k, t, _, _)| (*k, t.as_str()))
.collect::<Vec<_>>(),
vec![(LinkKind::Image, "i.png"), (LinkKind::Wiki, "docs/x.md")]
);
let links = markdown_links("![alt [[docs/x.md]]](i.png)");
assert!(links[0].span.start < links[1].span.start);
assert!(links[0].span.end > links[1].span.end);
}
#[test]
fn an_inline_link_may_enclose_a_wiki_link() {
let line = "[See [[docs/x.md]]](target.md)";
let links = markdown_links(line);
assert_eq!(
links
.iter()
.map(|l| (l.kind, l.target.as_str()))
.collect::<Vec<_>>(),
vec![
(LinkKind::Inline, "target.md"),
(LinkKind::Wiki, "docs/x.md")
]
);
assert_eq!(&line[links[0].span.clone()], line);
assert_eq!(&line[links[1].span.clone()], "[[docs/x.md]]");
for kind in [LinkKind::Wiki, LinkKind::Inline] {
let mut at = 0;
for l in markdown_links(line).iter().filter(|l| l.kind == kind) {
assert!(l.span.start >= at, "{kind:?} spans overlap");
at = l.span.end;
}
}
}
#[test]
fn a_code_span_splits_a_link_only_between_its_bracket_and_its_paren() {
assert!(scanned("[a]`x`(docs/b.md)").is_empty());
assert!(scanned("[not a `link](/foo`)").is_empty());
assert_eq!(scanned("[x](a`b`c.md)")[0].1, "a`b`c.md");
assert_eq!(scanned("[x](`docs/x.md`)")[0].1, "`docs/x.md`");
assert_eq!(scanned("[t](docs/x.md \"a `b`\")")[0].1, "docs/x.md");
assert_eq!(scanned("[the `Foo` type](docs/x.md)")[0].1, "docs/x.md");
assert_eq!(
scanned("[the `Foo` type](docs/x.md)")[0].2,
"the `Foo` type"
);
}
#[test]
fn a_code_span_before_a_link_does_not_make_it_an_image() {
let line = "!`x`[label](target)";
assert_eq!(scanned(line)[0].0, LinkKind::Inline);
assert_eq!(markdown_links(line)[0].span, 4..19);
let line = "`q`";
assert_eq!(scanned(line)[0].0, LinkKind::Image);
assert_eq!(markdown_links(line)[0].span, 3..10);
assert_eq!(scanned("\\")[0].0, LinkKind::Inline);
}
#[test]
fn an_image_whose_alt_text_is_a_wiki_token_is_still_an_image() {
let line = "![[a]](target)";
assert_eq!(
scanned(line)
.iter()
.map(|(k, t, _, _)| (*k, t.clone()))
.collect::<Vec<_>>(),
vec![
(LinkKind::Image, "target".to_owned()),
(LinkKind::Wiki, "a".to_owned()),
]
);
assert_eq!(wiki_link_targets(line), vec!["a"]);
assert_eq!(scanned("[[a]]").len(), 1);
}
#[test]
fn an_angle_destination_may_hold_what_would_otherwise_close_the_link() {
let line = "[t](<https://e.org/a_(b)>) after";
let links = markdown_links(line);
assert_eq!(links[0].target, "https://e.org/a_(b)");
assert_eq!(&line[links[0].span.clone()], "[t](<https://e.org/a_(b)>)");
assert!(links[0].scope.is_external());
assert_eq!(scanned(r#"[t](<a "b".md>)"#)[0].1, r#"a "b".md"#);
assert_eq!(scanned(r"[t](<a\>b.md>)")[0].1, r"a\>b.md");
assert!(scanned("[t](<https://e.org/a").is_empty());
}
#[test]
fn an_unescaped_angle_bracket_is_not_an_angle_destination() {
assert!(scanned("[t](<a<b>)").is_empty());
assert!(scanned("[t](<docs/<x.md>)").is_empty());
assert_eq!(scanned(r"[t](<a\<b>)")[0].1, r"a\<b");
assert_eq!(scanned("[t](<a b.md>)")[0].1, "a b.md");
}
#[test]
fn a_destination_that_names_nothing_is_not_a_link() {
for line in [
"[t](< >)",
"[t](< >)",
"[t](<>)",
"[t]( )",
"[t]()",
"[[ ]]",
] {
assert!(scanned(line).is_empty(), "{line:?} should not be a link");
}
assert_eq!(scanned("[t](< docs/x.md >)")[0].1, "docs/x.md");
assert_eq!(
scanned("[t](< https://e.org/a >)"),
vec![(
LinkKind::Inline,
"https://e.org/a".to_owned(),
"t".to_owned(),
true,
)]
);
}
#[test]
fn a_destination_takes_one_title_and_no_more() {
for line in [
r#"[t](x.md "one" "two")"#,
r#"[t](x.md "a"x"b")"#,
r#"[t](<x.md> "a" "b")"#,
"[t](x.md (a)b(c))",
"[t](x.md (a(b)c))",
r#"[t](x.md 'a' "b")"#,
] {
assert!(scanned(line).is_empty(), "{line:?} should not be a link");
}
for line in [
r#"[t](x.md "title")"#,
r"[t](x.md 'title')",
"[t](x.md (title))",
r#"[t](x.md "")"#,
r"[t](x.md '')",
"[t](x.md ())",
] {
assert_eq!(scanned(line)[0].1, "x.md", "{line:?} should be a link");
}
assert_eq!(scanned(r#"[t](x.md "a\"b")"#)[0].1, "x.md");
assert_eq!(scanned(r#"[t](x.md 'a"b')"#)[0].1, "x.md");
}
#[test]
fn a_title_may_follow_an_angle_destination_without_a_separator() {
assert_eq!(scanned(r#"[t](<docs/x.md>"title")"#)[0].1, "docs/x.md");
let line = r#"[t](<docs/x.md>"a ) b") after"#;
let links = markdown_links(line);
assert_eq!(links[0].target, "docs/x.md");
assert_eq!(&line[links[0].span.clone()], r#"[t](<docs/x.md>"a ) b")"#);
assert_eq!(scanned(r"[t](<docs/x.md>'a ) b')")[0].1, "docs/x.md");
assert_eq!(scanned("[t](<docs/x.md>(a b))")[0].1, "docs/x.md");
assert!(scanned("[t](<a>junk)").is_empty());
assert!(scanned(r"[t](<a>x'y)").is_empty());
assert_eq!(scanned(r#"[t](<docs/x.md> "title")"#)[0].1, "docs/x.md");
assert_eq!(
scanned(r#"[t](docs/x.md"title")"#)[0].1,
r#"docs/x.md"title""#
);
}
#[test]
fn a_label_keeps_the_code_spans_the_scan_removed() {
assert_eq!(
scanned("see [the `Foo` type](docs/x.md)"),
vec![(
LinkKind::Inline,
"docs/x.md".to_owned(),
"the `Foo` type".to_owned(),
false,
)]
);
assert_eq!(scanned("[`Foo`](docs/x.md)")[0].2, "`Foo`");
}
#[test]
fn a_title_may_hold_the_bracket_that_would_close_the_link() {
let line = r#"[t](docs/x.md "a ) b") after"#;
let links = markdown_links(line);
assert_eq!(links[0].target, "docs/x.md");
assert_eq!(&line[links[0].span.clone()], r#"[t](docs/x.md "a ) b")"#);
assert_eq!(scanned("[t](docs/x.md 'a ) b')")[0].1, "docs/x.md");
assert_eq!(scanned("[t](docs/x.md (a title))")[0].1, "docs/x.md");
assert_eq!(scanned("[t](https://e.org/a'b)")[0].1, "https://e.org/a'b");
}
#[test]
fn the_two_kinds_do_not_double_count_one_link() {
assert_eq!(
scanned("[[docs/x.md]]")
.iter()
.map(|(k, _, _, _)| *k)
.collect::<Vec<_>>(),
vec![LinkKind::Wiki]
);
}
#[test]
fn links_are_reported_in_source_order_with_usable_ranges() {
let line = "a [t](x.md) b [[y.md]] c [u](https://e.org)";
let links = markdown_links(line);
assert_eq!(
links.iter().map(|l| l.target.as_str()).collect::<Vec<_>>(),
vec!["x.md", "y.md", "https://e.org"]
);
assert_eq!(&line[links[0].span.clone()], "[t](x.md)");
assert_eq!(&line[links[1].span.clone()], "[[y.md]]");
assert_eq!(&line[links[2].span.clone()], "[u](https://e.org)");
assert!(links[2].scope.is_external());
}
}