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