Skip to main content

quillmark_core/quill/
resolved.rs

1//! The resolved-value view: [`Quill::resolve`].
2//!
3//! A projection that makes field resolution observable *data* rather than an
4//! inferred behavior chain: for every declared field, the value the render
5//! projection would use and the [`FieldSource`] rung it came from. It cuts the
6//! one commitment ladder (`prose/canon/SCHEMAS.md` § "Value sources and
7//! projections") through the shared producer
8//! `resolve_card_sourced` (in `super::compose`), whose sourced ladder
9//! (`ladder_sourced`) the render plate cuts too: never a parallel precedence
10//! policy.
11//!
12//! Values only: diagnostics stay [`Quill::validate`]'s job (the editor merges
13//! `validate()` with its own producers regardless, so bucketing here would
14//! delete no consumer code), and schema guidance (`example:`, labels, groups)
15//! reads from [`Quill::schema`]. The view answers one question: what value
16//! renders, and from which rung.
17
18use indexmap::IndexMap;
19use serde::Serialize;
20
21use super::compose::resolve_card_sourced;
22use super::{CardSchema, Quill, QuillConfig};
23use crate::{Card, Document, QuillValue};
24
25/// The rung of the commitment ladder that produced a [`ResolvedField::value`].
26/// Serializes lowercase (`"authored" | "default" | "zero"`).
27#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
28#[serde(rename_all = "lowercase")]
29#[non_exhaustive]
30pub enum FieldSource {
31    /// The authored value: the document's own content.
32    Authored,
33    /// The schema `default:` (or its content form for a content field).
34    Default,
35    /// The type-empty [`zero_value`](crate::quill::zero_value) floor.
36    Zero,
37}
38
39/// One resolved row: its `name`, the value the render projection would use, and
40/// the [`FieldSource`] rung that value came from. A row carries its own name so
41/// declaration order is structural: an ordered array, not JSON object key
42/// order. The card body is a [`ResolvedMain::body`] / [`ResolvedCard::body`]
43/// sibling, never a row in `fields`, so a consumer iterating declared fields
44/// never trips over it.
45#[derive(Debug, Clone, PartialEq, Serialize)]
46#[non_exhaustive]
47pub struct ResolvedField {
48    pub name: String,
49    pub value: QuillValue,
50    pub source: FieldSource,
51}
52
53/// The main card's resolved rows in declaration order, plus its body row when
54/// the main enables a body.
55#[derive(Debug, Clone, PartialEq, Serialize)]
56#[non_exhaustive]
57pub struct ResolvedMain {
58    pub fields: Vec<ResolvedField>,
59    pub body: Option<ResolvedField>,
60}
61
62/// One composable card's resolved rows, with its authored `kind` (present even
63/// for an unknown kind, which carries its fields verbatim), its document-array
64/// `index`, and its body row when the kind enables a body.
65#[derive(Debug, Clone, PartialEq, Serialize)]
66#[non_exhaustive]
67pub struct ResolvedCard {
68    pub kind: Option<String>,
69    pub index: usize,
70    pub fields: Vec<ResolvedField>,
71    pub body: Option<ResolvedField>,
72}
73
74/// The whole resolved-value view: the main card and every composable card.
75#[derive(Debug, Clone, PartialEq, Serialize)]
76#[non_exhaustive]
77pub struct Resolved {
78    pub main: ResolvedMain,
79    pub cards: Vec<ResolvedCard>,
80}
81
82impl Quill {
83    /// The resolved-value view of `doc` against this quill's schema.
84    ///
85    /// For every declared field, the value [`compile_data`] emits into the plate:
86    /// the two cut the *same* sourced ladder (`ladder_sourced`) over equal
87    /// coerced input, so the value is the plate's by construction: tagged with
88    /// the [`FieldSource`] rung it came from. Completeness and errors stay
89    /// [`Quill::validate`]'s; this view carries no diagnostics.
90    ///
91    /// [`compile_data`]: Quill::compile_data
92    pub fn resolve(&self, doc: &Document) -> Resolved {
93        let config = self.config();
94        let (fields, body) = resolve_card_fields(&config.main, doc.main());
95        let main = ResolvedMain { fields, body };
96        let cards = doc
97            .cards()
98            .iter()
99            .enumerate()
100            .map(|(index, card)| card_states(config, card, index))
101            .collect();
102        Resolved { main, cards }
103    }
104}
105
106/// Resolve one card (main or a schema-declared kind) into its ordered
107/// [`ResolvedField`] rows and its body row (present iff the kind enables a body).
108///
109/// The value and source of every field come from the one shared resolver
110/// [`resolve_card_sourced`] (the same producer [`compile_data`] cuts for the
111/// plate) so the two projections cannot drift. This layer only re-cuts the
112/// **presentation order**: declared fields first in declaration order (the canon
113/// ordering contract, carried structurally by the row array, not the validation
114/// walker's alphabetical sort), then undeclared authored fields in authored
115/// order. The body is a sibling row, never an entry in `fields`.
116///
117/// [`compile_data`]: crate::Quill::compile_data
118fn resolve_card_fields(schema: &CardSchema, card: &Card) -> (Vec<ResolvedField>, Option<ResolvedField>) {
119    let sourced: IndexMap<String, (QuillValue, FieldSource)> = resolve_card_sourced(schema, card);
120    let mut fields = Vec::new();
121
122    // Declared rows in schema declaration order. Every declared field is present
123    // in the map (the resolver's ladder inserts each one) so the lookup holds.
124    for (name, _field_schema) in &schema.fields {
125        let (value, source) = sourced
126            .get(name)
127            .cloned()
128            .expect("resolve_card_sourced emits every declared field");
129        fields.push(ResolvedField {
130            name: name.clone(),
131            value,
132            source,
133        });
134    }
135
136    // Undeclared authored fields, appended in authored order under their NFC
137    // keys: the schema is a floor, not an allowlist, so these reach both
138    // projections too, value verbatim, source Authored.
139    for (name, (value, source)) in &sourced {
140        if !schema.fields.contains_key(name) {
141            fields.push(ResolvedField {
142                name: name.clone(),
143                value: value.clone(),
144                source: *source,
145            });
146        }
147    }
148
149    let body = schema.body_enabled().then(|| body_state(card));
150    (fields, body)
151}
152
153/// Resolve one composable card. A card whose `$kind` names a schema resolves
154/// through the ladder; an unknown-kind card (declared `$kind` with no schema, or
155/// a kindless card) carries its authored fields verbatim: no coercion, no
156/// ladder, no `$body` row.
157fn card_states(config: &QuillConfig, card: &Card, index: usize) -> ResolvedCard {
158    // The raw authored kind rides the entry even when it names no schema: the
159    // card reports what it *claimed* to be.
160    let kind = card.kind().map(String::from);
161    match card.kind().and_then(|k| config.card_kind(k)) {
162        Some(schema) => {
163            let (fields, body) = resolve_card_fields(schema, card);
164            ResolvedCard {
165                kind,
166                index,
167                fields,
168                body,
169            }
170        }
171        None => {
172            // An unknown-kind card carries its authored fields verbatim: no
173            // schema, no ladder, and `to_index_map` drops `$` keys, so no body.
174            let fields = card
175                .payload()
176                .to_index_map()
177                .into_iter()
178                .map(|(name, value)| ResolvedField {
179                    name,
180                    value,
181                    source: FieldSource::Authored,
182                })
183                .collect();
184            ResolvedCard {
185                kind,
186                index,
187                fields,
188                body: None,
189            }
190        }
191    }
192}
193
194/// The body row (`name: "body"`). The value is byte-identical to the plate's
195/// `$body` (canonical Content-JSON of the card body). A body has no `default:`
196/// rung, so its source is only ever [`Authored`](FieldSource::Authored)
197/// (non-blank) or [`Zero`](FieldSource::Zero) (blank).
198fn body_state(card: &Card) -> ResolvedField {
199    let value = QuillValue::from_json(quillmark_content::serial::to_canonical_value(card.body()));
200    let source = if card.body().is_blank() {
201        FieldSource::Zero
202    } else {
203        FieldSource::Authored
204    };
205    ResolvedField {
206        name: "body".to_string(),
207        value,
208        source,
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::quill::quill_from_yaml;
216    use crate::{Card, Document, Payload};
217
218    fn parse(md: &str) -> Document {
219        Document::parse(md).expect("document should parse").document
220    }
221
222    /// Look up a resolved row by name: rows are an ordered array.
223    fn row<'a>(fields: &'a [ResolvedField], name: &str) -> &'a ResolvedField {
224        fields
225            .iter()
226            .find(|f| f.name == name)
227            .unwrap_or_else(|| panic!("no row `{name}`"))
228    }
229
230    fn has_row(fields: &[ResolvedField], name: &str) -> bool {
231        fields.iter().any(|f| f.name == name)
232    }
233
234    const QUILL: &str = r#"
235quill:
236  name: fs_test
237  version: "1.0"
238  backend: typst
239  description: Field-state tests
240main:
241  body:
242    example: "Example body prose."
243  fields:
244    title:
245      type: string
246    status:
247      type: string
248      default: draft
249    notes:
250      type: string
251    intro:
252      type: richtext
253      default: "**hi**"
254    recipients:
255      type: array
256      items:
257        type: object
258        properties:
259          name: { type: string }
260card_kinds:
261  note:
262    fields:
263      author:
264        type: string
265        example: A. Author
266      tag:
267        type: string
268"#;
269
270    // ── Sources ──────────────────────────────────────────────────────────────
271
272    #[test]
273    fn scalar_sources_authored_default_zero() {
274        let quill = quill_from_yaml(QUILL);
275        // title authored; status absent (has a default); notes absent (no default).
276        let doc = parse("~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: Hello\n~~~\n");
277        let states = quill.resolve(&doc);
278        let f = &states.main.fields;
279
280        assert_eq!(row(f, "title").source, FieldSource::Authored);
281        assert_eq!(row(f, "title").value.as_json(), &serde_json::json!("Hello"));
282
283        assert_eq!(row(f, "status").source, FieldSource::Default);
284        assert_eq!(row(f, "status").value.as_json(), &serde_json::json!("draft"));
285
286        assert_eq!(row(f, "notes").source, FieldSource::Zero);
287        assert_eq!(row(f, "notes").value.as_json(), &serde_json::json!(""));
288    }
289
290    #[test]
291    fn richtext_default_reports_default_and_matches_plate() {
292        let quill = quill_from_yaml(QUILL);
293        // intro absent → its richtext `default:` (committed as content).
294        let doc = parse("~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: T\n~~~\n");
295        let states = quill.resolve(&doc);
296        let intro = row(&states.main.fields, "intro");
297
298        assert_eq!(intro.source, FieldSource::Default);
299        // The value is the content form of the default, byte-equal to the plate.
300        let plate = quill.compile_data(&doc).expect("compile");
301        assert_eq!(intro.value.as_json(), &plate["intro"]);
302        // And it is content, not the raw markdown string.
303        assert!(intro.value.as_json().is_object());
304    }
305
306    #[test]
307    fn present_null_is_absent_takes_default_rung() {
308        let quill = quill_from_yaml(QUILL);
309        // `status:` is a present-null → treated as absent → default rung.
310        let doc = parse("~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: T\nstatus:\n~~~\n");
311        let states = quill.resolve(&doc);
312        let status = row(&states.main.fields, "status");
313        assert_eq!(status.source, FieldSource::Default);
314        assert_eq!(status.value.as_json(), &serde_json::json!("draft"));
315    }
316
317    // ── Byte-for-byte with the render projection ─────────────────────────────
318    // Both projections cut the one shared ladder (`ladder_sourced`), but over
319    // separately-conformed input: the render gate's fallible conform vs the
320    // view's keep-raw `conform_card_render`. This pins that those two conform
321    // paths agree on a gated document, plus the plate-build wiring (order, meta,
322    // body) against the row view.
323
324    #[test]
325    fn every_row_is_byte_for_byte_with_compile_data() {
326        let quill = quill_from_yaml(QUILL);
327        let md = "~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\n\
328                  title: Hello\nintro: \"**bold**\"\nrecipients:\n  - name: Alice\n~~~\n\n\
329                  Body prose here.\n\n\
330                  ~~~card-yaml\n$kind: note\nauthor: Zed\n~~~\nNote body.\n";
331        let doc = parse(md);
332        let states = quill.resolve(&doc);
333        let plate = quill.compile_data(&doc).expect("compile");
334
335        // Every declared main row equals its plate field; the body equals plate `$body`.
336        for name in ["title", "status", "notes", "intro", "recipients"] {
337            assert_eq!(
338                row(&states.main.fields, name).value.as_json(),
339                &plate[name],
340                "main row `{name}` must be byte-for-byte with the plate"
341            );
342        }
343        assert_eq!(
344            states.main.body.as_ref().unwrap().value.as_json(),
345            &plate["$body"]
346        );
347
348        // The card's declared rows equal its plate card; the body equals plate `$body`.
349        let plate_card = &plate["$cards"][0];
350        let card = &states.cards[0];
351        for name in ["author", "tag"] {
352            assert_eq!(
353                row(&card.fields, name).value.as_json(),
354                &plate_card[name],
355                "card row `{name}` must be byte-for-byte with the plate"
356            );
357        }
358        assert_eq!(
359            card.body.as_ref().unwrap().value.as_json(),
360            &plate_card["$body"]
361        );
362    }
363
364    #[test]
365    fn non_nfc_key_on_a_constructed_payload_rows_under_its_nfc_spelling() {
366        // Every validated ingress (parse, the mutators) restricts field names
367        // to ASCII, so a non-NFC key only enters through direct construction
368        // (`Payload::from_index_map`). Render NFC-normalizes it between
369        // coercion and the ladder; the view mirrors that, rowing it under the
370        // NFC key the plate carries: not the raw decomposed one.
371        let quill = quill_from_yaml(QUILL);
372        let mut map = IndexMap::new();
373        // `e` + U+0301 combining acute: NFC-composes to U+00E9.
374        map.insert(
375            "cafe\u{301}".to_string(),
376            QuillValue::from_json(serde_json::json!("hot")),
377        );
378        let mut payload = Payload::from_index_map(map);
379        payload.set_quill("fs_test@1.0".parse().unwrap());
380        payload.set_kind("main");
381        let main = Card::from_parts(payload, quillmark_content::Content::empty());
382        let doc = Document::from_main_and_cards(main, Vec::new());
383        let states = quill.resolve(&doc);
384
385        assert!(!has_row(&states.main.fields, "cafe\u{301}"));
386        let r = row(&states.main.fields, "caf\u{e9}");
387        assert_eq!(r.source, FieldSource::Authored);
388        assert_eq!(r.value.as_json(), &serde_json::json!("hot"));
389    }
390
391    // ── The body row ─────────────────────────────────────────────────────────
392
393    #[test]
394    fn body_row_authored_vs_blank_source() {
395        let quill = quill_from_yaml(QUILL);
396
397        let authored =
398            parse("~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: T\n~~~\n\nHello body.\n");
399        assert_eq!(
400            quill.resolve(&authored).main.body.unwrap().source,
401            FieldSource::Authored
402        );
403
404        let blank = parse("~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: T\n~~~\n");
405        let states = quill.resolve(&blank);
406        let body = states.main.body.as_ref().unwrap();
407        assert_eq!(body.source, FieldSource::Zero);
408        assert!(body.value.as_json().is_object(), "blank body is empty content");
409    }
410
411    const BODY_DISABLED_QUILL: &str = r#"
412quill:
413  name: bd_test
414  version: "1.0"
415  backend: typst
416  description: Body-disabled test
417main:
418  fields:
419    title:
420      type: string
421card_kinds:
422  stamp:
423    body:
424      enabled: false
425    fields:
426      label:
427        type: string
428"#;
429
430    #[test]
431    fn body_disabled_kind_omits_body_row() {
432        let quill = quill_from_yaml(BODY_DISABLED_QUILL);
433        let doc = parse(
434            "~~~card-yaml\n$quill: bd_test@1.0\n$kind: main\ntitle: T\n~~~\n\n\
435             ~~~card-yaml\n$kind: stamp\nlabel: L\n~~~\nStray prose.\n",
436        );
437        let states = quill.resolve(&doc);
438        let card = &states.cards[0];
439        assert!(card.body.is_none(), "a body-disabled kind has no body row");
440        assert!(has_row(&card.fields, "label"), "declared rows still present");
441    }
442
443    // ── Unknown-kind card ────────────────────────────────────────────────────
444
445    #[test]
446    fn unknown_kind_card_shape() {
447        let quill = quill_from_yaml(QUILL);
448        let doc = parse(
449            "~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: T\n~~~\n\n\
450             ~~~card-yaml\n$kind: mystery\nfoo: bar\n~~~\nUnread body.\n",
451        );
452        let states = quill.resolve(&doc);
453        let card = &states.cards[0];
454
455        assert_eq!(card.kind.as_deref(), Some("mystery"));
456        assert_eq!(card.index, 0);
457        // Authored fields only: no body row, no ladder.
458        assert_eq!(row(&card.fields, "foo").source, FieldSource::Authored);
459        assert_eq!(row(&card.fields, "foo").value.as_json(), &serde_json::json!("bar"));
460        assert!(card.body.is_none());
461    }
462
463    // ── Undeclared authored field ────────────────────────────────────────────
464
465    #[test]
466    fn undeclared_authored_field_row_is_authored() {
467        let quill = quill_from_yaml(QUILL);
468        let doc = parse(
469            "~~~card-yaml\n$quill: fs_test@1.0\n$kind: main\ntitle: T\nextra: whatever\n~~~\n",
470        );
471        let states = quill.resolve(&doc);
472        let r = row(&states.main.fields, "extra");
473        assert_eq!(r.source, FieldSource::Authored);
474        assert_eq!(r.value.as_json(), &serde_json::json!("whatever"));
475    }
476
477    // ── Wire shape ───────────────────────────────────────────────────────────
478
479    #[test]
480    fn field_state_is_name_value_and_source_only() {
481        let state = ResolvedField {
482            name: "x".to_string(),
483            value: QuillValue::from_json(serde_json::json!("v")),
484            source: FieldSource::Authored,
485        };
486        let json = serde_json::to_value(&state).unwrap();
487        let obj = json.as_object().unwrap();
488        assert_eq!(obj.len(), 3, "only name + value + source on the wire: {json}");
489        assert!(
490            obj.contains_key("name") && obj.contains_key("value") && obj.contains_key("source")
491        );
492    }
493}