Skip to main content

varar_core/
structurer.rs

1//! Groups the flat scanner output into [`Example`]s, tracking a heading scope
2//! stack — port of `structurer.ts` / `Structurer.java`.
3
4use crate::ast::{Block, Doc, Example, Heading, TableOrFence};
5use crate::span::Span;
6
7/// Groups `blocks` (scanned from `source`) into a [`Doc`].
8///
9/// This is pure syntax — it does NOT decide where one example ends and the next
10/// begins. Instead each candidate records `preceded_by_delimiter` (a heading or
11/// `---` sits before it), and the planner groups adjacent matching candidates
12/// into examples using that flag plus which candidates match a step. See ADR
13/// 0012.
14pub fn structure(path: &str, source: &str, blocks: Vec<Block>) -> Doc {
15    let mut examples: Vec<Example> = Vec::new();
16    let mut orphan_attachments: Vec<TableOrFence> = Vec::new();
17    let mut headings: Vec<Heading> = Vec::new();
18    let mut scope_stack: Vec<(usize, String)> = Vec::new();
19    let mut last_example_idx: Option<usize> = None;
20    let mut attachment_open = false;
21    // A heading or thematic break seen since the previous candidate — the next
22    // candidate is then delimiter-preceded. Starts true so the first candidate in
23    // the file counts as delimiter-preceded (nothing to merge into).
24    let mut delimiter_pending = true;
25
26    for block in blocks {
27        match &block {
28            Block::Heading(heading) => {
29                // Pop deeper-or-equal-level entries before pushing the new heading.
30                while scope_stack.last().is_some_and(|e| e.0 >= heading.level) {
31                    scope_stack.pop();
32                }
33                scope_stack.push((heading.level, heading.text.clone()));
34                headings.push(heading.clone());
35                attachment_open = false;
36                delimiter_pending = true;
37            }
38            Block::Paragraph(_) | Block::ListItem(_) | Block::Blockquote(_) => {
39                let block_span = block.span();
40                examples.push(Example {
41                    scope_stack: scope_texts(&scope_stack),
42                    span: block_span,
43                    body: vec![block],
44                    preceded_by_delimiter: delimiter_pending,
45                });
46                last_example_idx = Some(examples.len() - 1);
47                attachment_open = true;
48                delimiter_pending = false;
49            }
50            Block::Table(_) | Block::Fence(_) => {
51                let target = if attachment_open {
52                    last_example_idx
53                } else {
54                    None
55                };
56                if let Some(idx) = target {
57                    let block_span = block.span();
58                    examples[idx].span = Span::from_offsets(
59                        source,
60                        examples[idx].span.start_offset,
61                        block_span.end_offset,
62                    );
63                    examples[idx].body.push(block);
64                } else {
65                    orphan_attachments.push(match block {
66                        Block::Table(t) => TableOrFence::Table(t),
67                        Block::Fence(f) => TableOrFence::Fence(f),
68                        _ => unreachable!(),
69                    });
70                }
71            }
72            Block::ThematicBreak(_) => {
73                attachment_open = false;
74                delimiter_pending = true;
75            }
76        }
77    }
78
79    Doc {
80        path: path.to_string(),
81        source: source.to_string(),
82        examples,
83        orphan_attachments,
84        headings,
85    }
86}
87
88fn scope_texts(scope_stack: &[(usize, String)]) -> Vec<String> {
89    scope_stack.iter().map(|e| e.1.clone()).collect()
90}