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