Skip to main content

agent_first_data/document/format/
markdown.rs

1//! Markdown mode: read a CommonMark file as a tree of heading sections.
2//! Read-only — Markdown prose is never written back.
3//!
4//! A heading owns everything under it until the next heading of its own level
5//! or shallower, so the document becomes a tree. Each section reports its
6//! heading `text` and `level`, its own `blocks` in source order, the
7//! `paragraph` subset of those, and its child sections under `h2`/`h3`/…
8//! Anything before the first heading is `preamble`, which carries the same
9//! `blocks`/`paragraph` pair.
10//!
11//! ```text
12//! preamble.blocks.0   → { type: "frontmatter", format: "toml", text: "" }
13//! preamble.paragraph.0 → { type: "paragraph", text: "CI" }       (a badge line)
14//! h1.0.text           → "Real"
15//! h1.0.paragraph.0    → { type: "paragraph", text: "The lead." }
16//! h1.0.blocks.1       → { type: "code", language: "bash", text: "…" }
17//! h1.0.h2.0.text      → "A quick look"
18//! ```
19//!
20//! Segments are matched by content as well as by index (see
21//! [`Format::array_rule`](crate::document::Format::array_rule)): `h2.look`
22//! finds the section headed "A quick look", and matching several sections is an
23//! error rather than a guess. That is what makes an address survive editing —
24//! `h2.3` moves the moment a section is inserted above it, and a top-level
25//! index moves the moment a badge line appears above the title.
26//!
27//! Sections are the reason the tree is not flat. A heading level is the one
28//! piece of hierarchy CommonMark states outright, and folding on it costs
29//! nothing while giving every address a stable frame: content is addressed
30//! relative to the heading it lives under, not to the top of the file.
31//!
32//! What this backend owns is the *specification* layer — block identification,
33//! block boundaries, and the heading nesting that follows from levels — and
34//! nothing above it. Where a document's title lives, whether the first
35//! paragraph is a synopsis, which blockquote carries a prompt: those are one
36//! project's layout conventions, and they stay with the caller that holds them.
37//! The distinction matters because block boundaries are precisely the part a
38//! hand-rolled line scanner gets wrong: setext headings, the seven HTML-block
39//! start conditions, four-space indented code, and the rule that an ATX heading
40//! interrupts a paragraph are each a rule a `^# ` regex does not have.
41//!
42//! Detection is never automatic: a `.md` path resolves to no format on its own
43//! and the caller must ask for `--input-format markdown`. The same file is
44//! legitimately readable as `yaml-frontmatter`, and a reader that guesses
45//! between two valid readings of one file is the shape-guessing AFDATA avoids.
46//!
47//! Deliberately out of scope, so that what this does return is exact:
48//!
49//! - **No dialects.** No GFM tables, footnotes, task lists, or strikethrough.
50//!   A table's rows parse as a paragraph, which is the specification's answer,
51//!   not a defect. (A leading `+++`/`---` metadata block *is* recognised, as
52//!   its own block kind — see `options` for why that is not a dialect.)
53//! - **No parsing or copying of frontmatter fields.** It is reported as one
54//!   block with `format: "toml"|"yaml"` and empty `text`;
55//!   `--input-format toml-frontmatter` is the reading that turns it into
56//!   values. Omitting the raw metadata also prevents this structural reading
57//!   from becoming a way around field-name-based secret redaction.
58//! - **`text` is flattened plain text.** Emphasis is unwrapped, a code span
59//!   keeps only its content, a link keeps its text, an image keeps its alt
60//!   text, and inline HTML tags are dropped. What was flattened away is kept
61//!   beside it: a paragraph, a heading, and a section report `inlines`, the
62//!   ordered inline tree (`text`, `code`, `html`, `line_break`, and `emphasis`,
63//!   `strong`, `link` with their own `inlines`; `link` and `image` carry
64//!   `destination_url`). A blockquote, a list, and a list item report their
65//!   child blocks under `blocks`, so a code fence inside a list item is still a
66//!   `code` block. (Heading sections nest by level, not by walking a block.)
67//! - **No byte offsets or columns.** Every block does carry 1-based inclusive
68//!   `source_start_line` / `source_end_line`. Every section carries its whole
69//!   source range plus `heading_end_line` for the
70//!   heading alone. These are enough to splice whole Markdown blocks without
71//!   making every consumer implement UTF-8 byte indexing.
72//! - **No Markdown-to-Markdown transformation.** Rewriting a document in place
73//!   needs source-preserving serialization, which is a separate design, not a
74//!   rider on a reader.
75
76use std::{collections::BTreeMap, ops::Range};
77
78use pulldown_cmark::{CodeBlockKind, Event, MetadataBlockKind, Options, Parser, Tag};
79
80use crate::document::{DocumentError, DocumentResult, Value};
81
82/// Parse `content` as CommonMark into the section tree described above.
83///
84/// CommonMark has no invalid-input production — every UTF-8 string is some
85/// sequence of blocks — so this fails only where afdata declines to answer: a
86/// bare carriage return, or block nesting more than 48 levels deep. An
87/// empty string yields an empty `preamble` and no sections.
88pub fn load(content: &str) -> DocumentResult<Value> {
89    reject_lone_carriage_return(content)?;
90    let events: Vec<SpannedEvent<'_>> = Parser::new_ext(content, options())
91        .into_offset_iter()
92        .collect();
93    reject_deep_nesting(&events)?;
94    let lines = LineIndex::new(content);
95    let mut cursor = 0;
96    Ok(fold_sections(read_blocks(&events, &lines, &mut cursor)))
97}
98
99type SpannedEvent<'a> = (Event<'a>, Range<usize>);
100
101/// Byte offsets of logical line starts, used only inside the parser boundary.
102///
103/// Public consumers see line numbers, not byte offsets. LF and CRLF input use
104/// the same `\n`-based numbering as common line-oriented tools; a lone CR is
105/// rejected before this index is constructed.
106struct LineIndex<'a> {
107    content: &'a str,
108    starts: Vec<usize>,
109}
110
111impl<'a> LineIndex<'a> {
112    /// Index the line starts of `content`, counting a line as everything up to
113    /// the next `\n`.
114    ///
115    /// Not CommonMark's rule, deliberately. CommonMark also treats a bare `\r`
116    /// as a line ending, and these numbers exist to be handed to something
117    /// else — `sed -n '5,10p'`, `head -n`, an editor jump, a `git diff` — all
118    /// of which split on `\n` alone. A number that only afdata can interpret
119    /// is worse than no number: it looks usable and silently points one line
120    /// off. CRLF agrees with both rules, so the two definitions differ only
121    /// for a bare `\r`, which [`reject_lone_carriage_return`] refuses outright
122    /// rather than reporting a line number nothing else can act on.
123    fn new(content: &'a str) -> Self {
124        let mut starts = vec![0];
125        for (offset, byte) in content.bytes().enumerate() {
126            if byte == b'\n' {
127                starts.push(offset + 1);
128            }
129        }
130        Self { content, starts }
131    }
132
133    fn number_at(&self, offset: usize) -> i64 {
134        let line = self.starts.partition_point(|start| *start <= offset);
135        i64::try_from(line).unwrap_or(i64::MAX)
136    }
137
138    /// The 1-based inclusive line range a source span covers.
139    ///
140    /// The span's trailing whitespace is dropped first. Most block kinds end
141    /// at their own last character, but pulldown-cmark runs a list's span on
142    /// to where the next block begins, so it carries the blank lines that
143    /// separate them. A range is a splice instruction — a consumer cutting one
144    /// out must not take the separator with it, or the blocks on either side
145    /// merge (a lead paragraph absorbed into the setext heading below it).
146    fn range(&self, source: &Range<usize>) -> SourceLines {
147        let content_end = self
148            .content
149            .get(..source.end)
150            .map_or(source.end, |head| head.trim_end().len())
151            .max(source.start);
152        let end_offset = content_end.saturating_sub(1).max(source.start);
153        SourceLines {
154            start: self.number_at(source.start),
155            end: self.number_at(end_offset),
156        }
157    }
158}
159
160#[derive(Clone, Copy)]
161struct SourceLines {
162    start: i64,
163    end: i64,
164}
165
166/// Refuse a document containing a bare `\r` — a carriage return not followed
167/// by a newline.
168///
169/// This is the single case where CommonMark's line rule and every other tool's
170/// disagree: CommonMark ends a line there, `sed`/`awk`/`wc`/`head`/`git` do
171/// not. A reported line number is only useful if the thing the caller hands it
172/// to counts the same way, and for such a file no single number can satisfy
173/// both — two blocks would share one line, and a splice by that number would
174/// cut the wrong text with nothing to notice it by.
175///
176/// So the file is refused instead of answered wrongly. It is the classic Mac
177/// (pre-2002) line ending; CRLF and LF, which every current tool produces, are
178/// unaffected because both rules agree on them.
179fn reject_lone_carriage_return(content: &str) -> DocumentResult<()> {
180    let bytes = content.as_bytes();
181    for (offset, byte) in bytes.iter().enumerate() {
182        if *byte == b'\r' && bytes.get(offset + 1) != Some(&b'\n') {
183            return Err(DocumentError::SourceRefused {
184                format: "Markdown".to_string(),
185                detail: format!(
186                    "a bare carriage return at byte {offset} ends a line for CommonMark but not \
187                     for the tools these line numbers are meant to feed; convert the file to LF \
188                     or CRLF line endings"
189                ),
190            });
191        }
192    }
193    Ok(())
194}
195
196/// How deep blocks and inline spans may nest. The walk recurses once per
197/// level, and the reported tree is deeper still: each level becomes an object
198/// plus the array of its children, and the root and six heading levels add
199/// about fifteen more. 48 keeps the deepest accepted file's tree near 111
200/// levels, so it fits inside the 128 a JSON reader allows — serde_json's
201/// limit, which `afdata get` applies to its own output read back — with room
202/// for an envelope around it.
203const MAX_BLOCK_DEPTH: usize = 48;
204
205/// Refuse nesting the recursive walk cannot hold before it starts, so a file
206/// of `> > > …` is an error rather than a stack overflow. Inline spans count
207/// too: the reported inline tree nests exactly as deep as they do.
208fn reject_deep_nesting(events: &[SpannedEvent<'_>]) -> DocumentResult<()> {
209    let mut depth = 0usize;
210    for (event, _) in events {
211        match event {
212            Event::Start(_) => {
213                depth += 1;
214                if depth > MAX_BLOCK_DEPTH {
215                    return Err(DocumentError::SourceRefused {
216                        format: "Markdown".to_string(),
217                        detail: format!(
218                            "blocks and inline spans nest deeper than {MAX_BLOCK_DEPTH} levels"
219                        ),
220                    });
221                }
222            }
223            Event::End(_) => depth = depth.saturating_sub(1),
224            _ => {}
225        }
226    }
227    Ok(())
228}
229
230/// Pure CommonMark, plus the two metadata-block rules and nothing else.
231///
232/// The extensions that stay off are *dialects* — GFM tables, footnotes, task
233/// lists, strikethrough each re-read text CommonMark already assigns a meaning
234/// to, so enabling one is a guess about which flavour a file was written in.
235///
236/// A leading `+++`/`---` block is not that. afdata already treats frontmatter
237/// as a first-class thing to read — `Format::TomlFrontmatter` and
238/// `Format::YamlFrontmatter` exist — so a Markdown reader that took the same
239/// bytes for prose would contradict the crate's own model, and did: a Zola
240/// `_index.md` arrived with its `title = "…"` and `[extra]` lines flattened
241/// into two paragraphs of the body. The block is only recognised at the very
242/// start of the file, so it cannot reinterpret a `---` anywhere else (a setext
243/// underline or a thematic break stays what it is).
244fn options() -> Options {
245    Options::ENABLE_YAML_STYLE_METADATA_BLOCKS | Options::ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS
246}
247
248/// One heading and everything it owns, while the tree is still being built.
249struct Section {
250    level: i64,
251    text: String,
252    inlines: Value,
253    heading_lines: SourceLines,
254    blocks: Vec<Value>,
255    children: Vec<Section>,
256}
257
258/// Fold a flat block sequence into the section tree.
259///
260/// A heading closes every open section at its own level or deeper, then opens
261/// its own — the standard reading of heading levels, and the only hierarchy
262/// CommonMark itself states. A level may be skipped (an `h3` directly under an
263/// `h1`); the `h3` simply becomes a child of whatever section is open, since
264/// there is no `h2` to hold it.
265fn fold_sections(blocks: Vec<Value>) -> Value {
266    let mut preamble = Vec::new();
267    let mut roots: Vec<Section> = Vec::new();
268    let mut open: Vec<Section> = Vec::new();
269
270    for block in blocks {
271        let heading_level = (block.get("type").and_then(Value::as_str) == Some("heading"))
272            .then(|| block.get("level").and_then(Value::as_integer))
273            .flatten();
274        match heading_level {
275            Some(level) => {
276                while open.last().is_some_and(|section| section.level >= level) {
277                    close_section(&mut open, &mut roots);
278                }
279                open.push(Section {
280                    level,
281                    text: block
282                        .get("text")
283                        .and_then(Value::as_str)
284                        .unwrap_or_default()
285                        .to_string(),
286                    inlines: block
287                        .get("inlines")
288                        .cloned()
289                        .unwrap_or_else(|| Value::Array(Vec::new())),
290                    heading_lines: SourceLines {
291                        start: block
292                            .get("source_start_line")
293                            .and_then(Value::as_integer)
294                            .unwrap_or_default(),
295                        end: block
296                            .get("source_end_line")
297                            .and_then(Value::as_integer)
298                            .unwrap_or_default(),
299                    },
300                    blocks: Vec::new(),
301                    children: Vec::new(),
302                });
303            }
304            // Content before the first heading belongs to no section. It is
305            // kept rather than dropped: "this file opens with a generated-file
306            // comment" is a fact, and whether that is acceptable is the
307            // caller's rule to apply, not this reader's to enforce by omission.
308            None => match open.last_mut() {
309                Some(section) => section.blocks.push(block),
310                None => preamble.push(block),
311            },
312        }
313    }
314    while !open.is_empty() {
315        close_section(&mut open, &mut roots);
316    }
317
318    // `preamble` carries the same two views a section does, so "the first
319    // paragraph" is one address in both. As a bare array it was not: a `+++`
320    // block or a badge line above the prose shifted every index, which is the
321    // problem sections exist to remove.
322    let mut root = BTreeMap::from([("preamble".to_string(), Value::Object(block_views(preamble)))]);
323    insert_sections(&mut root, roots);
324    Value::Object(root)
325}
326
327/// Pop the deepest open section and file it under its parent, or under the
328/// document root when it has none.
329fn close_section(open: &mut Vec<Section>, roots: &mut Vec<Section>) {
330    let Some(done) = open.pop() else { return };
331    match open.last_mut() {
332        Some(parent) => parent.children.push(done),
333        None => roots.push(done),
334    }
335}
336
337/// File `sections` into `target` under `h1`/`h2`/… by their own level, each
338/// group in source order.
339fn insert_sections(target: &mut BTreeMap<String, Value>, sections: Vec<Section>) {
340    for section in sections {
341        let key = format!("h{}", section.level);
342        let group = target
343            .entry(key)
344            .or_insert_with(|| Value::Array(Vec::new()));
345        if let Some(items) = group.as_array_mut() {
346            items.push(section.into_value());
347        }
348    }
349}
350
351/// The two views every block container reports: `blocks` in source order, and
352/// the `paragraph` subset of them.
353///
354/// The subset is duplicated out of `blocks` on purpose. Prose is what a
355/// container is usually read for, and "the first paragraph" must not shift
356/// because a frontmatter block, a code fence, or a thematic break happened to
357/// land above it. Badge syntax and a GFM-looking pipe table are paragraphs
358/// under the deliberately enabled CommonMark grammar, so they remain in this
359/// view. `blocks` stays the one place that shows what the container actually
360/// looks like, in order.
361fn block_views(blocks: Vec<Value>) -> BTreeMap<String, Value> {
362    let of_kind = |kind: &str| {
363        Value::Array(
364            blocks
365                .iter()
366                .filter(|block| block.get("type").and_then(Value::as_str) == Some(kind))
367                .cloned()
368                .collect::<Vec<Value>>(),
369        )
370    };
371    let paragraph = of_kind("paragraph");
372    let blockquote = of_kind("blockquote");
373    BTreeMap::from([
374        ("paragraph".to_string(), paragraph),
375        ("blockquote".to_string(), blockquote),
376        ("blocks".to_string(), Value::Array(blocks)),
377    ])
378}
379
380impl Section {
381    fn into_value(self) -> Value {
382        let source_end_line = self.source_end_line();
383        let mut fields = block_views(self.blocks);
384        fields.insert("level".to_string(), Value::Integer(self.level));
385        fields.insert("text".to_string(), Value::String(self.text));
386        fields.insert("inlines".to_string(), self.inlines);
387        fields.insert(
388            "source_start_line".to_string(),
389            Value::Integer(self.heading_lines.start),
390        );
391        fields.insert(
392            "source_end_line".to_string(),
393            Value::Integer(source_end_line),
394        );
395        fields.insert(
396            "heading_end_line".to_string(),
397            Value::Integer(self.heading_lines.end),
398        );
399        insert_sections(&mut fields, self.children);
400        Value::Object(fields)
401    }
402
403    fn source_end_line(&self) -> i64 {
404        self.blocks
405            .iter()
406            .filter_map(|block| block.get("source_end_line"))
407            .filter_map(Value::as_integer)
408            .chain(self.children.iter().map(Section::source_end_line))
409            .max()
410            .unwrap_or(self.heading_lines.end)
411    }
412}
413
414/// Read sibling blocks until the enclosing container's `End` event, or the end
415/// of the stream at top level. Leaves `cursor` *on* that `End`, for the caller
416/// that opened the container to consume.
417fn read_blocks(
418    events: &[SpannedEvent<'_>],
419    lines: &LineIndex<'_>,
420    cursor: &mut usize,
421) -> Vec<Value> {
422    let mut blocks = Vec::new();
423    while let Some((event, source)) = events.get(*cursor) {
424        match event {
425            Event::End(_) => break,
426            Event::Rule => {
427                let source = source.clone();
428                *cursor += 1;
429                blocks.push(block("rule", String::new(), &source, lines, vec![]));
430            }
431            Event::Start(tag) if !is_inline_tag(tag) => {
432                let source = source.clone();
433                *cursor += 1;
434                blocks.extend(read_block(tag, &source, events, lines, cursor));
435            }
436            // Inline content standing where a block belongs: a tight list's
437            // item holds its text directly, with no paragraph around it.
438            // Reading it as an implicit paragraph is what makes a tight and a
439            // loose list report the same items.
440            _ => {
441                let first = *cursor;
442                let (text, inlines) = read_loose_inline(events, cursor);
443                if !text.is_empty() {
444                    let source = covered_range(events, first, *cursor);
445                    blocks.push(block(
446                        "paragraph",
447                        text,
448                        &source,
449                        lines,
450                        vec![("inlines", inlines)],
451                    ));
452                }
453            }
454        }
455    }
456    blocks
457}
458
459/// Whether `tag` wraps text inside a block rather than opening one.
460fn is_inline_tag(tag: &Tag<'_>) -> bool {
461    matches!(
462        tag,
463        Tag::Emphasis
464            | Tag::Strong
465            | Tag::Strikethrough
466            | Tag::Superscript
467            | Tag::Subscript
468            | Tag::Link { .. }
469            | Tag::Image { .. }
470    )
471}
472
473/// Read the one block opened by `tag`, consuming through its own `End`.
474fn read_block(
475    tag: &Tag<'_>,
476    source: &Range<usize>,
477    events: &[SpannedEvent<'_>],
478    lines: &LineIndex<'_>,
479    cursor: &mut usize,
480) -> Option<Value> {
481    match tag {
482        Tag::Paragraph => {
483            let (text, inlines) = read_inline(events, cursor);
484            Some(block(
485                "paragraph",
486                text,
487                source,
488                lines,
489                vec![("inlines", inlines)],
490            ))
491        }
492        Tag::Heading { level, .. } => {
493            let (text, inlines) = read_inline(events, cursor);
494            Some(block(
495                "heading",
496                text,
497                source,
498                lines,
499                vec![
500                    ("level", Value::Integer(*level as i64)),
501                    ("inlines", inlines),
502                ],
503            ))
504        }
505        Tag::CodeBlock(kind) => {
506            // CommonMark's info string is everything after the opening fence;
507            // in practice it is the language. An indented block has none.
508            let info = match kind {
509                CodeBlockKind::Fenced(info) => info.trim().to_string(),
510                CodeBlockKind::Indented => String::new(),
511            };
512            Some(block(
513                "code",
514                read_verbatim(events, cursor),
515                source,
516                lines,
517                // `language`, not CommonMark's `info`: a field must say what
518                // it holds without its neighbours (spec/agent-first-data.md
519                // rule 5, "Self-contained"). Sharing one name with the
520                // frontmatter block below made `info: "toml"` mean either a
521                // fence written in TOML or a `+++` delimiter, tellable apart
522                // only by also reading `type`.
523                vec![("language", Value::String(info))],
524            ))
525        }
526        Tag::HtmlBlock => Some(block(
527            "html",
528            read_verbatim(events, cursor),
529            source,
530            lines,
531            vec![],
532        )),
533        // The leading `+++`/`---` block, reported as its own kind without
534        // copying its potentially secret-bearing source into `text`: which
535        // fields it holds is TOML's or YAML's answer, and
536        // `--input-format toml-frontmatter` is the reading that gives it.
537        // `format` names the delimiter's dialect, matching the
538        // `--input-format` token that reads its fields.
539        Tag::MetadataBlock(kind) => {
540            // Advance without collecting the metadata into a second String.
541            // The source parser already borrows it, and this view deliberately
542            // exposes neither a copy nor a flattened form.
543            skip_subtree(events, cursor);
544            Some(block(
545                "frontmatter",
546                String::new(),
547                source,
548                lines,
549                vec![(
550                    "format",
551                    Value::String(
552                        match kind {
553                            MetadataBlockKind::PlusesStyle => "toml",
554                            MetadataBlockKind::YamlStyle => "yaml",
555                        }
556                        .to_string(),
557                    ),
558                )],
559            ))
560        }
561        Tag::BlockQuote(_) => {
562            let (text, children) = read_container(events, lines, cursor);
563            Some(block(
564                "blockquote",
565                text,
566                source,
567                lines,
568                vec![("blocks", children)],
569            ))
570        }
571        Tag::List(first_number) => {
572            let (text, children) = read_container(events, lines, cursor);
573            Some(block(
574                "list",
575                text,
576                source,
577                lines,
578                vec![
579                    ("ordered", Value::Bool(first_number.is_some())),
580                    ("blocks", children),
581                ],
582            ))
583        }
584        // An item exists only as a list's child: it is reached through the
585        // list's `blocks`, one `item` per list item, each with its own blocks.
586        Tag::Item => {
587            let (text, children) = read_container(events, lines, cursor);
588            Some(block(
589                "item",
590                text,
591                source,
592                lines,
593                vec![("blocks", children)],
594            ))
595        }
596        // Tables, footnote definitions, definition lists, and metadata blocks
597        // require options this backend does not enable, so none of them can
598        // occur. Skipping the subtree keeps that a fact rather than a
599        // half-formed block if the option set ever widens.
600        _ => {
601            skip_subtree(events, cursor);
602            None
603        }
604    }
605}
606
607/// Flattened text of a container block — its child blocks' `text` joined by a
608/// newline — and the child blocks themselves, consuming through the
609/// container's own `End`.
610///
611/// Block boundaries survive as newlines because they are structure; a wrapped
612/// line inside one paragraph does not, because it is presentation (see
613/// [`read_inline`]). Children that flatten to nothing, such as a thematic
614/// break, contribute nothing rather than a blank line.
615fn read_container(
616    events: &[SpannedEvent<'_>],
617    lines: &LineIndex<'_>,
618    cursor: &mut usize,
619) -> (String, Value) {
620    let children = read_blocks(events, lines, cursor);
621    // `read_blocks` stops on the container's `End` rather than past it.
622    *cursor += 1;
623    let text = children
624        .iter()
625        .filter_map(|child| child.get("text").and_then(Value::as_str))
626        .filter(|text| !text.is_empty())
627        .collect::<Vec<_>>()
628        .join("\n");
629    (text, Value::Array(children))
630}
631
632/// Flattened plain text of a leaf block's inline content, and the inline tree
633/// it was flattened from, consuming through that block's own `End`.
634///
635/// In `text`, emphasis and strong are unwrapped, a code span keeps its
636/// content, a link keeps its text and drops its URL, an image keeps its alt
637/// text, raw inline HTML tags are dropped, and every line break inside the
638/// block — soft or hard — becomes a single space. The result is one line of
639/// plain prose, which is what a name, a synopsis, or a prompt is. The tree
640/// keeps what that dropped; see [`InlineReader`].
641fn read_inline(events: &[SpannedEvent<'_>], cursor: &mut usize) -> (String, Value) {
642    let read = read_loose_inline(events, cursor);
643    if matches!(events.get(*cursor), Some((Event::End(_), _))) {
644        *cursor += 1;
645    }
646    read
647}
648
649/// [`read_inline`] without an owning block: stops *before* the next block
650/// boundary instead of consuming a closing `End`. This is the form a tight
651/// list item needs, whose text has no paragraph of its own to end.
652fn read_loose_inline(events: &[SpannedEvent<'_>], cursor: &mut usize) -> (String, Value) {
653    let mut reader = InlineReader::default();
654    let mut depth = 0usize;
655    while let Some((event, _)) = events.get(*cursor) {
656        match event {
657            Event::Start(tag) if depth == 0 && !is_inline_tag(tag) => break,
658            Event::End(_) if depth == 0 => break,
659            Event::Rule if depth == 0 => break,
660            Event::Start(tag) => {
661                depth += 1;
662                reader.open(tag);
663            }
664            Event::End(_) => {
665                depth -= 1;
666                reader.close();
667            }
668            Event::Text(chunk) => reader.text(chunk),
669            Event::Code(chunk) => reader.leaf("code", chunk, chunk),
670            Event::InlineHtml(chunk) | Event::Html(chunk) => reader.leaf("html", chunk, ""),
671            Event::SoftBreak => reader.text(" "),
672            Event::HardBreak => reader.leaf("line_break", "", " "),
673            _ => {}
674        }
675        *cursor += 1;
676    }
677    reader.finish()
678}
679
680/// One inline node while its parent is still open.
681struct Inline {
682    kind: &'static str,
683    /// Flattened text, under the same rule as the block's `text`.
684    text: String,
685    destination_url: Option<String>,
686    /// `None` for a leaf, and for an image: alt text is plain text in the
687    /// rendered output, so its markup is not reported as structure.
688    children: Option<Vec<Inline>>,
689}
690
691impl Inline {
692    fn into_value(self) -> Value {
693        // A span's flattened text is trimmed like a block's; a leaf is not, or
694        // the space between `see ` and a following link would be lost.
695        let text = if self.children.is_some() || self.destination_url.is_some() {
696            self.text.trim().to_string()
697        } else {
698            self.text
699        };
700        let mut fields = BTreeMap::from([
701            ("type".to_string(), Value::String(self.kind.to_string())),
702            ("text".to_string(), Value::String(text)),
703        ]);
704        if let Some(url) = self.destination_url {
705            fields.insert("destination_url".to_string(), Value::String(url));
706        }
707        if let Some(children) = self.children {
708            fields.insert("inlines".to_string(), inline_values(children));
709        }
710        Value::Object(fields)
711    }
712}
713
714fn inline_values(inlines: Vec<Inline>) -> Value {
715    Value::Array(inlines.into_iter().map(Inline::into_value).collect())
716}
717
718/// Builds a block's flattened `text` and its inline tree in one pass.
719///
720/// Every open span accumulates its own flattened text as content arrives, so
721/// a link reports the text a reader sees without the caller walking its
722/// children. Adjacent text — including a soft break, which is presentation —
723/// merges into one `text` node. Spans that need a dialect this reader does not
724/// enable (strikethrough, sub- and superscript) cannot occur; if one did, its
725/// content would join its parent rather than invent a node kind.
726#[derive(Default)]
727struct InlineReader {
728    text: String,
729    roots: Vec<Inline>,
730    open: Vec<Inline>,
731    /// Per opened tag, whether it opened a node in `open`.
732    opened: Vec<bool>,
733}
734
735impl InlineReader {
736    /// The list new content joins. Never an image's: content inside one is
737    /// only flattened (see `in_image`), so an image is never open here.
738    fn siblings(&mut self) -> &mut Vec<Inline> {
739        match self.open.last_mut() {
740            Some(span) => span.children.get_or_insert_with(Vec::new),
741            None => &mut self.roots,
742        }
743    }
744
745    fn flatten(&mut self, chunk: &str) {
746        self.text.push_str(chunk);
747        for span in &mut self.open {
748            span.text.push_str(chunk);
749        }
750    }
751
752    fn in_image(&self) -> bool {
753        self.open.iter().any(|span| span.kind == "image")
754    }
755
756    fn text(&mut self, chunk: &str) {
757        self.flatten(chunk);
758        if self.in_image() {
759            return;
760        }
761        let siblings = self.siblings();
762        match siblings.last_mut() {
763            Some(last) if last.kind == "text" => last.text.push_str(chunk),
764            _ => siblings.push(Inline {
765                kind: "text",
766                text: chunk.to_string(),
767                destination_url: None,
768                children: None,
769            }),
770        }
771    }
772
773    fn leaf(&mut self, kind: &'static str, node_text: &str, flat: &str) {
774        self.flatten(flat);
775        if self.in_image() {
776            return;
777        }
778        self.siblings().push(Inline {
779            kind,
780            text: node_text.to_string(),
781            destination_url: None,
782            children: None,
783        });
784    }
785
786    fn open(&mut self, tag: &Tag<'_>) {
787        let span = match tag {
788            Tag::Emphasis => Some(("emphasis", None, true)),
789            Tag::Strong => Some(("strong", None, true)),
790            Tag::Link { dest_url, .. } => Some(("link", Some(dest_url.to_string()), true)),
791            Tag::Image { dest_url, .. } => Some(("image", Some(dest_url.to_string()), false)),
792            _ => None,
793        };
794        // Inside an image everything is alt text: no nested nodes.
795        let span = span.filter(|_| !self.in_image());
796        self.opened.push(span.is_some());
797        if let Some((kind, destination_url, structured)) = span {
798            self.open.push(Inline {
799                kind,
800                text: String::new(),
801                destination_url,
802                children: structured.then(Vec::new),
803            });
804        }
805    }
806
807    fn close(&mut self) {
808        if self.opened.pop() == Some(true)
809            && let Some(done) = self.open.pop()
810        {
811            self.siblings().push(done);
812        }
813    }
814
815    fn finish(mut self) -> (String, Value) {
816        while !self.open.is_empty() {
817            self.opened.push(true);
818            self.close();
819        }
820        (self.text.trim().to_string(), inline_values(self.roots))
821    }
822}
823
824/// Literal content of a code or HTML block, consuming through its `End`.
825///
826/// Nothing is flattened here — the content is not prose. The single trailing
827/// newline every such block carries (the line break before its closing fence,
828/// or ending its last line) is dropped; everything else is verbatim.
829fn read_verbatim(events: &[SpannedEvent<'_>], cursor: &mut usize) -> String {
830    let mut text = String::new();
831    while let Some((event, _)) = events.get(*cursor) {
832        *cursor += 1;
833        match event {
834            Event::End(_) => break,
835            Event::Text(chunk) | Event::Html(chunk) => text.push_str(chunk),
836            _ => {}
837        }
838    }
839    let unterminated = text.strip_suffix('\n').unwrap_or(text.as_str());
840    unterminated
841        .strip_suffix('\r')
842        .unwrap_or(unterminated)
843        .to_string()
844}
845
846/// Consume an unhandled container's whole subtree, including its own `End`.
847fn skip_subtree(events: &[SpannedEvent<'_>], cursor: &mut usize) {
848    let mut depth = 0usize;
849    while let Some((event, _)) = events.get(*cursor) {
850        *cursor += 1;
851        match event {
852            Event::Start(_) => depth += 1,
853            Event::End(_) => {
854                if depth == 0 {
855                    break;
856                }
857                depth -= 1;
858            }
859            _ => {}
860        }
861    }
862}
863
864/// The smallest source range covering events in `start..end`.
865///
866/// Top-level CommonMark blocks carry their full range on the opening event.
867/// This fallback exists for inline text in tight containers, where there is no
868/// paragraph `Start` event to lend us one.
869fn covered_range(events: &[SpannedEvent<'_>], start: usize, end: usize) -> Range<usize> {
870    let mut covered = events
871        .get(start)
872        .map(|(_, source)| source.clone())
873        .unwrap_or(0..0);
874    for (_, source) in events.get(start..end).unwrap_or_default() {
875        covered.start = covered.start.min(source.start);
876        covered.end = covered.end.max(source.end);
877    }
878    covered
879}
880
881/// Assemble one block: the `type`, flattened `text`, and 1-based inclusive
882/// source-line range every block carries, plus kind-specific fields.
883fn block(
884    kind: &str,
885    text: String,
886    source: &Range<usize>,
887    lines: &LineIndex<'_>,
888    extra: Vec<(&str, Value)>,
889) -> Value {
890    let source = lines.range(source);
891    let mut fields = BTreeMap::from([
892        ("type".to_string(), Value::String(kind.to_string())),
893        ("text".to_string(), Value::String(text)),
894        (
895            "source_start_line".to_string(),
896            Value::Integer(source.start),
897        ),
898        ("source_end_line".to_string(), Value::Integer(source.end)),
899    ]);
900    for (name, value) in extra {
901        fields.insert(name.to_string(), value);
902    }
903    Value::Object(fields)
904}
905
906#[cfg(test)]
907mod tests {
908    #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
909    use super::*;
910    use crate::document::{Addressing, Format, get_path};
911
912    /// Read one address exactly as a caller would, through the same traversal
913    /// and the same format rule the CLI uses.
914    fn at(source: &str, path: &str) -> DocumentResult<Value> {
915        get_path(
916            &load(source).unwrap(),
917            path,
918            Addressing::INDEX_ONLY.with_array_rule(Format::Markdown.array_rule()),
919        )
920    }
921
922    fn text(source: &str, path: &str) -> String {
923        at(source, path)
924            .unwrap_or_else(|error| panic!("{path}: {error}"))
925            .as_str()
926            .unwrap_or_else(|| panic!("{path} is not a string"))
927            .to_string()
928    }
929
930    fn integer(source: &str, path: &str) -> i64 {
931        at(source, path)
932            .unwrap_or_else(|error| panic!("{path}: {error}"))
933            .as_integer()
934            .unwrap_or_else(|| panic!("{path} is not an integer"))
935    }
936
937    /// `(type, text)` of a block array, which is what a caller reads.
938    fn shape(source: &str, path: &str) -> Vec<(String, String)> {
939        at(source, path)
940            .unwrap_or_else(|error| panic!("{path}: {error}"))
941            .as_array()
942            .unwrap_or_else(|| panic!("{path} is not an array"))
943            .iter()
944            .map(|block| {
945                let field = |name: &str| {
946                    block
947                        .get(name)
948                        .and_then(Value::as_str)
949                        .unwrap_or_default()
950                        .to_string()
951                };
952                (field("type"), field("text"))
953            })
954            .collect()
955    }
956
957    fn types(source: &str, path: &str) -> Vec<String> {
958        shape(source, path).into_iter().map(|(k, _)| k).collect()
959    }
960
961    // ---- the eight probes -------------------------------------------------
962    //
963    // These are the cases a hand-rolled line scanner answered wrong — all eight
964    // of them. Each names the CommonMark rule it turns on, and each is the
965    // classification a caller's layout policy then reads.
966
967    #[test]
968    fn probe_1_setext_heading_is_a_heading() {
969        // An underlined title is a heading; `^# ` never sees it.
970        let source = "Title\n=====\n\nThe lead.\n";
971        assert_eq!(text(source, "h1.0.text"), "Title");
972        assert_eq!(text(source, "h1.0.paragraph.0.text"), "The lead.");
973    }
974
975    #[test]
976    fn probe_2_html_comment_before_the_heading_is_its_own_block() {
977        // A generated-file banner is an HTML block. It lands in `preamble`,
978        // where a caller can see it, and does not shift the title.
979        let source = "<!-- generated -->\n\n# Real\n\nThe lead.\n";
980        assert_eq!(
981            shape(source, "preamble.blocks"),
982            [("html".to_string(), "<!-- generated -->".to_string())]
983        );
984        assert_eq!(text(source, "h1.0.text"), "Real");
985    }
986
987    #[test]
988    fn probe_3_badge_line_before_the_heading_is_a_paragraph() {
989        // The most common README opening there is. A flat reading would make
990        // this block 0 and publish `CI` as the project's name; here it is
991        // preamble and the title is still `h1.0`.
992        let source = "[![CI](a.svg)](b)\n\n# Real\n\nThe lead.\n";
993        assert_eq!(
994            shape(source, "preamble.blocks"),
995            [("paragraph".to_string(), "CI".to_string())]
996        );
997        assert_eq!(text(source, "h1.0.text"), "Real");
998        assert_eq!(text(source, "h1.0.paragraph.0.text"), "The lead.");
999    }
1000
1001    #[test]
1002    fn probe_4_fenced_code_in_the_lead_position_is_code() {
1003        // The section's first *block* is code, so `paragraph` is empty — a
1004        // caller asking for a synopsis gets a failure, not the code.
1005        let source = "# Real\n\n```bash\nafdata get x\n```\n";
1006        assert_eq!(
1007            shape(source, "h1.0.blocks"),
1008            [("code".to_string(), "afdata get x".to_string())]
1009        );
1010        assert_eq!(shape(source, "h1.0.paragraph"), []);
1011        assert_eq!(
1012            at(source, "h1.0.paragraph.0").unwrap_err().code(),
1013            "document_path_not_found"
1014        );
1015        assert_eq!(
1016            at(source, "h1.0.blocks.0.language").unwrap(),
1017            Value::String("bash".to_string())
1018        );
1019    }
1020
1021    #[test]
1022    fn probe_5_leading_fence_swallows_the_heading_inside_it() {
1023        // `# Real` is code here, not a heading — so there is no section at all
1024        // and the whole file is preamble.
1025        let source = "```\n# Real\n```\n";
1026        assert_eq!(
1027            shape(source, "preamble.blocks"),
1028            [("code".to_string(), "# Real".to_string())]
1029        );
1030        assert_eq!(
1031            at(source, "h1.0").unwrap_err().code(),
1032            "document_path_not_found"
1033        );
1034    }
1035
1036    #[test]
1037    fn probe_6_atx_heading_interrupts_a_paragraph() {
1038        // The rule that looks backwards and is not: a `# ` line ends the
1039        // paragraph it appears in rather than joining it. It also opens a
1040        // sibling section, so the lead stops there too.
1041        let source = "# Real\n\nThe lead.\n# looks like heading\nmore.\n";
1042        assert_eq!(text(source, "h1.0.text"), "Real");
1043        assert_eq!(
1044            shape(source, "h1.0.paragraph"),
1045            [("paragraph".to_string(), "The lead.".to_string())]
1046        );
1047        assert_eq!(text(source, "h1.1.text"), "looks like heading");
1048        assert_eq!(text(source, "h1.1.paragraph.0.text"), "more.");
1049    }
1050
1051    #[test]
1052    fn probe_7_four_space_indent_is_code_not_a_heading() {
1053        // The worst of the eight: an indented `# Title` reads as a title to a
1054        // scanner that strips leading whitespace, and is code to CommonMark.
1055        let source = "    # Indented\n\nAfter.\n";
1056        assert_eq!(
1057            types(source, "preamble.blocks"),
1058            ["code".to_string(), "paragraph".to_string()]
1059        );
1060        assert_eq!(
1061            at(source, "h1.0").unwrap_err().code(),
1062            "document_path_not_found"
1063        );
1064    }
1065
1066    #[test]
1067    fn probe_8_whole_paragraph_emphasis_is_unwrapped() {
1068        // Flattening gives this for free; a scanner needs a special case for
1069        // it, and still leaks the inner markers of the partial-emphasis form.
1070        assert_eq!(
1071            text("# Real\n\n**A bold tagline.**\n", "h1.0.paragraph.0.text"),
1072            "A bold tagline."
1073        );
1074        // Partial emphasis, a code span, and a link all flatten to plain text
1075        // too — the scanner leaked every one of these into published metadata.
1076        assert_eq!(
1077            text(
1078                "# T\n\nA **bold** `span` and a [link](https://example.com).\n",
1079                "h1.0.paragraph.0.text"
1080            ),
1081            "A bold span and a link."
1082        );
1083    }
1084
1085    // ---- section folding --------------------------------------------------
1086
1087    #[test]
1088    fn headings_nest_by_level() {
1089        let source = "# A\n\na.\n\n## B\n\nb.\n\n### C\n\nc.\n\n## D\n\nd.\n\n# E\n";
1090        assert_eq!(text(source, "h1.0.text"), "A");
1091        assert_eq!(text(source, "h1.0.paragraph.0.text"), "a.");
1092        assert_eq!(text(source, "h1.0.h2.0.text"), "B");
1093        assert_eq!(text(source, "h1.0.h2.0.h3.0.text"), "C");
1094        assert_eq!(text(source, "h1.0.h2.0.h3.0.paragraph.0.text"), "c.");
1095        // `D` closes `B` (same level) and `C` with it.
1096        assert_eq!(text(source, "h1.0.h2.1.text"), "D");
1097        assert_eq!(
1098            at(source, "h1.0.h2.1.h3.0").unwrap_err().code(),
1099            "document_path_not_found"
1100        );
1101        assert_eq!(text(source, "h1.1.text"), "E");
1102    }
1103
1104    #[test]
1105    fn a_skipped_level_keeps_its_own_name() {
1106        // `### C` under `# A` with no `## ` between: C is A's child, filed by
1107        // its own level rather than promoted to one it does not have.
1108        let source = "# A\n\n### C\n\nc.\n";
1109        assert_eq!(text(source, "h1.0.h3.0.text"), "C");
1110        assert_eq!(
1111            at(source, "h1.0.h2.0").unwrap_err().code(),
1112            "document_path_not_found"
1113        );
1114    }
1115
1116    #[test]
1117    fn a_document_opening_below_h1_has_no_h1() {
1118        // No invented level-1 wrapper: `h2` is simply where the sections are,
1119        // and a caller demanding `h1.0` fails loudly.
1120        let source = "## Only\n\ntext.\n";
1121        assert_eq!(text(source, "h2.0.text"), "Only");
1122        assert_eq!(
1123            at(source, "h1.0").unwrap_err().code(),
1124            "document_path_not_found"
1125        );
1126    }
1127
1128    #[test]
1129    fn preamble_is_always_present_and_empty_for_a_clean_file() {
1130        assert_eq!(shape("# T\n\nlead\n", "preamble.blocks"), []);
1131        assert_eq!(shape("", "preamble.blocks"), []);
1132    }
1133
1134    // ---- addressing by content --------------------------------------------
1135
1136    #[test]
1137    fn a_section_is_addressable_by_a_word_of_its_heading() {
1138        let source = "# T\n\n## A Quick Look\n\ninside look.\n\n## Supported suffixes\n\ns.\n";
1139        // Case-insensitive substring: the memorable word, not the full text.
1140        assert_eq!(text(source, "h1.0.h2.look.text"), "A Quick Look");
1141        assert_eq!(
1142            text(source, "h1.0.h2.look.paragraph.0.text"),
1143            "inside look."
1144        );
1145        assert_eq!(text(source, "h1.0.h2.SUFFIX.text"), "Supported suffixes");
1146        // Content and index address the same section.
1147        assert_eq!(
1148            at(source, "h1.0.h2.look.text").unwrap(),
1149            at(source, "h1.0.h2.0.text").unwrap()
1150        );
1151        // Matching a body paragraph does not leak in: `h2` holds only headings,
1152        // which is the whole reason the tree is not flat.
1153        assert_eq!(
1154            at(source, "h1.0.h2.inside").unwrap_err().code(),
1155            "document_slug_not_found"
1156        );
1157    }
1158
1159    #[test]
1160    fn an_empty_segment_is_not_an_address() {
1161        // `contains("")` is true for everything, so before this an empty
1162        // interpolated name resolved to whichever element happened to be
1163        // alone in the array — a confident wrong answer that only turned into
1164        // an error once a second element existed.
1165        let one = "# T\n\n## Only One\n\na\n";
1166        let two = "# T\n\n## A\n\na\n\n## B\n\nb\n";
1167        for source in [one, two] {
1168            assert_eq!(
1169                at(source, "h1.0.h2..text").unwrap_err().code(),
1170                "document_slug_not_found"
1171            );
1172        }
1173        // Named addressing still works.
1174        assert_eq!(text(two, "h1.0.h2.A.text"), "A");
1175    }
1176
1177    #[test]
1178    fn a_word_matching_several_sections_is_refused() {
1179        let source = "# T\n\n## Quick look\n\na.\n\n## Another look\n\nb.\n";
1180        let error = at(source, "h1.0.h2.look").unwrap_err();
1181        assert_eq!(error.code(), "document_ambiguous_match");
1182        // The refusal reports structural indices, never matched document text.
1183        let message = error.to_string();
1184        assert!(message.contains("indices 0, 1"), "{message}");
1185        assert!(!message.contains("Quick look"), "{message}");
1186        assert!(!message.contains("Another look"), "{message}");
1187        // A word that separates them resolves.
1188        assert_eq!(text(source, "h1.0.h2.Another.text"), "Another look");
1189    }
1190
1191    #[test]
1192    fn content_addressing_lowercases_unicode() {
1193        let source = "# T\n\n## Überblick\n\ninside.\n";
1194        assert_eq!(text(source, "h1.0.h2.ÜBER.text"), "Überblick");
1195    }
1196
1197    #[test]
1198    fn the_ask_prompt_blockquote_is_addressable_by_its_opening_words() {
1199        // The real use: this blockquote sits at no fixed index in any README,
1200        // and its position moves with every edit above it.
1201        let source = "# T\n\nThe lead.\n\n> **Ask your agent:** \"Do the thing.\"\n";
1202        assert_eq!(
1203            text(source, "h1.0.blocks.Ask your agent.text"),
1204            "Ask your agent: \"Do the thing.\""
1205        );
1206    }
1207
1208    // ---- inline flattening and block kinds --------------------------------
1209
1210    #[test]
1211    fn wrapped_paragraph_joins_onto_one_line() {
1212        assert_eq!(
1213            text("# T\n\nLead line one\nline two.\n", "h1.0.paragraph.0.text"),
1214            "Lead line one line two."
1215        );
1216    }
1217
1218    #[test]
1219    fn heading_level_is_reported_for_every_depth() {
1220        let source = "# a\n\n## b\n\n###### f\n";
1221        assert_eq!(at(source, "h1.0.level").unwrap(), Value::Integer(1));
1222        assert_eq!(at(source, "h1.0.h2.0.level").unwrap(), Value::Integer(2));
1223        assert_eq!(
1224            at(source, "h1.0.h2.0.h6.0.level").unwrap(),
1225            Value::Integer(6)
1226        );
1227    }
1228
1229    #[test]
1230    fn blockquote_flattens_its_paragraphs() {
1231        // One wrapped paragraph is one line, as the prompt convention needs.
1232        assert_eq!(
1233            shape(
1234                "> **Ask your agent:** \"Wrapped across\n> two lines.\"\n",
1235                "preamble.blocks"
1236            ),
1237            [(
1238                "blockquote".to_string(),
1239                "Ask your agent: \"Wrapped across two lines.\"".to_string()
1240            )]
1241        );
1242        // Two paragraphs are two blocks, and that boundary is structure, so it
1243        // survives as a newline rather than dissolving into a space.
1244        assert_eq!(
1245            shape("> first\n>\n> second\n", "preamble.blocks"),
1246            [("blockquote".to_string(), "first\nsecond".to_string())]
1247        );
1248    }
1249
1250    #[test]
1251    fn list_reports_its_items_and_whether_it_is_ordered() {
1252        let bullet = at("- one\n- two\n", "preamble.blocks.0").unwrap();
1253        assert_eq!(bullet.get("text").and_then(Value::as_str), Some("one\ntwo"));
1254        assert_eq!(bullet.get("ordered"), Some(&Value::Bool(false)));
1255
1256        assert_eq!(
1257            at("1. one\n2. two\n", "preamble.blocks.0")
1258                .unwrap()
1259                .get("ordered"),
1260            Some(&Value::Bool(true))
1261        );
1262
1263        // A loose list wraps each item in a paragraph and a tight one does
1264        // not. That is a rendering difference, not a content one, so the two
1265        // must read the same.
1266        assert_eq!(
1267            at("- one\n\n- two\n", "preamble.blocks.0")
1268                .unwrap()
1269                .get("text")
1270                .and_then(Value::as_str),
1271            Some("one\ntwo")
1272        );
1273
1274        // A nested list is a block inside its item, and flattens with it.
1275        assert_eq!(
1276            at("- one\n  - inner\n- two\n", "preamble.blocks.0")
1277                .unwrap()
1278                .get("text")
1279                .and_then(Value::as_str),
1280            Some("one\ninner\ntwo")
1281        );
1282    }
1283
1284    #[test]
1285    fn a_leading_metadata_block_is_its_own_kind() {
1286        // A Zola `_index.md`: the `+++` block used to flatten into two
1287        // paragraphs of the body, so `preamble` reported prose that was really
1288        // metadata and a caller reading "the first paragraph" got TOML.
1289        let toml = "+++\ntitle = \"T\"\n\n[extra]\ntagline = \"x\"\n+++\n\n# Real\n\nThe lead.\n";
1290        assert_eq!(types(toml, "preamble.blocks"), ["frontmatter".to_string()]);
1291        assert_eq!(
1292            at(toml, "preamble.blocks.0.format").unwrap(),
1293            Value::String("toml".to_string())
1294        );
1295        // Deliberately not copied into `text`: which fields it holds is TOML's
1296        // answer, and `--input-format toml-frontmatter` is how you get them.
1297        // This structural view must not bypass field-name-based redaction.
1298        assert_eq!(text(toml, "preamble.blocks.0.text"), "");
1299        // The body reads exactly as it would without the block.
1300        assert_eq!(text(toml, "h1.0.text"), "Real");
1301        assert_eq!(text(toml, "h1.0.paragraph.0.text"), "The lead.");
1302
1303        let yaml = "---\ntitle: T\n---\n\n# Real\n";
1304        assert_eq!(types(yaml, "preamble.blocks"), ["frontmatter".to_string()]);
1305        assert_eq!(
1306            at(yaml, "preamble.blocks.0.format").unwrap(),
1307            Value::String("yaml".to_string())
1308        );
1309        assert_eq!(text(yaml, "h1.0.text"), "Real");
1310    }
1311
1312    #[test]
1313    fn dashes_away_from_the_start_keep_their_commonmark_meaning() {
1314        // The metadata rule applies only at the very start of the file, so it
1315        // cannot reinterpret a `---` elsewhere. Both of these would break if
1316        // it did.
1317        assert_eq!(text("Setext\n---\n\nbody\n", "h2.0.text"), "Setext");
1318        assert_eq!(
1319            types("# T\n\na\n\n---\n\nb\n", "h1.0.blocks"),
1320            [
1321                "paragraph".to_string(),
1322                "rule".to_string(),
1323                "paragraph".to_string()
1324            ]
1325        );
1326    }
1327
1328    #[test]
1329    fn a_source_range_stops_at_the_block_it_names() {
1330        // pulldown-cmark runs a list's span on to where the next block starts,
1331        // so it carries the blank separator. A range is a splice instruction:
1332        // taking the separator out merges the blocks on either side. Here that
1333        // made `Lead.` and `Next Title\n====` one setext heading, swallowing
1334        // the lead paragraph into a title.
1335        let source = "- a\n  - b\n\n\nAfter.\n";
1336        assert_eq!(
1337            at(source, "preamble.blocks.0.type").unwrap(),
1338            Value::String("list".to_string())
1339        );
1340        assert_eq!(
1341            at(source, "preamble.blocks.0.source_start_line").unwrap(),
1342            Value::Integer(1)
1343        );
1344        assert_eq!(
1345            at(source, "preamble.blocks.0.source_end_line").unwrap(),
1346            Value::Integer(2)
1347        );
1348        assert_eq!(
1349            at(source, "preamble.blocks.1.source_start_line").unwrap(),
1350            Value::Integer(5)
1351        );
1352
1353        // Every other kind already ended at its own last line; assert they
1354        // still do, so the trim cannot over-correct.
1355        let mixed = "# H\n\npara\n\n```\ncode\n```\n\n> quote\n\n---\n\ntail\n";
1356        for (address, start, end) in [
1357            ("h1.0.blocks.0", 3, 3),
1358            ("h1.0.blocks.1", 5, 7),
1359            ("h1.0.blocks.2", 9, 9),
1360            ("h1.0.blocks.3", 11, 11),
1361            ("h1.0.blocks.4", 13, 13),
1362        ] {
1363            assert_eq!(
1364                at(mixed, &format!("{address}.source_start_line")).unwrap(),
1365                Value::Integer(start),
1366                "{address} start"
1367            );
1368            assert_eq!(
1369                at(mixed, &format!("{address}.source_end_line")).unwrap(),
1370                Value::Integer(end),
1371                "{address} end"
1372            );
1373        }
1374    }
1375
1376    #[test]
1377    fn gfm_table_rows_are_a_paragraph() {
1378        // No extensions: a table is not a block kind here, and the pipe rows
1379        // are a paragraph. That is the specification's reading, not a gap.
1380        assert_eq!(
1381            shape("| a | b |\n|---|---|\n| 1 | 2 |\n", "preamble.blocks"),
1382            [(
1383                "paragraph".to_string(),
1384                "| a | b | |---|---| | 1 | 2 |".to_string()
1385            )]
1386        );
1387        assert_eq!(
1388            shape("| a | b |\n|---|---|\n| 1 | 2 |\n", "preamble.paragraph"),
1389            [(
1390                "paragraph".to_string(),
1391                "| a | b | |---|---| | 1 | 2 |".to_string()
1392            )]
1393        );
1394    }
1395
1396    #[test]
1397    fn badge_syntax_remains_in_the_paragraph_view() {
1398        let source = "# T\n\n[![CI](a.svg)](b)\n\nThe lead.\n";
1399        assert_eq!(
1400            shape(source, "h1.0.paragraph"),
1401            [
1402                ("paragraph".to_string(), "CI".to_string()),
1403                ("paragraph".to_string(), "The lead.".to_string()),
1404            ]
1405        );
1406    }
1407
1408    // ---- source line ranges ----------------------------------------------
1409
1410    #[test]
1411    fn atx_heading_and_blocks_report_inclusive_source_lines() {
1412        let source = "# Title\n\nLead line one\nline two.\n\n```rs\nfn main() {}\n```\n";
1413        assert_eq!(integer(source, "h1.0.source_start_line"), 1);
1414        assert_eq!(integer(source, "h1.0.source_end_line"), 8);
1415        assert_eq!(integer(source, "h1.0.source_start_line"), 1);
1416        assert_eq!(integer(source, "h1.0.heading_end_line"), 1);
1417        assert_eq!(integer(source, "h1.0.paragraph.0.source_start_line"), 3);
1418        assert_eq!(integer(source, "h1.0.paragraph.0.source_end_line"), 4);
1419        assert_eq!(integer(source, "h1.0.blocks.1.source_start_line"), 6);
1420        assert_eq!(integer(source, "h1.0.blocks.1.source_end_line"), 8);
1421    }
1422
1423    #[test]
1424    fn setext_heading_range_includes_its_underline() {
1425        let source = "My Project\n==========\n\nThe synopsis.\n\n## Install\n";
1426        assert_eq!(integer(source, "h1.0.source_start_line"), 1);
1427        assert_eq!(integer(source, "h1.0.heading_end_line"), 2);
1428        assert_eq!(integer(source, "h1.0.source_end_line"), 6);
1429        assert_eq!(integer(source, "h1.0.h2.0.source_start_line"), 6);
1430        assert_eq!(integer(source, "h1.0.h2.0.source_end_line"), 6);
1431        assert_eq!(integer(source, "h1.0.paragraph.0.source_start_line"), 4);
1432        assert_eq!(integer(source, "h1.0.paragraph.0.source_end_line"), 4);
1433    }
1434
1435    #[test]
1436    fn line_ranges_are_utf8_safe_and_newline_style_independent() {
1437        let source = "# 中文标题\r\n\r\n这是首段,\r\n也是首段。\r\n\r\n## 安装";
1438        assert_eq!(integer(source, "h1.0.source_start_line"), 1);
1439        assert_eq!(integer(source, "h1.0.heading_end_line"), 1);
1440        assert_eq!(integer(source, "h1.0.paragraph.0.source_start_line"), 3);
1441        assert_eq!(integer(source, "h1.0.paragraph.0.source_end_line"), 4);
1442        assert_eq!(integer(source, "h1.0.h2.0.source_start_line"), 6);
1443        assert_eq!(integer(source, "h1.0.h2.0.heading_end_line"), 6);
1444    }
1445
1446    #[test]
1447    fn nesting_beyond_the_block_depth_is_refused_not_a_stack_overflow() {
1448        let nested = |depth: usize| format!("# T\n{}x\n", "> ".repeat(depth));
1449        // The heading closes first, so the innermost paragraph adds one level.
1450        assert_eq!(text(&nested(47), "h1.0.blockquote.0.text"), "x");
1451        for depth in [48, 100_000] {
1452            let error = load(&nested(depth)).unwrap_err();
1453            assert_eq!(error.code(), "document_source_refused");
1454            assert!(error.to_string().contains("48 levels"), "{error}");
1455        }
1456        let error = load(&format!("{}x\n", "- ".repeat(100_000))).unwrap_err();
1457        assert_eq!(error.code(), "document_source_refused");
1458    }
1459
1460    #[test]
1461    fn a_bare_carriage_return_is_refused_rather_than_numbered() {
1462        // CommonMark ends a line at a bare `\r`; sed, awk, wc, head, and git
1463        // do not. These numbers exist to be handed to those, so for such a
1464        // file no single number is right for both readers — here `Lead` and
1465        // `## End` would share a line, and a splice by that number would cut
1466        // the wrong text silently.
1467        let error = load("# T\r\rLead\rcontinued\r\r## End").unwrap_err();
1468        // Its own code, not `document_parse_failed`: the file is valid
1469        // CommonMark and afdata is declining to number it, so a reader told
1470        // "parse failed" would go hunting for a syntax error that is not there.
1471        assert_eq!(error.code(), "document_source_refused");
1472        assert!(error.to_string().contains("carriage return"), "{error}");
1473        // The way out survives redaction. This detail is written here, about
1474        // the file's line endings, and holds no document text — dropping it
1475        // left `failed to parse Markdown` and nothing to act on.
1476        assert!(
1477            error.redacted_message().contains("CRLF line endings"),
1478            "{}",
1479            error.redacted_message()
1480        );
1481
1482        // The endings every current tool writes are unaffected, and both
1483        // rules agree on them.
1484        let crlf = "# T\r\n\r\nLead\r\ncontinued\r\n\r\n## End\r\n";
1485        assert_eq!(integer(crlf, "h1.0.paragraph.0.source_start_line"), 3);
1486        assert_eq!(integer(crlf, "h1.0.paragraph.0.source_end_line"), 4);
1487        assert_eq!(integer(crlf, "h1.0.h2.0.source_start_line"), 6);
1488
1489        // No final newline still numbers its last line.
1490        let bare = "# T\n\nLead";
1491        assert_eq!(integer(bare, "h1.0.paragraph.0.source_end_line"), 3);
1492    }
1493
1494    #[test]
1495    fn frontmatter_range_includes_both_delimiters() {
1496        let source = "---\ntitle: T\nnested:\n  token_secret: hidden\n---\n\n# T\n";
1497        assert_eq!(integer(source, "preamble.blocks.0.source_start_line"), 1);
1498        assert_eq!(integer(source, "preamble.blocks.0.source_end_line"), 5);
1499        assert_eq!(text(source, "preamble.blocks.0.text"), "");
1500    }
1501
1502    #[test]
1503    fn thematic_break_is_a_block_with_no_text() {
1504        assert_eq!(
1505            shape("# T\n\na\n\n---\n\nb\n", "h1.0.blocks"),
1506            [
1507                ("paragraph".to_string(), "a".to_string()),
1508                ("rule".to_string(), String::new()),
1509                ("paragraph".to_string(), "b".to_string()),
1510            ]
1511        );
1512        // The paragraph view skips it, so "the second paragraph" stays the
1513        // second paragraph.
1514        assert_eq!(
1515            shape("# T\n\na\n\n---\n\nb\n", "h1.0.paragraph"),
1516            [
1517                ("paragraph".to_string(), "a".to_string()),
1518                ("paragraph".to_string(), "b".to_string()),
1519            ]
1520        );
1521    }
1522
1523    #[test]
1524    fn a_byte_order_mark_makes_the_first_block_a_paragraph() {
1525        // CommonMark does not strip a BOM, so `\u{feff}# Title` is not a
1526        // heading. Pinned because a Windows editor writes one silently: the
1527        // file then has no section at all, which fails loudly — the outcome to
1528        // keep, against publishing "\u{feff}Title" as a name.
1529        assert_eq!(
1530            types("\u{feff}# Title\n\nlead\n", "preamble.blocks"),
1531            ["paragraph".to_string(), "paragraph".to_string()]
1532        );
1533    }
1534
1535    #[test]
1536    fn empty_document_has_no_blocks() {
1537        assert_eq!(shape("", "preamble.blocks"), []);
1538        assert_eq!(shape("\n\n   \n", "preamble.blocks"), []);
1539    }
1540
1541    #[test]
1542    fn code_block_keeps_its_lines_and_drops_one_trailing_newline() {
1543        assert_eq!(
1544            shape("```\nline one\nline two\n```\n", "preamble.blocks"),
1545            [("code".to_string(), "line one\nline two".to_string())]
1546        );
1547    }
1548
1549    /// `text` flattens inline markup; `inlines` keeps exactly what that drops:
1550    /// which words are code, where a link points, what is an image.
1551    #[test]
1552    fn inlines_keep_what_text_flattens() {
1553        let source =
1554            "See [the **docs**](docs.md), ![alt *x*](img.png) and `code` <b>b</b>\\\nend\n";
1555        assert_eq!(
1556            text(source, "preamble.paragraph.0.text"),
1557            "See the docs, alt x and code b end"
1558        );
1559        let kinds = |path: &str| -> Vec<(String, String)> {
1560            at(source, path)
1561                .unwrap()
1562                .as_array()
1563                .unwrap()
1564                .iter()
1565                .map(|node| {
1566                    (
1567                        node.get("type")
1568                            .and_then(Value::as_str)
1569                            .unwrap()
1570                            .to_string(),
1571                        node.get("text")
1572                            .and_then(Value::as_str)
1573                            .unwrap()
1574                            .to_string(),
1575                    )
1576                })
1577                .collect()
1578        };
1579        let pairs = |items: &[(&str, &str)]| -> Vec<(String, String)> {
1580            items
1581                .iter()
1582                .map(|(kind, text)| (kind.to_string(), text.to_string()))
1583                .collect()
1584        };
1585        assert_eq!(
1586            kinds("preamble.paragraph.0.inlines"),
1587            pairs(&[
1588                ("text", "See "),
1589                ("link", "the docs"),
1590                ("text", ", "),
1591                ("image", "alt x"),
1592                ("text", " and "),
1593                ("code", "code"),
1594                ("text", " "),
1595                ("html", "<b>"),
1596                ("text", "b"),
1597                ("html", "</b>"),
1598                ("line_break", ""),
1599                ("text", "end"),
1600            ])
1601        );
1602        assert_eq!(
1603            kinds("preamble.paragraph.0.inlines.docs.inlines"),
1604            pairs(&[("text", "the "), ("strong", "docs")])
1605        );
1606        assert_eq!(
1607            text(source, "preamble.paragraph.0.inlines.docs.destination_url"),
1608            "docs.md"
1609        );
1610        assert_eq!(
1611            text(source, "preamble.paragraph.0.inlines.3.destination_url"),
1612            "img.png"
1613        );
1614        // Alt text is plain text when rendered, so an image has no children.
1615        assert!(
1616            at(source, "preamble.paragraph.0.inlines.3.inlines").is_err(),
1617            "an image reports flattened alt text only"
1618        );
1619    }
1620
1621    #[test]
1622    fn a_heading_and_its_section_report_inlines() {
1623        let source = "# Run `serve` now\n";
1624        assert_eq!(text(source, "h1.0.text"), "Run serve now");
1625        assert_eq!(text(source, "h1.0.inlines.1.type"), "code");
1626        assert_eq!(text(source, "h1.0.inlines.1.text"), "serve");
1627    }
1628
1629    /// A soft break is a wrapped line, which is presentation: it joins the
1630    /// surrounding text node rather than becoming a node of its own.
1631    #[test]
1632    fn a_soft_break_joins_adjacent_text() {
1633        assert_eq!(
1634            text("one\ntwo\n", "preamble.paragraph.0.inlines.0.text"),
1635            "one two"
1636        );
1637        assert!(at("one\ntwo\n", "preamble.paragraph.0.inlines.1").is_err());
1638    }
1639
1640    /// A code fence inside a list item or a blockquote is still a `code`
1641    /// block: containers report their children, not only flattened text.
1642    #[test]
1643    fn containers_report_their_child_blocks() {
1644        let source = "- item\n\n  ```bash\n  nested\n  ```\n\n> ```json\n> {}\n> ```\n";
1645        assert_eq!(text(source, "preamble.blocks.0.blocks.0.type"), "item");
1646        assert_eq!(
1647            text(source, "preamble.blocks.0.blocks.0.blocks.1.type"),
1648            "code"
1649        );
1650        assert_eq!(
1651            text(source, "preamble.blocks.0.blocks.0.blocks.1.language"),
1652            "bash"
1653        );
1654        assert_eq!(
1655            text(source, "preamble.blocks.0.blocks.0.blocks.1.text"),
1656            "nested"
1657        );
1658        assert_eq!(
1659            integer(
1660                source,
1661                "preamble.blocks.0.blocks.0.blocks.1.source_start_line"
1662            ),
1663            3
1664        );
1665        assert_eq!(text(source, "preamble.blocks.1.blocks.0.type"), "code");
1666        assert_eq!(text(source, "preamble.blocks.1.blocks.0.language"), "json");
1667        assert_eq!(text(source, "preamble.blocks.1.blocks.0.text"), "{}");
1668        // The flattened view is unchanged.
1669        assert_eq!(text(source, "preamble.blocks.0.text"), "item\nnested");
1670    }
1671
1672    /// The reported tree of the deepest file the reader accepts must still be
1673    /// readable as JSON, inside a protocol envelope, by a reader that stops at
1674    /// 128 levels — or `afdata get` emits documents it cannot read back.
1675    #[test]
1676    fn the_deepest_accepted_file_reports_a_tree_json_can_read_back() {
1677        fn depth(value: &Value) -> usize {
1678            match value {
1679                Value::Array(items) => 1 + items.iter().map(depth).max().unwrap_or(0),
1680                Value::Object(fields) => 1 + fields.values().map(depth).max().unwrap_or(0),
1681                _ => 0,
1682            }
1683        }
1684        let headings = "# a\n## b\n### c\n#### d\n##### e\n###### f\n";
1685        // A list item is two levels (list, item), the paragraph inside one more.
1686        let deepest = [
1687            format!("{headings}{}x\n", "> ".repeat(MAX_BLOCK_DEPTH - 1)),
1688            format!("{headings}{}x\n", "- ".repeat((MAX_BLOCK_DEPTH - 1) / 2)),
1689            format!("{headings}{}*x*\n", "> ".repeat(MAX_BLOCK_DEPTH - 3)),
1690        ];
1691        for source in deepest {
1692            let tree = load(&source).unwrap();
1693            assert!(depth(&tree) <= 112, "{} levels", depth(&tree));
1694            let json = serde_json::to_string(&tree).unwrap();
1695            let enveloped = format!("{{\"kind\":\"result\",\"result\":{{\"value\":{json}}}}}");
1696            serde_json::from_str::<serde_json::Value>(&enveloped).unwrap();
1697        }
1698    }
1699
1700    #[test]
1701    fn inline_nesting_counts_toward_the_depth_limit() {
1702        let nested = |depth: usize| format!("{}x{}\n", "*".repeat(depth), "*".repeat(depth));
1703        assert!(load(&nested(8)).is_ok());
1704        let error = load(&nested(400)).unwrap_err();
1705        assert_eq!(error.code(), "document_source_refused");
1706    }
1707}