quillmark_core/quill/config.rs
1//! Quill configuration parsing and normalization.
2use std::collections::{BTreeSet, HashMap, HashSet};
3use std::error::Error as StdError;
4
5use indexmap::IndexMap;
6
7use serde::{Deserialize, Serialize};
8
9use crate::error::{Diagnostic, Severity};
10use crate::value::QuillValue;
11
12use super::types::{RICHTEXT_INLINE_TOKEN_MSG, UI_ORDER_REMOVED_MSG};
13use super::{BodyCardSchema, CardSchema, FieldSchema, FieldType, GroupRegistry, UiCardSchema};
14
15/// Canonical string text for a bare scalar unambiguously representable as a
16/// string: a boolean (`true`/`false`) or number (`47`, `1.0`). `None` for
17/// `null` (≡ absent), strings (already strings), and collections.
18///
19/// Shared by `QuillConfig::conform_value` (to adopt the value) and
20/// `validation::validate_value` (to accept it), so coercion and validation
21/// never disagree about which bare scalars a `string` field accepts.
22pub(crate) fn scalar_as_string(value: &serde_json::Value) -> Option<String> {
23 match value {
24 serde_json::Value::Bool(b) => Some(b.to_string()),
25 serde_json::Value::Number(n) => Some(n.to_string()),
26 _ => None,
27 }
28}
29
30/// Reduce a lenient value to its authored-string form: a bare string, the
31/// sole element of a length-1 array when that element is a string (the
32/// array-unwrap leniency), or a bare scalar's canonical text (via
33/// [`scalar_as_string`]). `None` for anything else (a multi-element array, an
34/// object, null), leaving the caller's own fallback to apply.
35///
36/// Shared by the `String` and `Content` coercion branches, which both reduce
37/// a lenient value to a string before adopting it (as the field value itself,
38/// or as markdown to import).
39fn lenient_string(value: &serde_json::Value) -> Option<String> {
40 if let Some(s) = value.as_str() {
41 return Some(s.to_string());
42 }
43 if let Some(s) = value
44 .as_array()
45 .filter(|a| a.len() == 1)
46 .and_then(|a| a[0].as_str())
47 {
48 return Some(s.to_string());
49 }
50 scalar_as_string(value)
51}
52
53/// Top-level configuration for a Quillmark project
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55#[non_exhaustive]
56pub struct QuillConfig {
57 /// Quill package name
58 pub name: String,
59 /// Human-readable description of the quill itself (parsed from
60 /// `quill.description`). Distinct from `main.description`, which describes
61 /// the main card's schema.
62 pub description: String,
63 /// The entry-point card schema (parsed from the Quill.yaml `main:` section).
64 pub main: CardSchema,
65 /// Named, composable card-kind schemas (parsed from the Quill.yaml
66 /// `card_kinds:` section). Does not include `main`.
67 pub card_kinds: Vec<CardSchema>,
68 /// Backend to use for rendering (e.g., "typst", "html")
69 pub backend: String,
70 /// Version of the Quillmark spec
71 pub version: String,
72 /// Author of the project
73 pub author: String,
74 /// Backend-specific configuration parsed from the top-level YAML section
75 /// whose key matches `backend` (e.g. `[typst]`, `[html]`).
76 #[serde(default)]
77 pub backend_config: HashMap<String, QuillValue>,
78}
79
80impl QuillConfig {
81 /// The four fields `Quill.yaml` requires. `description`, `author`,
82 /// `card_kinds`, and `backend_config` start empty.
83 ///
84 /// This bypasses [`Self::from_yaml`] and its validation, so a config built
85 /// here can hold shapes the parser refuses. Loading a quill goes through
86 /// `from_yaml`. This is for a caller assembling a schema in memory.
87 pub fn new(name: String, backend: String, version: String, main: CardSchema) -> Self {
88 Self {
89 name,
90 description: String::new(),
91 main,
92 card_kinds: Vec::new(),
93 backend,
94 version,
95 author: String::new(),
96 backend_config: HashMap::new(),
97 }
98 }
99}
100
101#[derive(Debug, Deserialize)]
102#[serde(deny_unknown_fields)]
103struct CardSchemaDef {
104 pub description: Option<String>,
105 // Declared so `deny_unknown_fields` accepts a `fields:` block on a card.
106 // Fields are parsed separately via `parse_fields` (per-field diagnostics).
107 #[allow(dead_code)]
108 pub fields: Option<serde_json::Map<String, serde_json::Value>>,
109 pub ui: Option<UiCardSchema>,
110 pub body: Option<BodyCardSchema>,
111}
112
113/// Depth context for [`QuillConfig::validate_field_schema_shape`]. Encodes
114/// which shapes are legal at the current nesting level, so the one-level
115/// nesting contract is enforced by a single recursive walk.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117enum ShapePosition {
118 /// A field declared directly on a card: scalar, object, or array.
119 Top,
120 /// An array's `items`: scalar or object (typed-table row), not an array.
121 ArrayItem,
122 /// An object's property: scalar only.
123 Leaf,
124}
125
126#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
127#[non_exhaustive]
128pub enum CoercionError {
129 #[error("cannot coerce `{value}` to type `{target}` at `{path}`: {reason}")]
130 Uncoercible {
131 path: String,
132 value: String,
133 target: String,
134 reason: String,
135 },
136}
137
138/// Write-side leniency mode for [`QuillConfig::conform_value`]: the one axis
139/// that separates the render floor's forgiving coercion from a strict typed
140/// write.
141///
142/// The dispatch is shared; only the arms that *defer to the validation layer*
143/// or *cross type boundaries* branch on this. See `conform_value`.
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub(crate) enum Leniency {
146 /// The render floor's forgiving cascade: cross-type scalar coercions apply
147 /// and a shape a type cannot adopt falls through unchanged for the
148 /// validation layer to report.
149 Render,
150 /// A strict typed write ([`Card::commit_field`](crate::document::Card::commit_field)):
151 /// value-parsing normalizations still apply (`"3"` → `3`, a bare scalar
152 /// wraps into a singleton array, richtext markdown imports to content), but
153 /// cross-type `Boolean`↔`Number` coercions are dropped and every
154 /// defer-to-validation fall-through becomes a `CoercionError`, so a
155 /// mismatched value fails at the write, not silently at a later render.
156 ///
157 /// "Strict" is asymmetric by target, not absolute: `string` and `array` are
158 /// universal sinks, so a scalar→`string` (`true` → `"true"`) and a
159 /// scalar→singleton-`array` wrap stay lenient even here (both are lossless,
160 /// unambiguous, and author-intended); only the lossy/ambiguous crossings
161 /// (scalar→`object`, `String`→`number`/`bool`, `Boolean`↔`Number`) are
162 /// rejected. A strict write thus still reshapes toward `string`/`array`
163 /// while refusing to invent structure or reinterpret a scalar's type.
164 Write,
165}
166
167impl QuillConfig {
168 /// Returns a named card-kind schema by name.
169 pub fn card_kind(&self, name: &str) -> Option<&CardSchema> {
170 self.card_kinds.iter().find(|card| card.name == name)
171 }
172
173 /// Full schema including `ui` hints.
174 ///
175 /// Describes the user-fillable fields of the main card and each named
176 /// card kind. The quill reference (constructed as `name@version` from
177 /// quill metadata) and card-kind discriminators are document-level
178 /// metadata, not fields, so they do not appear here.
179 ///
180 /// Key order is the ordering contract: fields, nested properties, and card
181 /// kinds all emit in declaration order (`preserve_order` end-to-end), so a
182 /// consumer walking the maps in key order renders the authored layout.
183 pub fn schema(&self) -> serde_json::Value {
184 let mut obj = serde_json::Map::new();
185
186 let main_value =
187 serde_json::to_value(&self.main).expect("CardSchema is always serializable");
188 obj.insert("main".to_string(), main_value);
189
190 if !self.card_kinds.is_empty() {
191 let mut card_kinds = serde_json::Map::new();
192 for card in &self.card_kinds {
193 let card_value =
194 serde_json::to_value(card).expect("CardSchema is always serializable");
195 card_kinds.insert(card.name.clone(), card_value);
196 }
197 obj.insert(
198 "card_kinds".to_string(),
199 serde_json::Value::Object(card_kinds),
200 );
201 }
202
203 serde_json::Value::Object(obj)
204 }
205
206 /// Coerce typed payload fields (IndexMap of user fields only).
207 pub fn coerce_payload(
208 &self,
209 payload: &IndexMap<String, QuillValue>,
210 ) -> Result<IndexMap<String, QuillValue>, CoercionError> {
211 let mut coerced: IndexMap<String, QuillValue> = IndexMap::new();
212 for (field_name, field_value) in payload {
213 if let Some(field_schema) = self.main.fields.get(field_name) {
214 let path = field_name.as_str();
215 coerced.insert(
216 field_name.clone(),
217 Self::conform_value(field_value, field_schema, path, Leniency::Render)?,
218 );
219 } else {
220 coerced.insert(field_name.clone(), field_value.clone());
221 }
222 }
223 Ok(coerced)
224 }
225
226 /// Coerce typed fields for a single card (IndexMap of user fields only).
227 ///
228 /// Returns the input unchanged when the card kind is unknown.
229 pub fn coerce_card(
230 &self,
231 card_kind: &str,
232 fields: &IndexMap<String, QuillValue>,
233 ) -> Result<IndexMap<String, QuillValue>, CoercionError> {
234 let Some(card_schema) = self.card_kind(card_kind) else {
235 return Ok(fields.clone());
236 };
237 let mut coerced: IndexMap<String, QuillValue> = IndexMap::new();
238 for (field_name, field_value) in fields {
239 if let Some(field_schema) = card_schema.fields.get(field_name) {
240 let path = format!("card_kinds.{card_kind}.{field_name}");
241 coerced.insert(
242 field_name.clone(),
243 Self::conform_value(field_value, field_schema, &path, Leniency::Render)?,
244 );
245 } else {
246 coerced.insert(field_name.clone(), field_value.clone());
247 }
248 }
249 Ok(coerced)
250 }
251
252 /// Validate a typed [`crate::document::Document`] against this configuration.
253 pub fn validate_document(
254 &self,
255 doc: &crate::document::Document,
256 ) -> Result<(), Vec<super::validation::ValidationError>> {
257 super::validation::validate_typed_document(self, doc)
258 }
259
260 /// The one write-side per-type dispatch: given a value, a field's schema,
261 /// and a [`Leniency`] mode, validate/normalize the value to the canonical
262 /// form the type stores. `Render` is the render floor's forgiving coercion;
263 /// `Write` is the strict typed-write commit driving
264 /// [`Card::commit_field`](crate::document::Card::commit_field).
265 ///
266 /// Validation keeps its own read-only dispatch (`validation::validate_value`),
267 /// synced with this via the shared helpers `scalar_as_string` /
268 /// `decode_richtext_value`.
269 pub(crate) fn conform_value(
270 value: &QuillValue,
271 field_schema: &super::FieldSchema,
272 path: &str,
273 mode: Leniency,
274 ) -> Result<QuillValue, CoercionError> {
275 use super::FieldType;
276
277 let json_value = value.as_json();
278
279 // Null ≡ absent: a present-null value (`field:`, `field: null`,
280 // `field: ~`) carries no data, so it passes through coercion unchanged
281 // for every type rather than failing as a mismatch. The render floor
282 // and the validation layer treat it the same as an omitted field. This
283 // also preserves a `!must_fill` marker riding on `value` (the fill flag
284 // is never part of the JSON projection).
285 if json_value.is_null() {
286 return Ok(value.clone());
287 }
288
289 match field_schema.r#type {
290 FieldType::Array => {
291 let arr = if let Some(a) = json_value.as_array() {
292 a.clone()
293 } else {
294 vec![json_value.clone()]
295 };
296
297 // Every array carries an element schema (`items`). Coerce each
298 // element against it: scalar items (`string[]`, `integer[]`,
299 // `richtext[]`) coerce element-wise; object items recurse into
300 // the element's `properties` via the Object branch.
301 if let Some(items) = &field_schema.items {
302 let mut out = Vec::with_capacity(arr.len());
303 for (idx, elem) in arr.iter().enumerate() {
304 let coerced = Self::conform_value(
305 &QuillValue::from_json(elem.clone()),
306 items,
307 &format!("{path}[{idx}]"),
308 mode,
309 )?;
310 out.push(coerced.into_json());
311 }
312 Ok(QuillValue::from_json(serde_json::Value::Array(out)))
313 } else {
314 // Defensive fallback: schema-load rejects any array without
315 // `items` (quill::array_missing_items), so a validated
316 // config never reaches here, pass the array through as-is.
317 Ok(QuillValue::from_json(serde_json::Value::Array(arr)))
318 }
319 }
320 FieldType::Boolean => {
321 if let Some(b) = json_value.as_bool() {
322 return Ok(QuillValue::from_json(serde_json::Value::Bool(b)));
323 }
324 if let Some(s) = json_value.as_str() {
325 let lower = s.to_lowercase();
326 if lower == "true" {
327 return Ok(QuillValue::from_json(serde_json::Value::Bool(true)));
328 } else if lower == "false" {
329 return Ok(QuillValue::from_json(serde_json::Value::Bool(false)));
330 }
331 }
332 // Cross-type number→boolean is a render-floor leniency; a strict
333 // write requires an actual boolean or its `"true"`/`"false"` text.
334 if mode == Leniency::Render {
335 if let Some(n) = json_value.as_i64() {
336 return Ok(QuillValue::from_json(serde_json::Value::Bool(n != 0)));
337 }
338 if let Some(n) = json_value.as_f64() {
339 if n.is_nan() {
340 return Ok(QuillValue::from_json(serde_json::Value::Bool(false)));
341 }
342 return Ok(QuillValue::from_json(serde_json::Value::Bool(
343 n.abs() > f64::EPSILON,
344 )));
345 }
346 }
347
348 Err(CoercionError::Uncoercible {
349 path: path.to_string(),
350 value: json_value.to_string(),
351 target: "boolean".to_string(),
352 reason: "value is not coercible to boolean".to_string(),
353 })
354 }
355 FieldType::Number => {
356 if json_value.is_number() {
357 return Ok(value.clone());
358 }
359 if let Some(s) = json_value.as_str() {
360 if let Ok(i) = s.parse::<i64>() {
361 return Ok(QuillValue::from_json(serde_json::Number::from(i).into()));
362 }
363 if let Ok(f) = s.parse::<f64>() {
364 if let Some(num) = serde_json::Number::from_f64(f) {
365 return Ok(QuillValue::from_json(num.into()));
366 }
367 }
368 return Err(CoercionError::Uncoercible {
369 path: path.to_string(),
370 value: s.to_string(),
371 target: "number".to_string(),
372 reason: "string is not a valid number".to_string(),
373 });
374 }
375 // Cross-type boolean→number is a render-floor leniency only.
376 if mode == Leniency::Render {
377 if let Some(b) = json_value.as_bool() {
378 let n = if b { 1 } else { 0 };
379 return Ok(QuillValue::from_json(serde_json::Value::Number(
380 serde_json::Number::from(n),
381 )));
382 }
383 }
384
385 Err(CoercionError::Uncoercible {
386 path: path.to_string(),
387 value: json_value.to_string(),
388 target: "number".to_string(),
389 reason: "value is not coercible to number".to_string(),
390 })
391 }
392 FieldType::Integer => {
393 if let Some(i) = json_value.as_i64() {
394 return Ok(QuillValue::from_json(serde_json::Number::from(i).into()));
395 }
396 if let Some(u) = json_value.as_u64() {
397 if let Ok(i) = i64::try_from(u) {
398 return Ok(QuillValue::from_json(serde_json::Number::from(i).into()));
399 }
400 return Err(CoercionError::Uncoercible {
401 path: path.to_string(),
402 value: json_value.to_string(),
403 target: "integer".to_string(),
404 reason: "integer value exceeds i64 range".to_string(),
405 });
406 }
407 if let Some(s) = json_value.as_str() {
408 if let Ok(i) = s.parse::<i64>() {
409 return Ok(QuillValue::from_json(serde_json::Number::from(i).into()));
410 }
411 return Err(CoercionError::Uncoercible {
412 path: path.to_string(),
413 value: s.to_string(),
414 target: "integer".to_string(),
415 reason: "string is not a valid integer".to_string(),
416 });
417 }
418 // Cross-type boolean→integer is a render-floor leniency only.
419 if mode == Leniency::Render {
420 if let Some(b) = json_value.as_bool() {
421 let n = if b { 1 } else { 0 };
422 return Ok(QuillValue::from_json(serde_json::Value::Number(
423 serde_json::Number::from(n),
424 )));
425 }
426 }
427
428 Err(CoercionError::Uncoercible {
429 path: path.to_string(),
430 value: json_value.to_string(),
431 target: "integer".to_string(),
432 reason: "value is not coercible to integer".to_string(),
433 })
434 }
435 // Enum is open scalar data drawn from a closed domain: coerced as a
436 // string here; domain membership is checked at the validation layer
437 // (an out-of-domain string is a value error, not a type error).
438 FieldType::String | FieldType::Enum => {
439 if json_value.is_string() {
440 return Ok(value.clone());
441 }
442 // Gracious leniency: unwrap a length-1 array's sole string
443 // element, or adopt a bare bool/number's canonical text (an
444 // author writing `verified: true` for a `string` field), rather
445 // than reject it. Null is handled above; other collections fall
446 // through.
447 if let Some(text) = lenient_string(json_value) {
448 return Ok(QuillValue::from_json(serde_json::Value::String(text)));
449 }
450 // A non-stringifiable shape (object, multi-element array): the
451 // render floor defers to validation, a strict write fails now.
452 match mode {
453 Leniency::Render => Ok(value.clone()),
454 Leniency::Write => Err(CoercionError::Uncoercible {
455 path: path.to_string(),
456 value: json_value.to_string(),
457 target: field_schema.r#type.as_str().to_string(),
458 reason: "value is not a string".to_string(),
459 }),
460 }
461 }
462 FieldType::PlainText { inline } => {
463 // Plaintext rides the same content as richtext but through the
464 // *literal* codec: a string is imported verbatim via
465 // `from_plaintext` (no markdown parsing, no escaping), an
466 // already-structured content is validated plain. A wire content
467 // carrying marks or islands is rejected, not silently stripped:
468 // matching the `inline` precedent and keeping coercion lossless.
469 let plain_check =
470 |rt: &quillmark_content::Content| -> Result<(), CoercionError> {
471 if !rt.is_plain() {
472 return Err(CoercionError::Uncoercible {
473 path: path.to_string(),
474 value: "<plaintext>".to_string(),
475 target: "plaintext".to_string(),
476 reason: "plaintext carries no marks, islands, or block \
477 formatting (lists, quotes, headings)"
478 .to_string(),
479 });
480 }
481 if inline && !rt.is_inline() {
482 return Err(CoercionError::Uncoercible {
483 path: path.to_string(),
484 value: "<plaintext>".to_string(),
485 target: "plaintext(inline)".to_string(),
486 reason: "plaintext(inline) requires a single line".to_string(),
487 });
488 }
489 Ok(())
490 };
491 if json_value.is_object() {
492 let rt = quillmark_content::serial::from_canonical_value(json_value).map_err(
493 |e| CoercionError::Uncoercible {
494 path: path.to_string(),
495 value: "<object>".to_string(),
496 target: "plaintext".to_string(),
497 reason: format!("not a valid richtext content: {e}"),
498 },
499 )?;
500 plain_check(&rt)?;
501 return Ok(QuillValue::from_json(
502 quillmark_content::serial::to_canonical_value(&rt),
503 ));
504 }
505 // Reduce to the authored literal string via the shared leniency
506 // cascade, then import verbatim.
507 let Some(text) = lenient_string(json_value) else {
508 return match mode {
509 Leniency::Render => Ok(value.clone()),
510 Leniency::Write => Err(CoercionError::Uncoercible {
511 path: path.to_string(),
512 value: json_value.to_string(),
513 target: "plaintext".to_string(),
514 reason: "value is not a plaintext string or content".to_string(),
515 }),
516 };
517 };
518 let rt = quillmark_content::from_plaintext(&text);
519 plain_check(&rt)?;
520 Ok(QuillValue::from_json(
521 quillmark_content::serial::to_canonical_value(&rt),
522 ))
523 }
524 FieldType::RichText { inline } => {
525 // The seam carries the content, so coercion commits the content
526 // form: an already-structured value (editor / re-render) is
527 // validated and re-canonicalized; an authored markdown string is
528 // imported. Determinism is inherited from `import` being pure.
529 // An `inline` field additionally requires the resulting content to
530 // be single-`Para` (`richtext(inline)`): editors mount a one-line
531 // surface, so multi-block content is a coercion error here, in
532 // lockstep with the validation-layer `richtext::not_inline` check.
533 //
534 // This is the deliberately-lenient sibling of
535 // `document::decode_richtext_value` (used by the strict wire /
536 // literal / validation sites): the string branch below reduces a
537 // bare scalar or length-1 array to text before importing, which
538 // the strict decoder must not do, so it stays open-coded here.
539 let inline_check =
540 |rt: &quillmark_content::Content| -> Result<(), CoercionError> {
541 if inline && !rt.is_inline() {
542 return Err(CoercionError::Uncoercible {
543 path: path.to_string(),
544 value: "<richtext>".to_string(),
545 target: "richtext(inline)".to_string(),
546 reason: "richtext(inline) requires a single paragraph line \
547 with no list/quote container and no islands"
548 .to_string(),
549 });
550 }
551 Ok(())
552 };
553 // A strict write uses `decode_richtext_value` semantics: a
554 // canonical content object or a markdown string, nothing else. No
555 // scalar→string reduction (the render floor's lenient cascade
556 // below): a bare scalar for a richtext field fails the write. The
557 // messages mirror `Card::commit_field`'s richtext error variants,
558 // which the bindings key on.
559 if mode == Leniency::Write {
560 let content = match crate::document::decode_richtext_value(json_value) {
561 Some(result) => result.map_err(|e| CoercionError::Uncoercible {
562 path: path.to_string(),
563 value: "<richtext>".to_string(),
564 target: "richtext".to_string(),
565 reason: e.into_message(),
566 })?,
567 None => {
568 return Err(CoercionError::Uncoercible {
569 path: path.to_string(),
570 value: json_value.to_string(),
571 target: "richtext".to_string(),
572 reason: format!(
573 "expected a richtext content object or a markdown string, got {}",
574 match json_value {
575 serde_json::Value::Bool(_) => "a boolean",
576 serde_json::Value::Number(_) => "a number",
577 serde_json::Value::Array(_) => "an array",
578 _ => "an unsupported value",
579 }
580 ),
581 })
582 }
583 };
584 inline_check(&content)?;
585 return Ok(QuillValue::from_json(
586 quillmark_content::serial::to_canonical_value(&content),
587 ));
588 }
589 if json_value.is_object() {
590 let rt = quillmark_content::serial::from_canonical_value(json_value).map_err(
591 |e| CoercionError::Uncoercible {
592 path: path.to_string(),
593 value: "<object>".to_string(),
594 target: "richtext".to_string(),
595 reason: format!("not a valid richtext content: {e}"),
596 },
597 )?;
598 inline_check(&rt)?;
599 return Ok(QuillValue::from_json(
600 quillmark_content::serial::to_canonical_value(&rt),
601 ));
602 }
603 // Reduce to the authored markdown string via the shared
604 // leniency cascade (bare string, length-1 array unwrap, or bare
605 // scalar), then import.
606 let Some(markdown) = lenient_string(json_value) else {
607 // A shape that is neither content nor stringifiable (e.g. a
608 // multi-element array): leave it for the validation layer to
609 // report, matching the String branch's fall-through.
610 return Ok(value.clone());
611 };
612 let rt = quillmark_content::import::from_markdown(&markdown).map_err(|e| {
613 CoercionError::Uncoercible {
614 path: path.to_string(),
615 value: markdown.clone(),
616 target: "richtext".to_string(),
617 reason: format!("markdown import failed: {e}"),
618 }
619 })?;
620 inline_check(&rt)?;
621 Ok(QuillValue::from_json(
622 quillmark_content::serial::to_canonical_value(&rt),
623 ))
624 }
625 FieldType::Date | FieldType::DateTime => {
626 if json_value.is_null() {
627 return Ok(QuillValue::from_json(serde_json::Value::Null));
628 }
629 let text = if let Some(s) = json_value.as_str() {
630 if s.is_empty() {
631 return Ok(QuillValue::from_json(serde_json::Value::Null));
632 }
633 s.to_string()
634 } else if let Some(arr) = json_value.as_array() {
635 if arr.len() == 1 {
636 if let Some(s) = arr[0].as_str() {
637 s.to_string()
638 } else {
639 return Err(CoercionError::Uncoercible {
640 path: path.to_string(),
641 value: json_value.to_string(),
642 target: field_schema.r#type.as_str().to_string(),
643 reason: "value must be a string".to_string(),
644 });
645 }
646 } else {
647 return Err(CoercionError::Uncoercible {
648 path: path.to_string(),
649 value: json_value.to_string(),
650 target: field_schema.r#type.as_str().to_string(),
651 reason: "value must be a single string".to_string(),
652 });
653 }
654 } else {
655 return Err(CoercionError::Uncoercible {
656 path: path.to_string(),
657 value: json_value.to_string(),
658 target: field_schema.r#type.as_str().to_string(),
659 reason: "value must be a string".to_string(),
660 });
661 };
662
663 // The two date types share extraction and verbatim storage;
664 // only the grammar differs. A `date` rejects any time component,
665 // a `datetime` rejects offsets/space/fraction/bare-date: neither
666 // truncates, so the stored string is exactly the authored one.
667 let (valid, reason) = match field_schema.r#type {
668 FieldType::Date => {
669 (super::formats::is_valid_date(&text), "invalid date format")
670 }
671 _ => (
672 super::formats::is_valid_datetime(&text),
673 "invalid datetime format",
674 ),
675 };
676 if valid {
677 Ok(QuillValue::from_json(serde_json::Value::String(text)))
678 } else {
679 Err(CoercionError::Uncoercible {
680 path: path.to_string(),
681 value: text,
682 target: field_schema.r#type.as_str().to_string(),
683 reason: reason.to_string(),
684 })
685 }
686 }
687 FieldType::Object => {
688 if let Some(obj) = json_value.as_object() {
689 if let Some(props) = &field_schema.properties {
690 let coerced_obj = Self::coerce_object_props(obj, props, path, mode)?;
691 Ok(QuillValue::from_json(serde_json::Value::Object(
692 coerced_obj,
693 )))
694 } else {
695 Ok(value.clone())
696 }
697 } else {
698 // A non-object value: the render floor defers to validation,
699 // a strict write fails now.
700 match mode {
701 Leniency::Render => Ok(value.clone()),
702 Leniency::Write => Err(CoercionError::Uncoercible {
703 path: path.to_string(),
704 value: json_value.to_string(),
705 target: "object".to_string(),
706 reason: "value is not an object".to_string(),
707 }),
708 }
709 }
710 }
711 }
712 }
713
714 /// Walk `obj`'s keys, coercing any that match `props` against the matching
715 /// schema and copying any others through verbatim. `parent_path` is the
716 /// breadcrumb for the enclosing scope (e.g. `"foo[3]"` or `"foo"`); each
717 /// child's path is `"{parent_path}.{k}"`.
718 fn coerce_object_props(
719 obj: &serde_json::Map<String, serde_json::Value>,
720 props: &IndexMap<String, Box<super::FieldSchema>>,
721 parent_path: &str,
722 mode: Leniency,
723 ) -> Result<serde_json::Map<String, serde_json::Value>, CoercionError> {
724 let mut out = serde_json::Map::new();
725 for (k, v) in obj {
726 if let Some(prop_schema) = props.get(k) {
727 let child_path = format!("{parent_path}.{k}");
728 out.insert(
729 k.clone(),
730 Self::conform_value(
731 &QuillValue::from_json(v.clone()),
732 prop_schema,
733 &child_path,
734 mode,
735 )?
736 .into_json(),
737 );
738 } else {
739 out.insert(k.clone(), v.clone());
740 }
741 }
742 Ok(out)
743 }
744
745 /// Recursively validate a field's structural shape, enforcing the
746 /// one-level nesting contract in a single pass. The `position` records
747 /// what shapes are legal at the current depth:
748 ///
749 /// - [`ShapePosition::Top`], a field declared directly on a card: scalar,
750 /// `object` (typed dictionary), or `array` (primitive list or typed
751 /// table).
752 /// - [`ShapePosition::ArrayItem`], an array's `items`: a scalar or an
753 /// `object` (the typed-table row), but **not** another array.
754 /// - [`ShapePosition::Leaf`], an object's property (whether a top-level
755 /// typed dictionary or a typed-table row): scalar only. No deeper
756 /// containers, so `array<object<array>>` and `object<array>` are
757 /// rejected here.
758 ///
759 /// Returns the first violation as a ready-to-push [`Diagnostic`] whose
760 /// message names `owner` (the field-name path, e.g. `rows[].tags`), or
761 /// `None` when the shape is valid.
762 fn validate_field_schema_shape(
763 schema: &FieldSchema,
764 owner: &str,
765 position: ShapePosition,
766 ) -> Option<Diagnostic> {
767 let err = |code: &str, message: String| {
768 Some(Diagnostic::new(Severity::Error, message).with_code(code.to_string()))
769 };
770
771 // `items` is only meaningful on arrays; `properties` only on objects.
772 if schema.r#type != FieldType::Array && schema.items.is_some() {
773 return err(
774 "quill::items_not_supported",
775 format!(
776 "Field '{owner}' declares 'items' but is not type: array. \
777 'items' (the element schema) is only valid on array fields."
778 ),
779 );
780 }
781 // `inline` on a non-prose field is rejected earlier and once, when
782 // `from_quill_value` folds the wire key into the `FieldType` enum
783 // (`resolve_prose_inline`); no second check belongs here.
784
785 // `ui.group` clusters card-level fields only: the blueprint's grouping
786 // pass never descends into object properties or array items, so a nested
787 // `group` is an inert knob. Reject it rather than let it silently do
788 // nothing, the same dead-knob class this walk exists to catch.
789 if position != ShapePosition::Top
790 && schema.ui.as_ref().and_then(|u| u.group.as_ref()).is_some()
791 {
792 return err(
793 "quill::nested_group_not_supported",
794 format!(
795 "Field '{owner}' sets ui.group in a nested position. Grouping applies \
796 only to card-level fields; an object property or array item cannot \
797 join a group."
798 ),
799 );
800 }
801
802 match schema.r#type {
803 FieldType::Object => {
804 // An object nested inside another object (a Leaf position) is
805 // the classic "nested type: object" rejection.
806 if position == ShapePosition::Leaf {
807 return err(
808 "quill::nested_object_not_supported",
809 format!(
810 "Field '{owner}' uses a nested type: object, which is not supported. \
811 An object's properties may only be scalars."
812 ),
813 );
814 }
815 let Some(props) = &schema.properties else {
816 return err(
817 "quill::object_missing_properties",
818 format!(
819 "Field '{owner}' has type: object but no properties defined. \
820 Declare a properties map, or use type: array with \
821 items: {{ type: object, properties: … }} for a list of objects."
822 ),
823 );
824 };
825 if props.is_empty() {
826 return err(
827 "quill::object_empty_properties",
828 format!(
829 "Field '{owner}' has type: object with an empty properties map. \
830 Declare at least one property, or remove the field entirely."
831 ),
832 );
833 }
834 // Object properties are leaves: scalars only.
835 props.iter().find_map(|(name, prop)| {
836 Self::validate_field_schema_shape(
837 prop,
838 &format!("{owner}.{name}"),
839 ShapePosition::Leaf,
840 )
841 })
842 }
843 FieldType::Array => {
844 // An array may sit at the top level only; an array element may
845 // not itself be an array, and neither may an object property.
846 if position != ShapePosition::Top {
847 return err(
848 "quill::nested_array_not_supported",
849 format!(
850 "Field '{owner}' declares a nested array, which is not supported. \
851 Array elements must be scalars or objects, and object properties \
852 may only be scalars."
853 ),
854 );
855 }
856 if schema.properties.is_some() {
857 return err(
858 "quill::array_properties_not_supported",
859 format!(
860 "Field '{owner}' is type: array with a bare 'properties' map. \
861 Declare the element type under 'items' instead: for a list \
862 of objects use items: {{ type: object, properties: … }}."
863 ),
864 );
865 }
866 let Some(items) = &schema.items else {
867 return err(
868 "quill::array_missing_items",
869 format!(
870 "Field '{owner}' has type: array but no 'items' element schema. \
871 Declare the element type, e.g. items: {{ type: string }} \
872 for a list of strings or items: {{ type: object, \
873 properties: … }} for a list of objects."
874 ),
875 );
876 };
877 Self::validate_field_schema_shape(
878 items,
879 &format!("{owner}[]"),
880 ShapePosition::ArrayItem,
881 )
882 }
883 // Scalars are leaves; nothing further to validate.
884 _ => None,
885 }
886 }
887
888 /// Reject multi-line descriptions. Single-line is required so the leading
889 /// `# <description>` blueprint slot stays one line and the field-comment
890 /// stack remains parseable for LLM consumers.
891 fn validate_description_singleline(
892 desc: Option<&str>,
893 owner_label: &str,
894 errors: &mut Vec<Diagnostic>,
895 ) {
896 if let Some(d) = desc {
897 if d.contains('\n') {
898 errors.push(
899 Diagnostic::new(
900 Severity::Error,
901 format!(
902 "{} description must be a single line; multi-line \
903 descriptions are not allowed.",
904 owner_label
905 ),
906 )
907 .with_code("quill::description_multiline".to_string()),
908 );
909 }
910 }
911 }
912
913 /// Reject `>`, `;`, `|` in enum literals. These characters are reserved by
914 /// the blueprint inline annotation grammar (`<format>` close, role
915 /// separator, enum value separator) and have no escape syntax.
916 fn validate_enum_literals(
917 field: &FieldSchema,
918 owner_label: &str,
919 errors: &mut Vec<Diagnostic>,
920 ) {
921 if let Some(values) = &field.enum_values {
922 for v in values {
923 if v.contains('>') || v.contains(';') || v.contains('|') {
924 errors.push(
925 Diagnostic::new(
926 Severity::Error,
927 format!(
928 "{} enum value '{}' contains a reserved character \
929 ('>', ';', or '|') that conflicts with the \
930 blueprint inline annotation grammar.",
931 owner_label, v
932 ),
933 )
934 .with_code("quill::format_literal_reserved_char".to_string()),
935 );
936 }
937 }
938 }
939 }
940
941 /// Recursively validate field-level blueprint constraints across the field,
942 /// any nested object properties, and an array's element schema (`items`).
943 fn validate_field_blueprint_constraints(
944 schema: &FieldSchema,
945 owner_label: &str,
946 errors: &mut Vec<Diagnostic>,
947 ) {
948 Self::validate_description_singleline(schema.description.as_deref(), owner_label, errors);
949 Self::validate_enum_literals(schema, owner_label, errors);
950 if let Some(v) = &schema.example {
951 Self::validate_schema_slot("example", v, schema, owner_label, errors);
952 }
953 if let Some(v) = &schema.default {
954 Self::validate_schema_slot("default", v, schema, owner_label, errors);
955 }
956 if let Some(props) = &schema.properties {
957 for (name, prop) in props {
958 let nested = format!("{}.{}", owner_label, name);
959 Self::validate_field_blueprint_constraints(prop, &nested, errors);
960 }
961 }
962 if let Some(items) = &schema.items {
963 let nested = format!("{}[]", owner_label);
964 Self::validate_field_blueprint_constraints(items, &nested, errors);
965 }
966 }
967
968 /// Validate a card's group registry and every card-level field's `ui.group`
969 /// reference against it. Nested `ui.group` is already rejected upstream by
970 /// [`validate_field_schema_shape`](Self::validate_field_schema_shape), so
971 /// only card-level fields are considered here.
972 ///
973 /// With a registry present, `ui.group` is a *reference*: registry ids carry
974 /// the same snake_case discipline as field keys and must be unique, and a
975 /// reference to an id the registry does not declare is `quill::unknown_group`
976 /// (the "no mixing implicit and declared" rule falls out of this, with a
977 /// registry there is no implicit fallback). With no registry, each `ui.group`
978 /// is a deprecated implicit group (label-as-identity) and the card earns one
979 /// `quill::implicit_group` warning.
980 fn validate_card_groups(
981 label: &str,
982 card: &CardSchema,
983 errors: &mut Vec<Diagnostic>,
984 warnings: &mut Vec<Diagnostic>,
985 ) {
986 let referenced: Vec<&str> = card
987 .fields
988 .values()
989 .filter_map(|f| f.ui.as_ref().and_then(|u| u.group.as_deref()))
990 .collect();
991
992 match card.ui.as_ref().and_then(|u| u.groups.as_ref()) {
993 Some(GroupRegistry(groups)) => {
994 let mut ids: HashSet<&str> = HashSet::new();
995 for g in groups {
996 if !Self::is_snake_case_identifier(&g.id) {
997 errors.push(
998 Diagnostic::new(
999 Severity::Error,
1000 format!(
1001 "{label} group id '{}' must be snake_case (lowercase letters, \
1002 digits, and underscores only); the display label goes in \
1003 'title:'.",
1004 g.id
1005 ),
1006 )
1007 .with_code("quill::invalid_group_id".to_string()),
1008 );
1009 }
1010 // Insert regardless of snake_case validity so a reference to
1011 // an ill-named id resolves: one diagnostic, not a cascade.
1012 if !ids.insert(g.id.as_str()) {
1013 errors.push(
1014 Diagnostic::new(
1015 Severity::Error,
1016 format!("{label} declares group '{}' more than once.", g.id),
1017 )
1018 .with_code("quill::duplicate_group".to_string()),
1019 );
1020 }
1021 }
1022 // One diagnostic per distinct unresolved reference.
1023 let unresolved: BTreeSet<&str> =
1024 referenced.iter().copied().filter(|g| !ids.contains(g)).collect();
1025 for group in unresolved {
1026 errors.push(
1027 Diagnostic::new(
1028 Severity::Error,
1029 format!(
1030 "{label} field references group '{group}', which is not declared \
1031 in ui.groups. Add it to the registry, or fix the reference."
1032 ),
1033 )
1034 .with_code("quill::unknown_group".to_string()),
1035 );
1036 }
1037 }
1038 None => {
1039 if !referenced.is_empty() {
1040 warnings.push(
1041 Diagnostic::new(
1042 Severity::Warning,
1043 format!(
1044 "{label} uses ui.group without a ui.groups registry (implicit \
1045 groups). Declare the groups under the card's ui.groups; implicit \
1046 groups are deprecated and become an error in a future release."
1047 ),
1048 )
1049 .with_code("quill::implicit_group".to_string())
1050 .with_hint(
1051 "Add a ui.groups registry listing each group id, and reference the id \
1052 from each field's ui.group."
1053 .to_string(),
1054 ),
1055 );
1056 }
1057 }
1058 }
1059 }
1060
1061 /// Validate a single `example:` or `default:` literal against the declared
1062 /// schema, pushing `quill::*`-namespaced [`Diagnostic`]s for any violations.
1063 ///
1064 /// Delegates type/enum/format/recursion checking to
1065 /// [`super::validation::validate_schema_literal`] (the shared conformance
1066 /// primitive) then converts each [`ValidationError`] into a Quill.yaml
1067 /// load-time diagnostic with the appropriate `quill::{slot}_*` error code
1068 /// and author-friendly hint.
1069 fn validate_schema_slot(
1070 slot: &str,
1071 value: &QuillValue,
1072 schema: &FieldSchema,
1073 owner_label: &str,
1074 errors: &mut Vec<Diagnostic>,
1075 ) {
1076 use super::validation::{validate_schema_literal, ValidationError};
1077
1078 // A Quill.yaml schema-literal anchor (`$seed.<kind>`, a field label) is
1079 // config-space, not a document path; it rides the one serializer with
1080 // its prefix as an opaque head field.
1081 let owner_path = crate::path::DocPath::new().field(owner_label);
1082 for violation in validate_schema_literal(schema, value, &owner_path) {
1083 let diag = match &violation {
1084 ValidationError::TypeMismatch {
1085 path,
1086 actual,
1087 source_token,
1088 ..
1089 } => {
1090 // Use the field's declared `type:` verbatim (`datetime`,
1091 // `markdown`, …); the validator's `expected` collapses those
1092 // to `string`, which would misreport the author's intent.
1093 let declared = schema.r#type.as_str();
1094 // validation.rs uses "number" for all non-integer JSON numbers;
1095 // display as "float" so messages match the YAML author's mental model.
1096 let display_actual = if actual == "number" {
1097 "float"
1098 } else {
1099 actual.as_str()
1100 };
1101 // Show the offending value's content. A top-level mismatch
1102 // renders the original literal (so arrays/objects show their
1103 // contents); a nested mismatch is always a scalar, whose
1104 // verbatim token is already the full value.
1105 let preview = if path.as_str() == owner_label {
1106 Self::literal_preview(value.as_json())
1107 } else {
1108 Self::truncate_preview(source_token)
1109 };
1110 let hint = if actual == "number" || actual == "integer" {
1111 let schema_type = if actual == "integer" {
1112 "integer"
1113 } else {
1114 "number"
1115 };
1116 format!(
1117 "Quote the {slot} as \"{raw}\" if the value is intentionally a \
1118 string, or change the field type to '{schema_type}'.",
1119 raw = source_token.trim_matches('"'),
1120 )
1121 } else if actual == "string" {
1122 format!(
1123 "Remove the quotes around the {slot} value to keep it a {declared}."
1124 )
1125 } else {
1126 format!(
1127 "Make the {slot} value a {declared}, or change the field type to match."
1128 )
1129 };
1130 Diagnostic::new(
1131 Severity::Error,
1132 format!(
1133 "{owner_label} declares type '{declared}' but {slot} is {display_actual} ({preview})."
1134 ),
1135 )
1136 .with_code(format!("quill::{slot}_type_mismatch"))
1137 .with_hint(hint)
1138 }
1139 ValidationError::EnumViolation {
1140 path,
1141 value: val,
1142 allowed,
1143 } => {
1144 let values_str = allowed
1145 .iter()
1146 .map(|v| format!("\"{}\"", v))
1147 .collect::<Vec<_>>()
1148 .join(", ");
1149 Diagnostic::new(
1150 Severity::Error,
1151 format!(
1152 "{path} {slot} \"{val}\" is not one of the declared enum values [{values_str}]."
1153 ),
1154 )
1155 .with_code(format!("quill::{slot}_not_in_enum"))
1156 .with_hint(format!("Set the {slot} to one of: {values_str}."))
1157 }
1158 ValidationError::FormatViolation { path, format } => Diagnostic::new(
1159 Severity::Error,
1160 format!("{path} {slot} has an invalid {format} format."),
1161 )
1162 .with_code(format!("quill::{slot}_format_violation"))
1163 .with_hint(format!("Provide a valid {format} value for the {slot}.")),
1164 // UnknownCard, BodyDisabled do not apply to schema literals.
1165 _ => continue,
1166 };
1167 errors.push(diag);
1168 }
1169 }
1170
1171 /// Render a short, quoted preview of a value for an error message. Strings
1172 /// are quoted; everything else uses its JSON form. Long renderings are
1173 /// truncated (see [`Self::truncate_preview`]).
1174 fn literal_preview(value: &serde_json::Value) -> String {
1175 let raw = match value {
1176 serde_json::Value::String(s) => format!("\"{}\"", s),
1177 other => other.to_string(),
1178 };
1179 Self::truncate_preview(&raw)
1180 }
1181
1182 /// Truncate an already-rendered preview token to at most 60 characters,
1183 /// appending an ellipsis when it overflows.
1184 fn truncate_preview(raw: &str) -> String {
1185 const MAX: usize = 60;
1186 if raw.chars().count() > MAX {
1187 let truncated: String = raw.chars().take(MAX).collect();
1188 format!("{}…", truncated)
1189 } else {
1190 raw.to_string()
1191 }
1192 }
1193
1194 /// Parse fields from a JSON map into `FieldSchema`s (both `main.fields` and
1195 /// a card kind's `fields`). Declaration order rides the map itself: the
1196 /// source map preserves key order (serde_json's `preserve_order`) and the
1197 /// returned `IndexMap` keeps insertion order, so no ordering pass runs.
1198 /// `context` labels error messages (e.g. `"field schema"`,
1199 /// `"card_kind 'note' field"`).
1200 fn parse_fields(
1201 fields_map: &serde_json::Map<String, serde_json::Value>,
1202 context: &str,
1203 errors: &mut Vec<Diagnostic>,
1204 ) -> IndexMap<String, FieldSchema> {
1205 let mut fields = IndexMap::new();
1206
1207 for (field_name, field_value) in fields_map {
1208 if !Self::is_snake_case_identifier(field_name) {
1209 errors.push(
1210 Diagnostic::new(
1211 Severity::Error,
1212 format!(
1213 "Invalid {} '{}': field keys must be snake_case \
1214 (lowercase letters, digits, and underscores only), \
1215 and capitalized field keys are reserved.",
1216 context, field_name
1217 ),
1218 )
1219 .with_code("quill::invalid_field_name".to_string()),
1220 );
1221 continue;
1222 }
1223
1224 let quill_value = QuillValue::from_json(field_value.clone());
1225 match FieldSchema::from_quill_value(field_name.clone(), &quill_value) {
1226 Ok(schema) => {
1227 // One recursive pass enforces the whole shape contract:
1228 // containers carry the right child schema (`object` →
1229 // `properties`, `array` → `items`), and nesting stops after
1230 // one structural level (a typed table is the deepest shape).
1231 if let Some(diag) =
1232 Self::validate_field_schema_shape(&schema, field_name, ShapePosition::Top)
1233 {
1234 errors.push(diag);
1235 continue;
1236 }
1237
1238 let owner = format!("{} '{}'", context, field_name);
1239 Self::validate_field_blueprint_constraints(&schema, &owner, errors);
1240
1241 fields.insert(field_name.clone(), schema);
1242 }
1243 Err(e) => {
1244 let hint = Self::field_parse_hint(field_value);
1245 let mut diag = Diagnostic::new(
1246 Severity::Error,
1247 format!("Failed to parse {} '{}': {}", context, field_name, e),
1248 )
1249 .with_code("quill::field_parse_error".to_string());
1250 if let Some(h) = hint {
1251 diag = diag.with_hint(h);
1252 }
1253 errors.push(diag);
1254 }
1255 }
1256 }
1257
1258 fields
1259 }
1260
1261 /// Produce an actionable hint for common field schema mistakes based on the raw value.
1262 fn field_parse_hint(field_value: &serde_json::Value) -> Option<String> {
1263 if let Some(obj) = field_value.as_object() {
1264 if obj.contains_key("title") {
1265 return Some(
1266 "'title' is not a valid field key; use 'description' instead.".to_string(),
1267 );
1268 }
1269 if obj
1270 .get("ui")
1271 .and_then(|u| u.as_object())
1272 .is_some_and(|u| u.contains_key("order"))
1273 {
1274 return Some(format!("{UI_ORDER_REMOVED_MSG}."));
1275 }
1276 if obj.get("type").and_then(|v| v.as_str()) == Some("richtext(inline)") {
1277 return Some(format!("{RICHTEXT_INLINE_TOKEN_MSG}."));
1278 }
1279 if obj.get("type").and_then(|v| v.as_str()) == Some("markdown") {
1280 return Some(
1281 "'markdown' is no longer a field type; use type: richtext (block) \
1282 or type: richtext with inline: true."
1283 .to_string(),
1284 );
1285 }
1286 }
1287 None
1288 }
1289
1290 fn is_snake_case_identifier(name: &str) -> bool {
1291 let mut chars = name.chars();
1292 match chars.next() {
1293 Some(c) if c.is_ascii_lowercase() => {}
1294 _ => return false,
1295 }
1296
1297 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
1298 }
1299
1300 fn is_valid_quill_name(name: &str) -> bool {
1301 name == "__default__" || Self::is_snake_case_identifier(name)
1302 }
1303
1304 /// Parse QuillConfig from YAML content
1305 pub fn from_yaml(yaml_content: &str) -> Result<Self, Box<dyn StdError + Send + Sync>> {
1306 match Self::from_yaml_with_warnings(yaml_content) {
1307 Ok((config, _warnings)) => Ok(config),
1308 Err(diags) => {
1309 let msg = diags
1310 .iter()
1311 .map(|d| d.fmt_pretty())
1312 .collect::<Vec<_>>()
1313 .join("\n");
1314 Err(msg.into())
1315 }
1316 }
1317 }
1318
1319 /// Parse QuillConfig from YAML content while collecting non-fatal warnings.
1320 ///
1321 /// Returns `Ok((config, warnings))` on success, or `Err(errors)` containing all
1322 /// parse/validation errors when the config is invalid. Errors are always collected
1323 /// exhaustively: callers see every problem, not just the first.
1324 pub fn from_yaml_with_warnings(
1325 yaml_content: &str,
1326 ) -> Result<(Self, Vec<Diagnostic>), Vec<Diagnostic>> {
1327 let mut warnings: Vec<Diagnostic> = Vec::new();
1328 let mut errors: Vec<Diagnostic> = Vec::new();
1329
1330 // Parse YAML into serde_json::Value via serde_saphyr. The depth budget
1331 // bounds nesting so an untrusted Quill.yaml cannot overflow the stack.
1332 // Note: serde_json with "preserve_order" feature is required for this to work as expected
1333 let quill_yaml_val: serde_json::Value = match serde_saphyr::from_str_with_options(
1334 yaml_content,
1335 crate::document::limits::yaml_parse_options(),
1336 ) {
1337 Ok(v) => v,
1338 Err(e) => {
1339 // Through `YamlError` so this shares the one saphyr adapter:
1340 // the engine's Rust API names stripped, the hint derived, and
1341 // the position carried as a `Location`.
1342 return Err(vec![crate::error::YamlError::from_de(e, yaml_content)
1343 .to_diagnostic("quill::yaml_parse_error", "Quill.yaml")]);
1344 }
1345 };
1346
1347 // Extract [quill] section (required): fail immediately if absent since all
1348 // subsequent validation depends on it.
1349 let quill_section = match quill_yaml_val.get("quill") {
1350 Some(v) => v,
1351 None => {
1352 return Err(vec![Diagnostic::new(
1353 Severity::Error,
1354 "Missing required 'quill' section in Quill.yaml".to_string(),
1355 )
1356 .with_code("quill::missing_section".to_string())
1357 .with_hint(
1358 "Add a 'quill:' section with name, backend, version, and description."
1359 .to_string(),
1360 )]);
1361 }
1362 };
1363
1364 // Validate that no unknown keys appear in the [quill] section.
1365 const KNOWN_QUILL_KEYS: &[&str] =
1366 &["name", "backend", "description", "version", "author", "ui"];
1367 if let Some(quill_obj) = quill_section.as_object() {
1368 for key in quill_obj.keys() {
1369 if !KNOWN_QUILL_KEYS.contains(&key.as_str()) {
1370 errors.push(
1371 Diagnostic::new(
1372 Severity::Error,
1373 format!("Unknown key '{}' in 'quill:' section", key),
1374 )
1375 .with_code("quill::unknown_key".to_string())
1376 .with_hint(format!("Valid keys are: {}", KNOWN_QUILL_KEYS.join(", "))),
1377 );
1378 }
1379 }
1380 }
1381
1382 // Extract required fields: collect all missing-field errors before returning.
1383 let name = match quill_section.get("name").and_then(|v| v.as_str()) {
1384 Some(n) => {
1385 if !Self::is_valid_quill_name(n) {
1386 errors.push(
1387 Diagnostic::new(
1388 Severity::Error,
1389 format!(
1390 "Invalid Quill name '{}': quill.name must be snake_case \
1391 (lowercase letters, digits, and underscores only).",
1392 n
1393 ),
1394 )
1395 .with_code("quill::invalid_name".to_string())
1396 .with_hint(format!(
1397 "Rename '{}' to '{}'",
1398 n,
1399 n.to_lowercase().replace('-', "_")
1400 )),
1401 );
1402 }
1403 n.to_string()
1404 }
1405 None => {
1406 errors.push(
1407 Diagnostic::new(
1408 Severity::Error,
1409 "Missing required 'name' field in 'quill' section".to_string(),
1410 )
1411 .with_code("quill::missing_name".to_string())
1412 .with_hint(
1413 "Add 'name: your_quill_name' under the 'quill:' section.".to_string(),
1414 ),
1415 );
1416 String::new()
1417 }
1418 };
1419
1420 let backend = match quill_section.get("backend").and_then(|v| v.as_str()) {
1421 Some(b) => b.to_string(),
1422 None => {
1423 errors.push(
1424 Diagnostic::new(
1425 Severity::Error,
1426 "Missing required 'backend' field in 'quill' section".to_string(),
1427 )
1428 .with_code("quill::missing_backend".to_string())
1429 .with_hint("Add 'backend: typst' (or another supported backend).".to_string()),
1430 );
1431 String::new()
1432 }
1433 };
1434
1435 let description = match quill_section.get("description").and_then(|v| v.as_str()) {
1436 Some(d) if !d.trim().is_empty() => {
1437 Self::validate_description_singleline(Some(d), "quill", &mut errors);
1438 d.to_string()
1439 }
1440 Some(_) => {
1441 errors.push(
1442 Diagnostic::new(
1443 Severity::Error,
1444 "'description' field in 'quill' section cannot be empty".to_string(),
1445 )
1446 .with_code("quill::empty_description".to_string()),
1447 );
1448 String::new()
1449 }
1450 None => {
1451 errors.push(
1452 Diagnostic::new(
1453 Severity::Error,
1454 "Missing required 'description' field in 'quill' section".to_string(),
1455 )
1456 .with_code("quill::missing_description".to_string())
1457 .with_hint("Add a brief 'description:' of what this quill is for.".to_string()),
1458 );
1459 String::new()
1460 }
1461 };
1462
1463 // Extract the required `version` field.
1464 let version = match quill_section.get("version") {
1465 Some(version_val) => {
1466 // Handle version as string or number (YAML might parse 1.0 as number)
1467 let raw = if let Some(s) = version_val.as_str() {
1468 s.to_string()
1469 } else if let Some(n) = version_val.as_f64() {
1470 n.to_string()
1471 } else {
1472 errors.push(
1473 Diagnostic::new(
1474 Severity::Error,
1475 "Invalid 'version' field format".to_string(),
1476 )
1477 .with_code("quill::invalid_version".to_string())
1478 .with_hint("Use semver format: '1.0' or '1.0.0'.".to_string()),
1479 );
1480 String::new()
1481 };
1482 if !raw.is_empty() {
1483 use std::str::FromStr;
1484 if let Err(e) = crate::version::Version::from_str(&raw) {
1485 errors.push(
1486 Diagnostic::new(
1487 Severity::Error,
1488 format!("Invalid version '{}': {}", raw, e),
1489 )
1490 .with_code("quill::invalid_version".to_string())
1491 .with_hint("Use semver format: '1.0' or '1.0.0'.".to_string()),
1492 );
1493 }
1494 }
1495 raw
1496 }
1497 None => {
1498 errors.push(
1499 Diagnostic::new(
1500 Severity::Error,
1501 "Missing required 'version' field in 'quill' section".to_string(),
1502 )
1503 .with_code("quill::missing_version".to_string())
1504 .with_hint("Add 'version: 1.0' under the 'quill:' section.".to_string()),
1505 );
1506 String::new()
1507 }
1508 };
1509
1510 let author = quill_section
1511 .get("author")
1512 .and_then(|v| v.as_str())
1513 .map(|s| s.to_string())
1514 .unwrap_or_else(|| "Unknown".to_string());
1515
1516 let ui_section: Option<UiCardSchema> = match quill_section.get("ui").cloned() {
1517 None => None,
1518 Some(v) => match serde_json::from_value::<UiCardSchema>(v) {
1519 Ok(parsed) => Some(parsed),
1520 Err(e) => {
1521 errors.push(
1522 Diagnostic::new(
1523 Severity::Error,
1524 format!("Invalid 'quill.ui' block: {}", e),
1525 )
1526 .with_code("quill::invalid_ui".to_string())
1527 .with_hint("Valid keys under 'ui' are: title, groups.".to_string()),
1528 );
1529 None
1530 }
1531 },
1532 };
1533
1534 // Extract optional backend-specific section (keyed by `quill.backend`).
1535 let mut backend_config = HashMap::new();
1536 if !backend.is_empty() {
1537 if let Some(section_val) = quill_yaml_val.get(&backend) {
1538 if let Some(table) = section_val.as_object() {
1539 for (key, value) in table {
1540 backend_config.insert(key.clone(), QuillValue::from_json(value.clone()));
1541 }
1542 }
1543 }
1544 }
1545
1546 // Reject unknown top-level sections. Known sections are: quill, main, card_kinds,
1547 // and the backend name (e.g. typst). Everything else is a mistake. `fields` gets
1548 // a targeted hint since it's the most common shape mistake.
1549 if let Some(top_obj) = quill_yaml_val.as_object() {
1550 for key in top_obj.keys() {
1551 let is_known = key == "quill"
1552 || key == "main"
1553 || key == "card_kinds"
1554 || (!backend.is_empty() && key == &backend);
1555 if is_known {
1556 continue;
1557 }
1558
1559 let mut diag = Diagnostic::new(
1560 Severity::Error,
1561 format!("Unknown top-level section '{}'", key),
1562 )
1563 .with_code("quill::unknown_section".to_string());
1564
1565 diag = if key == "fields" {
1566 diag.with_hint(
1567 "Root-level `fields` is not supported; use `main.fields` instead."
1568 .to_string(),
1569 )
1570 } else {
1571 diag.with_hint(format!(
1572 "Valid top-level sections are: quill, main, card_kinds{}",
1573 if backend.is_empty() {
1574 String::new()
1575 } else {
1576 format!(", {}", backend)
1577 }
1578 ))
1579 };
1580
1581 errors.push(diag);
1582 }
1583 }
1584
1585 let main_obj_opt = quill_yaml_val.get("main").and_then(|v| v.as_object());
1586
1587 // Extract main.fields (optional)
1588 let fields = if let Some(fields_map) = main_obj_opt
1589 .and_then(|main_obj| main_obj.get("fields"))
1590 .and_then(|v| v.as_object())
1591 {
1592 Self::parse_fields(fields_map, "field schema", &mut errors)
1593 } else {
1594 IndexMap::new()
1595 };
1596
1597 // Extract main.ui (optional). Fail loudly on malformed UI metadata rather
1598 // than silently dropping it; see `quill.ui` handling above.
1599 let main_ui: Option<UiCardSchema> = match main_obj_opt
1600 .and_then(|main_obj| main_obj.get("ui"))
1601 .cloned()
1602 {
1603 None => None,
1604 Some(v) => match serde_json::from_value::<UiCardSchema>(v) {
1605 Ok(parsed) => Some(parsed),
1606 Err(e) => {
1607 errors.push(
1608 Diagnostic::new(Severity::Error, format!("Invalid 'main.ui' block: {}", e))
1609 .with_code("quill::invalid_ui".to_string())
1610 .with_hint("Valid keys under 'ui' are: title, groups.".to_string()),
1611 );
1612 None
1613 }
1614 },
1615 };
1616
1617 // Extract main.body (optional). Fail loudly on malformed body metadata.
1618 let main_body: Option<BodyCardSchema> = match main_obj_opt
1619 .and_then(|main_obj| main_obj.get("body"))
1620 .cloned()
1621 {
1622 None => None,
1623 Some(v) => match serde_json::from_value::<BodyCardSchema>(v) {
1624 Ok(parsed) => Some(parsed),
1625 Err(e) => {
1626 errors.push(
1627 Diagnostic::new(
1628 Severity::Error,
1629 format!("Invalid 'main.body' block: {}", e),
1630 )
1631 .with_code("quill::invalid_body".to_string())
1632 .with_hint("Valid keys under 'body' are: enabled, example.".to_string()),
1633 );
1634 None
1635 }
1636 },
1637 };
1638
1639 // Extract main.description (optional, authored under `main:` like any
1640 // other card kind). This is independent of `quill.description`.
1641 let main_description = main_obj_opt
1642 .and_then(|main_obj| main_obj.get("description"))
1643 .and_then(|v| v.as_str())
1644 .map(|s| s.to_string());
1645 Self::validate_description_singleline(main_description.as_deref(), "main", &mut errors);
1646
1647 // The main entry-point card.
1648 let mut main = CardSchema {
1649 name: "main".to_string(),
1650 description: main_description,
1651 fields,
1652 ui: main_ui.or(ui_section),
1653 body: main_body,
1654 };
1655
1656 // Extract [card_kinds] section (optional)
1657 let mut card_kinds: Vec<CardSchema> = Vec::new();
1658 if let Some(card_kinds_val) = quill_yaml_val.get("card_kinds") {
1659 match card_kinds_val.as_object() {
1660 None => {
1661 errors.push(
1662 Diagnostic::new(
1663 Severity::Error,
1664 "'card_kinds' section must be an object (mapping of kind names to schemas)".to_string(),
1665 )
1666 .with_code("quill::invalid_card_kinds".to_string()),
1667 );
1668 }
1669 Some(card_kinds_table) => {
1670 for (card_name, card_value) in card_kinds_table {
1671 if !crate::document::is_valid_kind_name(card_name) {
1672 errors.push(
1673 Diagnostic::new(
1674 Severity::Error,
1675 format!(
1676 "Invalid card-kind name '{}': names must match \
1677 [a-z_][a-z0-9_]* (lowercase letters, digits, and underscores only).",
1678 card_name
1679 ),
1680 )
1681 .with_code("quill::invalid_card_name".to_string()),
1682 );
1683 continue;
1684 }
1685
1686 // Parse card basic info using serde
1687 let card_def: CardSchemaDef =
1688 match serde_json::from_value(card_value.clone()) {
1689 Ok(d) => d,
1690 Err(e) => {
1691 errors.push(
1692 Diagnostic::new(
1693 Severity::Error,
1694 format!(
1695 "Failed to parse card_kind '{}': {}",
1696 card_name, e
1697 ),
1698 )
1699 .with_code("quill::invalid_card_schema".to_string()),
1700 );
1701 continue;
1702 }
1703 };
1704
1705 // Parse card fields
1706 let card_fields = if let Some(card_fields_table) =
1707 card_value.get("fields").and_then(|v| v.as_object())
1708 {
1709 Self::parse_fields(
1710 card_fields_table,
1711 &format!("card_kind '{}' field", card_name),
1712 &mut errors,
1713 )
1714 } else {
1715 IndexMap::new()
1716 };
1717
1718 Self::validate_description_singleline(
1719 card_def.description.as_deref(),
1720 &format!("card_kind '{}'", card_name),
1721 &mut errors,
1722 );
1723 card_kinds.push(CardSchema {
1724 name: card_name.clone(),
1725 description: card_def.description,
1726 fields: card_fields,
1727 ui: card_def.ui,
1728 body: card_def.body,
1729 });
1730 }
1731 }
1732 }
1733 }
1734
1735 // Warn when `body.example` is set together with `body.enabled: false`:
1736 // the example has no effect since the body editor is disabled.
1737 let warn_example_unused = |label: &str,
1738 body: &Option<BodyCardSchema>|
1739 -> Option<Diagnostic> {
1740 let body = body.as_ref()?;
1741 if body.enabled == Some(false) && body.example.is_some() {
1742 Some(
1743 Diagnostic::new(
1744 Severity::Warning,
1745 format!(
1746 "`{label}.body.example` is set but `{label}.body.enabled` is false; the example will have no effect"
1747 ),
1748 )
1749 .with_code("quill::body_example_unused".to_string())
1750 .with_hint(
1751 "Set `body.enabled: true` to surface the example, or remove `body.example`."
1752 .to_string(),
1753 ),
1754 )
1755 } else {
1756 None
1757 }
1758 };
1759 if let Some(d) = warn_example_unused("main", &main.body) {
1760 warnings.push(d);
1761 }
1762 for card in &card_kinds {
1763 if let Some(d) = warn_example_unused(&format!("card_kinds.{}", card.name), &card.body) {
1764 warnings.push(d);
1765 }
1766 }
1767
1768 // Validate each card's group registry and its fields' group references.
1769 Self::validate_card_groups("main", &main, &mut errors, &mut warnings);
1770 for card in &card_kinds {
1771 Self::validate_card_groups(
1772 &format!("card_kinds.{}", card.name),
1773 card,
1774 &mut errors,
1775 &mut warnings,
1776 );
1777 }
1778
1779 // Error when `body.example` contains a line that the document parser
1780 // would interpret as a `~~~` card-yaml block opener. Such a line would
1781 // start a new metadata block, corrupting document structure.
1782 let err_example_contains_fence = |label: &str,
1783 body: &Option<BodyCardSchema>|
1784 -> Option<Diagnostic> {
1785 let example = body.as_ref()?.example.as_deref()?;
1786 if example_contains_fence_line(example) {
1787 Some(
1788 Diagnostic::new(
1789 Severity::Error,
1790 format!(
1791 "`{label}.body.example` contains a line that would be parsed as a `~~~` card-yaml block opener; this would corrupt the blueprint"
1792 ),
1793 )
1794 .with_code("quill::body_example_contains_fence".to_string())
1795 .with_hint(
1796 "Remove or reword any column-zero line that opens a card-yaml block (`~~~`, a longer tilde run, or `~~~card-yaml`). For a literal fenced code block, use a backtick fence (```).".to_string(),
1797 ),
1798 )
1799 } else {
1800 None
1801 }
1802 };
1803 if let Some(d) = err_example_contains_fence("main", &main.body) {
1804 errors.push(d);
1805 }
1806 for card in &card_kinds {
1807 if let Some(d) =
1808 err_example_contains_fence(&format!("card_kinds.{}", card.name), &card.body)
1809 {
1810 errors.push(d);
1811 }
1812 }
1813
1814 // Import every richtext `default` / `example` / `body.example` literal
1815 // once into its canonical-content companion cache: a pure function of the
1816 // Quill.yaml bytes, never serialized. This is where `richtext(inline)`
1817 // violations and malformed richtext literals surface as load errors, and
1818 // where seeding and the render floor later read a pre-validated content
1819 // instead of re-importing the markdown per document.
1820 populate_card_content(&mut main, "main", &mut errors);
1821 for card in &mut card_kinds {
1822 let label = format!("card_kinds.{}", card.name);
1823 populate_card_content(card, &label, &mut errors);
1824 }
1825
1826 if !errors.is_empty() {
1827 return Err(errors);
1828 }
1829
1830 Ok((
1831 QuillConfig {
1832 name,
1833 description,
1834 main,
1835 card_kinds,
1836 backend,
1837 version,
1838 author,
1839 backend_config,
1840 },
1841 warnings,
1842 ))
1843 }
1844}
1845
1846/// Returns true if any line in `text` would be parsed as a card-yaml block
1847/// opener by the document parser, which would corrupt the blueprint's document
1848/// structure when the example is embedded verbatim as body content.
1849///
1850/// Delegates to the parser's own opener predicate
1851/// ([`crate::document::fences::is_card_yaml_opener_line`]) so the guard stays
1852/// in lock-step with fence detection: a column-zero tilde fence (three or more
1853/// tildes) whose info string is empty or `card-yaml`. Backtick fences,
1854/// language-tagged `~~~` fences, and indented fences are ordinary code blocks
1855/// and are not flagged.
1856fn example_contains_fence_line(text: &str) -> bool {
1857 text.lines().any(|line| {
1858 let line = line.strip_suffix('\r').unwrap_or(line);
1859 crate::document::fences::is_card_yaml_opener_line(line)
1860 })
1861}
1862
1863/// Whether a field's type tree contains any content leaf: the gate for caching
1864/// a content companion. Both `richtext` and its literal-codec sibling `plaintext`
1865/// are content leaves; a scalar (`string`, `integer`, `enum`, …) never carries
1866/// one; an `array<richtext>` or an `object` with a content property does.
1867fn field_contains_content(field: &FieldSchema) -> bool {
1868 match &field.r#type {
1869 FieldType::RichText { .. } | FieldType::PlainText { .. } => true,
1870 FieldType::Array => field.items.as_deref().is_some_and(field_contains_content),
1871 FieldType::Object => field
1872 .properties
1873 .as_ref()
1874 .is_some_and(|p| p.values().any(|f| field_contains_content(f))),
1875 _ => false,
1876 }
1877}
1878
1879/// Populate a field's `default_content` / `example_content` companion caches from
1880/// its markdown literals. No-op for a non-richtext field; a failed import or a
1881/// `richtext(inline)` violation is appended to `errors` as a load diagnostic.
1882fn populate_field_content(field: &mut FieldSchema, owner: &str, errors: &mut Vec<Diagnostic>) {
1883 if !field_contains_content(field) {
1884 return;
1885 }
1886 if let Some(default) = field.default.clone() {
1887 match literal_content(&default, field, &format!("{owner} `default`")) {
1888 Ok(content) => field.default_content = content,
1889 Err(d) => errors.push(d),
1890 }
1891 }
1892 if let Some(example) = field.example.clone() {
1893 match literal_content(&example, field, &format!("{owner} `example`")) {
1894 Ok(content) => field.example_content = content,
1895 Err(d) => errors.push(d),
1896 }
1897 }
1898}
1899
1900/// Populate every content companion on a card: each field's
1901/// `default`/`example`, and the card's `body.example` (block richtext, no
1902/// inline constraint; skipped when the body is disabled, since its example is
1903/// inert).
1904fn populate_card_content(card: &mut CardSchema, label: &str, errors: &mut Vec<Diagnostic>) {
1905 for (name, field) in card.fields.iter_mut() {
1906 populate_field_content(field, &format!("{label} field `{name}`"), errors);
1907 }
1908 let body_enabled = card.body.as_ref().is_none_or(|b| b.enabled != Some(false));
1909 if body_enabled {
1910 if let Some(body) = card.body.as_mut() {
1911 if let Some(example) = body.example.clone() {
1912 match crate::document::import_body(&example) {
1913 Ok(rt) => {
1914 body.example_content = Some(QuillValue::from_json(
1915 quillmark_content::serial::to_canonical_value(&rt),
1916 ));
1917 }
1918 Err(e) => errors.push(
1919 Diagnostic::new(
1920 Severity::Error,
1921 format!("Failed to import {label} `body.example`: {e}"),
1922 )
1923 .with_code("quill::richtext_example_import".to_string()),
1924 ),
1925 }
1926 }
1927 }
1928 }
1929}
1930
1931/// Compute the canonical-content form of a richtext-bearing schema literal
1932/// (`default` / `example`), importing every markdown leaf once and enforcing
1933/// `richtext(inline)`. Recurses through `array` / `object` shapes, converting
1934/// only their richtext leaves and passing other elements through unchanged.
1935/// `Ok(None)` when the literal carries no importable richtext (a null value, or
1936/// a field the gate already cleared as non-richtext); `Err` is a load error.
1937fn literal_content(
1938 value: &QuillValue,
1939 field: &FieldSchema,
1940 label: &str,
1941) -> Result<Option<QuillValue>, Diagnostic> {
1942 let json = value.as_json();
1943 // Null ≡ absent: no data to import, so no companion is cached.
1944 if json.is_null() {
1945 return Ok(None);
1946 }
1947 match &field.r#type {
1948 FieldType::RichText { inline } => {
1949 let rt = match crate::document::decode_richtext_value(json) {
1950 Some(Ok(rt)) => rt,
1951 Some(Err(e)) => {
1952 let reason = match e {
1953 crate::document::RichtextDecodeError::BadMarkdown(m) => {
1954 format!("markdown import failed: {m}")
1955 }
1956 crate::document::RichtextDecodeError::NotContent(m) => {
1957 format!("not a valid richtext content: {m}")
1958 }
1959 };
1960 return Err(richtext_literal_error(label, &reason));
1961 }
1962 None => {
1963 return Err(richtext_literal_error(
1964 label,
1965 "expected a markdown string (richtext literals are authored as markdown)",
1966 ));
1967 }
1968 };
1969 if *inline && !rt.is_inline() {
1970 return Err(richtext_inline_error(label));
1971 }
1972 Ok(Some(QuillValue::from_json(
1973 quillmark_content::serial::to_canonical_value(&rt),
1974 )))
1975 }
1976 FieldType::PlainText { inline } => {
1977 // Plaintext literals are authored as literal strings and imported
1978 // verbatim (never markdown), so the cached content is plain by
1979 // construction; a content-object literal is revalidated. Shares the
1980 // one object-vs-string dispatch with the validation shape check.
1981 let rt = match crate::document::decode_plaintext_value(json) {
1982 Some(Ok(rt)) => rt,
1983 Some(Err(e)) => {
1984 return Err(richtext_literal_error(
1985 label,
1986 &format!("not a valid richtext content: {e}"),
1987 ))
1988 }
1989 None => {
1990 return Err(richtext_literal_error(
1991 label,
1992 "expected a plaintext string (plaintext literals are authored as literal text)",
1993 ))
1994 }
1995 };
1996 if !rt.is_plain() {
1997 return Err(richtext_literal_error(
1998 label,
1999 "plaintext carries no marks, islands, or block formatting",
2000 ));
2001 }
2002 if *inline && !rt.is_inline() {
2003 return Err(richtext_inline_error(label));
2004 }
2005 Ok(Some(QuillValue::from_json(
2006 quillmark_content::serial::to_canonical_value(&rt),
2007 )))
2008 }
2009 FieldType::Array => {
2010 let Some(items) = field.items.as_deref() else {
2011 return Ok(None);
2012 };
2013 if !field_contains_content(items) {
2014 return Ok(None);
2015 }
2016 let arr = json.as_array().cloned().unwrap_or_default();
2017 let mut out = Vec::with_capacity(arr.len());
2018 for (idx, elem) in arr.iter().enumerate() {
2019 let elem_v = QuillValue::from_json(elem.clone());
2020 let content =
2021 literal_content(&elem_v, items, &format!("{label}[{idx}]"))?.unwrap_or(elem_v);
2022 out.push(content.into_json());
2023 }
2024 Ok(Some(QuillValue::from_json(serde_json::Value::Array(out))))
2025 }
2026 FieldType::Object => {
2027 let Some(props) = field.properties.as_ref() else {
2028 return Ok(None);
2029 };
2030 if !props.values().any(|f| field_contains_content(f)) {
2031 return Ok(None);
2032 }
2033 let obj = json.as_object().cloned().unwrap_or_default();
2034 let mut out = serde_json::Map::new();
2035 for (k, v) in &obj {
2036 let converted = match props.get(k) {
2037 Some(pschema) => {
2038 let pv = QuillValue::from_json(v.clone());
2039 literal_content(&pv, pschema, &format!("{label}.{k}"))?
2040 .map(QuillValue::into_json)
2041 .unwrap_or_else(|| v.clone())
2042 }
2043 None => v.clone(),
2044 };
2045 out.insert(k.clone(), converted);
2046 }
2047 Ok(Some(QuillValue::from_json(serde_json::Value::Object(out))))
2048 }
2049 _ => Ok(None),
2050 }
2051}
2052
2053/// A load diagnostic for a richtext schema literal that failed to import.
2054fn richtext_literal_error(label: &str, reason: &str) -> Diagnostic {
2055 Diagnostic::new(
2056 Severity::Error,
2057 format!("Failed to import richtext {label}: {reason}"),
2058 )
2059 .with_code("quill::richtext_example_import".to_string())
2060}
2061
2062/// A load diagnostic for a `richtext(inline)` schema literal whose content spans
2063/// more than a single paragraph.
2064fn richtext_inline_error(label: &str) -> Diagnostic {
2065 Diagnostic::new(
2066 Severity::Error,
2067 format!(
2068 "richtext(inline) {label} must be a single paragraph (no blank lines, \
2069 headings, lists, quotes, or tables)"
2070 ),
2071 )
2072 .with_code("richtext::not_inline".to_string())
2073 .with_hint(
2074 "Reduce the value to one paragraph, or change the field `type:` to `richtext`.".to_string(),
2075 )
2076}