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}