Skip to main content

quarb_text/
lib.rs

1//! The text level: a shared, source-independent semantics for
2//! written documents — sections, paragraphs, quotes, lists, and
3//! verbatim blocks — produced by format crates and served by this
4//! crate's single adapter.
5//!
6//! The block model follows the atrep markup language (litogramma's
7//! koine core): every block is `(kind, taxis?, lemma?, body,
8//! hypograph?)` — the lemma is the head or title, the hypograph the
9//! footer or attribution, and a paragraph is the degenerate
10//! lemma-less, hypograph-less block. Producers (`quarb-text-html`,
11//! `quarb-text-markdown`, the built-in plain-text reader) lower
12//! their format into the [`Block`] event stream; this crate derives
13//! the section tree and implements the adapter once, so
14//! `//section[::lemma ...]`, `//paragraph`, and `//blockquote` read
15//! identically over any text substrate — including an atrep
16//! document mounted by `quarb-atrep`.
17//!
18//! - Node names are the structural kinds: `section`, `paragraph`,
19//!   `blockquote`, `unordered-list`, `ordered-list`,
20//!   `unordered-item`, `ordered-item`, `verbatim`.
21//! - `::lemma`, `::hypograph`, and `::taxis` are properties; bare
22//!   `::` (and `::text`) is the flattened prose of the subtree,
23//!   lemma first, hypograph last.
24//! - `::::level` on a section is the source heading level;
25//!   `::::lang` on a verbatim block is its declared language.
26//! - Sections are derived from the flat heading stream by the
27//!   outline rule: a heading closes every open section at its
28//!   level or deeper, then opens a section under the nearest
29//!   shallower one. Content before the first heading belongs to
30//!   the document root. A heading inside an open container
31//!   (blockquote, list) is decorative, not sectioning: it lowers
32//!   to a paragraph of its text.
33//! - Every kind admits `::lemma`, `::taxis`, and `::hypograph` —
34//!   the atrep model, where these are universal affordances of a
35//!   block rather than privileges of particular kinds.
36//! - Tables denormalize into nested lists: an `ordered-list`
37//!   carrying the `<table>` trait (`::lemma` = the caption), one
38//!   `ordered-item` per row (`::taxis` = row number, `<row>`
39//!   trait), one `unordered-item` per cell (`<cell>` trait) whose
40//!   `::lemma` is the column name — from the header row in grids,
41//!   from the row's `th` label otherwise; headerless cells carry
42//!   no lemma. A lemma'd item flattens as `lemma: prose`, so a
43//!   row exists, the bare cell text otherwise. Empty cells are
44//!   skipped.
45
46use quarb::{AstAdapter, NodeId, Value};
47
48pub mod render;
49pub use render::{Render, render_node, render_nodes};
50
51/// A block-level event in the text-level vocabulary — what a format
52/// producer emits. Headings arrive flat; the section tree is
53/// derived here, once, for every producer.
54#[derive(Debug, Clone, PartialEq)]
55pub enum Block {
56    /// A flat heading: `level` is the source level (`h2` → 2, a
57    /// LaTeX `\section` → its depth), `lemma` its text.
58    Heading { level: u8, lemma: String },
59    /// A plain paragraph — the implicit, lemma-less block.
60    Paragraph { text: String },
61    /// Inline content belonging directly to the open container (a
62    /// list item's own text, a bare-text blockquote). With no open
63    /// container it is read as a paragraph.
64    Text { text: String },
65    /// Open a nesting container. Items take their `unordered-` /
66    /// `ordered-` flavor (and taxis) from the enclosing list.
67    Open { kind: Container, lemma: Option<String> },
68    /// Close the innermost open container, optionally with its
69    /// hypograph (footer or attribution).
70    Close { hypograph: Option<String> },
71    /// A verbatim block — code or other preformatted lines, kept
72    /// as authored.
73    Verbatim { lang: Option<String>, text: String },
74    /// A table, denormalized here into nested lists (rows =
75    /// ordered items with the `<row>` trait, cells = unordered
76    /// items with the `<cell>` trait and the column name as
77    /// `::lemma`). Header *detection* is the producer's job; the
78    /// lowering rule lives here. A cell's own `label` (a row's
79    /// `th`) wins over the positional `headers` entry.
80    Table {
81        lemma: Option<String>,
82        headers: Option<Vec<String>>,
83        rows: Vec<Vec<Cell>>,
84    },
85}
86
87/// One table cell as a producer hands it over: the text, plus the
88/// label a row-shaped dialect attaches directly (an infobox row's
89/// `th`). Grid dialects leave `label` empty and let the lowering
90/// zip the header row on by position.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct Cell {
93    pub label: Option<String>,
94    pub text: String,
95}
96
97impl From<&str> for Cell {
98    fn from(text: &str) -> Self {
99        Cell {
100            label: None,
101            text: text.to_string(),
102        }
103    }
104}
105
106impl From<String> for Cell {
107    fn from(text: String) -> Self {
108        Cell { label: None, text }
109    }
110}
111
112/// The nesting containers a producer opens and closes explicitly.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum Container {
115    Blockquote,
116    UnorderedList,
117    /// `start` is the first item's ordinal (Markdown's `3.` lists).
118    OrderedList { start: i64 },
119    /// A list item; flavor and taxis come from the enclosing list.
120    Item,
121}
122
123/// The structural kind of a node — also its name.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125enum Kind {
126    Document,
127    Section,
128    Paragraph,
129    Blockquote,
130    UnorderedList,
131    OrderedList,
132    UnorderedItem,
133    OrderedItem,
134    Verbatim,
135}
136
137impl Kind {
138    fn name(self) -> Option<&'static str> {
139        Some(match self {
140            Kind::Document => return None,
141            Kind::Section => "section",
142            Kind::Paragraph => "paragraph",
143            Kind::Blockquote => "blockquote",
144            Kind::UnorderedList => "unordered-list",
145            Kind::OrderedList => "ordered-list",
146            Kind::UnorderedItem => "unordered-item",
147            Kind::OrderedItem => "ordered-item",
148            Kind::Verbatim => "verbatim",
149        })
150    }
151}
152
153struct Node {
154    kind: Kind,
155    lemma: Option<String>,
156    hypograph: Option<String>,
157    taxis: Option<i64>,
158    /// Source heading level, on sections.
159    level: Option<u8>,
160    /// Declared language, on verbatim blocks.
161    lang: Option<String>,
162    /// First ordinal of an ordered list (not exposed; feeds the
163    /// items' taxis).
164    start: i64,
165    /// The node's own (direct) text, before subtree flattening.
166    text: String,
167    /// The flattened prose of the subtree — the `::` projection.
168    prose: String,
169    /// The node heads a denormalized table (`<table>` trait).
170    table: bool,
171    /// The node is a denormalized table row (`<row>` trait).
172    row: bool,
173    /// The node is a denormalized table cell (`<cell>` trait).
174    cell: bool,
175    parent: Option<NodeId>,
176    children: Vec<NodeId>,
177}
178
179impl Node {
180    fn new(kind: Kind, parent: Option<NodeId>) -> Self {
181        Node {
182            kind,
183            lemma: None,
184            hypograph: None,
185            taxis: None,
186            level: None,
187            lang: None,
188            start: 1,
189            text: String::new(),
190            prose: String::new(),
191            table: false,
192            row: false,
193            cell: false,
194            parent,
195            children: Vec::new(),
196        }
197    }
198}
199
200/// Collapse whitespace runs to single spaces and trim — the prose
201/// normalization producers apply to inline content. Verbatim text
202/// is the exception: it is kept as authored.
203pub fn normalize_ws(s: &str) -> String {
204    s.split_whitespace().collect::<Vec<_>>().join(" ")
205}
206
207/// A Quarb adapter over a text-level document.
208pub struct TextModel {
209    nodes: Vec<Node>,
210    root: NodeId,
211}
212
213impl TextModel {
214    /// Assemble the document tree from a producer's event stream.
215    ///
216    /// Iterative throughout (the stream is flat; prose flattening
217    /// runs over indices), so pathological nesting cannot overflow
218    /// the call stack. Lenient on malformed streams: a stray
219    /// `Close` is ignored, unclosed containers close at the end.
220    pub fn build(blocks: Vec<Block>) -> Self {
221        let mut nodes = vec![Node::new(Kind::Document, None)];
222        let root = NodeId(0);
223        // Innermost-last stack of open *sections* (outline-derived).
224        let mut sections: Vec<NodeId> = Vec::new();
225        // Innermost-last stack of open explicit containers.
226        let mut containers: Vec<NodeId> = Vec::new();
227
228        for block in blocks {
229            match block {
230                Block::Heading { level, lemma } => {
231                    let lemma = normalize_ws(&lemma);
232                    if !containers.is_empty() {
233                        // Decorative heading inside a container:
234                        // not sectioning — lower to a paragraph.
235                        if !lemma.is_empty() {
236                            let parent = *containers.last().unwrap();
237                            let id = push(&mut nodes, Kind::Paragraph, parent);
238                            nodes[id.0 as usize].text = lemma;
239                        }
240                        continue;
241                    }
242                    while let Some(&open) = sections.last() {
243                        if nodes[open.0 as usize].level >= Some(level) {
244                            sections.pop();
245                        } else {
246                            break;
247                        }
248                    }
249                    let parent = sections.last().copied().unwrap_or(root);
250                    let id = push(&mut nodes, Kind::Section, parent);
251                    let n = &mut nodes[id.0 as usize];
252                    n.lemma = Some(lemma);
253                    n.level = Some(level);
254                    sections.push(id);
255                }
256                Block::Paragraph { text } => {
257                    let text = normalize_ws(&text);
258                    if text.is_empty() {
259                        continue;
260                    }
261                    let parent = cursor(&sections, &containers, root);
262                    let id = push(&mut nodes, Kind::Paragraph, parent);
263                    nodes[id.0 as usize].text = text;
264                }
265                Block::Text { text } => {
266                    let text = normalize_ws(&text);
267                    if text.is_empty() {
268                        continue;
269                    }
270                    match containers.last() {
271                        Some(&open) => {
272                            let own = &mut nodes[open.0 as usize].text;
273                            if !own.is_empty() {
274                                own.push(' ');
275                            }
276                            own.push_str(&text);
277                        }
278                        None => {
279                            let parent = sections.last().copied().unwrap_or(root);
280                            let id = push(&mut nodes, Kind::Paragraph, parent);
281                            nodes[id.0 as usize].text = text;
282                        }
283                    }
284                }
285                Block::Open { kind, lemma } => {
286                    let parent = cursor(&sections, &containers, root);
287                    let (nkind, start) = match kind {
288                        Container::Blockquote => (Kind::Blockquote, None),
289                        Container::UnorderedList => (Kind::UnorderedList, None),
290                        Container::OrderedList { start } => (Kind::OrderedList, Some(start)),
291                        Container::Item => (
292                            match nodes[parent.0 as usize].kind {
293                                Kind::OrderedList => Kind::OrderedItem,
294                                _ => Kind::UnorderedItem,
295                            },
296                            None,
297                        ),
298                    };
299                    let id = push(&mut nodes, nkind, parent);
300                    nodes[id.0 as usize].lemma =
301                        lemma.map(|l| normalize_ws(&l)).filter(|l| !l.is_empty());
302                    if let Some(start) = start {
303                        nodes[id.0 as usize].start = start;
304                    }
305                    if nkind == Kind::OrderedItem {
306                        // `push` already appended this item, so the
307                        // count includes it.
308                        let nth = nodes[parent.0 as usize]
309                            .children
310                            .iter()
311                            .filter(|&&c| nodes[c.0 as usize].kind == Kind::OrderedItem)
312                            .count() as i64;
313                        let start = nodes[parent.0 as usize].start;
314                        nodes[id.0 as usize].taxis = Some(start + nth - 1);
315                    }
316                    containers.push(id);
317                }
318                Block::Close { hypograph } => {
319                    if let Some(open) = containers.pop() {
320                        nodes[open.0 as usize].hypograph =
321                            hypograph.map(|h| normalize_ws(&h)).filter(|h| !h.is_empty());
322                    }
323                }
324                Block::Verbatim { lang, text } => {
325                    let parent = cursor(&sections, &containers, root);
326                    let id = push(&mut nodes, Kind::Verbatim, parent);
327                    let n = &mut nodes[id.0 as usize];
328                    n.lang = lang.filter(|l| !l.is_empty());
329                    n.text = text;
330                }
331                Block::Table {
332                    lemma,
333                    headers,
334                    rows,
335                } => {
336                    let parent = cursor(&sections, &containers, root);
337                    lower_table(&mut nodes, parent, lemma, headers, rows);
338                }
339            }
340        }
341
342        flatten_prose(&mut nodes);
343        TextModel { nodes, root }
344    }
345
346    /// Read plain text: blank-line-separated paragraphs, each
347    /// collapsed to one line — the atramento paragraph rule. No
348    /// headings, no markup.
349    pub fn parse_plain(text: &str) -> Self {
350        let mut blocks = Vec::new();
351        let mut para: Vec<&str> = Vec::new();
352        for line in text.lines() {
353            if line.trim().is_empty() {
354                if !para.is_empty() {
355                    blocks.push(Block::Paragraph {
356                        text: para.join(" "),
357                    });
358                    para.clear();
359                }
360            } else {
361                para.push(line);
362            }
363        }
364        if !para.is_empty() {
365            blocks.push(Block::Paragraph {
366                text: para.join(" "),
367            });
368        }
369        Self::build(blocks)
370    }
371
372    /// A locator path to `node`, like `/section[2]/paragraph[3]`,
373    /// for rendering. A `[n]` index is added only to disambiguate
374    /// same-name siblings.
375    pub fn locator(&self, node: NodeId) -> String {
376        let mut segments = Vec::new();
377        let mut cur = Some(node);
378        while let Some(id) = cur {
379            let n = &self.nodes[id.0 as usize];
380            if let Some(name) = n.kind.name() {
381                segments.push(self.segment(id, name));
382            }
383            cur = n.parent;
384        }
385        segments.reverse();
386        format!("/{}", segments.join("/"))
387    }
388
389    fn segment(&self, node: NodeId, name: &str) -> String {
390        let Some(parent) = self.nodes[node.0 as usize].parent else {
391            return name.to_string();
392        };
393        let siblings = &self.nodes[parent.0 as usize].children;
394        let same_name: Vec<NodeId> = siblings
395            .iter()
396            .copied()
397            .filter(|&s| self.nodes[s.0 as usize].kind == self.nodes[node.0 as usize].kind)
398            .collect();
399        if same_name.len() > 1 {
400            let n = same_name.iter().position(|&s| s == node).unwrap() + 1;
401            format!("{name}[{n}]")
402        } else {
403            name.to_string()
404        }
405    }
406}
407
408/// Where the next block lands: the innermost open container, else
409/// the innermost open section, else the root.
410fn cursor(sections: &[NodeId], containers: &[NodeId], root: NodeId) -> NodeId {
411    containers
412        .last()
413        .or(sections.last())
414        .copied()
415        .unwrap_or(root)
416}
417
418fn push(nodes: &mut Vec<Node>, kind: Kind, parent: NodeId) -> NodeId {
419    let id = NodeId(nodes.len() as u64);
420    nodes.push(Node::new(kind, Some(parent)));
421    nodes[parent.0 as usize].children.push(id);
422    id
423}
424
425/// Denormalize a table into nested lists (see the module doc).
426/// The column name lands as the cell's `::lemma` — a cell's own
427/// label (a row's `th`) wins over the positional header entry —
428/// and never as folded text: addressing is property projection,
429/// the flattening rule alone spells `lemma: value`.
430fn lower_table(
431    nodes: &mut Vec<Node>,
432    parent: NodeId,
433    lemma: Option<String>,
434    headers: Option<Vec<String>>,
435    rows: Vec<Vec<Cell>>,
436) {
437    let list = push(nodes, Kind::OrderedList, parent);
438    {
439        let n = &mut nodes[list.0 as usize];
440        n.table = true;
441        n.lemma = lemma.map(|l| normalize_ws(&l)).filter(|l| !l.is_empty());
442    }
443    for (i, row) in rows.into_iter().enumerate() {
444        let item = push(nodes, Kind::OrderedItem, list);
445        nodes[item.0 as usize].taxis = Some(i as i64 + 1);
446        nodes[item.0 as usize].row = true;
447        let cells = push(nodes, Kind::UnorderedList, item);
448        for (j, cell) in row.into_iter().enumerate() {
449            let value = normalize_ws(&cell.text);
450            if value.is_empty() {
451                continue;
452            }
453            let label = cell
454                .label
455                .as_deref()
456                .or_else(|| headers.as_ref().and_then(|h| h.get(j)).map(|h| h.as_str()))
457                .map(normalize_ws)
458                .filter(|h| !h.is_empty());
459            let cell_item = push(nodes, Kind::UnorderedItem, cells);
460            let n = &mut nodes[cell_item.0 as usize];
461            n.cell = true;
462            n.lemma = label;
463            n.text = value;
464        }
465    }
466}
467
468/// Compute every node's flattened prose: lemma first, then the
469/// node's own text, then its children's prose in order, then the
470/// hypograph, block-joined with newlines. On a list *item*, the
471/// lemma joins the rest with `: ` instead — an item's lemma names
472/// its content inline (a table cell reads `Outcome: Emus won`, a
473/// definition reads `term: description`), where a section's lemma
474/// opens its block. Children always carry larger indices than
475/// their parents (nodes are interned in document order), so one
476/// reverse index scan suffices — no recursion.
477fn flatten_prose(nodes: &mut [Node]) {
478    for i in (0..nodes.len()).rev() {
479        let inline_lemma = matches!(
480            nodes[i].kind,
481            Kind::UnorderedItem | Kind::OrderedItem
482        );
483        let mut lemma_part: Option<String> = None;
484        let mut parts: Vec<String> = Vec::new();
485        if let Some(lemma) = &nodes[i].lemma
486            && !lemma.is_empty()
487        {
488            if inline_lemma {
489                lemma_part = Some(lemma.clone());
490            } else {
491                parts.push(lemma.clone());
492            }
493        }
494        if !nodes[i].text.is_empty() {
495            parts.push(nodes[i].text.clone());
496        }
497        for &child in nodes[i].children.clone().iter() {
498            let prose = &nodes[child.0 as usize].prose;
499            if !prose.is_empty() {
500                parts.push(prose.clone());
501            }
502        }
503        if let Some(hypograph) = &nodes[i].hypograph
504            && !hypograph.is_empty()
505        {
506            parts.push(hypograph.clone());
507        }
508        let mut prose = parts.join("\n");
509        if let Some(lemma) = lemma_part {
510            prose = if prose.is_empty() {
511                lemma
512            } else {
513                format!("{lemma}: {prose}")
514            };
515        }
516        nodes[i].prose = prose;
517    }
518}
519
520impl TextModel {
521    /// The body prose: everything between the lemma and the
522    /// hypograph — the simmere anatomy's third member, derived
523    /// from the flattened prose by construction (the lemma joins
524    /// a block on its own line, an item with `: `; the hypograph
525    /// closes on its own line).
526    fn grammata(&self, node: NodeId) -> String {
527        let n = &self.nodes[node.0 as usize];
528        let mut s = n.prose.as_str();
529        if let Some(lemma) = &n.lemma
530            && !lemma.is_empty()
531            && let Some(rest) = s.strip_prefix(lemma.as_str())
532        {
533            s = rest
534                .strip_prefix(": ")
535                .or_else(|| rest.strip_prefix('\n'))
536                .unwrap_or(rest);
537        }
538        if let Some(h) = &n.hypograph
539            && !h.is_empty()
540            && let Some(rest) = s.strip_suffix(h.as_str())
541        {
542            s = rest.strip_suffix('\n').unwrap_or(rest);
543        }
544        s.trim_end().to_string()
545    }
546}
547
548impl AstAdapter for TextModel {
549    fn root(&self) -> NodeId {
550        self.root
551    }
552
553    fn children(&self, node: NodeId) -> Vec<NodeId> {
554        self.nodes[node.0 as usize].children.clone()
555    }
556
557    fn name(&self, node: NodeId) -> Option<String> {
558        self.nodes[node.0 as usize].kind.name().map(str::to_string)
559    }
560
561    fn parent(&self, node: NodeId) -> Option<NodeId> {
562        self.nodes[node.0 as usize].parent
563    }
564
565    /// The `<block>` family on every block node, plus `<table>` on
566    /// a list that denormalizes a table. Kinds are node names, not
567    /// traits.
568    fn traits(&self, node: NodeId) -> Vec<String> {
569        let n = &self.nodes[node.0 as usize];
570        let mut out = Vec::new();
571        if n.kind != Kind::Document {
572            out.push("block".to_string());
573        }
574        if n.table {
575            out.push("table".to_string());
576        }
577        if n.row {
578            out.push("row".to_string());
579        }
580        if n.cell {
581            out.push("cell".to_string());
582        }
583        out
584    }
585
586    /// `::lemma` (title), `::hypograph` (footer or attribution),
587    /// `::taxis` (ordinal), `::text` (the flattened prose, same as
588    /// the bare projection).
589    /// The Greek anatomy — `::lemma`, `::grammata`, `::hypograph`,
590    /// `::taxis` — plus the friendly aliases (`::title`, `::body`,
591    /// `::attribution`, `::ord`), answered here because this
592    /// adapter's property surface IS the vocabulary; on data
593    /// adapters those spellings stay ordinary field names. The
594    /// Greek is canon in docs and reflection preserves whichever
595    /// spelling was written.
596    fn property(&self, node: NodeId, name: &str) -> Option<Value> {
597        let n = &self.nodes[node.0 as usize];
598        match name {
599            "lemma" | "title" => n.lemma.clone().map(Value::Str),
600            "hypograph" | "attribution" => n.hypograph.clone().map(Value::Str),
601            "taxis" | "ord" => n.taxis.map(Value::Int),
602            "grammata" | "body" => {
603                let g = self.grammata(node);
604                if g.is_empty() { None } else { Some(Value::Str(g)) }
605            }
606            "text" => Some(Value::Str(n.prose.clone())),
607            _ => None,
608        }
609    }
610
611    /// The default projection is the flattened prose of the
612    /// subtree — lemma first, hypograph last.
613    fn default_value(&self, node: NodeId) -> Option<Value> {
614        Some(Value::Str(self.nodes[node.0 as usize].prose.clone()))
615    }
616
617    /// Ruling #29: the text level's surface is the vocabulary
618    /// itself — no document can introduce a property name — so
619    /// its two annotations answer at `::` as well.
620    fn aliased_metadata(&self, _node: NodeId) -> &'static [&'static str] {
621        &["level", "lang"]
622    }
623
624    /// `::::level` on sections (the source heading level) and
625    /// `::::lang` on verbatim blocks (the declared language).
626    fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
627        let n = &self.nodes[node.0 as usize];
628        match key {
629            "level" => n.level.map(|l| Value::Int(l as i64)),
630            "lang" => n.lang.clone().map(Value::Str),
631            _ => None,
632        }
633    }
634}