Skip to main content

quillmark_core/quill/
conform.rs

1//! Conform-on-load: the bound door that lands a document at its **canonical
2//! rest**.
3//!
4//! A content field rests in its codec's lossless written form whenever the
5//! quill resolved, the value commits under the strict write, and no
6//! `!must_fill` marker rides anywhere in it: `richtext` as the canonical
7//! content object, `plaintext` as its literal string
8//! (`prose/canon/SCHEMAS.md` ยง "Content fields rest per codec"). Every
9//! departure is a named state carrying a marker or a diagnostic, never a silent
10//! second resting form.
11//!
12//! [`Quill::conform`] is the primitive and [`Quill::parse`] (parse, then
13//! conform) the convenience: the documented primary ingestion path. The
14//! schema-free [`Document::parse`] stays the transport/repair door (migrations,
15//! `$ext` stamping, quill-unavailable fallback, opening-to-fix); its resting
16//! form is unspecified.
17//!
18//! The walk is the typed write, driven by the schema instead of by a caller:
19//! every declared content-bearing field goes through the same
20//! [`resolve_field_write`] the typed writer commits through, so parse-then-conform
21//! equals typed-write by construction rather than by parallel policy. What
22//! differs is the failure posture: the writer refuses, conform leaves the value
23//! authored and reports a `conform::*` warning.
24//!
25//! Four states are representable and none is silent:
26//!
27//! 1. **Quill unavailable**: the document loads through the transport door,
28//!    fully readable and round-trippable, resting as authored.
29//! 2. **Wrong quill**: [`Quill::conform`] errors before any mutation.
30//! 3. **Non-conforming value**: rests as authored plus a `conform::*`
31//!    diagnostic; the walk is stateless, so a repeat conform re-emits it.
32//! 4. **Fill-marked**: rests as authored; the marker is the state.
33
34use crate::document::edit::resolve_field_write;
35use crate::document::{Card, Document, EditError, Parsed, PayloadItem};
36use crate::path::DocPath;
37use crate::quill::config::field_contains_content;
38use crate::{Diagnostic, ParseError, Quill, QuillValue, RenderError, Severity};
39
40use super::CardSchema;
41
42/// The failure of the bound door ([`Quill::parse`]): the markdown did not
43/// parse, or it parsed under a `$quill` this quill does not answer to. Nothing
44/// conforms under the wrong schema, so the mismatch is an error and not a
45/// warning; [`to_diagnostics`](Self::to_diagnostics) flattens either half for a
46/// consumer that only routes on codes.
47#[derive(Debug, thiserror::Error)]
48#[non_exhaustive]
49pub enum BoundParseError {
50    /// The markdown is not a well-formed card-yaml document.
51    #[error(transparent)]
52    Parse(#[from] ParseError),
53    /// The document is well-formed but declares a different `$quill`
54    /// (`quill::name_mismatch` / `quill::version_mismatch`).
55    #[error(transparent)]
56    Mismatch(#[from] RenderError),
57}
58
59impl BoundParseError {
60    /// Every diagnostic this failure carries: the one parse diagnostic, or the
61    /// mismatch's list.
62    pub fn to_diagnostics(&self) -> Vec<Diagnostic> {
63        match self {
64            BoundParseError::Parse(e) => vec![e.to_diagnostic()],
65            BoundParseError::Mismatch(e) => e.diagnostics().to_vec(),
66        }
67    }
68}
69
70impl Quill {
71    /// Parse `markdown` and conform it against this quill: the **primary
72    /// ingestion path**, and the one that lands the document at canonical rest.
73    ///
74    /// [`Document::parse`] followed by [`conform`](Self::conform), returning the
75    /// same [`Parsed`] record with the conform diagnostics appended to
76    /// `warnings`. A `$quill` naming a different quill fails here rather than
77    /// conforming under the wrong schema; the escape hatch for a stale reference
78    /// is the transport door ([`Document::parse`], retarget `$quill`, then
79    /// [`conform`](Self::conform)).
80    ///
81    /// Bootstrap needs nothing extra: `$quill` is mandatory at the root, so the
82    /// schema-free parse is the sniffer. Resolve the quill it names, then enter
83    /// here.
84    pub fn parse(&self, markdown: &str) -> Result<Parsed, BoundParseError> {
85        let Parsed {
86            mut document,
87            mut warnings,
88        } = Document::parse(markdown)?;
89        warnings.extend(self.conform(&mut document)?);
90        Ok(Parsed { document, warnings })
91    }
92
93    /// Land `doc`'s declared content fields at their canonical rest, returning
94    /// the `conform::*` diagnostics for the values that would not commit.
95    ///
96    /// The document's `$quill` is checked against this quill **before any
97    /// mutation**, so a mismatch (`quill::name_mismatch` /
98    /// `quill::version_mismatch`) leaves `doc` untouched.
99    ///
100    /// The walk covers the main card and every composable card whose `$kind`
101    /// resolves, recursing through array `items` and object `properties`. Per
102    /// field:
103    ///
104    /// - A `!must_fill` marker **anywhere** in the value skips the whole field:
105    ///   the marker already names the state, and transporting one through a
106    ///   reshaping coercion is ill-defined.
107    /// - The value commits through the same strict write the typed writer runs,
108    ///   so `richtext` lands as canonical content and `plaintext` as its literal
109    ///   string; a refusal leaves the value authored and adds a diagnostic.
110    /// - An equal value is **not written**: every write path clears the field's
111    ///   `nested_comments`, so an unguarded conform would strip YAML comments and
112    ///   move bytes on an untouched document.
113    ///
114    /// Undeclared fields, unknown card kinds, and nulls pass untouched, and a
115    /// field whose declared type carries no content is the typed write's to
116    /// canonicalize. Idempotent: a second call is a byte no-op and re-emits the
117    /// identical diagnostics.
118    pub fn conform(&self, doc: &mut Document) -> Result<Vec<Diagnostic>, RenderError> {
119        self.check_quill_reference(doc)?;
120        let config = self.config();
121        let mut diags = Vec::new();
122        conform_card(&config.main, doc.main_mut(), &DocPath::main(), &mut diags);
123        for (index, card) in doc.cards_mut().iter_mut().enumerate() {
124            // A card whose `$kind` declares no schema has no declared field to
125            // conform: it passes untouched, as the render gate passes it. The
126            // kind is copied out so the card is free to be borrowed mutably.
127            let Some(kind) = card.kind().map(str::to_string) else {
128                continue;
129            };
130            let Some(schema) = config.card_kind(&kind) else {
131                continue;
132            };
133            conform_card(schema, card, &DocPath::card(Some(&kind), index), &mut diags);
134        }
135        Ok(diags)
136    }
137}
138
139/// Conform one card's declared content fields in place. Field-name resolution
140/// is the render gate's raw lookup (`schema.fields.get(name)`, no NFC respelling)
141/// so the two walks cannot diverge on which fields count as declared.
142fn conform_card(
143    schema: &CardSchema,
144    card: &mut Card,
145    base: &DocPath,
146    diags: &mut Vec<Diagnostic>,
147) {
148    let mut updates: Vec<(String, QuillValue)> = Vec::new();
149    // Over `items()` rather than the map projection: a root `!must_fill` rides
150    // on the payload item, nested ones on the value tree, and the skip rule
151    // covers both.
152    for item in card.payload().items() {
153        let PayloadItem::Field {
154            key: name,
155            value,
156            fill,
157            ..
158        } = item
159        else {
160            continue;
161        };
162        let Some(field) = schema.fields.get(name) else {
163            continue;
164        };
165        // Only a field whose type tree bears a content leaf has a resting form
166        // to enforce; a scalar field's shorthands are the typed write's to
167        // canonicalize, not conform's. Inside a content-bearing field the whole
168        // subtree conforms, which is what the typed write does to it too.
169        if !field_contains_content(field) {
170            continue;
171        }
172        // A marker anywhere in the value is the state: the payload item's own
173        // flag carries a root marker, the value tree the nested ones. A null
174        // needs no guard, since the strict write passes it through and the
175        // no-op check below then skips the write.
176        if *fill || !value.fill_paths().is_empty() {
177            continue;
178        }
179        match resolve_field_write(name, value.clone(), field) {
180            Ok(conformed) => {
181                if &conformed != value {
182                    updates.push((name.clone(), conformed));
183                }
184            }
185            Err(e) => diags.push(conform_diagnostic(&e, base)),
186        }
187    }
188    for (name, value) in updates {
189        // Pre-validated by `resolve_field_write` (name and stored-value depth),
190        // exactly as the typed commit's own insert.
191        card.payload_mut().insert_unchecked(name, value);
192    }
193}
194
195/// The `conform::*` diagnostic for a value the strict write refuses: the
196/// `edit::*` class the typed write would have raised, re-namespaced and demoted
197/// to a **warning**. Conform leaves the value authored, so this reports a
198/// repairable departure rather than a refusal; the code family lets a consumer
199/// route on "this field is not at rest" without parsing message text.
200pub(crate) fn conform_diagnostic(err: &EditError, base: &DocPath) -> Diagnostic {
201    let code = err.code().strip_prefix("edit::").unwrap_or(err.code());
202    let mut diag = Diagnostic::new(Severity::Warning, err.to_string())
203        .with_code(format!("conform::{code}"))
204        .with_args(err.args());
205    if let Some(path) = err.doc_path(base) {
206        diag = diag.with_path(path.to_string());
207    }
208    diag
209}
210
211#[cfg(test)]
212mod tests;