Skip to main content

mos_core/
document.rs

1//! The lowered semantic document graph (manifest §5, §6 stage 2).
2//!
3//! [`Document`] owns every [`Node`] and hands them out through their stable
4//! [`NodeId`]. Each node carries a [`NodeKind`], a [`SourceSpan`], a
5//! [`ContentHash`], a [`StyleId`], and an [`AttrMap`] of [`AttrValue`]s.
6
7use std::collections::BTreeMap;
8use std::path::PathBuf;
9use std::sync::Arc;
10
11use crate::{ContentHash, SourceSpan};
12
13/// Stable identifier for a document node.
14///
15/// Per manifest §5.1, IDs should ideally be derived from
16/// `hash(file path + syntactic position + explicit label + local structure)`
17/// rather than parse order. The MVP 0 lowerer (`mos-eval`) hands out
18/// monotonic IDs through `Document::alloc`; the hash-based derivation is
19/// deferred to MVP 5 when stable IDs become observable through the cache.
20///
21/// # Examples
22///
23/// ```
24/// use mos_core::NodeId;
25///
26/// let root = NodeId(0);
27///
28/// assert_eq!(root.0, 0);
29/// ```
30#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
31pub struct NodeId(pub u64);
32
33/// Identifier for a resolved style bundle.
34///
35/// # Examples
36///
37/// ```
38/// use mos_core::StyleId;
39///
40/// let style = StyleId::default();
41///
42/// assert_eq!(style.0, 0);
43/// ```
44#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
45pub struct StyleId(pub u32);
46
47/// The kinds of nodes Mosaic recognises (manifest §5.1).
48///
49/// # Examples
50///
51/// ```
52/// use mos_core::NodeKind;
53///
54/// let kind = NodeKind::Paragraph;
55///
56/// assert_eq!(kind, NodeKind::Paragraph);
57/// ```
58#[derive(Copy, Clone, Eq, PartialEq, Debug)]
59pub enum NodeKind {
60    Document,
61    Section,
62    Paragraph,
63    Text,
64    Emphasis,
65    Strong,
66    BoldItalic,
67    Math,
68    Equation,
69    /// A captioned container: an image plus a caption paragraph, laid
70    /// out together with the caption beneath. Cross-references via
71    /// `@fig:foo` will target this kind once MVP 3 lands.
72    Figure,
73    /// A raster image (PNG / JPEG in MVP 1.5). The decoded pixel data
74    /// and natural dimensions live on the node's attributes; see the
75    /// `mos-eval` resolver for the exact attribute names.
76    Image,
77    Table,
78    Citation,
79    Reference,
80    /// A `@page(label)` reference to the printed page number of a labelled
81    /// target. Distinct from [`Reference`](Self::Reference) (which resolves to
82    /// a section/figure number): a page reference resolves to where the target
83    /// lands, which is only known after layout, via the resolve↔layout fixpoint
84    /// (issue #72). Carries a `label` attribute and placeholder `text`; layout
85    /// renders the `text` attribute like any inline run.
86    PageReference,
87    Theorem,
88    Footnote,
89    Bibliography,
90    Raw,
91    /// A bullet or numbered list. The `ordered` attribute distinguishes
92    /// the two kinds and child nodes are [`NodeKind::ListItem`]s.
93    List,
94    /// One entry inside a [`NodeKind::List`]. Paragraph children carry item
95    /// text; nested [`NodeKind::List`] children describe deeper levels.
96    ListItem,
97    /// `\\`: a forced line break inside a paragraph. Carries no
98    /// attributes; layout consumes it as a `WordItem::HardBreak`
99    /// sentinel in the inline word stream. A blank-line paragraph
100    /// break is **not** the same node: it ends the paragraph and
101    /// triggers paragraph-spacing leading, whereas `HardBreak` keeps
102    /// the same paragraph and applies normal inter-line leading.
103    HardBreak,
104}
105
106/// A semantic document node (manifest §5.1).
107///
108/// Nodes are allocated only by [`Document::alloc`] / [`Document::alloc_child`]
109/// from a [`NodeSpec`]: the arena assigns the [`NodeId`] and owns the
110/// `content_hash`/`style_id` placeholders. Those two fields are `pub(crate)`,
111/// which makes the struct literal unconstructible outside this crate, so no
112/// caller can fabricate a node with a fake id or a hand-set hash.
113///
114/// # Examples
115///
116/// ```
117/// use std::path::PathBuf;
118///
119/// use mos_core::{Document, NodeKind, NodeSpec, SourceSpan};
120///
121/// let file = PathBuf::from("main.mos");
122/// let mut doc = Document::new(file.clone());
123/// let id = doc.alloc(NodeSpec::new(NodeKind::Paragraph, SourceSpan::placeholder(file)));
124///
125/// assert_eq!(doc.get(id).map(|node| &node.kind), Some(&NodeKind::Paragraph));
126/// ```
127#[derive(Clone, Debug)]
128pub struct Node {
129    pub id: NodeId,
130    pub kind: NodeKind,
131    pub span: SourceSpan,
132    pub children: Vec<NodeId>,
133    pub attributes: AttrMap,
134    /// Hash-derived identity placeholder (manifest §5.1); set by the arena,
135    /// always default until the MVP 5 cache work. `pub(crate)` to seal
136    /// external construction.
137    pub(crate) content_hash: ContentHash,
138    /// Resolved style slot placeholder; set by the arena, always default
139    /// until styling lands. `pub(crate)` to seal external construction.
140    pub(crate) style_id: StyleId,
141}
142
143impl Node {
144    /// The node's content hash: a hash-derived identity placeholder
145    /// (manifest §5.1), default until the MVP 5 cache work. Read-only: the
146    /// arena owns this field.
147    #[must_use]
148    pub const fn content_hash(&self) -> ContentHash {
149        self.content_hash
150    }
151
152    /// The node's resolved style slot: a placeholder, default until styling
153    /// lands. Read-only: the arena owns this field.
154    #[must_use]
155    pub const fn style_id(&self) -> StyleId {
156        self.style_id
157    }
158}
159
160/// Blueprint for allocating a node in the document arena.
161///
162/// Carries only caller-chosen fields: `kind`, `span`, and `attributes`.
163/// The arena supplies the `id`, empty `children`, and identity/style
164/// placeholders.
165#[derive(Clone, Debug)]
166pub struct NodeSpec {
167    pub kind: NodeKind,
168    pub span: SourceSpan,
169    pub attributes: AttrMap,
170}
171
172impl NodeSpec {
173    /// A spec for a node of `kind` spanning `span`, with no attributes.
174    #[must_use]
175    pub const fn new(kind: NodeKind, span: SourceSpan) -> Self {
176        Self {
177            kind,
178            span,
179            attributes: AttrMap::new(),
180        }
181    }
182
183    /// Attach `attributes` to this spec.
184    #[must_use]
185    pub fn with_attributes(mut self, attributes: AttrMap) -> Self {
186        self.attributes = attributes;
187        self
188    }
189}
190
191/// Attribute map carried on each node. Keys are interned strings in a
192/// later iteration; for now plain `String` keys are fine for the stub.
193pub type AttrMap = BTreeMap<String, AttrValue>;
194
195/// Attribute value carried on a semantic [`Node`].
196///
197/// # Examples
198///
199/// ```
200/// use mos_core::AttrValue;
201///
202/// let value = AttrValue::Str("intro".to_owned());
203///
204/// assert_eq!(value, AttrValue::Str("intro".to_owned()));
205/// ```
206#[derive(Clone, Debug, PartialEq)]
207pub enum AttrValue {
208    Bool(bool),
209    Int(i64),
210    Float(f64),
211    Str(String),
212    List(Vec<Self>),
213    /// A length already resolved to PDF points. The parser carries
214    /// unit-tagged literals (`mm`, `pt`, `em`); the lowerer converts
215    /// them to a single canonical scalar so layout never has to know
216    /// about units.
217    Length(f64),
218    /// Opaque binary payload; currently used to carry decoded raster
219    /// image pixels (RGB8) onto an [`NodeKind::Image`] node so the PDF
220    /// backend can emit them as an Image `XObject` without re-reading the
221    /// source file.
222    ///
223    /// Stored as `Arc<[u8]>` so a node carrying decoded pixels is cheap
224    /// to clone (e.g. across cache boundaries or when the same image
225    /// would otherwise be duplicated through the document graph). The
226    /// layout engine still dedups by resolved path, so most documents
227    /// hold one buffer per image regardless; the `Arc` is insurance
228    /// against accidental copies on the eval → layout boundary.
229    Bytes(Arc<[u8]>),
230}
231
232/// The lowered semantic document graph (manifest §5, §6 stage 2).
233///
234/// Owns every [`Node`] and exposes them through their stable [`NodeId`].
235/// MVP 0 stores nodes in insertion order; the manifest §5.1 hash-derived
236/// IDs land alongside the cache work in MVP 5.
237///
238/// # Examples
239///
240/// ```
241/// use std::path::PathBuf;
242///
243/// use mos_core::{Document, NodeId};
244///
245/// let doc = Document::new(PathBuf::from("main.mos"));
246///
247/// assert_eq!(doc.root, NodeId(0));
248/// ```
249#[derive(Debug)]
250pub struct Document {
251    pub root: NodeId,
252    pub file: PathBuf,
253    nodes: BTreeMap<NodeId, Node>,
254    next_id: u64,
255}
256
257impl Document {
258    /// Create an empty document rooted at `file`. Allocates the
259    /// `Document` root node (`NodeId(0)`) eagerly so callers can append
260    /// children to it immediately.
261    ///
262    /// # Examples
263    ///
264    /// ```
265    /// use std::path::PathBuf;
266    ///
267    /// use mos_core::Document;
268    ///
269    /// let doc = Document::new(PathBuf::from("main.mos"));
270    ///
271    /// assert_eq!(doc.len(), 1);
272    /// ```
273    #[must_use]
274    pub fn new(file: PathBuf) -> Self {
275        let root_id = NodeId(0);
276        let root_node = Node {
277            id: root_id,
278            kind: NodeKind::Document,
279            span: SourceSpan::placeholder(file.clone()),
280            content_hash: ContentHash::default(),
281            style_id: StyleId::default(),
282            children: Vec::new(),
283            attributes: AttrMap::new(),
284        };
285        let mut nodes = BTreeMap::new();
286        nodes.insert(root_id, root_node);
287        Self {
288            root: root_id,
289            file,
290            nodes,
291            next_id: 1,
292        }
293    }
294
295    /// Allocate a node from `spec` in the arena and return its assigned
296    /// [`NodeId`]. The arena fills in the id, an empty `children` list, and
297    /// the default `content_hash`/`style_id` placeholders.
298    ///
299    /// # Examples
300    ///
301    /// ```
302    /// use std::path::PathBuf;
303    ///
304    /// use mos_core::{Document, NodeId, NodeKind, NodeSpec, SourceSpan};
305    ///
306    /// let file = PathBuf::from("main.mos");
307    /// let mut doc = Document::new(file.clone());
308    /// let id = doc.alloc(NodeSpec::new(NodeKind::Paragraph, SourceSpan::placeholder(file)));
309    ///
310    /// assert_eq!(id, NodeId(1));
311    /// ```
312    pub fn alloc(&mut self, spec: NodeSpec) -> NodeId {
313        let id = NodeId(self.next_id);
314        self.next_id += 1;
315        self.nodes.insert(id, Self::node_from_spec(id, spec));
316        id
317    }
318
319    /// Build the arena-owned [`Node`] for `id` from a caller's [`NodeSpec`],
320    /// supplying the fields the caller does not control.
321    fn node_from_spec(id: NodeId, spec: NodeSpec) -> Node {
322        Node {
323            id,
324            kind: spec.kind,
325            span: spec.span,
326            children: Vec::new(),
327            attributes: spec.attributes,
328            content_hash: ContentHash::default(),
329            style_id: StyleId::default(),
330        }
331    }
332
333    /// Allocate a node from `spec` as a child of `parent` and return its
334    /// [`NodeId`].
335    ///
336    /// # Panics
337    ///
338    /// Panics if `parent` is not a node already allocated by this
339    /// `Document`. Silently producing detached nodes would hide lowerer
340    /// bugs in release builds, so this is intentionally a release-time
341    /// assertion rather than a `debug_assert!`.
342    ///
343    /// # Examples
344    ///
345    /// ```
346    /// use std::path::PathBuf;
347    ///
348    /// use mos_core::{Document, NodeKind, NodeSpec, SourceSpan};
349    ///
350    /// let file = PathBuf::from("main.mos");
351    /// let mut doc = Document::new(file.clone());
352    /// let child = doc.alloc_child(doc.root, NodeSpec::new(NodeKind::Paragraph, SourceSpan::placeholder(file)));
353    ///
354    /// assert_eq!(doc.get(doc.root).map(|node| node.children.as_slice()), Some(&[child][..]));
355    /// ```
356    pub fn alloc_child(&mut self, parent: NodeId, spec: NodeSpec) -> NodeId {
357        assert!(
358            self.nodes.contains_key(&parent),
359            "Document::alloc_child: unknown parent {parent:?}"
360        );
361        let child_id = self.alloc(spec);
362        // Safe to index: we just verified the key exists, and `alloc`
363        // doesn't remove existing entries.
364        if let Some(parent_node) = self.nodes.get_mut(&parent) {
365            parent_node.children.push(child_id);
366        }
367        child_id
368    }
369
370    /// Get a node by id.
371    ///
372    /// # Examples
373    ///
374    /// ```
375    /// use std::path::PathBuf;
376    ///
377    /// use mos_core::{Document, NodeKind};
378    ///
379    /// let doc = Document::new(PathBuf::from("main.mos"));
380    ///
381    /// assert_eq!(doc.get(doc.root).map(|node| node.kind), Some(NodeKind::Document));
382    /// ```
383    #[must_use]
384    pub fn get(&self, id: NodeId) -> Option<&Node> {
385        self.nodes.get(&id)
386    }
387
388    /// Mutable accessor for a single node. Used by the resolver
389    /// (manifest §6 stage 3) to back-patch attributes like `number`
390    /// onto sections and `text` onto `@label` references.
391    ///
392    /// # Examples
393    ///
394    /// ```
395    /// use std::path::PathBuf;
396    ///
397    /// use mos_core::{AttrValue, Document};
398    ///
399    /// let mut doc = Document::new(PathBuf::from("main.mos"));
400    /// if let Some(root) = doc.get_mut(doc.root) {
401    ///     root.attributes.insert("title".to_owned(), AttrValue::Str("Demo".to_owned()));
402    /// }
403    ///
404    /// assert!(doc.get(doc.root).is_some_and(|node| node.attributes.contains_key("title")));
405    /// ```
406    #[must_use]
407    pub fn get_mut(&mut self, id: NodeId) -> Option<&mut Node> {
408        self.nodes.get_mut(&id)
409    }
410
411    /// Iterate over every node in the arena in insertion order.
412    ///
413    /// # Examples
414    ///
415    /// ```
416    /// use std::path::PathBuf;
417    ///
418    /// use mos_core::{Document, NodeKind};
419    ///
420    /// let doc = Document::new(PathBuf::from("main.mos"));
421    /// let kinds: Vec<NodeKind> = doc.nodes().map(|node| node.kind).collect();
422    ///
423    /// assert_eq!(kinds, vec![NodeKind::Document]);
424    /// ```
425    pub fn nodes(&self) -> impl Iterator<Item = &Node> {
426        self.nodes.values()
427    }
428
429    /// Total number of nodes including the document root.
430    ///
431    /// # Examples
432    ///
433    /// ```
434    /// use std::path::PathBuf;
435    ///
436    /// use mos_core::Document;
437    ///
438    /// let doc = Document::new(PathBuf::from("main.mos"));
439    ///
440    /// assert_eq!(doc.len(), 1);
441    /// ```
442    #[must_use]
443    pub fn len(&self) -> usize {
444        self.nodes.len()
445    }
446
447    /// Return whether the document has no semantic content beyond the root.
448    ///
449    /// # Examples
450    ///
451    /// ```
452    /// use std::path::PathBuf;
453    ///
454    /// use mos_core::Document;
455    ///
456    /// let doc = Document::new(PathBuf::from("main.mos"));
457    ///
458    /// assert!(doc.is_empty());
459    /// ```
460    #[must_use]
461    pub fn is_empty(&self) -> bool {
462        // The root always exists, so `Document` is never truly empty;
463        // expose the conventional method anyway for clippy compliance.
464        self.len() <= 1
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    #[test]
473    #[should_panic(expected = "unknown parent")]
474    fn alloc_child_panics_on_unknown_parent() {
475        let mut doc = Document::new(PathBuf::from("test.mos"));
476        // `NodeId(9999)` was never allocated by `doc`; the call must
477        // abort instead of leaking a detached node.
478        doc.alloc_child(
479            NodeId(9999),
480            NodeSpec::new(
481                NodeKind::Text,
482                SourceSpan::placeholder(PathBuf::from("test.mos")),
483            ),
484        );
485    }
486
487    #[test]
488    fn document_alloc_and_traverse() {
489        let mut doc = Document::new(PathBuf::from("test.mos"));
490        let para = doc.alloc_child(
491            doc.root,
492            NodeSpec::new(
493                NodeKind::Paragraph,
494                SourceSpan::placeholder(PathBuf::from("test.mos")),
495            ),
496        );
497        doc.alloc_child(
498            para,
499            NodeSpec::new(
500                NodeKind::Text,
501                SourceSpan::placeholder(PathBuf::from("test.mos")),
502            ),
503        );
504        assert_eq!(doc.len(), 3);
505        assert_eq!(doc.get(doc.root).unwrap().children.len(), 1);
506        assert_eq!(doc.get(para).unwrap().children.len(), 1);
507    }
508}