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//! - **No recursion into a block's children.** A blockquote or list reports
59//!   flattened `text`; its inner block structure is not exposed. (Heading
60//!   sections *are* nested — that hierarchy comes from the level, not from
61//!   walking inside a block.)
62//! - **No byte offsets or columns.** Every block does carry 1-based inclusive
63//!   `source_start_line` / `source_end_line`. Every section carries its whole
64//!   source range plus `heading_end_line` for the
65//!   heading alone. These are enough to splice whole Markdown blocks without
66//!   making every consumer implement UTF-8 byte indexing.
67//! - **No Markdown-to-Markdown transformation.** Rewriting a document in place
68//!   needs source-preserving serialization, which is a separate design, not a
69//!   rider on a reader.
70
71use std::{collections::BTreeMap, ops::Range};
72
73use pulldown_cmark::{CodeBlockKind, Event, MetadataBlockKind, Options, Parser, Tag, TagEnd};
74
75use crate::document::{DocumentError, DocumentResult, Value};
76
77/// Parse `content` as CommonMark into the section tree described above.
78///
79/// CommonMark has no invalid-input production — every UTF-8 string is some
80/// sequence of blocks — so this fails only where afdata declines to answer: a
81/// bare carriage return, or block nesting deeper than [`MAX_BLOCK_DEPTH`]. An
82/// empty string yields an empty `preamble` and no sections.
83pub fn load(content: &str) -> DocumentResult<Value> {
84    reject_lone_carriage_return(content)?;
85    let events: Vec<SpannedEvent<'_>> = Parser::new_ext(content, options())
86        .into_offset_iter()
87        .collect();
88    reject_deep_nesting(&events)?;
89    let lines = LineIndex::new(content);
90    let mut cursor = 0;
91    Ok(fold_sections(read_blocks(&events, &lines, &mut cursor)))
92}
93
94type SpannedEvent<'a> = (Event<'a>, Range<usize>);
95
96/// Byte offsets of logical line starts, used only inside the parser boundary.
97///
98/// Public consumers see line numbers, not byte offsets. LF and CRLF input use
99/// the same `\n`-based numbering as common line-oriented tools; a lone CR is
100/// rejected before this index is constructed.
101struct LineIndex<'a> {
102    content: &'a str,
103    starts: Vec<usize>,
104}
105
106impl<'a> LineIndex<'a> {
107    /// Index the line starts of `content`, counting a line as everything up to
108    /// the next `\n`.
109    ///
110    /// Not CommonMark's rule, deliberately. CommonMark also treats a bare `\r`
111    /// as a line ending, and these numbers exist to be handed to something
112    /// else — `sed -n '5,10p'`, `head -n`, an editor jump, a `git diff` — all
113    /// of which split on `\n` alone. A number that only afdata can interpret
114    /// is worse than no number: it looks usable and silently points one line
115    /// off. CRLF agrees with both rules, so the two definitions differ only
116    /// for a bare `\r`, which [`reject_lone_carriage_return`] refuses outright
117    /// rather than reporting a line number nothing else can act on.
118    fn new(content: &'a str) -> Self {
119        let mut starts = vec![0];
120        for (offset, byte) in content.bytes().enumerate() {
121            if byte == b'\n' {
122                starts.push(offset + 1);
123            }
124        }
125        Self { content, starts }
126    }
127
128    fn number_at(&self, offset: usize) -> i64 {
129        let line = self.starts.partition_point(|start| *start <= offset);
130        i64::try_from(line).unwrap_or(i64::MAX)
131    }
132
133    /// The 1-based inclusive line range a source span covers.
134    ///
135    /// The span's trailing whitespace is dropped first. Most block kinds end
136    /// at their own last character, but pulldown-cmark runs a list's span on
137    /// to where the next block begins, so it carries the blank lines that
138    /// separate them. A range is a splice instruction — a consumer cutting one
139    /// out must not take the separator with it, or the blocks on either side
140    /// merge (a lead paragraph absorbed into the setext heading below it).
141    fn range(&self, source: &Range<usize>) -> SourceLines {
142        let content_end = self
143            .content
144            .get(..source.end)
145            .map_or(source.end, |head| head.trim_end().len())
146            .max(source.start);
147        let end_offset = content_end.saturating_sub(1).max(source.start);
148        SourceLines {
149            start: self.number_at(source.start),
150            end: self.number_at(end_offset),
151        }
152    }
153}
154
155#[derive(Clone, Copy)]
156struct SourceLines {
157    start: i64,
158    end: i64,
159}
160
161/// Refuse a document containing a bare `\r` — a carriage return not followed
162/// by a newline.
163///
164/// This is the single case where CommonMark's line rule and every other tool's
165/// disagree: CommonMark ends a line there, `sed`/`awk`/`wc`/`head`/`git` do
166/// not. A reported line number is only useful if the thing the caller hands it
167/// to counts the same way, and for such a file no single number can satisfy
168/// both — two blocks would share one line, and a splice by that number would
169/// cut the wrong text with nothing to notice it by.
170///
171/// So the file is refused instead of answered wrongly. It is the classic Mac
172/// (pre-2002) line ending; CRLF and LF, which every current tool produces, are
173/// unaffected because both rules agree on them.
174fn reject_lone_carriage_return(content: &str) -> DocumentResult<()> {
175    let bytes = content.as_bytes();
176    for (offset, byte) in bytes.iter().enumerate() {
177        if *byte == b'\r' && bytes.get(offset + 1) != Some(&b'\n') {
178            return Err(DocumentError::SourceRefused {
179                format: "Markdown".to_string(),
180                detail: format!(
181                    "a bare carriage return at byte {offset} ends a line for CommonMark but not \
182                     for the tools these line numbers are meant to feed; convert the file to LF \
183                     or CRLF line endings"
184                ),
185            });
186        }
187    }
188    Ok(())
189}
190
191/// The block walk recurses once per nested container, matching the depth the
192/// JSON and YAML readers allow.
193const MAX_BLOCK_DEPTH: usize = 128;
194
195/// Refuse nesting the recursive block walk cannot hold before it starts, so a
196/// file of `> > > …` is an error rather than a stack overflow.
197fn reject_deep_nesting(events: &[SpannedEvent<'_>]) -> DocumentResult<()> {
198    let mut depth = 0usize;
199    for (event, _) in events {
200        match event {
201            Event::Start(tag) if !is_inline_tag(tag) => {
202                depth += 1;
203                if depth > MAX_BLOCK_DEPTH {
204                    return Err(DocumentError::SourceRefused {
205                        format: "Markdown".to_string(),
206                        detail: format!("blocks nest deeper than {MAX_BLOCK_DEPTH} levels"),
207                    });
208                }
209            }
210            Event::End(tag) if !is_inline_end(*tag) => depth = depth.saturating_sub(1),
211            _ => {}
212        }
213    }
214    Ok(())
215}
216
217fn is_inline_end(tag: TagEnd) -> bool {
218    matches!(
219        tag,
220        TagEnd::Emphasis
221            | TagEnd::Strong
222            | TagEnd::Strikethrough
223            | TagEnd::Superscript
224            | TagEnd::Subscript
225            | TagEnd::Link
226            | TagEnd::Image
227    )
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    heading_lines: SourceLines,
253    blocks: Vec<Value>,
254    children: Vec<Section>,
255}
256
257/// Fold a flat block sequence into the section tree.
258///
259/// A heading closes every open section at its own level or deeper, then opens
260/// its own — the standard reading of heading levels, and the only hierarchy
261/// CommonMark itself states. A level may be skipped (an `h3` directly under an
262/// `h1`); the `h3` simply becomes a child of whatever section is open, since
263/// there is no `h2` to hold it.
264fn fold_sections(blocks: Vec<Value>) -> Value {
265    let mut preamble = Vec::new();
266    let mut roots: Vec<Section> = Vec::new();
267    let mut open: Vec<Section> = Vec::new();
268
269    for block in blocks {
270        let heading_level = (block.get("type").and_then(Value::as_str) == Some("heading"))
271            .then(|| block.get("level").and_then(Value::as_integer))
272            .flatten();
273        match heading_level {
274            Some(level) => {
275                while open.last().is_some_and(|section| section.level >= level) {
276                    close_section(&mut open, &mut roots);
277                }
278                open.push(Section {
279                    level,
280                    text: block
281                        .get("text")
282                        .and_then(Value::as_str)
283                        .unwrap_or_default()
284                        .to_string(),
285                    heading_lines: SourceLines {
286                        start: block
287                            .get("source_start_line")
288                            .and_then(Value::as_integer)
289                            .unwrap_or_default(),
290                        end: block
291                            .get("source_end_line")
292                            .and_then(Value::as_integer)
293                            .unwrap_or_default(),
294                    },
295                    blocks: Vec::new(),
296                    children: Vec::new(),
297                });
298            }
299            // Content before the first heading belongs to no section. It is
300            // kept rather than dropped: "this file opens with a generated-file
301            // comment" is a fact, and whether that is acceptable is the
302            // caller's rule to apply, not this reader's to enforce by omission.
303            None => match open.last_mut() {
304                Some(section) => section.blocks.push(block),
305                None => preamble.push(block),
306            },
307        }
308    }
309    while !open.is_empty() {
310        close_section(&mut open, &mut roots);
311    }
312
313    // `preamble` carries the same two views a section does, so "the first
314    // paragraph" is one address in both. As a bare array it was not: a `+++`
315    // block or a badge line above the prose shifted every index, which is the
316    // problem sections exist to remove.
317    let mut root = BTreeMap::from([("preamble".to_string(), Value::Object(block_views(preamble)))]);
318    insert_sections(&mut root, roots);
319    Value::Object(root)
320}
321
322/// Pop the deepest open section and file it under its parent, or under the
323/// document root when it has none.
324fn close_section(open: &mut Vec<Section>, roots: &mut Vec<Section>) {
325    let Some(done) = open.pop() else { return };
326    match open.last_mut() {
327        Some(parent) => parent.children.push(done),
328        None => roots.push(done),
329    }
330}
331
332/// File `sections` into `target` under `h1`/`h2`/… by their own level, each
333/// group in source order.
334fn insert_sections(target: &mut BTreeMap<String, Value>, sections: Vec<Section>) {
335    for section in sections {
336        let key = format!("h{}", section.level);
337        let group = target
338            .entry(key)
339            .or_insert_with(|| Value::Array(Vec::new()));
340        if let Some(items) = group.as_array_mut() {
341            items.push(section.into_value());
342        }
343    }
344}
345
346/// The two views every block container reports: `blocks` in source order, and
347/// the `paragraph` subset of them.
348///
349/// The subset is duplicated out of `blocks` on purpose. Prose is what a
350/// container is usually read for, and "the first paragraph" must not shift
351/// because a frontmatter block, a code fence, or a thematic break happened to
352/// land above it. Badge syntax and a GFM-looking pipe table are paragraphs
353/// under the deliberately enabled CommonMark grammar, so they remain in this
354/// view. `blocks` stays the one place that shows what the container actually
355/// looks like, in order.
356fn block_views(blocks: Vec<Value>) -> BTreeMap<String, Value> {
357    let of_kind = |kind: &str| {
358        Value::Array(
359            blocks
360                .iter()
361                .filter(|block| block.get("type").and_then(Value::as_str) == Some(kind))
362                .cloned()
363                .collect::<Vec<Value>>(),
364        )
365    };
366    let paragraph = of_kind("paragraph");
367    let blockquote = of_kind("blockquote");
368    BTreeMap::from([
369        ("paragraph".to_string(), paragraph),
370        ("blockquote".to_string(), blockquote),
371        ("blocks".to_string(), Value::Array(blocks)),
372    ])
373}
374
375impl Section {
376    fn into_value(self) -> Value {
377        let source_end_line = self.source_end_line();
378        let mut fields = block_views(self.blocks);
379        fields.insert("level".to_string(), Value::Integer(self.level));
380        fields.insert("text".to_string(), Value::String(self.text));
381        fields.insert(
382            "source_start_line".to_string(),
383            Value::Integer(self.heading_lines.start),
384        );
385        fields.insert(
386            "source_end_line".to_string(),
387            Value::Integer(source_end_line),
388        );
389        fields.insert(
390            "heading_end_line".to_string(),
391            Value::Integer(self.heading_lines.end),
392        );
393        insert_sections(&mut fields, self.children);
394        Value::Object(fields)
395    }
396
397    fn source_end_line(&self) -> i64 {
398        self.blocks
399            .iter()
400            .filter_map(|block| block.get("source_end_line"))
401            .filter_map(Value::as_integer)
402            .chain(self.children.iter().map(Section::source_end_line))
403            .max()
404            .unwrap_or(self.heading_lines.end)
405    }
406}
407
408/// Read sibling blocks until the enclosing container's `End` event, or the end
409/// of the stream at top level. Leaves `cursor` *on* that `End`, for the caller
410/// that opened the container to consume.
411fn read_blocks(
412    events: &[SpannedEvent<'_>],
413    lines: &LineIndex<'_>,
414    cursor: &mut usize,
415) -> Vec<Value> {
416    let mut blocks = Vec::new();
417    while let Some((event, source)) = events.get(*cursor) {
418        match event {
419            Event::End(_) => break,
420            Event::Rule => {
421                let source = source.clone();
422                *cursor += 1;
423                blocks.push(block("rule", String::new(), &source, lines, vec![]));
424            }
425            Event::Start(tag) if !is_inline_tag(tag) => {
426                let source = source.clone();
427                *cursor += 1;
428                blocks.extend(read_block(tag, &source, events, lines, cursor));
429            }
430            // Inline content standing where a block belongs: a tight list's
431            // item holds its text directly, with no paragraph around it.
432            // Reading it as an implicit paragraph is what makes a tight and a
433            // loose list report the same items.
434            _ => {
435                let first = *cursor;
436                let text = read_loose_inline(events, cursor);
437                if !text.is_empty() {
438                    let source = covered_range(events, first, *cursor);
439                    blocks.push(block("paragraph", text, &source, lines, vec![]));
440                }
441            }
442        }
443    }
444    blocks
445}
446
447/// Whether `tag` wraps text inside a block rather than opening one.
448fn is_inline_tag(tag: &Tag<'_>) -> bool {
449    matches!(
450        tag,
451        Tag::Emphasis
452            | Tag::Strong
453            | Tag::Strikethrough
454            | Tag::Superscript
455            | Tag::Subscript
456            | Tag::Link { .. }
457            | Tag::Image { .. }
458    )
459}
460
461/// Read the one block opened by `tag`, consuming through its own `End`.
462fn read_block(
463    tag: &Tag<'_>,
464    source: &Range<usize>,
465    events: &[SpannedEvent<'_>],
466    lines: &LineIndex<'_>,
467    cursor: &mut usize,
468) -> Option<Value> {
469    match tag {
470        Tag::Paragraph => Some(block(
471            "paragraph",
472            read_inline(events, cursor),
473            source,
474            lines,
475            vec![],
476        )),
477        Tag::Heading { level, .. } => Some(block(
478            "heading",
479            read_inline(events, cursor),
480            source,
481            lines,
482            vec![("level", Value::Integer(*level as i64))],
483        )),
484        Tag::CodeBlock(kind) => {
485            // CommonMark's info string is everything after the opening fence;
486            // in practice it is the language. An indented block has none.
487            let info = match kind {
488                CodeBlockKind::Fenced(info) => info.trim().to_string(),
489                CodeBlockKind::Indented => String::new(),
490            };
491            Some(block(
492                "code",
493                read_verbatim(events, cursor),
494                source,
495                lines,
496                // `language`, not CommonMark's `info`: a field must say what
497                // it holds without its neighbours (spec/agent-first-data.md
498                // rule 5, "Self-contained"). Sharing one name with the
499                // frontmatter block below made `info: "toml"` mean either a
500                // fence written in TOML or a `+++` delimiter, tellable apart
501                // only by also reading `type`.
502                vec![("language", Value::String(info))],
503            ))
504        }
505        Tag::HtmlBlock => Some(block(
506            "html",
507            read_verbatim(events, cursor),
508            source,
509            lines,
510            vec![],
511        )),
512        // The leading `+++`/`---` block, reported as its own kind without
513        // copying its potentially secret-bearing source into `text`: which
514        // fields it holds is TOML's or YAML's answer, and
515        // `--input-format toml-frontmatter` is the reading that gives it.
516        // `format` names the delimiter's dialect, matching the
517        // `--input-format` token that reads its fields.
518        Tag::MetadataBlock(kind) => {
519            // Advance without collecting the metadata into a second String.
520            // The source parser already borrows it, and this view deliberately
521            // exposes neither a copy nor a flattened form.
522            skip_subtree(events, cursor);
523            Some(block(
524                "frontmatter",
525                String::new(),
526                source,
527                lines,
528                vec![(
529                    "format",
530                    Value::String(
531                        match kind {
532                            MetadataBlockKind::PlusesStyle => "toml",
533                            MetadataBlockKind::YamlStyle => "yaml",
534                        }
535                        .to_string(),
536                    ),
537                )],
538            ))
539        }
540        Tag::BlockQuote(_) => Some(block(
541            "blockquote",
542            read_container(events, lines, cursor),
543            source,
544            lines,
545            vec![],
546        )),
547        Tag::List(first_number) => Some(block(
548            "list",
549            read_container(events, lines, cursor),
550            source,
551            lines,
552            vec![("ordered", Value::Bool(first_number.is_some()))],
553        )),
554        // An item exists only as a list's child, and the list flattens it away
555        // into its own `text`, so this shape never reaches a caller. It is a
556        // block here so that the generic container walk can reach an item's
557        // own children at all.
558        Tag::Item => Some(block(
559            "item",
560            read_container(events, lines, cursor),
561            source,
562            lines,
563            vec![],
564        )),
565        // Tables, footnote definitions, definition lists, and metadata blocks
566        // require options this backend does not enable, so none of them can
567        // occur. Skipping the subtree keeps that a fact rather than a
568        // half-formed block if the option set ever widens.
569        _ => {
570            skip_subtree(events, cursor);
571            None
572        }
573    }
574}
575
576/// Flattened text of a container block — its child blocks' `text` joined by a
577/// newline — consuming through the container's own `End`.
578///
579/// Block boundaries survive as newlines because they are structure; a wrapped
580/// line inside one paragraph does not, because it is presentation (see
581/// [`read_inline`]). Children that flatten to nothing, such as a thematic
582/// break, contribute nothing rather than a blank line.
583fn read_container(
584    events: &[SpannedEvent<'_>],
585    lines: &LineIndex<'_>,
586    cursor: &mut usize,
587) -> String {
588    let children = read_blocks(events, lines, cursor);
589    // `read_blocks` stops on the container's `End` rather than past it.
590    *cursor += 1;
591    children
592        .iter()
593        .filter_map(|child| child.get("text").and_then(Value::as_str))
594        .filter(|text| !text.is_empty())
595        .collect::<Vec<_>>()
596        .join("\n")
597}
598
599/// Flattened plain text of a leaf block's inline content, consuming through
600/// that block's own `End`.
601///
602/// Emphasis and strong are unwrapped, a code span keeps its content, a link
603/// keeps its text and drops its URL, an image keeps its alt text, raw inline
604/// HTML tags are dropped, and every line break inside the block — soft or hard
605/// — becomes a single space. The result is one line of plain prose, which is
606/// what a name, a synopsis, or a prompt is.
607fn read_inline(events: &[SpannedEvent<'_>], cursor: &mut usize) -> String {
608    let text = read_loose_inline(events, cursor);
609    if matches!(events.get(*cursor), Some((Event::End(_), _))) {
610        *cursor += 1;
611    }
612    text
613}
614
615/// [`read_inline`] without an owning block: stops *before* the next block
616/// boundary instead of consuming a closing `End`. This is the form a tight
617/// list item needs, whose text has no paragraph of its own to end.
618fn read_loose_inline(events: &[SpannedEvent<'_>], cursor: &mut usize) -> String {
619    let mut text = String::new();
620    let mut depth = 0usize;
621    while let Some((event, _)) = events.get(*cursor) {
622        match event {
623            Event::Start(tag) if depth == 0 && !is_inline_tag(tag) => break,
624            Event::End(_) if depth == 0 => break,
625            Event::Rule if depth == 0 => break,
626            Event::Start(_) => {
627                depth += 1;
628                *cursor += 1;
629            }
630            Event::End(_) => {
631                depth -= 1;
632                *cursor += 1;
633            }
634            Event::Text(chunk) | Event::Code(chunk) => {
635                text.push_str(chunk);
636                *cursor += 1;
637            }
638            Event::SoftBreak | Event::HardBreak => {
639                text.push(' ');
640                *cursor += 1;
641            }
642            _ => *cursor += 1,
643        }
644    }
645    text.trim().to_string()
646}
647
648/// Literal content of a code or HTML block, consuming through its `End`.
649///
650/// Nothing is flattened here — the content is not prose. The single trailing
651/// newline every such block carries (the line break before its closing fence,
652/// or ending its last line) is dropped; everything else is verbatim.
653fn read_verbatim(events: &[SpannedEvent<'_>], cursor: &mut usize) -> String {
654    let mut text = String::new();
655    while let Some((event, _)) = events.get(*cursor) {
656        *cursor += 1;
657        match event {
658            Event::End(_) => break,
659            Event::Text(chunk) | Event::Html(chunk) => text.push_str(chunk),
660            _ => {}
661        }
662    }
663    let unterminated = text.strip_suffix('\n').unwrap_or(text.as_str());
664    unterminated
665        .strip_suffix('\r')
666        .unwrap_or(unterminated)
667        .to_string()
668}
669
670/// Consume an unhandled container's whole subtree, including its own `End`.
671fn skip_subtree(events: &[SpannedEvent<'_>], cursor: &mut usize) {
672    let mut depth = 0usize;
673    while let Some((event, _)) = events.get(*cursor) {
674        *cursor += 1;
675        match event {
676            Event::Start(_) => depth += 1,
677            Event::End(_) => {
678                if depth == 0 {
679                    break;
680                }
681                depth -= 1;
682            }
683            _ => {}
684        }
685    }
686}
687
688/// The smallest source range covering events in `start..end`.
689///
690/// Top-level CommonMark blocks carry their full range on the opening event.
691/// This fallback exists for inline text in tight containers, where there is no
692/// paragraph `Start` event to lend us one.
693fn covered_range(events: &[SpannedEvent<'_>], start: usize, end: usize) -> Range<usize> {
694    let mut covered = events
695        .get(start)
696        .map(|(_, source)| source.clone())
697        .unwrap_or(0..0);
698    for (_, source) in events.get(start..end).unwrap_or_default() {
699        covered.start = covered.start.min(source.start);
700        covered.end = covered.end.max(source.end);
701    }
702    covered
703}
704
705/// Assemble one block: the `type`, flattened `text`, and 1-based inclusive
706/// source-line range every block carries, plus kind-specific fields.
707fn block(
708    kind: &str,
709    text: String,
710    source: &Range<usize>,
711    lines: &LineIndex<'_>,
712    extra: Vec<(&str, Value)>,
713) -> Value {
714    let source = lines.range(source);
715    let mut fields = BTreeMap::from([
716        ("type".to_string(), Value::String(kind.to_string())),
717        ("text".to_string(), Value::String(text)),
718        (
719            "source_start_line".to_string(),
720            Value::Integer(source.start),
721        ),
722        ("source_end_line".to_string(), Value::Integer(source.end)),
723    ]);
724    for (name, value) in extra {
725        fields.insert(name.to_string(), value);
726    }
727    Value::Object(fields)
728}
729
730#[cfg(test)]
731mod tests {
732    #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
733    use super::*;
734    use crate::document::{Addressing, Format, get_path};
735
736    /// Read one address exactly as a caller would, through the same traversal
737    /// and the same format rule the CLI uses.
738    fn at(source: &str, path: &str) -> DocumentResult<Value> {
739        get_path(
740            &load(source).unwrap(),
741            path,
742            Addressing::INDEX_ONLY.with_array_rule(Format::Markdown.array_rule()),
743        )
744    }
745
746    fn text(source: &str, path: &str) -> String {
747        at(source, path)
748            .unwrap_or_else(|error| panic!("{path}: {error}"))
749            .as_str()
750            .unwrap_or_else(|| panic!("{path} is not a string"))
751            .to_string()
752    }
753
754    fn integer(source: &str, path: &str) -> i64 {
755        at(source, path)
756            .unwrap_or_else(|error| panic!("{path}: {error}"))
757            .as_integer()
758            .unwrap_or_else(|| panic!("{path} is not an integer"))
759    }
760
761    /// `(type, text)` of a block array, which is what a caller reads.
762    fn shape(source: &str, path: &str) -> Vec<(String, String)> {
763        at(source, path)
764            .unwrap_or_else(|error| panic!("{path}: {error}"))
765            .as_array()
766            .unwrap_or_else(|| panic!("{path} is not an array"))
767            .iter()
768            .map(|block| {
769                let field = |name: &str| {
770                    block
771                        .get(name)
772                        .and_then(Value::as_str)
773                        .unwrap_or_default()
774                        .to_string()
775                };
776                (field("type"), field("text"))
777            })
778            .collect()
779    }
780
781    fn types(source: &str, path: &str) -> Vec<String> {
782        shape(source, path).into_iter().map(|(k, _)| k).collect()
783    }
784
785    // ---- the eight probes -------------------------------------------------
786    //
787    // These are the cases a hand-rolled line scanner answered wrong — all eight
788    // of them. Each names the CommonMark rule it turns on, and each is the
789    // classification a caller's layout policy then reads.
790
791    #[test]
792    fn probe_1_setext_heading_is_a_heading() {
793        // An underlined title is a heading; `^# ` never sees it.
794        let source = "Title\n=====\n\nThe lead.\n";
795        assert_eq!(text(source, "h1.0.text"), "Title");
796        assert_eq!(text(source, "h1.0.paragraph.0.text"), "The lead.");
797    }
798
799    #[test]
800    fn probe_2_html_comment_before_the_heading_is_its_own_block() {
801        // A generated-file banner is an HTML block. It lands in `preamble`,
802        // where a caller can see it, and does not shift the title.
803        let source = "<!-- generated -->\n\n# Real\n\nThe lead.\n";
804        assert_eq!(
805            shape(source, "preamble.blocks"),
806            [("html".to_string(), "<!-- generated -->".to_string())]
807        );
808        assert_eq!(text(source, "h1.0.text"), "Real");
809    }
810
811    #[test]
812    fn probe_3_badge_line_before_the_heading_is_a_paragraph() {
813        // The most common README opening there is. A flat reading would make
814        // this block 0 and publish `CI` as the project's name; here it is
815        // preamble and the title is still `h1.0`.
816        let source = "[![CI](a.svg)](b)\n\n# Real\n\nThe lead.\n";
817        assert_eq!(
818            shape(source, "preamble.blocks"),
819            [("paragraph".to_string(), "CI".to_string())]
820        );
821        assert_eq!(text(source, "h1.0.text"), "Real");
822        assert_eq!(text(source, "h1.0.paragraph.0.text"), "The lead.");
823    }
824
825    #[test]
826    fn probe_4_fenced_code_in_the_lead_position_is_code() {
827        // The section's first *block* is code, so `paragraph` is empty — a
828        // caller asking for a synopsis gets a failure, not the code.
829        let source = "# Real\n\n```bash\nafdata get x\n```\n";
830        assert_eq!(
831            shape(source, "h1.0.blocks"),
832            [("code".to_string(), "afdata get x".to_string())]
833        );
834        assert_eq!(shape(source, "h1.0.paragraph"), []);
835        assert_eq!(
836            at(source, "h1.0.paragraph.0").unwrap_err().code(),
837            "document_path_not_found"
838        );
839        assert_eq!(
840            at(source, "h1.0.blocks.0.language").unwrap(),
841            Value::String("bash".to_string())
842        );
843    }
844
845    #[test]
846    fn probe_5_leading_fence_swallows_the_heading_inside_it() {
847        // `# Real` is code here, not a heading — so there is no section at all
848        // and the whole file is preamble.
849        let source = "```\n# Real\n```\n";
850        assert_eq!(
851            shape(source, "preamble.blocks"),
852            [("code".to_string(), "# Real".to_string())]
853        );
854        assert_eq!(
855            at(source, "h1.0").unwrap_err().code(),
856            "document_path_not_found"
857        );
858    }
859
860    #[test]
861    fn probe_6_atx_heading_interrupts_a_paragraph() {
862        // The rule that looks backwards and is not: a `# ` line ends the
863        // paragraph it appears in rather than joining it. It also opens a
864        // sibling section, so the lead stops there too.
865        let source = "# Real\n\nThe lead.\n# looks like heading\nmore.\n";
866        assert_eq!(text(source, "h1.0.text"), "Real");
867        assert_eq!(
868            shape(source, "h1.0.paragraph"),
869            [("paragraph".to_string(), "The lead.".to_string())]
870        );
871        assert_eq!(text(source, "h1.1.text"), "looks like heading");
872        assert_eq!(text(source, "h1.1.paragraph.0.text"), "more.");
873    }
874
875    #[test]
876    fn probe_7_four_space_indent_is_code_not_a_heading() {
877        // The worst of the eight: an indented `# Title` reads as a title to a
878        // scanner that strips leading whitespace, and is code to CommonMark.
879        let source = "    # Indented\n\nAfter.\n";
880        assert_eq!(
881            types(source, "preamble.blocks"),
882            ["code".to_string(), "paragraph".to_string()]
883        );
884        assert_eq!(
885            at(source, "h1.0").unwrap_err().code(),
886            "document_path_not_found"
887        );
888    }
889
890    #[test]
891    fn probe_8_whole_paragraph_emphasis_is_unwrapped() {
892        // Flattening gives this for free; a scanner needs a special case for
893        // it, and still leaks the inner markers of the partial-emphasis form.
894        assert_eq!(
895            text("# Real\n\n**A bold tagline.**\n", "h1.0.paragraph.0.text"),
896            "A bold tagline."
897        );
898        // Partial emphasis, a code span, and a link all flatten to plain text
899        // too — the scanner leaked every one of these into published metadata.
900        assert_eq!(
901            text(
902                "# T\n\nA **bold** `span` and a [link](https://example.com).\n",
903                "h1.0.paragraph.0.text"
904            ),
905            "A bold span and a link."
906        );
907    }
908
909    // ---- section folding --------------------------------------------------
910
911    #[test]
912    fn headings_nest_by_level() {
913        let source = "# A\n\na.\n\n## B\n\nb.\n\n### C\n\nc.\n\n## D\n\nd.\n\n# E\n";
914        assert_eq!(text(source, "h1.0.text"), "A");
915        assert_eq!(text(source, "h1.0.paragraph.0.text"), "a.");
916        assert_eq!(text(source, "h1.0.h2.0.text"), "B");
917        assert_eq!(text(source, "h1.0.h2.0.h3.0.text"), "C");
918        assert_eq!(text(source, "h1.0.h2.0.h3.0.paragraph.0.text"), "c.");
919        // `D` closes `B` (same level) and `C` with it.
920        assert_eq!(text(source, "h1.0.h2.1.text"), "D");
921        assert_eq!(
922            at(source, "h1.0.h2.1.h3.0").unwrap_err().code(),
923            "document_path_not_found"
924        );
925        assert_eq!(text(source, "h1.1.text"), "E");
926    }
927
928    #[test]
929    fn a_skipped_level_keeps_its_own_name() {
930        // `### C` under `# A` with no `## ` between: C is A's child, filed by
931        // its own level rather than promoted to one it does not have.
932        let source = "# A\n\n### C\n\nc.\n";
933        assert_eq!(text(source, "h1.0.h3.0.text"), "C");
934        assert_eq!(
935            at(source, "h1.0.h2.0").unwrap_err().code(),
936            "document_path_not_found"
937        );
938    }
939
940    #[test]
941    fn a_document_opening_below_h1_has_no_h1() {
942        // No invented level-1 wrapper: `h2` is simply where the sections are,
943        // and a caller demanding `h1.0` fails loudly.
944        let source = "## Only\n\ntext.\n";
945        assert_eq!(text(source, "h2.0.text"), "Only");
946        assert_eq!(
947            at(source, "h1.0").unwrap_err().code(),
948            "document_path_not_found"
949        );
950    }
951
952    #[test]
953    fn preamble_is_always_present_and_empty_for_a_clean_file() {
954        assert_eq!(shape("# T\n\nlead\n", "preamble.blocks"), []);
955        assert_eq!(shape("", "preamble.blocks"), []);
956    }
957
958    // ---- addressing by content --------------------------------------------
959
960    #[test]
961    fn a_section_is_addressable_by_a_word_of_its_heading() {
962        let source = "# T\n\n## A Quick Look\n\ninside look.\n\n## Supported suffixes\n\ns.\n";
963        // Case-insensitive substring: the memorable word, not the full text.
964        assert_eq!(text(source, "h1.0.h2.look.text"), "A Quick Look");
965        assert_eq!(
966            text(source, "h1.0.h2.look.paragraph.0.text"),
967            "inside look."
968        );
969        assert_eq!(text(source, "h1.0.h2.SUFFIX.text"), "Supported suffixes");
970        // Content and index address the same section.
971        assert_eq!(
972            at(source, "h1.0.h2.look.text").unwrap(),
973            at(source, "h1.0.h2.0.text").unwrap()
974        );
975        // Matching a body paragraph does not leak in: `h2` holds only headings,
976        // which is the whole reason the tree is not flat.
977        assert_eq!(
978            at(source, "h1.0.h2.inside").unwrap_err().code(),
979            "document_slug_not_found"
980        );
981    }
982
983    #[test]
984    fn an_empty_segment_is_not_an_address() {
985        // `contains("")` is true for everything, so before this an empty
986        // interpolated name resolved to whichever element happened to be
987        // alone in the array — a confident wrong answer that only turned into
988        // an error once a second element existed.
989        let one = "# T\n\n## Only One\n\na\n";
990        let two = "# T\n\n## A\n\na\n\n## B\n\nb\n";
991        for source in [one, two] {
992            assert_eq!(
993                at(source, "h1.0.h2..text").unwrap_err().code(),
994                "document_slug_not_found"
995            );
996        }
997        // Named addressing still works.
998        assert_eq!(text(two, "h1.0.h2.A.text"), "A");
999    }
1000
1001    #[test]
1002    fn a_word_matching_several_sections_is_refused() {
1003        let source = "# T\n\n## Quick look\n\na.\n\n## Another look\n\nb.\n";
1004        let error = at(source, "h1.0.h2.look").unwrap_err();
1005        assert_eq!(error.code(), "document_ambiguous_match");
1006        // The refusal reports structural indices, never matched document text.
1007        let message = error.to_string();
1008        assert!(message.contains("indices 0, 1"), "{message}");
1009        assert!(!message.contains("Quick look"), "{message}");
1010        assert!(!message.contains("Another look"), "{message}");
1011        // A word that separates them resolves.
1012        assert_eq!(text(source, "h1.0.h2.Another.text"), "Another look");
1013    }
1014
1015    #[test]
1016    fn content_addressing_lowercases_unicode() {
1017        let source = "# T\n\n## Überblick\n\ninside.\n";
1018        assert_eq!(text(source, "h1.0.h2.ÜBER.text"), "Überblick");
1019    }
1020
1021    #[test]
1022    fn the_ask_prompt_blockquote_is_addressable_by_its_opening_words() {
1023        // The real use: this blockquote sits at no fixed index in any README,
1024        // and its position moves with every edit above it.
1025        let source = "# T\n\nThe lead.\n\n> **Ask your agent:** \"Do the thing.\"\n";
1026        assert_eq!(
1027            text(source, "h1.0.blocks.Ask your agent.text"),
1028            "Ask your agent: \"Do the thing.\""
1029        );
1030    }
1031
1032    // ---- inline flattening and block kinds --------------------------------
1033
1034    #[test]
1035    fn wrapped_paragraph_joins_onto_one_line() {
1036        assert_eq!(
1037            text("# T\n\nLead line one\nline two.\n", "h1.0.paragraph.0.text"),
1038            "Lead line one line two."
1039        );
1040    }
1041
1042    #[test]
1043    fn heading_level_is_reported_for_every_depth() {
1044        let source = "# a\n\n## b\n\n###### f\n";
1045        assert_eq!(at(source, "h1.0.level").unwrap(), Value::Integer(1));
1046        assert_eq!(at(source, "h1.0.h2.0.level").unwrap(), Value::Integer(2));
1047        assert_eq!(
1048            at(source, "h1.0.h2.0.h6.0.level").unwrap(),
1049            Value::Integer(6)
1050        );
1051    }
1052
1053    #[test]
1054    fn blockquote_flattens_its_paragraphs() {
1055        // One wrapped paragraph is one line, as the prompt convention needs.
1056        assert_eq!(
1057            shape(
1058                "> **Ask your agent:** \"Wrapped across\n> two lines.\"\n",
1059                "preamble.blocks"
1060            ),
1061            [(
1062                "blockquote".to_string(),
1063                "Ask your agent: \"Wrapped across two lines.\"".to_string()
1064            )]
1065        );
1066        // Two paragraphs are two blocks, and that boundary is structure, so it
1067        // survives as a newline rather than dissolving into a space.
1068        assert_eq!(
1069            shape("> first\n>\n> second\n", "preamble.blocks"),
1070            [("blockquote".to_string(), "first\nsecond".to_string())]
1071        );
1072    }
1073
1074    #[test]
1075    fn list_reports_its_items_and_whether_it_is_ordered() {
1076        let bullet = at("- one\n- two\n", "preamble.blocks.0").unwrap();
1077        assert_eq!(bullet.get("text").and_then(Value::as_str), Some("one\ntwo"));
1078        assert_eq!(bullet.get("ordered"), Some(&Value::Bool(false)));
1079
1080        assert_eq!(
1081            at("1. one\n2. two\n", "preamble.blocks.0")
1082                .unwrap()
1083                .get("ordered"),
1084            Some(&Value::Bool(true))
1085        );
1086
1087        // A loose list wraps each item in a paragraph and a tight one does
1088        // not. That is a rendering difference, not a content one, so the two
1089        // must read the same.
1090        assert_eq!(
1091            at("- one\n\n- two\n", "preamble.blocks.0")
1092                .unwrap()
1093                .get("text")
1094                .and_then(Value::as_str),
1095            Some("one\ntwo")
1096        );
1097
1098        // A nested list is a block inside its item, and flattens with it.
1099        assert_eq!(
1100            at("- one\n  - inner\n- two\n", "preamble.blocks.0")
1101                .unwrap()
1102                .get("text")
1103                .and_then(Value::as_str),
1104            Some("one\ninner\ntwo")
1105        );
1106    }
1107
1108    #[test]
1109    fn a_leading_metadata_block_is_its_own_kind() {
1110        // A Zola `_index.md`: the `+++` block used to flatten into two
1111        // paragraphs of the body, so `preamble` reported prose that was really
1112        // metadata and a caller reading "the first paragraph" got TOML.
1113        let toml = "+++\ntitle = \"T\"\n\n[extra]\ntagline = \"x\"\n+++\n\n# Real\n\nThe lead.\n";
1114        assert_eq!(types(toml, "preamble.blocks"), ["frontmatter".to_string()]);
1115        assert_eq!(
1116            at(toml, "preamble.blocks.0.format").unwrap(),
1117            Value::String("toml".to_string())
1118        );
1119        // Deliberately not copied into `text`: which fields it holds is TOML's
1120        // answer, and `--input-format toml-frontmatter` is how you get them.
1121        // This structural view must not bypass field-name-based redaction.
1122        assert_eq!(text(toml, "preamble.blocks.0.text"), "");
1123        // The body reads exactly as it would without the block.
1124        assert_eq!(text(toml, "h1.0.text"), "Real");
1125        assert_eq!(text(toml, "h1.0.paragraph.0.text"), "The lead.");
1126
1127        let yaml = "---\ntitle: T\n---\n\n# Real\n";
1128        assert_eq!(types(yaml, "preamble.blocks"), ["frontmatter".to_string()]);
1129        assert_eq!(
1130            at(yaml, "preamble.blocks.0.format").unwrap(),
1131            Value::String("yaml".to_string())
1132        );
1133        assert_eq!(text(yaml, "h1.0.text"), "Real");
1134    }
1135
1136    #[test]
1137    fn dashes_away_from_the_start_keep_their_commonmark_meaning() {
1138        // The metadata rule applies only at the very start of the file, so it
1139        // cannot reinterpret a `---` elsewhere. Both of these would break if
1140        // it did.
1141        assert_eq!(text("Setext\n---\n\nbody\n", "h2.0.text"), "Setext");
1142        assert_eq!(
1143            types("# T\n\na\n\n---\n\nb\n", "h1.0.blocks"),
1144            [
1145                "paragraph".to_string(),
1146                "rule".to_string(),
1147                "paragraph".to_string()
1148            ]
1149        );
1150    }
1151
1152    #[test]
1153    fn a_source_range_stops_at_the_block_it_names() {
1154        // pulldown-cmark runs a list's span on to where the next block starts,
1155        // so it carries the blank separator. A range is a splice instruction:
1156        // taking the separator out merges the blocks on either side. Here that
1157        // made `Lead.` and `Next Title\n====` one setext heading, swallowing
1158        // the lead paragraph into a title.
1159        let source = "- a\n  - b\n\n\nAfter.\n";
1160        assert_eq!(
1161            at(source, "preamble.blocks.0.type").unwrap(),
1162            Value::String("list".to_string())
1163        );
1164        assert_eq!(
1165            at(source, "preamble.blocks.0.source_start_line").unwrap(),
1166            Value::Integer(1)
1167        );
1168        assert_eq!(
1169            at(source, "preamble.blocks.0.source_end_line").unwrap(),
1170            Value::Integer(2)
1171        );
1172        assert_eq!(
1173            at(source, "preamble.blocks.1.source_start_line").unwrap(),
1174            Value::Integer(5)
1175        );
1176
1177        // Every other kind already ended at its own last line; assert they
1178        // still do, so the trim cannot over-correct.
1179        let mixed = "# H\n\npara\n\n```\ncode\n```\n\n> quote\n\n---\n\ntail\n";
1180        for (address, start, end) in [
1181            ("h1.0.blocks.0", 3, 3),
1182            ("h1.0.blocks.1", 5, 7),
1183            ("h1.0.blocks.2", 9, 9),
1184            ("h1.0.blocks.3", 11, 11),
1185            ("h1.0.blocks.4", 13, 13),
1186        ] {
1187            assert_eq!(
1188                at(mixed, &format!("{address}.source_start_line")).unwrap(),
1189                Value::Integer(start),
1190                "{address} start"
1191            );
1192            assert_eq!(
1193                at(mixed, &format!("{address}.source_end_line")).unwrap(),
1194                Value::Integer(end),
1195                "{address} end"
1196            );
1197        }
1198    }
1199
1200    #[test]
1201    fn gfm_table_rows_are_a_paragraph() {
1202        // No extensions: a table is not a block kind here, and the pipe rows
1203        // are a paragraph. That is the specification's reading, not a gap.
1204        assert_eq!(
1205            shape("| a | b |\n|---|---|\n| 1 | 2 |\n", "preamble.blocks"),
1206            [(
1207                "paragraph".to_string(),
1208                "| a | b | |---|---| | 1 | 2 |".to_string()
1209            )]
1210        );
1211        assert_eq!(
1212            shape("| a | b |\n|---|---|\n| 1 | 2 |\n", "preamble.paragraph"),
1213            [(
1214                "paragraph".to_string(),
1215                "| a | b | |---|---| | 1 | 2 |".to_string()
1216            )]
1217        );
1218    }
1219
1220    #[test]
1221    fn badge_syntax_remains_in_the_paragraph_view() {
1222        let source = "# T\n\n[![CI](a.svg)](b)\n\nThe lead.\n";
1223        assert_eq!(
1224            shape(source, "h1.0.paragraph"),
1225            [
1226                ("paragraph".to_string(), "CI".to_string()),
1227                ("paragraph".to_string(), "The lead.".to_string()),
1228            ]
1229        );
1230    }
1231
1232    // ---- source line ranges ----------------------------------------------
1233
1234    #[test]
1235    fn atx_heading_and_blocks_report_inclusive_source_lines() {
1236        let source = "# Title\n\nLead line one\nline two.\n\n```rs\nfn main() {}\n```\n";
1237        assert_eq!(integer(source, "h1.0.source_start_line"), 1);
1238        assert_eq!(integer(source, "h1.0.source_end_line"), 8);
1239        assert_eq!(integer(source, "h1.0.source_start_line"), 1);
1240        assert_eq!(integer(source, "h1.0.heading_end_line"), 1);
1241        assert_eq!(integer(source, "h1.0.paragraph.0.source_start_line"), 3);
1242        assert_eq!(integer(source, "h1.0.paragraph.0.source_end_line"), 4);
1243        assert_eq!(integer(source, "h1.0.blocks.1.source_start_line"), 6);
1244        assert_eq!(integer(source, "h1.0.blocks.1.source_end_line"), 8);
1245    }
1246
1247    #[test]
1248    fn setext_heading_range_includes_its_underline() {
1249        let source = "My Project\n==========\n\nThe synopsis.\n\n## Install\n";
1250        assert_eq!(integer(source, "h1.0.source_start_line"), 1);
1251        assert_eq!(integer(source, "h1.0.heading_end_line"), 2);
1252        assert_eq!(integer(source, "h1.0.source_end_line"), 6);
1253        assert_eq!(integer(source, "h1.0.h2.0.source_start_line"), 6);
1254        assert_eq!(integer(source, "h1.0.h2.0.source_end_line"), 6);
1255        assert_eq!(integer(source, "h1.0.paragraph.0.source_start_line"), 4);
1256        assert_eq!(integer(source, "h1.0.paragraph.0.source_end_line"), 4);
1257    }
1258
1259    #[test]
1260    fn line_ranges_are_utf8_safe_and_newline_style_independent() {
1261        let source = "# 中文标题\r\n\r\n这是首段,\r\n也是首段。\r\n\r\n## 安装";
1262        assert_eq!(integer(source, "h1.0.source_start_line"), 1);
1263        assert_eq!(integer(source, "h1.0.heading_end_line"), 1);
1264        assert_eq!(integer(source, "h1.0.paragraph.0.source_start_line"), 3);
1265        assert_eq!(integer(source, "h1.0.paragraph.0.source_end_line"), 4);
1266        assert_eq!(integer(source, "h1.0.h2.0.source_start_line"), 6);
1267        assert_eq!(integer(source, "h1.0.h2.0.heading_end_line"), 6);
1268    }
1269
1270    #[test]
1271    fn nesting_beyond_the_block_depth_is_refused_not_a_stack_overflow() {
1272        let nested = |depth: usize| format!("# T\n{}x\n", "> ".repeat(depth));
1273        // The heading closes first, so the innermost paragraph adds one level.
1274        assert_eq!(text(&nested(127), "h1.0.blockquote.0.text"), "x");
1275        for depth in [128, 100_000] {
1276            let error = load(&nested(depth)).unwrap_err();
1277            assert_eq!(error.code(), "document_source_refused");
1278            assert!(error.to_string().contains("128 levels"), "{error}");
1279        }
1280        let error = load(&format!("{}x\n", "- ".repeat(100_000))).unwrap_err();
1281        assert_eq!(error.code(), "document_source_refused");
1282    }
1283
1284    #[test]
1285    fn a_bare_carriage_return_is_refused_rather_than_numbered() {
1286        // CommonMark ends a line at a bare `\r`; sed, awk, wc, head, and git
1287        // do not. These numbers exist to be handed to those, so for such a
1288        // file no single number is right for both readers — here `Lead` and
1289        // `## End` would share a line, and a splice by that number would cut
1290        // the wrong text silently.
1291        let error = load("# T\r\rLead\rcontinued\r\r## End").unwrap_err();
1292        // Its own code, not `document_parse_failed`: the file is valid
1293        // CommonMark and afdata is declining to number it, so a reader told
1294        // "parse failed" would go hunting for a syntax error that is not there.
1295        assert_eq!(error.code(), "document_source_refused");
1296        assert!(error.to_string().contains("carriage return"), "{error}");
1297        // The way out survives redaction. This detail is written here, about
1298        // the file's line endings, and holds no document text — dropping it
1299        // left `failed to parse Markdown` and nothing to act on.
1300        assert!(
1301            error.redacted_message().contains("CRLF line endings"),
1302            "{}",
1303            error.redacted_message()
1304        );
1305
1306        // The endings every current tool writes are unaffected, and both
1307        // rules agree on them.
1308        let crlf = "# T\r\n\r\nLead\r\ncontinued\r\n\r\n## End\r\n";
1309        assert_eq!(integer(crlf, "h1.0.paragraph.0.source_start_line"), 3);
1310        assert_eq!(integer(crlf, "h1.0.paragraph.0.source_end_line"), 4);
1311        assert_eq!(integer(crlf, "h1.0.h2.0.source_start_line"), 6);
1312
1313        // No final newline still numbers its last line.
1314        let bare = "# T\n\nLead";
1315        assert_eq!(integer(bare, "h1.0.paragraph.0.source_end_line"), 3);
1316    }
1317
1318    #[test]
1319    fn frontmatter_range_includes_both_delimiters() {
1320        let source = "---\ntitle: T\nnested:\n  token_secret: hidden\n---\n\n# T\n";
1321        assert_eq!(integer(source, "preamble.blocks.0.source_start_line"), 1);
1322        assert_eq!(integer(source, "preamble.blocks.0.source_end_line"), 5);
1323        assert_eq!(text(source, "preamble.blocks.0.text"), "");
1324    }
1325
1326    #[test]
1327    fn thematic_break_is_a_block_with_no_text() {
1328        assert_eq!(
1329            shape("# T\n\na\n\n---\n\nb\n", "h1.0.blocks"),
1330            [
1331                ("paragraph".to_string(), "a".to_string()),
1332                ("rule".to_string(), String::new()),
1333                ("paragraph".to_string(), "b".to_string()),
1334            ]
1335        );
1336        // The paragraph view skips it, so "the second paragraph" stays the
1337        // second paragraph.
1338        assert_eq!(
1339            shape("# T\n\na\n\n---\n\nb\n", "h1.0.paragraph"),
1340            [
1341                ("paragraph".to_string(), "a".to_string()),
1342                ("paragraph".to_string(), "b".to_string()),
1343            ]
1344        );
1345    }
1346
1347    #[test]
1348    fn a_byte_order_mark_makes_the_first_block_a_paragraph() {
1349        // CommonMark does not strip a BOM, so `\u{feff}# Title` is not a
1350        // heading. Pinned because a Windows editor writes one silently: the
1351        // file then has no section at all, which fails loudly — the outcome to
1352        // keep, against publishing "\u{feff}Title" as a name.
1353        assert_eq!(
1354            types("\u{feff}# Title\n\nlead\n", "preamble.blocks"),
1355            ["paragraph".to_string(), "paragraph".to_string()]
1356        );
1357    }
1358
1359    #[test]
1360    fn empty_document_has_no_blocks() {
1361        assert_eq!(shape("", "preamble.blocks"), []);
1362        assert_eq!(shape("\n\n   \n", "preamble.blocks"), []);
1363    }
1364
1365    #[test]
1366    fn code_block_keeps_its_lines_and_drops_one_trailing_newline() {
1367        assert_eq!(
1368            shape("```\nline one\nline two\n```\n", "preamble.blocks"),
1369            [("code".to_string(), "line one\nline two".to_string())]
1370        );
1371    }
1372}