Skip to main content

quillmark_core/quill/
compose.rs

1//! Consumer-facing operations on a [`Quill`]: validation, seeding, and the
2//! zero-filled compile to backend wire JSON. All pure reads of the quill's
3//! config: no backend, no engine (those live in the `quillmark` crate).
4
5use std::str::FromStr;
6
7use indexmap::IndexMap;
8
9use super::resolved::FieldSource;
10use super::{seed, CardSchema, CoercionError, FieldSchema, FieldType, Leniency, Quill, QuillConfig};
11use crate::normalize::{normalize_document, normalize_field_name};
12use crate::quill::zero_value;
13use crate::path::DocPath;
14use crate::{
15    Card, Diagnostic, Document, Payload, QuillValue, RenderError, SeedOverlay, Severity, Version,
16};
17
18impl Quill {
19    /// [`QuillConfig::compile_data`] on this quill's config.
20    pub fn compile_data(&self, doc: &Document) -> Result<serde_json::Value, RenderError> {
21        self.config().compile_data(doc)
22    }
23
24    /// [`QuillConfig::compile_checked`] on this quill's config.
25    pub fn compile_checked(&self, doc: &Document) -> Result<serde_json::Value, RenderError> {
26        self.config().compile_checked(doc)
27    }
28
29    /// Validate without backend compilation.
30    pub fn dry_run(&self, doc: &Document) -> Result<(), RenderError> {
31        self.config().dry_run(doc)
32    }
33
34    /// [`QuillConfig::check_quill_reference`] on this quill's config.
35    pub(crate) fn check_quill_reference(&self, doc: &Document) -> Result<(), RenderError> {
36        self.config().check_quill_reference(doc)
37    }
38}
39
40/// The document→data compile is a pure config read: coercion, validation,
41/// normalization, and zero-fill consult only the parsed schemas, never the
42/// quill's file tree. Living on [`QuillConfig`] lets a consumer that only
43/// compiles data (e.g. a live session's `apply`) retain the config alone
44/// rather than the whole quill with its font/package bytes.
45impl QuillConfig {
46    /// Applies coercion, validation, normalization, and **zero-filled render**:
47    /// every absent schema field is resolved to its authored value, else its
48    /// schema default, else its type-empty zero value, in this plate-JSON
49    /// projection only, never in the persisted document. A merely *incomplete*
50    /// document compiles fine; only a *malformed* one (a value that won't
51    /// coerce/validate) errors. A `!must_fill` placeholder never gates render:
52    /// it surfaces as a non-fatal warning from `validate`. See
53    /// `prose/canon/SCHEMAS.md`.
54    pub fn compile_data(&self, doc: &Document) -> Result<serde_json::Value, RenderError> {
55        // The gate is the **one** coercion pass: `coerce_and_validate` conforms
56        // every field (Render leniency, fallible) and validates, erroring on a
57        // malformed document. The ladder below consumes its coerced, NFC-normalized
58        // output rather than re-conforming: a document that reaches the ladder is
59        // already Render-conformed, so the plate is the sourced ladder with its
60        // rungs dropped. `resolve()` runs the total (keep-raw) conform for its own
61        // fallibility-free path; both cut the same [`ladder_sourced`].
62        let coerced = self.coerce_and_validate(doc)?;
63        let normalized = normalize_document(coerced)?;
64
65        let final_main = Card::from_parts(
66            rebuild_payload_with_meta(
67                normalized.main(),
68                plate_fields(ladder_sourced(
69                    &self.main,
70                    &normalized.main().payload().to_index_map(),
71                )),
72            ),
73            normalized.main().body().clone(),
74        );
75        // A card's `$body` is defined for the plate iff its kind resolves to a
76        // body-enabled schema: the `$body` half of "absent on
77        // undefined". Capture it here, where the schema is already in hand for
78        // field lowering, and hand it to the plate builder, so the decision is
79        // never re-derived from the serialized plate. (`$kind`, the document-
80        // defined half, is gated structurally by `to_plate_json`.)
81        let mut card_bodies: Vec<bool> = Vec::with_capacity(normalized.cards().len());
82        let cards_resolved: Vec<Card> = normalized
83            .cards()
84            .iter()
85            .map(|card| {
86                let schema = self.card_kind(card.kind().unwrap_or(""));
87                card_bodies.push(schema.is_some_and(|s| s.body_enabled()));
88                let fields = match schema {
89                    Some(schema) => {
90                        plate_fields(ladder_sourced(schema, &card.payload().to_index_map()))
91                    }
92                    // Unknown-kind card: authored fields verbatim, no ladder, as
93                    // the resolved-value view leaves it (`card_states`).
94                    None => card.payload().to_index_map(),
95                };
96                Card::from_parts(rebuild_payload_with_meta(card, fields), card.body().clone())
97            })
98            .collect();
99
100        Ok(Document::from_main_and_cards(final_main, cards_resolved)
101            .to_plate_json_gated(self.main.body_enabled(), Some(&card_bodies)))
102    }
103
104    /// [`compile_data`](Self::compile_data) behind the `$quill` pairing check:
105    /// the render door's whole preamble, in the one place that owns it. Every
106    /// door that turns a document into plate data for *this* schema goes
107    /// through here (`Quillmark::open` for a session's first compile,
108    /// [`LiveSession::update`](crate::LiveSession::update) for each edit), so
109    /// the pairing cannot be checked at one and skipped at the other.
110    ///
111    /// [`compile_data`](Self::compile_data) stays available unchecked for a
112    /// caller that wants the plate alone (the CLI's `--output-data`), where no
113    /// render follows and the pairing is the caller's to assert.
114    pub fn compile_checked(&self, doc: &Document) -> Result<serde_json::Value, RenderError> {
115        self.check_quill_reference(doc)?;
116        self.compile_data(doc)
117    }
118
119    /// Validate without backend compilation.
120    pub fn dry_run(&self, doc: &Document) -> Result<(), RenderError> {
121        self.check_quill_reference(doc)?;
122        self.coerce_and_validate(doc).map(|_| ())
123    }
124
125    fn coerce_and_validate(&self, doc: &Document) -> Result<Document, RenderError> {
126        let coerced_payload = self
127            .coerce_payload(&doc.main().payload().to_index_map())
128            .map_err(coercion_error)?;
129
130        let mut coerced_cards: Vec<Card> = Vec::with_capacity(doc.cards().len());
131        for card in doc.cards() {
132            let coerced_fields = self
133                .coerce_card(card.kind().unwrap_or(""), &card.payload().to_index_map())
134                .map_err(coercion_error)?;
135            coerced_cards.push(Card::from_parts(
136                rebuild_payload_with_meta(card, coerced_fields),
137                card.body().clone(),
138            ));
139        }
140
141        let coerced_main = Card::from_parts(
142            rebuild_payload_with_meta(doc.main(), coerced_payload),
143            doc.main().body().clone(),
144        );
145        let coerced_doc = Document::from_main_and_cards(coerced_main, coerced_cards);
146
147        // Only *malformed* input is fatal (a value that won't coerce/validate).
148        // An incomplete document (absent fields or `!must_fill` placeholders)
149        // renders fine via zero-fill. `validate_document` returns `Err` only
150        // with a non-empty error list; each error keeps its own `path` for UI
151        // navigation.
152        self.validate_document(&coerced_doc).map_err(|errors| {
153            RenderError::new(errors.iter().map(|e| e.to_diagnostic()).collect())
154        })?;
155
156        Ok(coerced_doc)
157    }
158
159    /// Enforce the document's `$quill` reference (`name@selector`) against this
160    /// quill, failing with a `quill::name_mismatch` / `quill::version_mismatch`
161    /// diagnostic if either component diverges. The document is well-formed; it
162    /// was paired with the wrong quill
163    /// (a different format, or an incompatible version of one) which yields
164    /// undefined output, so it errors rather than warns.
165    ///
166    /// Every schema-bound door runs it, the bound ingestion
167    /// ([`Quill::parse`](crate::Quill::parse) /
168    /// [`Quill::conform`](crate::Quill::conform)) included, so the message names
169    /// the pairing rather than a verb.
170    ///
171    /// Name is the prerequisite (a selector belongs to a *named* quill): a name
172    /// mismatch (`quill::name_mismatch`) short-circuits and the version is left
173    /// unevaluated; otherwise the selector is checked (`quill::version_mismatch`).
174    /// The version parses infallibly in practice (validated at load); if it
175    /// somehow doesn't, the version check is skipped.
176    pub(crate) fn check_quill_reference(&self, doc: &Document) -> Result<(), RenderError> {
177        let doc_ref = doc.quill_reference();
178
179        if doc_ref.name.as_str() != self.name {
180            return Err(quill_mismatch(
181                format!(
182                    "document declares $quill '{}' but was paired with '{}'",
183                    doc_ref, self.name
184                ),
185                "quill::name_mismatch",
186                "use the quill named by $quill, or update the $quill name",
187            ));
188        }
189
190        let Ok(quill_version) = Version::from_str(&self.version) else {
191            return Ok(());
192        };
193        if !doc_ref.selector.matches(quill_version) {
194            return Err(quill_mismatch(
195                format!(
196                    "document declares $quill '{}' but the loaded quill is version '{}'",
197                    doc_ref, quill_version
198                ),
199                "quill::version_mismatch",
200                "use a quill whose version satisfies the selector, or update the $quill selector",
201            ));
202        }
203
204        Ok(())
205    }
206}
207
208impl Quill {
209    /// Validate `doc` against this quill's schema, returning every diagnostic
210    /// (an empty `Vec` when the document is valid).
211    ///
212    /// The editor-facing validation surface. Forwards the canonical
213    /// `validation::*` diagnostics verbatim (same code, `path`, `hint`) so
214    /// consumers route on the code without parsing message text: type
215    /// mismatches, unknown card kinds, body-on-disabled-body, and the non-fatal
216    /// `validation::must_fill` warning, the only non-fatal one; the rest are
217    /// blockers. Field absence is not surfaced (it zero-fills at render).
218    ///
219    /// Field values, defaults, and presentation order are not part of this
220    /// surface: read them from the [`Document`] payload and the quill schema
221    /// (`quill.config().schema()`, whose key order is display order).
222    pub fn validate(&self, doc: &Document) -> Vec<Diagnostic> {
223        let mut diags = match self.config().validate_document(doc) {
224            Ok(()) => Vec::new(),
225            Err(errors) => errors.iter().map(|e| e.to_diagnostic()).collect(),
226        };
227        diags.extend(validate_fills(self.config(), doc));
228        diags.extend(self.validate_seed(doc));
229        diags
230    }
231
232    /// Advisory validation of the main card's `$seed` overlays.
233    ///
234    /// Seed overlays are editor-surface only: they never gate render
235    /// (`compile_data` / `dry_run` ignore `$seed`), so every diagnostic here is
236    /// a **warning** rooted at `$seed.<kind>[.<field>]`. An overlay keyed by a
237    /// name that is not a declared `card_kind` is flagged; otherwise each
238    /// overlaid field is checked against that kind's schema with the same
239    /// conformance core the schema's own `example:` / `default:` literals use
240    /// (partial values allowed, no null/absence gating).
241    /// The reserved `$body` key is the body override, not a field, and is
242    /// skipped.
243    fn validate_seed(&self, doc: &Document) -> Vec<Diagnostic> {
244        let Some(seed_map) = doc.main().payload().seed() else {
245            return Vec::new();
246        };
247        let config = self.config();
248        let mut diags = Vec::new();
249        for (kind, overlay) in seed_map {
250            let Some(card_schema) = config.card_kind(kind) else {
251                diags.push(
252                    Diagnostic::new(
253                        Severity::Warning,
254                        format!("`$seed` overlay targets unknown card kind `{kind}`"),
255                    )
256                    .with_code("validation::seed_unknown_kind".to_string())
257                    .with_path(DocPath::new().field("$seed").field(kind).to_string())
258                    .with_hint(format!(
259                        "Remove the `{kind}` overlay, or rename it to a declared card kind."
260                    )),
261                );
262                continue;
263            };
264            let Some(obj) = overlay.as_object() else {
265                diags.push(
266                    Diagnostic::new(
267                        Severity::Warning,
268                        format!("`$seed.{kind}` must be a mapping of field overrides"),
269                    )
270                    .with_code("validation::seed_overlay_shape".to_string())
271                    .with_path(DocPath::new().field("$seed").field(kind).to_string()),
272                );
273                continue;
274            };
275            for (field, value) in obj {
276                if field == "$body" {
277                    continue;
278                }
279                let field_path = DocPath::new().field("$seed").field(kind).field(field);
280                let Some(field_schema) = card_schema.fields.get(field) else {
281                    diags.push(
282                        Diagnostic::new(
283                            Severity::Warning,
284                            format!("`$seed.{kind}.{field}` is not a field of card kind `{kind}`"),
285                        )
286                        .with_code("validation::seed_unknown_field".to_string())
287                        .with_path(field_path.to_string()),
288                    );
289                    continue;
290                };
291                let qv = QuillValue::from_json(value.clone());
292                for violation in
293                    super::validation::validate_schema_literal(field_schema, &qv, &field_path)
294                {
295                    diags.push(seed_violation_diagnostic(&violation));
296                }
297            }
298        }
299        diags
300    }
301
302    /// Seed a starter [`Document`]: the main card plus one instance of each
303    /// declared composable card kind, each committing its fields' `example`
304    /// values and leaving all other fields absent (interpolated at render:
305    /// `default` → type-empty zero). The committed, structured "filled-out" twin
306    /// of the [`blueprint`](crate::quill::QuillConfig::blueprint). See the
307    /// `seed` module.
308    pub fn seed_document(&self) -> Document {
309        seed::seed_document(self)
310    }
311
312    /// Seed a starter main [`Card`] (carries `$quill`). Use as the main card of
313    /// a fresh document. See [`Quill::seed_document`].
314    pub fn seed_main(&self) -> Card {
315        seed::seed_main(self)
316    }
317
318    /// Seed a starter composable [`Card`] of the given kind (carries `$kind`),
319    /// layering an optional per-kind [`SeedOverlay`] over the schema-example
320    /// base (`overlay › example › absent`); `None` if the kind is not declared.
321    /// Use to add a new card to a document: pass the document's `$seed` entry
322    /// for the kind (`doc.main().seed().and_then(|m| m.get(card_kind)).and_then(SeedOverlay::from_json)`)
323    /// so a card spawned into a template-derived document inherits its curated
324    /// starting values, and `None` for the bare schema seed.
325    pub fn seed_card(&self, card_kind: &str, overlay: Option<&SeedOverlay>) -> Option<Card> {
326        seed::seed_card_for_kind(self, card_kind, overlay)
327    }
328}
329
330/// A single-diagnostic quill-mismatch failure. `path` is unset: the
331/// mismatch is the root `$quill` line, not a field.
332fn quill_mismatch(message: String, code: &str, hint: &str) -> RenderError {
333    RenderError::from_diag(
334        Diagnostic::new(Severity::Error, message)
335            .with_code(code.to_string())
336            .with_hint(hint.to_string()),
337    )
338}
339
340/// Render a seed-overlay validation error as a **warning**-severity diagnostic:
341/// seed overlays are advisory and never gate render. The error's `path` is
342/// already rooted at `$seed.<kind>.<field>` by the caller.
343fn seed_violation_diagnostic(v: &super::validation::ValidationError) -> Diagnostic {
344    let mut diag = Diagnostic::new(Severity::Warning, v.to_string())
345        .with_code(v.code().to_string())
346        .with_path(v.path().to_string())
347        .with_args(v.args());
348    if let Some(hint) = v.hint() {
349        diag = diag.with_hint(hint);
350    }
351    diag
352}
353
354/// Wrap a coercion error into a `validation::coercion_failed` failure.
355/// `Diagnostic::path` is unset: coercion runs before structured validation, and
356/// the anchor the error does carry is schema-space (see
357/// [`CoercionError::args`](super::config::CoercionError::args)).
358fn coercion_error(e: CoercionError) -> RenderError {
359    RenderError::from_diag(
360        Diagnostic::new(Severity::Error, e.to_string())
361            .with_code("validation::coercion_failed".to_string())
362            .with_args(e.args())
363            .with_hint("Ensure all fields can be coerced to their declared types".to_string()),
364    )
365}
366
367/// The total (keep-raw) resolver behind [`Quill::resolve`](crate::Quill::resolve):
368/// conform each authored value under Render leniency (keep-raw on failure, the
369/// fallibility-free path a consumer-side view needs), NFC-normalize the key, then
370/// cut the shared [`ladder_sourced`]. The render plate reaches the same rows by a
371/// different route: its gate does the fallible conform, and `compile_data` hands
372/// the coerced result straight to `ladder_sourced`, so the two cut one ladder
373/// over equal input (a document that passes the gate never takes the keep-raw
374/// branch), never a parallel precedence policy.
375pub(crate) fn resolve_card_sourced(
376    schema: &CardSchema,
377    card: &Card,
378) -> IndexMap<String, (QuillValue, FieldSource)> {
379    ladder_sourced(schema, &conform_card_render(schema, card))
380}
381
382/// Conform one card's authored fields under Render leniency, keep-raw on failure,
383/// NFC-normalizing each key: the total (infallible) coercion the resolved-value
384/// view runs in place of the render gate's fallible one. Every validated ingress
385/// (parse, the mutators) restricts field names to ASCII (NFC-invariant), so the
386/// normalization only respells keys on a directly-constructed payload
387/// (`Payload::from_index_map`), under the same NFC key the plate carries. A value
388/// Render coercion cannot conform is kept raw (the ladder reads it Authored); on a
389/// document that passes the gate that branch never fires, so this equals the gated
390/// path byte-for-byte.
391fn conform_card_render(schema: &CardSchema, card: &Card) -> IndexMap<String, QuillValue> {
392    let mut coerced: IndexMap<String, QuillValue> = IndexMap::new();
393    for (raw_name, value) in card.payload().to_index_map() {
394        let name = normalize_field_name(&raw_name);
395        let entry = match schema.fields.get(&raw_name) {
396            Some(field_schema) => {
397                QuillConfig::conform_value(&value, field_schema, &name, Leniency::Render)
398                    .unwrap_or(value)
399            }
400            None => value,
401        };
402        coerced.insert(name, entry);
403    }
404    coerced
405}
406
407/// The shared sourced ladder both canon projections cut, the render-fidelity
408/// plate ([`compile_data`](QuillConfig::compile_data)) and the resolved-value view
409/// ([`Quill::resolve`](crate::Quill::resolve)), over an already-coerced,
410/// NFC-normalized field map. For every declared field it reports the value the
411/// render projection uses and the [`FieldSource`] rung that produced it; undeclared
412/// authored fields carry through verbatim ([`Authored`](FieldSource::Authored)):
413/// the schema is a floor, not an allowlist.
414///
415/// Field order is authored-first with declared-but-absent fields appended: the
416/// render plate's order. Each projection re-cuts the presentation order it wants
417/// from this one value-and-source map (the view rows declared fields first in
418/// declaration order) rather than re-deriving the ladder against a parallel
419/// precedence policy (`prose/canon/SCHEMAS.md` § "Value sources and projections").
420/// Null ≡ absent applies recursively inside [`resolve_value_sourced`], so no bare
421/// null reaches either projection.
422pub(crate) fn ladder_sourced(
423    schema: &CardSchema,
424    coerced: &IndexMap<String, QuillValue>,
425) -> IndexMap<String, (QuillValue, FieldSource)> {
426    // Undeclared authored fields seed the map in authored order (verbatim,
427    // Authored); the declared fields then overlay in place (or append when
428    // absent) each carrying its ladder value and the source rung that produced
429    // it. Insert on an existing key preserves its authored position, so the
430    // order is authored-first, declared-but-absent appended.
431    let mut out: IndexMap<String, (QuillValue, FieldSource)> = coerced
432        .iter()
433        .map(|(name, value)| (name.clone(), (value.clone(), FieldSource::Authored)))
434        .collect();
435    for (name, field_schema) in &schema.fields {
436        out.insert(
437            name.clone(),
438            resolve_value_sourced(coerced.get(name), field_schema),
439        );
440    }
441    out
442}
443
444/// Drop the source rungs from [`resolve_card_sourced`]'s map: the render plate
445/// consumes the value half only; the resolved-value view keeps both.
446fn plate_fields(
447    sourced: IndexMap<String, (QuillValue, FieldSource)>,
448) -> IndexMap<String, QuillValue> {
449    sourced
450        .into_iter()
451        .map(|(name, (value, _source))| (name, value))
452        .collect()
453}
454
455/// The value half of [`resolve_value_sourced`], discarding the rung tag: the
456/// nested-recursion helper for a typed dictionary's properties and a typed
457/// array's elements, where the source of an inner cell is not surfaced (a
458/// present dict/array is [`Authored`](FieldSource::Authored) as a whole). Both
459/// canon projections cut the sourced ladder through [`resolve_card_sourced`];
460/// this is the inner value-only cut beneath it.
461fn resolve_value(value: Option<&QuillValue>, field: &FieldSchema) -> QuillValue {
462    resolve_value_sourced(value, field).0
463}
464
465/// Resolve one (possibly absent or null) value against its field schema,
466/// reporting the [`FieldSource`] rung that produced it, and applying null ≡
467/// absent recursively so no bare null reaches the plate:
468///
469/// - A null or absent value becomes the schema `default:`
470///   ([`Default`](FieldSource::Default)), else the type-empty [`zero_value`]
471///   ([`Zero`](FieldSource::Zero)).
472/// - A present **typed dictionary** is rebuilt from its declared properties so a
473///   null/absent property zero-fills and the projection matches the schema shape.
474///   Source keys the schema does not declare pass through verbatim, matching
475///   `config::coerce_object_props`'s coercion-time behavior: the schema is a
476///   floor, not an allowlist, so an undeclared `note:` on a typed dict reaches
477///   the plate instead of being silently dropped.
478/// - A present **typed array** resolves each element against the item schema, so
479///   a null element zero-fills in place.
480/// - Any other present value is returned unchanged.
481///
482/// Every present shape is [`Authored`](FieldSource::Authored) (the nested
483/// zero-fill inside a dict/array is a projection detail, not a source change).
484/// The source is the byproduct of the same branch that computes the value, so
485/// the render projection ([`resolve_value`]) and the field-state view cut the
486/// one commitment ladder rather than each re-deriving precedence
487/// (`prose/canon/SCHEMAS.md` § "Value sources and projections").
488pub(crate) fn resolve_value_sourced(
489    value: Option<&QuillValue>,
490    field: &FieldSchema,
491) -> (QuillValue, FieldSource) {
492    let present = value.filter(|v| !v.as_json().is_null());
493    let Some(v) = present else {
494        // A content-bearing field (`richtext` or its literal sibling
495        // `plaintext`) commits the *content* form of its default
496        // (`default_content`, cached at load by `from_yaml_with_warnings`), so
497        // the seam carries canonical Content-JSON the backend can classify. It
498        // must NOT fall through to the raw `default`: the ladder injects this
499        // default without re-coercing it (coercion touched only authored
500        // values), so a bare authored string here would reach the plate
501        // uncoerced and be misread. A content field with no cached
502        // `default_content` (only reachable via a serde-built `QuillConfig`,
503        // never the loader) zero-fills to the empty content.
504        if matches!(
505            field.r#type,
506            FieldType::RichText { .. } | FieldType::PlainText { .. }
507        ) {
508            return match field.default_content.clone() {
509                Some(content) => (content, FieldSource::Default),
510                None => (zero_value(field), FieldSource::Zero),
511            };
512        }
513        // Non-content: `default_content` is always `None`, so use the raw
514        // `default`, then the type-empty zero.
515        return match field.default.clone() {
516            Some(default) => (default, FieldSource::Default),
517            None => (zero_value(field), FieldSource::Zero),
518        };
519    };
520    let resolved = match (&field.r#type, &field.properties, &field.items) {
521        (FieldType::Object, Some(props), _) => {
522            let obj = v.as_json().as_object();
523            let mut out = serde_json::Map::new();
524            for (pname, pschema) in props {
525                let pv = obj
526                    .and_then(|o| o.get(pname))
527                    .map(|j| QuillValue::from_json(j.clone()));
528                out.insert(
529                    pname.clone(),
530                    resolve_value(pv.as_ref(), pschema).into_json(),
531                );
532            }
533            // Preserve undeclared keys verbatim; only rebuild the ones the
534            // schema names. Skips keys already emitted above so a declared
535            // property keeps its resolved (zero-filled) value.
536            if let Some(o) = obj {
537                for (k, v) in o {
538                    if !props.contains_key(k) {
539                        out.insert(k.clone(), v.clone());
540                    }
541                }
542            }
543            QuillValue::from_json(serde_json::Value::Object(out))
544        }
545        (FieldType::Array, _, Some(items)) => {
546            let arr = v.as_json().as_array().cloned().unwrap_or_default();
547            let out: Vec<serde_json::Value> = arr
548                .into_iter()
549                .map(|e| resolve_value(Some(&QuillValue::from_json(e)), items).into_json())
550                .collect();
551            QuillValue::from_json(serde_json::Value::Array(out))
552        }
553        _ => v.clone(),
554    };
555    (resolved, FieldSource::Authored)
556}
557
558/// Build a [`Payload`] from a coerced/defaulted field map, re-attaching `$quill`
559/// / `$kind` from `source`. Comments are dropped: this payload feeds
560/// backend rendering, not round-trip storage.
561fn rebuild_payload_with_meta(source: &Card, fields: IndexMap<String, QuillValue>) -> Payload {
562    let mut payload = Payload::from_index_map(fields);
563    if let Some(q) = source.quill() {
564        payload.set_quill(q.clone());
565    }
566    if let Some(k) = source.kind() {
567        payload.set_kind(k.to_string());
568    }
569    payload
570}
571
572/// Surface every `!must_fill` marker as a non-fatal **warning**, root-and-nested
573/// across the main card and every composable card.
574///
575/// The marker fires whether or not the cell carries a suggested value, and never
576/// gates render (the cell zero-fills or uses its suggested value). A strict
577/// consumer treats any outstanding marker as "not done".
578fn validate_fills(config: &QuillConfig, doc: &Document) -> Vec<Diagnostic> {
579    let mut diags = Vec::new();
580    collect_fill_diags(doc.main(), &DocPath::main(), &mut diags);
581    for (index, card) in doc.cards().iter().enumerate() {
582        // A card whose declared `$kind` has no schema drops the kind segment and
583        // stays `cards[<i>]`, matching `validate_typed_document`; a
584        // schema-declared kind qualifies as `cards.<kind>[<i>]`.
585        let kind = card.kind().filter(|k| config.card_kind(k).is_some());
586        collect_fill_diags(card, &DocPath::card(kind, index), &mut diags);
587    }
588    diags
589}
590
591/// Append a `validation::must_fill` warning for each marker in `card`'s fields.
592fn collect_fill_diags(card: &Card, base: &DocPath, out: &mut Vec<Diagnostic>) {
593    let payload = card.payload();
594    for (key, value) in payload {
595        let field_path = base.field(key);
596        // Root marker (the field-level `fill` flag) plus any nested markers
597        // carried on the value tree, each rebased onto the field path.
598        if payload.is_fill(key) {
599            out.push(fill_warning(&field_path));
600        }
601        for nested in value.nonroot_fill_paths() {
602            let nested_path = nested.iter().fold(field_path.clone(), |p, s| p.segment(s));
603            out.push(fill_warning(&nested_path));
604        }
605    }
606}
607
608fn fill_warning(path: &DocPath) -> Diagnostic {
609    let path = path.to_string();
610    Diagnostic::new(
611        Severity::Warning,
612        format!("Field `{path}` is marked `!must_fill`: a placeholder awaiting a value."),
613    )
614    .with_code("validation::must_fill".to_string())
615    .with_path(path)
616    .with_hint(
617        "Replace the value and drop the `!must_fill` marker, or remove the marker if the \
618         current value is intended."
619            .to_string(),
620    )
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626    use serde_json::json;
627
628    fn field(yaml: &str) -> FieldSchema {
629        let value = QuillValue::from_yaml_str(yaml).unwrap();
630        FieldSchema::from_quill_value("field".to_string(), &value).unwrap()
631    }
632
633    // A typed dictionary carrying a key the schema does not declare keeps that
634    // key in the resolved projection (regression guard: the schema is
635    // a floor, not an allowlist). Declared-but-absent properties still zero-fill.
636    #[test]
637    fn typed_dict_preserves_undeclared_keys() {
638        let schema = field(
639            r#"
640type: object
641properties:
642  street: { type: string }
643  zip: { type: integer }
644"#,
645        );
646        let input = QuillValue::from_json(json!({ "street": "1 Infinite Loop", "note": "extra" }));
647
648        let resolved = resolve_value(Some(&input), &schema).into_json();
649
650        assert_eq!(
651            resolved,
652            json!({ "street": "1 Infinite Loop", "zip": 0, "note": "extra" })
653        );
654    }
655
656    // A card whose declared `$kind` has no schema anchors its `!must_fill`
657    // warning at the bare-index root `cards[<i>].<field>` (matching
658    // `validate_typed_document`'s unknown-card path) never `cards.<kind>[<i>]`.
659    // A truly kindless card (no `$kind`) stays bare-index the same way.
660    #[test]
661    fn unknown_kind_card_fill_path_is_bare_index() {
662        use crate::document::Payload;
663
664        let config = QuillConfig::from_yaml(
665            r#"
666quill:
667  name: fills_test
668  backend: typst
669  description: fill path tests
670  version: 1.0.0
671main:
672  fields:
673    title:
674      type: string
675      default: ""
676card_kinds:
677  known:
678    fields:
679      note:
680        type: string
681"#,
682        )
683        .unwrap();
684
685        let mut main = Payload::new();
686        main.set_quill("fills_test@1.0.0".parse().unwrap());
687        main.set_kind("main");
688        let main = Card::from_parts(main, quillmark_content::Content::empty());
689
690        // Index 0: a card whose `$kind` ("mystery") is not a declared card kind.
691        let mut unknown = Card::new("mystery").unwrap();
692        unknown
693            .store_fill("note", QuillValue::from_json(json!(null)))
694            .unwrap();
695
696        // Index 1: a kindless card (no `$kind` at all).
697        let mut kindless =
698            Card::from_parts(Payload::new(), quillmark_content::Content::empty());
699        kindless
700            .store_fill("memo", QuillValue::from_json(json!(null)))
701            .unwrap();
702
703        let doc = Document::from_main_and_cards(main, vec![unknown, kindless]);
704        let paths: Vec<String> = validate_fills(&config, &doc)
705            .iter()
706            .filter_map(|d| d.path.clone())
707            .collect();
708
709        assert!(
710            paths.contains(&"cards[0].note".to_string()),
711            "unknown-kind card fill must anchor at the bare index; got {paths:?}"
712        );
713        assert!(
714            !paths.iter().any(|p| p.starts_with("cards.mystery")),
715            "unknown-kind card fill must NOT carry the kind segment; got {paths:?}"
716        );
717        assert!(
718            paths.contains(&"cards[1].memo".to_string()),
719            "kindless card fill must anchor at the bare index; got {paths:?}"
720        );
721    }
722
723    // Same pass-through inside a typed-table row (the Array→Object recursion).
724    #[test]
725    fn typed_table_row_preserves_undeclared_keys() {
726        let schema = field(
727            r#"
728type: array
729items:
730  type: object
731  properties:
732    name: { type: string }
733"#,
734        );
735        let input = QuillValue::from_json(json!([{ "name": "ACME", "year": 2020 }]));
736
737        let resolved = resolve_value(Some(&input), &schema).into_json();
738
739        assert_eq!(resolved, json!([{ "name": "ACME", "year": 2020 }]));
740    }
741
742    // ── "Absent on undefined": the render plate omits `$body` wherever the
743    //    schema defines none (issue 1030). The `$kind` half is structural in
744    //    `to_plate_json`; these pin the schema-gated `$body` half. Only the
745    //    body-disabled edge is reachable here: `validate_document` rejects an
746    //    unknown-kind or kindless card before render.
747
748    fn plate_of(yaml: &str, md: &str) -> serde_json::Value {
749        let config = QuillConfig::from_yaml(yaml).expect("valid quill");
750        let doc = Document::parse(md).expect("parse").document;
751        config.compile_data(&doc).expect("compile")
752    }
753
754    #[test]
755    fn body_disabled_kind_omits_dollar_body() {
756        let plate = plate_of(
757            r#"
758quill: { name: bd, version: 1.0.0, backend: typst, description: x }
759main:
760  fields:
761    title: { type: string }
762card_kinds:
763  stamp:
764    body:
765      enabled: false
766    fields:
767      label: { type: string }
768"#,
769            "~~~card-yaml\n$quill: bd@1.0.0\n$kind: main\ntitle: T\n~~~\n\n\
770             ~~~card-yaml\n$kind: stamp\nlabel: L\n~~~\n",
771        );
772        let card = &plate["$cards"][0];
773        assert_eq!(card["$kind"], "stamp", "$kind is document-defined, kept");
774        assert_eq!(card["label"], "L", "declared fields kept");
775        assert!(
776            card.get("$body").is_none(),
777            "a body-disabled kind carries no $body in the plate; got {card}"
778        );
779    }
780
781    #[test]
782    fn body_disabled_main_omits_root_dollar_body() {
783        let plate = plate_of(
784            r#"
785quill: { name: bdm, version: 1.0.0, backend: typst, description: x }
786main:
787  body:
788    enabled: false
789  fields:
790    title: { type: string }
791"#,
792            "~~~card-yaml\n$quill: bdm@1.0.0\n$kind: main\ntitle: T\n~~~\n",
793        );
794        assert_eq!(plate["title"], "T");
795        assert!(
796            plate.get("$body").is_none(),
797            "a body-disabled main carries no root $body; got {plate}"
798        );
799    }
800
801    #[test]
802    fn body_enabled_keeps_dollar_body() {
803        let plate = plate_of(
804            r#"
805quill: { name: be, version: 1.0.0, backend: typst, description: x }
806main:
807  fields:
808    title: { type: string }
809card_kinds:
810  note:
811    fields:
812      tag: { type: string }
813"#,
814            "~~~card-yaml\n$quill: be@1.0.0\n$kind: main\ntitle: T\n~~~\n\n\
815             Main body.\n\n\
816             ~~~card-yaml\n$kind: note\ntag: x\n~~~\nNote body.\n",
817        );
818        assert_eq!(
819            plate["$body"]["text"], "Main body.",
820            "a body-enabled main keeps its $body"
821        );
822        let card = &plate["$cards"][0];
823        assert_eq!(
824            card["$body"]["text"], "Note body.",
825            "a body-enabled kind keeps its $body content object"
826        );
827    }
828}