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