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}