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