Skip to main content

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` `![alt](source)` 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 `![alt](src)` 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 = "![a diagram](docs/x.png)";
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"\![t](docs/x.md)")
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("![img](a.png) 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`![a](b)";
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("\\![a](b)")[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}