quillmark_core/document/mod.rs
1//! # Document Module
2//!
3//! Parsing functionality for markdown documents with YAML frontmatter.
4//!
5//! ## Overview
6//!
7//! The `document` module provides the [`Document::from_markdown`] function for parsing
8//! markdown documents into a typed in-memory model.
9//!
10//! ## Key Types
11//!
12//! - [`Document`]: Typed in-memory Quillmark document — `main` card plus composable cards.
13//! - [`Card`]: A single metadata fence block, main or composable, with a sentinel,
14//! typed frontmatter, and a body.
15//! - [`Sentinel`]: Discriminates `QUILL:` main cards from `CARD:` composable cards.
16//! - [`Frontmatter`]: Ordered list of items (fields + comments) parsed from a YAML fence.
17//!
18//! ## Examples
19//!
20//! ### Basic Parsing
21//!
22//! ```
23//! use quillmark_core::Document;
24//!
25//! let markdown = r#"---
26//! QUILL: my_quill
27//! title: My Document
28//! author: John Doe
29//! ---
30//!
31//! # Introduction
32//!
33//! Document content here.
34//! "#;
35//!
36//! let doc = Document::from_markdown(markdown).unwrap();
37//! let title = doc.main()
38//! .frontmatter()
39//! .get("title")
40//! .and_then(|v| v.as_str())
41//! .unwrap_or("Untitled");
42//! assert_eq!(title, "My Document");
43//! assert_eq!(doc.cards().len(), 0);
44//! ```
45//!
46//! ### Accessing the plate wire format
47//!
48//! ```
49//! use quillmark_core::Document;
50//!
51//! let doc = Document::from_markdown(
52//! "---\nQUILL: my_quill\ntitle: Hi\n---\n\nBody here.\n"
53//! ).unwrap();
54//! let json = doc.to_plate_json();
55//! assert_eq!(json["QUILL"], "my_quill");
56//! assert_eq!(json["title"], "Hi");
57//! assert_eq!(json["BODY"], "\nBody here.\n");
58//! assert!(json["CARDS"].is_array());
59//! ```
60//!
61//! ## Error Handling
62//!
63//! [`Document::from_markdown`] returns errors for:
64//! - Malformed YAML syntax
65//! - Unclosed frontmatter blocks
66//! - Multiple global frontmatter blocks
67//! - Both QUILL and CARD specified in the same block
68//! - Reserved field name usage
69//! - Name collisions
70//!
71//! See [PARSE.md](https://github.com/nibsbin/quillmark/blob/main/designs/PARSE.md) for
72//! comprehensive documentation of the Extended YAML Metadata Standard.
73
74use crate::error::ParseError;
75use crate::version::QuillReference;
76use crate::Diagnostic;
77
78pub mod assemble;
79pub mod edit;
80pub mod emit;
81pub mod fences;
82pub mod frontmatter;
83pub mod limits;
84pub mod prescan;
85pub mod sentinel;
86
87pub use edit::EditError;
88pub use frontmatter::{Frontmatter, FrontmatterItem};
89
90// Re-export the sentinel type (defined below in this module file).
91// `Sentinel` is exported at the crate root via `lib.rs`.
92
93#[cfg(test)]
94mod tests;
95
96/// Parse result carrying both the parsed document and any non-fatal warnings
97/// (e.g. near-miss sentinel lints emitted per spec §4.2).
98#[derive(Debug)]
99pub struct ParseOutput {
100 /// The successfully parsed document.
101 pub document: Document,
102 /// Non-fatal warnings collected during parsing.
103 pub warnings: Vec<Diagnostic>,
104}
105
106/// Discriminator for a [`Card`]'s metadata fence.
107///
108/// The first fence in a Quillmark document carries `QUILL: <ref>` and is the
109/// document-level *main* card; every subsequent fence carries `CARD: <tag>`
110/// and is a composable card. `Sentinel` captures that distinction in the typed
111/// model so every fence is one uniform shape.
112#[derive(Debug, Clone, PartialEq)]
113pub enum Sentinel {
114 /// `QUILL: <ref>` — the document entry card.
115 Main(QuillReference),
116 /// `CARD: <tag>` — a composable card with the given tag.
117 Card(String),
118}
119
120impl Sentinel {
121 /// String form of this sentinel's value: the quill reference for `Main`,
122 /// the tag for `Card`.
123 pub fn as_str(&self) -> String {
124 match self {
125 Sentinel::Main(r) => r.to_string(),
126 Sentinel::Card(t) => t.clone(),
127 }
128 }
129
130 /// Returns `true` if this is a `Main` sentinel.
131 pub fn is_main(&self) -> bool {
132 matches!(self, Sentinel::Main(_))
133 }
134}
135
136/// A single metadata fence parsed from a Quillmark Markdown document.
137///
138/// A `Card` is the uniform shape for both the document entry (main) fence and
139/// composable card fences. `sentinel` distinguishes the two.
140///
141/// Every card has:
142/// - `sentinel` — the `QUILL` reference (for main) or `CARD` tag (for composable).
143/// - `frontmatter` — ordered items parsed from the YAML fence body (with the
144/// sentinel key already removed).
145/// - `body` — the Markdown text that follows the closing fence, up to the next
146/// fence (or EOF).
147///
148/// ## Card body absence
149///
150/// If a card block has no trailing Markdown content (e.g. the next block or
151/// EOF immediately follows the closing fence), `body` is the empty string `""`.
152/// It is never `None`; callers that need to distinguish "absent" from "empty"
153/// should check `card.body().is_empty()`.
154#[derive(Debug, Clone, PartialEq)]
155pub struct Card {
156 sentinel: Sentinel,
157 frontmatter: Frontmatter,
158 body: String,
159}
160
161impl Card {
162 /// Create a `Card` directly from a sentinel, a typed frontmatter, and a
163 /// body. Does **not** validate the sentinel tag or any field names —
164 /// callers are responsible for providing already-valid data. For
165 /// user-facing construction of composable cards use [`Card::new`]
166 /// (defined in `edit.rs`).
167 pub fn new_with_sentinel(sentinel: Sentinel, frontmatter: Frontmatter, body: String) -> Self {
168 Self {
169 sentinel,
170 frontmatter,
171 body,
172 }
173 }
174
175 /// The sentinel discriminating this card as main or composable.
176 pub fn sentinel(&self) -> &Sentinel {
177 &self.sentinel
178 }
179
180 /// The card tag — the `CARD:` value for composable cards, or the string
181 /// form of the quill reference for main cards.
182 pub fn tag(&self) -> String {
183 self.sentinel.as_str()
184 }
185
186 /// Typed frontmatter (map-keyed view and ordered item list).
187 pub fn frontmatter(&self) -> &Frontmatter {
188 &self.frontmatter
189 }
190
191 /// Mutable access to the frontmatter.
192 pub fn frontmatter_mut(&mut self) -> &mut Frontmatter {
193 &mut self.frontmatter
194 }
195
196 /// Markdown body that follows this card's closing fence.
197 ///
198 /// Empty string when no trailing content is present.
199 pub fn body(&self) -> &str {
200 &self.body
201 }
202
203 /// Returns `true` if this is the document entry (main) card.
204 pub fn is_main(&self) -> bool {
205 self.sentinel.is_main()
206 }
207
208 /// Replace this card's sentinel. Internal helper; public mutators
209 /// ([`Document::set_quill_ref`], the parser) call this.
210 pub(crate) fn replace_sentinel(&mut self, sentinel: Sentinel) {
211 self.sentinel = sentinel;
212 }
213
214 /// Overwrite the body string. Internal helper used by [`Card::replace_body`].
215 pub(crate) fn overwrite_body(&mut self, body: String) {
216 self.body = body;
217 }
218}
219
220/// A fully-parsed, typed in-memory Quillmark document.
221///
222/// `Document` is the canonical representation of a Quillmark Markdown file.
223/// Markdown is both the import and export format; the structured data here
224/// is primary.
225///
226/// ## Structure
227///
228/// - `main` — the entry `Card` (sentinel is `Sentinel::Main(reference)`).
229/// - `cards` — ordered composable cards (each with `Sentinel::Card(tag)`).
230///
231/// Backend plates consume the flat JSON wire shape produced by
232/// [`Document::to_plate_json`]. That method is the **only** place in core
233/// that reconstructs `{"QUILL": ..., "CARDS": [...], "BODY": "..."}`.
234#[derive(Debug, Clone)]
235pub struct Document {
236 main: Card,
237 cards: Vec<Card>,
238 warnings: Vec<Diagnostic>,
239}
240
241// Equality is defined over the structural content only — `warnings` are
242// parse-time observations that depend on what the source text happened to
243// contain (near-miss sentinels, unsupported tag drops, etc.) and so differ
244// between a source document and its round-tripped emission. Two documents
245// are equal when their `main` and `cards` match.
246impl PartialEq for Document {
247 fn eq(&self, other: &Self) -> bool {
248 self.main == other.main && self.cards == other.cards
249 }
250}
251
252impl Document {
253 /// Create a `Document` from a pre-built main `Card` and composable cards.
254 ///
255 /// The caller must guarantee that `main.sentinel` is `Sentinel::Main(_)`
256 /// and every card in `cards` has `sentinel` = `Sentinel::Card(_)`.
257 pub fn from_main_and_cards(main: Card, cards: Vec<Card>, warnings: Vec<Diagnostic>) -> Self {
258 debug_assert!(main.sentinel.is_main(), "main card must be Sentinel::Main");
259 debug_assert!(
260 cards.iter().all(|c| !c.sentinel.is_main()),
261 "composable cards must be Sentinel::Card"
262 );
263 Self {
264 main,
265 cards,
266 warnings,
267 }
268 }
269
270 /// Parse a Quillmark Markdown document, discarding any non-fatal warnings.
271 pub fn from_markdown(markdown: &str) -> Result<Self, ParseError> {
272 assemble::decompose(markdown)
273 }
274
275 /// Parse a Quillmark Markdown document, returning warnings alongside the document.
276 pub fn from_markdown_with_warnings(markdown: &str) -> Result<ParseOutput, ParseError> {
277 assemble::decompose_with_warnings(markdown)
278 .map(|(document, warnings)| ParseOutput { document, warnings })
279 }
280
281 // ── Accessors ──────────────────────────────────────────────────────────────
282
283 /// The document's main (entry) card.
284 pub fn main(&self) -> &Card {
285 &self.main
286 }
287
288 /// Mutable access to the main card.
289 pub fn main_mut(&mut self) -> &mut Card {
290 &mut self.main
291 }
292
293 /// The quill reference (`name@version-selector`) carried by the main card's
294 /// sentinel. Convenience reader over `doc.main().sentinel()`.
295 pub fn quill_reference(&self) -> &QuillReference {
296 match &self.main.sentinel {
297 Sentinel::Main(r) => r,
298 Sentinel::Card(_) => {
299 unreachable!("main card must carry Sentinel::Main by construction")
300 }
301 }
302 }
303
304 /// Ordered list of composable card blocks.
305 pub fn cards(&self) -> &[Card] {
306 &self.cards
307 }
308
309 /// Mutable access to the composable cards slice.
310 pub fn cards_mut(&mut self) -> &mut [Card] {
311 &mut self.cards
312 }
313
314 /// Internal mutable access to the backing `Vec<Card>`. Used by edit
315 /// operations ([`Document::push_card`], etc.) that need to insert or
316 /// remove elements.
317 pub(crate) fn cards_vec_mut(&mut self) -> &mut Vec<Card> {
318 &mut self.cards
319 }
320
321 /// Non-fatal warnings collected during parsing.
322 pub fn warnings(&self) -> &[Diagnostic] {
323 &self.warnings
324 }
325
326 // ── Wire format ────────────────────────────────────────────────────────────
327
328 /// Serialize this document to the JSON shape expected by backend plates.
329 ///
330 /// The output has the following top-level keys, which match what
331 /// `lib.typ.template` reads at Typst runtime:
332 ///
333 /// ```json
334 /// {
335 /// "QUILL": "<ref>",
336 /// "<field>": <value>,
337 /// ...
338 /// "BODY": "<global-body>",
339 /// "CARDS": [
340 /// { "CARD": "<tag>", "<field>": <value>, ..., "BODY": "<card-body>" },
341 /// ...
342 /// ]
343 /// }
344 /// ```
345 ///
346 /// This is the **only** place in `quillmark-core` that knows about the plate
347 /// wire format. All internal consumers (Quill, backends) call this instead
348 /// of constructing the shape by hand.
349 pub fn to_plate_json(&self) -> serde_json::Value {
350 let mut map = serde_json::Map::new();
351
352 // QUILL first — plate authors expect this at the top.
353 map.insert(
354 "QUILL".to_string(),
355 serde_json::Value::String(self.quill_reference().to_string()),
356 );
357
358 // Frontmatter fields in insertion order.
359 for (key, value) in self.main.frontmatter.iter() {
360 map.insert(key.clone(), value.as_json().clone());
361 }
362
363 // Global body.
364 map.insert(
365 "BODY".to_string(),
366 serde_json::Value::String(self.main.body.clone()),
367 );
368
369 // Cards array.
370 let cards_array: Vec<serde_json::Value> = self
371 .cards
372 .iter()
373 .map(|card| {
374 let mut card_map = serde_json::Map::new();
375 card_map.insert("CARD".to_string(), serde_json::Value::String(card.tag()));
376 for (key, value) in card.frontmatter.iter() {
377 card_map.insert(key.clone(), value.as_json().clone());
378 }
379 card_map.insert(
380 "BODY".to_string(),
381 serde_json::Value::String(card.body.clone()),
382 );
383 serde_json::Value::Object(card_map)
384 })
385 .collect();
386
387 map.insert("CARDS".to_string(), serde_json::Value::Array(cards_array));
388
389 serde_json::Value::Object(map)
390 }
391}