rto_graph/text.rs
1//! Small text helpers shared across the crates that build and render the graph.
2
3use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
4use std::collections::BTreeMap;
5use std::ops::Range;
6
7/// A URL-safe slug: lowercase, non-alphanumeric runs collapsed to a single `-`,
8/// trimmed of leading/trailing `-`.
9///
10/// # Why this lives here rather than in either caller
11///
12/// A document's `## ` heading becomes two things that have to agree: a section
13/// **node key** in the authored layer (`rto_spec` builds `adr:0001#design`,
14/// `site:modes#offline-mode`) and the **`id` attribute** of the rendered heading
15/// (`rto_render` emits `<h2 id="design">`). A link into a section resolves
16/// through one and lands through the other, so the moment the two slugifiers
17/// disagree — on a `&`, on a trailing `?`, on a run of punctuation — the graph
18/// says the section exists and the browser scrolls nowhere.
19///
20/// `rto_render` cannot borrow `rto_spec`'s copy: it depends on `rto_spec` only
21/// under the `mcp` feature, so a default render build would have no slugifier at
22/// all. Both depend on this crate unconditionally, so this is the one place the
23/// rule can sit and be the only copy of itself.
24#[must_use]
25pub fn slugify(s: &str) -> String {
26 let mut out = String::new();
27 let mut prev_dash = false;
28 for c in s.chars() {
29 if c.is_ascii_alphanumeric() {
30 out.push(c.to_ascii_lowercase());
31 prev_dash = false;
32 } else if !prev_dash {
33 out.push('-');
34 prev_dash = true;
35 }
36 }
37 out.trim_matches('-').to_owned()
38}
39
40/// The Markdown dialect this project reads and renders with — the one answer to
41/// "what does this source mean", for every surface that asks.
42///
43/// # Anyone parsing Markdown in this workspace must use this
44///
45/// Not as a convention: a *different* option set is a different language. With
46/// `ENABLE_HEADING_ATTRIBUTES` off, `{#modes}` is four literal characters of
47/// heading text rather than an attribute block, so a heading's text — and the
48/// slug, node title and `id` derived from it — changes meaning with the flag.
49/// Two parsers with two option sets do not fail; they quietly disagree about
50/// where a heading's text ends, which is the defect #469 was.
51///
52/// That makes this the foundation [`first_h1`] and [`heading_text`] stand on,
53/// and it is why it is `pub`: `rto_render` parses the same documents to render
54/// them (the document body, every heading's `id`, the page `<title>`), and those
55/// answers have to be the same answers. It cannot borrow the rule from
56/// `rto_spec` — it depends on that crate only under `mcp` — so, exactly like
57/// [`slugify`], this crate is the one place the dialect can sit and be the only
58/// copy of itself.
59///
60/// Strikethrough and tables are here for that reason and no other: a
61/// `~~retracted~~` heading has to reduce to the same text on every surface, not
62/// because a heading contains a table.
63#[must_use]
64pub fn markdown_dialect() -> Options {
65 let mut opts = Options::empty();
66 opts.insert(Options::ENABLE_TABLES);
67 opts.insert(Options::ENABLE_STRIKETHROUGH);
68 opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
69 opts
70}
71
72/// The visible text of the first `# ` heading in `md`, or `None` when there is
73/// none — including a `#` that opens an empty heading, which names nothing and
74/// so defers to whatever fallback the caller has (a slug, a file stem, `ADR-nnnn`).
75///
76/// # Why this lives here rather than in either caller
77///
78/// The same argument as [`slugify`] directly above, one step earlier in the
79/// pipeline: a document's H1 becomes both a **node title** in the authored layer
80/// (`rto_spec` puts it on `site:`/`blueprint:` nodes, which is what `roteiro
81/// search` prints) and the **`<title>`/`<h1>`** of the rendered page
82/// (`rto_render`). Neither crate can borrow the other's copy — `rto_render`
83/// depends on `rto_spec` only under `mcp` — so this is the one place the rule can
84/// sit and be the only copy of itself.
85///
86/// **Read with the parser, never scanned.** A line scan cannot know that `#`
87/// inside a fenced block is a code sample rather than a heading, that
88/// `Title` over `===` *is* an H1, or where an attribute block ends — and it is
89/// the last of those that put a literal `{#modes}` into graph node titles (#469).
90#[must_use]
91pub fn first_h1(md: &str) -> Option<String> {
92 let mut text: Option<String> = None;
93 for event in Parser::new_ext(md, markdown_dialect()) {
94 match event {
95 Event::Start(Tag::Heading {
96 level: HeadingLevel::H1,
97 ..
98 }) => text = Some(String::new()),
99 // Only accumulates once an H1 has opened; a code span is part of the
100 // heading's text, exactly as it is for the heading's id.
101 Event::Text(t) | Event::Code(t) => {
102 if let Some(text) = text.as_mut() {
103 text.push_str(&t);
104 }
105 }
106 Event::End(TagEnd::Heading(HeadingLevel::H1)) => break,
107 _ => {}
108 }
109 }
110 text.map(|t| t.trim().to_owned()).filter(|t| !t.is_empty())
111}
112
113/// The visible text of a heading whose Markdown *source content* is `source` —
114/// the part after the `## `, with the markup that produced it removed.
115///
116/// The same rule as [`first_h1`] and literally the same code path: `source` is
117/// read back as a heading, so an attribute block, a code span or an inline link
118/// reduces here exactly as it does for the document's H1 and for the heading
119/// `rto_render` emits. Callers pass one line (a `## ` heading cannot span lines);
120/// anything after a newline in `source` is a separate block and is ignored.
121#[must_use]
122pub fn heading_text(source: &str) -> String {
123 first_h1(&format!("# {source}")).unwrap_or_default()
124}
125
126/// The `id` a heading claims, from its **explicit** `{#id}` attribute when the
127/// author wrote one and its visible text otherwise.
128///
129/// # One rule, two callers — which is the whole point
130///
131/// `rto_render` puts this on the rendered heading as its `id` attribute, and
132/// `rto_spec` builds the section's node key from it for **all three** document
133/// classes it parses — ADRs, blueprints and site pages — so a `[[doc#section]]`
134/// link resolves in the graph *and* lands in the browser.
135///
136/// The three are named rather than summarised because "universally" is the kind
137/// of claim that goes quietly stale: #524's first fix reached site pages only,
138/// and ADRs and blueprints kept slugifying the heading text, so an author who
139/// wrote `{#id}` in an ADR would have got the same bug in a document class the
140/// fix had not reached. Extending it moved **no** existing key — none of the
141/// repository's 233 section keys changed — because no ADR or blueprint declares
142/// an explicit id today. It removes the trap rather than repairing damage.
143///
144/// Both files already claimed that agreement in prose; before #524 the code only
145/// had it on one of two branches. The renderer honoured an explicit `{#id}` and the graph slugified
146/// the heading text regardless, so
147///
148/// ```text
149/// ## 1 · Offline mode — the default {#offline}
150///
151/// graph site:modes#1-offline-mode-the-default
152/// html id="offline"
153/// ```
154///
155/// — **correct on the surface everyone looks at, wrong in the one tools read.**
156/// Five of this repository's site headings diverged; the other eight agreed only
157/// because their explicit id happened to equal the slug of their own text.
158///
159/// The explicit id is taken **verbatim**, not slugified: the author wrote an
160/// address, and re-slugifying it would silently answer a different one — the
161/// very move that produced the divergence.
162///
163/// # What this deliberately does not decide
164///
165/// It returns empty for a heading with no explicit id and no text that slugifies
166/// to anything (`## ###`), and it does not de-duplicate. Both are **document**
167/// questions — a heading's position, and whether an earlier heading already took
168/// the id — and this sees one heading.
169///
170/// They are answered one level up, by [`headings`], which reads the whole
171/// document. They used to be answered in `rto_render::docs` instead, on the
172/// argument that only the renderer emits elements and so only the renderer can
173/// have two of them share an `id`. That argument was wrong in its consequence:
174/// the renderer suffixed the second `{#same}` to `same-2` and the graph upserted
175/// one section over the other, so the surviving node named a place the page
176/// addressed as something else (#629). A rule only one side applies is a
177/// divergence with extra steps.
178#[must_use]
179pub fn heading_id_from(explicit: Option<&str>, text: &str) -> String {
180 explicit
181 .map(str::trim)
182 .filter(|e| !e.is_empty())
183 .map_or_else(|| slugify(text), ToOwned::to_owned)
184}
185
186/// [`heading_id_from`] for a heading whose Markdown **source content** is
187/// `source` — the part after the `## `.
188///
189/// Parsed rather than scanned, by the same parser and dialect the renderer uses,
190/// so "what id will this heading get" is answered once and identically on both
191/// sides. A line scan would have to re-implement attribute-block parsing, which
192/// is how a third rule gets born.
193///
194/// # The one heading it cannot answer for
195///
196/// The parse is of `# {source}` **alone**, so anything a heading inherits from
197/// the rest of its document is invisible here. In practice that is one
198/// construct: a **reference-style link**, whose definition lives elsewhere in the
199/// file.
200///
201/// ```text
202/// [plan]: plan.md
203///
204/// ## See [the plan][plan]
205/// ```
206///
207/// The renderer parses the whole document, resolves the definition, and anchors
208/// the heading at `see-the-plan`. This function sees no definition, so
209/// pulldown-cmark keeps `[the plan][plan]` as literal text and it returns
210/// `see-the-plan-plan`.
211///
212/// Left as a stated limit rather than fixed, because fixing it means threading
213/// every document's reference definitions through this signature and giving each
214/// of the three line-scanning parsers a pre-pass to collect them — a large change
215/// against **zero** occurrences: the repository contains no reference-style link
216/// definitions at all, in any document, and no heading anywhere uses the syntax.
217///
218/// It is not unguarded, either. `heading_anchor_agreement` renders every site
219/// page in full and compares the emitted `id` attributes against the graph's
220/// section keys, so a real instance in a site page fails that test rather than
221/// diverging quietly. See also the blockquote divergence (#621), recorded the
222/// same way.
223#[must_use]
224pub fn heading_id(source: &str) -> String {
225 let md = format!("# {source}");
226 let (mut explicit, mut text) = (None, String::new());
227 let mut open = false;
228 for event in Parser::new_ext(&md, markdown_dialect()) {
229 match event {
230 Event::Start(Tag::Heading { id, .. }) => {
231 explicit = id.map(|i| i.to_string());
232 open = true;
233 }
234 // A code span is part of the heading's text, exactly as it is for
235 // [`first_h1`] and for the heading `rto_render` emits.
236 Event::Text(t) | Event::Code(t) if open => text.push_str(&t),
237 Event::End(TagEnd::Heading(_)) => break,
238 _ => {}
239 }
240 }
241 heading_id_from(explicit.as_deref(), text.trim())
242}
243
244#[cfg(test)]
245mod tests {
246 use super::{first_h1, heading_id, heading_text, headings, slugify};
247
248 /// The three classes #621 measured, each a heading to a parser and invisible
249 /// to a `strip_prefix("## ")` scan — plus the mirror error, a `## ` inside a
250 /// fence, which a scan counts and a parser knows is code.
251 #[test]
252 fn a_heading_is_more_than_a_line_starting_with_two_hashes() {
253 let md = "# Title\n\n\
254 > ## Quoted\n\n\
255 \u{20}\u{20}## Indented\n\n\
256 Setext\n---\n\n\
257 ```\n## Not a heading\n```\n\n\
258 ## Plain\n";
259 let hs = headings(md);
260 let ids: Vec<&str> = hs.iter().map(|h| h.id.as_str()).collect();
261 assert_eq!(
262 ids,
263 ["title", "quoted", "indented", "setext", "plain"],
264 "blockquoted, indented and setext headings are headings; a fenced \
265 `## ` is not"
266 );
267 }
268
269 /// Offsets are each heading's own start, in document order — which is all a
270 /// caller needs to attribute a later byte to the heading it falls under.
271 #[test]
272 fn heading_offsets_ascend_and_point_at_the_heading_not_its_container() {
273 let md = "## First\n\ntext\n\n> ## Quoted\n\nmore\n";
274 let hs = headings(md);
275 assert_eq!(hs.len(), 2);
276 assert!(hs[0].start < hs[1].start, "document order: {hs:?}");
277 // The heading's own start, past the blockquote marker. Asserted because I
278 // documented the opposite first and this test is what corrected it: a
279 // caller slicing from here would otherwise get `> ## Quoted`.
280 assert!(
281 md[hs[1].start..].starts_with("## Quoted"),
282 "offset points at the heading, not its container: {:?}",
283 &md[hs[1].start..]
284 );
285 }
286
287 /// A second heading claiming an id the first took is suffixed, and the
288 /// numbering runs over **every** level (#629).
289 ///
290 /// The all-levels part is the half that cannot be reproduced by a caller that
291 /// keeps only `##`: `# Same` before `## Same` puts the h2 at `same-2`, so a
292 /// caller filtering to `##` after this ran agrees with the renderer and one
293 /// deduplicating within its own subset does not. That asymmetry — the
294 /// renderer counting all levels, `rto_spec` recording one — is precisely why
295 /// the rule sits here instead of in either of them.
296 #[test]
297 fn a_repeated_id_is_suffixed_and_the_count_spans_every_level() {
298 let ids = |md: &str| -> Vec<String> { headings(md).into_iter().map(|h| h.id).collect() };
299
300 assert_eq!(ids("## A {#same}\n\n## B {#same}\n"), ["same", "same-2"]);
301 assert_eq!(
302 ids("## Dup\n\n## Dup\n\n## Dup\n"),
303 ["dup", "dup-2", "dup-3"]
304 );
305 // Across levels, in both directions: an h1 or an h3 takes the bare id
306 // just as an h2 would, and the h2 that follows is suffixed.
307 assert_eq!(ids("# Same\n\n## Same\n"), ["same", "same-2"]);
308 assert_eq!(ids("### Same\n\n## Same\n"), ["same", "same-2"]);
309 // And a heading a `## ` scan cannot see still consumes its id, so the
310 // one that follows is numbered against the page rather than against the
311 // subset any caller happens to keep.
312 assert_eq!(ids("> ## X\n\n## X\n"), ["x", "x-2"]);
313 }
314
315 /// A heading that names nothing is numbered by its position among **all**
316 /// headings — the other document-level rule that moved here with the dedup.
317 ///
318 /// `## ###` slugifies to the empty string, and an empty id is not an address.
319 /// `section-2` because the `# Title` above it is heading one.
320 #[test]
321 fn a_heading_that_names_nothing_falls_back_to_its_position() {
322 let hs = headings("# Title\n\n## ###\n\n## Real\n");
323 let ids: Vec<&str> = hs.iter().map(|h| h.id.as_str()).collect();
324 assert_eq!(ids, ["title", "section-2", "real"]);
325 // Position first, then uniqueness: two unnameable headings get distinct
326 // positions rather than one name and a suffix.
327 let hs = headings("## ###\n\n## ###\n");
328 let ids: Vec<&str> = hs.iter().map(|h| h.id.as_str()).collect();
329 assert_eq!(ids, ["section-1", "section-2"]);
330 }
331
332 /// Level and text come back too, and an explicit id still wins over the slug.
333 #[test]
334 fn a_heading_carries_its_level_text_and_declared_id() {
335 let hs = headings("### Design *notes* {#arch}\n");
336 assert_eq!(hs.len(), 1);
337 assert_eq!(hs[0].level, 3);
338 assert_eq!(hs[0].text, "Design notes", "markup reduced");
339 assert_eq!(hs[0].id, "arch", "the declared anchor, not the slug");
340 }
341
342 /// The single construct the isolated parse cannot resolve, pinned so the
343 /// limit is a recorded value rather than a surprise. See [`heading_id`]'s
344 /// docs: the renderer, parsing the whole document, would anchor the same
345 /// heading at `see-the-plan`.
346 ///
347 /// Asserted as the *divergent* value on purpose. Writing the aspirational
348 /// `see-the-plan` here and marking it `#[ignore]` would leave the real
349 /// behaviour untested, and the next person to touch this would have no way
350 /// to tell a deliberate limit from an undiscovered bug.
351 #[test]
352 fn a_reference_style_link_cannot_resolve_without_its_document() {
353 assert_eq!(heading_id("See [the plan][plan]"), "see-the-plan-plan");
354 // Inline and collapsed forms need nothing from the document, so they
355 // agree with the renderer already — the gap really is this narrow.
356 assert_eq!(heading_id("See [the plan](plan.md)"), "see-the-plan");
357 assert_eq!(heading_id("See [the plan]"), "see-the-plan");
358 }
359
360 #[test]
361 fn collapses_punctuation_and_trims() {
362 assert_eq!(slugify("Install & build"), "install-build");
363 assert_eq!(
364 slugify("The five ways to run it"),
365 "the-five-ways-to-run-it"
366 );
367 assert_eq!(slugify(" §2 — Context! "), "2-context");
368 assert_eq!(
369 slugify("Cross-repo: a hub and its spokes"),
370 "cross-repo-a-hub-and-its-spokes"
371 );
372 assert_eq!(slugify("!!!"), "");
373 }
374
375 #[test]
376 fn an_attribute_block_is_markup_not_part_of_the_title() {
377 // The defect behind #469: a line scan yields `… {#modes}`, and that
378 // string became a `site:` node title — invisible in the rendered page,
379 // present in everything that reads the graph.
380 let title = first_h1("# The five ways to run it {#modes}\n").expect("an h1");
381 assert_eq!(title, "The five ways to run it");
382 assert!(
383 !title.contains("{#"),
384 "an attribute block must not survive into a title: {title:?}"
385 );
386 }
387
388 #[test]
389 fn both_entry_points_agree_on_where_a_heading_ends() {
390 // Not a literal assertion, deliberately. `# Sets like {#1, #2}` is a real
391 // ambiguity and the *dialect* decides it — so what is worth pinning is
392 // that the document rule and the `## `-heading rule cannot decide it
393 // differently, whichever way the parser goes.
394 for source in [
395 "The five ways to run it {#modes}",
396 "Sets like {#1, #2}",
397 "The `--json` flag",
398 "See [the docs](x.md)",
399 "A ~~retracted~~ claim",
400 "Install & build",
401 "",
402 ] {
403 assert_eq!(
404 first_h1(&format!("# {source}")).unwrap_or_default(),
405 heading_text(source),
406 "the two entry points disagreed about {source:?}"
407 );
408 }
409 }
410
411 #[test]
412 fn a_heading_inside_a_fence_is_a_code_sample() {
413 // A line scan cannot tell these apart; it is why a document *about*
414 // blueprints could classify itself as one.
415 let md = "```\n# Widget — Technical Implementation Plan\n```\n\n# Real title\n";
416 assert_eq!(first_h1(md).as_deref(), Some("Real title"));
417 assert_eq!(
418 first_h1("```\n# Fenced only\n```\n"),
419 None,
420 "a fenced `#` is not a heading at all"
421 );
422 }
423
424 #[test]
425 fn a_setext_heading_is_an_h1() {
426 assert_eq!(
427 first_h1("Underlined title\n===\n").as_deref(),
428 Some("Underlined title")
429 );
430 }
431
432 #[test]
433 fn an_empty_heading_names_nothing() {
434 // Defers to the caller's fallback (a slug, a file stem, `ADR-nnnn`)
435 // rather than titling a node with the empty string.
436 assert_eq!(first_h1("#\n\n# Second\n"), None);
437 assert_eq!(heading_text(""), "");
438 }
439
440 #[test]
441 fn heading_text_feeds_slugify_the_text_a_reader_sees() {
442 // The two rules in this module are one pipeline: the section key is the
443 // slug of the visible text, so markup must be gone before slugify runs.
444 assert_eq!(
445 slugify(&heading_text("1 · Offline mode — the default {#offline}")),
446 "1-offline-mode-the-default"
447 );
448 }
449}
450
451/// One heading found by **parsing** a document, with the byte offset at which it
452/// begins.
453///
454/// See [`headings`] for why the offset is the useful part.
455#[derive(Debug, Clone, PartialEq, Eq)]
456pub struct Heading {
457 /// Heading level: 1 for `#`, 2 for `##`, and so on.
458 pub level: u8,
459 /// The id this heading **gets**, which is the id it can be linked by.
460 ///
461 /// [`heading_id_from`] answers what it claims — its explicit `{#id}` when the
462 /// author wrote one, the slug of its text otherwise. This is that answer after
463 /// the two questions only the document can settle: an unnameable heading falls
464 /// back to its position, and a claim an earlier heading already took is
465 /// suffixed. See [`headings`].
466 pub id: String,
467 /// The heading's visible text, with the markup that produced it removed.
468 pub text: String,
469 /// Byte offset into the source where this heading begins.
470 ///
471 /// The heading's own start, **not** its container's: for `> ## Quoted` it
472 /// points at the `#`, past the blockquote marker. Callers use it to decide
473 /// which heading a later byte falls under, which is a comparison rather than
474 /// a slice, so what precedes it on the line does not concern them.
475 pub start: usize,
476}
477
478/// Every heading in `md`, in document order, read with the shared dialect.
479///
480/// # Why parse rather than scan for `## `
481///
482/// Because a heading is not a line that starts with `## `. It is also
483/// `> ## Quoted` inside a blockquote, ` ## Indented` under three spaces, and
484/// `Title` over `---`. All three are headings to a parser and to the renderer,
485/// which duly emits an addressable `<h2 id="…">` for each — while a
486/// `strip_prefix("## ")` scan sees none of them, so the graph records no section
487/// and a link naming that place cannot resolve even though the place exists
488/// (#621). A `## ` inside a fenced block is the mirror error: a scan counts it,
489/// a parser knows it is code.
490///
491/// # Why an offset rather than a line number
492///
493/// The callers that need this are attributing *other* things — wiki-links,
494/// section body text — to the heading they fall under. Given the offsets, that is
495/// a comparison against the next heading's start, and it works identically for a
496/// heading the caller could not have found by scanning.
497///
498/// # Why the id is settled here and not per heading
499///
500/// Two of the three questions in "what is this heading's id" need the whole
501/// document, so [`heading_id_from`] cannot answer them and this is the first
502/// place that can:
503///
504/// - a heading that names nothing (`## ###`, no explicit id) falls back to its
505/// **position**, `section-N`, 1-based over every heading in the document;
506/// - a heading claiming an id an earlier heading already took is **suffixed**,
507/// `same` then `same-2` then `same-3`.
508///
509/// Both used to live in `rto_render::docs::heading_ids`, which is where #629
510/// found them: the renderer deduplicated and the graph did not, so
511/// `## A {#same}` / `## B {#same}` rendered as two addressable anchors and
512/// upserted into **one** graph node — section A gone, and the `same-2` anchor
513/// addressable by nothing.
514///
515/// # It counts every level, and that is the load-bearing part
516///
517/// `# Same` followed by `## Same` renders as `same` / `same-2`. A caller that
518/// wants only `##` sections — [`rto_spec`](https://docs.rs/rto-spec) does —
519/// must filter **after** this ran, not dedupe within its own subset, or the h2
520/// gets keyed `same` while the page addresses it as `same-2`. That is the
521/// divergence a dedup local to either side reintroduces, and the reason this
522/// numbering is over all headings rather than over the ones any one caller keeps.
523#[must_use]
524pub fn headings(md: &str) -> Vec<Heading> {
525 let mut out: Vec<Heading> = Vec::new();
526 let mut seen: BTreeMap<String, usize> = BTreeMap::new();
527 let mut open: Option<(u8, Option<String>, String, usize)> = None;
528 for (event, range) in Parser::new_ext(md, markdown_dialect()).into_offset_iter() {
529 match event {
530 Event::Start(Tag::Heading { level, id, .. }) => {
531 let level = match level {
532 HeadingLevel::H1 => 1,
533 HeadingLevel::H2 => 2,
534 HeadingLevel::H3 => 3,
535 HeadingLevel::H4 => 4,
536 HeadingLevel::H5 => 5,
537 HeadingLevel::H6 => 6,
538 };
539 open = Some((level, id.map(|i| i.to_string()), String::new(), range.start));
540 }
541 // A code span is part of a heading's text, exactly as it is for the
542 // heading's id — the same rule `first_h1` applies.
543 Event::Text(t) | Event::Code(t) => {
544 if let Some((_, _, text, _)) = open.as_mut() {
545 text.push_str(&t);
546 }
547 }
548 Event::End(TagEnd::Heading(_)) => {
549 if let Some((level, explicit, text, start)) = open.take() {
550 let text = text.trim().to_owned();
551 let claimed = heading_id_from(explicit.as_deref(), &text);
552 // Position first, then uniqueness — in that order, because a
553 // heading that names nothing still has to be given a name
554 // before anything can ask whether the name is taken.
555 let claimed = if claimed.is_empty() {
556 format!("section-{}", out.len() + 1)
557 } else {
558 claimed
559 };
560 let n = seen.entry(claimed.clone()).or_insert(0);
561 *n += 1;
562 let id = if *n == 1 {
563 claimed
564 } else {
565 format!("{claimed}-{n}")
566 };
567 out.push(Heading {
568 level,
569 id,
570 text,
571 start,
572 });
573 }
574 }
575 _ => {}
576 }
577 }
578 out
579}
580
581// ---------------------------------------------------------------------------
582// Markdown links
583// ---------------------------------------------------------------------------
584
585/// Which Markdown syntax a [`MarkdownLink`] was written in.
586///
587/// Both are links in this project's dialect and both are read by one scanner,
588/// which is the point: "find a Markdown link" had five implementations sharing
589/// no code, and the kinds were never all found by the same one.
590///
591/// # Deliberately closed, and not `#[non_exhaustive]`
592///
593/// `CommonMark` has link forms this does not read — reference links (`[a][b]`)
594/// and autolinks (`<https://…>`) — so a fourth variant is imaginable, which is
595/// exactly why the set is shut rather than left open. A caller decides what
596/// to *do* per kind: [`crate::markdown_links`]' own callers rewrite an inline
597/// link's text over the whole link and resolve a wiki-link's target against a
598/// node key, and there is no behaviour that is right for a kind nobody has seen.
599/// Left open, every one of them grows a wildcard arm and a new kind is silently
600/// handled as whichever of these it is least like. Shut, adding one is a major
601/// version and a compile error at each place that has to decide — which is the
602/// cost that should be paid, and the same argument `rto_faithful::Segment` makes
603/// for the same reason.
604#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
605pub enum LinkKind {
606 /// A Roteiro `[[target]]` wiki-link — the authored layer's citation into the
607 /// graph, resolved by `rto_spec` against a node key.
608 Wiki,
609 /// A `CommonMark` `[text](destination)` inline link.
610 Inline,
611 /// A `CommonMark` `` image.
612 ///
613 /// Reported rather than skipped, and **distinct from [`Self::Inline`]** so
614 /// that it can be: a citation list filters to `Inline` and a `.png` never
615 /// reaches it, while a caller reducing markdown to its visible text — the
616 /// rustdoc-anchor guard does — keeps the alt text and drops the source. Read
617 /// as one kind and either of those is wrong.
618 Image,
619}
620
621/// Whether a link destination addresses something this repository holds, or
622/// somewhere outside it.
623///
624/// The distinction is one rule because it is asked in three places that each had
625/// their own answer: the site renderer deciding whether a destination can be
626/// rewritten to a page it serves, the rendered-site link gate deciding whose
627/// uptime a href depends on, and — the reason this is `pub` rather than private
628/// to either — a citation needing to know whether a locator names a work someone
629/// else published.
630///
631/// # Deliberately closed, and not `#[non_exhaustive]`
632///
633/// The question is a yes/no one — a destination either names something this
634/// repository is expected to contain or it does not — so this is a `bool` that
635/// says which way round it is, and a `bool` cannot acquire a third value. Any
636/// finer distinction anybody wants later (which scheme, which host, whether the
637/// path exists) is a different question with a different answer type, not a
638/// variant here; making it one would change what every existing arm means.
639#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
640pub enum LinkScope {
641 /// A relative or root-relative path, or a bare `#fragment`: something this
642 /// repository is expected to contain.
643 Internal,
644 /// A destination carrying a URL scheme (`https:`, `mailto:`, …) or written
645 /// protocol-relative (`//host/…`): somebody else's.
646 External,
647}
648
649impl LinkScope {
650 /// Whether this scope is [`LinkScope::External`].
651 #[must_use]
652 pub fn is_external(self) -> bool {
653 matches!(self, Self::External)
654 }
655}
656
657/// One Markdown link found on one line by [`markdown_links`].
658///
659/// # Its invariants are enforced, not merely documented
660///
661/// Every claim the accessors below make is made true by `MarkdownLink::new`,
662/// which is the only constructor this crate has, and **the fields are private**,
663/// so the guarantees hold for the whole life of the value rather than only at
664/// the moment it is built.
665///
666/// Both halves were earned. Three rounds of review on #806 and #807 each turned
667/// up a field whose doc comment stated a guarantee its constructor did not keep —
668/// `target` promised "trimmed, and never empty" while the angle-destination
669/// branch could return whitespace — so the invariants moved into `new`. The
670/// round after that pointed out that `new` was only half the job: the fields
671/// were `pub`, so a caller holding one could assign to `target`, `scope` or
672/// `span` afterwards and break every one of them, including the one that keeps
673/// `&line[link.span()]` from panicking. A guarantee that lasts until somebody
674/// writes to a field is not a guarantee, and this is now the single link scanner
675/// every crate in the workspace reads through. Hence accessors.
676///
677/// `#[non_exhaustive]` is kept for what it is actually for — letting a field be
678/// added later without breaking a downstream pattern — rather than for the
679/// invariant, which privacy now carries on its own. New fields belong in `new`
680/// as much as they belong here.
681#[derive(Debug, Clone, PartialEq, Eq)]
682#[non_exhaustive]
683pub struct MarkdownLink {
684 // Private: see the accessors below for what each one guarantees, and the
685 // type docs above for why that has to be enforced rather than described.
686 kind: LinkKind,
687 target: String,
688 text: String,
689 scope: LinkScope,
690 span: Range<usize>,
691}
692
693impl MarkdownLink {
694 /// Which syntax it was written in.
695 #[must_use]
696 pub fn kind(&self) -> LinkKind {
697 self.kind
698 }
699
700 /// Where it points: the inner text for a wiki-link, the destination for an
701 /// inline one.
702 ///
703 /// **Trimmed, and never empty** — a link naming nothing is not a link, which
704 /// is the reading that cannot invent an edge out of stray punctuation. Held
705 /// by `MarkdownLink::new`, which refuses to build one otherwise.
706 #[must_use]
707 pub fn target(&self) -> &str {
708 &self.target
709 }
710
711 /// What a reader sees.
712 ///
713 /// For an inline link that is its bracketed text, which is the half a
714 /// citation label needs and which no scanner here used to keep. For a
715 /// wiki-link the visible text **is** the target, so this repeats it rather
716 /// than being empty: a caller labelling links does not have to know which
717 /// kind it is holding. Derived from the kind rather than passed in, so those
718 /// two sentences cannot come apart.
719 #[must_use]
720 pub fn text(&self) -> &str {
721 &self.text
722 }
723
724 /// Whether [`target`](Self::target) names something outside this repository.
725 ///
726 /// Always [`LinkScope::Internal`] for a wiki-link, which addresses a graph
727 /// node by key and cannot name a URL — also derived from the kind.
728 #[must_use]
729 pub fn scope(&self) -> LinkScope {
730 self.scope
731 }
732
733 /// The byte range the whole link occupies **in the line as given**, so a
734 /// caller can rewrite it in place.
735 ///
736 /// Code spans are excluded from the scan but not from this range: a link
737 /// whose brackets straddle one covers it. Always non-empty, inside the line,
738 /// and on character boundaries, so `&line[link.span()]` cannot panic.
739 #[must_use]
740 pub fn span(&self) -> Range<usize> {
741 self.span.clone()
742 }
743
744 /// The one constructor, and the one place this type's documented invariants
745 /// are made true.
746 ///
747 /// Returns [`None`] when `target` names nothing once trimmed. That is the
748 /// `target` field's contract — "trimmed, and never empty" — held by
749 /// construction rather than by hope, and it is the same reading the rest of
750 /// the scanner already had: `[t]()`, `[t]( )` and `[[ ]]` were all
751 /// already no link at all, and only the angle-destination branch let
752 /// `[t](< >)` through with a target of one space.
753 ///
754 /// `kind` decides the other two. A wiki-link's visible text **is** its
755 /// target, and a wiki-link addresses a graph node by key and so cannot name
756 /// a URL — both are stated on the fields, and taking them as parameters
757 /// would be inviting the next caller to disagree with the documentation.
758 ///
759 /// # Where this differs from `pulldown-cmark`, deliberately
760 ///
761 /// `pulldown-cmark` renders `[t](< >)` as a link whose destination is one
762 /// space, and `[t](< docs/x.md >)` with the spaces kept. This reports no
763 /// link for the first and `docs/x.md` for the second, which is the
764 /// divergence three of the four destination forms already had. The reason is
765 /// what this type is *for*: a `target` is a key a graph node is looked up by
766 /// and a label a citation is written from, and `" "` is neither. Nothing in
767 /// this repository writes such a link — `markdown_links_parity.rs` checks
768 /// that over every `.md` and `.rs` in the tree.
769 ///
770 /// **`renderer_agreement.rs` is the list, not this comment.** It runs every
771 /// shape through both readers and holds each divergence to a stated reason,
772 /// in both directions. An earlier version of this paragraph said "the one
773 /// place", and by then the table already recorded two — prose restating a
774 /// test is prose that drifts from it. Raised in review on #806, twice.
775 ///
776 /// # Panics
777 ///
778 /// Debug builds only, and only on a bug in this module: `span` must be
779 /// non-empty and must slice `line` on character boundaries. Those cannot be
780 /// enforced by returning [`None`] — a scanner that produced a bad range has
781 /// miscounted and should say so where it happened, not hand back a silently
782 /// shorter list. The corpus test asserts the same three properties over the
783 /// whole repository, in a build where these are live.
784 fn new(
785 kind: LinkKind,
786 target: &str,
787 text: &str,
788 span: Range<usize>,
789 line: &str,
790 ) -> Option<Self> {
791 let target = target.trim();
792 if target.is_empty() {
793 return None;
794 }
795 debug_assert!(
796 span.start < span.end
797 && span.end <= line.len()
798 && line.is_char_boundary(span.start)
799 && line.is_char_boundary(span.end),
800 "{kind:?} link span {span:?} does not address {line:?}"
801 );
802 let wiki = kind == LinkKind::Wiki;
803 Some(Self {
804 kind,
805 text: if wiki { target } else { text }.to_owned(),
806 scope: if wiki {
807 LinkScope::Internal
808 } else {
809 link_scope(target)
810 },
811 target: target.to_owned(),
812 span,
813 })
814 }
815}
816
817/// Every Markdown link on `line`, of both kinds, in the order they are written.
818///
819/// # The one scanner
820///
821/// "Find a Markdown link" was implemented five times across this workspace with
822/// no shared code — a `[[…]]` scanner in `rto_spec`, a `pulldown-cmark` event
823/// filter in `rto_render`, a hand-rolled target reader in the OKF bundle reader,
824/// and two more in the test suite. This is that rule, once. The failure mode is
825/// not that one of them is wrong: it is that they quietly disagree, which is
826/// exactly the defect two Markdown *walkers* produced in #790 and which
827/// `docs_are_canonical.rs` records verbatim.
828///
829/// It sits beside [`slugify`] and [`heading_text`] for the reason those do:
830/// `rto_spec` and `rto_render` both depend on this crate unconditionally and on
831/// each other only under a feature, so this is the one place the rule can be the
832/// only copy of itself.
833///
834/// # What it does and does not read
835///
836/// **One line.** Every caller scanning a document already tracks its own fenced
837/// code state and attributes each link to the section enclosing it, so a
838/// document-level scan would answer a question none of them asked and would take
839/// the fence rule away from the four scanners that disagree about it (see
840/// [`is_code_fence`]).
841///
842/// **Inline code spans are not scanned**, so a `` `[[path#Symbol]]` `` or a
843/// `` `[text](x)` `` written as a documentation example is not a link. That is
844/// [`strip_code_spans`]' rule, and it is why this is not `pulldown-cmark`: a
845/// destination is read here only where the source closes it, so a malformed line
846/// yields no link rather than whatever a recovering parser makes of it.
847///
848/// The rule stops at the `]`, though. `CommonMark` parses inlines left to right
849/// and consumes a link's destination and title raw as soon as the `]` is
850/// reached, so a backtick past it never opens a span at all: ``[x](a`b`c.md)``
851/// is a link to ``a`b`c.md`` and ``[t](x.md "a `b`")`` is a titled link to
852/// `x.md`. What a span *can* do is take the `]` (``[not a `link](/foo`)``) or
853/// stand between the `]` and the `(` (``[a]`x`(b)``), and neither of those is a
854/// link. Both halves of this were raised in review on #806 and checked against
855/// `pulldown-cmark`, which renders this repository's documents — where the two
856/// could differ, the renderer's reading wins, because a gate that disagrees with
857/// the renderer is the defect #801 exists to remove.
858///
859/// A backslash escapes the delimiter after it, so `\[not a link](x)` is prose.
860/// Escapes are **not** processed inside `[[…]]`, which is a Roteiro token rather
861/// than `CommonMark` syntax and has never had them.
862///
863/// # Ranges may overlap, and a splicing caller must expect it
864///
865/// `[See [[x]]](target)` is one inline link *and* one wiki-link, and the inline
866/// one's range encloses the other's. That is the honest report: both are there,
867/// and which one matters depends on who is asking — the renderer rewrites
868/// wiki-links, the rustdoc-anchor guard reduces inline ones to their text.
869/// Neither wants the other's answer, and a scanner that picked one would be
870/// wrong for the other.
871///
872/// Overlap only ever pairs a wiki-link with an inline or image one — a nested
873/// `[…](…)` is consumed by the link enclosing it, and `[[…]]` never nests — so
874/// **taking a single [`LinkKind`] gives a non-overlapping set**, which is what
875/// both rewriting callers in this workspace do. A caller that splices *across*
876/// kinds must skip a link starting before where the last one ended, or it slices
877/// a backwards range and panics. Raised in review on #806.
878///
879/// **A `[[…]]` inside an image's alt text is still reported**, so
880/// `![alt [[docs/x.md]]](i.png)` yields an [`LinkKind::Image`] *and* a
881/// [`LinkKind::Wiki`]. That is not an oversight and cannot be tidied here: a
882/// `[[…]]` is a Roteiro token found anywhere on the line, the scanner this
883/// replaced had no concept of images, and `roteiro check` counts what that
884/// scanner found. Suppressing it would move the gate's number — see
885/// `markdown_links_parity.rs`, which holds this function to that scanner over
886/// the whole tree. The image rule applies to the `[…](…)` syntax it is part of.
887#[must_use]
888pub fn markdown_links(line: &str) -> Vec<MarkdownLink> {
889 let (stripped, map) = strip_and_map(line);
890 let wiki = wiki_spans(&stripped);
891 let mut out: Vec<MarkdownLink> = wiki
892 .iter()
893 .filter_map(|(range, target)| {
894 MarkdownLink::new(
895 LinkKind::Wiki,
896 target,
897 target,
898 map.start(range.start)..map.end(range.end),
899 line,
900 )
901 })
902 .collect();
903 out.extend(
904 inline_spans(line, &stripped, &wiki, &map)
905 .into_iter()
906 .filter_map(|(kind, span, text, destination)| {
907 MarkdownLink::new(
908 kind,
909 &destination,
910 // Read back out of the **line**, not the stripped string: a
911 // label like ``[the `Foo` type](x.md)`` is scanned with its
912 // code span removed, so taking the text from there would
913 // cite "the type". The span is excluded from the *scan*
914 // because a link inside one is an example; its content is
915 // still the label.
916 &line[text],
917 span,
918 line,
919 )
920 }),
921 );
922 out.sort_by_key(|l| l.span.start);
923 out
924}
925
926/// The inner text of every `[[…]]` on `line`, ignoring any inside an inline code
927/// span — [`markdown_links`] narrowed to the kind the authored-layer scanners
928/// read.
929///
930/// A convenience over the one scanner rather than a second one: `rto_spec`'s ADR,
931/// blueprint, site-page and lat.md parsers all want this exact list, and giving
932/// each of them a filter to write is how a sixth implementation starts.
933#[must_use]
934pub fn wiki_link_targets(line: &str) -> Vec<String> {
935 markdown_links(line)
936 .into_iter()
937 .filter(|l| l.kind == LinkKind::Wiki)
938 .map(|l| l.target)
939 .collect()
940}
941
942/// Whether `destination` names something outside this repository.
943///
944/// External is "carries a URL scheme" (RFC 3986 §3.1 — an ASCII letter then
945/// letters, digits, `+`, `-` or `.`, then `:`) or "is protocol-relative"
946/// (`//host/…`). Everything else — a relative path, a root-relative one, a bare
947/// `#fragment` — is internal.
948///
949/// Written as the scheme rule rather than as a list of the four prefixes the two
950/// call sites happened to enumerate (`http://`, `https://`, `mailto:`, `//`),
951/// because a list is a thing to forget an entry from: `tel:`, `ftp:` and `data:`
952/// were external before this and were classified internal by both of them.
953#[must_use]
954pub fn link_scope(destination: &str) -> LinkScope {
955 let destination = destination.trim();
956 if destination.starts_with("//") {
957 return LinkScope::External;
958 }
959 let Some((scheme, _)) = destination.split_once(':') else {
960 return LinkScope::Internal;
961 };
962 let mut chars = scheme.chars();
963 let valid = chars.next().is_some_and(|c| c.is_ascii_alphabetic())
964 && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
965 if valid {
966 LinkScope::External
967 } else {
968 LinkScope::Internal
969 }
970}
971
972/// Whether `line` opens or closes a fenced code block — a run of three or more
973/// backticks or tildes, after leading whitespace.
974///
975/// # The **delimiter** is `CommonMark`'s; the indentation deliberately is not
976///
977/// `CommonMark` allows at most three spaces of indentation before a fence *at a
978/// block's content column*, and this accepts any amount. That is not laxness, it
979/// is the limit of a per-line predicate: four spaces at the top level open an
980/// indented code block, and the same four inside a list item open a perfectly
981/// ordinary fence. Checked against `pulldown-cmark`: a four-space-indented
982/// backtick fence is `Indented` on its own and `Fenced` under `- item`, so the
983/// bound is relative to a container this function is never told about. A fixed limit of three would
984/// therefore *break* fences this repository already has: see
985/// `crates/rto-render/tests/fixtures/okf-upstream/acme_retail/skills/run-on-bq.md`,
986/// where a `json` fence sits four spaces deep inside a list.
987///
988/// Callers that need the real rule need a document scan, which is
989/// [`markdown_links`]' "One line" note all over again. Raised in review on #806
990/// as an overstated claim, and it was one.
991///
992/// # Three scanners in this workspace do not use even the delimiter half
993///
994/// `rto_render::docs` recognises both delimiters. `rto_spec`'s ADR, blueprint,
995/// site-page and lat.md scanners each carry their own
996/// `trim_start().starts_with("```")`, which recognises only backticks — noted at
997/// the ADR one as a known narrowing. So a `~~~`-fenced example is code to the
998/// renderer and prose to the gate, and a `[[…]]` inside one is a link the gate
999/// resolves and the site renders literally.
1000///
1001/// That divergence is **not** closed here, because closing it changes what the
1002/// gate counts and this rule's introduction is not the change that should decide
1003/// it: no document in this repository fences with `~~~` today, so unifying them
1004/// moves nothing now and would move the count the first time somebody wrote one.
1005/// It is written down here instead of staying an accident of five copies.
1006#[must_use]
1007pub fn is_code_fence(line: &str) -> bool {
1008 let trimmed = line.trim_start();
1009 trimmed.starts_with("```") || trimmed.starts_with("~~~")
1010}
1011
1012/// Return `line` with inline code spans removed, so tokens documented as
1013/// examples (e.g. `` `[[path#Symbol]]` `` or ``` ``@rto:0001`` ```) are not
1014/// scanned as real links or annotations.
1015///
1016/// Follows the `CommonMark` rule for code spans: a span opens with a run of *n*
1017/// backticks and closes with the next run of exactly *n* backticks. An opening
1018/// run with no matching close is literal text and is kept. Non-backtick text is
1019/// preserved verbatim (backticks are ASCII, so all slice boundaries are valid).
1020#[must_use]
1021pub fn strip_code_spans(line: &str) -> String {
1022 strip_and_map(line).0
1023}
1024
1025/// The byte ranges of `line`'s **matched** inline code spans, in order.
1026///
1027/// The `CommonMark` rule, in one place: a span opens with a run of *n* backticks
1028/// and closes with the next run of exactly *n*; an opening run with no matching
1029/// close is literal text and yields no span. A **backslash-escaped** backtick is
1030/// literal and opens nothing — without that, `` \` `` paired with a later real
1031/// opener and swallowed everything between them, which hid a table column from
1032/// `rto_spec::fmt` and would hide a `[[…]]` link or a `@rto:` annotation from
1033/// the scanners that read this. The rule is **asymmetric**: escapes do not work
1034/// *inside* a code span, so a backslash before the closing run is content and
1035/// the run still closes.
1036///
1037/// Separate from [`strip_code_spans`] because removing a span and knowing where
1038/// one *is* are different questions, and `rto_spec::fmt` needs the second — a
1039/// table row's `|` inside a code span is content rather than a column boundary.
1040/// It had its own backtick scanner until #790 found that it entered code mode on
1041/// an unmatched run and hid the rest of the row, which is exactly the case this
1042/// rule exists to get right.
1043#[must_use]
1044pub fn code_spans(line: &str) -> Vec<(usize, usize)> {
1045 let bytes = line.as_bytes();
1046 let mut out = Vec::new();
1047 let mut i = 0;
1048 while i < bytes.len() {
1049 if bytes[i] != b'`' || is_escaped(bytes, i) {
1050 i += 1;
1051 continue;
1052 }
1053 // Measure the opening backtick run.
1054 let run_start = i;
1055 while i < bytes.len() && bytes[i] == b'`' {
1056 i += 1;
1057 }
1058 let run = i - run_start;
1059 // Find a closing run of exactly the same length.
1060 let mut j = i;
1061 let mut close = None;
1062 while j < bytes.len() {
1063 // **No escape check on the close.** `CommonMark`: backslash
1064 // escapes do not work inside a code span, so a backslash before the
1065 // closing run is literal content and the run still closes. Applying
1066 // the opener's rule here made `` `a\` `` run on to the next
1067 // backtick and swallow whatever lay between.
1068 if bytes[j] == b'`' {
1069 let s = j;
1070 while j < bytes.len() && bytes[j] == b'`' {
1071 j += 1;
1072 }
1073 if j - s == run {
1074 close = Some(j);
1075 break;
1076 }
1077 } else {
1078 j += 1;
1079 }
1080 }
1081 // An unmatched opening run is literal, and the scan continues *after*
1082 // it rather than restarting inside it.
1083 if let Some(end) = close {
1084 out.push((run_start, end));
1085 i = end;
1086 }
1087 }
1088 out
1089}
1090
1091/// The pieces of a line that survived [`code_spans`], and where each of them
1092/// started in the line itself.
1093///
1094/// Scanning happens on the stripped string so that the answer is the one the
1095/// `[[…]]` scanner has always given — a link whose brackets straddle a code span
1096/// is found, because removing the span joins its halves. Reporting happens in
1097/// the caller's coordinates, so a rewriter can act on what it was handed. The
1098/// two are different, so the stripping keeps a map rather than throwing it away.
1099struct SpanMap(
1100 /// `(offset in the stripped string, offset in the source line, length)`, in
1101 /// order and non-empty.
1102 Vec<(usize, usize, usize)>,
1103);
1104
1105impl SpanMap {
1106 /// The source-line offset a stripped-string range **starts** at: the chunk
1107 /// holding that offset, which is the one the first byte of the link is in.
1108 fn start(&self, at: usize) -> usize {
1109 self.at(at, |chunk_start| at >= chunk_start)
1110 }
1111
1112 /// The source-line offset a stripped-string range **ends** at.
1113 ///
1114 /// Deliberately a different lookup from [`Self::start`], and the difference
1115 /// is the whole reason the two exist. An exclusive end that lands exactly on
1116 /// a chunk boundary belongs to the chunk it closes, not the one beginning
1117 /// there — so it maps to the end of the text the link was read from. Taking
1118 /// the later chunk instead extends the range over the code span that
1119 /// separates them, which is how `[[…]]`` ::x` came back as a span running
1120 /// past its own `]]`.
1121 fn end(&self, at: usize) -> usize {
1122 self.at(at, |chunk_start| at > chunk_start)
1123 }
1124
1125 /// The **widest** source range a stripped-string range came from: every byte
1126 /// that reduced to it, code spans included.
1127 ///
1128 /// The opposite of pairing [`Self::start`] with [`Self::end`], and needed
1129 /// for the opposite question. A link's *extent* should stop at its own
1130 /// delimiters; a link's *text* is what a reader sees, and a reader sees the
1131 /// code span the scan removed. `` [the `Foo` type](x.md) `` scans as the text
1132 /// `the type` and reads as `` the `Foo` type ``, and it is the second that
1133 /// is the citation label.
1134 ///
1135 /// Also the only form that cannot invert: an empty stripped range sitting on
1136 /// a chunk boundary — `` [`x`](y) ``, whose whole label is one code span —
1137 /// has `start` land after `end`, and this widens to the span instead.
1138 fn widest(&self, range: &Range<usize>) -> Range<usize> {
1139 self.end(range.start)..self.start(range.end)
1140 }
1141
1142 /// The stripped-string offset a **source-line** offset reduced to — the
1143 /// inverse of [`Self::start`].
1144 ///
1145 /// Needed because the two halves of an inline link are read in different
1146 /// coordinates. Its brackets are matched over the stripped string, because a
1147 /// code span may hide the `]` that would otherwise close it; its
1148 /// parenthesised part is read from the line, because a backtick there is
1149 /// destination or title text and not a span at all. The scan then has to
1150 /// resume in stripped coordinates from a line offset, which is this.
1151 ///
1152 /// A line offset **inside** a removed span has no stripped offset of its
1153 /// own. It maps to where that span was cut out, which is the first position
1154 /// scanning could sensibly resume at.
1155 fn stripped(&self, at: usize) -> usize {
1156 self.0
1157 .iter()
1158 .rev()
1159 .find(|(_, source, _)| *source <= at)
1160 .map_or(at, |(start, source, len)| start + (at - source).min(*len))
1161 }
1162
1163 /// The source offset of `at`, in the last chunk `keep` accepts.
1164 fn at(&self, at: usize, keep: impl Fn(usize) -> bool) -> usize {
1165 self.0
1166 .iter()
1167 .rev()
1168 .find(|(start, _, _)| keep(*start))
1169 .map_or(at, |(start, source, _)| source + (at - start))
1170 }
1171}
1172
1173/// `line` with its code spans removed, and the map back to it.
1174fn strip_and_map(line: &str) -> (String, SpanMap) {
1175 let mut stripped = String::with_capacity(line.len());
1176 let mut chunks = Vec::new();
1177 let mut at = 0;
1178 for (start, end) in code_spans(line) {
1179 if start > at {
1180 chunks.push((stripped.len(), at, start - at));
1181 stripped.push_str(&line[at..start]);
1182 }
1183 at = end;
1184 }
1185 if at < line.len() {
1186 chunks.push((stripped.len(), at, line.len() - at));
1187 stripped.push_str(&line[at..]);
1188 }
1189 (stripped, SpanMap(chunks))
1190}
1191
1192/// Every `[[…]]` on an already-stripped line: its range there, and its trimmed
1193/// inner text.
1194///
1195/// Deliberately the scan `rto_spec::text::scan_wiki_links` ran before this
1196/// existed, down to the rest-of-line walk: an unclosed `[[` stops the scan
1197/// rather than being skipped past, and an empty `[[]]` is consumed without being
1198/// reported. `markdown_links_parity.rs` holds that claim to a frozen copy of the
1199/// original over every Markdown file in the tree.
1200fn wiki_spans(stripped: &str) -> Vec<(Range<usize>, String)> {
1201 let mut out = Vec::new();
1202 let mut base = 0usize;
1203 let mut rest = stripped;
1204 while let Some(open) = rest.find("[[") {
1205 let after = &rest[open + 2..];
1206 let Some(close) = after.find("]]") else {
1207 break;
1208 };
1209 let inner = after[..close].trim();
1210 let start = base + open;
1211 let end = start + 2 + close + 2;
1212 if !inner.is_empty() {
1213 out.push((start..end, inner.to_owned()));
1214 }
1215 base = end;
1216 rest = &after[close + 2..];
1217 }
1218 out
1219}
1220
1221/// Every `[text](destination)` on an already-stripped line, as `(whole range,
1222/// text range, destination)`.
1223///
1224/// Ranges are **source-line** offsets, and the text range is the widest source
1225/// the label reduced from — a code span inside the label is part of it.
1226///
1227/// An `` is reported as [`LinkKind::Image`] and its range **starts at
1228/// the `!`**, which is the half that matters to a caller splicing over it.
1229fn inline_spans(
1230 line: &str,
1231 stripped: &str,
1232 wiki: &[(Range<usize>, String)],
1233 map: &SpanMap,
1234) -> Vec<(LinkKind, Range<usize>, Range<usize>, String)> {
1235 let bytes = stripped.as_bytes();
1236 let source = line.as_bytes();
1237 let mut out = Vec::new();
1238 let mut i = 0;
1239 while i < bytes.len() {
1240 if bytes[i] == b'\\' {
1241 i += 2;
1242 continue;
1243 }
1244 if bytes[i] != b'[' {
1245 i += 1;
1246 continue;
1247 }
1248 // A `[` opening a `[[…]]` is usually only that, and stepping over the
1249 // claimed range stops it also being read as an inline link whose text is
1250 // a bracket. But `![[a]](target)` is a real image whose *whole alt text*
1251 // is a wiki token, and skipping the claim outright emitted no
1252 // [`LinkKind::Image`] for it — which left the image's source in
1253 // `heading_text`, the one thing that variant exists to prevent. So the
1254 // claim is honoured only once [`inline_at`] has declined. Raised in
1255 // review on #806.
1256 let step = wiki
1257 .iter()
1258 .find(|(r, _)| r.contains(&i))
1259 .map_or(i + 1, |(r, _)| r.end);
1260 let Some((text, destination, end)) = inline_at(line, stripped, map, i) else {
1261 i = step;
1262 continue;
1263 };
1264 let open = map.start(i);
1265 // An image is a `!` immediately before the `[` **in the line**, not in
1266 // the stripped string. Removing a code span joins what was either side
1267 // of it, so ``!`x`[label](target)`` — a literal `!`, a code span and an
1268 // ordinary link — read there as an image, and the span it reported
1269 // covered two things that are not part of one. Raised in review on #806.
1270 let image = open > 0 && source[open - 1] == b'!' && !is_escaped(source, open - 1);
1271 let start = if image { open - 1 } else { open };
1272 let kind = if image {
1273 LinkKind::Image
1274 } else {
1275 LinkKind::Inline
1276 };
1277 out.push((kind, start..end, map.widest(&text), destination));
1278 // `end` is a line offset and the scan runs over the stripped string, so
1279 // come back through the map — and never stand still, whatever it says.
1280 i = map.stripped(end).max(i + 1);
1281 }
1282 out
1283}
1284
1285/// The inline link whose `[` is at `open` in `stripped`.
1286///
1287/// Returns the label's range **in `stripped`**, the destination, and the end of
1288/// the whole link **in `line`** — see the coordinates section below.
1289///
1290/// Brackets and parentheses are matched by depth, so `[see [x]](y)` is one link
1291/// with the text `see [x]` rather than two half-read ones, and a destination may
1292/// hold the balanced parentheses a Wikipedia URL does. A backslash escapes the
1293/// delimiter after it. Anything reaching the end of the line unclosed, or a
1294/// parenthesised part that is not a destination and an optional title, yields no
1295/// link — the no-recovery reading this scanner exists to keep.
1296///
1297/// # Two coordinate systems, and why
1298///
1299/// The **label** is matched over the stripped string: a code span may hold the
1300/// `]` that would otherwise close the link, and `CommonMark` gives the span
1301/// precedence — ``[not a `link](/foo`)`` is prose and a code span, not a link.
1302/// Removing spans first is what gets that right.
1303///
1304/// The **parenthesised part** is read from the line. Inline parsing is
1305/// left-to-right and a link's destination and title are consumed raw the moment
1306/// the `]` is reached, so a backtick past it never opens a span at all:
1307/// ``[x](a`b`c)`` is a link to ``a`b`c`` and ``[t](docs/x.md "a `b`")`` is a
1308/// link to `docs/x.md` titled ``a `b` ``. Reading those off the stripped string
1309/// invented `docs/.md` for the first and rejected the second outright. Raised in
1310/// review on #806; `pulldown-cmark`, which renders this repository's documents,
1311/// is the oracle both claims were checked against.
1312fn inline_at(
1313 line: &str,
1314 stripped: &str,
1315 map: &SpanMap,
1316 open: usize,
1317) -> Option<(Range<usize>, String, usize)> {
1318 let bytes = stripped.as_bytes();
1319 let close = matching(bytes, open, b'[', b']')?;
1320 if bytes.get(close + 1) != Some(&b'(') {
1321 return None;
1322 }
1323 // The `](` must be contiguous **in the line**. These two bytes are the only
1324 // place a removed span makes a link out of what was not one: ``[a]`x`(b)``
1325 // is a bracketed literal followed by a code span, and joining its halves
1326 // reported a link to `b`. A backtick inside the label or inside the
1327 // destination is content and is deliberately not covered here.
1328 let paren = map.start(close + 1);
1329 if paren != map.start(close) + 1 {
1330 return None;
1331 }
1332 let dest_end = destination_end(line.as_bytes(), paren)?;
1333 let destination = destination_of(&line[paren + 1..dest_end])?;
1334 Some((open + 1..close, destination, dest_end + 1))
1335}
1336
1337/// The offset of the first **unescaped** `byte` in `s`.
1338fn unescaped(s: &str, byte: u8) -> Option<usize> {
1339 let bytes = s.as_bytes();
1340 let mut i = 0;
1341 while i < bytes.len() {
1342 if bytes[i] == b'\\' {
1343 i += 2;
1344 continue;
1345 }
1346 if bytes[i] == byte {
1347 return Some(i);
1348 }
1349 i += 1;
1350 }
1351 None
1352}
1353
1354/// Whether the byte at `at` is escaped by an unbalanced run of backslashes.
1355fn is_escaped(bytes: &[u8], at: usize) -> bool {
1356 bytes[..at]
1357 .iter()
1358 .rev()
1359 .take_while(|b| **b == b'\\')
1360 .count()
1361 % 2
1362 == 1
1363}
1364
1365/// The offset of the `)` closing the `(` at `from`, counting nested parentheses
1366/// and **ignoring the ones inside a quoted title**.
1367///
1368/// Not [`matching`]: `CommonMark` allows `[t](x.md "a ) b")`, where the first
1369/// `)` is title text. Counting it closed the link early and left a range ending
1370/// inside itself, which a caller splicing over the span turns into rubble.
1371///
1372/// A quote only opens a title, and a title only begins after whitespace — so the
1373/// apostrophe in `(https://e.org/a'b)` is part of the destination rather than an
1374/// unterminated title swallowing the rest of the line.
1375fn destination_end(bytes: &[u8], from: usize) -> Option<usize> {
1376 let mut i = from + 1;
1377 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
1378 i += 1;
1379 }
1380 // An angle-bracket destination is **opaque**: `CommonMark` lets it hold the
1381 // parentheses and quotes that close the link everywhere else, which is the
1382 // point of writing one. `[t](<https://e.org/a_(b)>)` is a valid link, and
1383 // counting its `)` against the outer depth rejected it.
1384 let mut angle = false;
1385 if bytes.get(i) == Some(&b'<') {
1386 i += 1;
1387 loop {
1388 let byte = *bytes.get(i)?;
1389 i += 1;
1390 if byte == b'\\' {
1391 i += 1;
1392 } else if byte == b'>' {
1393 break;
1394 }
1395 }
1396 angle = true;
1397 }
1398 let mut depth = 1usize;
1399 let mut quote: Option<u8> = None;
1400 // A `>` ends the destination as definitively as whitespace does, so a title
1401 // may open on the very next byte — and this scanner accepts one there, see
1402 // `a_title_may_follow_an_angle_destination_without_a_separator`. Leaving
1403 // this `false` made that acceptance half-work: the quote never opened a
1404 // title, so a `)` inside the title closed the outer link and
1405 // `[t](<x.md>"a ) b")` — which `pulldown-cmark` renders as a link — was
1406 // rejected outright. Raised in review on #806 as the downstream cost of that
1407 // divergence; this is the half of it that was simply a bug.
1408 let mut after_space = angle;
1409 while i < bytes.len() {
1410 if bytes[i] == b'\\' {
1411 i += 2;
1412 continue;
1413 }
1414 match quote {
1415 Some(open) if bytes[i] == open => quote = None,
1416 Some(_) => {}
1417 None => match bytes[i] {
1418 b'"' | b'\'' if after_space && depth == 1 => quote = Some(bytes[i]),
1419 b'(' => depth += 1,
1420 b')' => {
1421 depth = depth.checked_sub(1)?;
1422 if depth == 0 {
1423 return Some(i);
1424 }
1425 }
1426 b if b.is_ascii_whitespace() => after_space = true,
1427 _ => {}
1428 },
1429 }
1430 i += 1;
1431 }
1432 None
1433}
1434
1435/// The offset of the `shut` byte closing the `open` byte at `from`, counting
1436/// nesting and honouring backslash escapes.
1437fn matching(bytes: &[u8], from: usize, open: u8, shut: u8) -> Option<usize> {
1438 let mut depth = 0usize;
1439 let mut i = from;
1440 while i < bytes.len() {
1441 // A continuation byte of a multi-byte character is never one of the
1442 // ASCII delimiters below, so stepping over one byte after a backslash
1443 // cannot mis-read a character — and every offset returned is at an
1444 // ASCII delimiter, so it is always a char boundary.
1445 if bytes[i] == b'\\' {
1446 i += 2;
1447 continue;
1448 }
1449 if bytes[i] == open {
1450 depth += 1;
1451 } else if bytes[i] == shut {
1452 // A close with nothing open is malformed rather than a link, and
1453 // saying so here is also what keeps the subtraction from wrapping.
1454 depth = depth.checked_sub(1)?;
1455 if depth == 0 {
1456 return Some(i);
1457 }
1458 }
1459 i += 1;
1460 }
1461 None
1462}
1463
1464/// The destination out of an inline link's parenthesised part, or `None` when
1465/// that part is not a destination and an optional title.
1466///
1467/// `CommonMark` allows exactly two forms — a bare destination holding no
1468/// unescaped whitespace, or a `<…>`-wrapped one that may — each optionally
1469/// followed by a title in `"…"`, `'…'` or `(…)`. **Anything else is not a
1470/// link**, and saying so is the whole difference between this and a recovering
1471/// parser: `[t](foo bar)` reads as a citation of `foo` the moment the trailing
1472/// junk is ignored, and `[t](<unclosed)` as one of `<unclosed`. Neither names
1473/// anything, and a plausible wrong citation is worse than none.
1474fn destination_of(raw: &str) -> Option<String> {
1475 let raw = raw.trim();
1476 let (destination, rest) = if let Some(rest) = raw.strip_prefix('<') {
1477 // The angle form must close, on an **unescaped** `>`; `[t](<a\>b>)` is
1478 // one destination, not one truncated at the escape. An unclosed one is
1479 // not a destination at all.
1480 let end = unescaped(rest, b'>')?;
1481 // It may hold a `<` only escaped, for the same reason. `[t](<a<b>)` is
1482 // not a link to `a<b`; it is not a link at all, and `pulldown-cmark`
1483 // renders it as the literal text it is. Accepting it invented a target
1484 // out of malformed punctuation, which is the one thing this function
1485 // exists to refuse. Raised in review on #806.
1486 if unescaped(&rest[..end], b'<').is_some() {
1487 return None;
1488 }
1489 (&rest[..end], rest[end + 1..].trim_start())
1490 } else {
1491 let end = raw.find(char::is_whitespace).unwrap_or(raw.len());
1492 (&raw[..end], raw[end..].trim_start())
1493 };
1494 if destination.is_empty() || !(rest.is_empty() || is_title(rest)) {
1495 return None;
1496 }
1497 Some(destination.to_owned())
1498}
1499
1500/// Whether `rest` is exactly **one** `CommonMark` link title and nothing else.
1501///
1502/// The three forms are `"…"`, `'…'` and `(…)`. What makes this more than a
1503/// first-and-last-character test is the interior rule: a title may not hold its
1504/// own closing delimiter unescaped, and the parenthesised form may not hold an
1505/// unescaped `(` either. Testing only the ends accepted five malformed shapes as
1506/// links, every one of which `pulldown-cmark` renders as literal text — one
1507/// title running into another (`[t](x.md "one" "two")`), one closed and
1508/// reopened (`[t](x.md "a"x"b")`), the same through an angle destination
1509/// (`[t](<x.md> "a" "b")`), and both paren shapes (`[t](x.md (a)b(c))`,
1510/// `[t](x.md (a(b)c))`). Each produced a confident target for a line that names
1511/// nothing, which is precisely the invention [`destination_of`] exists to
1512/// refuse. Raised in review on #806 as one instance; the other four came out of
1513/// sweeping the rule against the renderer.
1514///
1515/// `rest` is already trimmed, so a title with anything after it fails on the
1516/// closing delimiter rather than needing a separate trailing-junk test.
1517fn is_title(rest: &str) -> bool {
1518 let Some(shut) = rest.as_bytes().first().and_then(|b| match b {
1519 b'"' => Some(b'"'),
1520 b'\'' => Some(b'\''),
1521 b'(' => Some(b')'),
1522 _ => None,
1523 }) else {
1524 return false;
1525 };
1526 if rest.len() < 2 || rest.as_bytes()[rest.len() - 1] != shut {
1527 return false;
1528 }
1529 let inner = &rest[1..rest.len() - 1];
1530 // The paren form is the only one whose delimiters differ, so it is the only
1531 // one that has to refuse its *opener* as well.
1532 unescaped(inner, shut).is_none() && (shut != b')' || unescaped(inner, b'(').is_none())
1533}
1534
1535#[cfg(test)]
1536mod link_tests {
1537 use super::{
1538 LinkKind, LinkScope, code_spans, is_code_fence, link_scope, markdown_links,
1539 strip_code_spans, wiki_link_targets,
1540 };
1541
1542 /// Every link on a line, as `(kind, target, text, external)`, so a case can
1543 /// state the whole answer rather than one field of it.
1544 fn scanned(line: &str) -> Vec<(LinkKind, String, String, bool)> {
1545 markdown_links(line)
1546 .into_iter()
1547 .map(|l| (l.kind, l.target, l.text, l.scope.is_external()))
1548 .collect()
1549 }
1550
1551 // -----------------------------------------------------------------------
1552 // Code spans — the rule `rto_spec::text` held before this, moved with it.
1553 // -----------------------------------------------------------------------
1554
1555 /// A backslash-escaped backtick is literal and opens no span.
1556 ///
1557 /// It used to pair with the next real opener and swallow everything
1558 /// between, which hid a table column from `rto_spec::fmt` — and would hide
1559 /// a `[[…]]` link or a `@rto:` annotation from the scanners that read this,
1560 /// since they share this rule. Raised on #790.
1561 #[test]
1562 fn an_escaped_backtick_opens_no_span() {
1563 assert_eq!(code_spans(r"a \` b `code` c").len(), 1);
1564 assert_eq!(strip_code_spans(r"a \` b `code` c"), r"a \` b c");
1565 // A doubled backslash escapes itself, so the backtick is real again.
1566 assert_eq!(code_spans(r"a \\`code` b").len(), 1);
1567 // And the link scanner is not fooled by one.
1568 assert_eq!(wiki_link_targets(r"\` [[docs/x.md]]"), vec!["docs/x.md"]);
1569 }
1570
1571 /// The rule is asymmetric: an escape opens nothing, but closes normally.
1572 ///
1573 /// `CommonMark` does not process backslash escapes inside a code span, so a
1574 /// backslash before the closing run is literal content and the run still
1575 /// closes. Treating the close like the open made a span run on to the next
1576 /// backtick and swallow everything between. Raised on #790.
1577 #[test]
1578 fn an_escape_before_a_closing_run_still_closes_the_span() {
1579 // One span, ending at the backtick after the backslash.
1580 assert_eq!(code_spans(r"`a\` and [[docs/x.md]]").len(), 1);
1581 assert_eq!(
1582 wiki_link_targets(r"`a\` and [[docs/x.md]]"),
1583 vec!["docs/x.md"]
1584 );
1585 assert_eq!(strip_code_spans(r"`a\` rest"), " rest");
1586 }
1587
1588 #[test]
1589 fn removes_single_and_multi_backtick_spans() {
1590 assert_eq!(strip_code_spans("a `code` b"), "a b");
1591 // A run of two backticks (used to embed a literal backtick) is a span too.
1592 assert_eq!(strip_code_spans("see ``@rto:0001`` here"), "see here");
1593 assert_eq!(strip_code_spans("x ```fenced inline``` y"), "x y");
1594 }
1595
1596 #[test]
1597 fn keeps_unmatched_backticks_and_plain_text() {
1598 assert_eq!(strip_code_spans("no code here"), "no code here");
1599 assert_eq!(strip_code_spans("unmatched ` tick"), "unmatched ` tick");
1600 // Mismatched run lengths do not close the span.
1601 assert_eq!(strip_code_spans("``open ` mid"), "``open ` mid");
1602 }
1603
1604 #[test]
1605 fn preserves_utf8_outside_spans() {
1606 assert_eq!(strip_code_spans("café `x` — ok"), "café — ok");
1607 }
1608
1609 // -----------------------------------------------------------------------
1610 // Code-span exclusion, for both kinds
1611 // -----------------------------------------------------------------------
1612
1613 /// A link of either kind inside single backticks is a documentation
1614 /// example, not a link — the property the four `rto_spec` scanners depend
1615 /// on and the one most likely to be lost in a rewrite of this scanner.
1616 #[test]
1617 fn a_link_inside_a_code_span_is_not_a_link() {
1618 assert!(scanned("see `[[docs/x.md#Sym]]` for the form").is_empty());
1619 assert!(scanned("write `[label](target.md)` like this").is_empty());
1620 // A run of two backticks is a span too, and so is a triple-backtick
1621 // *inline* run — which is not a fence, because a fence is a whole line.
1622 assert!(scanned("``[[a/b.md]]`` and ```[c](d.md)```").is_empty());
1623 // The example and a real link on one line: only the real one counts.
1624 assert_eq!(
1625 scanned("`[[example]]` but [[docs/real.md]] resolves")
1626 .iter()
1627 .map(|(_, t, _, _)| t.as_str())
1628 .collect::<Vec<_>>(),
1629 vec!["docs/real.md"]
1630 );
1631 }
1632
1633 /// An **unmatched** backtick run shields nothing, so a link after one is
1634 /// still a link. This is the half #790 got wrong in `rto_spec::fmt`: a
1635 /// scanner that enters code mode on an opener it never closes hides the
1636 /// rest of the line.
1637 #[test]
1638 fn an_unclosed_code_span_hides_nothing() {
1639 assert_eq!(wiki_link_targets("` [[docs/x.md]]"), vec!["docs/x.md"]);
1640 assert_eq!(
1641 scanned("`` [text](docs/x.md)")
1642 .iter()
1643 .map(|(_, t, _, _)| t.as_str())
1644 .collect::<Vec<_>>(),
1645 vec!["docs/x.md"]
1646 );
1647 }
1648
1649 /// Removing a code span **joins** what was either side of it, which is what
1650 /// the scan has always done and is therefore what it must keep doing: a
1651 /// link whose brackets straddle a span is found, and its reported range
1652 /// covers the span it straddles so a rewriter replaces the whole thing.
1653 #[test]
1654 fn a_link_straddling_a_code_span_is_one_link() {
1655 let line = "[[docs/`x`.md]]";
1656 assert_eq!(wiki_link_targets(line), vec!["docs/.md"]);
1657 assert_eq!(markdown_links(line)[0].span, 0..line.len());
1658 }
1659
1660 /// A link whose close **abuts** a code span ends at its own `]]`, not at the
1661 /// far side of the span.
1662 ///
1663 /// The two offsets are the same number in the stripped string and different
1664 /// numbers in the line, so this is the one case where mapping an exclusive
1665 /// end like a start silently over-extends every such range. It was a real
1666 /// defect, found by `markdown_links_parity.rs` over
1667 /// `docs/adr/0009-…:232` while none of the cases here noticed; it is pinned
1668 /// here so the corpus is not the only thing standing between the bug and a
1669 /// rewriter splicing over a reader's backticks.
1670 #[test]
1671 fn a_link_ending_where_a_code_span_begins_stops_at_its_own_close() {
1672 let line = "[[crates/x.rs#Sym]]`::field` and prose";
1673 let links = markdown_links(line);
1674 assert_eq!(&line[links[0].span.clone()], "[[crates/x.rs#Sym]]");
1675 // The same shape for an inline link, whose close is a single `)`.
1676 let line = "[t](docs/x.md)`::field`";
1677 assert_eq!(
1678 &line[markdown_links(line)[0].span.clone()],
1679 "[t](docs/x.md)"
1680 );
1681 }
1682
1683 /// A fenced or indented code block is **not** this function's business, and
1684 /// saying so is the point: it reads one line and cannot see a fence at all.
1685 ///
1686 /// # This is a reported inconsistency, not a design
1687 ///
1688 /// Every document scanner in `rto_spec` tracks fences itself and skips the
1689 /// lines inside them, so a fenced `[[…]]` is not an authored link. **No
1690 /// scanner in this workspace excludes an indented code block**, so a
1691 /// four-space-indented `[[…]]` *is* one, and the gate resolves a link the
1692 /// renderer shows as literal code. That predates this function, is
1693 /// unchanged by it, and is recorded here rather than silently fixed —
1694 /// fixing it moves what `roteiro check` counts.
1695 #[test]
1696 fn a_fence_is_not_visible_from_one_line() {
1697 // The fence delimiter itself, which callers key on.
1698 assert!(is_code_fence("```"));
1699 assert!(is_code_fence(" ```rust"));
1700 assert!(is_code_fence("~~~"));
1701 assert!(!is_code_fence("a ``` b"));
1702 // Indentation is deliberately unbounded, and the reason is in the docs:
1703 // four spaces at the top level is an indented code block while the same
1704 // four inside a list item is an ordinary fence, so the real bound is
1705 // relative to a container one line cannot see. This repository already
1706 // has the second shape (`okf-upstream/.../run-on-bq.md`), so a fixed
1707 // limit of three would break it. Raised in review on #806.
1708 assert!(is_code_fence(" ```json"));
1709 assert!(is_code_fence("\t```"));
1710 // A line *inside* either kind of block still yields its link here…
1711 assert_eq!(
1712 wiki_link_targets("[[docs/fenced.md]]"),
1713 vec!["docs/fenced.md"]
1714 );
1715 // …including a four-space-indented one, which nothing filters today.
1716 assert_eq!(
1717 wiki_link_targets(" [[docs/indented.md]]"),
1718 vec!["docs/indented.md"]
1719 );
1720 // A line that both opens a fence and carries a link: the delimiter test
1721 // and the scan are independent, so a caller sees both facts.
1722 assert!(is_code_fence("``` [[docs/straddle.md]]"));
1723 assert_eq!(
1724 wiki_link_targets("``` [[docs/straddle.md]]"),
1725 vec!["docs/straddle.md"]
1726 );
1727 }
1728
1729 // -----------------------------------------------------------------------
1730 // Wiki links — the semantics that must not move
1731 // -----------------------------------------------------------------------
1732
1733 #[test]
1734 fn wiki_links_are_trimmed_and_never_empty() {
1735 assert_eq!(
1736 wiki_link_targets("[[ docs/x.md#Sym ]]"),
1737 vec!["docs/x.md#Sym"]
1738 );
1739 assert!(wiki_link_targets("[[]] and [[ ]]").is_empty());
1740 assert_eq!(
1741 wiki_link_targets("[[a.md]] then [[b.md]]"),
1742 vec!["a.md", "b.md"]
1743 );
1744 }
1745
1746 /// An unclosed `[[` **stops** the scan rather than being skipped past, and
1747 /// an *earlier* one swallows a later well-formed link into its own target
1748 /// instead of yielding two.
1749 ///
1750 /// Both are what `rto_spec::text::scan_wiki_links` did, so both are what
1751 /// this has to keep doing. Neither is what a reader would call right, and
1752 /// the second produces a target that resolves to nothing — which is why it
1753 /// is safe as well as required: it costs the gate a violation it already
1754 /// reported, not a link it already counted. Recorded rather than fixed;
1755 /// fixing it changes what `roteiro check` counts.
1756 #[test]
1757 fn an_unclosed_wiki_link_ends_the_scan() {
1758 // The close belongs to the *first* opener, so this is one target.
1759 assert_eq!(
1760 wiki_link_targets("[[unclosed and [[docs/x.md]]"),
1761 vec!["unclosed and [[docs/x.md"]
1762 );
1763 // An opener with no close at all ends the scan where it stands.
1764 assert_eq!(
1765 wiki_link_targets("[[docs/x.md]] then [[unclosed"),
1766 vec!["docs/x.md"]
1767 );
1768 }
1769
1770 /// A wiki-link is never external and its text is its target, so a caller
1771 /// labelling links does not have to special-case the kind.
1772 #[test]
1773 fn a_wiki_link_is_internal_and_labels_itself() {
1774 assert_eq!(
1775 scanned("[[docs/adr/0001-x.md#Design]]"),
1776 vec![(
1777 LinkKind::Wiki,
1778 "docs/adr/0001-x.md#Design".to_owned(),
1779 "docs/adr/0001-x.md#Design".to_owned(),
1780 false,
1781 )]
1782 );
1783 }
1784
1785 // -----------------------------------------------------------------------
1786 // Inline links — the new capability
1787 // -----------------------------------------------------------------------
1788
1789 #[test]
1790 fn an_inline_link_yields_its_text_and_destination() {
1791 assert_eq!(
1792 scanned("see [the ADR](docs/adr/0026-x.md) for why"),
1793 vec![(
1794 LinkKind::Inline,
1795 "docs/adr/0026-x.md".to_owned(),
1796 "the ADR".to_owned(),
1797 false,
1798 )]
1799 );
1800 }
1801
1802 /// The classification a citation needs: whose work this names.
1803 #[test]
1804 fn a_destination_is_internal_or_external_by_its_scheme() {
1805 for internal in [
1806 "docs/x.md",
1807 "../README.md",
1808 "/docs/x.md",
1809 "#a-section",
1810 "x.md#a:b",
1811 "C/x.md",
1812 ] {
1813 assert_eq!(
1814 link_scope(internal),
1815 LinkScope::Internal,
1816 "{internal} is in this repository"
1817 );
1818 }
1819 for external in [
1820 "https://example.org/a",
1821 "http://example.org",
1822 "mailto:a@b.c",
1823 "//example.org/a",
1824 "ftp://example.org",
1825 "tel:+441234",
1826 ] {
1827 assert_eq!(
1828 link_scope(external),
1829 LinkScope::External,
1830 "{external} is somebody else's"
1831 );
1832 }
1833 }
1834
1835 /// Brackets and parentheses nest, and a title is metadata rather than a
1836 /// destination — both cases where reading to the *first* delimiter gives a
1837 /// target nothing resolves.
1838 #[test]
1839 fn nesting_and_titles_are_read_the_way_commonmark_writes_them() {
1840 assert_eq!(
1841 scanned("[see [x]](docs/y.md)"),
1842 vec![(
1843 LinkKind::Inline,
1844 "docs/y.md".to_owned(),
1845 "see [x]".to_owned(),
1846 false,
1847 )]
1848 );
1849 assert_eq!(
1850 scanned(r#"[t](docs/y.md "A title")"#)
1851 .iter()
1852 .map(|(_, t, _, _)| t.as_str())
1853 .collect::<Vec<_>>(),
1854 vec!["docs/y.md"]
1855 );
1856 // Balanced parentheses inside a destination, as a Wikipedia URL has.
1857 assert_eq!(
1858 scanned("[t](https://e.org/A_(b))")
1859 .iter()
1860 .map(|(_, t, _, _)| t.as_str())
1861 .collect::<Vec<_>>(),
1862 vec!["https://e.org/A_(b)"]
1863 );
1864 // The angle-bracket form, which may hold a space.
1865 assert_eq!(
1866 scanned("[t](<a b.md>)")
1867 .iter()
1868 .map(|(_, t, _, _)| t.as_str())
1869 .collect::<Vec<_>>(),
1870 vec!["a b.md"]
1871 );
1872 }
1873
1874 /// A link that does not close is not a link, and neither is one naming
1875 /// nothing — the reading the OKF bundle reader argued for, kept here so it
1876 /// cannot invent an edge out of stray punctuation.
1877 ///
1878 /// The last three are the same rule reaching further than "closes": a
1879 /// parenthesised part that is not a destination and an optional title is
1880 /// **not a link either**. Ignoring the junk instead turns `[t](foo bar)`
1881 /// into a citation of `foo` and `[t](<unclosed)` into one of `<unclosed`,
1882 /// and a plausible wrong citation is worse than none (#801, raised in
1883 /// review on #806).
1884 #[test]
1885 fn malformed_and_empty_inline_links_are_not_links() {
1886 assert!(scanned("[text](unclosed").is_empty());
1887 assert!(scanned("[text without a destination]").is_empty());
1888 assert!(scanned("[text]()").is_empty());
1889 assert!(scanned(r"\[not a link](docs/x.md)").is_empty());
1890 assert!(scanned("[t](<unclosed)").is_empty());
1891 assert!(scanned("[t](foo bar)").is_empty());
1892 assert!(scanned("[t](docs/x.md not-a-title)").is_empty());
1893 }
1894
1895 /// An image is its own kind, not an `Inline` link — so a `.png` cannot
1896 /// reach a reference list, and a caller reducing markdown to visible text
1897 /// still gets the alt text rather than the source folded in.
1898 ///
1899 /// It was read as an `Inline` link *starting one byte late*, which is both
1900 /// at once wrong. Raised in review on #806.
1901 #[test]
1902 fn an_image_is_its_own_kind_and_starts_at_the_bang() {
1903 let line = "";
1904 assert_eq!(
1905 scanned(line),
1906 vec![(
1907 LinkKind::Image,
1908 "docs/x.png".to_owned(),
1909 "a diagram".to_owned(),
1910 false,
1911 )]
1912 );
1913 // The range covers the `!`, so a caller splicing over it drops the whole
1914 // image rather than leaving a stray bang behind.
1915 assert_eq!(&line[markdown_links(line)[0].span.clone()], line);
1916 // An escaped `!` is literal text, so what follows it *is* a link.
1917 assert_eq!(
1918 scanned(r"\")
1919 .iter()
1920 .map(|(_, t, _, _)| t.as_str())
1921 .collect::<Vec<_>>(),
1922 vec!["docs/x.md"]
1923 );
1924 // A real link on the same line as an image is still an `Inline` one.
1925 assert_eq!(
1926 scanned(" and [t](docs/x.md)")
1927 .iter()
1928 .map(|(k, t, _, _)| (*k, t.as_str()))
1929 .collect::<Vec<_>>(),
1930 vec![(LinkKind::Image, "a.png"), (LinkKind::Inline, "docs/x.md")]
1931 );
1932 }
1933
1934 /// A `[[…]]` inside an image's alt text **is** still reported, and that is
1935 /// required rather than tolerated.
1936 ///
1937 /// A `[[…]]` is a Roteiro token found anywhere on the line; the scanner this
1938 /// replaced had no concept of images, and `roteiro check` counts what that
1939 /// scanner found. Suppressing it here would move the gate's number. Raised
1940 /// in review on #806, and answered by the parity contract rather than by a
1941 /// change. The image rule applies to the `[…](…)` syntax it is part of.
1942 #[test]
1943 fn a_wiki_link_in_alt_text_is_reported_because_the_gate_counts_it() {
1944 assert_eq!(
1945 scanned("![alt [[docs/x.md]]](i.png)")
1946 .iter()
1947 .map(|(k, t, _, _)| (*k, t.as_str()))
1948 .collect::<Vec<_>>(),
1949 vec![(LinkKind::Image, "i.png"), (LinkKind::Wiki, "docs/x.md")]
1950 );
1951 // The image is reported first because its range opens first, and that
1952 // range **encloses** the wiki-link's. Overlap is the honest report of an
1953 // overlap; a caller splicing ranges takes one kind, as both of this
1954 // function's rewriting callers do.
1955 let links = markdown_links("![alt [[docs/x.md]]](i.png)");
1956 assert!(links[0].span.start < links[1].span.start);
1957 assert!(links[0].span.end > links[1].span.end);
1958 }
1959
1960 /// The same overlap through an ordinary inline link — the general case, and
1961 /// the one that says what a splicing caller may assume.
1962 ///
1963 /// `[See [[x]]](target)` is one inline link enclosing one wiki-link. A caller
1964 /// walking **both** would pass the outer link's end and then meet the inner
1965 /// one's start, slicing a backwards range. No caller does today: overlap only
1966 /// ever pairs a wiki-link with an inline or image one, so taking a single
1967 /// kind — which both rewriting callers do — is non-overlapping, and that is
1968 /// asserted below rather than asserted in prose. Raised in review on #806 as
1969 /// a live panic in `doc_anchor_fragments.rs`; it was not one, because that
1970 /// caller drops wiki-links. The contract still permits it, so it is written
1971 /// down and guarded.
1972 #[test]
1973 fn an_inline_link_may_enclose_a_wiki_link() {
1974 let line = "[See [[docs/x.md]]](target.md)";
1975 let links = markdown_links(line);
1976 assert_eq!(
1977 links
1978 .iter()
1979 .map(|l| (l.kind, l.target.as_str()))
1980 .collect::<Vec<_>>(),
1981 vec![
1982 (LinkKind::Inline, "target.md"),
1983 (LinkKind::Wiki, "docs/x.md")
1984 ]
1985 );
1986 assert_eq!(&line[links[0].span.clone()], line);
1987 assert_eq!(&line[links[1].span.clone()], "[[docs/x.md]]");
1988 // Either kind on its own is non-overlapping, which is what the two
1989 // rewriting callers rely on.
1990 for kind in [LinkKind::Wiki, LinkKind::Inline] {
1991 let mut at = 0;
1992 for l in markdown_links(line).iter().filter(|l| l.kind == kind) {
1993 assert!(l.span.start >= at, "{kind:?} spans overlap");
1994 at = l.span.end;
1995 }
1996 }
1997 }
1998
1999 /// A code span between the `]` and the `(` means this is **not** a link — and
2000 /// a backtick past the `]` is not a code span at all.
2001 ///
2002 /// Removing code spans before the scan joins what was either side of them,
2003 /// which is right for a `[[…]]` (it is what the scan this replaced did) and
2004 /// wrong for the two bytes `](`: ``[a]`x`(b)`` is a bracketed literal
2005 /// followed by a code span, and joining its halves reported a link to `b`.
2006 ///
2007 /// The first version of this guard covered the **whole** `](…)` tail, and
2008 /// that was too much. Inline parsing is left-to-right: once the `]` is
2009 /// reached the destination and title are consumed raw, so a backtick inside
2010 /// them never opens a span. ``[x](a`b`c.md)`` is a link to ``a`b`c.md``, not
2011 /// prose, and ``[t](x.md "a `b`")`` is a titled link this rejected outright.
2012 /// Both were raised in review on #806 and both were checked against
2013 /// `pulldown-cmark`, which is what renders this repository's documents — a
2014 /// scanner disagreeing with the renderer is the defect class #801 exists to
2015 /// remove, so the renderer's reading is the one that wins.
2016 #[test]
2017 fn a_code_span_splits_a_link_only_between_its_bracket_and_its_paren() {
2018 // A span across the `](` — not a link, and never was.
2019 assert!(scanned("[a]`x`(docs/b.md)").is_empty());
2020 // A span swallowing the `]` — the close is inside code, so no link.
2021 assert!(scanned("[not a `link](/foo`)").is_empty());
2022 // Past the `]` a backtick is destination or title text.
2023 assert_eq!(scanned("[x](a`b`c.md)")[0].1, "a`b`c.md");
2024 assert_eq!(scanned("[x](`docs/x.md`)")[0].1, "`docs/x.md`");
2025 assert_eq!(scanned("[t](docs/x.md \"a `b`\")")[0].1, "docs/x.md");
2026 // The label is deliberately not covered either: a span there is content.
2027 assert_eq!(scanned("[the `Foo` type](docs/x.md)")[0].1, "docs/x.md");
2028 assert_eq!(
2029 scanned("[the `Foo` type](docs/x.md)")[0].2,
2030 "the `Foo` type"
2031 );
2032 }
2033
2034 /// An image is a `!` immediately before the `[` **in the line**, not in the
2035 /// string the code spans were cut out of.
2036 ///
2037 /// ``!`x`[label](target)`` is a literal `!`, a code span and an ordinary
2038 /// link. Reading the `!` off the stripped string made the three adjacent and
2039 /// reported an [`LinkKind::Image`] whose span covered two things that are
2040 /// not part of it — so a caller splicing the span would have deleted the
2041 /// code span with it. Raised in review on #806.
2042 #[test]
2043 fn a_code_span_before_a_link_does_not_make_it_an_image() {
2044 let line = "!`x`[label](target)";
2045 assert_eq!(scanned(line)[0].0, LinkKind::Inline);
2046 assert_eq!(markdown_links(line)[0].span, 4..19);
2047 // A code span *before* a real `!` still leaves it an image, and the
2048 // span starts at the `!` rather than at the backtick.
2049 let line = "`q`";
2050 assert_eq!(scanned(line)[0].0, LinkKind::Image);
2051 assert_eq!(markdown_links(line)[0].span, 3..10);
2052 // An escaped `!` is prose, so what follows it is a plain link.
2053 assert_eq!(scanned("\\")[0].0, LinkKind::Inline);
2054 }
2055
2056 /// An image whose **whole** alt text is a wiki token is still an image.
2057 ///
2058 /// The scan steps over a range `[[…]]` has already claimed so that `[[a]]`
2059 /// is one wiki-link rather than also an inline one with a bracket for text.
2060 /// For `![[a]](target)` that stepped straight past the `](…)` and emitted no
2061 /// [`LinkKind::Image`], which left the image's *source* in `heading_text` —
2062 /// `diagram-img-png` instead of `diagram`, the exact failure that variant
2063 /// was added to prevent. The claim is now honoured only after an inline read
2064 /// has been attempted. Raised in review on #806.
2065 #[test]
2066 fn an_image_whose_alt_text_is_a_wiki_token_is_still_an_image() {
2067 let line = "![[a]](target)";
2068 assert_eq!(
2069 scanned(line)
2070 .iter()
2071 .map(|(k, t, _, _)| (*k, t.clone()))
2072 .collect::<Vec<_>>(),
2073 vec![
2074 (LinkKind::Image, "target".to_owned()),
2075 (LinkKind::Wiki, "a".to_owned()),
2076 ]
2077 );
2078 // The wiki-link is still reported, because `roteiro check` counts it —
2079 // see the parity corpus. Without the image beside it the gate is fine
2080 // and the *renderer* is not, which is how the two drift apart.
2081 assert_eq!(wiki_link_targets(line), vec!["a"]);
2082 // A bare `[[a]]` with no `(…)` after it is only a wiki-link.
2083 assert_eq!(scanned("[[a]]").len(), 1);
2084 }
2085
2086 /// An angle-bracket destination is opaque: it may hold the parentheses and
2087 /// quotes that close the link everywhere else, which is the point of writing
2088 /// one. Counting them rejected `[t](<https://e.org/a_(b)>)`, a valid link.
2089 /// Raised in review on #806.
2090 #[test]
2091 fn an_angle_destination_may_hold_what_would_otherwise_close_the_link() {
2092 let line = "[t](<https://e.org/a_(b)>) after";
2093 let links = markdown_links(line);
2094 assert_eq!(links[0].target, "https://e.org/a_(b)");
2095 assert_eq!(&line[links[0].span.clone()], "[t](<https://e.org/a_(b)>)");
2096 assert!(links[0].scope.is_external());
2097 // A quote inside one is content, not a title nobody closed.
2098 assert_eq!(scanned(r#"[t](<a "b".md>)"#)[0].1, r#"a "b".md"#);
2099 // The closing `>` must be unescaped, so an escaped one is content.
2100 assert_eq!(scanned(r"[t](<a\>b.md>)")[0].1, r"a\>b.md");
2101 // And one that never closes is still not a link.
2102 assert!(scanned("[t](<https://e.org/a").is_empty());
2103 }
2104
2105 /// An angle destination may hold `<` and `>` only **escaped**.
2106 ///
2107 /// `[t](<a<b>)` was reported as a link to `a<b`, which is a target invented
2108 /// out of malformed punctuation — the one thing [`destination_of`] exists to
2109 /// refuse. `pulldown-cmark` renders that line as the literal text it is, and
2110 /// renders `[t](<a\<b>)` as a link. Raised in review on #806.
2111 #[test]
2112 fn an_unescaped_angle_bracket_is_not_an_angle_destination() {
2113 assert!(scanned("[t](<a<b>)").is_empty());
2114 assert!(scanned("[t](<docs/<x.md>)").is_empty());
2115 // Escaped, it is content and the link stands.
2116 assert_eq!(scanned(r"[t](<a\<b>)")[0].1, r"a\<b");
2117 // The `>` rule is unchanged and still the one that closes it.
2118 assert_eq!(scanned("[t](<a b.md>)")[0].1, "a b.md");
2119 }
2120
2121 /// A destination that names nothing is not a link, whichever form it is
2122 /// written in — and a target is trimmed.
2123 ///
2124 /// This is [`MarkdownLink::target`]'s documented contract, and until #806's
2125 /// third round it was prose: `[t]()`, `[t]( )` and `[[ ]]` all honoured
2126 /// it, and the angle-destination branch returned its interior unchanged, so
2127 /// `[t](< >)` was a link whose target was one space and
2128 /// `[t](< docs/x.md >)` kept its padding. It is now held by
2129 /// `MarkdownLink::new`, which is the only constructor and cannot be
2130 /// bypassed.
2131 ///
2132 /// The five rejections below are the one place this scanner knowingly
2133 /// disagrees with `pulldown-cmark`, which renders each of them as a link to
2134 /// nothing. `renderer_agreement.rs` lists them as expected divergences and
2135 /// fails if a *sixth* appears — or if one of these quietly stops diverging.
2136 #[test]
2137 fn a_destination_that_names_nothing_is_not_a_link() {
2138 for line in [
2139 "[t](< >)",
2140 "[t](< >)",
2141 "[t](<>)",
2142 "[t]( )",
2143 "[t]()",
2144 "[[ ]]",
2145 ] {
2146 assert!(scanned(line).is_empty(), "{line:?} should not be a link");
2147 }
2148 // Trimmed, not rejected, when there is something between the spaces.
2149 assert_eq!(scanned("[t](< docs/x.md >)")[0].1, "docs/x.md");
2150 // Trimming happens *before* the scheme test, so a padded URL is still
2151 // external. Reading the scheme off the untrimmed destination found no
2152 // scheme at offset 0 and called this internal.
2153 assert_eq!(
2154 scanned("[t](< https://e.org/a >)"),
2155 vec![(
2156 LinkKind::Inline,
2157 "https://e.org/a".to_owned(),
2158 "t".to_owned(),
2159 true,
2160 )]
2161 );
2162 }
2163
2164 /// Exactly **one** optional title, and a title may not hold its own closing
2165 /// delimiter unescaped.
2166 ///
2167 /// The test this replaced checked the first and last characters of whatever
2168 /// followed the destination, which accepted five malformed shapes as links —
2169 /// every one of them rendered as literal text by `pulldown-cmark`. Each gave
2170 /// a confident target for a line that names nothing, which is the invention
2171 /// [`destination_of`] exists to refuse. One was raised in review on #806;
2172 /// the other four came out of sweeping the rule against the renderer, which
2173 /// is why `renderer_agreement.rs` now exists.
2174 #[test]
2175 fn a_destination_takes_one_title_and_no_more() {
2176 for line in [
2177 r#"[t](x.md "one" "two")"#,
2178 r#"[t](x.md "a"x"b")"#,
2179 r#"[t](<x.md> "a" "b")"#,
2180 "[t](x.md (a)b(c))",
2181 "[t](x.md (a(b)c))",
2182 r#"[t](x.md 'a' "b")"#,
2183 ] {
2184 assert!(scanned(line).is_empty(), "{line:?} should not be a link");
2185 }
2186 // All three forms, empty and not, are still titles.
2187 for line in [
2188 r#"[t](x.md "title")"#,
2189 r"[t](x.md 'title')",
2190 "[t](x.md (title))",
2191 r#"[t](x.md "")"#,
2192 r"[t](x.md '')",
2193 "[t](x.md ())",
2194 ] {
2195 assert_eq!(scanned(line)[0].1, "x.md", "{line:?} should be a link");
2196 }
2197 // An escaped closing delimiter is content, and the other forms' quotes
2198 // are content too — only the closer of the form in use is special.
2199 assert_eq!(scanned(r#"[t](x.md "a\"b")"#)[0].1, "x.md");
2200 assert_eq!(scanned(r#"[t](x.md 'a"b')"#)[0].1, "x.md");
2201 }
2202
2203 /// A title needs no whitespace after an angle destination, because the
2204 /// renderer needs none.
2205 ///
2206 /// `CommonMark`'s prose requires a separator when both a destination and a
2207 /// title are present, and `[t](<docs/x.md>"title")` therefore reads as
2208 /// malformed against the letter of the spec — raised on that ground in
2209 /// review on #806. It is **deliberately accepted**, because
2210 /// `pulldown-cmark` accepts it (its `scan_separator` may consume nothing)
2211 /// and `pulldown-cmark` is what renders this repository's documents. The
2212 /// site publishes `<a href="docs/x.md">`; rejecting it here would take that
2213 /// live link out of the gate's reach and leave the one scanner disagreeing
2214 /// with the one renderer, which is the defect class #801 exists to remove.
2215 ///
2216 /// The bare form needs no rule — `[t](docs/x.md"title")` has no separator to
2217 /// look for, because the destination runs to the whitespace and swallows the
2218 /// quotes. Both scanners agree there too.
2219 #[test]
2220 fn a_title_may_follow_an_angle_destination_without_a_separator() {
2221 assert_eq!(scanned(r#"[t](<docs/x.md>"title")"#)[0].1, "docs/x.md");
2222 // The acceptance has to survive a `)` inside that title, or it is not
2223 // an acceptance. `destination_end` treated the `"` as content because
2224 // no whitespace preceded it, so the `)` closed the outer link and this
2225 // was rejected — a link `pulldown-cmark` renders. Raised in review on
2226 // #806 as the cost of this divergence; it was the divergence being only
2227 // half-implemented.
2228 let line = r#"[t](<docs/x.md>"a ) b") after"#;
2229 let links = markdown_links(line);
2230 assert_eq!(links[0].target, "docs/x.md");
2231 assert_eq!(&line[links[0].span.clone()], r#"[t](<docs/x.md>"a ) b")"#);
2232 assert_eq!(scanned(r"[t](<docs/x.md>'a ) b')")[0].1, "docs/x.md");
2233 assert_eq!(scanned("[t](<docs/x.md>(a b))")[0].1, "docs/x.md");
2234 // Junk after the destination is still not a title, separator or not.
2235 assert!(scanned("[t](<a>junk)").is_empty());
2236 assert!(scanned(r"[t](<a>x'y)").is_empty());
2237 assert_eq!(scanned(r#"[t](<docs/x.md> "title")"#)[0].1, "docs/x.md");
2238 assert_eq!(
2239 scanned(r#"[t](docs/x.md"title")"#)[0].1,
2240 r#"docs/x.md"title""#
2241 );
2242 }
2243
2244 /// A label is what a **reader** sees, so it keeps the code spans the scan
2245 /// removed.
2246 ///
2247 /// Excluding a code span from the scan and excluding it from the label are
2248 /// different decisions: a link *inside* backticks is an example, but
2249 /// backticks *inside a label* are how this repository writes the name of a
2250 /// type. Taking the text from the stripped string cited "the type".
2251 /// Raised in review on #806.
2252 #[test]
2253 fn a_label_keeps_the_code_spans_the_scan_removed() {
2254 assert_eq!(
2255 scanned("see [the `Foo` type](docs/x.md)"),
2256 vec![(
2257 LinkKind::Inline,
2258 "docs/x.md".to_owned(),
2259 "the `Foo` type".to_owned(),
2260 false,
2261 )]
2262 );
2263 // A label that is *entirely* one code span: the stripped range is empty
2264 // and sits on a chunk boundary, which is the case that inverts if the
2265 // two ends are mapped the same way.
2266 assert_eq!(scanned("[`Foo`](docs/x.md)")[0].2, "`Foo`");
2267 }
2268
2269 /// A title may contain the `)` that would otherwise close the link.
2270 ///
2271 /// `CommonMark` allows it, and counting it ended the span inside the link —
2272 /// which a caller splicing over that span (`doc_anchor_fragments.rs`'s
2273 /// heading reader does) turns into rubble. An apostrophe with no whitespace
2274 /// before it is destination content, not a title nobody closed. Raised in
2275 /// review on #806.
2276 #[test]
2277 fn a_title_may_hold_the_bracket_that_would_close_the_link() {
2278 let line = r#"[t](docs/x.md "a ) b") after"#;
2279 let links = markdown_links(line);
2280 assert_eq!(links[0].target, "docs/x.md");
2281 assert_eq!(&line[links[0].span.clone()], r#"[t](docs/x.md "a ) b")"#);
2282 // Single-quoted and parenthesised titles are the other two forms.
2283 assert_eq!(scanned("[t](docs/x.md 'a ) b')")[0].1, "docs/x.md");
2284 assert_eq!(scanned("[t](docs/x.md (a title))")[0].1, "docs/x.md");
2285 // An apostrophe inside a destination opens nothing.
2286 assert_eq!(scanned("[t](https://e.org/a'b)")[0].1, "https://e.org/a'b");
2287 }
2288
2289 /// `[[a]]` is one wiki-link, not also an inline link whose text is `[a`.
2290 #[test]
2291 fn the_two_kinds_do_not_double_count_one_link() {
2292 assert_eq!(
2293 scanned("[[docs/x.md]]")
2294 .iter()
2295 .map(|(k, _, _, _)| *k)
2296 .collect::<Vec<_>>(),
2297 vec![LinkKind::Wiki]
2298 );
2299 }
2300
2301 /// Links come back in source order however they are written, because a
2302 /// caller rewriting them in place walks the list once.
2303 #[test]
2304 fn links_are_reported_in_source_order_with_usable_ranges() {
2305 let line = "a [t](x.md) b [[y.md]] c [u](https://e.org)";
2306 let links = markdown_links(line);
2307 assert_eq!(
2308 links.iter().map(|l| l.target.as_str()).collect::<Vec<_>>(),
2309 vec!["x.md", "y.md", "https://e.org"]
2310 );
2311 // Every range addresses the link it was reported for, so a rewriter can
2312 // splice over it without re-finding anything.
2313 assert_eq!(&line[links[0].span.clone()], "[t](x.md)");
2314 assert_eq!(&line[links[1].span.clone()], "[[y.md]]");
2315 assert_eq!(&line[links[2].span.clone()], "[u](https://e.org)");
2316 assert!(links[2].scope.is_external());
2317 }
2318}