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//! - Tables denormalize into nested lists: an `ordered-list`
34//!   carrying the `<table>` trait (`::lemma` = the caption), one
35//!   `ordered-item` per row (`::taxis` = row number), one
36//!   `unordered-item` per cell — `Header: value` where a header
37//!   row exists, the bare cell text otherwise. Empty cells are
38//!   skipped.
39
40use quarb::{AstAdapter, NodeId, Value};
41
42pub mod render;
43pub use render::{Render, render_node, render_nodes};
44
45/// A block-level event in the text-level vocabulary — what a format
46/// producer emits. Headings arrive flat; the section tree is
47/// derived here, once, for every producer.
48#[derive(Debug, Clone, PartialEq)]
49pub enum Block {
50    /// A flat heading: `level` is the source level (`h2` → 2, a
51    /// LaTeX `\section` → its depth), `lemma` its text.
52    Heading { level: u8, lemma: String },
53    /// A plain paragraph — the implicit, lemma-less block.
54    Paragraph { text: String },
55    /// Inline content belonging directly to the open container (a
56    /// list item's own text, a bare-text blockquote). With no open
57    /// container it is read as a paragraph.
58    Text { text: String },
59    /// Open a nesting container. Items take their `unordered-` /
60    /// `ordered-` flavor (and taxis) from the enclosing list.
61    Open { kind: Container, lemma: Option<String> },
62    /// Close the innermost open container, optionally with its
63    /// hypograph (footer or attribution).
64    Close { hypograph: Option<String> },
65    /// A verbatim block — code or other preformatted lines, kept
66    /// as authored.
67    Verbatim { lang: Option<String>, text: String },
68    /// A table, denormalized here into nested lists (rows =
69    /// ordered items, cells = unordered items, `Header: value`
70    /// when `headers` is present). Header *detection* is the
71    /// producer's job; the lowering rule lives here.
72    Table {
73        lemma: Option<String>,
74        headers: Option<Vec<String>>,
75        rows: Vec<Vec<String>>,
76    },
77}
78
79/// The nesting containers a producer opens and closes explicitly.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum Container {
82    Blockquote,
83    UnorderedList,
84    /// `start` is the first item's ordinal (Markdown's `3.` lists).
85    OrderedList { start: i64 },
86    /// A list item; flavor and taxis come from the enclosing list.
87    Item,
88}
89
90/// The structural kind of a node — also its name.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92enum Kind {
93    Document,
94    Section,
95    Paragraph,
96    Blockquote,
97    UnorderedList,
98    OrderedList,
99    UnorderedItem,
100    OrderedItem,
101    Verbatim,
102}
103
104impl Kind {
105    fn name(self) -> Option<&'static str> {
106        Some(match self {
107            Kind::Document => return None,
108            Kind::Section => "section",
109            Kind::Paragraph => "paragraph",
110            Kind::Blockquote => "blockquote",
111            Kind::UnorderedList => "unordered-list",
112            Kind::OrderedList => "ordered-list",
113            Kind::UnorderedItem => "unordered-item",
114            Kind::OrderedItem => "ordered-item",
115            Kind::Verbatim => "verbatim",
116        })
117    }
118}
119
120struct Node {
121    kind: Kind,
122    lemma: Option<String>,
123    hypograph: Option<String>,
124    taxis: Option<i64>,
125    /// Source heading level, on sections.
126    level: Option<u8>,
127    /// Declared language, on verbatim blocks.
128    lang: Option<String>,
129    /// First ordinal of an ordered list (not exposed; feeds the
130    /// items' taxis).
131    start: i64,
132    /// The node's own (direct) text, before subtree flattening.
133    text: String,
134    /// The flattened prose of the subtree — the `::` projection.
135    prose: String,
136    /// The node heads a denormalized table (`<table>` trait).
137    table: bool,
138    parent: Option<NodeId>,
139    children: Vec<NodeId>,
140}
141
142impl Node {
143    fn new(kind: Kind, parent: Option<NodeId>) -> Self {
144        Node {
145            kind,
146            lemma: None,
147            hypograph: None,
148            taxis: None,
149            level: None,
150            lang: None,
151            start: 1,
152            text: String::new(),
153            prose: String::new(),
154            table: false,
155            parent,
156            children: Vec::new(),
157        }
158    }
159}
160
161/// Collapse whitespace runs to single spaces and trim — the prose
162/// normalization producers apply to inline content. Verbatim text
163/// is the exception: it is kept as authored.
164pub fn normalize_ws(s: &str) -> String {
165    s.split_whitespace().collect::<Vec<_>>().join(" ")
166}
167
168/// A Quarb adapter over a text-level document.
169pub struct TextModel {
170    nodes: Vec<Node>,
171    root: NodeId,
172}
173
174impl TextModel {
175    /// Assemble the document tree from a producer's event stream.
176    ///
177    /// Iterative throughout (the stream is flat; prose flattening
178    /// runs over indices), so pathological nesting cannot overflow
179    /// the call stack. Lenient on malformed streams: a stray
180    /// `Close` is ignored, unclosed containers close at the end.
181    pub fn build(blocks: Vec<Block>) -> Self {
182        let mut nodes = vec![Node::new(Kind::Document, None)];
183        let root = NodeId(0);
184        // Innermost-last stack of open *sections* (outline-derived).
185        let mut sections: Vec<NodeId> = Vec::new();
186        // Innermost-last stack of open explicit containers.
187        let mut containers: Vec<NodeId> = Vec::new();
188
189        for block in blocks {
190            match block {
191                Block::Heading { level, lemma } => {
192                    let lemma = normalize_ws(&lemma);
193                    if !containers.is_empty() {
194                        // Decorative heading inside a container:
195                        // not sectioning — lower to a paragraph.
196                        if !lemma.is_empty() {
197                            let parent = *containers.last().unwrap();
198                            let id = push(&mut nodes, Kind::Paragraph, parent);
199                            nodes[id.0 as usize].text = lemma;
200                        }
201                        continue;
202                    }
203                    while let Some(&open) = sections.last() {
204                        if nodes[open.0 as usize].level >= Some(level) {
205                            sections.pop();
206                        } else {
207                            break;
208                        }
209                    }
210                    let parent = sections.last().copied().unwrap_or(root);
211                    let id = push(&mut nodes, Kind::Section, parent);
212                    let n = &mut nodes[id.0 as usize];
213                    n.lemma = Some(lemma);
214                    n.level = Some(level);
215                    sections.push(id);
216                }
217                Block::Paragraph { text } => {
218                    let text = normalize_ws(&text);
219                    if text.is_empty() {
220                        continue;
221                    }
222                    let parent = cursor(&sections, &containers, root);
223                    let id = push(&mut nodes, Kind::Paragraph, parent);
224                    nodes[id.0 as usize].text = text;
225                }
226                Block::Text { text } => {
227                    let text = normalize_ws(&text);
228                    if text.is_empty() {
229                        continue;
230                    }
231                    match containers.last() {
232                        Some(&open) => {
233                            let own = &mut nodes[open.0 as usize].text;
234                            if !own.is_empty() {
235                                own.push(' ');
236                            }
237                            own.push_str(&text);
238                        }
239                        None => {
240                            let parent = sections.last().copied().unwrap_or(root);
241                            let id = push(&mut nodes, Kind::Paragraph, parent);
242                            nodes[id.0 as usize].text = text;
243                        }
244                    }
245                }
246                Block::Open { kind, lemma } => {
247                    let parent = cursor(&sections, &containers, root);
248                    let (nkind, start) = match kind {
249                        Container::Blockquote => (Kind::Blockquote, None),
250                        Container::UnorderedList => (Kind::UnorderedList, None),
251                        Container::OrderedList { start } => (Kind::OrderedList, Some(start)),
252                        Container::Item => (
253                            match nodes[parent.0 as usize].kind {
254                                Kind::OrderedList => Kind::OrderedItem,
255                                _ => Kind::UnorderedItem,
256                            },
257                            None,
258                        ),
259                    };
260                    let id = push(&mut nodes, nkind, parent);
261                    nodes[id.0 as usize].lemma =
262                        lemma.map(|l| normalize_ws(&l)).filter(|l| !l.is_empty());
263                    if let Some(start) = start {
264                        nodes[id.0 as usize].start = start;
265                    }
266                    if nkind == Kind::OrderedItem {
267                        // `push` already appended this item, so the
268                        // count includes it.
269                        let nth = nodes[parent.0 as usize]
270                            .children
271                            .iter()
272                            .filter(|&&c| nodes[c.0 as usize].kind == Kind::OrderedItem)
273                            .count() as i64;
274                        let start = nodes[parent.0 as usize].start;
275                        nodes[id.0 as usize].taxis = Some(start + nth - 1);
276                    }
277                    containers.push(id);
278                }
279                Block::Close { hypograph } => {
280                    if let Some(open) = containers.pop() {
281                        nodes[open.0 as usize].hypograph =
282                            hypograph.map(|h| normalize_ws(&h)).filter(|h| !h.is_empty());
283                    }
284                }
285                Block::Verbatim { lang, text } => {
286                    let parent = cursor(&sections, &containers, root);
287                    let id = push(&mut nodes, Kind::Verbatim, parent);
288                    let n = &mut nodes[id.0 as usize];
289                    n.lang = lang.filter(|l| !l.is_empty());
290                    n.text = text;
291                }
292                Block::Table {
293                    lemma,
294                    headers,
295                    rows,
296                } => {
297                    let parent = cursor(&sections, &containers, root);
298                    lower_table(&mut nodes, parent, lemma, headers, rows);
299                }
300            }
301        }
302
303        flatten_prose(&mut nodes);
304        TextModel { nodes, root }
305    }
306
307    /// Read plain text: blank-line-separated paragraphs, each
308    /// collapsed to one line — the atramento paragraph rule. No
309    /// headings, no markup.
310    pub fn parse_plain(text: &str) -> Self {
311        let mut blocks = Vec::new();
312        let mut para: Vec<&str> = Vec::new();
313        for line in text.lines() {
314            if line.trim().is_empty() {
315                if !para.is_empty() {
316                    blocks.push(Block::Paragraph {
317                        text: para.join(" "),
318                    });
319                    para.clear();
320                }
321            } else {
322                para.push(line);
323            }
324        }
325        if !para.is_empty() {
326            blocks.push(Block::Paragraph {
327                text: para.join(" "),
328            });
329        }
330        Self::build(blocks)
331    }
332
333    /// A locator path to `node`, like `/section[2]/paragraph[3]`,
334    /// for rendering. A `[n]` index is added only to disambiguate
335    /// same-name siblings.
336    pub fn locator(&self, node: NodeId) -> String {
337        let mut segments = Vec::new();
338        let mut cur = Some(node);
339        while let Some(id) = cur {
340            let n = &self.nodes[id.0 as usize];
341            if let Some(name) = n.kind.name() {
342                segments.push(self.segment(id, name));
343            }
344            cur = n.parent;
345        }
346        segments.reverse();
347        format!("/{}", segments.join("/"))
348    }
349
350    fn segment(&self, node: NodeId, name: &str) -> String {
351        let Some(parent) = self.nodes[node.0 as usize].parent else {
352            return name.to_string();
353        };
354        let siblings = &self.nodes[parent.0 as usize].children;
355        let same_name: Vec<NodeId> = siblings
356            .iter()
357            .copied()
358            .filter(|&s| self.nodes[s.0 as usize].kind == self.nodes[node.0 as usize].kind)
359            .collect();
360        if same_name.len() > 1 {
361            let n = same_name.iter().position(|&s| s == node).unwrap() + 1;
362            format!("{name}[{n}]")
363        } else {
364            name.to_string()
365        }
366    }
367}
368
369/// Where the next block lands: the innermost open container, else
370/// the innermost open section, else the root.
371fn cursor(sections: &[NodeId], containers: &[NodeId], root: NodeId) -> NodeId {
372    containers
373        .last()
374        .or(sections.last())
375        .copied()
376        .unwrap_or(root)
377}
378
379fn push(nodes: &mut Vec<Node>, kind: Kind, parent: NodeId) -> NodeId {
380    let id = NodeId(nodes.len() as u64);
381    nodes.push(Node::new(kind, Some(parent)));
382    nodes[parent.0 as usize].children.push(id);
383    id
384}
385
386/// Denormalize a table into nested lists (see the module doc).
387fn lower_table(
388    nodes: &mut Vec<Node>,
389    parent: NodeId,
390    lemma: Option<String>,
391    headers: Option<Vec<String>>,
392    rows: Vec<Vec<String>>,
393) {
394    let list = push(nodes, Kind::OrderedList, parent);
395    {
396        let n = &mut nodes[list.0 as usize];
397        n.table = true;
398        n.lemma = lemma.map(|l| normalize_ws(&l)).filter(|l| !l.is_empty());
399    }
400    for (i, row) in rows.into_iter().enumerate() {
401        let item = push(nodes, Kind::OrderedItem, list);
402        nodes[item.0 as usize].taxis = Some(i as i64 + 1);
403        let cells = push(nodes, Kind::UnorderedList, item);
404        for (j, cell) in row.into_iter().enumerate() {
405            let value = normalize_ws(&cell);
406            if value.is_empty() {
407                continue;
408            }
409            let header = headers
410                .as_ref()
411                .and_then(|h| h.get(j))
412                .map(|h| normalize_ws(h))
413                .filter(|h| !h.is_empty());
414            let text = match header {
415                Some(h) => format!("{h}: {value}"),
416                None => value,
417            };
418            let cell_item = push(nodes, Kind::UnorderedItem, cells);
419            nodes[cell_item.0 as usize].text = text;
420        }
421    }
422}
423
424/// Compute every node's flattened prose: lemma first, then the
425/// node's own text, then its children's prose in order, then the
426/// hypograph, block-joined with newlines. Children always carry
427/// larger indices than their parents (nodes are interned in
428/// document order), so one reverse index scan suffices — no
429/// recursion.
430fn flatten_prose(nodes: &mut [Node]) {
431    for i in (0..nodes.len()).rev() {
432        let mut parts: Vec<String> = Vec::new();
433        if let Some(lemma) = &nodes[i].lemma
434            && !lemma.is_empty()
435        {
436            parts.push(lemma.clone());
437        }
438        if !nodes[i].text.is_empty() {
439            parts.push(nodes[i].text.clone());
440        }
441        for &child in nodes[i].children.clone().iter() {
442            let prose = &nodes[child.0 as usize].prose;
443            if !prose.is_empty() {
444                parts.push(prose.clone());
445            }
446        }
447        if let Some(hypograph) = &nodes[i].hypograph
448            && !hypograph.is_empty()
449        {
450            parts.push(hypograph.clone());
451        }
452        nodes[i].prose = parts.join("\n");
453    }
454}
455
456impl AstAdapter for TextModel {
457    fn root(&self) -> NodeId {
458        self.root
459    }
460
461    fn children(&self, node: NodeId) -> Vec<NodeId> {
462        self.nodes[node.0 as usize].children.clone()
463    }
464
465    fn name(&self, node: NodeId) -> Option<String> {
466        self.nodes[node.0 as usize].kind.name().map(str::to_string)
467    }
468
469    fn parent(&self, node: NodeId) -> Option<NodeId> {
470        self.nodes[node.0 as usize].parent
471    }
472
473    /// The `<block>` family on every block node, plus `<table>` on
474    /// a list that denormalizes a table. Kinds are node names, not
475    /// traits.
476    fn traits(&self, node: NodeId) -> Vec<String> {
477        let n = &self.nodes[node.0 as usize];
478        let mut out = Vec::new();
479        if n.kind != Kind::Document {
480            out.push("block".to_string());
481        }
482        if n.table {
483            out.push("table".to_string());
484        }
485        out
486    }
487
488    /// `::lemma` (title), `::hypograph` (footer or attribution),
489    /// `::taxis` (ordinal), `::text` (the flattened prose, same as
490    /// the bare projection).
491    fn property(&self, node: NodeId, name: &str) -> Option<Value> {
492        let n = &self.nodes[node.0 as usize];
493        match name {
494            "lemma" => n.lemma.clone().map(Value::Str),
495            "hypograph" => n.hypograph.clone().map(Value::Str),
496            "taxis" => n.taxis.map(Value::Int),
497            "text" => Some(Value::Str(n.prose.clone())),
498            _ => None,
499        }
500    }
501
502    /// The default projection is the flattened prose of the
503    /// subtree — lemma first, hypograph last.
504    fn default_value(&self, node: NodeId) -> Option<Value> {
505        Some(Value::Str(self.nodes[node.0 as usize].prose.clone()))
506    }
507
508    /// `::::level` on sections (the source heading level) and
509    /// `::::lang` on verbatim blocks (the declared language).
510    fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
511        let n = &self.nodes[node.0 as usize];
512        match key {
513            "level" => n.level.map(|l| Value::Int(l as i64)),
514            "lang" => n.lang.clone().map(Value::Str),
515            _ => None,
516        }
517    }
518}