use std::str::FromStr;
use indexmap::IndexMap;
use super::resolved::FieldSource;
use super::{seed, CardSchema, FieldSchema, FieldType, Leniency, Quill, QuillConfig};
use crate::normalize::{normalize_document, normalize_field_name};
use crate::quill::zero_value;
use crate::path::DocPath;
use crate::{
Card, Diagnostic, Document, Payload, QuillValue, RenderError, SeedOverlay, Severity, Version,
};
impl Quill {
pub fn compile_data(&self, doc: &Document) -> Result<serde_json::Value, RenderError> {
self.config().compile_data(doc)
}
pub fn dry_run(&self, doc: &Document) -> Result<(), RenderError> {
self.config().dry_run(doc)
}
pub fn check_quill_reference(&self, doc: &Document) -> Result<(), RenderError> {
self.config().check_quill_reference(doc)
}
}
impl QuillConfig {
pub fn compile_data(&self, doc: &Document) -> Result<serde_json::Value, RenderError> {
let coerced = self.coerce_and_validate(doc)?;
let normalized = normalize_document(coerced)?;
let final_main = Card::from_parts(
rebuild_payload_with_meta(
normalized.main(),
plate_fields(ladder_sourced(
&self.main,
&normalized.main().payload().to_index_map(),
)),
),
normalized.main().body().clone(),
);
let mut card_bodies: Vec<bool> = Vec::with_capacity(normalized.cards().len());
let cards_resolved: Vec<Card> = normalized
.cards()
.iter()
.map(|card| {
let schema = self.card_kind(card.kind().unwrap_or(""));
card_bodies.push(schema.is_some_and(|s| s.body_enabled()));
let fields = match schema {
Some(schema) => {
plate_fields(ladder_sourced(schema, &card.payload().to_index_map()))
}
None => card.payload().to_index_map(),
};
Card::from_parts(rebuild_payload_with_meta(card, fields), card.body().clone())
})
.collect();
Ok(Document::from_main_and_cards(final_main, cards_resolved)
.to_plate_json_gated(self.main.body_enabled(), Some(&card_bodies)))
}
pub fn dry_run(&self, doc: &Document) -> Result<(), RenderError> {
self.check_quill_reference(doc)?;
self.coerce_and_validate(doc).map(|_| ())
}
fn coerce_and_validate(&self, doc: &Document) -> Result<Document, RenderError> {
let coerced_payload = self
.coerce_payload(&doc.main().payload().to_index_map())
.map_err(coercion_error)?;
let mut coerced_cards: Vec<Card> = Vec::with_capacity(doc.cards().len());
for card in doc.cards() {
let coerced_fields = self
.coerce_card(card.kind().unwrap_or(""), &card.payload().to_index_map())
.map_err(coercion_error)?;
coerced_cards.push(Card::from_parts(
rebuild_payload_with_meta(card, coerced_fields),
card.body().clone(),
));
}
let coerced_main = Card::from_parts(
rebuild_payload_with_meta(doc.main(), coerced_payload),
doc.main().body().clone(),
);
let coerced_doc = Document::from_main_and_cards(coerced_main, coerced_cards);
self.validate_document(&coerced_doc).map_err(|errors| {
RenderError::new(errors.iter().map(|e| e.to_diagnostic()).collect())
})?;
Ok(coerced_doc)
}
pub fn check_quill_reference(&self, doc: &Document) -> Result<(), RenderError> {
let doc_ref = doc.quill_reference();
if doc_ref.name.as_str() != self.name {
return Err(quill_mismatch(
format!(
"document declares $quill '{}' but was rendered with '{}'",
doc_ref, self.name
),
"quill::name_mismatch",
"render with the quill named by $quill, or update the $quill name",
));
}
let Ok(quill_version) = Version::from_str(&self.version) else {
return Ok(());
};
if !doc_ref.selector.matches(quill_version) {
return Err(quill_mismatch(
format!(
"document declares $quill '{}' but the loaded quill is version '{}'",
doc_ref, quill_version
),
"quill::version_mismatch",
"render with a quill whose version satisfies the selector, or update the $quill selector",
));
}
Ok(())
}
}
impl Quill {
pub fn validate(&self, doc: &Document) -> Vec<Diagnostic> {
let mut diags = match self.config().validate_document(doc) {
Ok(()) => Vec::new(),
Err(errors) => errors.iter().map(|e| e.to_diagnostic()).collect(),
};
diags.extend(validate_fills(self.config(), doc));
diags.extend(self.validate_seed(doc));
diags
}
fn validate_seed(&self, doc: &Document) -> Vec<Diagnostic> {
let Some(seed_map) = doc.main().payload().seed() else {
return Vec::new();
};
let config = self.config();
let mut diags = Vec::new();
for (kind, overlay) in seed_map {
let Some(card_schema) = config.card_kind(kind) else {
diags.push(
Diagnostic::new(
Severity::Warning,
format!("`$seed` overlay targets unknown card kind `{kind}`"),
)
.with_code("validation::seed_unknown_kind".to_string())
.with_path(DocPath::new().field("$seed").field(kind).to_string())
.with_hint(format!(
"Remove the `{kind}` overlay, or rename it to a declared card kind."
)),
);
continue;
};
let Some(obj) = overlay.as_object() else {
diags.push(
Diagnostic::new(
Severity::Warning,
format!("`$seed.{kind}` must be a mapping of field overrides"),
)
.with_code("validation::seed_overlay_shape".to_string())
.with_path(DocPath::new().field("$seed").field(kind).to_string()),
);
continue;
};
for (field, value) in obj {
if field == "$body" {
continue;
}
let field_path = DocPath::new().field("$seed").field(kind).field(field);
let Some(field_schema) = card_schema.fields.get(field) else {
diags.push(
Diagnostic::new(
Severity::Warning,
format!("`$seed.{kind}.{field}` is not a field of card kind `{kind}`"),
)
.with_code("validation::seed_unknown_field".to_string())
.with_path(field_path.to_string()),
);
continue;
};
let qv = QuillValue::from_json(value.clone());
for violation in
super::validation::validate_schema_literal(field_schema, &qv, &field_path)
{
diags.push(seed_violation_diagnostic(&violation));
}
}
}
diags
}
pub fn seed_document(&self) -> Document {
seed::seed_document(self)
}
pub fn seed_main(&self) -> Card {
seed::seed_main(self)
}
pub fn seed_card(&self, card_kind: &str, overlay: Option<&SeedOverlay>) -> Option<Card> {
seed::seed_card_for_kind(self, card_kind, overlay)
}
}
fn quill_mismatch(message: String, code: &str, hint: &str) -> RenderError {
RenderError::from_diag(
Diagnostic::new(Severity::Error, message)
.with_code(code.to_string())
.with_hint(hint.to_string()),
)
}
fn seed_violation_diagnostic(v: &super::validation::ValidationError) -> Diagnostic {
let mut diag = Diagnostic::new(Severity::Warning, v.to_string())
.with_code(v.code().to_string())
.with_path(v.path().to_string());
if let Some(hint) = v.hint() {
diag = diag.with_hint(hint);
}
diag
}
fn coercion_error(e: impl std::fmt::Display) -> RenderError {
RenderError::from_diag(
Diagnostic::new(Severity::Error, e.to_string())
.with_code("validation::coercion_failed".to_string())
.with_hint("Ensure all fields can be coerced to their declared types".to_string()),
)
}
pub(crate) fn resolve_card_sourced(
schema: &CardSchema,
card: &Card,
) -> IndexMap<String, (QuillValue, FieldSource)> {
ladder_sourced(schema, &conform_card_render(schema, card))
}
fn conform_card_render(schema: &CardSchema, card: &Card) -> IndexMap<String, QuillValue> {
let mut coerced: IndexMap<String, QuillValue> = IndexMap::new();
for (raw_name, value) in card.payload().to_index_map() {
let name = normalize_field_name(&raw_name);
let entry = match schema.fields.get(&raw_name) {
Some(field_schema) => {
QuillConfig::conform_value(&value, field_schema, &name, Leniency::Render)
.unwrap_or(value)
}
None => value,
};
coerced.insert(name, entry);
}
coerced
}
pub(crate) fn ladder_sourced(
schema: &CardSchema,
coerced: &IndexMap<String, QuillValue>,
) -> IndexMap<String, (QuillValue, FieldSource)> {
let mut out: IndexMap<String, (QuillValue, FieldSource)> = coerced
.iter()
.map(|(name, value)| (name.clone(), (value.clone(), FieldSource::Authored)))
.collect();
for (name, field_schema) in &schema.fields {
out.insert(
name.clone(),
resolve_value_sourced(coerced.get(name), field_schema),
);
}
out
}
fn plate_fields(
sourced: IndexMap<String, (QuillValue, FieldSource)>,
) -> IndexMap<String, QuillValue> {
sourced
.into_iter()
.map(|(name, (value, _source))| (name, value))
.collect()
}
fn resolve_value(value: Option<&QuillValue>, field: &FieldSchema) -> QuillValue {
resolve_value_sourced(value, field).0
}
pub(crate) fn resolve_value_sourced(
value: Option<&QuillValue>,
field: &FieldSchema,
) -> (QuillValue, FieldSource) {
let present = value.filter(|v| !v.as_json().is_null());
let Some(v) = present else {
if matches!(
field.r#type,
FieldType::RichText { .. } | FieldType::PlainText { .. }
) {
return match field.default_content.clone() {
Some(content) => (content, FieldSource::Default),
None => (zero_value(field), FieldSource::Zero),
};
}
return match field.default.clone() {
Some(default) => (default, FieldSource::Default),
None => (zero_value(field), FieldSource::Zero),
};
};
let resolved = match (&field.r#type, &field.properties, &field.items) {
(FieldType::Object, Some(props), _) => {
let obj = v.as_json().as_object();
let mut out = serde_json::Map::new();
for (pname, pschema) in props {
let pv = obj
.and_then(|o| o.get(pname))
.map(|j| QuillValue::from_json(j.clone()));
out.insert(
pname.clone(),
resolve_value(pv.as_ref(), pschema).into_json(),
);
}
if let Some(o) = obj {
for (k, v) in o {
if !props.contains_key(k) {
out.insert(k.clone(), v.clone());
}
}
}
QuillValue::from_json(serde_json::Value::Object(out))
}
(FieldType::Array, _, Some(items)) => {
let arr = v.as_json().as_array().cloned().unwrap_or_default();
let out: Vec<serde_json::Value> = arr
.into_iter()
.map(|e| resolve_value(Some(&QuillValue::from_json(e)), items).into_json())
.collect();
QuillValue::from_json(serde_json::Value::Array(out))
}
_ => v.clone(),
};
(resolved, FieldSource::Authored)
}
fn rebuild_payload_with_meta(source: &Card, fields: IndexMap<String, QuillValue>) -> Payload {
let mut payload = Payload::from_index_map(fields);
if let Some(q) = source.quill() {
payload.set_quill(q.clone());
}
if let Some(k) = source.kind() {
payload.set_kind(k.to_string());
}
if let Some(id) = source.id() {
payload.set_id(id.to_string());
}
payload
}
fn validate_fills(config: &QuillConfig, doc: &Document) -> Vec<Diagnostic> {
let mut diags = Vec::new();
collect_fill_diags(doc.main(), &DocPath::main(), &mut diags);
for (index, card) in doc.cards().iter().enumerate() {
let kind = card.kind().filter(|k| config.card_kind(k).is_some());
collect_fill_diags(card, &DocPath::card(kind, index), &mut diags);
}
diags
}
fn collect_fill_diags(card: &Card, base: &DocPath, out: &mut Vec<Diagnostic>) {
let payload = card.payload();
for (key, value) in payload {
let field_path = base.field(key);
if payload.is_fill(key) {
out.push(fill_warning(&field_path));
}
for nested in value.nonroot_fill_paths() {
let nested_path = nested.iter().fold(field_path.clone(), |p, s| p.segment(s));
out.push(fill_warning(&nested_path));
}
}
}
fn fill_warning(path: &DocPath) -> Diagnostic {
let path = path.to_string();
Diagnostic::new(
Severity::Warning,
format!("Field `{path}` is marked `!must_fill` — a placeholder awaiting a value."),
)
.with_code("validation::must_fill".to_string())
.with_path(path)
.with_hint(
"Replace the value and drop the `!must_fill` marker, or remove the marker if the \
current value is intended."
.to_string(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn field(yaml: &str) -> FieldSchema {
let value = QuillValue::from_yaml_str(yaml).unwrap();
FieldSchema::from_quill_value("field".to_string(), &value).unwrap()
}
#[test]
fn typed_dict_preserves_undeclared_keys() {
let schema = field(
r#"
type: object
properties:
street: { type: string }
zip: { type: integer }
"#,
);
let input = QuillValue::from_json(json!({ "street": "1 Infinite Loop", "note": "extra" }));
let resolved = resolve_value(Some(&input), &schema).into_json();
assert_eq!(
resolved,
json!({ "street": "1 Infinite Loop", "zip": 0, "note": "extra" })
);
}
#[test]
fn unknown_kind_card_fill_path_is_bare_index() {
use crate::document::Payload;
let config = QuillConfig::from_yaml(
r#"
quill:
name: fills_test
backend: typst
description: fill path tests
version: 1.0.0
main:
fields:
title:
type: string
default: ""
card_kinds:
known:
fields:
note:
type: string
"#,
)
.unwrap();
let mut main = Payload::new();
main.set_quill("fills_test@1.0.0".parse().unwrap());
main.set_kind("main");
let main = Card::from_parts(main, quillmark_content::Content::empty());
let mut unknown = Card::new("mystery").unwrap();
unknown
.store_fill("note", QuillValue::from_json(json!(null)))
.unwrap();
let mut kindless =
Card::from_parts(Payload::new(), quillmark_content::Content::empty());
kindless
.store_fill("memo", QuillValue::from_json(json!(null)))
.unwrap();
let doc = Document::from_main_and_cards(main, vec![unknown, kindless]);
let paths: Vec<String> = validate_fills(&config, &doc)
.iter()
.filter_map(|d| d.path.clone())
.collect();
assert!(
paths.contains(&"cards[0].note".to_string()),
"unknown-kind card fill must anchor at the bare index; got {paths:?}"
);
assert!(
!paths.iter().any(|p| p.starts_with("cards.mystery")),
"unknown-kind card fill must NOT carry the kind segment; got {paths:?}"
);
assert!(
paths.contains(&"cards[1].memo".to_string()),
"kindless card fill must anchor at the bare index; got {paths:?}"
);
}
#[test]
fn typed_table_row_preserves_undeclared_keys() {
let schema = field(
r#"
type: array
items:
type: object
properties:
name: { type: string }
"#,
);
let input = QuillValue::from_json(json!([{ "name": "ACME", "year": 2020 }]));
let resolved = resolve_value(Some(&input), &schema).into_json();
assert_eq!(resolved, json!([{ "name": "ACME", "year": 2020 }]));
}
fn plate_of(yaml: &str, md: &str) -> serde_json::Value {
let config = QuillConfig::from_yaml(yaml).expect("valid quill");
let doc = Document::parse(md).expect("parse").document;
config.compile_data(&doc).expect("compile")
}
#[test]
fn body_disabled_kind_omits_dollar_body() {
let plate = plate_of(
r#"
quill: { name: bd, version: 1.0.0, backend: typst, description: x }
main:
fields:
title: { type: string }
card_kinds:
stamp:
body:
enabled: false
fields:
label: { type: string }
"#,
"~~~card-yaml\n$quill: bd@1.0.0\n$kind: main\ntitle: T\n~~~\n\n\
~~~card-yaml\n$kind: stamp\nlabel: L\n~~~\n",
);
let card = &plate["$cards"][0];
assert_eq!(card["$kind"], "stamp", "$kind is document-defined, kept");
assert_eq!(card["label"], "L", "declared fields kept");
assert!(
card.get("$body").is_none(),
"a body-disabled kind carries no $body in the plate; got {card}"
);
}
#[test]
fn body_disabled_main_omits_root_dollar_body() {
let plate = plate_of(
r#"
quill: { name: bdm, version: 1.0.0, backend: typst, description: x }
main:
body:
enabled: false
fields:
title: { type: string }
"#,
"~~~card-yaml\n$quill: bdm@1.0.0\n$kind: main\ntitle: T\n~~~\n",
);
assert_eq!(plate["title"], "T");
assert!(
plate.get("$body").is_none(),
"a body-disabled main carries no root $body; got {plate}"
);
}
#[test]
fn body_enabled_keeps_dollar_body() {
let plate = plate_of(
r#"
quill: { name: be, version: 1.0.0, backend: typst, description: x }
main:
fields:
title: { type: string }
card_kinds:
note:
fields:
tag: { type: string }
"#,
"~~~card-yaml\n$quill: be@1.0.0\n$kind: main\ntitle: T\n~~~\n\n\
Main body.\n\n\
~~~card-yaml\n$kind: note\ntag: x\n~~~\nNote body.\n",
);
assert_eq!(
plate["$body"]["text"], "Main body.",
"a body-enabled main keeps its $body"
);
let card = &plate["$cards"][0];
assert_eq!(
card["$body"]["text"], "Note body.",
"a body-enabled kind keeps its $body content object"
);
}
}