Skip to main content

moss_core/ast/
document.rs

1//! Top-level parsed document.
2
3use serde::{Deserialize, Serialize};
4
5use super::node::Block;
6
7/// Per-top-level-block metadata.
8///
9/// Holds parse-time annotations that don't belong on the `Block` enum
10/// itself (which is shape-only). Lives in a parallel `Vec<BlockMeta>` on
11/// [`Document`] so existing pattern matches over `Block` don't need to
12/// unwrap a meta wrapper.
13///
14/// Today the only field is `source_line` (set by the parser when
15/// [`crate::ast::ParseConfig::emit_source_lines`] is true). Additional
16/// per-block annotations (block IDs, custom attrs, etc.) land here too
17/// when needed.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
19pub struct BlockMeta {
20    /// 1-based source line where this block begins, or `None` when source
21    /// tracking is off (or the block was synthesized — e.g. shortcode
22    /// substitution — and has no faithful source position).
23    ///
24    /// Consumed by the renderer to emit `data-source-line="N"` on the
25    /// opening tag, which the preview's `cm-scroll-sync` consumes for
26    /// paragraph-level editor↔preview scroll sync.
27    pub source_line: Option<usize>,
28}
29
30/// A parsed markdown document body.
31///
32/// Wraps `Vec<Block>` with document-level flags. The frontmatter sits in
33/// [`crate::frontmatter::ParsedDocument`]; this struct is body-only.
34/// Higher-level code combines the two as needed.
35#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
36pub struct Document {
37    /// Body content as a flat list of block-level nodes.
38    pub blocks: Vec<Block>,
39    /// Parallel-to-`blocks` metadata. **Invariant:** `block_meta.len() ==
40    /// blocks.len()`. The renderer asserts this in debug builds and
41    /// gracefully degrades (treats missing entries as `BlockMeta::default()`)
42    /// in release builds.
43    ///
44    /// Defaults to an all-`BlockMeta::default()` vec sized to match
45    /// `blocks` when constructed via [`Document::from_blocks`]; only the
46    /// parser populates `source_line` (under
47    /// [`crate::ast::ParseConfig::emit_source_lines`]).
48    #[serde(default)]
49    pub block_meta: Vec<BlockMeta>,
50    /// True if this document is a slot file (e.g. `footer.md`) — its
51    /// content fills a layout slot rather than being rendered as an
52    /// article. The renderer suppresses auto-injected article chrome
53    /// (`<h1 class="moss-article-title">`) when this is set.
54    ///
55    /// Replaces the `heading: false` YAML synthesis hack at
56    /// `src-tauri/src/build/footer.rs:160`. Lands in Phase A.5 of the
57    /// typed-AST migration; in Phase A it's available but not yet
58    /// consumed by src-tauri.
59    pub slot_only: bool,
60}
61
62impl Document {
63    /// Construct an empty document.
64    pub fn new() -> Self {
65        Self::default()
66    }
67
68    /// Construct a document from a list of blocks. All `block_meta` entries
69    /// are default (no source-line tracking). Use
70    /// [`Document::from_blocks_with_meta`] when meta is known.
71    pub fn from_blocks(blocks: Vec<Block>) -> Self {
72        let block_meta = vec![BlockMeta::default(); blocks.len()];
73        Self {
74            blocks,
75            block_meta,
76            slot_only: false,
77        }
78    }
79
80    /// Construct a document from blocks + parallel meta. Panics in debug if
81    /// the two slices have different lengths.
82    pub fn from_blocks_with_meta(blocks: Vec<Block>, block_meta: Vec<BlockMeta>) -> Self {
83        debug_assert_eq!(
84            blocks.len(),
85            block_meta.len(),
86            "Document::from_blocks_with_meta: blocks and block_meta must be equal length"
87        );
88        Self {
89            blocks,
90            block_meta,
91            slot_only: false,
92        }
93    }
94
95    /// True if any top-level block is a shortcode of the given kind.
96    ///
97    /// Shallow check: does NOT descend into nested blocks (e.g. a
98    /// `:::subscribe` inside a `:::grid` cell would not be found by this
99    /// query alone). Phase A models the shallow case; deeper
100    /// `has_shortcode_recursive` lands when nested-shortcode AST queries
101    /// are first needed.
102    pub fn has_shortcode(&self, kind: super::shortcode::ShortcodeKind) -> bool {
103        self.blocks.iter().any(|b| match b {
104            Block::Shortcode(sc) => sc.kind() == kind,
105            _ => false,
106        })
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::super::node::Inline;
113    use super::*;
114
115    #[test]
116    fn empty_document_has_no_blocks() {
117        let d = Document::new();
118        assert!(d.blocks.is_empty());
119        assert!(!d.slot_only);
120    }
121
122    #[test]
123    fn from_blocks_constructs_with_blocks() {
124        let d = Document::from_blocks(vec![Block::ThematicBreak]);
125        assert_eq!(d.blocks.len(), 1);
126        assert!(!d.slot_only);
127    }
128
129    #[test]
130    fn slot_only_default_is_false() {
131        // Crucial: an article doc must NOT silently become a slot.
132        let d = Document::default();
133        assert!(!d.slot_only);
134    }
135
136    #[test]
137    fn slot_only_settable() {
138        let mut d = Document::new();
139        d.slot_only = true;
140        assert!(d.slot_only);
141    }
142
143    #[test]
144    fn document_round_trips_through_serde() {
145        let mut original = Document::new();
146        original.blocks.push(Block::Paragraph(vec![Inline::Text(
147            "hello".to_string(),
148        )]));
149        original.slot_only = true;
150        let s = serde_json::to_string(&original).expect("serialize");
151        let back: Document = serde_json::from_str(&s).expect("deserialize");
152        assert_eq!(original, back);
153    }
154
155    #[test]
156    fn has_shortcode_returns_false_when_empty() {
157        // The Shortcode enum is empty in Phase A; this test exercises the
158        // query path on the empty case (which is the only constructable
159        // case until Phase B). Extended per-kind tests land alongside
160        // each shortcode migration.
161        let d = Document::new();
162        assert!(!d.has_shortcode(super::super::shortcode::ShortcodeKind::Subscribe));
163    }
164}