quillmark_core/quill/config.rs
1//! Quill configuration parsing and normalization.
2use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
3
4use indexmap::IndexMap;
5
6use serde::{Deserialize, Serialize};
7
8use crate::error::{Diagnostic, Severity, diag_args};
9use crate::value::QuillValue;
10
11use super::types::{RICHTEXT_INLINE_TOKEN_MSG, UI_ORDER_REMOVED_MSG};
12use super::{BodyCardSchema, CardSchema, FieldSchema, FieldType, GroupRegistry, UiCardSchema};
13
14/// Canonical string text for a bare scalar unambiguously representable as a
15/// string: a boolean (`true`/`false`) or number (`47`, `1.0`). `None` for
16/// `null` (≡ absent), strings (already strings), and collections.
17///
18/// Shared by `QuillConfig::conform_value` (to adopt the value) and
19/// `validation::validate_value` (to accept it), so coercion and validation
20/// never disagree about which bare scalars a `string` field accepts.
21pub(crate) fn scalar_as_string(value: &serde_json::Value) -> Option<String> {
22 match value {
23 serde_json::Value::Bool(b) => Some(b.to_string()),
24 serde_json::Value::Number(n) => Some(n.to_string()),
25 _ => None,
26 }
27}
28
29/// Reduce a lenient value to its authored-string form: a bare string, the
30/// sole element of a length-1 array when that element is a string (the
31/// array-unwrap leniency), or a bare scalar's canonical text (via
32/// [`scalar_as_string`]). `None` for anything else (a multi-element array, an
33/// object, null), leaving the caller's own fallback to apply.
34///
35/// Shared by the `String` and `Content` coercion branches, which both reduce
36/// a lenient value to a string before adopting it (as the field value itself,
37/// or as markdown to import).
38fn lenient_string(value: &serde_json::Value) -> Option<String> {
39 if let Some(s) = value.as_str() {
40 return Some(s.to_string());
41 }
42 if let Some(s) = value
43 .as_array()
44 .filter(|a| a.len() == 1)
45 .and_then(|a| a[0].as_str())
46 {
47 return Some(s.to_string());
48 }
49 scalar_as_string(value)
50}
51
52/// Top-level configuration for a Quillmark project
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54#[non_exhaustive]
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
79impl QuillConfig {
80 /// The four fields `Quill.yaml` requires. `description`, `author`,
81 /// `card_kinds`, and `backend_config` start empty.
82 ///
83 /// This bypasses [`Self::from_yaml_with_warnings`] and its validation, so a
84 /// config built here can hold shapes the parser refuses. Loading a quill
85 /// goes through that path; this one is for a caller assembling a schema in
86 /// 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 content object: {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 `validation::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 while collecting non-fatal warnings.
1352 ///
1353 /// Returns `Ok((config, warnings))` on success, or `Err(errors)` containing all
1354 /// parse/validation errors when the config is invalid. Errors are always collected
1355 /// exhaustively: callers see every problem, not just the first.
1356 pub fn from_yaml_with_warnings(
1357 yaml_content: &str,
1358 ) -> Result<(Self, Vec<Diagnostic>), Vec<Diagnostic>> {
1359 let mut warnings: Vec<Diagnostic> = Vec::new();
1360 let mut errors: Vec<Diagnostic> = Vec::new();
1361
1362 // Parse YAML into serde_json::Value via serde_saphyr. The depth budget
1363 // bounds nesting so an untrusted Quill.yaml cannot overflow the stack.
1364 // Note: serde_json with "preserve_order" feature is required for this to work as expected
1365 let quill_yaml_val: serde_json::Value = match serde_saphyr::from_str_with_options(
1366 yaml_content,
1367 crate::document::limits::yaml_parse_options(),
1368 ) {
1369 Ok(v) => v,
1370 Err(e) => {
1371 // Through `YamlError` so this shares the one saphyr adapter:
1372 // the engine's Rust API names stripped, the hint derived, and
1373 // the position carried as a `Location`.
1374 return Err(vec![crate::error::YamlError::from_de(e, yaml_content)
1375 .to_diagnostic("quill::yaml_parse_error", "Quill.yaml")]);
1376 }
1377 };
1378
1379 // Extract [quill] section (required): fail immediately if absent since all
1380 // subsequent validation depends on it.
1381 let quill_section = match quill_yaml_val.get("quill") {
1382 Some(v) => v,
1383 None => {
1384 return Err(vec![Diagnostic::new(
1385 Severity::Error,
1386 "Missing required 'quill' section in Quill.yaml".to_string(),
1387 )
1388 .with_code("quill::missing_section".to_string())
1389 .with_hint(
1390 "Add a 'quill:' section with name, backend, version, and description."
1391 .to_string(),
1392 )]);
1393 }
1394 };
1395
1396 // Validate that no unknown keys appear in the [quill] section.
1397 const KNOWN_QUILL_KEYS: &[&str] =
1398 &["name", "backend", "description", "version", "author", "ui"];
1399 if let Some(quill_obj) = quill_section.as_object() {
1400 for key in quill_obj.keys() {
1401 if !KNOWN_QUILL_KEYS.contains(&key.as_str()) {
1402 errors.push(
1403 Diagnostic::new(
1404 Severity::Error,
1405 format!("Unknown key '{}' in 'quill:' section", key),
1406 )
1407 .with_code("quill::unknown_key".to_string())
1408 .with_hint(format!("Valid keys are: {}", KNOWN_QUILL_KEYS.join(", "))),
1409 );
1410 }
1411 }
1412 }
1413
1414 // Extract required fields: collect all missing-field errors before returning.
1415 let name = match quill_section.get("name").and_then(|v| v.as_str()) {
1416 Some(n) => {
1417 if !Self::is_valid_quill_name(n) {
1418 errors.push(
1419 Diagnostic::new(
1420 Severity::Error,
1421 format!(
1422 "Invalid Quill name '{}': quill.name must be snake_case \
1423 (lowercase letters, digits, and underscores only).",
1424 n
1425 ),
1426 )
1427 .with_code("quill::invalid_name".to_string())
1428 .with_hint(format!(
1429 "Rename '{}' to '{}'",
1430 n,
1431 n.to_lowercase().replace('-', "_")
1432 )),
1433 );
1434 }
1435 n.to_string()
1436 }
1437 None => {
1438 errors.push(
1439 Diagnostic::new(
1440 Severity::Error,
1441 "Missing required 'name' field in 'quill' section".to_string(),
1442 )
1443 .with_code("quill::missing_name".to_string())
1444 .with_hint(
1445 "Add 'name: your_quill_name' under the 'quill:' section.".to_string(),
1446 ),
1447 );
1448 String::new()
1449 }
1450 };
1451
1452 let backend = match quill_section.get("backend").and_then(|v| v.as_str()) {
1453 Some(b) => b.to_string(),
1454 None => {
1455 errors.push(
1456 Diagnostic::new(
1457 Severity::Error,
1458 "Missing required 'backend' field in 'quill' section".to_string(),
1459 )
1460 .with_code("quill::missing_backend".to_string())
1461 .with_hint("Add 'backend: typst' (or another supported backend).".to_string()),
1462 );
1463 String::new()
1464 }
1465 };
1466
1467 let description = match quill_section.get("description").and_then(|v| v.as_str()) {
1468 Some(d) if !d.trim().is_empty() => {
1469 Self::validate_description_singleline(Some(d), "quill", &mut errors);
1470 d.to_string()
1471 }
1472 Some(_) => {
1473 errors.push(
1474 Diagnostic::new(
1475 Severity::Error,
1476 "'description' field in 'quill' section cannot be empty".to_string(),
1477 )
1478 .with_code("quill::empty_description".to_string()),
1479 );
1480 String::new()
1481 }
1482 None => {
1483 errors.push(
1484 Diagnostic::new(
1485 Severity::Error,
1486 "Missing required 'description' field in 'quill' section".to_string(),
1487 )
1488 .with_code("quill::missing_description".to_string())
1489 .with_hint("Add a brief 'description:' of what this quill is for.".to_string()),
1490 );
1491 String::new()
1492 }
1493 };
1494
1495 // Extract the required `version` field.
1496 let version = match quill_section.get("version") {
1497 Some(version_val) => {
1498 // Handle version as string or number (YAML might parse 1.0 as number)
1499 let raw = if let Some(s) = version_val.as_str() {
1500 s.to_string()
1501 } else if let Some(n) = version_val.as_f64() {
1502 n.to_string()
1503 } else {
1504 errors.push(
1505 Diagnostic::new(
1506 Severity::Error,
1507 "Invalid 'version' field format".to_string(),
1508 )
1509 .with_code("quill::invalid_version".to_string())
1510 .with_hint("Use semver format: '1.0' or '1.0.0'.".to_string()),
1511 );
1512 String::new()
1513 };
1514 if !raw.is_empty() {
1515 use std::str::FromStr;
1516 if let Err(e) = crate::version::Version::from_str(&raw) {
1517 errors.push(
1518 Diagnostic::new(
1519 Severity::Error,
1520 format!("Invalid version '{}': {}", raw, e),
1521 )
1522 .with_code("quill::invalid_version".to_string())
1523 .with_hint("Use semver format: '1.0' or '1.0.0'.".to_string()),
1524 );
1525 }
1526 }
1527 raw
1528 }
1529 None => {
1530 errors.push(
1531 Diagnostic::new(
1532 Severity::Error,
1533 "Missing required 'version' field in 'quill' section".to_string(),
1534 )
1535 .with_code("quill::missing_version".to_string())
1536 .with_hint("Add 'version: 1.0' under the 'quill:' section.".to_string()),
1537 );
1538 String::new()
1539 }
1540 };
1541
1542 let author = quill_section
1543 .get("author")
1544 .and_then(|v| v.as_str())
1545 .map(|s| s.to_string())
1546 .unwrap_or_else(|| "Unknown".to_string());
1547
1548 let ui_section: Option<UiCardSchema> = match quill_section.get("ui").cloned() {
1549 None => None,
1550 Some(v) => match serde_json::from_value::<UiCardSchema>(v) {
1551 Ok(parsed) => Some(parsed),
1552 Err(e) => {
1553 errors.push(
1554 Diagnostic::new(
1555 Severity::Error,
1556 format!("Invalid 'quill.ui' block: {}", e),
1557 )
1558 .with_code("quill::invalid_ui".to_string())
1559 .with_hint("Valid keys under 'ui' are: title, groups.".to_string()),
1560 );
1561 None
1562 }
1563 },
1564 };
1565
1566 // Extract optional backend-specific section (keyed by `quill.backend`).
1567 let mut backend_config = HashMap::new();
1568 if !backend.is_empty() {
1569 if let Some(section_val) = quill_yaml_val.get(&backend) {
1570 if let Some(table) = section_val.as_object() {
1571 for (key, value) in table {
1572 backend_config.insert(key.clone(), QuillValue::from_json(value.clone()));
1573 }
1574 }
1575 }
1576 }
1577
1578 // Reject unknown top-level sections. Known sections are: quill, main, card_kinds,
1579 // and the backend name (e.g. typst). Everything else is a mistake. `fields` gets
1580 // a targeted hint since it's the most common shape mistake.
1581 if let Some(top_obj) = quill_yaml_val.as_object() {
1582 for key in top_obj.keys() {
1583 let is_known = key == "quill"
1584 || key == "main"
1585 || key == "card_kinds"
1586 || (!backend.is_empty() && key == &backend);
1587 if is_known {
1588 continue;
1589 }
1590
1591 let mut diag = Diagnostic::new(
1592 Severity::Error,
1593 format!("Unknown top-level section '{}'", key),
1594 )
1595 .with_code("quill::unknown_section".to_string());
1596
1597 diag = if key == "fields" {
1598 diag.with_hint(
1599 "Root-level `fields` is not supported; use `main.fields` instead."
1600 .to_string(),
1601 )
1602 } else {
1603 diag.with_hint(format!(
1604 "Valid top-level sections are: quill, main, card_kinds{}",
1605 if backend.is_empty() {
1606 String::new()
1607 } else {
1608 format!(", {}", backend)
1609 }
1610 ))
1611 };
1612
1613 errors.push(diag);
1614 }
1615 }
1616
1617 let main_obj_opt = quill_yaml_val.get("main").and_then(|v| v.as_object());
1618
1619 // Extract main.fields (optional)
1620 let fields = if let Some(fields_map) = main_obj_opt
1621 .and_then(|main_obj| main_obj.get("fields"))
1622 .and_then(|v| v.as_object())
1623 {
1624 Self::parse_fields(fields_map, "field schema", &mut errors)
1625 } else {
1626 IndexMap::new()
1627 };
1628
1629 // Extract main.ui (optional). Fail loudly on malformed UI metadata rather
1630 // than silently dropping it; see `quill.ui` handling above.
1631 let main_ui: Option<UiCardSchema> = match main_obj_opt
1632 .and_then(|main_obj| main_obj.get("ui"))
1633 .cloned()
1634 {
1635 None => None,
1636 Some(v) => match serde_json::from_value::<UiCardSchema>(v) {
1637 Ok(parsed) => Some(parsed),
1638 Err(e) => {
1639 errors.push(
1640 Diagnostic::new(Severity::Error, format!("Invalid 'main.ui' block: {}", e))
1641 .with_code("quill::invalid_ui".to_string())
1642 .with_hint("Valid keys under 'ui' are: title, groups.".to_string()),
1643 );
1644 None
1645 }
1646 },
1647 };
1648
1649 // Extract main.body (optional). Fail loudly on malformed body metadata.
1650 let main_body: Option<BodyCardSchema> = match main_obj_opt
1651 .and_then(|main_obj| main_obj.get("body"))
1652 .cloned()
1653 {
1654 None => None,
1655 Some(v) => match serde_json::from_value::<BodyCardSchema>(v) {
1656 Ok(parsed) => Some(parsed),
1657 Err(e) => {
1658 errors.push(
1659 Diagnostic::new(
1660 Severity::Error,
1661 format!("Invalid 'main.body' block: {}", e),
1662 )
1663 .with_code("quill::invalid_body".to_string())
1664 .with_hint("Valid keys under 'body' are: enabled, example.".to_string()),
1665 );
1666 None
1667 }
1668 },
1669 };
1670
1671 // Extract main.description (optional, authored under `main:` like any
1672 // other card kind). This is independent of `quill.description`.
1673 let main_description = main_obj_opt
1674 .and_then(|main_obj| main_obj.get("description"))
1675 .and_then(|v| v.as_str())
1676 .map(|s| s.to_string());
1677 Self::validate_description_singleline(main_description.as_deref(), "main", &mut errors);
1678
1679 // The main entry-point card.
1680 let mut main = CardSchema {
1681 name: "main".to_string(),
1682 description: main_description,
1683 fields,
1684 ui: main_ui.or(ui_section),
1685 body: main_body,
1686 };
1687
1688 // Extract [card_kinds] section (optional)
1689 let mut card_kinds: Vec<CardSchema> = Vec::new();
1690 if let Some(card_kinds_val) = quill_yaml_val.get("card_kinds") {
1691 match card_kinds_val.as_object() {
1692 None => {
1693 errors.push(
1694 Diagnostic::new(
1695 Severity::Error,
1696 "'card_kinds' section must be an object (mapping of kind names to schemas)".to_string(),
1697 )
1698 .with_code("quill::invalid_card_kinds".to_string()),
1699 );
1700 }
1701 Some(card_kinds_table) => {
1702 for (card_name, card_value) in card_kinds_table {
1703 if !crate::document::is_valid_kind_name(card_name) {
1704 errors.push(
1705 Diagnostic::new(
1706 Severity::Error,
1707 format!(
1708 "Invalid card-kind name '{}': names must match \
1709 [a-z_][a-z0-9_]* (lowercase letters, digits, and underscores only).",
1710 card_name
1711 ),
1712 )
1713 .with_code("quill::invalid_card_name".to_string()),
1714 );
1715 continue;
1716 }
1717
1718 // Parse card basic info using serde
1719 let card_def: CardSchemaDef =
1720 match serde_json::from_value(card_value.clone()) {
1721 Ok(d) => d,
1722 Err(e) => {
1723 errors.push(
1724 Diagnostic::new(
1725 Severity::Error,
1726 format!(
1727 "Failed to parse card_kind '{}': {}",
1728 card_name, e
1729 ),
1730 )
1731 .with_code("quill::invalid_card_schema".to_string()),
1732 );
1733 continue;
1734 }
1735 };
1736
1737 // Parse card fields
1738 let card_fields = if let Some(card_fields_table) =
1739 card_value.get("fields").and_then(|v| v.as_object())
1740 {
1741 Self::parse_fields(
1742 card_fields_table,
1743 &format!("card_kind '{}' field", card_name),
1744 &mut errors,
1745 )
1746 } else {
1747 IndexMap::new()
1748 };
1749
1750 Self::validate_description_singleline(
1751 card_def.description.as_deref(),
1752 &format!("card_kind '{}'", card_name),
1753 &mut errors,
1754 );
1755 card_kinds.push(CardSchema {
1756 name: card_name.clone(),
1757 description: card_def.description,
1758 fields: card_fields,
1759 ui: card_def.ui,
1760 body: card_def.body,
1761 });
1762 }
1763 }
1764 }
1765 }
1766
1767 // Warn when `body.example` is set together with `body.enabled: false`:
1768 // the example has no effect since the body editor is disabled.
1769 let warn_example_unused = |label: &str,
1770 body: &Option<BodyCardSchema>|
1771 -> Option<Diagnostic> {
1772 let body = body.as_ref()?;
1773 if body.enabled == Some(false) && body.example.is_some() {
1774 Some(
1775 Diagnostic::new(
1776 Severity::Warning,
1777 format!(
1778 "`{label}.body.example` is set but `{label}.body.enabled` is false; the example will have no effect"
1779 ),
1780 )
1781 .with_code("quill::body_example_unused".to_string())
1782 .with_hint(
1783 "Set `body.enabled: true` to surface the example, or remove `body.example`."
1784 .to_string(),
1785 ),
1786 )
1787 } else {
1788 None
1789 }
1790 };
1791 if let Some(d) = warn_example_unused("main", &main.body) {
1792 warnings.push(d);
1793 }
1794 for card in &card_kinds {
1795 if let Some(d) = warn_example_unused(&format!("card_kinds.{}", card.name), &card.body) {
1796 warnings.push(d);
1797 }
1798 }
1799
1800 // Validate each card's group registry and its fields' group references.
1801 Self::validate_card_groups("main", &main, &mut errors, &mut warnings);
1802 for card in &card_kinds {
1803 Self::validate_card_groups(
1804 &format!("card_kinds.{}", card.name),
1805 card,
1806 &mut errors,
1807 &mut warnings,
1808 );
1809 }
1810
1811 // Error when `body.example` contains a line that the document parser
1812 // would interpret as a `~~~` card-yaml block opener. Such a line would
1813 // start a new metadata block, corrupting document structure.
1814 let err_example_contains_fence = |label: &str,
1815 body: &Option<BodyCardSchema>|
1816 -> Option<Diagnostic> {
1817 let example = body.as_ref()?.example.as_deref()?;
1818 if example_contains_fence_line(example) {
1819 Some(
1820 Diagnostic::new(
1821 Severity::Error,
1822 format!(
1823 "`{label}.body.example` contains a line that would be parsed as a `~~~` card-yaml block opener; this would corrupt the blueprint"
1824 ),
1825 )
1826 .with_code("quill::body_example_contains_fence".to_string())
1827 .with_hint(
1828 "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(),
1829 ),
1830 )
1831 } else {
1832 None
1833 }
1834 };
1835 if let Some(d) = err_example_contains_fence("main", &main.body) {
1836 errors.push(d);
1837 }
1838 for card in &card_kinds {
1839 if let Some(d) =
1840 err_example_contains_fence(&format!("card_kinds.{}", card.name), &card.body)
1841 {
1842 errors.push(d);
1843 }
1844 }
1845
1846 // Import every richtext `default` / `example` / `body.example` literal
1847 // once into its canonical-content companion cache: a pure function of the
1848 // Quill.yaml bytes, never serialized. This is where `richtext(inline)`
1849 // violations and malformed richtext literals surface as load errors, and
1850 // where seeding and the render floor later read a pre-validated content
1851 // instead of re-importing the markdown per document.
1852 populate_card_content(&mut main, "main", &mut errors);
1853 for card in &mut card_kinds {
1854 let label = format!("card_kinds.{}", card.name);
1855 populate_card_content(card, &label, &mut errors);
1856 }
1857
1858 if !errors.is_empty() {
1859 return Err(errors);
1860 }
1861
1862 Ok((
1863 QuillConfig {
1864 name,
1865 description,
1866 main,
1867 card_kinds,
1868 backend,
1869 version,
1870 author,
1871 backend_config,
1872 },
1873 warnings,
1874 ))
1875 }
1876}
1877
1878/// Returns true if any line in `text` would be parsed as a card-yaml block
1879/// opener by the document parser, which would corrupt the blueprint's document
1880/// structure when the example is embedded verbatim as body content.
1881///
1882/// Delegates to the parser's own opener predicate
1883/// ([`crate::document::fences::is_card_yaml_opener_line`]) so the guard stays
1884/// in lock-step with fence detection: a column-zero tilde fence (three or more
1885/// tildes) whose info string is empty or `card-yaml`. Backtick fences,
1886/// language-tagged `~~~` fences, and indented fences are ordinary code blocks
1887/// and are not flagged.
1888fn example_contains_fence_line(text: &str) -> bool {
1889 text.lines().any(|line| {
1890 let line = line.strip_suffix('\r').unwrap_or(line);
1891 crate::document::fences::is_card_yaml_opener_line(line)
1892 })
1893}
1894
1895/// Whether a field's type tree contains any content leaf: the gate for caching
1896/// a content companion. Both `richtext` and its literal-codec sibling `plaintext`
1897/// are content leaves; a scalar (`string`, `integer`, `enum`, …) never carries
1898/// one; an `array<richtext>` or an `object` with a content property does.
1899pub(crate) fn field_contains_content(field: &FieldSchema) -> bool {
1900 match &field.r#type {
1901 FieldType::RichText { .. } | FieldType::PlainText { .. } => true,
1902 FieldType::Array => field.items.as_deref().is_some_and(field_contains_content),
1903 FieldType::Object => field
1904 .properties
1905 .as_ref()
1906 .is_some_and(|p| p.values().any(|f| field_contains_content(f))),
1907 _ => false,
1908 }
1909}
1910
1911/// Populate a field's `default_content` / `example_content` companion caches from
1912/// its markdown literals. No-op for a non-richtext field; a failed import or a
1913/// `richtext(inline)` violation is appended to `errors` as a load diagnostic.
1914fn populate_field_content(field: &mut FieldSchema, owner: &str, errors: &mut Vec<Diagnostic>) {
1915 if !field_contains_content(field) {
1916 return;
1917 }
1918 if let Some(default) = field.default.clone() {
1919 match literal_content(&default, field, &format!("{owner} `default`")) {
1920 Ok(content) => field.default_content = content,
1921 Err(d) => errors.push(d),
1922 }
1923 }
1924 if let Some(example) = field.example.clone() {
1925 match literal_content(&example, field, &format!("{owner} `example`")) {
1926 Ok(content) => field.example_content = content,
1927 Err(d) => errors.push(d),
1928 }
1929 }
1930}
1931
1932/// Populate every content companion on a card: each field's
1933/// `default`/`example`, and the card's `body.example` (block richtext, no
1934/// inline constraint; skipped when the body is disabled, since its example is
1935/// inert).
1936fn populate_card_content(card: &mut CardSchema, label: &str, errors: &mut Vec<Diagnostic>) {
1937 for (name, field) in card.fields.iter_mut() {
1938 populate_field_content(field, &format!("{label} field `{name}`"), errors);
1939 }
1940 let body_enabled = card.body.as_ref().is_none_or(|b| b.enabled != Some(false));
1941 if body_enabled {
1942 if let Some(body) = card.body.as_mut() {
1943 if let Some(example) = body.example.clone() {
1944 match crate::document::import_body(&example) {
1945 Ok(rt) => {
1946 body.example_content = Some(QuillValue::from_json(
1947 quillmark_content::serial::to_canonical_value(&rt),
1948 ));
1949 }
1950 Err(e) => errors.push(
1951 Diagnostic::new(
1952 Severity::Error,
1953 format!("Failed to import {label} `body.example`: {e}"),
1954 )
1955 .with_code("quill::richtext_example_import".to_string()),
1956 ),
1957 }
1958 }
1959 }
1960 }
1961}
1962
1963/// Compute the canonical-content form of a richtext-bearing schema literal
1964/// (`default` / `example`), importing every markdown leaf once and enforcing
1965/// `richtext(inline)`. Recurses through `array` / `object` shapes, converting
1966/// only their richtext leaves and passing other elements through unchanged.
1967/// `Ok(None)` when the literal carries no importable richtext (a null value, or
1968/// a field the gate already cleared as non-richtext); `Err` is a load error.
1969fn literal_content(
1970 value: &QuillValue,
1971 field: &FieldSchema,
1972 label: &str,
1973) -> Result<Option<QuillValue>, Diagnostic> {
1974 let json = value.as_json();
1975 // Null ≡ absent: no data to import, so no companion is cached.
1976 if json.is_null() {
1977 return Ok(None);
1978 }
1979 match &field.r#type {
1980 FieldType::RichText { inline } => {
1981 let rt = match crate::document::decode_richtext_value(json) {
1982 Some(Ok(rt)) => rt,
1983 Some(Err(e)) => {
1984 let reason = match e {
1985 crate::document::RichtextDecodeError::BadMarkdown(m) => {
1986 format!("markdown import failed: {m}")
1987 }
1988 crate::document::RichtextDecodeError::NotContent(m) => {
1989 format!("not a valid richtext content: {m}")
1990 }
1991 };
1992 return Err(richtext_literal_error(label, &reason));
1993 }
1994 None => {
1995 return Err(richtext_literal_error(
1996 label,
1997 "expected a markdown string (richtext literals are authored as markdown)",
1998 ));
1999 }
2000 };
2001 if *inline && !rt.is_inline() {
2002 return Err(richtext_inline_error(label));
2003 }
2004 Ok(Some(QuillValue::from_json(
2005 quillmark_content::serial::to_canonical_value(&rt),
2006 )))
2007 }
2008 FieldType::PlainText { inline } => {
2009 // Plaintext literals are authored as literal strings and imported
2010 // verbatim (never markdown), so the cached content is plain by
2011 // construction; a content-object literal is revalidated. Shares the
2012 // one object-vs-string dispatch with the validation shape check.
2013 let rt = match crate::document::decode_plaintext_value(json) {
2014 Some(Ok(rt)) => rt,
2015 Some(Err(e)) => {
2016 return Err(richtext_literal_error(
2017 label,
2018 &format!("not a valid richtext content: {e}"),
2019 ))
2020 }
2021 None => {
2022 return Err(richtext_literal_error(
2023 label,
2024 "expected a plaintext string (plaintext literals are authored as literal text)",
2025 ))
2026 }
2027 };
2028 if !rt.is_plain() {
2029 return Err(richtext_literal_error(
2030 label,
2031 "plaintext carries no marks, islands, or block formatting",
2032 ));
2033 }
2034 if *inline && !rt.is_inline() {
2035 return Err(richtext_inline_error(label));
2036 }
2037 Ok(Some(QuillValue::from_json(
2038 quillmark_content::serial::to_canonical_value(&rt),
2039 )))
2040 }
2041 FieldType::Array => {
2042 let Some(items) = field.items.as_deref() else {
2043 return Ok(None);
2044 };
2045 if !field_contains_content(items) {
2046 return Ok(None);
2047 }
2048 let arr = json.as_array().cloned().unwrap_or_default();
2049 let mut out = Vec::with_capacity(arr.len());
2050 for (idx, elem) in arr.iter().enumerate() {
2051 let elem_v = QuillValue::from_json(elem.clone());
2052 let content =
2053 literal_content(&elem_v, items, &format!("{label}[{idx}]"))?.unwrap_or(elem_v);
2054 out.push(content.into_json());
2055 }
2056 Ok(Some(QuillValue::from_json(serde_json::Value::Array(out))))
2057 }
2058 FieldType::Object => {
2059 let Some(props) = field.properties.as_ref() else {
2060 return Ok(None);
2061 };
2062 if !props.values().any(|f| field_contains_content(f)) {
2063 return Ok(None);
2064 }
2065 let obj = json.as_object().cloned().unwrap_or_default();
2066 let mut out = serde_json::Map::new();
2067 for (k, v) in &obj {
2068 let converted = match props.get(k) {
2069 Some(pschema) => {
2070 let pv = QuillValue::from_json(v.clone());
2071 literal_content(&pv, pschema, &format!("{label}.{k}"))?
2072 .map(QuillValue::into_json)
2073 .unwrap_or_else(|| v.clone())
2074 }
2075 None => v.clone(),
2076 };
2077 out.insert(k.clone(), converted);
2078 }
2079 Ok(Some(QuillValue::from_json(serde_json::Value::Object(out))))
2080 }
2081 _ => Ok(None),
2082 }
2083}
2084
2085/// A load diagnostic for a richtext schema literal that failed to import.
2086fn richtext_literal_error(label: &str, reason: &str) -> Diagnostic {
2087 Diagnostic::new(
2088 Severity::Error,
2089 format!("Failed to import richtext {label}: {reason}"),
2090 )
2091 .with_code("quill::richtext_example_import".to_string())
2092}
2093
2094/// A load diagnostic for a `richtext(inline)` schema literal whose content spans
2095/// more than a single paragraph.
2096fn richtext_inline_error(label: &str) -> Diagnostic {
2097 Diagnostic::new(
2098 Severity::Error,
2099 format!(
2100 "richtext(inline) {label} must be a single paragraph (no blank lines, \
2101 headings, lists, quotes, or tables)"
2102 ),
2103 )
2104 .with_code("validation::not_inline".to_string())
2105 .with_hint(
2106 "Reduce the value to one paragraph, or change the field `type:` to `richtext`.".to_string(),
2107 )
2108}
2109
2110#[cfg(test)]
2111impl QuillConfig {
2112 /// The config, or every load diagnostic joined into one pretty string.
2113 /// Flattening `Vec<Diagnostic>` drops code, hint, and location, so the
2114 /// shape stays off the published surface; a test asserting on message text
2115 /// is the one caller that loss costs nothing.
2116 /// [`from_yaml_with_warnings`](Self::from_yaml_with_warnings) is the real
2117 /// load path.
2118 pub(crate) fn from_yaml(yaml_content: &str) -> Result<Self, String> {
2119 Self::from_yaml_with_warnings(yaml_content)
2120 .map(|(config, _warnings)| config)
2121 .map_err(|diags| {
2122 diags
2123 .iter()
2124 .map(|d| d.fmt_pretty())
2125 .collect::<Vec<_>>()
2126 .join("\n")
2127 })
2128 }
2129}