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. The format adapters (`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` — plus the
21//!   apparatus: `footnote` and `endnote` (callout and body share
22//!   the family name, ruling #35), `aside` (litogramma's fourth
23//!   deixis-attached family — anchored content, not apparatus),
24//!   `index-mark` (ruling #36), and the verse vocabulary
25//!   (ruling #37): `verse` holding `strophe`s holding `stichos`
26//!   lines, the stichos `::taxis` the citation coordinate.
27//! - `::lemma`, `::hypograph`, and `::taxis` are properties; bare
28//!   `::` (and `::text`) is the flattened prose of the subtree,
29//!   lemma first, hypograph last.
30//! - `::::level` on a section is the source heading level;
31//!   `::::lang` on a verbatim block is its declared language.
32//! - Sections are derived from the flat heading stream by the
33//!   outline rule: a heading closes every open section at its
34//!   level or deeper, then opens a section under the nearest
35//!   shallower one. Content before the first heading belongs to
36//!   the document root. A heading inside an open container
37//!   (blockquote, list) is decorative, not sectioning: it lowers
38//!   to a paragraph of its text.
39//! - Every kind admits `::lemma`, `::taxis`, and `::hypograph` —
40//!   the atrep model, where these are universal affordances of a
41//!   block rather than privileges of particular kinds.
42//! - Tables denormalize into nested lists: an `ordered-list`
43//!   carrying the `<table>` trait (`::lemma` = the caption), one
44//!   `ordered-item` per row (`::taxis` = row number, `<row>`
45//!   trait), one `unordered-item` per cell (`<cell>` trait) whose
46//!   `::lemma` is the column name — from the header row in grids,
47//!   from the row's `th` label otherwise; headerless cells carry
48//!   no lemma. A lemma'd item flattens as `lemma: prose`, so a
49//!   row exists, the bare cell text otherwise. Empty cells are
50//!   skipped.
51
52use quarb::{AstAdapter, NodeId, Value};
53
54pub mod render;
55pub use render::{Render, render_node, render_nodes};
56
57/// A block-level event in the text-level vocabulary — what a format
58/// adapter emits. Headings arrive flat; the section tree is
59/// derived here, once, for every adapter.
60#[derive(Debug, Clone, PartialEq)]
61pub enum Block {
62    /// A flat heading: `level` is the source level (`h2` → 2, a
63    /// LaTeX `\section` → its depth), `lemma` its text.
64    Heading { level: u8, lemma: String },
65    /// A plain paragraph — the implicit, lemma-less block.
66    Paragraph { text: String },
67    /// A speech: a paragraph whose lemma names the speaker (TEI's
68    /// `<said who>` / `<sp><speaker>`, litogramma's dialogue sim).
69    /// The reader sees a paragraph; `::lemma` answers the speaker
70    /// and the text stays the words spoken.
71    Dialogue {
72        lemma: String,
73        text: String,
74        /// The literary reading names the block `dialogue`
75        /// (litogramma's sim); the text level reads it as a
76        /// paragraph with a lemma.
77        lit: bool,
78    },
79    /// The literary reading (`lit:`): a prose block that is a
80    /// speech — litogramma's `dialogue-line` (at-drama `@:-`),
81    /// TEI's whole-paragraph `<said>` — with the unseen metadata
82    /// its aphanes monosims carry (`prosopon`, the speaker) as
83    /// fields and its genoses as traits.
84    Speech {
85        text: String,
86        genoses: Vec<String>,
87        fields: Vec<(String, String)>,
88    },
89    /// The literary reading: an inline span of the preceding flow
90    /// block — a quotation, an annotation (a name), an emphasis, a
91    /// stage direction — named by its litogramma sim, at byte
92    /// offsets into the block's normalized text, with its genoses
93    /// as traits and its aphanes monosims as fields. Spans nest by
94    /// containment of offsets: a name inside a speech is the
95    /// speech's child.
96    Span {
97        kind: String,
98        lo: u32,
99        hi: u32,
100        genoses: Vec<String>,
101        fields: Vec<(String, String)>,
102    },
103    /// The literary reading: a milestone (`@("page:12")`) at an
104    /// offset into the preceding flow block — `::scheme`,
105    /// `::value`.
106    Milestone {
107        scheme: String,
108        value: String,
109        at: u32,
110    },
111    /// The literary reading: unseen metadata on the preceding
112    /// block itself (a paragraph's `eidos`).
113    Annotate { fields: Vec<(String, String)> },
114    /// The parsing pack (ruling #80): a token the source itself
115    /// parsed — litogramma's at-epimerismos diaphane, which TEI's
116    /// `w`/`pc`, the Russian National Corpus, OpenCorpora and
117    /// PROIEL import to — at byte offsets into the preceding flow
118    /// block, with its parsings (the first is the reading's; the
119    /// rest are alternatives) and the onym a head may point at.
120    Parsed {
121        lo: u32,
122        hi: u32,
123        onym: Option<String>,
124        parsings: Vec<Parsing>,
125    },
126    /// The parsing pack: a sentence the source declared (the
127    /// periodos diaphane, TEI's `s`), at byte offsets into the
128    /// preceding flow block, with the source's id.
129    Periodos { lo: u32, hi: u32, id: String },
130    /// The literary reading: a cast entry the source declares
131    /// (at-drama's `@:!! name … !!:@(id)`, a dramatis-persona
132    /// line, TEI's cast list) — the name, the id when the source
133    /// gives one, the description as the node's text.
134    Character {
135        lemma: String,
136        onym: Option<String>,
137        text: String,
138        genoses: Vec<String>,
139    },
140    /// The literary reading: genoses the preceding block wears as
141    /// traits — the sim of a paragraph-level simmere that has no
142    /// reading of its own (a block stage direction's paragraphs
143    /// read `paragraph<stage-direction>`).
144    Wear { genoses: Vec<String> },
145    /// Inline content belonging directly to the open container (a
146    /// list item's own text, a bare-text blockquote). With no open
147    /// container it is read as a paragraph.
148    Text { text: String },
149    /// Open a nesting container. Items take their `unordered-` /
150    /// `ordered-` flavor (and taxis) from the enclosing list.
151    Open {
152        kind: Container,
153        lemma: Option<String>,
154    },
155    /// Close the innermost open container, optionally with its
156    /// hypograph (footer or attribution).
157    Close { hypograph: Option<String> },
158    /// A verbatim block — code or other preformatted lines, kept
159    /// as authored.
160    Verbatim { lang: Option<String>, text: String },
161    /// An in-text note callout (the deixis, ruling #35): `onym`
162    /// names the body it cites within its family. Emitted after
163    /// the flow block it sits in; it becomes that block's child,
164    /// in order, and the block's own text stays clean of markers.
165    /// `family: None` means the source declares none (an HTML
166    /// noteref): the callout takes its resolved body's family,
167    /// and a dangling one defaults to footnote.
168    NoteRef {
169        onym: String,
170        family: Option<NoteFamily>,
171        /// The declared spelling placed this pair in the margin
172        /// (a Tufte-style sidenote): surfaced as `::::form =
173        /// "margin"` on both ends — the family stays footnote,
174        /// placement is presentation.
175        margin: bool,
176        /// The callout's byte offset in the flow block's text,
177        /// when the producer knows it (the literary reading's
178        /// inline notes): the citation in force at the note.
179        at: Option<u32>,
180    },
181    /// An index mark (ruling #36): an invisible anchor declaring
182    /// this place concerns `term`. The back-of-book index is a
183    /// query over these, never a stored structure.
184    IndexMark { term: String },
185    /// A reference mark — the text-level mention (html's in-prose
186    /// `<a href>`, markdown's `[text](url)`, LaTeX's `\ref`):
187    /// `target` is the identifier as written, `text` the authored
188    /// link text (LaTeX `\ref` has none), and `internal` is the
189    /// producer's declaration that the target names a label in
190    /// *this* document (`#fragment`, a `\ref` key) rather than
191    /// another document. A mention, not an attachment: refs do
192    /// not carry `<deixis>`.
193    Ref {
194        target: String,
195        text: Option<String>,
196        internal: bool,
197    },
198    /// A point anchor — an invisible in-flow node bearing an
199    /// `onym` at this position (LaTeX's mid-flow `\label`, an
200    /// inline html `id=`). The IndexMark construction: child of
201    /// its flow block, document order.
202    Anchor { onym: String },
203    /// A citation mark — the bibliography's mention (LaTeX's
204    /// `\cite`, koine's cite monosim): `target` is the authored
205    /// key, resolved against the `bib` bearers — a namespace of
206    /// its own, separate from labels (LaTeX precedent). The
207    /// `locator` is the mark's own pinpoint (`\cite[I.1]{key}`,
208    /// `[@key, I.1]`), kept as written.
209    Cite {
210        target: String,
211        locator: Option<String>,
212    },
213    /// One bibliographic entry: a block bearing its authored
214    /// `key`. Unstructured sources (\bibitem) carry the full
215    /// reference as `text`; structured ones (bibliogramma,
216    /// BibTeX/BibLaTeX through it) carry `fields` — canonical
217    /// campus name → value, Latin per the bibliogramma
218    /// vocabulary — and the entry's `genus` (liber,
219    /// commentarius, …).
220    Bib {
221        key: String,
222        text: String,
223        fields: Vec<(String, String)>,
224        genus: Option<String>,
225    },
226    /// A block label: the innermost open section takes `onym` as
227    /// the name it bears (a heading's `id`; a `\label` attached
228    /// to a sectioning command — the promotion rule). With no
229    /// open section it degrades to a point [`Block::Anchor`].
230    Label { onym: String },
231    /// A verse block (ruling #37 — litogramma's stichoi model):
232    /// strophes of lines, denormalized like [`Block::Table`].
233    /// Lines arrive flattened by the adapter and are kept as
234    /// authored; the stichos `::taxis` numbers lines 1-based,
235    /// CONTINUOUSLY across strophes — the citation coordinate.
236    Verse {
237        lemma: Option<String>,
238        strophes: Vec<Vec<String>>,
239        hypograph: Option<String>,
240    },
241    /// A table, denormalized here into nested lists (rows =
242    /// ordered items with the `<row>` trait, cells = unordered
243    /// items with the `<cell>` trait and the column name as
244    /// `::lemma`). Header *detection* is the adapter's job; the
245    /// lowering rule lives here. A cell's own `label` (a row's
246    /// `th`) wins over the positional `headers` entry.
247    Table {
248        lemma: Option<String>,
249        headers: Option<Vec<String>>,
250        rows: Vec<Vec<Cell>>,
251    },
252}
253
254/// One table cell as an adapter hands it over: the text, plus the
255/// label a row-shaped dialect attaches directly (an infobox row's
256/// `th`). Grid dialects leave `label` empty and let the lowering
257/// zip the header row on by position.
258#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct Cell {
260    pub label: Option<String>,
261    pub text: String,
262}
263
264impl From<&str> for Cell {
265    fn from(text: &str) -> Self {
266        Cell {
267            label: None,
268            text: text.to_string(),
269        }
270    }
271}
272
273impl From<String> for Cell {
274    fn from(text: String) -> Self {
275        Cell { label: None, text }
276    }
277}
278
279/// The nesting containers an adapter opens and closes explicitly.
280#[derive(Debug, Clone, PartialEq, Eq)]
281pub enum Container {
282    Blockquote,
283    UnorderedList,
284    /// `start` is the first item's ordinal (Markdown's `3.` lists).
285    OrderedList {
286        start: i64,
287    },
288    /// A list item; flavor and taxis come from the enclosing list.
289    Item,
290    /// A note body (the noted, ruling #35): opens a note node of
291    /// its family at the document root — litogramma's canonical
292    /// document-end placement — holding its own blocks. `margin`
293    /// as on [`Block::NoteRef`].
294    Note {
295        onym: String,
296        family: NoteFamily,
297        margin: bool,
298    },
299}
300
301/// The deixis-attached families litogramma's canon names
302/// (ruling #35, as amended): each family's callout and body
303/// share its name. `Aside` rides the same construction —
304/// koine's `aside` sim, an anchored body whose insertion point
305/// is a deixis — but is content, not apparatus: its bodies do
306/// not carry `<note>`.
307#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
308pub enum NoteFamily {
309    Footnote,
310    Endnote,
311    Aside,
312}
313
314impl NoteFamily {
315    fn kind(self) -> Kind {
316        match self {
317            NoteFamily::Footnote => Kind::Footnote,
318            NoteFamily::Endnote => Kind::Endnote,
319            NoteFamily::Aside => Kind::Aside,
320        }
321    }
322}
323
324/// One parsing of a source-parsed token (ruling #80), in the
325/// pack's own vocabulary: the lexeme's lemma, the part of speech
326/// (`UPOS` or `UPOS/XPOS`), the accidents (features, in the
327/// source's spelling), a semantic class, the head's onym and the
328/// dependency relation.
329#[derive(Debug, Clone, Default, PartialEq, Eq)]
330pub struct Parsing {
331    pub lexema: Option<String>,
332    pub meros: Option<String>,
333    pub parepomena: Option<String>,
334    pub semasia: Option<String>,
335    pub kephale: Option<String>,
336    pub schesis: Option<String>,
337}
338
339/// A source-parsed token waiting for the tokenizer: its byte
340/// range in the block's prose, its onym, its parsings.
341#[derive(Debug, Clone)]
342struct DeclaredToken {
343    lo: u32,
344    hi: u32,
345    onym: Option<String>,
346    parsings: Vec<Parsing>,
347}
348
349/// A row of a cast table (ruling #79): the character's id (the
350/// `prosopon` a mention answers), its name, the pattern a word
351/// token must match to be its mention, and the table's other
352/// columns as the character's fields.
353#[derive(Debug, Clone)]
354pub struct CastRow {
355    pub id: String,
356    pub name: String,
357    pub pattern: Option<regex::Regex>,
358    /// How many word tokens the pattern spans (a `mention` or
359    /// `stem` of several words matches a run of tokens).
360    pub words: usize,
361    pub fields: Vec<(String, String)>,
362}
363
364impl CastRow {
365    /// Read a cast table from a CSV file: an `id` column (required),
366    /// a `name`, and the pattern a word token must match to be the
367    /// character's mention — `pattern` as a regex, `stem` (or
368    /// `prefix`) as a prefix, or `mention` / `form` as the whole
369    /// word — with every
370    /// other column kept as the character's fields.
371    pub fn read_csv(path: &str) -> Result<Vec<CastRow>, String> {
372        let mut reader = csv::Reader::from_path(path).map_err(|e| e.to_string())?;
373        let headers: Vec<String> = reader
374            .headers()
375            .map_err(|e| e.to_string())?
376            .iter()
377            .map(str::to_string)
378            .collect();
379        if !headers.iter().any(|h| h == "id") {
380            return Err(format!(
381                "a cast table needs an id column (found: {})",
382                headers.join(", ")
383            ));
384        }
385        let mut rows = Vec::new();
386        for record in reader.records() {
387            let record = record.map_err(|e| e.to_string())?;
388            let get = |k: &str| {
389                headers
390                    .iter()
391                    .position(|h| h == k)
392                    .and_then(|i| record.get(i))
393                    .map(str::trim)
394                    .filter(|v| !v.is_empty())
395            };
396            let Some(id) = get("id") else { continue };
397            let (pattern, words) = if let Some(p) = get("pattern") {
398                let re = regex::Regex::new(p).map_err(|e| format!("cast pattern for {id}: {e}"))?;
399                // A pattern of several words matches a run of that
400                // many tokens, as a stem or a mention does.
401                (Some(re), p.split_whitespace().count().max(1))
402            } else if let Some(stem) = get("stem").or_else(|| get("prefix")) {
403                let re = regex::Regex::new(&format!("^{}", regex::escape(stem))).unwrap();
404                (Some(re), stem.split_whitespace().count())
405            } else if let Some(form) = get("mention").or_else(|| get("form")) {
406                let re = regex::Regex::new(&format!("^{}$", regex::escape(form))).unwrap();
407                (Some(re), form.split_whitespace().count())
408            } else {
409                (None, 0)
410            };
411            let fields: Vec<(String, String)> = headers
412                .iter()
413                .zip(record.iter())
414                .filter(|(h, v)| h.as_str() != "id" && !v.trim().is_empty())
415                .map(|(h, v)| (h.clone(), v.trim().to_string()))
416                .collect();
417            rows.push(CastRow {
418                id: id.to_string(),
419                name: get("name").unwrap_or(id).to_string(),
420                pattern,
421                words,
422                fields,
423            });
424        }
425        Ok(rows)
426    }
427}
428
429/// The structural kind of a node — also its name.
430#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
431enum Kind {
432    Document,
433    Section,
434    /// The literary reading's cast entry (ruling #79): a character
435    /// declared by the source or by a cast table, `::lemma` its
436    /// name, `::onym` its id, `<-character` its mentions.
437    Character,
438    /// The literary reading's gazetteer entry (ruling #87): a place
439    /// declared by a places table, `::lemma` its name, `::onym` its
440    /// id, `<-place` its mentions.
441    Place,
442    Paragraph,
443    Blockquote,
444    UnorderedList,
445    OrderedList,
446    UnorderedItem,
447    OrderedItem,
448    Verbatim,
449    /// Callout and body both — they share the name by
450    /// construction (litogramma F5): `//footnote` gathers the
451    /// whole apparatus, `<deixis>`/`<note>` tell them apart.
452    Footnote,
453    /// The second note family, same construction.
454    Endnote,
455    /// The anchored-content family, same construction
456    /// (litogramma's aside): body + insertion-point deixis. Content, not
457    /// apparatus — no `<note>` on its bodies.
458    Aside,
459    /// An index mark (ruling #36): `::term`, in flow position.
460    IndexMark,
461    /// A reference mark: `::target`, `-->` resolves it.
462    Ref,
463    /// A citation mark: `::target` the authored key, resolved
464    /// against the `bib` namespace.
465    Cit,
466    /// One bibliographic entry: `::onym` the key it bears, its
467    /// projection the full reference text.
468    Bib,
469    /// A point anchor: `::onym`, an invisible in-flow bearer.
470    Anchor,
471    /// The verse vocabulary (ruling #37): the stichoi container…
472    Verse,
473    /// …its strophes…
474    Strophe,
475    /// …and its lines, `::taxis` the citation coordinate.
476    Stichos,
477    /// The corpus reading (ruling #62): one token of a prose
478    /// block — a UAX #29 word segment — with `::` its form,
479    /// `::lower` the case-folded form, `<word>` / `<number>` /
480    /// `<punct>` its class, `::::n` its position in the document
481    /// and `::::sentence` the UAX #29 sentence it falls in. Flat
482    /// under the block: a sentence is an annotation on the token,
483    /// never a container, so sibling hops run across a false
484    /// sentence break ("Dr. Smith") as they do across a true one.
485    Token,
486    /// A treebank's sentence (ruling #63): in a standalone CoNLL-U
487    /// document the file's blank-line blocks are declared
488    /// structure that nothing re-segments, so each is a prose
489    /// block — `::` its text (`# text`, else the forms joined per
490    /// `SpaceAfter`), `::id` its `# sent_id`, every other `# key =
491    /// value` comment a property — and under `corpus:` its tokens
492    /// are its children. Over prose, a sidecar's sentence stays an
493    /// annotation on the token.
494    Sentence,
495    /// The literary reading's speech block: litogramma's
496    /// `dialogue-line`.
497    Speech,
498    /// The literary reading's drama speech (`@: Speaker`): the
499    /// lemma is the printed speech prefix.
500    Dialogue,
501    /// The literary reading's inline span, named by its sim
502    /// (`quotation`, `annotation`, `emphasis`, …).
503    Span,
504    /// The literary reading's milestone, a point in the prose.
505    Milestone,
506}
507
508impl Kind {
509    fn name(self) -> Option<&'static str> {
510        Some(match self {
511            Kind::Document => return None,
512            Kind::Section => "section",
513            Kind::Paragraph => "paragraph",
514            Kind::Blockquote => "blockquote",
515            Kind::Footnote => "footnote",
516            Kind::Endnote => "endnote",
517            Kind::Aside => "aside",
518            Kind::IndexMark => "index-mark",
519            Kind::Ref => "ref",
520            Kind::Cit => "cit",
521            Kind::Bib => "bib",
522            Kind::Anchor => "anchor",
523            Kind::Verse => "verse",
524            Kind::Strophe => "strophe",
525            Kind::Stichos => "stichos",
526            Kind::UnorderedList => "unordered-list",
527            Kind::OrderedList => "ordered-list",
528            Kind::UnorderedItem => "unordered-item",
529            Kind::OrderedItem => "ordered-item",
530            Kind::Verbatim => "verbatim",
531            Kind::Token => "token",
532            Kind::Sentence => "sentence",
533            Kind::Speech => "dialogue-line",
534            Kind::Character => "character",
535            Kind::Place => "place",
536            Kind::Dialogue => "dialogue",
537            // The adapter answers the sim name kept on the node.
538            Kind::Span => "span",
539            Kind::Milestone => "milestone",
540        })
541    }
542}
543
544struct Node {
545    kind: Kind,
546    lemma: Option<String>,
547    hypograph: Option<String>,
548    taxis: Option<i64>,
549    /// Source heading level, on sections.
550    level: Option<u8>,
551    /// Declared language, on verbatim blocks.
552    lang: Option<String>,
553    /// First ordinal of an ordered list (not exposed; feeds the
554    /// items' taxis).
555    start: i64,
556    /// The node's own (direct) text, before subtree flattening.
557    text: String,
558    /// The flattened prose of the subtree — the `::` projection.
559    prose: String,
560    /// The node heads a denormalized table (`<table>` trait).
561    table: bool,
562    /// The node is a denormalized table row (`<row>` trait).
563    row: bool,
564    /// The node is a denormalized table cell (`<cell>` trait).
565    cell: bool,
566    /// The apparatus (ruling #35): the note name as written —
567    /// and, on an index mark (ruling #36), the term.
568    onym: Option<String>,
569    /// A footnote callout (`<deixis>`) rather than a body.
570    deixis: bool,
571    /// A callout whose body is missing (`<dangling>`).
572    dangling: bool,
573    /// A callout whose source declared no family: resolution may
574    /// re-kind it from the body it reaches.
575    family_open: bool,
576    /// The declared spelling was a margin form (`::::form`).
577    margin: bool,
578    /// Callout -> body edge, once resolved.
579    note_edge: Option<NodeId>,
580    /// Body <- callouts (the reverse index).
581    cites: Vec<NodeId>,
582    /// A ref's target identifier, as written.
583    target: Option<String>,
584    /// A cit's locator (page, chapter, line), as written.
585    locator: Option<String>,
586    /// The producer declared the target in-document.
587    internal: bool,
588    /// A structured bib entry's fields (campus → value) and its
589    /// genus, per the bibliogramma vocabulary.
590    fields: Vec<(String, String)>,
591    genus: Option<String>,
592    /// Ref -> bearer edge, once resolved (in-document).
593    ref_edge: Option<NodeId>,
594    /// Bearer <- refs (the reverse index).
595    ref_cites: Vec<NodeId>,
596    parent: Option<NodeId>,
597    children: Vec<NodeId>,
598    /// The corpus reading's annotation, on token nodes.
599    token: Option<TokenInfo>,
600    /// A sentence the corpus reading derived at mount (ruling
601    /// #64) — from the segmentation in force, never declared by
602    /// the source — as against a treebank's declared one.
603    derived: bool,
604    /// The literary reading: the genoses a simmere wore (its
605    /// traits), the sim name of a span, its byte range in the
606    /// flow block's text, and the tokens it covers.
607    genoses: Vec<String>,
608    span_kind: Option<String>,
609    at: Option<(u32, u32)>,
610    span_tokens: Vec<NodeId>,
611    /// The character a mention resolves to (`->character`), and on
612    /// a character the mentions that resolve to it (`<-character`).
613    character: Option<NodeId>,
614    mentions: Vec<NodeId>,
615    /// The place a mention resolves to (`->place`); a place keeps
616    /// its mentions in `mentions` too.
617    place: Option<NodeId>,
618    /// The parsing pack's tokens and sentences declared on this
619    /// flow block, consumed by the tokenizer (ruling #80).
620    declared_tokens: Vec<DeclaredToken>,
621    declared_sentences: Vec<(u32, u32, String)>,
622}
623
624/// A token's class — the trait it wears.
625#[derive(Debug, Clone, Copy, PartialEq, Eq)]
626enum TokenClass {
627    /// Carries a letter.
628    Word,
629    /// Carries a digit and no letter.
630    Number,
631    /// Everything else that is not whitespace.
632    Punct,
633}
634
635impl TokenClass {
636    fn name(self) -> &'static str {
637        match self {
638            TokenClass::Word => "word",
639            TokenClass::Number => "number",
640            TokenClass::Punct => "punct",
641        }
642    }
643}
644
645#[derive(Debug, Clone)]
646struct TokenInfo {
647    class: TokenClass,
648    /// 1-based position among the document's tokens; 0 for an
649    /// empty node, which is not in the text (`::::n` null).
650    n: u32,
651    /// 1-based ordinal of the sentence, document-wide.
652    sentence: u32,
653    /// The sentence's byte range in the block's prose — `::sentence`
654    /// is that substring.
655    span: (u32, u32),
656    /// The annotation a CoNLL-U source supplied, when one did.
657    annot: Option<Box<Annot>>,
658    /// The token's own byte offset in the block's prose, and the
659    /// literary reading's spans that cover it, outermost first.
660    at: u32,
661    spans: Vec<NodeId>,
662}
663
664/// A token's linguistic annotation from a CoNLL-U source (ruling
665/// #63): its in-sentence id, the lemma, the universal and
666/// language-specific parts of speech, the raw feature, enhanced
667/// dependency and miscellany columns, the dependency relation, the
668/// basic head (`->head`; `<-head` the dependents) and the enhanced
669/// heads with their relations (`->ehead`, `$-::rel`). Every
670/// `Key=Value` of FEATS, MISC and a CoNLL-U Plus column answers as
671/// a property under its key, in the annotation's own spelling and
672/// case-folded.
673#[derive(Debug, Clone, Default)]
674struct Annot {
675    id: String,
676    lemma: Option<String>,
677    upos: Option<String>,
678    xpos: Option<String>,
679    feats: Option<String>,
680    deprel: Option<String>,
681    deps: Option<String>,
682    misc: Option<String>,
683    /// FEATS, MISC and extra-column pairs, in source order.
684    keys: Vec<(String, String)>,
685    /// The multiword range this word belongs to: its id (`1-2`)
686    /// and surface form (`don't`).
687    mwt: Option<(String, String)>,
688    /// An empty node (`8.1`): present in the enhanced graph only.
689    empty: bool,
690    head: Option<NodeId>,
691    dependents: Vec<NodeId>,
692    eheads: Vec<(NodeId, String)>,
693    edependents: Vec<(NodeId, String)>,
694    /// The mentions this token falls in, as 1-based ordinals into
695    /// the model's mention list (several where mentions nest).
696    mentions: Vec<u32>,
697}
698
699/// A mention (ruling #63): a span of tokens an annotation marks as
700/// a named entity or a coreference mention — the sentence pattern
701/// again, an annotation on the tokens, never a container. Decoded
702/// at mount from a `ner` key in MISC (BIO and BIOES tags) and from
703/// CorefUD's `Entity=` bracket notation.
704#[derive(Debug, Clone)]
705struct Mention {
706    tokens: Vec<NodeId>,
707    /// The entity type (PERSON, GPE, …), when the source names one.
708    etype: Option<String>,
709    /// The coreference cluster id, when the source declares one.
710    cluster: Option<String>,
711}
712
713impl Annot {
714    /// A `Key=Value` pair's value: the key as the annotation spells
715    /// it first, then case-folded.
716    fn key(&self, name: &str) -> Option<&str> {
717        self.keys
718            .iter()
719            .find(|(k, _)| k == name)
720            .or_else(|| self.keys.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)))
721            .map(|(_, v)| v.as_str())
722    }
723}
724
725/// The bracket entries of a CorefUD `Entity=` value, which may
726/// run together (`(e4-object-5-(e3-person-3-`, `e3)(e5-x-1)`) or
727/// be `|`-separated: an opening entry runs from its `(` to the
728/// next bracket, a closing one ends at its `)`.
729fn entity_entries(spec: &str) -> Vec<String> {
730    let mut out = Vec::new();
731    for piece in spec.split('|') {
732        let mut cur = String::new();
733        for ch in piece.chars() {
734            if ch == '(' && !cur.is_empty() {
735                out.push(std::mem::take(&mut cur));
736            }
737            cur.push(ch);
738            if ch == ')' {
739                out.push(std::mem::take(&mut cur));
740            }
741        }
742        if !cur.is_empty() {
743            out.push(cur);
744        }
745    }
746    out
747}
748
749/// `Key=Value|Key=Value` (FEATS, MISC) as pairs; an entry without
750/// `=` keeps its text as the key with an empty value; `_` is none.
751fn key_values(column: &str) -> Vec<(String, String)> {
752    if column == "_" || column.is_empty() {
753        return Vec::new();
754    }
755    column
756        .split('|')
757        .filter(|e| !e.is_empty())
758        .map(|e| match e.split_once('=') {
759            Some((k, v)) => (k.to_string(), v.to_string()),
760            None => (e.to_string(), String::new()),
761        })
762        .collect()
763}
764
765/// The UAX #29 sentence spans of a block's prose — with the
766/// sentence bonds applied when a `.desm` set is in force (the
767/// `syndesmos` crate: abbreviations a sentence may end in without
768/// ending, and patterns whose match straddles a break).
769fn sentence_spans(prose: &str, bonds: Option<&syndesmos::Syndesmos>) -> Vec<(usize, usize)> {
770    let raw: Vec<(usize, &str)> = match bonds {
771        Some(b) => b.split_sentence_bound_indices(prose),
772        None => {
773            use unicode_segmentation::UnicodeSegmentation;
774            prose.split_sentence_bound_indices().collect()
775        }
776    };
777    raw.into_iter()
778        .filter(|(_, seg)| !seg.trim().is_empty())
779        .map(|(at, seg)| (at, at + seg.len()))
780        .collect()
781}
782
783/// The ten standard CoNLL-U columns, in order.
784const CONLLU_COLUMNS: [&str; 10] = [
785    "ID", "FORM", "LEMMA", "UPOS", "XPOS", "FEATS", "HEAD", "DEPREL", "DEPS", "MISC",
786];
787
788/// One token line of a CoNLL-U sentence, as read.
789#[derive(Debug, Clone, Default)]
790struct ConlluToken {
791    id: String,
792    /// A multiword range `a-b`, parsed.
793    range: Option<(u32, u32)>,
794    /// The numeric id of a word, or an empty node's integer part.
795    ord: u32,
796    /// An empty node (`8.1`).
797    empty: bool,
798    form: String,
799    lemma: String,
800    upos: String,
801    xpos: String,
802    feats: String,
803    head: String,
804    deprel: String,
805    deps: String,
806    misc: String,
807    /// CoNLL-U Plus columns beyond the ten, under their declared
808    /// names with `:` read as `-`.
809    extra: Vec<(String, String)>,
810}
811
812/// One sentence block of a CoNLL-U file.
813#[derive(Debug, Clone, Default)]
814struct ConlluSentence {
815    /// `# key = value` comments, in order (`sent_id`, `text`, …).
816    comments: Vec<(String, String)>,
817    /// `# newdoc [id = X]` opened a document before this sentence.
818    newdoc: Option<Option<String>>,
819    /// `# newpar [id = X]` opened a paragraph before this sentence.
820    newpar: Option<Option<String>>,
821    tokens: Vec<ConlluToken>,
822}
823
824/// A CoNLL-U file: its sentences and its file-level declarations
825/// (`# global.columns`, `# global.Entity`).
826#[derive(Debug, Clone, Default)]
827struct ConlluDoc {
828    sentences: Vec<ConlluSentence>,
829    globals: Vec<(String, String)>,
830}
831
832/// Whether `text` reads as CoNLL-U: its first line that is neither
833/// blank nor a `#` comment is a tab-separated token line whose id
834/// is a word (`3`), a range (`3-4`) or an empty node (`3.1`), with
835/// the ten columns (or, under a `# global.columns` header, that
836/// header's count). The sniff for an extensionless input or a
837/// pipe, where no `.conllu` names the reading.
838pub fn looks_like_conllu(text: &str) -> bool {
839    let mut columns = CONLLU_COLUMNS.len();
840    for line in text.lines().take(200) {
841        let line = line.trim_end_matches('\r');
842        if line.trim().is_empty() {
843            continue;
844        }
845        if let Some(c) = line.strip_prefix('#') {
846            if let Some((k, v)) = c.split_once('=')
847                && k.trim() == "global.columns"
848            {
849                columns = v.split_whitespace().count().max(2);
850            }
851            continue;
852        }
853        let fields: Vec<&str> = line.split('\t').collect();
854        if fields.len() != columns {
855            return false;
856        }
857        let id = fields[0];
858        let (a, b) = match id.split_once(['-', '.']) {
859            Some((a, b)) => (a, Some(b)),
860            None => (id, None),
861        };
862        return a.parse::<u32>().is_ok() && b.is_none_or(|b| b.parse::<u32>().is_ok());
863    }
864    false
865}
866
867/// The treebank files of a directory, for the set readings: every
868/// `.conllu` / `.conllup` beneath `dir` (a UD treebank's train /
869/// dev / test, a release's languages), named by their path
870/// relative to `dir`, in path order. Hidden entries are skipped.
871/// An empty set is an error: the directory holds no treebank.
872pub fn read_conllu_dir(dir: &std::path::Path) -> Result<Vec<(String, String)>, String> {
873    fn walk(
874        dir: &std::path::Path,
875        base: &std::path::Path,
876        out: &mut Vec<(String, String)>,
877    ) -> Result<(), String> {
878        let mut entries: Vec<std::path::PathBuf> = std::fs::read_dir(dir)
879            .map_err(|e| format!("reading {}: {e}", dir.display()))?
880            .filter_map(|e| e.ok().map(|e| e.path()))
881            .filter(|p| {
882                !p.file_name()
883                    .and_then(|n| n.to_str())
884                    .is_some_and(|n| n.starts_with('.'))
885            })
886            .collect();
887        entries.sort();
888        for p in entries {
889            if p.is_dir() {
890                walk(&p, base, out)?;
891            } else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| {
892                e.eq_ignore_ascii_case("conllu") || e.eq_ignore_ascii_case("conllup")
893            }) {
894                let text = std::fs::read_to_string(&p)
895                    .map_err(|e| format!("reading {}: {e}", p.display()))?;
896                let text = text
897                    .strip_prefix('\u{feff}')
898                    .map(str::to_owned)
899                    .unwrap_or(text);
900                let name = p
901                    .strip_prefix(base)
902                    .unwrap_or(&p)
903                    .to_string_lossy()
904                    .into_owned();
905                out.push((name, text));
906            }
907        }
908        Ok(())
909    }
910    let mut out = Vec::new();
911    walk(dir, dir, &mut out)?;
912    if out.is_empty() {
913        return Err(format!("{} holds no .conllu file", dir.display()));
914    }
915    Ok(out)
916}
917
918/// The sentences of a CoNLL-U text: token lines split on tabs,
919/// sentences separated by blank lines, comments read (`# key =
920/// value` kept on the sentence, `# newdoc` / `# newpar` as
921/// structure, `# global.*` on the file), empty nodes (`1.1`) kept,
922/// multiword ranges (`1-2`) kept as the unit that aligns to the
923/// text. A `# global.columns` line (CoNLL-U Plus) reorders the
924/// columns and names extra ones.
925fn parse_conllu(text: &str) -> Result<ConlluDoc, String> {
926    let mut doc = ConlluDoc::default();
927    let mut current = ConlluSentence::default();
928    let mut columns: Vec<String> = CONLLU_COLUMNS.iter().map(|c| c.to_string()).collect();
929    let trimmed = |s: &str| s.trim().to_string();
930    for (i, line) in text.lines().enumerate() {
931        let line = line.trim_end_matches('\r');
932        if line.trim().is_empty() {
933            if !current.tokens.is_empty() {
934                doc.sentences.push(std::mem::take(&mut current));
935            }
936            continue;
937        }
938        if let Some(comment) = line.strip_prefix('#') {
939            let comment = comment.trim();
940            if let Some(rest) = comment.strip_prefix("newdoc") {
941                let id = rest
942                    .trim()
943                    .strip_prefix("id")
944                    .and_then(|r| r.trim().strip_prefix('='));
945                current.newdoc = Some(id.map(trimmed));
946            } else if let Some(rest) = comment.strip_prefix("newpar") {
947                let id = rest
948                    .trim()
949                    .strip_prefix("id")
950                    .and_then(|r| r.trim().strip_prefix('='));
951                current.newpar = Some(id.map(trimmed));
952            } else if let Some((k, v)) = comment.split_once('=') {
953                let (k, v) = (k.trim(), v.trim());
954                if let Some(g) = k.strip_prefix("global.") {
955                    if g == "columns" {
956                        columns = v.split_whitespace().map(|c| c.to_string()).collect();
957                    }
958                    doc.globals.push((g.to_string(), v.to_string()));
959                } else {
960                    current.comments.push((k.to_string(), v.to_string()));
961                }
962            }
963            continue;
964        }
965        let f: Vec<&str> = line.split('\t').collect();
966        if f.len() < 2 {
967            return Err(format!(
968                "CoNLL-U line {}: expected tab-separated columns",
969                i + 1
970            ));
971        }
972        let mut tok = ConlluToken::default();
973        for (k, name) in columns.iter().enumerate() {
974            let v = f.get(k).copied().unwrap_or("_");
975            match name.to_ascii_uppercase().as_str() {
976                "ID" => tok.id = v.to_string(),
977                "FORM" => tok.form = v.to_string(),
978                "LEMMA" => tok.lemma = v.to_string(),
979                "UPOS" => tok.upos = v.to_string(),
980                "XPOS" => tok.xpos = v.to_string(),
981                "FEATS" => tok.feats = v.to_string(),
982                "HEAD" => tok.head = v.to_string(),
983                "DEPREL" => tok.deprel = v.to_string(),
984                "DEPS" => tok.deps = v.to_string(),
985                "MISC" => tok.misc = v.to_string(),
986                _ => {
987                    if v != "_" && !v.is_empty() {
988                        tok.extra.push((name.replace(':', "-"), v.to_string()));
989                    }
990                }
991            }
992        }
993        if tok.id.is_empty() {
994            return Err(format!("CoNLL-U line {}: no token id", i + 1));
995        }
996        if let Some((a, b)) = tok.id.split_once('-') {
997            match (a.parse::<u32>(), b.parse::<u32>()) {
998                (Ok(a), Ok(b)) if a <= b => {
999                    tok.range = Some((a, b));
1000                    tok.ord = a;
1001                }
1002                _ => {
1003                    return Err(format!(
1004                        "CoNLL-U line {}: malformed multiword range {:?}",
1005                        i + 1,
1006                        tok.id
1007                    ));
1008                }
1009            }
1010        } else if let Some((a, _)) = tok.id.split_once('.') {
1011            tok.empty = true;
1012            tok.ord = a.parse().map_err(|_| {
1013                format!(
1014                    "CoNLL-U line {}: malformed empty-node id {:?}",
1015                    i + 1,
1016                    tok.id
1017                )
1018            })?;
1019        } else {
1020            tok.ord = tok
1021                .id
1022                .parse()
1023                .map_err(|_| format!("CoNLL-U line {}: malformed token id {:?}", i + 1, tok.id))?;
1024        }
1025        current.tokens.push(tok);
1026    }
1027    if !current.tokens.is_empty() {
1028        doc.sentences.push(current);
1029    }
1030    Ok(doc)
1031}
1032
1033/// The sentence's surface text from its tokens: forms in order, a
1034/// multiword range standing for its words, a space after each
1035/// unless `SpaceAfter=No`; empty nodes are not in the text.
1036fn conllu_surface(tokens: &[ConlluToken]) -> String {
1037    let mut out = String::new();
1038    let mut covered: u32 = 0; // the last word id a range covers
1039    for t in tokens {
1040        if t.empty || (t.range.is_none() && t.ord <= covered) {
1041            continue;
1042        }
1043        if let Some((_, b)) = t.range {
1044            covered = b;
1045        }
1046        out.push_str(&t.form);
1047        let no_space = key_values(&t.misc)
1048            .iter()
1049            .any(|(k, v)| k.eq_ignore_ascii_case("SpaceAfter") && v == "No");
1050        if !no_space {
1051            out.push(' ');
1052        }
1053    }
1054    out.trim_end().to_string()
1055}
1056
1057fn class_of(seg: &str) -> TokenClass {
1058    if seg.chars().any(char::is_alphabetic) {
1059        TokenClass::Word
1060    } else if seg.chars().any(char::is_numeric) {
1061        TokenClass::Number
1062    } else {
1063        TokenClass::Punct
1064    }
1065}
1066
1067/// The class an annotation declares (ruling #65): UPOS `PUNCT` is
1068/// punctuation and `NUM` a number whatever the form's letters say
1069/// (`3rd`, a tagger's `PUNCT` on a lettered token); every other
1070/// tag, or none, leaves the form to decide.
1071fn class_of_annotated(form: &str, upos: &str) -> TokenClass {
1072    match upos {
1073        "PUNCT" => TokenClass::Punct,
1074        "NUM" => TokenClass::Number,
1075        _ => class_of(form),
1076    }
1077}
1078
1079/// Whether a token's form is a quotation mark (ruling #65): every
1080/// character one of Unicode's initial and final quotation
1081/// punctuation, the ASCII quotes, the guillemets, or the CJK
1082/// corner brackets. Such a token wears `<quote>` beside `<punct>`.
1083fn is_quote(form: &str) -> bool {
1084    !form.is_empty()
1085        && form.chars().all(|c| {
1086            matches!(
1087                c,
1088                '"' | '\'' | '`'
1089                    | '\u{AB}' | '\u{BB}'
1090                    | '\u{2018}'..='\u{201F}'
1091                    | '\u{2039}' | '\u{203A}'
1092                    | '\u{300C}'..='\u{300F}'
1093                    | '\u{301D}'..='\u{301F}'
1094                    | '\u{FF02}' | '\u{FF07}'
1095            )
1096        })
1097}
1098
1099impl Node {
1100    fn new(kind: Kind, parent: Option<NodeId>) -> Self {
1101        Node {
1102            kind,
1103            token: None,
1104            derived: false,
1105            lemma: None,
1106            hypograph: None,
1107            taxis: None,
1108            level: None,
1109            lang: None,
1110            start: 1,
1111            text: String::new(),
1112            prose: String::new(),
1113            table: false,
1114            row: false,
1115            cell: false,
1116            onym: None,
1117            deixis: false,
1118            dangling: false,
1119            family_open: false,
1120            margin: false,
1121            note_edge: None,
1122            cites: Vec::new(),
1123            target: None,
1124            locator: None,
1125            internal: false,
1126            fields: Vec::new(),
1127            genus: None,
1128            ref_edge: None,
1129            ref_cites: Vec::new(),
1130            parent,
1131            children: Vec::new(),
1132            genoses: Vec::new(),
1133            span_kind: None,
1134            at: None,
1135            span_tokens: Vec::new(),
1136            character: None,
1137            place: None,
1138            mentions: Vec::new(),
1139            declared_tokens: Vec::new(),
1140            declared_sentences: Vec::new(),
1141        }
1142    }
1143}
1144
1145/// The reference pass: collect every borne name (block labels
1146/// and point anchors — deixis callouts pair within their own
1147/// families and stay out of this namespace), then land each
1148/// internal ref on its bearer, `<dangling>` when nothing bears
1149/// the name. External refs resolve at query time, through the
1150/// engine's reference machinery.
1151fn resolve_refs(nodes: &mut [Node]) -> std::collections::HashMap<String, NodeId> {
1152    let mut onyms: std::collections::HashMap<String, NodeId> = std::collections::HashMap::new();
1153    // Citation keys are a namespace of their own, separate from
1154    // labels (LaTeX's \bibcite vs \newlabel precedent): `cit`
1155    // resolves against `bib` bearers only.
1156    let mut bibs: std::collections::HashMap<String, NodeId> = std::collections::HashMap::new();
1157    for (i, n) in nodes.iter().enumerate() {
1158        if n.deixis || matches!(n.kind, Kind::IndexMark | Kind::Ref | Kind::Cit) {
1159            continue;
1160        }
1161        if n.kind == Kind::Bib {
1162            if let Some(k) = &n.onym {
1163                bibs.entry(k.clone()).or_insert(NodeId(i as u64));
1164            }
1165            continue;
1166        }
1167        let bearer = n.kind == Kind::Anchor
1168            || (!matches!(n.kind, Kind::Footnote | Kind::Endnote | Kind::Aside)
1169                && n.onym.is_some());
1170        if bearer && let Some(o) = &n.onym {
1171            onyms.entry(o.clone()).or_insert(NodeId(i as u64));
1172        }
1173    }
1174    for i in 0..nodes.len() {
1175        let (map, key) = match nodes[i].kind {
1176            Kind::Ref if nodes[i].internal => (
1177                &onyms,
1178                nodes[i]
1179                    .target
1180                    .as_deref()
1181                    .map(|t| t.trim_start_matches('#').to_string())
1182                    .unwrap_or_default(),
1183            ),
1184            Kind::Cit => (&bibs, nodes[i].target.clone().unwrap_or_default()),
1185            _ => continue,
1186        };
1187        match map.get(&key) {
1188            Some(&bearer) => {
1189                nodes[i].ref_edge = Some(bearer);
1190                let me = NodeId(i as u64);
1191                nodes[bearer.0 as usize].ref_cites.push(me);
1192            }
1193            None => nodes[i].dangling = true,
1194        }
1195    }
1196    onyms
1197}
1198
1199/// Collapse whitespace runs to single spaces and trim — the prose
1200/// normalization adapters apply to inline content. Verbatim text
1201/// is the exception: it is kept as authored.
1202pub fn normalize_ws(s: &str) -> String {
1203    s.split_whitespace().collect::<Vec<_>>().join(" ")
1204}
1205
1206/// What a document declares about itself, in a standard
1207/// vocabulary its format provides — HTML's `<head>` (the `meta`
1208/// name/property pairs, `title`, the `lang` attribute, the
1209/// canonical link, `rel="tag"` links) and a schema.org JSON-LD
1210/// block. Two layers: every declaration lands verbatim under
1211/// `::::name` (`::::description`, `::::og:type`,
1212/// `::::article:section`, `::::schema:@type`), so nothing a page
1213/// says is lost; and the standard vocabularies feed a curated
1214/// core — the tags (`keywords`, `article:tag`, `rel=tag`, JSON-LD
1215/// `keywords`) become traits on the document and `::tags`, the
1216/// one classification (`article:section` / `articleSection`)
1217/// becomes `::category` and a trait too, `description`, `author`,
1218/// and the `published` / `modified` instants. Declared identity
1219/// only: a format adapter reads a cited vocabulary, never a class
1220/// name or a layout.
1221#[derive(Debug, Clone, Default)]
1222pub struct HeadMeta {
1223    /// Every declaration, name → values, in document order; a
1224    /// repeated name accumulates.
1225    pub declared: Vec<(String, Vec<String>)>,
1226    pub title: Option<String>,
1227    pub description: Option<String>,
1228    pub author: Option<String>,
1229    /// The one classification (a section of the site, a category).
1230    pub category: Option<String>,
1231    /// The tags, as written, first appearance wins.
1232    pub tags: Vec<String>,
1233    pub published: Option<Value>,
1234    pub modified: Option<Value>,
1235}
1236
1237impl HeadMeta {
1238    /// Record a declaration verbatim (the lossless layer).
1239    pub fn declare(&mut self, name: &str, value: &str) {
1240        let value = normalize_ws(value);
1241        if name.is_empty() || value.is_empty() {
1242            return;
1243        }
1244        match self.declared.iter_mut().find(|(k, _)| k == name) {
1245            Some((_, vs)) => vs.push(value),
1246            None => self.declared.push((name.to_string(), vec![value])),
1247        }
1248    }
1249
1250    /// Add a tag unless already present (case-insensitively).
1251    pub fn tag(&mut self, tag: &str) {
1252        let tag = normalize_ws(tag);
1253        if tag.is_empty() || self.tags.iter().any(|t| t.eq_ignore_ascii_case(&tag)) {
1254            return;
1255        }
1256        self.tags.push(tag);
1257    }
1258
1259    /// A declaration's value: the string when declared once, the
1260    /// list when repeated.
1261    pub fn declared_value(&self, name: &str) -> Option<Value> {
1262        let (_, vs) = self.declared.iter().find(|(k, _)| k == name)?;
1263        Some(match vs.as_slice() {
1264            [one] => Value::Str(one.clone()),
1265            many => Value::list(many.iter().cloned().map(Value::Str).collect()),
1266        })
1267    }
1268
1269    /// The trait spelling of a tag or category: as declared, case
1270    /// kept, with runs of anything a name cannot carry (spaces,
1271    /// slashes, parentheses) collapsed to one dash — `Execution
1272    /// Model` is `<tag:Execution-Model>`, `now()` is `<tag:now>`.
1273    pub fn trait_name(s: &str) -> String {
1274        let mut out = String::new();
1275        let mut dash = false;
1276        for c in s.chars() {
1277            if c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '+') {
1278                out.push(c);
1279                dash = c == '-';
1280            } else if !dash && !out.is_empty() {
1281                out.push('-');
1282                dash = true;
1283            }
1284        }
1285        while out.ends_with('-') {
1286            out.pop();
1287        }
1288        out
1289    }
1290}
1291
1292/// A Quarb adapter over a text-level document.
1293pub struct TextModel {
1294    nodes: Vec<Node>,
1295    root: NodeId,
1296    /// Every borne name → its bearer (block labels and point
1297    /// anchors, one namespace, first bearer in document order
1298    /// wins) — the landing map for `-->` and for a sibling
1299    /// document's `#fragment`.
1300    onyms: std::collections::HashMap<String, NodeId>,
1301    /// The document's own URL, when the mount knows it.
1302    document_url: Option<url::Url>,
1303    /// The base relative reference targets join against when the
1304    /// document declares one (html's `<base href>`); else the
1305    /// document's own URL.
1306    link_base: Option<url::Url>,
1307    /// Where the document is served from when no absolute URL is
1308    /// known — the mount's path, an archive member's path — the
1309    /// base `::href` links against.
1310    document_path: Option<String>,
1311    /// The citation scheme `::cite` answers under when the
1312    /// document carries several (`?scheme=`); else the first
1313    /// milestone's (ruling #81).
1314    cite_scheme: Option<String>,
1315    /// The spelling table the corpus reading folds through
1316    /// (`?modernize=`, ruling #82): `::lower` and `::modern` read
1317    /// the modern spelling of a historical edition's tokens; `::`
1318    /// stays the edition's own.
1319    orthography: Option<String>,
1320    /// Alias → canonical campus, for bib field lookup: the
1321    /// bibliogramma vocabulary's census (BibLaTeX's field names
1322    /// are its English rows), so `::author` answers as
1323    /// `::auctor` — in any covered language. Set by the koine
1324    /// route, which holds the dialektos.
1325    bib_aliases: std::collections::HashMap<String, String>,
1326    /// What the document declares about itself (see [`HeadMeta`]);
1327    /// answered on the document node.
1328    head: HeadMeta,
1329    /// The corpus reading's positional index: case-folded form →
1330    /// the tokens bearing it, in document order. Built on first
1331    /// use, once the tokens exist; answers `//token[::lower = "x"]`
1332    /// through `descendants_where` without a walk.
1333    token_index: std::sync::OnceLock<std::collections::HashMap<String, Vec<NodeId>>>,
1334    /// The mentions an annotation marked (ruling #63), in document
1335    /// order of their first token; tokens point in by ordinal.
1336    mentions: Vec<Mention>,
1337}
1338
1339impl TextModel {
1340    /// Assemble the document tree from a adapter's event stream.
1341    ///
1342    /// Iterative throughout (the stream is flat; prose flattening
1343    /// runs over indices), so pathological nesting cannot overflow
1344    /// the call stack. Lenient on malformed streams: a stray
1345    /// `Close` is ignored, unclosed containers close at the end.
1346    pub fn build(blocks: Vec<Block>) -> Self {
1347        let mut nodes = vec![Node::new(Kind::Document, None)];
1348        let root = NodeId(0);
1349        // Innermost-last stack of open *sections* (outline-derived).
1350        let mut sections: Vec<NodeId> = Vec::new();
1351        // Innermost-last stack of open explicit containers.
1352        let mut containers: Vec<NodeId> = Vec::new();
1353        // The block a NoteRef callout attaches to: the last flow
1354        // node created or appended to (ruling #35).
1355        let mut last_flow: Option<NodeId> = None;
1356        // The block a Label names: the most recently opened block
1357        // of any kind — a heading's section, a paragraph, a
1358        // container, a verbatim — the html-id / attached-\label
1359        // parity rule.
1360        let mut last_block: Option<NodeId> = None;
1361
1362        for block in blocks {
1363            match block {
1364                Block::Heading { level, lemma } => {
1365                    let lemma = normalize_ws(&lemma);
1366                    if !containers.is_empty() {
1367                        // Decorative heading inside a container:
1368                        // not sectioning — lower to a paragraph.
1369                        if !lemma.is_empty() {
1370                            let parent = *containers.last().unwrap();
1371                            let id = push(&mut nodes, Kind::Paragraph, parent);
1372                            nodes[id.0 as usize].text = lemma;
1373                        }
1374                        continue;
1375                    }
1376                    while let Some(&open) = sections.last() {
1377                        if nodes[open.0 as usize].level >= Some(level) {
1378                            sections.pop();
1379                        } else {
1380                            break;
1381                        }
1382                    }
1383                    let parent = sections.last().copied().unwrap_or(root);
1384                    let id = push(&mut nodes, Kind::Section, parent);
1385                    let n = &mut nodes[id.0 as usize];
1386                    n.lemma = Some(lemma);
1387                    n.level = Some(level);
1388                    sections.push(id);
1389                    last_flow = Some(id);
1390                    last_block = Some(id);
1391                }
1392                Block::Paragraph { text } => {
1393                    let text = normalize_ws(&text);
1394                    if text.is_empty() {
1395                        continue;
1396                    }
1397                    let parent = cursor(&sections, &containers, root);
1398                    let id = push(&mut nodes, Kind::Paragraph, parent);
1399                    nodes[id.0 as usize].text = text;
1400                    last_flow = Some(id);
1401                    last_block = Some(id);
1402                }
1403                // A speech is a paragraph that knows its speaker.
1404                Block::Dialogue { lemma, text, lit } => {
1405                    let text = normalize_ws(&text);
1406                    if text.is_empty() {
1407                        continue;
1408                    }
1409                    let parent = cursor(&sections, &containers, root);
1410                    let kind = if lit { Kind::Dialogue } else { Kind::Paragraph };
1411                    let id = push(&mut nodes, kind, parent);
1412                    let n = &mut nodes[id.0 as usize];
1413                    n.text = text;
1414                    n.lemma = Some(normalize_ws(&lemma)).filter(|l| !l.is_empty());
1415                    last_flow = Some(id);
1416                    last_block = Some(id);
1417                }
1418                // The literary reading's speech block.
1419                Block::Speech {
1420                    text,
1421                    genoses,
1422                    fields,
1423                } => {
1424                    let text = normalize_ws(&text);
1425                    if text.is_empty() {
1426                        continue;
1427                    }
1428                    let parent = cursor(&sections, &containers, root);
1429                    let id = push(&mut nodes, Kind::Speech, parent);
1430                    let n = &mut nodes[id.0 as usize];
1431                    n.text = text;
1432                    n.genoses = genoses;
1433                    n.fields = fields;
1434                    last_flow = Some(id);
1435                    last_block = Some(id);
1436                }
1437                // An inline span of the last flow block, nested
1438                // under the innermost earlier span that contains it.
1439                Block::Span {
1440                    kind,
1441                    lo,
1442                    hi,
1443                    genoses,
1444                    fields,
1445                } => {
1446                    let Some(flow) = last_flow else { continue };
1447                    let text = nodes[flow.0 as usize]
1448                        .text
1449                        .get(lo as usize..hi as usize)
1450                        .unwrap_or("")
1451                        .to_string();
1452                    let parent = enclosing_span(&nodes, flow, lo, hi).unwrap_or(flow);
1453                    let id = push(&mut nodes, Kind::Span, parent);
1454                    let n = &mut nodes[id.0 as usize];
1455                    n.span_kind = Some(kind);
1456                    n.at = Some((lo, hi));
1457                    n.text = text;
1458                    n.genoses = genoses;
1459                    n.fields = fields;
1460                }
1461                Block::Character {
1462                    lemma,
1463                    onym,
1464                    text,
1465                    genoses,
1466                } => {
1467                    let parent = cursor(&sections, &containers, root);
1468                    let id = push(&mut nodes, Kind::Character, parent);
1469                    let n = &mut nodes[id.0 as usize];
1470                    n.lemma = Some(normalize_ws(&lemma)).filter(|l| !l.is_empty());
1471                    n.onym = onym;
1472                    n.text = normalize_ws(&text);
1473                    n.genoses = genoses;
1474                    last_block = Some(id);
1475                }
1476                Block::Parsed {
1477                    lo,
1478                    hi,
1479                    onym,
1480                    parsings,
1481                } => {
1482                    let Some(flow) = last_flow else { continue };
1483                    nodes[flow.0 as usize].declared_tokens.push(DeclaredToken {
1484                        lo,
1485                        hi,
1486                        onym,
1487                        parsings,
1488                    });
1489                }
1490                Block::Periodos { lo, hi, id } => {
1491                    let Some(flow) = last_flow else { continue };
1492                    nodes[flow.0 as usize].declared_sentences.push((lo, hi, id));
1493                }
1494                Block::Milestone { scheme, value, at } => {
1495                    let Some(flow) = last_flow else { continue };
1496                    // `u32::MAX`: the milestone stood in a block with
1497                    // no prose of its own — the end of the flow
1498                    // block before it.
1499                    let at = if at == u32::MAX {
1500                        nodes[flow.0 as usize].text.len() as u32
1501                    } else {
1502                        at
1503                    };
1504                    let parent = enclosing_span(&nodes, flow, at, at).unwrap_or(flow);
1505                    let id = push(&mut nodes, Kind::Milestone, parent);
1506                    let n = &mut nodes[id.0 as usize];
1507                    n.at = Some((at, at));
1508                    n.fields = vec![("scheme".to_string(), scheme), ("value".to_string(), value)];
1509                }
1510                Block::Annotate { fields } => {
1511                    if let Some(b) = last_block {
1512                        nodes[b.0 as usize].fields.extend(fields);
1513                    }
1514                }
1515                Block::Wear { genoses } => {
1516                    if let Some(b) = last_block {
1517                        let own = &mut nodes[b.0 as usize].genoses;
1518                        for g in genoses {
1519                            if !own.contains(&g) {
1520                                own.push(g);
1521                            }
1522                        }
1523                    }
1524                }
1525                Block::Text { text } => {
1526                    let text = normalize_ws(&text);
1527                    if text.is_empty() {
1528                        continue;
1529                    }
1530                    match containers.last() {
1531                        Some(&open) => {
1532                            let own = &mut nodes[open.0 as usize].text;
1533                            if !own.is_empty() {
1534                                own.push(' ');
1535                            }
1536                            own.push_str(&text);
1537                            last_flow = Some(open);
1538                        }
1539                        None => {
1540                            let parent = sections.last().copied().unwrap_or(root);
1541                            let id = push(&mut nodes, Kind::Paragraph, parent);
1542                            nodes[id.0 as usize].text = text;
1543                            last_flow = Some(id);
1544                        }
1545                    }
1546                }
1547                Block::Open { kind, lemma } => {
1548                    // A note body opens at the document root —
1549                    // litogramma's canonical document-end
1550                    // placement — whatever else is open.
1551                    let kind = match kind {
1552                        Container::Note {
1553                            onym,
1554                            family,
1555                            margin,
1556                        } => {
1557                            let id = push(&mut nodes, family.kind(), root);
1558                            let n = &mut nodes[id.0 as usize];
1559                            n.onym = Some(onym.trim().to_string()).filter(|o| !o.is_empty());
1560                            n.margin = margin;
1561                            containers.push(id);
1562                            last_flow = Some(id);
1563                            continue;
1564                        }
1565                        other => other,
1566                    };
1567                    let parent = cursor(&sections, &containers, root);
1568                    let (nkind, start) = match kind {
1569                        Container::Blockquote => (Kind::Blockquote, None),
1570                        Container::UnorderedList => (Kind::UnorderedList, None),
1571                        Container::OrderedList { start } => (Kind::OrderedList, Some(start)),
1572                        Container::Item => (
1573                            match nodes[parent.0 as usize].kind {
1574                                Kind::OrderedList => Kind::OrderedItem,
1575                                _ => Kind::UnorderedItem,
1576                            },
1577                            None,
1578                        ),
1579                        Container::Note { .. } => unreachable!("handled above"),
1580                    };
1581                    let id = push(&mut nodes, nkind, parent);
1582                    last_block = Some(id);
1583                    nodes[id.0 as usize].lemma =
1584                        lemma.map(|l| normalize_ws(&l)).filter(|l| !l.is_empty());
1585                    if let Some(start) = start {
1586                        nodes[id.0 as usize].start = start;
1587                    }
1588                    if nkind == Kind::OrderedItem {
1589                        // `push` already appended this item, so the
1590                        // count includes it.
1591                        let nth = nodes[parent.0 as usize]
1592                            .children
1593                            .iter()
1594                            .filter(|&&c| nodes[c.0 as usize].kind == Kind::OrderedItem)
1595                            .count() as i64;
1596                        let start = nodes[parent.0 as usize].start;
1597                        nodes[id.0 as usize].taxis = Some(start + nth - 1);
1598                    }
1599                    containers.push(id);
1600                }
1601                Block::Close { hypograph } => {
1602                    if let Some(open) = containers.pop() {
1603                        nodes[open.0 as usize].hypograph = hypograph
1604                            .map(|h| normalize_ws(&h))
1605                            .filter(|h| !h.is_empty());
1606                    }
1607                }
1608                // The callout: a `footnote` child of the flow
1609                // block it sits in, `<deixis>`-traited; the edge
1610                // to its body resolves after the stream.
1611                Block::NoteRef {
1612                    onym,
1613                    family,
1614                    margin,
1615                    at,
1616                } => {
1617                    let parent = last_flow.unwrap_or(root);
1618                    // A declared family names the callout now; an
1619                    // undeclared one is settled at resolution from
1620                    // the body it reaches (footnote when dangling).
1621                    let kind = family.map(NoteFamily::kind).unwrap_or(Kind::Footnote);
1622                    let id = push(&mut nodes, kind, parent);
1623                    let n = &mut nodes[id.0 as usize];
1624                    n.deixis = true;
1625                    n.family_open = family.is_none();
1626                    n.margin = margin;
1627                    n.at = at.map(|a| (a, a));
1628                    n.onym = Some(onym.trim().to_string()).filter(|o| !o.is_empty());
1629                }
1630                Block::Ref {
1631                    target,
1632                    text,
1633                    internal,
1634                } => {
1635                    let parent = last_flow.unwrap_or(root);
1636                    let id = push(&mut nodes, Kind::Ref, parent);
1637                    let n = &mut nodes[id.0 as usize];
1638                    n.internal = internal;
1639                    n.target = Some(target.trim().to_string()).filter(|t| !t.is_empty());
1640                    n.text = text.map(|t| normalize_ws(&t)).unwrap_or_default();
1641                }
1642                Block::Cite { target, locator } => {
1643                    let parent = last_flow.unwrap_or(root);
1644                    let id = push(&mut nodes, Kind::Cit, parent);
1645                    let n = &mut nodes[id.0 as usize];
1646                    n.internal = true;
1647                    n.target = Some(target.trim().to_string()).filter(|t| !t.is_empty());
1648                    n.locator = locator
1649                        .map(|l| l.trim().to_string())
1650                        .filter(|l| !l.is_empty());
1651                }
1652                Block::Bib {
1653                    key,
1654                    text,
1655                    fields,
1656                    genus,
1657                } => {
1658                    let parent = cursor(&sections, &containers, root);
1659                    let id = push(&mut nodes, Kind::Bib, parent);
1660                    last_block = Some(id);
1661                    let n = &mut nodes[id.0 as usize];
1662                    n.onym = Some(key.trim().to_string()).filter(|k| !k.is_empty());
1663                    n.text = normalize_ws(&text);
1664                    n.fields = fields
1665                        .into_iter()
1666                        .map(|(k, v)| (k.trim().to_string(), normalize_ws(&v)))
1667                        .filter(|(k, v)| !k.is_empty() && !v.is_empty())
1668                        .collect();
1669                    n.genus = genus
1670                        .map(|g| g.trim().to_string())
1671                        .filter(|g| !g.is_empty());
1672                }
1673                Block::Anchor { onym } => {
1674                    let parent = last_flow.unwrap_or(root);
1675                    let id = push(&mut nodes, Kind::Anchor, parent);
1676                    nodes[id.0 as usize].onym =
1677                        Some(onym.trim().to_string()).filter(|o| !o.is_empty());
1678                }
1679                Block::Label { onym } => {
1680                    let onym = onym.trim().to_string();
1681                    if onym.is_empty() {
1682                        continue;
1683                    }
1684                    // The most recently opened block bears the
1685                    // name — a heading's section, a paragraph, a
1686                    // blockquote: the html-id / attached-\label
1687                    // parity rule. A label before any block, or on
1688                    // a block already named, degrades to a point.
1689                    let free = last_block
1690                        .map(|b| nodes[b.0 as usize].onym.is_none())
1691                        .unwrap_or(false);
1692                    match (last_block, free) {
1693                        (Some(b), true) => {
1694                            nodes[b.0 as usize].onym = Some(onym);
1695                        }
1696                        _ => {
1697                            let parent = last_flow.unwrap_or(root);
1698                            let id = push(&mut nodes, Kind::Anchor, parent);
1699                            nodes[id.0 as usize].onym = Some(onym);
1700                        }
1701                    }
1702                }
1703                Block::IndexMark { term } => {
1704                    let parent = last_flow.unwrap_or(root);
1705                    let id = push(&mut nodes, Kind::IndexMark, parent);
1706                    nodes[id.0 as usize].onym = Some(quarb_term(&term)).filter(|t| !t.is_empty());
1707                }
1708                Block::Verbatim { lang, text } => {
1709                    let parent = cursor(&sections, &containers, root);
1710                    let id = push(&mut nodes, Kind::Verbatim, parent);
1711                    last_block = Some(id);
1712                    let n = &mut nodes[id.0 as usize];
1713                    n.lang = lang.filter(|l| !l.is_empty());
1714                    n.text = text;
1715                }
1716                Block::Table {
1717                    lemma,
1718                    headers,
1719                    rows,
1720                } => {
1721                    let parent = cursor(&sections, &containers, root);
1722                    lower_table(&mut nodes, parent, lemma, headers, rows);
1723                }
1724                Block::Verse {
1725                    lemma,
1726                    strophes,
1727                    hypograph,
1728                } => {
1729                    let parent = cursor(&sections, &containers, root);
1730                    let id = lower_verse(&mut nodes, parent, lemma, strophes, hypograph);
1731                    last_flow = Some(id);
1732                    last_block = Some(id);
1733                }
1734            }
1735        }
1736
1737        resolve_notes(&mut nodes);
1738        let onyms = resolve_refs(&mut nodes);
1739        flatten_prose(&mut nodes);
1740        let mut model = TextModel {
1741            nodes,
1742            root,
1743            onyms,
1744            document_url: None,
1745            link_base: None,
1746            document_path: None,
1747            cite_scheme: None,
1748            orthography: None,
1749            bib_aliases: Default::default(),
1750            head: HeadMeta::default(),
1751            token_index: Default::default(),
1752            mentions: Vec::new(),
1753        };
1754        model.link_characters();
1755        model
1756    }
1757
1758    /// Register the bib-field alias census (alias → canonical
1759    /// campus), lowercased keys — the friction remover: a field
1760    /// authored or queried under any covered name answers.
1761    pub fn set_bib_aliases(&mut self, map: std::collections::HashMap<String, String>) {
1762        self.bib_aliases = map;
1763    }
1764
1765    /// Declare the document's own URL — the base a relative ref
1766    /// target joins against when `-->` reaches across documents.
1767    pub fn set_document_url(&mut self, url: &str) {
1768        self.document_url = url::Url::parse(url).ok();
1769    }
1770
1771    /// Declare the base relative reference targets join against
1772    /// — html's `<base href>` — when it differs from the
1773    /// document's own URL.
1774    pub fn set_link_base(&mut self, url: &str) {
1775        self.link_base = url::Url::parse(url).ok();
1776    }
1777
1778    /// Declare where the document is served from when it declares
1779    /// no absolute URL of its own: the base `::href` links against
1780    /// (a mount path, an archive member's path).
1781    pub fn set_document_path(&mut self, path: &str) {
1782        self.document_path = Some(path.to_string());
1783    }
1784
1785    /// The scheme `::cite` reads (ruling #81): the mount's
1786    /// `?scheme=`.
1787    pub fn set_cite_scheme(&mut self, scheme: &str) {
1788        self.cite_scheme = Some(scheme.to_string());
1789    }
1790
1791    /// The spelling table the tokens fold through (ruling #82):
1792    /// the mount's `?modernize=`. An unknown table is an error.
1793    pub fn set_orthography(&mut self, table: &str) -> Result<(), String> {
1794        quarb::translit::modernize("", table)?;
1795        self.orthography = Some(table.to_string());
1796        Ok(())
1797    }
1798
1799    /// A token's modern spelling: the form through the mount's
1800    /// spelling table, else the form itself.
1801    fn modern(&self, form: &str) -> String {
1802        match &self.orthography {
1803            Some(t) => quarb::translit::modernize(form, t).unwrap_or_else(|_| form.to_string()),
1804            None => form.to_string(),
1805        }
1806    }
1807
1808    /// A token's `::lower`: the modern spelling, case-folded.
1809    fn lower(&self, form: &str) -> String {
1810        self.modern(form).to_lowercase()
1811    }
1812
1813    /// The document's milestones under the citation scheme, in
1814    /// document order: (flow block, offset, milestone).
1815    fn cite_table(&self) -> Vec<(NodeId, u32, NodeId)> {
1816        let mut scheme = self.cite_scheme.clone();
1817        let mut out = Vec::new();
1818        for i in 0..self.nodes.len() {
1819            let n = &self.nodes[i];
1820            if n.kind != Kind::Milestone {
1821                continue;
1822            }
1823            let this = n
1824                .fields
1825                .iter()
1826                .find(|(k, _)| k == "scheme")
1827                .map(|(_, v)| v.clone());
1828            let Some(this) = this else { continue };
1829            match &scheme {
1830                None => scheme = Some(this),
1831                Some(s) if *s != this => continue,
1832                _ => {}
1833            }
1834            let at = n.at.map(|(a, _)| a).unwrap_or(0);
1835            let mut block = NodeId(i as u64);
1836            while self.nodes[block.0 as usize].kind == Kind::Span
1837                || self.nodes[block.0 as usize].kind == Kind::Milestone
1838            {
1839                match self.nodes[block.0 as usize].parent {
1840                    Some(p) => block = p,
1841                    None => break,
1842                }
1843            }
1844            out.push((block, at, NodeId(i as u64)));
1845        }
1846        out.sort_by_key(|(b, at, _)| (b.0, *at));
1847        out
1848    }
1849
1850    /// The citation in force at a node (ruling #81): the value of
1851    /// the last milestone under the citation scheme at or before
1852    /// the node's point in the document — a token's or a span's
1853    /// offset in its block, a sentence's first token, a flow
1854    /// block's start; a container (a section, the document) cites
1855    /// as the first milestone inside it. Null where no milestone
1856    /// governs.
1857    fn cite(&self, node: NodeId) -> Option<String> {
1858        let table = self.cite_table();
1859        if table.is_empty() {
1860            return self.structural_cite(node);
1861        }
1862        let n = &self.nodes[node.0 as usize];
1863        let value = |m: NodeId| {
1864            self.nodes[m.0 as usize]
1865                .fields
1866                .iter()
1867                .find(|(k, _)| k == "value")
1868                .map(|(_, v)| v.clone())
1869        };
1870        // Containers: the first milestone beneath.
1871        let container = matches!(n.kind, Kind::Document | Kind::Section)
1872            || (n.kind != Kind::Span
1873                && n.kind != Kind::Milestone
1874                && n.token.is_none()
1875                && n.kind != Kind::Sentence
1876                && !self.prose_blocks().contains(&node)
1877                && !n.children.is_empty()
1878                && n.prose.is_empty());
1879        if container {
1880            let inside = |mut b: NodeId| {
1881                loop {
1882                    if b == node {
1883                        return true;
1884                    }
1885                    match self.nodes[b.0 as usize].parent {
1886                        Some(p) => b = p,
1887                        None => return false,
1888                    }
1889                }
1890            };
1891            return table
1892                .iter()
1893                .find(|(b, _, _)| inside(*b))
1894                .and_then(|(_, _, m)| value(*m));
1895        }
1896        // A note body, and everything in it, cites as its callout.
1897        let body = if matches!(n.kind, Kind::Footnote | Kind::Endnote | Kind::Aside) && !n.deixis {
1898            Some(node)
1899        } else {
1900            self.note_body_of(node)
1901        };
1902        if let Some(body) = body {
1903            let callout = (0..self.nodes.len())
1904                .find(|&i| self.nodes[i].deixis && self.nodes[i].note_edge == Some(body))
1905                .map(|i| NodeId(i as u64));
1906            return callout.and_then(|c| self.cite(c));
1907        }
1908        // Everything else: its point in the document.
1909        let (block, at) = match n.kind {
1910            _ if n.deixis && n.at.is_some() => {
1911                (n.parent.unwrap_or(node), n.at.map(|(a, _)| a).unwrap_or(0))
1912            }
1913            Kind::Milestone | Kind::Span => {
1914                let mut b = node;
1915                while matches!(self.nodes[b.0 as usize].kind, Kind::Span | Kind::Milestone) {
1916                    match self.nodes[b.0 as usize].parent {
1917                        Some(p) => b = p,
1918                        None => break,
1919                    }
1920                }
1921                (b, n.at.map(|(a, _)| a).unwrap_or(0))
1922            }
1923            _ if n.token.is_some() => {
1924                let mut b = node;
1925                while self.nodes[b.0 as usize].token.is_some()
1926                    || self.nodes[b.0 as usize].kind == Kind::Sentence
1927                {
1928                    match self.nodes[b.0 as usize].parent {
1929                        Some(p) => b = p,
1930                        None => break,
1931                    }
1932                }
1933                (b, n.token.as_ref().map(|t| t.at).unwrap_or(0))
1934            }
1935            Kind::Sentence if n.derived => {
1936                let mut toks = Vec::new();
1937                self.collect_tokens(node, &mut toks);
1938                let at = toks
1939                    .first()
1940                    .and_then(|t| self.nodes[t.0 as usize].token.as_ref().map(|t| t.at))
1941                    .unwrap_or(0);
1942                (n.parent.unwrap_or(node), at)
1943            }
1944            _ => (node, 0),
1945        };
1946        table
1947            .iter()
1948            .rev()
1949            .find(|(b, a, _)| (b.0, *a) <= (block.0, at))
1950            .and_then(|(_, _, m)| value(*m))
1951            .or_else(|| self.structural_cite(node))
1952    }
1953
1954    /// The citation a text without milestones still has: the
1955    /// enclosing sections' names from the outermost in, joined by
1956    /// " / " — a chapter, an act and its scene. Null at the root.
1957    fn structural_cite(&self, node: NodeId) -> Option<String> {
1958        let mut names: Vec<String> = Vec::new();
1959        let mut cur = Some(node);
1960        while let Some(n) = cur {
1961            let node = &self.nodes[n.0 as usize];
1962            if node.kind == Kind::Section
1963                && let Some(l) = &node.lemma
1964                && !l.is_empty()
1965            {
1966                names.push(l.clone());
1967            }
1968            cur = node.parent;
1969        }
1970        if names.is_empty() {
1971            return None;
1972        }
1973        names.reverse();
1974        Some(names.join(" / "))
1975    }
1976
1977    /// The base `::href` links against: the document's URL, else
1978    /// its declared path.
1979    fn href_base(&self) -> Option<String> {
1980        if let Some(u) = &self.document_url {
1981            return Some(u.to_string());
1982        }
1983        self.document_path.clone()
1984    }
1985
1986    /// The nearest bearer of a name, the node itself first, then
1987    /// its ancestors — the anchor a link into this node lands on.
1988    /// `exact` says the node bears it itself.
1989    fn nearest_onym(&self, node: NodeId) -> Option<(String, bool)> {
1990        let mut cur = Some(node);
1991        let mut exact = true;
1992        while let Some(id) = cur {
1993            let n = &self.nodes[id.0 as usize];
1994            if n.kind != Kind::Document
1995                && !n.deixis
1996                && let Some(o) = &n.onym
1997            {
1998                return Some((o.clone(), exact));
1999            }
2000            exact = false;
2001            cur = n.parent;
2002        }
2003        None
2004    }
2005
2006    /// A link into `node`, as far as the served form can narrow
2007    /// it. The base is the document's URL (its canonical link,
2008    /// else the mount's path). An HTML page — a base ending in
2009    /// `.html` / `.htm` / `/` or without an extension — narrows
2010    /// with the nearest anchor and, for a block below the anchored
2011    /// one, a text fragment (`#:~:text=start,end`) built from the
2012    /// node's own prose. A PDF narrows to nothing yet; any other
2013    /// form links the document alone, since no browser takes a
2014    /// fragment into it. The narrowing stays available as data
2015    /// beside the link (`::anchor`, the prose) for a client that
2016    /// renders the document itself.
2017    fn href(&self, node: NodeId) -> Option<String> {
2018        let base = self.href_base()?;
2019        let n = &self.nodes[node.0 as usize];
2020        if n.kind == Kind::Document {
2021            return Some(base);
2022        }
2023        let served = base.split(['?', '#']).next().unwrap_or(&base);
2024        let last = served.rsplit('/').next().unwrap_or(served);
2025        let html = served.ends_with('/')
2026            || !last.contains('.')
2027            || last.ends_with(".html")
2028            || last.ends_with(".htm");
2029        if !html {
2030            return Some(base);
2031        }
2032        let mut out = base;
2033        let (anchor, exact) = match self.nearest_onym(node) {
2034            Some((a, exact)) => (Some(a), exact),
2035            None => (None, false),
2036        };
2037        if let Some(a) = &anchor {
2038            out.push('#');
2039            out.push_str(a);
2040        }
2041        if !exact {
2042            let words: Vec<&str> = n.prose.split_whitespace().collect();
2043            if !words.is_empty() {
2044                use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
2045                let enc =
2046                    |ws: &[&str]| utf8_percent_encode(&ws.join(" "), NON_ALPHANUMERIC).to_string();
2047                if anchor.is_none() {
2048                    out.push('#');
2049                }
2050                out.push_str(":~:text=");
2051                if words.len() <= 12 {
2052                    out.push_str(&enc(&words));
2053                } else {
2054                    out.push_str(&enc(&words[..6]));
2055                    out.push(',');
2056                    out.push_str(&enc(&words[words.len() - 6..]));
2057                }
2058            }
2059        }
2060        Some(out)
2061    }
2062
2063    /// The document's title for a link: what its head declares,
2064    /// else its first top-level section's lemma.
2065    fn document_title(&self) -> Option<String> {
2066        if let Some(t) = &self.head.title {
2067            return Some(t.clone());
2068        }
2069        self.nodes[self.root.0 as usize]
2070            .children
2071            .iter()
2072            .map(|c| &self.nodes[c.0 as usize])
2073            .find(|n| n.kind == Kind::Section)
2074            .and_then(|n| n.lemma.clone())
2075    }
2076
2077    /// Attach what the document declares about itself; the format
2078    /// adapter reads it from the source's own metadata vocabulary.
2079    pub fn set_head_meta(&mut self, head: HeadMeta) {
2080        self.head = head;
2081    }
2082
2083    /// What the document declares about itself.
2084    pub fn head_meta(&self) -> &HeadMeta {
2085        &self.head
2086    }
2087
2088    /// The document's text as one string: every section's lemma
2089    /// and every block's prose, one per line, in document order —
2090    /// the lowering a substrate stores beside the document so a
2091    /// substring predicate inside the graft can be prefiltered on
2092    /// it. The invariant a prefilter relies on: every grafted
2093    /// node's prose (and every lemma) is a substring of this.
2094    pub fn plain_text(&self) -> String {
2095        let mut out = String::new();
2096        let mut stack = vec![self.root];
2097        while let Some(id) = stack.pop() {
2098            let n = &self.nodes[id.0 as usize];
2099            if let Some(l) = &n.lemma
2100                && !l.is_empty()
2101            {
2102                out.push_str(l);
2103                out.push('\n');
2104            }
2105            // Every node's own prose, leaf or not: a paragraph that
2106            // holds a ref or an anchor still has prose of its own,
2107            // and the invariant covers it too.
2108            if !n.prose.is_empty() && n.kind != Kind::Document {
2109                out.push_str(&n.prose);
2110                out.push('\n');
2111            }
2112            for c in n.children.iter().rev() {
2113                stack.push(*c);
2114            }
2115        }
2116        out
2117    }
2118
2119    /// How many nodes the document holds (the root included) — the
2120    /// bound a host needs to pack this document's node ids into a
2121    /// wider id space.
2122    pub fn node_count(&self) -> usize {
2123        self.nodes.len()
2124    }
2125
2126    /// Read plain text: blank-line-separated paragraphs, each
2127    /// collapsed to one line — the atramento paragraph rule. No
2128    /// headings, no markup.
2129    pub fn parse_plain(text: &str) -> Self {
2130        let mut blocks = Vec::new();
2131        let mut para: Vec<&str> = Vec::new();
2132        for line in text.lines() {
2133            if line.trim().is_empty() {
2134                if !para.is_empty() {
2135                    blocks.push(Block::Paragraph {
2136                        text: para.join(" "),
2137                    });
2138                    para.clear();
2139                }
2140            } else {
2141                para.push(line);
2142            }
2143        }
2144        if !para.is_empty() {
2145            blocks.push(Block::Paragraph {
2146                text: para.join(" "),
2147            });
2148        }
2149        Self::build(blocks)
2150    }
2151
2152    /// A locator path to `node`, like `/section[2]/paragraph[3]`,
2153    /// for rendering. A `[n]` index is added only to disambiguate
2154    /// same-name siblings.
2155    pub fn locator(&self, node: NodeId) -> String {
2156        let mut segments = Vec::new();
2157        let mut cur = Some(node);
2158        while let Some(id) = cur {
2159            let n = &self.nodes[id.0 as usize];
2160            if let Some(name) = n.kind.name() {
2161                segments.push(self.segment(id, name));
2162            }
2163            cur = n.parent;
2164        }
2165        segments.reverse();
2166        format!("/{}", segments.join("/"))
2167    }
2168
2169    fn segment(&self, node: NodeId, name: &str) -> String {
2170        let Some(parent) = self.nodes[node.0 as usize].parent else {
2171            return name.to_string();
2172        };
2173        let siblings = &self.nodes[parent.0 as usize].children;
2174        let same_name: Vec<NodeId> = siblings
2175            .iter()
2176            .copied()
2177            .filter(|&s| self.nodes[s.0 as usize].kind == self.nodes[node.0 as usize].kind)
2178            .collect();
2179        if same_name.len() > 1 {
2180            let n = same_name.iter().position(|&s| s == node).unwrap() + 1;
2181            format!("{name}[{n}]")
2182        } else {
2183            name.to_string()
2184        }
2185    }
2186}
2187
2188/// Where the next block lands: the innermost open container, else
2189/// the innermost open section, else the root.
2190/// Ruling #35: wire each callout to its body by (family, onym) —
2191/// first body wins on a duplicate — build the reverse index, and
2192/// mark the dangling: a callout with no body keeps its node,
2193/// carries `<dangling>` and `::::resolved = false`, and emits no
2194/// edge.
2195fn resolve_notes(nodes: &mut [Node]) {
2196    let mut bodies: std::collections::HashMap<(Kind, String), NodeId> = Default::default();
2197    for (i, n) in nodes.iter().enumerate() {
2198        if matches!(n.kind, Kind::Footnote | Kind::Endnote | Kind::Aside)
2199            && !n.deixis
2200            && let Some(o) = &n.onym
2201        {
2202            bodies
2203                .entry((n.kind, o.clone()))
2204                .or_insert(NodeId(i as u64));
2205        }
2206    }
2207    for i in 0..nodes.len() {
2208        if !matches!(nodes[i].kind, Kind::Footnote | Kind::Endnote | Kind::Aside)
2209            || !nodes[i].deixis
2210        {
2211            continue;
2212        }
2213        let Some(onym) = nodes[i].onym.clone() else {
2214            nodes[i].dangling = true;
2215            continue;
2216        };
2217        let hit = if nodes[i].family_open {
2218            // No declared family (an HTML noteref): the body's
2219            // family is the callout's; footnote when dangling.
2220            [Kind::Footnote, Kind::Endnote, Kind::Aside]
2221                .into_iter()
2222                .find_map(|k| bodies.get(&(k, onym.clone())).map(|b| (k, *b)))
2223        } else {
2224            bodies
2225                .get(&(nodes[i].kind, onym))
2226                .map(|b| (nodes[i].kind, *b))
2227        };
2228        match hit {
2229            Some((family, body)) => {
2230                nodes[i].kind = family;
2231                nodes[i].note_edge = Some(body);
2232                let callout = NodeId(i as u64);
2233                nodes[body.0 as usize].cites.push(callout);
2234            }
2235            None => nodes[i].dangling = true,
2236        }
2237    }
2238}
2239
2240/// An index term as written, with the `|...` formatting and range
2241/// directives stripped (v1 of ruling #36; ranges and
2242/// see-references are recorded follow-ups).
2243fn quarb_term(term: &str) -> String {
2244    normalize_ws(term.split('|').next().unwrap_or(term))
2245}
2246
2247fn cursor(sections: &[NodeId], containers: &[NodeId], root: NodeId) -> NodeId {
2248    containers
2249        .last()
2250        .or(sections.last())
2251        .copied()
2252        .unwrap_or(root)
2253}
2254
2255impl TextModel {
2256    /// The corpus reading (ruling #62): give every prose block its
2257    /// tokens as children. Segmentation is UAX #29 word boundaries
2258    /// (the standard `sentences` already follows): `don’t`, `Tom’s`
2259    /// and `3,000` are one token each, an em dash is its own, and
2260    /// whitespace is none. Positions and sentence ordinals count
2261    /// through the document in reading order. The block's own
2262    /// `::` is unchanged; the tokens sit after any children it
2263    /// already had (an inline ref, a callout).
2264    /// The literary reading's cast layer (ruling #79): declare the
2265    /// characters a cast table names (those the source did not),
2266    /// annotate every word token a row's pattern matches with the
2267    /// row's id as its `prosopon`, then resolve every mention to
2268    /// its character.
2269    pub fn apply_cast(&mut self, rows: &[CastRow]) {
2270        for row in rows {
2271            let known = self
2272                .nodes
2273                .iter()
2274                .any(|n| n.kind == Kind::Character && n.onym.as_deref() == Some(row.id.as_str()));
2275            if known {
2276                continue;
2277            }
2278            let root = NodeId(0);
2279            let id = push(&mut self.nodes, Kind::Character, root);
2280            let n = &mut self.nodes[id.0 as usize];
2281            n.lemma = Some(row.name.clone()).filter(|l| !l.is_empty());
2282            n.onym = Some(row.id.clone());
2283            n.fields = row.fields.clone();
2284        }
2285        // A pattern of several words ("Aunt Polly") matches a run
2286        // of word tokens under one parent, joined by a space; the
2287        // run's first token carries the mention.
2288        let patterns: Vec<(&CastRow, &regex::Regex, usize)> = rows
2289            .iter()
2290            .filter_map(|r| r.pattern.as_ref().map(|p| (r, p, r.words.max(1))))
2291            .collect();
2292        let widest = patterns.iter().map(|(_, _, w)| *w).max().unwrap_or(0);
2293        if widest > 0 {
2294            let words: Vec<usize> = (0..self.nodes.len())
2295                .filter(|&i| {
2296                    self.nodes[i]
2297                        .token
2298                        .as_ref()
2299                        .is_some_and(|t| t.class == TokenClass::Word)
2300                })
2301                .collect();
2302            for (k, &i) in words.iter().enumerate() {
2303                if self.nodes[i].fields.iter().any(|(k, _)| k == "prosopon") {
2304                    continue;
2305                }
2306                let mut run = vec![self.nodes[i].text.as_str()];
2307                for &j in words.iter().skip(k + 1).take(widest - 1) {
2308                    if self.nodes[j].parent != self.nodes[i].parent {
2309                        break;
2310                    }
2311                    run.push(self.nodes[j].text.as_str());
2312                }
2313                // A pattern whose alternatives differ in length
2314                // ("Tom|Tom Sawyer") is tried from its widest run
2315                // down to one token.
2316                let hit = patterns.iter().find(|(_, p, w)| {
2317                    (1..=(*w).min(run.len()))
2318                        .rev()
2319                        .any(|n| p.is_match(&run[..n].join(" ")))
2320                });
2321                if let Some((row, _, _)) = hit {
2322                    self.nodes[i]
2323                        .fields
2324                        .push(("prosopon".to_string(), row.id.clone()));
2325                }
2326            }
2327        }
2328        // A coreference chain (a `cluster` column, the source's
2329        // chain id): every mention of the chain is the character's,
2330        // its first token carrying the mention.
2331        let chains: Vec<(&CastRow, &str)> = rows
2332            .iter()
2333            .filter_map(|r| {
2334                r.fields
2335                    .iter()
2336                    .find(|(k, _)| k == "cluster" || k == "chain")
2337                    .map(|(_, v)| (r, v.as_str()))
2338            })
2339            .collect();
2340        if !chains.is_empty() {
2341            let firsts: Vec<(NodeId, String)> = self
2342                .mentions
2343                .iter()
2344                .filter_map(|m| {
2345                    let c = m.cluster.as_deref()?;
2346                    let (row, _) = chains.iter().find(|(_, id)| *id == c)?;
2347                    Some((*m.tokens.first()?, row.id.clone()))
2348                })
2349                .collect();
2350            for (t, id) in firsts {
2351                let f = &mut self.nodes[t.0 as usize].fields;
2352                if !f.iter().any(|(k, _)| k == "prosopon") {
2353                    f.push(("prosopon".to_string(), id));
2354                }
2355            }
2356        }
2357        // A play's speech names its speaker by the label the source
2358        // printed (its lemma): a label a pattern matches is that
2359        // character's speech.
2360        if !patterns.is_empty() {
2361            for i in 0..self.nodes.len() {
2362                let n = &self.nodes[i];
2363                if n.kind != Kind::Dialogue || n.fields.iter().any(|(k, _)| k == "prosopon") {
2364                    continue;
2365                }
2366                let Some(label) = n.lemma.clone() else {
2367                    continue;
2368                };
2369                if let Some((row, _, _)) = patterns.iter().find(|(_, p, _)| p.is_match(&label)) {
2370                    self.nodes[i]
2371                        .fields
2372                        .push(("prosopon".to_string(), row.id.clone()));
2373                }
2374            }
2375        }
2376        self.link_characters();
2377    }
2378
2379    /// Resolve `->character`: every node whose own `prosopon`
2380    /// names a declared character links to it, and the character
2381    /// lists it among its mentions.
2382    fn link_characters(&mut self) {
2383        let mut by_onym: std::collections::HashMap<String, NodeId> =
2384            std::collections::HashMap::new();
2385        for (i, n) in self.nodes.iter().enumerate() {
2386            if n.kind == Kind::Character
2387                && let Some(o) = &n.onym
2388            {
2389                by_onym.entry(o.clone()).or_insert(NodeId(i as u64));
2390            }
2391        }
2392        for n in &mut self.nodes {
2393            n.mentions.clear();
2394        }
2395        for i in 0..self.nodes.len() {
2396            if self.nodes[i].kind == Kind::Character {
2397                continue;
2398            }
2399            let target = self.nodes[i]
2400                .fields
2401                .iter()
2402                .find(|(k, _)| k == "prosopon")
2403                .and_then(|(_, v)| by_onym.get(v).copied());
2404            self.nodes[i].character = target;
2405            if let Some(c) = target {
2406                self.nodes[c.0 as usize].mentions.push(NodeId(i as u64));
2407            }
2408        }
2409    }
2410
2411    /// The literary reading's gazetteer (ruling #87): a places
2412    /// table declares the places a source names, as a cast table
2413    /// declares its people. Each row is a `place` node under the
2414    /// root (`::lemma` its name, `::onym` its id, every other
2415    /// column a field — `::lat`, `::lon`, `::kind`); a word token a
2416    /// row's pattern matches answers the row's id as `::chora` and
2417    /// reaches the place as `->place`, and the place reaches its
2418    /// mentions back as `<-place`.
2419    pub fn apply_places(&mut self, rows: &[CastRow]) {
2420        for row in rows {
2421            let known = self
2422                .nodes
2423                .iter()
2424                .any(|n| n.kind == Kind::Place && n.onym.as_deref() == Some(row.id.as_str()));
2425            if known {
2426                continue;
2427            }
2428            let root = NodeId(0);
2429            let id = push(&mut self.nodes, Kind::Place, root);
2430            let n = &mut self.nodes[id.0 as usize];
2431            n.lemma = Some(row.name.clone()).filter(|l| !l.is_empty());
2432            n.onym = Some(row.id.clone());
2433            n.fields = row.fields.clone();
2434        }
2435        let patterns: Vec<(&CastRow, &regex::Regex, usize)> = rows
2436            .iter()
2437            .filter_map(|r| r.pattern.as_ref().map(|p| (r, p, r.words.max(1))))
2438            .collect();
2439        let widest = patterns.iter().map(|(_, _, w)| *w).max().unwrap_or(0);
2440        if widest > 0 {
2441            let words: Vec<usize> = (0..self.nodes.len())
2442                .filter(|&i| {
2443                    self.nodes[i]
2444                        .token
2445                        .as_ref()
2446                        .is_some_and(|t| t.class == TokenClass::Word)
2447                })
2448                .collect();
2449            for (k, &i) in words.iter().enumerate() {
2450                if self.nodes[i].fields.iter().any(|(k, _)| k == "chora") {
2451                    continue;
2452                }
2453                let mut run = vec![self.nodes[i].text.as_str()];
2454                for &j in words.iter().skip(k + 1).take(widest - 1) {
2455                    if self.nodes[j].parent != self.nodes[i].parent {
2456                        break;
2457                    }
2458                    run.push(self.nodes[j].text.as_str());
2459                }
2460                // A pattern whose alternatives differ in length
2461                // ("Tom|Tom Sawyer") is tried from its widest run
2462                // down to one token.
2463                let hit = patterns.iter().find(|(_, p, w)| {
2464                    (1..=(*w).min(run.len()))
2465                        .rev()
2466                        .any(|n| p.is_match(&run[..n].join(" ")))
2467                });
2468                if let Some((row, _, _)) = hit {
2469                    self.nodes[i]
2470                        .fields
2471                        .push(("chora".to_string(), row.id.clone()));
2472                }
2473            }
2474        }
2475        self.link_places();
2476    }
2477
2478    /// Resolve `->place`: every node whose own `chora` names a
2479    /// declared place links to it, and the place lists it among
2480    /// its mentions.
2481    fn link_places(&mut self) {
2482        let mut by_onym: std::collections::HashMap<String, NodeId> =
2483            std::collections::HashMap::new();
2484        for (i, n) in self.nodes.iter().enumerate() {
2485            if n.kind == Kind::Place
2486                && let Some(o) = &n.onym
2487            {
2488                by_onym.entry(o.clone()).or_insert(NodeId(i as u64));
2489            }
2490        }
2491        for n in &mut self.nodes {
2492            if n.kind == Kind::Place {
2493                n.mentions.clear();
2494            }
2495        }
2496        for i in 0..self.nodes.len() {
2497            if self.nodes[i].kind == Kind::Place {
2498                continue;
2499            }
2500            let target = self.nodes[i]
2501                .fields
2502                .iter()
2503                .find(|(k, _)| k == "chora")
2504                .and_then(|(_, v)| by_onym.get(v).copied());
2505            self.nodes[i].place = target;
2506            if let Some(c) = target {
2507                self.nodes[c.0 as usize].mentions.push(NodeId(i as u64));
2508            }
2509        }
2510    }
2511
2512    pub fn tokenize(&mut self) {
2513        self.tokenize_with(None)
2514    }
2515
2516    /// The prose blocks the corpus reading tokenizes, in reading
2517    /// order.
2518    fn prose_blocks(&self) -> Vec<NodeId> {
2519        (0..self.nodes.len())
2520            .filter(|&id| {
2521                let node = &self.nodes[id];
2522                let prose_block = match node.kind {
2523                    // A treebank paragraph delegates to its declared
2524                    // sentences (ruling #63); the sentences the
2525                    // corpus reading derives beneath a block (ruling
2526                    // #64) are annotation, not blocks.
2527                    Kind::Paragraph => !node.children.iter().any(|c| {
2528                        let c = &self.nodes[c.0 as usize];
2529                        c.kind == Kind::Sentence && !c.derived
2530                    }),
2531                    Kind::Sentence => !node.derived,
2532                    Kind::UnorderedItem | Kind::OrderedItem | Kind::Stichos => true,
2533                    Kind::Speech | Kind::Dialogue => true,
2534                    Kind::Footnote | Kind::Endnote | Kind::Aside => !node.deixis,
2535                    _ => false,
2536                };
2537                prose_block && !node.prose.is_empty() && !self.in_apparatus(NodeId(id as u64))
2538            })
2539            .map(|id| NodeId(id as u64))
2540            .collect()
2541    }
2542
2543    /// The note body a node stands in, if any: the nearest
2544    /// ancestor that is a footnote, endnote or aside body (not the
2545    /// callout). The corpus reading tokenizes the text, not the
2546    /// apparatus; a node in a body cites as the body's callout.
2547    fn note_body_of(&self, node: NodeId) -> Option<NodeId> {
2548        let mut cur = self.nodes[node.0 as usize].parent;
2549        while let Some(p) = cur {
2550            let n = &self.nodes[p.0 as usize];
2551            if matches!(n.kind, Kind::Footnote | Kind::Endnote | Kind::Aside) && !n.deixis {
2552                return Some(p);
2553            }
2554            cur = n.parent;
2555        }
2556        None
2557    }
2558
2559    fn in_apparatus(&self, node: NodeId) -> bool {
2560        let n = &self.nodes[node.0 as usize];
2561        (matches!(n.kind, Kind::Footnote | Kind::Endnote | Kind::Aside) && !n.deixis)
2562            || self.note_body_of(node).is_some()
2563    }
2564
2565    /// The document's prose as one text — every prose block's `::`,
2566    /// blank-line separated, in reading order — the text an
2567    /// external annotator reads and a CoNLL-U source aligns to.
2568    pub fn corpus_text(&self) -> String {
2569        self.prose_blocks()
2570            .iter()
2571            .map(|&b| self.nodes[b.0 as usize].prose.as_str())
2572            .collect::<Vec<_>>()
2573            .join("\n\n")
2574    }
2575
2576    /// Whether the corpus reading's tokens are already in place.
2577    pub fn is_tokenized(&self) -> bool {
2578        self.nodes.iter().any(|n| n.kind == Kind::Token)
2579    }
2580
2581    /// [`tokenize`](Self::tokenize) with sentence bonds: a `.desm`
2582    /// set (the `syndesmos` crate) whose abbreviations and patterns
2583    /// undo UAX #29 breaks.
2584    pub fn tokenize_with(&mut self, bonds: Option<&syndesmos::Syndesmos>) {
2585        use unicode_segmentation::UnicodeSegmentation;
2586        self.token_index = Default::default();
2587        let mut n: u32 = 0;
2588        let mut sentence: u32 = 0;
2589        for block in self.prose_blocks() {
2590            let id = block.0 as usize;
2591            let prose = self.nodes[id].prose.clone();
2592            // The sentence tier (ruling #64): one derived sentence
2593            // node per span, under the block; a block the
2594            // segmenter leaves whole is one sentence.
2595            // The parsing pack (ruling #80): the source's own
2596            // sentences and tokens stand in for the segmenter's
2597            // where the source declared them.
2598            let declared_sentences = std::mem::take(&mut self.nodes[id].declared_sentences);
2599            let mut declared_tokens = std::mem::take(&mut self.nodes[id].declared_tokens);
2600            declared_tokens.sort_by_key(|t| t.lo);
2601            let mut spans = if declared_sentences.is_empty() {
2602                sentence_spans(&prose, bonds)
2603            } else {
2604                declared_sentences
2605                    .iter()
2606                    .map(|(lo, hi, _)| (*lo as usize, *hi as usize))
2607                    .filter(|(lo, hi)| lo < hi && *hi <= prose.len())
2608                    .collect()
2609            };
2610            if spans.is_empty() {
2611                spans.push((0, prose.len()));
2612            }
2613            let sentence_ids: Vec<Option<String>> = if declared_sentences.is_empty() {
2614                Vec::new()
2615            } else {
2616                declared_sentences
2617                    .iter()
2618                    .map(|(_, _, sid)| Some(sid.clone()))
2619                    .collect()
2620            };
2621            let sentences: Vec<(usize, usize, u32, NodeId)> = spans
2622                .into_iter()
2623                .enumerate()
2624                .map(|(k, (lo, hi))| {
2625                    sentence += 1;
2626                    let node = self.push_derived_sentence(block, &prose[lo..hi], sentence);
2627                    if let Some(Some(sid)) = sentence_ids.get(k) {
2628                        self.nodes[node.0 as usize]
2629                            .fields
2630                            .push(("sent_id".to_string(), sid.clone()));
2631                    }
2632                    (lo, hi, sentence, node)
2633                })
2634                .collect();
2635            // Declared tokens by onym, for the heads.
2636            let mut by_onym: Vec<(String, NodeId)> = Vec::new();
2637            let mut heads: Vec<(NodeId, String)> = Vec::new();
2638            let mut next_declared = 0usize;
2639            let mut skip_until = 0usize;
2640            for (at, seg) in prose.split_word_bound_indices() {
2641                if at < skip_until {
2642                    continue;
2643                }
2644                // A declared token starting here takes the place
2645                // of the segmenter's, however many word bounds
2646                // it spans.
2647                while next_declared < declared_tokens.len()
2648                    && (declared_tokens[next_declared].lo as usize) < at
2649                {
2650                    next_declared += 1;
2651                }
2652                if let Some(d) = declared_tokens.get(next_declared)
2653                    && d.lo as usize == at
2654                    && (d.hi as usize) > at
2655                    && (d.hi as usize) <= prose.len()
2656                {
2657                    let d = d.clone();
2658                    next_declared += 1;
2659                    skip_until = d.hi as usize;
2660                    let form = &prose[d.lo as usize..d.hi as usize];
2661                    n += 1;
2662                    let (in_sentence, span, parent) = sentences
2663                        .iter()
2664                        .find(|(lo, hi, _, _)| *lo <= at && at < *hi)
2665                        .or_else(|| sentences.last())
2666                        .map(|(lo, hi, s, node)| (*s, (*lo as u32, *hi as u32), *node))
2667                        .expect("a block has at least one sentence");
2668                    let tok = self.push_parsed(parent, form, &d, n, in_sentence);
2669                    if let Some(t) = self.nodes[tok.0 as usize].token.as_mut() {
2670                        t.span = span;
2671                        t.at = at as u32;
2672                    }
2673                    if let Some(o) = &d.onym {
2674                        by_onym.push((o.clone(), tok));
2675                    }
2676                    if let Some(h) = d.parsings.first().and_then(|p| p.kephale.clone()) {
2677                        heads.push((tok, h));
2678                    }
2679                    continue;
2680                }
2681                if seg.chars().all(char::is_whitespace) {
2682                    continue;
2683                }
2684                let class = class_of(seg);
2685                n += 1;
2686                let (in_sentence, span, parent) = sentences
2687                    .iter()
2688                    .find(|(lo, hi, _, _)| *lo <= at && at < *hi)
2689                    .or_else(|| sentences.last())
2690                    .map(|(lo, hi, s, node)| (*s, (*lo as u32, *hi as u32), *node))
2691                    .expect("a block has at least one sentence");
2692                let tok = push(&mut self.nodes, Kind::Token, parent);
2693                let t = &mut self.nodes[tok.0 as usize];
2694                t.text = seg.to_string();
2695                t.prose = seg.to_string();
2696                t.token = Some(TokenInfo {
2697                    class,
2698                    n,
2699                    sentence: in_sentence,
2700                    span,
2701                    annot: None,
2702                    at: at as u32,
2703                    spans: Vec::new(),
2704                });
2705            }
2706            // The declared heads (`->head`, `<-head`), by onym.
2707            for (tok, h) in heads {
2708                let Some((_, head)) = by_onym.iter().find(|(o, _)| *o == h) else {
2709                    continue;
2710                };
2711                let head = *head;
2712                if let Some(a) = self.nodes[tok.0 as usize]
2713                    .token
2714                    .as_mut()
2715                    .and_then(|t| t.annot.as_mut())
2716                {
2717                    a.head = Some(head);
2718                }
2719                if let Some(a) = self.nodes[head.0 as usize]
2720                    .token
2721                    .as_mut()
2722                    .and_then(|t| t.annot.as_mut())
2723                {
2724                    a.dependents.push(tok);
2725                }
2726            }
2727        }
2728        self.attribute_spans();
2729    }
2730
2731    /// A token the source parsed (ruling #80): the annotation the
2732    /// CoNLL-U reading would carry, from the first parsing —
2733    /// `::lemma` the lexema, `::upos`/`::xpos` the meros (split on
2734    /// `/`), `::feats` the parepomena with every `Key=Value` a
2735    /// property, `::deprel` the schesis, `::sem` the semasia; the
2736    /// alternative parsings answer as `::alt`, lemma and meros
2737    /// joined, one per parsing.
2738    fn push_parsed(
2739        &mut self,
2740        sentence_node: NodeId,
2741        form: &str,
2742        d: &DeclaredToken,
2743        n: u32,
2744        sentence: u32,
2745    ) -> NodeId {
2746        let node = push(&mut self.nodes, Kind::Token, sentence_node);
2747        let t = &mut self.nodes[node.0 as usize];
2748        t.text = form.to_string();
2749        t.prose = form.to_string();
2750        let first = d.parsings.first().cloned().unwrap_or_default();
2751        let (upos, xpos) = match first.meros.as_deref() {
2752            Some(m) => match m.split_once('/') {
2753                Some((u, x)) => (Some(u.to_string()), Some(x.to_string())),
2754                None => (Some(m.to_string()), None),
2755            },
2756            None => (None, None),
2757        };
2758        let feats = first.parepomena.clone();
2759        // `Key=Value` features (TEI's msd, UD) answer under their
2760        // keys; a tagset's bare grammemes (RNC's `m,anim=sg,nom`)
2761        // stay the raw `::feats`.
2762        let mut keys = match feats.as_deref() {
2763            Some(f) if f.split([',', '|']).all(|p| p.contains('=')) => {
2764                key_values(&f.replace(',', "|"))
2765            }
2766            _ => Vec::new(),
2767        };
2768        if let Some(sem) = &first.semasia {
2769            keys.push(("sem".to_string(), sem.clone()));
2770        }
2771        for alt in d.parsings.iter().skip(1) {
2772            let mut v = alt.lexema.clone().unwrap_or_default();
2773            if let Some(m) = &alt.meros {
2774                v.push(' ');
2775                v.push_str(m);
2776            }
2777            if let Some(f) = &alt.parepomena {
2778                v.push(' ');
2779                v.push_str(f);
2780            }
2781            keys.push(("alt".to_string(), v.trim().to_string()));
2782        }
2783        t.token = Some(TokenInfo {
2784            class: class_of_annotated(form, upos.as_deref().unwrap_or("")),
2785            n,
2786            sentence,
2787            span: (0, 0),
2788            at: 0,
2789            spans: Vec::new(),
2790            annot: Some(Box::new(Annot {
2791                id: d.onym.clone().unwrap_or_else(|| n.to_string()),
2792                lemma: first.lexema.clone(),
2793                upos,
2794                xpos,
2795                feats,
2796                deprel: first.schesis.clone(),
2797                deps: None,
2798                misc: None,
2799                keys,
2800                mwt: None,
2801                empty: false,
2802                head: None,
2803                dependents: Vec::new(),
2804                eheads: Vec::new(),
2805                edependents: Vec::new(),
2806                mentions: Vec::new(),
2807            })),
2808        });
2809        node
2810    }
2811
2812    /// The literary reading: give every span the tokens its byte
2813    /// range covers (`->token`), and every token its spans,
2814    /// outermost first — after the tokens exist.
2815    fn attribute_spans(&mut self) {
2816        let spans: Vec<NodeId> = (0..self.nodes.len())
2817            .filter(|&i| self.nodes[i].kind == Kind::Span && self.nodes[i].at.is_some())
2818            .map(|i| NodeId(i as u64))
2819            .collect();
2820        if spans.is_empty() {
2821            return;
2822        }
2823        for span in spans {
2824            let (lo, hi) = self.nodes[span.0 as usize].at.expect("filtered");
2825            // The flow block the span belongs to: up past the
2826            // enclosing spans.
2827            let mut block = span;
2828            while self.nodes[block.0 as usize].kind == Kind::Span
2829                && let Some(p) = self.nodes[block.0 as usize].parent
2830            {
2831                block = p;
2832            }
2833            let mut tokens = Vec::new();
2834            self.collect_tokens(block, &mut tokens);
2835            let covered: Vec<NodeId> = tokens
2836                .into_iter()
2837                .filter(|t| {
2838                    let at = self.nodes[t.0 as usize].token.as_ref().map(|t| t.at);
2839                    at.is_some_and(|at| lo <= at && at < hi)
2840                })
2841                .collect();
2842            for &t in &covered {
2843                if let Some(info) = self.nodes[t.0 as usize].token.as_mut()
2844                    && !info.spans.contains(&span)
2845                {
2846                    info.spans.push(span);
2847                }
2848            }
2849            self.nodes[span.0 as usize].span_tokens = covered;
2850        }
2851        // Spans are pushed in pre-order, so node order is outermost
2852        // first.
2853        for n in self.nodes.iter_mut() {
2854            if let Some(t) = n.token.as_mut() {
2855                t.spans.sort();
2856            }
2857        }
2858    }
2859
2860    fn collect_tokens(&self, node: NodeId, out: &mut Vec<NodeId>) {
2861        for &c in &self.nodes[node.0 as usize].children {
2862            let n = &self.nodes[c.0 as usize];
2863            if n.token.is_some() {
2864                out.push(c);
2865            } else if n.kind == Kind::Sentence {
2866                self.collect_tokens(c, out);
2867            }
2868        }
2869    }
2870
2871    /// One sentence of the corpus reading's sentence tier (ruling
2872    /// #64), under `block`: a `sentence` node the segmentation in
2873    /// force derived, its `::` the span's text, its ordinal the
2874    /// document-wide sentence count. Annotation, never a block:
2875    /// the block's own prose is unchanged and the renderers never
2876    /// see it.
2877    fn push_derived_sentence(&mut self, block: NodeId, text: &str, ordinal: u32) -> NodeId {
2878        let node = push(&mut self.nodes, Kind::Sentence, block);
2879        let s = &mut self.nodes[node.0 as usize];
2880        s.text = text.trim().to_string();
2881        s.prose = s.text.clone();
2882        s.taxis = Some(ordinal as i64);
2883        s.derived = true;
2884        node
2885    }
2886
2887    /// The corpus reading from a CoNLL-U annotation (ruling #62's
2888    /// second layer; ruling #63 carries every column): the
2889    /// source's sentences and tokens replace the built-in
2890    /// segmentation wholesale, aligned to the document's prose by
2891    /// character offset — each token's form is found at the
2892    /// cursor, whitespace skipped — and every token carries the
2893    /// source's lemma, parts of speech, features, dependency
2894    /// relation, head edge, enhanced heads and miscellany. A form
2895    /// that does not align is an error naming the offset: the
2896    /// annotation must be of this text. A multiword range (`1-2
2897    /// don't`) aligns as the unit and its syntactic words share its
2898    /// span; an empty node (`8.1`) is not in the text and consumes
2899    /// none of it.
2900    pub fn annotate_conllu(&mut self, conllu: &str) -> Result<(), String> {
2901        if self.is_tokenized() {
2902            return Err("the document is already tokenized".into());
2903        }
2904        self.token_index = Default::default();
2905        let blocks = self.prose_blocks();
2906        // The document text and each block's range in it.
2907        let mut doc = String::new();
2908        let mut ranges: Vec<(usize, usize, NodeId)> = Vec::new();
2909        for (i, &b) in blocks.iter().enumerate() {
2910            if i > 0 {
2911                doc.push_str("\n\n");
2912            }
2913            let lo = doc.len();
2914            doc.push_str(&self.nodes[b.0 as usize].prose);
2915            ranges.push((lo, doc.len(), b));
2916        }
2917        if ranges.is_empty() {
2918            return Err("the document has no prose to annotate".into());
2919        }
2920        let parsed = parse_conllu(conllu)?;
2921        let mut cursor = 0usize;
2922        let mut block_at = 0usize;
2923        let mut n: u32 = 0;
2924        for (si, sent) in parsed.sentences.iter().enumerate() {
2925            let ordinal = si as u32 + 1;
2926            let mut made: Vec<(NodeId, &ConlluToken)> = Vec::new();
2927            // The (block, lo, hi) extents the sentence covers — and
2928            // the sentence node under each block (ruling #64): a
2929            // sentence the annotator runs across a block boundary
2930            // is one node per block, sharing the ordinal.
2931            let mut extents: Vec<(NodeId, usize, usize)> = Vec::new();
2932            let mut nodes_by_block: Vec<(NodeId, NodeId)> = Vec::new();
2933            // A multiword range in force: its span, last word id,
2934            // id and surface form.
2935            let mut range: Option<(usize, usize, u32, String, String)> = None;
2936            for tok in &sent.tokens {
2937                // A whitespace-only form (a tool's SPACE token) is
2938                // no token of the text.
2939                if !tok.empty && tok.form.trim().is_empty() {
2940                    continue;
2941                }
2942                let (lo, hi, mwt) = if tok.empty {
2943                    (cursor, cursor, None)
2944                } else if let Some((lo, hi, until, id, form)) = &range
2945                    && tok.range.is_none()
2946                    && tok.ord <= *until
2947                {
2948                    (*lo, *hi, Some((id.clone(), form.clone())))
2949                } else {
2950                    range = None;
2951                    while doc[cursor..].starts_with(char::is_whitespace) {
2952                        cursor += doc[cursor..].chars().next().map_or(0, char::len_utf8);
2953                    }
2954                    if !doc[cursor..].starts_with(tok.form.as_str()) {
2955                        let seen: String = doc[cursor..].chars().take(24).collect();
2956                        return Err(format!(
2957                            "CoNLL-U sentence {ordinal}, token {}: {:?} does not align with the text at offset {cursor} ({seen:?})",
2958                            tok.id, tok.form
2959                        ));
2960                    }
2961                    let lo = cursor;
2962                    cursor += tok.form.len();
2963                    if let Some((_, b)) = tok.range {
2964                        range = Some((lo, cursor, b, tok.id.clone(), tok.form.clone()));
2965                        continue;
2966                    }
2967                    (lo, cursor, None)
2968                };
2969                if !tok.empty {
2970                    while block_at + 1 < ranges.len() && lo >= ranges[block_at].1 {
2971                        block_at += 1;
2972                    }
2973                }
2974                let (blo, _, block) = ranges[block_at];
2975                if !tok.empty {
2976                    n += 1;
2977                }
2978                let sentence_node = match nodes_by_block.iter().find(|(b, _)| *b == block) {
2979                    Some((_, s)) => *s,
2980                    None => {
2981                        let s = self.push_derived_sentence(block, "", ordinal);
2982                        nodes_by_block.push((block, s));
2983                        s
2984                    }
2985                };
2986                let node = self.push_annotated(
2987                    sentence_node,
2988                    tok,
2989                    if tok.empty { 0 } else { n },
2990                    ordinal,
2991                    mwt,
2992                );
2993                if !tok.empty {
2994                    match extents.iter_mut().find(|(b, _, _)| *b == block) {
2995                        Some(e) => {
2996                            e.1 = e.1.min(lo - blo);
2997                            e.2 = e.2.max(hi - blo);
2998                        }
2999                        None => extents.push((block, lo - blo, hi - blo)),
3000                    }
3001                    if let Some(t) = self.nodes[node.0 as usize].token.as_mut() {
3002                        t.at = (lo - blo) as u32;
3003                    }
3004                }
3005                made.push((node, tok));
3006            }
3007            // The sentence's span per block — on its node's text
3008            // and on each token — then the edges.
3009            for (block, s) in &nodes_by_block {
3010                if let Some((_, lo, hi)) = extents.iter().find(|(b, _, _)| b == block) {
3011                    let text = self.nodes[block.0 as usize].prose[*lo..*hi]
3012                        .trim()
3013                        .to_string();
3014                    let sn = &mut self.nodes[s.0 as usize];
3015                    sn.text = text.clone();
3016                    sn.prose = text;
3017                }
3018            }
3019            for (node, _) in &made {
3020                let sentence_node = self.nodes[node.0 as usize]
3021                    .parent
3022                    .expect("token has a sentence");
3023                let block = self.nodes[sentence_node.0 as usize]
3024                    .parent
3025                    .expect("sentence has a block");
3026                if let Some((_, lo, hi)) = extents.iter().find(|(b, _, _)| *b == block)
3027                    && let Some(t) = self.nodes[node.0 as usize].token.as_mut()
3028                {
3029                    t.span = (*lo as u32, *hi as u32);
3030                }
3031            }
3032            self.link_sentence(&made);
3033        }
3034        let header = parsed
3035            .globals
3036            .iter()
3037            .find(|(k, _)| k == "Entity")
3038            .map(|(_, v)| v.as_str());
3039        self.decode_mentions(header);
3040        Ok(())
3041    }
3042
3043    /// A treebank read as a document (ruling #63): the file is the
3044    /// root; `# newdoc` opens a section, `# newpar` a paragraph,
3045    /// each sentence block a `sentence` node whose `::` is its
3046    /// `# text` (else the forms joined per `SpaceAfter`) and whose
3047    /// comments answer as properties. No tokens: the reading a
3048    /// bare `.conllu` or `text:` takes.
3049    pub fn parse_conllu_text(text: &str) -> Result<Self, String> {
3050        Self::from_conllu_set(&[(None, text)], false)
3051    }
3052
3053    /// A treebank read as a corpus: [`parse_conllu_text`]'s
3054    /// document with the file's own tokens as each sentence's
3055    /// children, in id order, every column carried — the reading
3056    /// `corpus:` takes on a `.conllu`.
3057    ///
3058    /// [`parse_conllu_text`]: Self::parse_conllu_text
3059    pub fn parse_conllu_corpus(text: &str) -> Result<Self, String> {
3060        Self::from_conllu_set(&[(None, text)], true)
3061    }
3062
3063    /// A set of treebank files read as one document — a UD
3064    /// treebank's train / dev / test files, a release directory:
3065    /// each file a level-1 `section` under the root whose
3066    /// `::lemma` is the name given, the file's own `# newdoc`
3067    /// sections nested beneath it. No tokens, as
3068    /// [`parse_conllu_text`](Self::parse_conllu_text).
3069    pub fn parse_conllu_text_set(files: &[(&str, &str)]) -> Result<Self, String> {
3070        let named: Vec<(Option<&str>, &str)> = files.iter().map(|(n, t)| (Some(*n), *t)).collect();
3071        Self::from_conllu_set(&named, false)
3072    }
3073
3074    /// [`parse_conllu_text_set`](Self::parse_conllu_text_set) with
3075    /// every file's tokens under its sentences: token positions,
3076    /// sentence ordinals and mention ordinals run across the whole
3077    /// set, as they do across one file.
3078    pub fn parse_conllu_corpus_set(files: &[(&str, &str)]) -> Result<Self, String> {
3079        let named: Vec<(Option<&str>, &str)> = files.iter().map(|(n, t)| (Some(*n), *t)).collect();
3080        Self::from_conllu_set(&named, true)
3081    }
3082
3083    fn from_conllu_set(files: &[(Option<&str>, &str)], tokens: bool) -> Result<Self, String> {
3084        let mut parsed: Vec<(Option<&str>, ConlluDoc)> = Vec::with_capacity(files.len());
3085        for (name, text) in files {
3086            let doc = parse_conllu(text).map_err(|e| match name {
3087                Some(n) => format!("{n}: {e}"),
3088                None => e,
3089            })?;
3090            parsed.push((*name, doc));
3091        }
3092        let mut nodes = vec![Node::new(Kind::Document, None)];
3093        let root = NodeId(0);
3094        // Every sentence node, with the file and sentence it came from.
3095        let mut sentences: Vec<(NodeId, usize, usize)> = Vec::new();
3096        for (fi, (name, doc)) in parsed.iter().enumerate() {
3097            let (base, doc_level) = match name {
3098                Some(n) => {
3099                    let sec = push(&mut nodes, Kind::Section, root);
3100                    nodes[sec.0 as usize].lemma = Some(n.to_string());
3101                    nodes[sec.0 as usize].level = Some(1);
3102                    (sec, 2)
3103                }
3104                None => (root, 1),
3105            };
3106            let mut section: Option<NodeId> = None;
3107            let mut para: Option<NodeId> = None;
3108            for (si, s) in doc.sentences.iter().enumerate() {
3109                if let Some(id) = &s.newdoc {
3110                    let sec = push(&mut nodes, Kind::Section, base);
3111                    nodes[sec.0 as usize].lemma = id.clone();
3112                    nodes[sec.0 as usize].level = Some(doc_level);
3113                    section = Some(sec);
3114                    para = None;
3115                }
3116                if s.newpar.is_some() {
3117                    let p = push(&mut nodes, Kind::Paragraph, section.unwrap_or(base));
3118                    para = Some(p);
3119                }
3120                let parent = para.or(section).unwrap_or(base);
3121                let sent = push(&mut nodes, Kind::Sentence, parent);
3122                nodes[sent.0 as usize].taxis = Some(sentences.len() as i64 + 1);
3123                let text = s
3124                    .comments
3125                    .iter()
3126                    .find(|(k, _)| k == "text")
3127                    .map(|(_, v)| v.clone())
3128                    .unwrap_or_else(|| conllu_surface(&s.tokens));
3129                nodes[sent.0 as usize].text = text;
3130                nodes[sent.0 as usize].fields = s.comments.clone();
3131                sentences.push((sent, fi, si));
3132            }
3133        }
3134        flatten_prose(&mut nodes);
3135        let mut model = TextModel {
3136            nodes,
3137            root,
3138            onyms: Default::default(),
3139            document_url: None,
3140            link_base: None,
3141            document_path: None,
3142            cite_scheme: None,
3143            orthography: None,
3144            bib_aliases: Default::default(),
3145            head: HeadMeta::default(),
3146            token_index: Default::default(),
3147            mentions: Vec::new(),
3148        };
3149        if tokens {
3150            let mut n: u32 = 0;
3151            for (i, (sent, fi, si)) in sentences.into_iter().enumerate() {
3152                let s = &parsed[fi].1.sentences[si];
3153                let ordinal = i as u32 + 1;
3154                let len = model.nodes[sent.0 as usize].prose.len() as u32;
3155                let mut made: Vec<(NodeId, &ConlluToken)> = Vec::new();
3156                // A multiword range in force: last word id, id, form.
3157                let mut range: Option<(u32, String, String)> = None;
3158                for tok in &s.tokens {
3159                    if let Some((_, b)) = tok.range {
3160                        range = Some((b, tok.id.clone(), tok.form.clone()));
3161                        continue;
3162                    }
3163                    let mwt = match &range {
3164                        Some((until, id, form)) if !tok.empty && tok.ord <= *until => {
3165                            Some((id.clone(), form.clone()))
3166                        }
3167                        _ => {
3168                            range = None;
3169                            None
3170                        }
3171                    };
3172                    if !tok.empty {
3173                        n += 1;
3174                    }
3175                    let node = model.push_annotated(
3176                        sent,
3177                        tok,
3178                        if tok.empty { 0 } else { n },
3179                        ordinal,
3180                        mwt,
3181                    );
3182                    if let Some(t) = model.nodes[node.0 as usize].token.as_mut() {
3183                        t.span = (0, len);
3184                    }
3185                    made.push((node, tok));
3186                }
3187                model.link_sentence(&made);
3188            }
3189            // One `# global.Entity` header serves the set: a
3190            // release declares it identically in every file.
3191            let header = parsed
3192                .iter()
3193                .flat_map(|(_, d)| d.globals.iter())
3194                .find(|(k, _)| k == "Entity")
3195                .map(|(_, v)| v.as_str());
3196            model.decode_mentions(header);
3197        }
3198        Ok(model)
3199    }
3200
3201    /// One annotated token under `block`, from a CoNLL-U line.
3202    fn push_annotated(
3203        &mut self,
3204        block: NodeId,
3205        tok: &ConlluToken,
3206        n: u32,
3207        sentence: u32,
3208        mwt: Option<(String, String)>,
3209    ) -> NodeId {
3210        let node = push(&mut self.nodes, Kind::Token, block);
3211        let t = &mut self.nodes[node.0 as usize];
3212        t.text = tok.form.clone();
3213        t.prose = tok.form.clone();
3214        let opt = |v: &str| (v != "_" && !v.is_empty()).then(|| v.to_string());
3215        let mut keys = key_values(&tok.feats);
3216        keys.extend(key_values(&tok.misc));
3217        keys.extend(tok.extra.iter().cloned());
3218        t.token = Some(TokenInfo {
3219            class: class_of_annotated(&tok.form, &tok.upos),
3220            n,
3221            sentence,
3222            span: (0, 0),
3223            at: 0,
3224            spans: Vec::new(),
3225            annot: Some(Box::new(Annot {
3226                id: tok.id.clone(),
3227                lemma: opt(&tok.lemma),
3228                upos: opt(&tok.upos),
3229                xpos: opt(&tok.xpos),
3230                feats: opt(&tok.feats),
3231                deprel: opt(&tok.deprel),
3232                deps: opt(&tok.deps),
3233                misc: opt(&tok.misc),
3234                keys,
3235                mwt,
3236                empty: tok.empty,
3237                head: None,
3238                dependents: Vec::new(),
3239                eheads: Vec::new(),
3240                edependents: Vec::new(),
3241                mentions: Vec::new(),
3242            })),
3243        });
3244        node
3245    }
3246
3247    /// The edges of one sentence: the basic head (`->head`,
3248    /// `<-head`) and the enhanced graph (`->ehead`, `<-ehead`,
3249    /// the relation as edge data), resolved by CoNLL-U id.
3250    fn link_sentence(&mut self, made: &[(NodeId, &ConlluToken)]) {
3251        let find = |id: &str| made.iter().find(|(_, t)| t.id == id).map(|(n, _)| *n);
3252        for (node, tok) in made {
3253            if !tok.empty
3254                && tok.head != "0"
3255                && tok.head != "_"
3256                && !tok.head.is_empty()
3257                && let Some(h) = find(&tok.head)
3258            {
3259                if let Some(a) = self.nodes[node.0 as usize]
3260                    .token
3261                    .as_mut()
3262                    .and_then(|t| t.annot.as_mut())
3263                {
3264                    a.head = Some(h);
3265                }
3266                if let Some(a) = self.nodes[h.0 as usize]
3267                    .token
3268                    .as_mut()
3269                    .and_then(|t| t.annot.as_mut())
3270                {
3271                    a.dependents.push(*node);
3272                }
3273            }
3274            if tok.deps == "_" || tok.deps.is_empty() {
3275                continue;
3276            }
3277            for entry in tok.deps.split('|') {
3278                let Some((h, rel)) = entry.split_once(':') else {
3279                    continue;
3280                };
3281                if h == "0" {
3282                    continue;
3283                }
3284                let Some(target) = find(h) else {
3285                    continue;
3286                };
3287                if let Some(a) = self.nodes[node.0 as usize]
3288                    .token
3289                    .as_mut()
3290                    .and_then(|t| t.annot.as_mut())
3291                {
3292                    a.eheads.push((target, rel.to_string()));
3293                }
3294                if let Some(a) = self.nodes[target.0 as usize]
3295                    .token
3296                    .as_mut()
3297                    .and_then(|t| t.annot.as_mut())
3298                {
3299                    a.edependents.push((*node, rel.to_string()));
3300                }
3301            }
3302        }
3303    }
3304    /// The mentions of the annotation (ruling #63), decoded once
3305    /// the tokens carry their keys: a `ner` key's BIO / BIOES tags
3306    /// (Stanza, spacy-conll — `B-PERSON` opens, `I-`/`E-` continue
3307    /// within the sentence, `S-` stands alone, `O` closes) and
3308    /// CorefUD's `Entity=` brackets (`(e1-person-1-` opens, `e1)`
3309    /// closes, `(e1-person-1)` is one token; attributes in the
3310    /// `# global.Entity` header's order, `eid` first; nesting and
3311    /// crossing allowed, so a token may fall in several). Each
3312    /// token records the mentions it falls in.
3313    fn decode_mentions(&mut self, entity_header: Option<&str>) {
3314        self.mentions.clear();
3315        let tokens: Vec<NodeId> = (0..self.nodes.len())
3316            .filter(|&i| {
3317                self.nodes[i]
3318                    .token
3319                    .as_ref()
3320                    .is_some_and(|t| t.annot.is_some())
3321            })
3322            .map(|i| NodeId(i as u64))
3323            .collect();
3324        let key = |nodes: &[Node], t: NodeId, name: &str| -> Option<String> {
3325            nodes[t.0 as usize]
3326                .token
3327                .as_ref()?
3328                .annot
3329                .as_ref()?
3330                .key(name)
3331                .map(str::to_string)
3332        };
3333        let sentence_of = |nodes: &[Node], t: NodeId| -> u32 {
3334            nodes[t.0 as usize].token.as_ref().map_or(0, |t| t.sentence)
3335        };
3336        // Named entities: BIO / BIOES.
3337        let mut open: Option<(usize, Option<String>, u32)> = None;
3338        for &t in &tokens {
3339            let Some(tag) = key(&self.nodes, t, "ner") else {
3340                open = None;
3341                continue;
3342            };
3343            let (prefix, etype) = match tag.split_once('-') {
3344                Some((p, e)) => (p.to_string(), Some(e.to_string())),
3345                None => (tag.clone(), None),
3346            };
3347            let sentence = sentence_of(&self.nodes, t);
3348            let continues = matches!(prefix.as_str(), "I" | "E")
3349                && open
3350                    .as_ref()
3351                    .is_some_and(|(_, ty, s)| *ty == etype && *s == sentence);
3352            match prefix.as_str() {
3353                "B" | "S" | "I" | "E" => {
3354                    if continues {
3355                        let (idx, _, _) = open.as_ref().unwrap();
3356                        self.mentions[*idx].tokens.push(t);
3357                    } else {
3358                        self.mentions.push(Mention {
3359                            tokens: vec![t],
3360                            etype: etype.clone(),
3361                            cluster: None,
3362                        });
3363                    }
3364                    let idx = if continues {
3365                        open.as_ref().unwrap().0
3366                    } else {
3367                        self.mentions.len() - 1
3368                    };
3369                    open = if matches!(prefix.as_str(), "B" | "I") {
3370                        Some((idx, etype, sentence))
3371                    } else {
3372                        None
3373                    };
3374                }
3375                _ => open = None,
3376            }
3377        }
3378        // Coreference: CorefUD brackets.
3379        let order: Vec<String> = entity_header
3380            .unwrap_or("eid-etype-head-other")
3381            .split('-')
3382            .map(str::to_string)
3383            .collect();
3384        let mut stack: Vec<(String, usize)> = Vec::new();
3385        for &t in &tokens {
3386            let Some(spec) = key(&self.nodes, t, "Entity") else {
3387                for (_, idx) in &stack {
3388                    if self.mentions[*idx].tokens.last() != Some(&t) {
3389                        self.mentions[*idx].tokens.push(t);
3390                    }
3391                }
3392                continue;
3393            };
3394            let mut closings: Vec<String> = Vec::new();
3395            for entry in entity_entries(&spec) {
3396                let entry = entry.as_str();
3397                let opening = entry.starts_with('(');
3398                let closing = entry.ends_with(')');
3399                let body = entry.trim_start_matches('(').trim_end_matches(')');
3400                if opening {
3401                    let mut attrs = body.splitn(order.len(), '-');
3402                    let eid = attrs.next().unwrap_or("").to_string();
3403                    let mut etype = None;
3404                    for (name, val) in order.iter().skip(1).zip(attrs) {
3405                        if name == "etype" && !val.is_empty() {
3406                            etype = Some(val.to_string());
3407                        }
3408                    }
3409                    // A discontinuous mention's parts (`e1[1/2]`)
3410                    // share the cluster.
3411                    let cluster = eid.split('[').next().unwrap_or(&eid).to_string();
3412                    let idx = self.mentions.len();
3413                    self.mentions.push(Mention {
3414                        tokens: vec![t],
3415                        etype,
3416                        cluster: Some(cluster),
3417                    });
3418                    if !closing {
3419                        stack.push((eid, idx));
3420                    }
3421                } else if closing {
3422                    closings.push(body.to_string());
3423                }
3424            }
3425            for (_, idx) in &stack {
3426                if self.mentions[*idx].tokens.last() != Some(&t) {
3427                    self.mentions[*idx].tokens.push(t);
3428                }
3429            }
3430            for eid in closings {
3431                if let Some(pos) = stack.iter().rposition(|(e, _)| *e == eid) {
3432                    stack.remove(pos);
3433                }
3434            }
3435        }
3436        // Each token's membership, by ordinal.
3437        for (i, m) in self.mentions.iter().enumerate() {
3438            for &t in &m.tokens {
3439                if let Some(a) = self.nodes[t.0 as usize]
3440                    .token
3441                    .as_mut()
3442                    .and_then(|t| t.annot.as_mut())
3443                {
3444                    a.mentions.push(i as u32 + 1);
3445                }
3446            }
3447        }
3448    }
3449
3450    /// A mention's text: its tokens' forms, a space between them
3451    /// unless the annotation says `SpaceAfter=No`.
3452    fn mention_text(&self, m: &Mention) -> String {
3453        let mut out = String::new();
3454        for (i, &t) in m.tokens.iter().enumerate() {
3455            let n = &self.nodes[t.0 as usize];
3456            if i > 0 {
3457                let prev = &self.nodes[m.tokens[i - 1].0 as usize];
3458                let glued = prev
3459                    .token
3460                    .as_ref()
3461                    .and_then(|t| t.annot.as_ref())
3462                    .and_then(|a| a.key("SpaceAfter"))
3463                    == Some("No");
3464                if !glued {
3465                    out.push(' ');
3466                }
3467            }
3468            out.push_str(&n.prose);
3469        }
3470        out
3471    }
3472
3473    /// The nearest literary-reading field over a token: its spans
3474    /// innermost first, then the block the token belongs to.
3475    fn token_field(&self, node: NodeId, name: &str) -> Option<Value> {
3476        let t = self.nodes[node.0 as usize].token.as_ref()?;
3477        // A cast table's own annotation on the token comes first.
3478        if let Some(v) = field_values(&self.nodes[node.0 as usize].fields, name) {
3479            return Some(v);
3480        }
3481        for &s in t.spans.iter().rev() {
3482            if let Some(v) = field_values(&self.nodes[s.0 as usize].fields, name) {
3483                return Some(v);
3484            }
3485        }
3486        let mut at = self.nodes[node.0 as usize].parent;
3487        while let Some(p) = at {
3488            let n = &self.nodes[p.0 as usize];
3489            if !(n.kind == Kind::Sentence && n.derived) {
3490                return field_values(&n.fields, name);
3491            }
3492            at = n.parent;
3493        }
3494        None
3495    }
3496
3497    /// One value or a list, for a token in one or several mentions.
3498    fn mention_values(&self, node: NodeId, f: impl Fn(&Mention) -> Option<Value>) -> Option<Value> {
3499        let a = self.nodes[node.0 as usize].token.as_ref()?.annot.as_ref()?;
3500        let vs: Vec<Value> = a
3501            .mentions
3502            .iter()
3503            .filter_map(|&i| f(&self.mentions[i as usize - 1]))
3504            .collect();
3505        match vs.len() {
3506            0 => None,
3507            1 => vs.into_iter().next(),
3508            _ => Some(Value::list(vs)),
3509        }
3510    }
3511}
3512
3513/// The innermost span under `flow` whose byte range contains
3514/// `lo..hi` — the parent a nested span (a name inside a speech)
3515/// lands under. Spans arrive in pre-order, so the deepest match
3516/// is the right one.
3517fn enclosing_span(nodes: &[Node], flow: NodeId, lo: u32, hi: u32) -> Option<NodeId> {
3518    let mut best: Option<NodeId> = None;
3519    let mut at = flow;
3520    loop {
3521        let next = nodes[at.0 as usize].children.iter().rev().find_map(|&c| {
3522            let n = &nodes[c.0 as usize];
3523            match (n.kind, n.at) {
3524                (Kind::Span, Some((slo, shi)))
3525                    if slo <= lo && hi <= shi && (slo, shi) != (lo, hi) =>
3526                {
3527                    Some(c)
3528                }
3529                _ => None,
3530            }
3531        });
3532        match next {
3533            Some(c) => {
3534                best = Some(c);
3535                at = c;
3536            }
3537            None => return best,
3538        }
3539    }
3540}
3541
3542/// Every value under `name` among a node's fields: one value, a
3543/// list where the key repeats, nothing where it is absent.
3544fn field_values(fields: &[(String, String)], name: &str) -> Option<Value> {
3545    let vs: Vec<Value> = fields
3546        .iter()
3547        .filter(|(k, _)| k == name)
3548        .map(|(_, v)| Value::Str(v.clone()))
3549        .collect();
3550    match vs.len() {
3551        0 => None,
3552        1 => vs.into_iter().next(),
3553        _ => Some(Value::list(vs)),
3554    }
3555}
3556
3557fn push(nodes: &mut Vec<Node>, kind: Kind, parent: NodeId) -> NodeId {
3558    let id = NodeId(nodes.len() as u64);
3559    nodes.push(Node::new(kind, Some(parent)));
3560    nodes[parent.0 as usize].children.push(id);
3561    id
3562}
3563
3564/// Denormalize a table into nested lists (see the module doc).
3565/// The column name lands as the cell's `::lemma` — a cell's own
3566/// label (a row's `th`) wins over the positional header entry —
3567/// and never as folded text: addressing is property projection,
3568/// the flattening rule alone spells `lemma: value`.
3569fn lower_table(
3570    nodes: &mut Vec<Node>,
3571    parent: NodeId,
3572    lemma: Option<String>,
3573    headers: Option<Vec<String>>,
3574    rows: Vec<Vec<Cell>>,
3575) {
3576    let list = push(nodes, Kind::OrderedList, parent);
3577    {
3578        let n = &mut nodes[list.0 as usize];
3579        n.table = true;
3580        n.lemma = lemma.map(|l| normalize_ws(&l)).filter(|l| !l.is_empty());
3581    }
3582    for (i, row) in rows.into_iter().enumerate() {
3583        let item = push(nodes, Kind::OrderedItem, list);
3584        nodes[item.0 as usize].taxis = Some(i as i64 + 1);
3585        nodes[item.0 as usize].row = true;
3586        let cells = push(nodes, Kind::UnorderedList, item);
3587        for (j, cell) in row.into_iter().enumerate() {
3588            let value = normalize_ws(&cell.text);
3589            if value.is_empty() {
3590                continue;
3591            }
3592            let label = cell
3593                .label
3594                .as_deref()
3595                .or_else(|| headers.as_ref().and_then(|h| h.get(j)).map(|h| h.as_str()))
3596                .map(normalize_ws)
3597                .filter(|h| !h.is_empty());
3598            let cell_item = push(nodes, Kind::UnorderedItem, cells);
3599            let n = &mut nodes[cell_item.0 as usize];
3600            n.cell = true;
3601            n.lemma = label;
3602            n.text = value;
3603        }
3604    }
3605}
3606
3607/// Lower a verse block (ruling #37): `verse` → `strophe`s →
3608/// `stichos` lines. The stichos taxis numbers lines 1-based,
3609/// continuously across strophes — Iliad 1.34 is a taxis; the
3610/// strophe taxis is its ordinal. Empty lines are dropped (the
3611/// strophe boundary carries the separation).
3612fn lower_verse(
3613    nodes: &mut Vec<Node>,
3614    parent: NodeId,
3615    lemma: Option<String>,
3616    strophes: Vec<Vec<String>>,
3617    hypograph: Option<String>,
3618) -> NodeId {
3619    let verse = push(nodes, Kind::Verse, parent);
3620    {
3621        let n = &mut nodes[verse.0 as usize];
3622        n.lemma = lemma.map(|l| normalize_ws(&l)).filter(|l| !l.is_empty());
3623        n.hypograph = hypograph
3624            .map(|h| normalize_ws(&h))
3625            .filter(|h| !h.is_empty());
3626    }
3627    let mut line_no = 0i64;
3628    for (i, strophe) in strophes.into_iter().enumerate() {
3629        let sid = push(nodes, Kind::Strophe, verse);
3630        nodes[sid.0 as usize].taxis = Some(i as i64 + 1);
3631        for line in strophe {
3632            let line = line.trim_end().to_string();
3633            if line.trim().is_empty() {
3634                continue;
3635            }
3636            line_no += 1;
3637            let lid = push(nodes, Kind::Stichos, sid);
3638            let n = &mut nodes[lid.0 as usize];
3639            n.taxis = Some(line_no);
3640            n.text = line;
3641        }
3642    }
3643    verse
3644}
3645
3646/// Compute every node's flattened prose: lemma first, then the
3647/// node's own text, then its children's prose in order, then the
3648/// hypograph, block-joined with newlines. On a list *item*, the
3649/// lemma joins the rest with `: ` instead — an item's lemma names
3650/// its content inline (a table cell reads `Outcome: Emus won`, a
3651/// definition reads `term: description`), where a section's lemma
3652/// opens its block. Children always carry larger indices than
3653/// their parents (nodes are interned in document order), so one
3654/// reverse index scan suffices — no recursion.
3655fn flatten_prose(nodes: &mut [Node]) {
3656    for i in (0..nodes.len()).rev() {
3657        let inline_lemma = matches!(nodes[i].kind, Kind::UnorderedItem | Kind::OrderedItem);
3658        let mut lemma_part: Option<String> = None;
3659        let mut parts: Vec<String> = Vec::new();
3660        if let Some(lemma) = &nodes[i].lemma
3661            && !lemma.is_empty()
3662        {
3663            if inline_lemma {
3664                lemma_part = Some(lemma.clone());
3665            } else if matches!(nodes[i].kind, Kind::Paragraph | Kind::Dialogue) {
3666                // A speech's lemma is its speaker — who said it,
3667                // not what was said: `::lemma`, never prose, so
3668                // the words alone are read, tokenized, counted.
3669            } else {
3670                parts.push(lemma.clone());
3671            }
3672        }
3673        if !nodes[i].text.is_empty() {
3674            parts.push(nodes[i].text.clone());
3675        }
3676        let mut child_parts: Vec<String> = Vec::new();
3677        for &child in nodes[i].children.clone().iter() {
3678            // A ref's recorded link text is already part of the
3679            // flow it was met in; an anchor is invisible. Neither
3680            // adds prose (the deixis/index-mark rule).
3681            if matches!(
3682                nodes[child.0 as usize].kind,
3683                Kind::Ref | Kind::Cit | Kind::Anchor | Kind::Span | Kind::Milestone
3684            ) {
3685                continue;
3686            }
3687            let prose = &nodes[child.0 as usize].prose;
3688            if !prose.is_empty() {
3689                child_parts.push(prose.clone());
3690            }
3691        }
3692        // Ruling #37: a verse block separates its strophes by a
3693        // blank line (lemma and hypograph still join normally) —
3694        // byte-compatible with the old verbatim lowering.
3695        if nodes[i].kind == Kind::Verse {
3696            let joined = child_parts.join("\n\n");
3697            if !joined.is_empty() {
3698                parts.push(joined);
3699            }
3700        } else if nodes[i].kind == Kind::Paragraph
3701            && !child_parts.is_empty()
3702            && nodes[i]
3703                .children
3704                .iter()
3705                .all(|c| nodes[c.0 as usize].kind == Kind::Sentence)
3706        {
3707            // Ruling #63: a treebank paragraph is its sentences
3708            // run together, as the prose would read.
3709            parts.push(child_parts.join(" "));
3710        } else {
3711            parts.extend(child_parts);
3712        }
3713        if let Some(hypograph) = &nodes[i].hypograph
3714            && !hypograph.is_empty()
3715        {
3716            parts.push(hypograph.clone());
3717        }
3718        let mut prose = parts.join("\n");
3719        if let Some(lemma) = lemma_part {
3720            prose = if prose.is_empty() {
3721                lemma
3722            } else {
3723                format!("{lemma}: {prose}")
3724            };
3725        }
3726        nodes[i].prose = prose;
3727    }
3728}
3729
3730impl TextModel {
3731    /// The body prose: everything between the lemma and the
3732    /// hypograph — the simmere anatomy's third member, derived
3733    /// from the flattened prose by construction (the lemma joins
3734    /// a block on its own line, an item with `: `; the hypograph
3735    /// closes on its own line).
3736    fn grammata(&self, node: NodeId) -> String {
3737        let n = &self.nodes[node.0 as usize];
3738        let mut s = n.prose.as_str();
3739        if let Some(lemma) = &n.lemma
3740            && !lemma.is_empty()
3741            && let Some(rest) = s.strip_prefix(lemma.as_str())
3742        {
3743            s = rest
3744                .strip_prefix(": ")
3745                .or_else(|| rest.strip_prefix('\n'))
3746                .unwrap_or(rest);
3747        }
3748        if let Some(h) = &n.hypograph
3749            && !h.is_empty()
3750            && let Some(rest) = s.strip_suffix(h.as_str())
3751        {
3752            s = rest.strip_suffix('\n').unwrap_or(rest);
3753        }
3754        s.trim_end().to_string()
3755    }
3756}
3757
3758impl AstAdapter for TextModel {
3759    fn root(&self) -> NodeId {
3760        self.root
3761    }
3762
3763    fn children(&self, node: NodeId) -> Vec<NodeId> {
3764        self.nodes[node.0 as usize].children.clone()
3765    }
3766
3767    fn name(&self, node: NodeId) -> Option<String> {
3768        let n = &self.nodes[node.0 as usize];
3769        if n.kind == Kind::Span {
3770            return n.span_kind.clone();
3771        }
3772        n.kind.name().map(str::to_string)
3773    }
3774
3775    fn parent(&self, node: NodeId) -> Option<NodeId> {
3776        self.nodes[node.0 as usize].parent
3777    }
3778
3779    /// The positional index (ruling #62, phase 2): `//token[::lower
3780    /// = "word"]` below any node answers from the form → tokens
3781    /// map, scoped to the node's subtree. Only a plain word is
3782    /// answered — a literal with a numeric, temporal, durational or
3783    /// unital reading is left to the walk, whose equality reads it.
3784    fn descendants_where(
3785        &self,
3786        node: NodeId,
3787        name: &str,
3788        property: &str,
3789        value: &Value,
3790    ) -> Option<Vec<(NodeId, usize)>> {
3791        if name != "token" || property != "lower" {
3792            return None;
3793        }
3794        let Value::Str(word) = value else {
3795            return None;
3796        };
3797        if word.is_empty() || !word.chars().all(|c| c.is_alphabetic() || "’'-".contains(c)) {
3798            return None;
3799        }
3800        let index = self.token_index.get_or_init(|| {
3801            let mut map: std::collections::HashMap<String, Vec<NodeId>> =
3802                std::collections::HashMap::new();
3803            for (i, n) in self.nodes.iter().enumerate() {
3804                if n.token.is_some() {
3805                    map.entry(self.lower(&n.prose))
3806                        .or_default()
3807                        .push(NodeId(i as u64));
3808                }
3809            }
3810            map
3811        });
3812        let Some(hits) = index.get(word.as_str()) else {
3813            return Some(Vec::new());
3814        };
3815        let mut out = Vec::new();
3816        for &t in hits {
3817            // The depth below `node`, if `t` is in its subtree.
3818            let mut depth = 0usize;
3819            let mut cur = t;
3820            while let Some(p) = self.nodes[cur.0 as usize].parent {
3821                depth += 1;
3822                if p == node {
3823                    out.push((t, depth));
3824                    break;
3825                }
3826                cur = p;
3827            }
3828        }
3829        Some(out)
3830    }
3831
3832    /// The `<block>` family on every block node, plus `<table>` on
3833    /// a list that denormalizes a table. Kinds are node names, not
3834    /// traits.
3835    fn traits(&self, node: NodeId) -> Vec<String> {
3836        let n = &self.nodes[node.0 as usize];
3837        let mut out = Vec::new();
3838        // The document wears its declared classification: one
3839        // trait per tag and one for the category — the LDAP rule,
3840        // where a multi-valued classifying attribute is the node's
3841        // traits — each in its own namespace (`<tag:pandas>`,
3842        // `<category:Guides>`), so a tag can never collide with a
3843        // structural trait like `<table>` or `<note>`, and spelled
3844        // as declared, case kept.
3845        if n.kind == Kind::Document {
3846            for (ns, t) in self
3847                .head
3848                .tags
3849                .iter()
3850                .map(|t| ("tag", t))
3851                .chain(self.head.category.iter().map(|c| ("category", c)))
3852            {
3853                let name = HeadMeta::trait_name(t);
3854                if name.is_empty() {
3855                    continue;
3856                }
3857                let name = format!("{ns}:{name}");
3858                if !out.contains(&name) {
3859                    out.push(name);
3860                }
3861            }
3862        }
3863        // A callout is inline apparatus, not a block (ruling #35);
3864        // so is an index mark (ruling #36). Strophes and stichos
3865        // lines are sub-block structure (ruling #37, litogramma's
3866        // own family rule) — the verse block carries the trait.
3867        if let Some(t) = &n.token {
3868            // An empty node (`8.1`) is in the enhanced graph, not
3869            // in the text: `<empty>` in place of a class.
3870            if t.annot.as_ref().is_some_and(|a| a.empty) {
3871                out.push("empty".to_string());
3872            } else {
3873                out.push(t.class.name().to_string());
3874                // A quotation mark wears `<quote>` beside `<punct>`
3875                // (ruling #65), so the punctuation counts stand.
3876                if t.class == TokenClass::Punct && is_quote(&n.prose) {
3877                    out.push("quote".to_string());
3878                }
3879            }
3880        }
3881        if n.kind != Kind::Document
3882            && !n.deixis
3883            && !n.derived
3884            && !matches!(
3885                n.kind,
3886                Kind::IndexMark
3887                    | Kind::Ref
3888                    | Kind::Cit
3889                    | Kind::Anchor
3890                    | Kind::Strophe
3891                    | Kind::Stichos
3892                    | Kind::Token
3893                    | Kind::Span
3894                    | Kind::Milestone
3895                    | Kind::Character
3896                    | Kind::Place
3897            )
3898        {
3899            out.push("block".to_string());
3900        }
3901        // The literary reading: a simmere's genoses are its
3902        // traits (`//quotation<said>`, `//annotation<persname>`).
3903        for g in &n.genoses {
3904            if !out.contains(g) {
3905                out.push(g.clone());
3906            }
3907        }
3908        // The corpus reading's sentence tier (ruling #64): a
3909        // sentence the segmentation derived, as against a
3910        // treebank's declared one.
3911        if n.derived {
3912            out.push("derived".to_string());
3913        }
3914        // The reference vocabulary (atrep's semantic traits): a
3915        // dangling mention keeps its node and the linter finds it;
3916        // `<target>` marks what bears a referable name — anchors
3917        // and labeled blocks, never the apparatus families (their
3918        // onyms pair within the family, not in this namespace).
3919        if matches!(n.kind, Kind::Ref | Kind::Cit) && n.dangling {
3920            out.push("dangling".to_string());
3921        }
3922        // A structured entry's genus (liber, commentarius, …)
3923        // surfaces as its trait, the genos rule.
3924        if n.kind == Kind::Bib
3925            && let Some(g) = &n.genus
3926        {
3927            out.push(g.clone());
3928        }
3929        if n.kind == Kind::Anchor
3930            || (n.onym.is_some()
3931                && !n.deixis
3932                && !matches!(
3933                    n.kind,
3934                    Kind::Footnote
3935                        | Kind::Endnote
3936                        | Kind::Aside
3937                        | Kind::IndexMark
3938                        | Kind::Ref
3939                        | Kind::Cit
3940                        | Kind::Bib
3941                        | Kind::Span
3942                        | Kind::Milestone
3943                ))
3944        {
3945            out.push("target".to_string());
3946        }
3947        if n.kind == Kind::Cit && n.locator.is_some() {
3948            out.push("locator".to_string());
3949        }
3950        if matches!(n.kind, Kind::Footnote | Kind::Endnote | Kind::Aside) {
3951            // The body IS the note — `<note>` marks it, whichever
3952            // note family; the callout carries `<deixis>` alone.
3953            // An aside body is content, not apparatus: no
3954            // `<note>`. No both-ends trait: `//footnote` already
3955            // gathers a family whole, `//*<deixis>` the callouts
3956            // across families, `//*<note>` the note bodies.
3957            if n.deixis {
3958                out.push("deixis".to_string());
3959            } else if n.kind != Kind::Aside {
3960                out.push("note".to_string());
3961            }
3962            if n.dangling {
3963                out.push("dangling".to_string());
3964            }
3965        }
3966        if n.table {
3967            out.push("table".to_string());
3968        }
3969        if n.row {
3970            out.push("row".to_string());
3971        }
3972        if n.cell {
3973            out.push("cell".to_string());
3974        }
3975        out
3976    }
3977
3978    /// `::lemma` (title), `::hypograph` (footer or attribution),
3979    /// `::taxis` (ordinal), `::text` (the flattened prose, same as
3980    /// the bare projection).
3981    /// The Greek anatomy — `::lemma`, `::grammata`, `::hypograph`,
3982    /// `::taxis` — plus the friendly aliases (`::title`, `::body`,
3983    /// `::attribution`, `::ord`), answered here because this
3984    /// adapter's property surface IS the vocabulary; on data
3985    /// adapters those spellings stay ordinary field names. The
3986    /// Greek is canon in docs and reflection preserves whichever
3987    /// spelling was written.
3988    fn property(&self, node: NodeId, name: &str) -> Option<Value> {
3989        let n = &self.nodes[node.0 as usize];
3990        // The citation in force at any node (ruling #81).
3991        if name == "cite" {
3992            return self.cite(node).map(Value::Str);
3993        }
3994        // The document node answers its declared identity: the
3995        // curated core of what the head says.
3996        if n.kind == Kind::Document {
3997            let h = &self.head;
3998            match name {
3999                "title" | "lemma" => return h.title.clone().map(Value::Str),
4000                "description" => return h.description.clone().map(Value::Str),
4001                "author" => return h.author.clone().map(Value::Str),
4002                "category" => return h.category.clone().map(Value::Str),
4003                "tags" => {
4004                    return (!h.tags.is_empty())
4005                        .then(|| Value::list(h.tags.iter().cloned().map(Value::Str).collect()));
4006                }
4007                "published" => return h.published.clone(),
4008                "modified" => return h.modified.clone(),
4009                _ => {}
4010            }
4011        }
4012        // A structured entry answers its campi first — under the
4013        // canonical Latin name or any census alias (BibLaTeX's
4014        // field names included), case-insensitively — ahead of
4015        // the general vocabulary, so `::title` on a bib means
4016        // titulus, not the lemma alias.
4017        if n.kind == Kind::Bib && !n.fields.is_empty() {
4018            let canon = self
4019                .bib_aliases
4020                .get(&name.to_lowercase())
4021                .map(String::as_str)
4022                .unwrap_or(name);
4023            if let Some((_, v)) = n.fields.iter().find(|(k, _)| k == canon) {
4024                return Some(Value::Str(v.clone()));
4025            }
4026        }
4027        match name {
4028            // A link into the node, as far as the served form can
4029            // narrow it; the anchor it lands on; and the record a
4030            // client renders as a clickable result.
4031            "href" => return self.href(node).map(Value::Str),
4032            "anchor" => return self.nearest_onym(node).map(|(a, _)| Value::Str(a)),
4033            "link" => {
4034                let href = self.href(node)?;
4035                let text = match self.default_value(node) {
4036                    Some(Value::Str(t)) => t,
4037                    _ => String::new(),
4038                };
4039                return Some(Value::Record(vec![
4040                    (
4041                        "title".to_string(),
4042                        self.document_title().map(Value::Str).unwrap_or(Value::Null),
4043                    ),
4044                    ("href".to_string(), Value::Str(href)),
4045                    ("text".to_string(), Value::Str(text)),
4046                ]));
4047            }
4048            _ => {}
4049        }
4050        match name {
4051            // The corpus reading's token properties; the linguistic
4052            // ones answer only under a CoNLL-U annotation.
4053            "lemma" | "upos" | "xpos" | "feats" | "deprel" | "deps" | "misc" | "id" | "mwt"
4054                if n.token.as_ref().is_some_and(|t| t.annot.is_some()) =>
4055            {
4056                let a = n.token.as_ref()?.annot.as_ref()?;
4057                match name {
4058                    "lemma" => a.lemma.clone(),
4059                    "upos" => a.upos.clone(),
4060                    "xpos" => a.xpos.clone(),
4061                    "feats" => a.feats.clone(),
4062                    "deprel" => a.deprel.clone(),
4063                    "deps" => a.deps.clone(),
4064                    "misc" => a.misc.clone(),
4065                    "id" => Some(a.id.clone()),
4066                    _ => a.mwt.as_ref().map(|(_, form)| form.clone()),
4067                }
4068                .map(Value::Str)
4069            }
4070            // A treebank sentence: `::id` its sent_id, every other
4071            // comment under its key (ruling #63).
4072            "id" if n.kind == Kind::Sentence => n
4073                .fields
4074                .iter()
4075                .find(|(k, _)| k == "sent_id")
4076                .map(|(_, v)| Value::Str(v.clone())),
4077            "lemma" | "title" => n.lemma.clone().map(Value::Str),
4078            "onym" => n.onym.clone().map(Value::Str),
4079            // On an index mark the onym IS the term (ruling #36).
4080            "term" if n.kind == Kind::IndexMark => n.onym.clone().map(Value::Str),
4081            // A ref's word: the identifier as written. Never the
4082            // resolved content — that is what `-->` is for.
4083            "target" if matches!(n.kind, Kind::Ref | Kind::Cit) => n.target.clone().map(Value::Str),
4084            // A cit's pinpoint, as written: `\cite[I.1]{key}`,
4085            // `[@key, I.1]`.
4086            "locator" if n.kind == Kind::Cit => n.locator.clone().map(Value::Str),
4087            // A structured entry answers its campi (auctor,
4088            // titulus, annus, …) — the bibliogramma vocabulary,
4089            // canonicalized upstream.
4090            _ if n.kind == Kind::Bib => {
4091                let canon = self
4092                    .bib_aliases
4093                    .get(&name.to_lowercase())
4094                    .map(String::as_str)
4095                    .unwrap_or(name);
4096                n.fields
4097                    .iter()
4098                    .find(|(k, _)| k == canon)
4099                    .map(|(_, v)| Value::Str(v.clone()))
4100            }
4101            "hypograph" | "attribution" => n.hypograph.clone().map(Value::Str),
4102            "taxis" | "ord" => n.taxis.map(Value::Int),
4103            "grammata" | "body" => {
4104                let g = self.grammata(node);
4105                if g.is_empty() {
4106                    None
4107                } else {
4108                    Some(Value::Str(g))
4109                }
4110            }
4111            "text" => Some(Value::Str(n.prose.clone())),
4112            "lower" if n.token.is_some() => Some(Value::Str(self.lower(&n.prose))),
4113            "modern" if n.token.is_some() => Some(Value::Str(self.modern(&n.prose))),
4114            "class" if n.token.is_some() => n
4115                .token
4116                .as_ref()
4117                .map(|t| Value::Str(t.class.name().to_string())),
4118            // The text of the token's sentence (its ordinal is
4119            // `::::sentence`): the block's prose over the span.
4120            "sentence" if n.token.is_some() => {
4121                let parent = &self.nodes[n.parent?.0 as usize];
4122                if parent.kind == Kind::Sentence {
4123                    return Some(Value::Str(parent.prose.clone()));
4124                }
4125                let t = n.token.as_ref()?;
4126                let (lo, hi) = (t.span.0 as usize, t.span.1 as usize);
4127                parent
4128                    .prose
4129                    .get(lo..hi)
4130                    .map(|s| Value::Str(s.trim().to_string()))
4131            }
4132            // A mention's text and entity type (ruling #63); a list
4133            // where mentions nest.
4134            "mention" if n.token.is_some() => {
4135                self.mention_values(node, |m| Some(Value::Str(self.mention_text(m))))
4136            }
4137            "entity" if n.token.is_some() => {
4138                self.mention_values(node, |m| m.etype.clone().map(Value::Str))
4139            }
4140            // Every `Key=Value` of FEATS, MISC and a CoNLL-U Plus
4141            // column, as the annotation spells it or case-folded.
4142            // A cast table's own annotation on the token (its
4143            // `prosopon`) answers when the annotation has no such
4144            // key.
4145            _ if n.token.as_ref().is_some_and(|t| t.annot.is_some()) => n
4146                .token
4147                .as_ref()?
4148                .annot
4149                .as_ref()?
4150                .key(name)
4151                .map(|v| Value::Str(v.to_string()))
4152                .or_else(|| self.token_field(node, name)),
4153            // A treebank sentence's `# key = value` comments, the
4154            // same two spellings.
4155            _ if n.kind == Kind::Sentence => n
4156                .fields
4157                .iter()
4158                .find(|(k, _)| k == name)
4159                .or_else(|| n.fields.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)))
4160                .map(|(_, v)| Value::Str(v.clone())),
4161            // The literary reading: a span's, a speech's, a
4162            // paragraph's unseen metadata under the sim name of the
4163            // monosim that carried it (`::prosopon`), a list where
4164            // it repeats (`eidos`); a milestone's scheme and value.
4165            _ if matches!(
4166                n.kind,
4167                Kind::Span
4168                    | Kind::Speech
4169                    | Kind::Dialogue
4170                    | Kind::Paragraph
4171                    | Kind::Milestone
4172                    | Kind::Character
4173                    | Kind::Place
4174            ) && !n.fields.is_empty() =>
4175            {
4176                field_values(&n.fields, name)
4177            }
4178            // A token answers the metadata of the innermost span
4179            // covering it, else of its block: `//token[::prosopon
4180            // = "tom"]` is every word Tom speaks.
4181            _ if n.token.is_some() => self.token_field(node, name),
4182            _ => None,
4183        }
4184    }
4185
4186    /// The default projection is the flattened prose of the
4187    /// subtree — lemma first, hypograph last.
4188    fn default_value(&self, node: NodeId) -> Option<Value> {
4189        let n = &self.nodes[node.0 as usize];
4190        if n.kind == Kind::IndexMark {
4191            // Invisible in the surrounding prose; its own
4192            // projection is the term it declares.
4193            return Some(Value::Str(n.onym.clone().unwrap_or_default()));
4194        }
4195        if n.deixis {
4196            // The atrep rule: a resolved reference projects as its
4197            // target's rendered form, degrading to the raw onym
4198            // when dangling.
4199            return Some(Value::Str(match n.note_edge {
4200                Some(body) => self.nodes[body.0 as usize].prose.clone(),
4201                None => n.onym.clone().unwrap_or_default(),
4202            }));
4203        }
4204        if n.kind == Kind::Ref {
4205            // The authored link text; LaTeX \ref has none, so the
4206            // atrep projection rule applies — the target's
4207            // rendered form (its lemma, else its prose), degrading
4208            // to the raw target when dangling.
4209            if !n.text.is_empty() {
4210                return Some(Value::Str(n.text.clone()));
4211            }
4212            return Some(Value::Str(match n.ref_edge {
4213                Some(t) => {
4214                    let b = &self.nodes[t.0 as usize];
4215                    b.lemma.clone().unwrap_or_else(|| b.prose.clone())
4216                }
4217                None => n.target.clone().unwrap_or_default(),
4218            }));
4219        }
4220        if n.kind == Kind::Bib && n.prose.is_empty() && !n.fields.is_empty() {
4221            // A structured entry's plain form: its field values in
4222            // source order — the full data, no citation styling.
4223            return Some(Value::Str(
4224                n.fields
4225                    .iter()
4226                    .map(|(_, v)| v.as_str())
4227                    .collect::<Vec<_>>()
4228                    .join(". "),
4229            ));
4230        }
4231        if n.kind == Kind::Cit {
4232            // The mark's own identity: the raw key. The entry is
4233            // one arrow away (`//cit--> ::`); rendered citation
4234            // styles are presentation, not structure.
4235            return Some(Value::Str(n.target.clone().unwrap_or_default()));
4236        }
4237        if n.kind == Kind::Anchor {
4238            // Invisible in the prose; its own projection is the
4239            // name it bears.
4240            return Some(Value::Str(n.onym.clone().unwrap_or_default()));
4241        }
4242        Some(Value::Str(n.prose.clone()))
4243    }
4244
4245    /// The edge label is the family name both ends share —
4246    /// `->footnote` / `->endnote` — the node's own kind, since
4247    /// resolution re-kinds an open-family callout to its body.
4248    fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
4249        let n = &self.nodes[node.0 as usize];
4250        // The literary reading: a mention reaches its character.
4251        let character: Vec<(String, NodeId)> = n
4252            .character
4253            .map(|c| ("character".to_string(), c))
4254            .into_iter()
4255            .chain(n.place.map(|p| ("place".to_string(), p)))
4256            .collect();
4257        // A token's dependency head (`->head`) and its enhanced
4258        // heads (`->ehead`, the relation at `$-::rel`), under a
4259        // CoNLL-U annotation.
4260        if let Some(a) = n.token.as_ref().and_then(|t| t.annot.as_ref()) {
4261            let mut out = Vec::new();
4262            if let Some(h) = a.head {
4263                out.push(("head".to_string(), h));
4264            }
4265            for (t, _) in &a.eheads {
4266                out.push(("ehead".to_string(), *t));
4267            }
4268            out.extend(character);
4269            return out;
4270        }
4271        if !character.is_empty() && n.kind != Kind::Span {
4272            return character;
4273        }
4274        if let Some(t) = n.ref_edge {
4275            // The atrep rule: a resolved mention emits its typed
4276            // crosslink — `->ref`, or `->cit` for a citation.
4277            let label = if n.kind == Kind::Cit { "cit" } else { "ref" };
4278            return vec![(label.to_string(), t)];
4279        }
4280        // The literary reading: a span reaches the tokens it
4281        // covers (`//quotation<said>->token<word>`).
4282        if n.kind == Kind::Span && !(n.span_tokens.is_empty() && character.is_empty()) {
4283            return n
4284                .span_tokens
4285                .iter()
4286                .map(|&t| ("token".to_string(), t))
4287                .chain(character)
4288                .collect();
4289        }
4290        match n.note_edge {
4291            Some(body) => vec![(n.kind.name().unwrap_or("footnote").to_string(), body)],
4292            None => Vec::new(),
4293        }
4294    }
4295
4296    /// `$-::rel` on a dependency edge: the relation — `->head`'s
4297    /// is the dependent's `::deprel`, `->ehead`'s the enhanced
4298    /// relation to that head.
4299    fn link_property(
4300        &self,
4301        source: NodeId,
4302        label: &str,
4303        target: NodeId,
4304        name: &str,
4305    ) -> Option<Value> {
4306        if name != "rel" {
4307            return None;
4308        }
4309        let a = self.nodes[source.0 as usize]
4310            .token
4311            .as_ref()?
4312            .annot
4313            .as_ref()?;
4314        match label {
4315            "head" if a.head == Some(target) => a.deprel.clone().map(Value::Str),
4316            "ehead" => a
4317                .eheads
4318                .iter()
4319                .find(|(t, _)| *t == target)
4320                .map(|(_, r)| Value::Str(r.clone())),
4321            _ => None,
4322        }
4323    }
4324
4325    fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
4326        let n = &self.nodes[node.0 as usize];
4327        if let Some(a) = n.token.as_ref().and_then(|t| t.annot.as_ref()) {
4328            return a
4329                .dependents
4330                .iter()
4331                .map(|&d| ("head".to_string(), d))
4332                .chain(a.edependents.iter().map(|(d, _)| ("ehead".to_string(), *d)))
4333                .collect();
4334        }
4335        let mut out: Vec<(String, NodeId)> = n
4336            .ref_cites
4337            .iter()
4338            .map(|&c| {
4339                let label = if self.nodes[c.0 as usize].kind == Kind::Cit {
4340                    "cit"
4341                } else {
4342                    "ref"
4343                };
4344                (label.to_string(), c)
4345            })
4346            .collect();
4347        let label = n.kind.name().unwrap_or("footnote");
4348        out.extend(n.cites.iter().map(|&c| (label.to_string(), c)));
4349        let mention_label = if n.kind == Kind::Place {
4350            "place"
4351        } else {
4352            "character"
4353        };
4354        out.extend(n.mentions.iter().map(|&m| (mention_label.to_string(), m)));
4355        out
4356    }
4357
4358    /// `//ref-->`: land on the bearer — the block if a block
4359    /// bears the name, the point anchor if a point does. The
4360    /// text-level hints choose the reading: `-->block` normalizes
4361    /// a point landing to its enclosing block, `-->point` selects
4362    /// point targets only; bare is the honest exact landing.
4363    fn resolve(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<NodeId> {
4364        let n = &self.nodes[node.0 as usize];
4365        if !matches!(n.kind, Kind::Ref | Kind::Cit) || property != "target" {
4366            return None;
4367        }
4368        let landed = n.ref_edge?;
4369        let is_point = self.nodes[landed.0 as usize].kind == Kind::Anchor;
4370        match hint {
4371            None | Some("*") => Some(landed),
4372            Some("point") => is_point.then_some(landed),
4373            Some("block") => {
4374                if !is_point {
4375                    return Some(landed);
4376                }
4377                // The anchor's enclosing block.
4378                let mut at = self.nodes[landed.0 as usize].parent;
4379                while let Some(b) = at {
4380                    let bn = &self.nodes[b.0 as usize];
4381                    if bn.kind != Kind::Document
4382                        && !matches!(bn.kind, Kind::Ref | Kind::Anchor | Kind::IndexMark)
4383                    {
4384                        return Some(b);
4385                    }
4386                    at = bn.parent;
4387                }
4388                None
4389            }
4390            Some(_) => None,
4391        }
4392    }
4393
4394    /// The bare arrow's property: a ref resolves its target.
4395    fn ref_property(&self, node: NodeId) -> Option<String> {
4396        matches!(self.nodes[node.0 as usize].kind, Kind::Ref | Kind::Cit)
4397            .then(|| "target".to_string())
4398    }
4399
4400    /// A ref the producer declared external: the absolute URL,
4401    /// relative targets joined against the document's own URL —
4402    /// the same acquisition rung the html DOM reading speaks.
4403    fn external_ref(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<String> {
4404        if !matches!(hint, None | Some("*")) {
4405            return None;
4406        }
4407        let n = &self.nodes[node.0 as usize];
4408        if n.kind != Kind::Ref || property != "target" || n.internal {
4409            return None;
4410        }
4411        let t = n.target.as_deref()?.trim();
4412        if t.is_empty() || t.starts_with('#') {
4413            return None;
4414        }
4415        let u = match url::Url::parse(t) {
4416            Ok(u) => u,
4417            Err(url::ParseError::RelativeUrlWithoutBase) => self
4418                .link_base
4419                .as_ref()
4420                .or(self.document_url.as_ref())?
4421                .join(t)
4422                .ok()?,
4423            Err(_) => return None,
4424        };
4425        if u.scheme() != "http" && u.scheme() != "https" {
4426            return None;
4427        }
4428        Some(u.into())
4429    }
4430
4431    /// A sibling document's `#fragment` lands here: the bearer of
4432    /// that name — a labeled block, or a point anchor.
4433    fn resolve_fragment(&self, _node: NodeId, fragment: &str) -> Option<NodeId> {
4434        self.onyms.get(fragment).copied()
4435    }
4436
4437    /// Ruling #29: the text level's surface is the vocabulary
4438    /// itself — no document can introduce a property name — so
4439    /// its two annotations answer at `::` as well.
4440    fn aliased_metadata(&self, _node: NodeId) -> &'static [&'static str] {
4441        &["level", "lang", "form", "n"]
4442    }
4443
4444    /// `::::level` on sections (the source heading level) and
4445    /// `::::lang` on verbatim blocks (the declared language).
4446    fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
4447        let n = &self.nodes[node.0 as usize];
4448        // The lossless layer: on the document node, every
4449        // declaration under the name it was declared with.
4450        if n.kind == Kind::Document
4451            && let Some(v) = self.head.declared_value(key)
4452        {
4453            return Some(v);
4454        }
4455        match key {
4456            "level" => n.level.map(|l| Value::Int(l as i64)),
4457            // A verbatim block's declared language; on the
4458            // literary reading, the genos after `foreign`
4459            // (`@/…/@.foreign.la`).
4460            "lang" => n
4461                .lang
4462                .clone()
4463                .or_else(|| {
4464                    let i = n.genoses.iter().position(|g| g == "foreign")?;
4465                    n.genoses.get(i + 1).cloned()
4466                })
4467                .map(Value::Str),
4468            "at" if n.at.is_some() => n.at.map(|(lo, _)| Value::Int(lo as i64)),
4469            // The corpus reading: a token's position and sentence.
4470            // A sentence node's document-wide ordinal, under both
4471            // spellings, so `/paragraph[7]/sentence[3]::::n` and a
4472            // token's `::::sentence` agree (ruling #64).
4473            "n" | "sentence" if n.kind == Kind::Sentence => n.taxis.map(Value::Int),
4474            "n" => n
4475                .token
4476                .as_ref()
4477                .filter(|t| t.n > 0)
4478                .map(|t| Value::Int(t.n as i64)),
4479            "sentence" => n.token.as_ref().map(|t| Value::Int(t.sentence as i64)),
4480            // A mention's ordinal and coreference cluster (ruling
4481            // #63); a list where mentions nest.
4482            "mention" if n.token.is_some() => {
4483                let ords: Vec<Value> = n
4484                    .token
4485                    .as_ref()
4486                    .and_then(|t| t.annot.as_ref())
4487                    .map(|a| a.mentions.iter().map(|&i| Value::Int(i as i64)).collect())
4488                    .unwrap_or_default();
4489                match ords.len() {
4490                    0 => None,
4491                    1 => ords.into_iter().next(),
4492                    _ => Some(Value::list(ords)),
4493                }
4494            }
4495            "entity" if n.token.is_some() => {
4496                self.mention_values(node, |m| m.cluster.clone().map(Value::Str))
4497            }
4498            // The multiword range a word belongs to (`1-2`); its
4499            // surface form is `::mwt`.
4500            "mwt" => n
4501                .token
4502                .as_ref()
4503                .and_then(|t| t.annot.as_ref())
4504                .and_then(|a| a.mwt.as_ref())
4505                .map(|(id, _)| Value::Str(id.clone())),
4506            // `::::resolved` on a callout: the broken-apparatus
4507            // linter's fact (ruling #35).
4508            "resolved" if n.deixis => Some(Value::Bool(!n.dangling)),
4509            // …and on an internal ref: the broken-cross-reference
4510            // linter's fact. External refs resolve at query time,
4511            // so the fact is not theirs to answer.
4512            "resolved" if n.kind == Kind::Cit || (n.kind == Kind::Ref && n.internal) => {
4513                Some(Value::Bool(n.ref_edge.is_some()))
4514            }
4515            // The declared spelling was a margin form (a Tufte
4516            // sidenote): family footnote, placement preserved.
4517            "form" if n.margin => Some(Value::Str("margin".to_string())),
4518            // The entry's genus, also queryable as an annotation.
4519            "genus" if n.kind == Kind::Bib => n.genus.clone().map(Value::Str),
4520            _ => None,
4521        }
4522    }
4523}