Skip to main content

quillmark_core/
reader.rs

1//! Schema-bound typed reader: the read twin of
2//! [`TypedWriter`](crate::TypedWriter).
3//!
4//! The read surface's verbs split by read-vs-write, but the deeper fault line is
5//! **interpret-vs-transport**. The verbatim [`payload().get`](crate::Card::payload)
6//! (the WASM binding's `Document.getStored`) is *transport*: it returns the stored
7//! value verbatim, schema-free and round-trippable, the disambiguation / debug
8//! read. Projecting a field to
9//! markdown is *interpretation*: a schema-shaped question ("this field's
10//! richtext, as markdown") that a schema-free `Document` cannot answer without
11//! guessing which fields are even richtext. [`Card::field_markdown`] carries the
12//! projection but has no schema to name the field set, so an unknown field name
13//! reads back as absent rather than as the typo it is.
14//!
15//! [`Quill::reader`](crate::Quill::reader) binds the schema (where the authority
16//! already lives, the writer's twin) so a single verb interprets by the field's
17//! declared type:
18//!
19//! ```ignore
20//! let v = quill.reader(&doc);
21//! v.get("subject")?;            // richtext → Some(Markdown(..))
22//! v.get("qty")?;                // integer  → Some(Value(3))
23//! v.get("absent")?;             // absent   → None
24//! v.get("nope");                // unknown name → Err(UnknownField)
25//! v.card(2)?.get("body")?;      // card field, kind resolves its schema
26//! ```
27//!
28//! **absence returns; mismatch raises; an unknown name is a typo.** A `richtext`
29//! field projects to markdown ([`ReadValue::Markdown`]) and a `plaintext` field
30//! to its literal text ([`ReadValue::Plaintext`]); every other declared type
31//! returns its canonical value verbatim ([`ReadValue::Value`]): the same
32//! transport `Document` reads, now reached with schema authority. A present value
33//! that does not decode under a content field raises
34//! [`EditError::FieldRichtextDecode`], the mismatch [`Card::field_markdown`] /
35//! [`Card::field_plaintext`] surfaces. A name the schema does not declare raises
36//! [`EditError::UnknownField`], exactly as [`TypedWriter::set`](crate::TypedWriter::set)
37//! rejects it on the write side.
38//!
39//! [`get_content`](TypedReader::get_content) is the same read at the other end of
40//! the codec: the corpus rather than the projection. A document that came
41//! through the bound door ([`Quill::parse`](crate::Quill::parse) /
42//! [`Quill::conform`](crate::Quill::conform)) rests at one form per codec, but
43//! one the transport door left rests as authored, so the verbatim payload read
44//! still answers "corpus or string?" with "depends where this document came
45//! from" and this one does not. Decoding needs the
46//! schema, not the payload: a `richtext` string is markdown and a `plaintext`
47//! string is literal text, so the same bytes decode two ways and only the
48//! declared type says which. That is why the corpus read binds the quill and
49//! `Document` carries none.
50//!
51//! The body read stays quill-free: a body's type is a format fact, not a schema
52//! fact, so [`get_body`](TypedReader::get_body) mirrors
53//! [`Card::body_markdown`](crate::Card::body_markdown) rather than consulting the
54//! schema.
55//!
56//! Like [`TypedWriter`](crate::TypedWriter), a bound reader holds `&Document`
57//! and `&QuillConfig`, so
58//! it cannot cross a binding boundary that carries no lifetimes (wasm-bindgen /
59//! pyo3); those surfaces construct one per call from the quill handle.
60
61use indexmap::IndexMap;
62use quillmark_content::Content;
63
64use crate::document::{Card, Document, EditError, RichtextDecodeError};
65use crate::quill::{CardSchema, FieldSchema, FieldType, QuillConfig};
66use crate::value::QuillValue;
67
68/// The interpreted value at a field address: the output of [`TypedReader::get`].
69/// A content field decodes to its codec's projection (`richtext` to markdown,
70/// `plaintext` to literal text); every other declared type carries its canonical
71/// value verbatim (the transport read, reached through the schema). Absence is
72/// the `None` of the enclosing `Option`, not a variant here.
73#[derive(Debug, Clone, PartialEq)]
74#[non_exhaustive]
75pub enum ReadValue {
76    /// A `richtext` field projected to markdown (`export ∘ decode`): the lossy,
77    /// on-demand view (content-only marks do not survive markdown).
78    Markdown(String),
79    /// A `plaintext` field projected through its literal codec (`to_plaintext ∘
80    /// decode`): verbatim text, marks never interpreted (`*hi*` is four
81    /// characters, not emphasis).
82    Plaintext(String),
83    /// A non-content field's canonical value, verbatim: the schema-free
84    /// transport read a `Document` returns, delivered here with schema authority.
85    Value(QuillValue),
86}
87
88/// A [`Document`] bound to its [`QuillConfig`] for typed reads. Construct with
89/// [`Quill::reader`](crate::Quill::reader). Reads target the main card; use
90/// [`card`](Self::card) for a composable card. The read twin of
91/// [`TypedWriter`](crate::TypedWriter).
92pub struct TypedReader<'a> {
93    config: &'a QuillConfig,
94    doc: &'a Document,
95}
96
97impl<'a> TypedReader<'a> {
98    /// Bind `doc` to `config`. Prefer [`Quill::reader`](crate::Quill::reader).
99    pub fn new(config: &'a QuillConfig, doc: &'a Document) -> Self {
100        Self { config, doc }
101    }
102
103    /// Read a main-card field, interpreted by its declared type: `richtext` to
104    /// markdown ([`ReadValue::Markdown`]), `plaintext` to literal text
105    /// ([`ReadValue::Plaintext`]), every other type verbatim
106    /// ([`ReadValue::Value`]). `Ok(None)` when the field is absent;
107    /// [`EditError::UnknownField`] for a name the schema does not declare (a typo,
108    /// as on the write side); [`EditError::FieldRichtextDecode`] when a content
109    /// field holds a value that does not decode (a scalar an opaque
110    /// [`store_field`](crate::Card::store_field) wrote).
111    pub fn get(&self, name: &str) -> Result<Option<ReadValue>, EditError> {
112        read_field(self.doc.main(), Some(&self.config.main.fields), name)
113    }
114
115    /// Read a main-card content field as its [`Content`] corpus, decoded through
116    /// the codec its declared type names: the corpus twin of [`get`](Self::get),
117    /// which returns a projection. Total over the storage form, so a field the
118    /// writer committed as a canonical corpus and one a markdown parse left as an
119    /// authored string both read back as a corpus, and which lane built the
120    /// document stops being the caller's business.
121    ///
122    /// `Ok(None)` when the field is absent;
123    /// [`EditError::UnknownField`] for a name the schema does not declare;
124    /// [`EditError::FieldNotContent`] for a declared type that is not a content
125    /// leaf (an `integer` has no corpus even when it holds a string, and an
126    /// `array<richtext>` carries content without having one corpus);
127    /// [`EditError::FieldRichtextDecode`] when the stored value decodes under
128    /// neither encoding.
129    ///
130    /// The codec is the schema's to name, which is why this read is here and not
131    /// on `Document`: a `richtext` string is markdown, a `plaintext` string is
132    /// literal text, and a quill-free read would have to guess.
133    pub fn get_content(&self, name: &str) -> Result<Option<Content>, EditError> {
134        read_content(self.doc.main(), Some(&self.config.main.fields), name)
135    }
136
137    /// The main body's markdown projection, the quill-free body read
138    /// ([`Card::body_markdown`](crate::Card::body_markdown)). A body's type is a
139    /// format fact, not a schema fact, so this consults no schema and never
140    /// raises; the body is never absent.
141    pub fn get_body(&self) -> String {
142        self.doc.main().body_markdown()
143    }
144
145    /// A schema-bound reader for the composable card at `index`. The card's
146    /// `$kind` resolves its [`CardSchema`]; an unknown kind carries no schema, so
147    /// every field name on it is undeclared and reads with
148    /// [`EditError::UnknownField`] (read such a card verbatim through
149    /// [`Card::payload`]). [`EditError::IndexOutOfRange`] when `index` is out of
150    /// range: a boundary error, not an absent field, as the card write verbs
151    /// treat it.
152    pub fn card(&self, index: usize) -> Result<CardReader<'_>, EditError> {
153        let len = self.doc.cards().len();
154        let card = self
155            .doc
156            .card(index)
157            .ok_or(EditError::IndexOutOfRange { index, len })?;
158        let schema = card.kind().and_then(|k| self.config.card_kind(k));
159        Ok(CardReader { schema, card })
160    }
161}
162
163/// A single composable card bound to its [`CardSchema`], from
164/// [`TypedReader::card`]. Same `get` / `get_body` verbs as [`TypedReader`],
165/// reading the card at its bound index.
166pub struct CardReader<'a> {
167    schema: Option<&'a CardSchema>,
168    card: &'a Card,
169}
170
171impl CardReader<'_> {
172    /// The card's `$kind`, if any.
173    pub fn kind(&self) -> Option<&str> {
174        self.card.kind()
175    }
176
177    /// Read a field on this card, interpreted by its declared type: the card
178    /// twin of [`TypedReader::get`]. Resolves the field against the card's
179    /// [`CardSchema`]; a name the schema does not declare (or any name when the
180    /// card kind is unknown) reads with [`EditError::UnknownField`].
181    pub fn get(&self, name: &str) -> Result<Option<ReadValue>, EditError> {
182        read_field(self.card, self.schema.map(|s| &s.fields), name)
183    }
184
185    /// Read a content field on this card as its [`Content`] corpus: the card twin
186    /// of [`TypedReader::get_content`], carrying the same outcomes.
187    pub fn get_content(&self, name: &str) -> Result<Option<Content>, EditError> {
188        read_content(self.card, self.schema.map(|s| &s.fields), name)
189    }
190
191    /// This card's body markdown: the card twin of [`TypedReader::get_body`],
192    /// quill-free and never raising.
193    pub fn get_body(&self) -> String {
194        self.card.body_markdown()
195    }
196}
197
198/// The shared read dispatch behind [`TypedReader::get`] and [`CardReader::get`]:
199/// resolve `name` against `fields_schema` (an unknown name, or every name when
200/// the whole schema is `None` (an unknown card kind) is
201/// [`EditError::UnknownField`]), then interpret by the field's declared type. A
202/// content field projects through its codec (`richtext` via
203/// [`Card::field_markdown`], `plaintext` via [`Card::field_plaintext`]) each
204/// carrying the projection's absent (`None`) / mismatch
205/// ([`EditError::FieldRichtextDecode`]) outcomes; every other type returns its
206/// canonical value verbatim, `None` when absent.
207fn read_field(
208    card: &Card,
209    fields_schema: Option<&IndexMap<String, FieldSchema>>,
210    name: &str,
211) -> Result<Option<ReadValue>, EditError> {
212    let schema = fields_schema
213        .and_then(|m| m.get(name))
214        .ok_or_else(|| EditError::UnknownField(name.to_string()))?;
215    match schema.r#type {
216        FieldType::RichText { .. } => project(card.field_markdown(name), name, ReadValue::Markdown),
217        FieldType::PlainText { .. } => {
218            project(card.field_plaintext(name), name, ReadValue::Plaintext)
219        }
220        _ => Ok(card
221            .payload()
222            .get(name)
223            .map(|v| ReadValue::Value(v.clone()))),
224    }
225}
226
227/// The shared corpus dispatch behind [`TypedReader::get_content`] and
228/// [`CardReader::get_content`]: resolve `name` against `fields_schema`, then
229/// decode through the codec the declared type names ([`Card::field_richtext`] for
230/// `richtext`, [`Card::field_plaintext_content`] for `plaintext`). Every other
231/// type is [`EditError::FieldNotContent`], answered from the schema before the
232/// payload is read: whether a field has a corpus is a declared-type fact, so a
233/// `string` field holding markdown-looking text is still not content. The two
234/// content leaves are the whole domain, so an `array<richtext>` lands here as
235/// well: it carries content and still has no single corpus.
236fn read_content(
237    card: &Card,
238    fields_schema: Option<&IndexMap<String, FieldSchema>>,
239    name: &str,
240) -> Result<Option<Content>, EditError> {
241    let schema = fields_schema
242        .and_then(|m| m.get(name))
243        .ok_or_else(|| EditError::UnknownField(name.to_string()))?;
244    let decoded = match schema.r#type {
245        FieldType::RichText { .. } => card.field_richtext(name),
246        FieldType::PlainText { .. } => card.field_plaintext_content(name),
247        ref other => {
248            return Err(EditError::FieldNotContent {
249                field: name.to_string(),
250                declared: other.as_str().to_string(),
251            })
252        }
253    };
254    match decoded {
255        None => Ok(None),
256        Some(Ok(content)) => Ok(Some(content)),
257        Some(Err(e)) => Err(EditError::FieldRichtextDecode {
258            field: name.to_string(),
259            message: e.into_message(),
260        }),
261    }
262}
263
264/// Lift a codec projection ([`Card::field_markdown`] / [`Card::field_plaintext`])
265/// into a [`ReadValue`]: `None` absent, `Some(Ok)` wrapped by `wrap`, `Some(Err)`
266/// the [`EditError::FieldRichtextDecode`] mismatch naming `name`.
267fn project(
268    projection: Option<Result<String, RichtextDecodeError>>,
269    name: &str,
270    wrap: fn(String) -> ReadValue,
271) -> Result<Option<ReadValue>, EditError> {
272    match projection {
273        None => Ok(None),
274        Some(Ok(text)) => Ok(Some(wrap(text))),
275        Some(Err(e)) => Err(EditError::FieldRichtextDecode {
276            field: name.to_string(),
277            message: e.into_message(),
278        }),
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use crate::document::Document;
286    use crate::version::QuillReference;
287    use std::str::FromStr;
288
289    const QUILL_YAML: &str = "\
290quill:
291  name: memo
292  backend: typst
293  version: 1.0.0
294  description: Reader test quill
295main:
296  fields:
297    subject:
298      type: richtext
299      inline: true
300    note:
301      type: plaintext
302    qty:
303      type: integer
304card_kinds:
305  note:
306    fields:
307      body:
308        type: richtext
309";
310
311    fn config() -> QuillConfig {
312        QuillConfig::from_yaml(QUILL_YAML).expect("valid quill")
313    }
314
315    fn blank_doc() -> Document {
316        Document::new(QuillReference::from_str("memo@1.0.0").unwrap())
317    }
318
319    // Build a document through the writer, then read it back through the view.
320    fn seeded_doc(config: &QuillConfig) -> Document {
321        let mut doc = blank_doc();
322        {
323            let mut w = crate::TypedWriter::new(config, &mut doc);
324            w.set("subject", "Hello **world**").unwrap();
325            w.set("qty", "3").unwrap();
326            w.add_card("note", [("body", "a *card*")], None, None).unwrap();
327        }
328        doc
329    }
330
331    #[test]
332    fn richtext_field_projects_to_markdown() {
333        let config = config();
334        let doc = seeded_doc(&config);
335        let view = TypedReader::new(&config, &doc);
336        assert_eq!(
337            view.get("subject").unwrap(),
338            Some(ReadValue::Markdown("Hello **world**".to_string()))
339        );
340    }
341
342    #[test]
343    fn plaintext_field_projects_to_literal_text() {
344        let config = config();
345        let mut doc = blank_doc();
346        {
347            let mut w = crate::TypedWriter::new(&config, &mut doc);
348            // Marks are literal under plaintext: `*hi*` is verbatim, not emphasis.
349            w.set("note", "a *literal* line").unwrap();
350        }
351        let view = TypedReader::new(&config, &doc);
352        assert_eq!(
353            view.get("note").unwrap(),
354            Some(ReadValue::Plaintext("a *literal* line".to_string()))
355        );
356    }
357
358    #[test]
359    fn scalar_field_returns_canonical_value() {
360        let config = config();
361        let doc = seeded_doc(&config);
362        let view = TypedReader::new(&config, &doc);
363        assert_eq!(
364            view.get("qty").unwrap(),
365            Some(ReadValue::Value(QuillValue::from_json(serde_json::json!(3))))
366        );
367    }
368
369    #[test]
370    fn absent_field_returns_none() {
371        let config = config();
372        let doc = blank_doc();
373        let view = TypedReader::new(&config, &doc);
374        assert_eq!(view.get("subject").unwrap(), None);
375        assert_eq!(view.get("qty").unwrap(), None);
376    }
377
378    #[test]
379    fn unknown_field_name_raises() {
380        let config = config();
381        let doc = blank_doc();
382        let view = TypedReader::new(&config, &doc);
383        assert!(matches!(
384            view.get("nope"),
385            Err(EditError::UnknownField(name)) if name == "nope"
386        ));
387    }
388
389    #[test]
390    fn richtext_field_holding_scalar_raises_mismatch() {
391        let config = config();
392        let mut doc = blank_doc();
393        // An opaque write puts a bare number under the `subject` richtext field.
394        doc.main_mut()
395            .store_field("subject", QuillValue::from_json(serde_json::json!(3)))
396            .unwrap();
397        let view = TypedReader::new(&config, &doc);
398        assert!(matches!(
399            view.get("subject"),
400            Err(EditError::FieldRichtextDecode { field, .. }) if field == "subject"
401        ));
402    }
403
404    // A `plaintext` field parsed from markdown rests as an authored string, and
405    // its codec is literal: `*literal*` is nine characters, not emphasis. The
406    // object lane is covered above; this is the string lane, which decoded
407    // through the markdown codec until it read its own declared type.
408    #[test]
409    fn parse_lane_plaintext_reads_through_the_literal_codec() {
410        let config = config();
411        let mut doc = blank_doc();
412        doc.main_mut()
413            .store_field(
414                "note",
415                QuillValue::from_json(serde_json::json!("a *literal* line")),
416            )
417            .unwrap();
418        let view = TypedReader::new(&config, &doc);
419        assert_eq!(
420            view.get("note").unwrap(),
421            Some(ReadValue::Plaintext("a *literal* line".to_string()))
422        );
423    }
424
425    // The corpus read is total over the storage form: the seeded (committed)
426    // lane and the parsed (authored-string) lane return the same corpus.
427    #[test]
428    fn content_read_spans_both_storage_forms() {
429        let config = config();
430        let committed = seeded_doc(&config);
431        let mut authored = blank_doc();
432        authored
433            .main_mut()
434            .store_field(
435                "subject",
436                QuillValue::from_json(serde_json::json!("Hello **world**")),
437            )
438            .unwrap();
439
440        let from_corpus = TypedReader::new(&config, &committed)
441            .get_content("subject")
442            .unwrap()
443            .unwrap();
444        let from_string = TypedReader::new(&config, &authored)
445            .get_content("subject")
446            .unwrap()
447            .unwrap();
448        assert_eq!(from_corpus.text, "Hello world");
449        assert_eq!(from_corpus.text, from_string.text);
450        assert_eq!(from_corpus.marks, from_string.marks);
451    }
452
453    // The codec follows the declared type, so the same stored bytes decode two
454    // ways: markdown under `richtext`, literal under `plaintext`.
455    #[test]
456    fn content_read_decodes_by_declared_type() {
457        let config = config();
458        let mut doc = blank_doc();
459        let text = serde_json::json!("a *literal* line");
460        {
461            let card = doc.main_mut();
462            card.store_field("subject", QuillValue::from_json(text.clone())).unwrap();
463            card.store_field("note", QuillValue::from_json(text)).unwrap();
464        }
465        let view = TypedReader::new(&config, &doc);
466        // `richtext`: the asterisks are emphasis, so they leave the text.
467        assert_eq!(view.get_content("subject").unwrap().unwrap().text, "a literal line");
468        // `plaintext`: the asterisks are characters.
469        assert_eq!(view.get_content("note").unwrap().unwrap().text, "a *literal* line");
470    }
471
472    #[test]
473    fn content_read_absent_unknown_and_non_content() {
474        let config = config();
475        let doc = seeded_doc(&config);
476        let view = TypedReader::new(&config, &doc);
477        assert_eq!(view.get_content("note").unwrap(), None);
478        assert!(matches!(
479            view.get_content("nope"),
480            Err(EditError::UnknownField(n)) if n == "nope"
481        ));
482        // A non-leaf declared type answers from the schema, not the payload:
483        // `qty` holds 3 and is still not a content field.
484        assert!(matches!(
485            view.get_content("qty"),
486            Err(EditError::FieldNotContent { field, declared })
487                if field == "qty" && declared == "integer"
488        ));
489    }
490
491    #[test]
492    fn content_read_undecodable_value_raises() {
493        let config = config();
494        let mut doc = blank_doc();
495        doc.main_mut()
496            .store_field("subject", QuillValue::from_json(serde_json::json!(3)))
497            .unwrap();
498        let view = TypedReader::new(&config, &doc);
499        assert!(matches!(
500            view.get_content("subject"),
501            Err(EditError::FieldRichtextDecode { field, .. }) if field == "subject"
502        ));
503    }
504
505    #[test]
506    fn card_content_reads_through_kind_schema() {
507        let config = config();
508        let doc = seeded_doc(&config);
509        let view = TypedReader::new(&config, &doc);
510        let card = view.card(0).unwrap();
511        assert_eq!(card.get_content("body").unwrap().unwrap().text, "a card");
512        assert!(matches!(
513            card.get_content("nope"),
514            Err(EditError::UnknownField(_))
515        ));
516    }
517
518    #[test]
519    fn card_field_reads_through_kind_schema() {
520        let config = config();
521        let doc = seeded_doc(&config);
522        let view = TypedReader::new(&config, &doc);
523        let card = view.card(0).unwrap();
524        assert_eq!(card.kind(), Some("note"));
525        assert_eq!(
526            card.get("body").unwrap(),
527            Some(ReadValue::Markdown("a *card*".to_string()))
528        );
529        assert!(matches!(card.get("nope"), Err(EditError::UnknownField(_))));
530    }
531
532    #[test]
533    fn card_out_of_range_raises() {
534        let config = config();
535        let doc = blank_doc();
536        let view = TypedReader::new(&config, &doc);
537        assert!(matches!(
538            view.card(9),
539            Err(EditError::IndexOutOfRange { index: 9, len: 0 })
540        ));
541    }
542
543    #[test]
544    fn body_read_is_quill_free() {
545        let config = config();
546        let mut doc = blank_doc();
547        doc.main_mut().revise_body("A **body**.").unwrap();
548        let view = TypedReader::new(&config, &doc);
549        assert_eq!(view.get_body(), "A **body**.");
550    }
551}