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}