Skip to main content

agent_plugins/
markdown.rs

1//! The skill body as a *typed document*, not a blob of text.
2//!
3//! Agent Skills load progressively: metadata first, the `SKILL.md` body on
4//! activation, referenced files only when needed. A client that can address
5//! *part* of a body — "the Rollback section", "everything under Deploy" —
6//! carries that idea one level further. So the body is parsed once into a
7//! [`Document`]: a preamble plus a tree of [`Section`]s, each addressable by
8//! heading path or by [`Slug`], each knowing its own span of the source.
9//!
10//! The parse is faithful about the two things that actually bite:
11//!
12//! * **Fenced code is inert.** A `# comment` inside a ``` fence is a comment,
13//!   not a heading — and skill bodies are full of shell snippets.
14//! * **Nesting follows heading level**, so a section's span covers its
15//!   subsections and ends exactly where a sibling or shallower heading begins.
16//!
17//! ```
18//! use agent_plugins::Document;
19//!
20//! let doc = Document::parse("Intro.\n\n# Deploy\n\nShip it.\n\n## Rollback\n\nUndo it.\n");
21//! assert_eq!(doc.preamble().trim(), "Intro.");
22//!
23//! let deploy = doc.section(["Deploy"]).unwrap();
24//! assert_eq!(deploy.body().trim(), "Ship it.");     // its own prose
25//! assert!(deploy.content().contains("Undo it."));    // prose and subsections
26//!
27//! let rollback = doc.find("rollback").unwrap();
28//! assert_eq!(rollback.level().get(), 2);
29//! ```
30
31use std::fmt;
32use std::ops::Range;
33use std::sync::Arc;
34
35/// A heading depth: 1–6, as Markdown defines and no more.
36///
37/// The type exists so a depth cannot be conjured from an arbitrary integer;
38/// [`HeadingLevel::new`] is the only door in.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
40pub struct HeadingLevel(u8);
41
42impl HeadingLevel {
43    /// `#`, the shallowest heading.
44    pub const H1: Self = Self(1);
45    /// `##`.
46    pub const H2: Self = Self(2);
47
48    /// Construct a level, or `None` outside 1–6.
49    pub const fn new(level: u8) -> Option<Self> {
50        if level >= 1 && level <= 6 {
51            Some(Self(level))
52        } else {
53            None
54        }
55    }
56
57    /// The depth as a number.
58    pub const fn get(self) -> u8 {
59        self.0
60    }
61}
62
63impl fmt::Display for HeadingLevel {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        write!(f, "h{}", self.0)
66    }
67}
68
69/// A URL-safe identifier derived from heading text, unique within a document.
70///
71/// Lowercased, non-alphanumeric runs become hyphens, collisions gain a
72/// numeric suffix — the anchor convention readers already expect.
73/// [`Document::find`] takes one.
74#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
75pub struct Slug(String);
76
77impl Slug {
78    /// The slug text.
79    pub fn as_str(&self) -> &str {
80        &self.0
81    }
82}
83
84impl fmt::Display for Slug {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        f.write_str(&self.0)
87    }
88}
89
90impl AsRef<str> for Slug {
91    fn as_ref(&self) -> &str {
92        &self.0
93    }
94}
95
96impl PartialEq<str> for Slug {
97    fn eq(&self, other: &str) -> bool {
98        self.0 == other
99    }
100}
101
102/// Where a link points, once classified.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum LinkTarget {
105    /// A relative path inside the skill directory — `references/runbook.md`,
106    /// `scripts/deploy.sh`. These are the progressive-disclosure resources.
107    InPackage(String),
108    /// An absolute URL with a scheme, or a protocol-relative one.
109    External(String),
110    /// A same-document anchor, `#section`.
111    Fragment(String),
112}
113
114impl LinkTarget {
115    /// The target exactly as written, minus any `#` for fragments.
116    pub fn as_str(&self) -> &str {
117        match self {
118            Self::InPackage(t) | Self::External(t) | Self::Fragment(t) => t,
119        }
120    }
121}
122
123/// An inline Markdown link, `[text](target)`.
124#[derive(Debug, Clone, PartialEq, Eq)]
125#[non_exhaustive]
126pub struct Link {
127    /// The link text.
128    pub text: String,
129    /// Where it points.
130    pub target: LinkTarget,
131}
132
133/// One heading and everything beneath it.
134///
135/// A section carries a handle to its document's source, so `body()` and
136/// `content()` work wherever the section travels.
137#[derive(Clone)]
138pub struct Section {
139    source: Arc<str>,
140    level: HeadingLevel,
141    heading: String,
142    slug: Slug,
143    /// Heading line through the end of all nested content.
144    span: Range<usize>,
145    /// Prose between this heading and its first subsection.
146    body: Range<usize>,
147    children: Vec<Section>,
148}
149
150impl Section {
151    /// The heading depth.
152    pub fn level(&self) -> HeadingLevel {
153        self.level
154    }
155
156    /// The heading text, without `#` markers.
157    pub fn heading(&self) -> &str {
158        &self.heading
159    }
160
161    /// The document-unique slug.
162    pub fn slug(&self) -> &Slug {
163        &self.slug
164    }
165
166    /// Immediate subsections.
167    pub fn children(&self) -> &[Section] {
168        &self.children
169    }
170
171    /// Whether this section has no subsections.
172    pub fn is_leaf(&self) -> bool {
173        self.children.is_empty()
174    }
175
176    /// Byte range of the whole section within the document source.
177    pub fn span(&self) -> Range<usize> {
178        self.span.clone()
179    }
180
181    /// Prose directly under this heading, excluding subsections.
182    pub fn body(&self) -> &str {
183        &self.source[self.body.clone()]
184    }
185
186    /// Heading, prose, and every subsection — the slice to hand a model when
187    /// it needs one part of a long skill.
188    pub fn content(&self) -> &str {
189        &self.source[self.span.clone()]
190    }
191
192    /// This section and its descendants, depth-first, with depth relative to
193    /// this section (0 for itself).
194    pub fn walk(&self) -> impl Iterator<Item = (usize, &Section)> {
195        Walk { stack: vec![(0, self)] }
196    }
197
198    /// Inline links appearing anywhere within this section.
199    pub fn links(&self) -> Vec<Link> {
200        collect_links(self.content())
201    }
202}
203
204impl fmt::Debug for Section {
205    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206        f.debug_struct("Section")
207            .field("level", &self.level)
208            .field("heading", &self.heading)
209            .field("slug", &self.slug)
210            .field("children", &self.children)
211            .finish_non_exhaustive()
212    }
213}
214
215impl PartialEq for Section {
216    fn eq(&self, other: &Self) -> bool {
217        self.level == other.level
218            && self.heading == other.heading
219            && self.slug == other.slug
220            && self.content() == other.content()
221            && self.children == other.children
222    }
223}
224
225impl Eq for Section {}
226
227/// A parsed Markdown document: a preamble and a tree of sections.
228#[derive(Clone, PartialEq, Eq)]
229pub struct Document {
230    source: Arc<str>,
231    preamble: Range<usize>,
232    sections: Vec<Section>,
233}
234
235impl Document {
236    /// Parse Markdown into a navigable document. Always succeeds: text with
237    /// no headings is simply all preamble.
238    pub fn parse(source: impl AsRef<str>) -> Self {
239        let source: Arc<str> = Arc::from(source.as_ref());
240        let headings = scan_headings(&source);
241        let preamble_end = headings.first().map_or(source.len(), |h| h.line.start);
242        let sections = build_tree(&source, &headings);
243        Self { source, preamble: 0..preamble_end, sections }
244    }
245
246    /// The document text exactly as given.
247    pub fn source(&self) -> &str {
248        &self.source
249    }
250
251    /// Content before the first heading. Skills often open with a sentence
252    /// of orientation; this is it.
253    pub fn preamble(&self) -> &str {
254        &self.source[self.preamble.clone()]
255    }
256
257    /// Top-level sections, in document order.
258    pub fn sections(&self) -> &[Section] {
259        &self.sections
260    }
261
262    /// Whether the document has no headings at all.
263    pub fn is_flat(&self) -> bool {
264        self.sections.is_empty()
265    }
266
267    /// Every section, depth-first, paired with its nesting depth (0 at the
268    /// top) — the shape of a table of contents.
269    ///
270    /// ```
271    /// # use agent_plugins::Document;
272    /// let doc = Document::parse("# A\n## B\n# C\n");
273    /// let toc: Vec<_> = doc.outline().map(|(d, s)| (d, s.heading())).collect();
274    /// assert_eq!(toc, [(0, "A"), (1, "B"), (0, "C")]);
275    /// ```
276    pub fn outline(&self) -> impl Iterator<Item = (usize, &Section)> {
277        Walk { stack: self.sections.iter().rev().map(|s| (0, s)).collect() }
278    }
279
280    /// Find a section by slug, at any depth.
281    pub fn find(&self, slug: &str) -> Option<&Section> {
282        self.outline().map(|(_, s)| s).find(|s| s.slug == *slug)
283    }
284
285    /// Follow a path of heading texts from the top: `["Deploy", "Rollback"]`
286    /// finds `Rollback` nested under `Deploy`. Matching ignores case and
287    /// surrounding whitespace.
288    pub fn section<'a>(&self, path: impl IntoIterator<Item = &'a str>) -> Option<&Section> {
289        let mut level = &self.sections;
290        let mut found = None;
291        for step in path {
292            let step = step.trim();
293            let next = level.iter().find(|s| s.heading.trim().eq_ignore_ascii_case(step))?;
294            level = &next.children;
295            found = Some(next);
296        }
297        found
298    }
299
300    /// Every inline link in the document, in order. Targets classified as
301    /// [`LinkTarget::InPackage`] are the skill's on-demand resources.
302    pub fn links(&self) -> Vec<Link> {
303        collect_links(&self.source)
304    }
305}
306
307impl fmt::Debug for Document {
308    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
309        f.debug_struct("Document")
310            .field("preamble", &self.preamble())
311            .field("sections", &self.sections)
312            .finish()
313    }
314}
315
316impl fmt::Display for Document {
317    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
318        f.write_str(&self.source)
319    }
320}
321
322struct Walk<'a> {
323    stack: Vec<(usize, &'a Section)>,
324}
325
326impl<'a> Iterator for Walk<'a> {
327    type Item = (usize, &'a Section);
328
329    fn next(&mut self) -> Option<Self::Item> {
330        let (depth, section) = self.stack.pop()?;
331        for child in section.children.iter().rev() {
332            self.stack.push((depth + 1, child));
333        }
334        Some((depth, section))
335    }
336}
337
338struct RawHeading {
339    level: HeadingLevel,
340    text: String,
341    slug: String,
342    /// Byte range of the heading line (or, for setext, both its lines).
343    line: Range<usize>,
344}
345
346/// Find every ATX (`#`) and setext (`===`/`---` underline) heading outside
347/// fenced code blocks.
348fn scan_headings(source: &str) -> Vec<RawHeading> {
349    let mut headings: Vec<RawHeading> = Vec::new();
350    let mut slugs: Vec<String> = Vec::new();
351    let mut fence: Option<(char, usize)> = None;
352    let mut offset = 0;
353    // The previous line, as a candidate setext heading text.
354    let mut previous: Option<(Range<usize>, String)> = None;
355
356    for line in source.split_inclusive('\n') {
357        let end = offset + line.len();
358        let trimmed = line.trim_end_matches(['\n', '\r']);
359        let content = trimmed.trim_start();
360        let indent = trimmed.len() - content.len();
361
362        // Fences open and close only at four-or-fewer spaces of indentation.
363        if indent <= 3 {
364            if let Some((marker, open_len)) = fence {
365                let run = content.chars().take_while(|c| *c == marker).count();
366                if run >= open_len && content.trim_end().chars().all(|c| c == marker) {
367                    fence = None;
368                }
369                offset = end;
370                previous = None;
371                continue;
372            }
373            if let Some(marker) = content.chars().next().filter(|c| *c == '`' || *c == '~') {
374                let run = content.chars().take_while(|c| *c == marker).count();
375                if run >= 3 {
376                    fence = Some((marker, run));
377                    offset = end;
378                    previous = None;
379                    continue;
380                }
381            }
382        } else if fence.is_some() {
383            offset = end;
384            previous = None;
385            continue;
386        }
387
388        let mut found: Option<(HeadingLevel, String, Range<usize>)> = None;
389
390        if indent <= 3 && content.starts_with('#') {
391            let hashes = content.chars().take_while(|c| *c == '#').count();
392            let rest = &content[hashes..];
393            if let Some(level) = HeadingLevel::new(hashes as u8) {
394                if rest.is_empty() || rest.starts_with([' ', '\t']) {
395                    let text = rest.trim().trim_end_matches('#').trim().to_owned();
396                    found = Some((level, text, offset..end));
397                }
398            }
399        } else if indent <= 3 && !content.is_empty() {
400            // Setext: an underline of `=` or `-` beneath a non-blank line.
401            if let Some(marker) = content.chars().next().filter(|c| *c == '=' || *c == '-') {
402                if content.trim_end().chars().all(|c| c == marker) {
403                    if let Some((prev_span, prev_text)) = previous.take() {
404                        let level = if marker == '=' { HeadingLevel::H1 } else { HeadingLevel::H2 };
405                        found = Some((level, prev_text, prev_span.start..end));
406                    }
407                }
408            }
409        }
410
411        match found {
412            Some((level, text, span)) => {
413                let slug = slugify(&text, &slugs);
414                slugs.push(slug.clone());
415                headings.push(RawHeading { level, text, slug, line: span });
416                previous = None;
417            }
418            None => {
419                previous = (!content.is_empty()).then(|| (offset..end, content.trim().to_owned()));
420            }
421        }
422        offset = end;
423    }
424
425    headings
426}
427
428/// Assemble a section tree from the flat heading list.
429///
430/// A stack holds the currently open sections. Each new heading closes every
431/// open section at its level or deeper — that closing point is exactly where
432/// the closed section's span ends.
433fn build_tree(source: &Arc<str>, headings: &[RawHeading]) -> Vec<Section> {
434    struct Open {
435        level: HeadingLevel,
436        heading: String,
437        slug: String,
438        start: usize,
439        body_start: usize,
440        children: Vec<Section>,
441    }
442
443    let mut roots: Vec<Section> = Vec::new();
444    let mut stack: Vec<Open> = Vec::new();
445
446    let close = |open: Open, end: usize, source: &Arc<str>| Section {
447        source: Arc::clone(source),
448        level: open.level,
449        heading: open.heading,
450        slug: Slug(open.slug),
451        span: open.start..end,
452        // Prose runs from the heading line to the first subsection, or to
453        // the end of the section when there are none.
454        body: open.body_start..open.children.first().map_or(end, |c| c.span.start),
455        children: open.children,
456    };
457
458    for head in headings {
459        while stack.last().is_some_and(|open| open.level >= head.level) {
460            let open = stack.pop().expect("checked by the condition");
461            let section = close(open, head.line.start, source);
462            match stack.last_mut() {
463                Some(parent) => parent.children.push(section),
464                None => roots.push(section),
465            }
466        }
467        stack.push(Open {
468            level: head.level,
469            heading: head.text.clone(),
470            slug: head.slug.clone(),
471            start: head.line.start,
472            body_start: head.line.end,
473            children: Vec::new(),
474        });
475    }
476
477    while let Some(open) = stack.pop() {
478        let section = close(open, source.len(), source);
479        match stack.last_mut() {
480            Some(parent) => parent.children.push(section),
481            None => roots.push(section),
482        }
483    }
484
485    roots
486}
487
488/// GitHub-style anchor: lowercase, non-alphanumeric runs to hyphens,
489/// collisions numbered.
490fn slugify(text: &str, taken: &[String]) -> String {
491    let mut base = String::with_capacity(text.len());
492    let mut pending_hyphen = false;
493    for c in text.chars() {
494        if c.is_alphanumeric() {
495            if pending_hyphen && !base.is_empty() {
496                base.push('-');
497            }
498            pending_hyphen = false;
499            base.extend(c.to_lowercase());
500        } else {
501            pending_hyphen = true;
502        }
503    }
504    if base.is_empty() {
505        base.push_str("section");
506    }
507    if !taken.contains(&base) {
508        return base;
509    }
510    (1..)
511        .map(|n| format!("{base}-{n}"))
512        .find(|candidate| !taken.contains(candidate))
513        .expect("an unused suffix always exists")
514}
515
516/// Collect inline `[text](target)` links outside fenced code and code spans.
517fn collect_links(source: &str) -> Vec<Link> {
518    let mut links = Vec::new();
519    let mut fence: Option<(char, usize)> = None;
520
521    for line in source.split_inclusive('\n') {
522        let content = line.trim_end_matches(['\n', '\r']).trim_start();
523        if let Some((marker, open_len)) = fence {
524            let run = content.chars().take_while(|c| *c == marker).count();
525            if run >= open_len && content.trim_end().chars().all(|c| c == marker) {
526                fence = None;
527            }
528            continue;
529        }
530        if let Some(marker) = content.chars().next().filter(|c| *c == '`' || *c == '~') {
531            if content.chars().take_while(|c| *c == marker).count() >= 3 {
532                fence = Some((marker, content.chars().take_while(|c| *c == marker).count()));
533                continue;
534            }
535        }
536        links.extend(links_in_line(content));
537    }
538    links
539}
540
541fn links_in_line(line: &str) -> Vec<Link> {
542    let chars: Vec<char> = line.chars().collect();
543    let mut links = Vec::new();
544    let mut in_code_span = false;
545    let mut i = 0;
546    while i < chars.len() {
547        match chars[i] {
548            '`' => in_code_span = !in_code_span,
549            '[' if !in_code_span && (i == 0 || chars[i - 1] != '\\') => {
550                if let Some((text, target, next)) = parse_link(&chars, i) {
551                    links.push(Link { text, target: classify(target) });
552                    i = next;
553                    continue;
554                }
555            }
556            _ => {}
557        }
558        i += 1;
559    }
560    links
561}
562
563/// Parse `[text](target)` starting at `open`, tolerating nested brackets in
564/// the text. Returns the text, the target, and the index just past the `)`.
565fn parse_link(chars: &[char], open: usize) -> Option<(String, String, usize)> {
566    let mut depth = 0usize;
567    let mut close = None;
568    for (offset, c) in chars.iter().enumerate().skip(open) {
569        match c {
570            '[' => depth += 1,
571            ']' => {
572                depth -= 1;
573                if depth == 0 {
574                    close = Some(offset);
575                    break;
576                }
577            }
578            _ => {}
579        }
580    }
581    let close = close?;
582    if chars.get(close + 1) != Some(&'(') {
583        return None;
584    }
585    let target_end = chars[close + 2..].iter().position(|c| *c == ')')? + close + 2;
586    let text: String = chars[open + 1..close].iter().collect();
587    let raw_target: String = chars[close + 2..target_end].iter().collect();
588    // Drop an optional title: [text](target "title")
589    let target = raw_target
590        .split_once(char::is_whitespace)
591        .map_or(raw_target.as_str(), |(t, _)| t)
592        .trim_matches(['<', '>'])
593        .to_owned();
594    if target.is_empty() {
595        return None;
596    }
597    Some((text, target, target_end + 1))
598}
599
600fn classify(target: String) -> LinkTarget {
601    if let Some(fragment) = target.strip_prefix('#') {
602        return LinkTarget::Fragment(fragment.to_owned());
603    }
604    let has_scheme = target.split_once(':').is_some_and(|(scheme, _)| {
605        scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
606            && scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
607    });
608    if has_scheme || target.starts_with("//") {
609        LinkTarget::External(target)
610    } else {
611        LinkTarget::InPackage(target)
612    }
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618
619    #[test]
620    fn nesting_and_spans() {
621        let doc = Document::parse(
622            "Preamble.\n\n# One\n\nalpha\n\n## One-A\n\nbeta\n\n## One-B\n\ngamma\n\n# Two\n\ndelta\n",
623        );
624        assert_eq!(doc.preamble().trim(), "Preamble.");
625        assert_eq!(doc.sections().len(), 2);
626
627        let one = &doc.sections()[0];
628        assert_eq!(one.heading(), "One");
629        assert_eq!(one.body().trim(), "alpha");
630        assert!(one.content().contains("beta") && one.content().contains("gamma"));
631        assert!(!one.content().contains("delta"));
632        assert_eq!(one.children().len(), 2);
633        assert_eq!(one.children()[0].body().trim(), "beta");
634
635        let two = &doc.sections()[1];
636        assert_eq!(two.heading(), "Two");
637        assert_eq!(two.body().trim(), "delta");
638        assert!(two.is_leaf());
639    }
640
641    #[test]
642    fn headings_inside_fences_are_inert() {
643        let doc = Document::parse(
644            "# Real\n\n```sh\n# not a heading\necho hi\n```\n\n~~~\n## also not\n~~~\n\n## Real Two\n",
645        );
646        let headings: Vec<_> = doc.outline().map(|(_, s)| s.heading()).collect();
647        assert_eq!(headings, ["Real", "Real Two"]);
648    }
649
650    #[test]
651    fn setext_headings() {
652        let doc = Document::parse("Title\n=====\n\nbody\n\nSub\n---\n\nmore\n");
653        assert_eq!(doc.sections().len(), 1);
654        let title = &doc.sections()[0];
655        assert_eq!(title.heading(), "Title");
656        assert_eq!(title.level(), HeadingLevel::H1);
657        assert_eq!(title.children()[0].heading(), "Sub");
658        assert_eq!(title.children()[0].level(), HeadingLevel::H2);
659    }
660
661    #[test]
662    fn navigation_by_path_and_slug() {
663        let doc = Document::parse("# Deploy\n## Roll Back!\ntext\n");
664        assert_eq!(doc.section(["deploy", "roll back!"]).unwrap().heading(), "Roll Back!");
665        assert_eq!(doc.find("roll-back").unwrap().body().trim(), "text");
666        assert!(doc.section(["Deploy", "Nope"]).is_none());
667    }
668
669    #[test]
670    fn duplicate_headings_get_distinct_slugs() {
671        let doc = Document::parse("# Notes\n# Notes\n# Notes\n");
672        let slugs: Vec<_> = doc.outline().map(|(_, s)| s.slug().as_str().to_owned()).collect();
673        assert_eq!(slugs, ["notes", "notes-1", "notes-2"]);
674    }
675
676    #[test]
677    fn skipped_levels_still_nest() {
678        let doc = Document::parse("# A\n### Deep\n## B\n");
679        let a = &doc.sections()[0];
680        assert_eq!(a.children().len(), 2);
681        assert_eq!(a.children()[0].heading(), "Deep");
682        assert_eq!(a.children()[1].heading(), "B");
683    }
684
685    #[test]
686    fn links_are_classified() {
687        let doc = Document::parse(
688            "# Refs\n\nSee [the runbook](references/runbook.md) and \
689             [home](https://example.com) and [top](#refs).\n\n\
690             ```\n[not a link](nope.md)\n```\n\nInline `[skip](me.md)` code.\n",
691        );
692        let links = doc.links();
693        assert_eq!(links.len(), 3);
694        assert_eq!(links[0].target, LinkTarget::InPackage("references/runbook.md".into()));
695        assert_eq!(links[0].text, "the runbook");
696        assert_eq!(links[1].target, LinkTarget::External("https://example.com".into()));
697        assert_eq!(links[2].target, LinkTarget::Fragment("refs".into()));
698    }
699
700    #[test]
701    fn documents_without_headings_are_all_preamble() {
702        let doc = Document::parse("just prose\n");
703        assert!(doc.is_flat());
704        assert_eq!(doc.preamble(), "just prose\n");
705        assert_eq!(doc.outline().count(), 0);
706    }
707
708    #[test]
709    fn heading_level_is_bounded() {
710        assert_eq!(HeadingLevel::new(0), None);
711        assert_eq!(HeadingLevel::new(7), None);
712        assert_eq!(HeadingLevel::new(6).map(HeadingLevel::get), Some(6));
713        // Seven hashes is not a heading at all.
714        let doc = Document::parse("####### nope\n");
715        assert!(doc.is_flat());
716    }
717}