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** ([`check_quill_reference`](Self::check_quill_reference)
98 /// semantics), so a 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;