Skip to main content

quarb_text/
tier.rs

1//! The corpus reading's tier on plain prose: sentences and tokens no
2//! node stores. A block's sentences and tokens are computed from its
3//! prose when a query first reaches them (the segmenter and the
4//! sentence bonds in force), their ids minted by arithmetic from
5//! the block and an ordinal, their forms slices of the prose. The
6//! engine sees a `sentence` node per span under the block and a
7//! `token` node per word under the sentence, as the stored tier
8//! gave it; nothing is kept per token beyond a 16-byte record in a
9//! table built per block on demand. Annotated sources — a CoNLL-U
10//! document, a parsing pack that declares its tokens — keep stored
11//! token nodes, since the token is the source's own row.
12
13use super::{Kind, TextModel, TokenClass, class_of, is_quote, sentence_spans};
14use quarb::{NodeId, Value};
15use std::rc::Rc;
16
17/// A virtual id: bit 55 set (the mount table owns the top byte).
18const V: u64 = 1 << 55;
19/// A token's id (else a sentence's).
20const TK: u64 = 1 << 54;
21/// The ordinal's bits: a block holds at most this many tokens.
22const ORD: u32 = 20;
23const ORD_MASK: u64 = (1 << ORD) - 1;
24const BLOCK_MASK: u64 = (1 << 34) - 1;
25/// Tokens a block's table holds at most; the rest of a block past
26/// it are not tokenized (a paragraph of a million words).
27pub(super) const MAX_PER_BLOCK: usize = ORD_MASK as usize;
28
29/// What a virtual id names.
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub(super) enum Virt {
32    Sentence {
33        block: usize,
34        k: u32,
35    },
36    Token {
37        block: usize,
38        k: u32,
39    },
40    /// An annotated token's row ([`super::ann`]).
41    Ann {
42        row: u32,
43    },
44}
45
46pub(super) fn virt(id: NodeId) -> Option<Virt> {
47    if id.0 & V == 0 {
48        return None;
49    }
50    if let Some(row) = super::ann::ann_row(id) {
51        return Some(Virt::Ann { row });
52    }
53    let block = ((id.0 >> ORD) & BLOCK_MASK) as usize;
54    let k = (id.0 & ORD_MASK) as u32;
55    Some(if id.0 & TK != 0 {
56        Virt::Token { block, k }
57    } else {
58        Virt::Sentence { block, k }
59    })
60}
61
62pub(super) fn sentence_id(block: usize, k: u32) -> NodeId {
63    NodeId(V | ((block as u64) << ORD) | k as u64)
64}
65
66pub(super) fn token_id(block: usize, k: u32) -> NodeId {
67    NodeId(V | TK | ((block as u64) << ORD) | k as u64)
68}
69
70/// One token of a block: its byte range in the block's prose, its
71/// sentence (an index into the block's spans), its class.
72#[derive(Clone, Copy, Debug)]
73pub(super) struct Tok {
74    pub at: u32,
75    pub end: u32,
76    pub sent: u32,
77    pub class: TokenClass,
78}
79
80/// A block's tier: the sentence spans, the tokens in order, and
81/// the first token of each sentence (one more entry closes the
82/// last).
83pub(super) struct Tier {
84    pub sents: Vec<(u32, u32)>,
85    pub toks: Vec<Tok>,
86    pub first: Vec<u32>,
87}
88
89impl Tier {
90    /// The segmentation the stored tier used: sentence spans from
91    /// the bonds or UAX #29, tokens from the word bounds, whitespace
92    /// dropped, each token in the sentence its start falls in (the
93    /// last sentence past them all).
94    pub(super) fn build(prose: &str, bonds: Option<&syndesmos::Syndesmos>) -> Tier {
95        Tier::over(prose, sentence_spans(prose, bonds))
96    }
97
98    /// A given segmentation (a `.sentences` file's lines aligned to
99    /// the block): the spans as given, the tokens UAX #29's.
100    pub(super) fn given(prose: &str, spans: &[(u32, u32)]) -> Tier {
101        Tier::over(
102            prose,
103            spans
104                .iter()
105                .map(|&(a, b)| (a as usize, b as usize))
106                .collect(),
107        )
108    }
109
110    fn over(prose: &str, mut spans: Vec<(usize, usize)>) -> Tier {
111        use unicode_segmentation::UnicodeSegmentation;
112        if spans.is_empty() {
113            spans.push((0, prose.len()));
114        }
115        let sents: Vec<(u32, u32)> = spans.iter().map(|&(a, b)| (a as u32, b as u32)).collect();
116        let mut toks = Vec::new();
117        let mut s = 0usize;
118        for (at, seg) in prose.split_word_bound_indices() {
119            if seg.chars().all(char::is_whitespace) {
120                continue;
121            }
122            while s + 1 < spans.len() && at >= spans[s].1 {
123                s += 1;
124            }
125            if toks.len() >= MAX_PER_BLOCK {
126                break;
127            }
128            toks.push(Tok {
129                at: at as u32,
130                end: (at + seg.len()) as u32,
131                sent: s as u32,
132                class: class_of(seg),
133            });
134        }
135        let mut first = vec![0u32; sents.len() + 1];
136        let mut k = 0usize;
137        for (i, t) in toks.iter().enumerate() {
138            while k <= t.sent as usize {
139                first[k] = i as u32;
140                k += 1;
141            }
142        }
143        while k <= sents.len() {
144            first[k] = toks.len() as u32;
145            k += 1;
146        }
147        Tier { sents, toks, first }
148    }
149
150    /// The token ordinals of sentence `k`.
151    pub(super) fn tokens_of(&self, k: u32) -> std::ops::Range<u32> {
152        let k = k as usize;
153        if k + 1 >= self.first.len() {
154            return 0..0;
155        }
156        self.first[k]..self.first[k + 1]
157    }
158}
159
160impl TextModel {
161    /// Whether `block` is a prose block whose tier is virtual: the
162    /// corpus reading is on and nothing in the document declared
163    /// its tokens.
164    pub(super) fn virtual_block(&self, block: usize) -> bool {
165        self.tiered
166            && block < self.nodes.len()
167            && (self.is_prose_block(block) || self.heading_text(block).is_some())
168            // `?notes=omit`: a note's body is left untokenized, as
169            // the stored tier left it.
170            && !(self.notes_omitted && self.in_body(NodeId(block as u64)).is_some())
171    }
172
173    /// A title or heading is a sentence too (the ruling of
174    /// 2026-10-09: so the computed tier and a given one agree, and
175    /// match the corpora that number headings): the document's
176    /// title and every section's heading get one sentence under
177    /// the node, before its content. The text the tier is built
178    /// from, for such a node.
179    pub(super) fn heading_text(&self, block: usize) -> Option<&str> {
180        let n = self.nodes.get(block)?;
181        match n.kind {
182            Kind::Section => n.lemma.as_deref().filter(|l| !l.is_empty()),
183            Kind::Document => self.head.title.as_deref().filter(|t| !t.is_empty()),
184            _ => None,
185        }
186    }
187
188    /// The text a block's tier is built from: a heading's own text
189    /// on a section or the titled document, else the block's prose.
190    pub(super) fn tier_text(&self, block: usize) -> &str {
191        self.heading_text(block)
192            .unwrap_or_else(|| &self.nodes[block].prose)
193    }
194
195    /// The tier's blocks in reading order: the prose blocks as
196    /// [`TextModel::prose_blocks`] orders them, each heading before
197    /// the first block that follows it in the document.
198    pub(super) fn tier_blocks(&self) -> Vec<usize> {
199        let mut headings: Vec<usize> = (0..self.nodes.len())
200            .filter(|&i| self.heading_text(i).is_some())
201            .collect();
202        headings.reverse();
203        let mut out = Vec::new();
204        for b in self.prose_blocks() {
205            let block = b.0 as usize;
206            while let Some(&h) = headings.last()
207                && h < block
208            {
209                out.push(h);
210                headings.pop();
211            }
212            out.push(block);
213        }
214        while let Some(h) = headings.pop() {
215            out.push(h);
216        }
217        out
218    }
219
220    /// The block's tier, built on first reach: from the given
221    /// segmentation when one is mounted, else computed.
222    pub(super) fn tier(&self, block: usize) -> Rc<Tier> {
223        if let Some(t) = self.tiers.borrow().get(&block) {
224            return t.clone();
225        }
226        let text = self.tier_text(block);
227        let t = Rc::new(match self.given.get(&block) {
228            Some(spans) => Tier::given(text, spans),
229            // A heading is one sentence whatever its punctuation
230            // ("CHAPTER I. THE BEGINNING"), as on the stored tiers.
231            None if self.heading_text(block).is_some() => {
232                let lo = text.len() - text.trim_start().len();
233                let hi = text.trim_end().len();
234                Tier::given(text, &[(lo as u32, hi as u32)])
235            }
236            None => Tier::build(text, self.bonds.as_ref()),
237        });
238        self.tiers.borrow_mut().insert(block, t.clone());
239        t
240    }
241
242    /// A `.sentences` file's lines, aligned to the text: one line
243    /// per sentence in reading order (blank lines ignored, headings
244    /// as lines), each matched whitespace-insensitively at the
245    /// current position; the first line that does not match is
246    /// refused by its number naming the text found, and the first
247    /// block the file leaves uncovered by its path. Line k is then
248    /// the sentence with `::::n` k.
249    pub fn tokenize_given(&mut self, sentences: &str) -> Result<(), String> {
250        self.mutated();
251        if self.is_tokenized() && !self.tiered {
252            return Err("the document is already tokenized".into());
253        }
254        let lines: Vec<(usize, &str)> = sentences
255            .lines()
256            .enumerate()
257            .map(|(i, l)| (i + 1, l.trim()))
258            .filter(|(_, l)| !l.is_empty())
259            .collect();
260        let mut given: std::collections::HashMap<usize, Vec<(u32, u32)>> = Default::default();
261        let mut pos = 0usize;
262        self.tiered = true;
263        self.bonds = None;
264        for block in self.tier_blocks() {
265            let text = self.tier_text(block).to_string();
266            let mut spans = Vec::new();
267            let mut cursor = 0usize;
268            loop {
269                // Past the whitespace at the cursor.
270                while let Some(c) = text[cursor..].chars().next()
271                    && c.is_whitespace()
272                {
273                    cursor += c.len_utf8();
274                }
275                if cursor >= text.len() {
276                    break;
277                }
278                let Some(&(lineno, line)) = lines.get(pos) else {
279                    return Err(format!(
280                        "the sentences file ends before {} is covered (at {:?})",
281                        self.locator(NodeId(block as u64)),
282                        text[cursor..].chars().take(24).collect::<String>()
283                    ));
284                };
285                match match_line(&text, cursor, line) {
286                    Some(end) => {
287                        spans.push((cursor as u32, end as u32));
288                        cursor = end;
289                        pos += 1;
290                    }
291                    None => {
292                        return Err(format!(
293                            "sentences line {lineno} does not match the text at {} ({:?}): the text reads {:?}",
294                            self.locator(NodeId(block as u64)),
295                            line.chars().take(24).collect::<String>(),
296                            text[cursor..].chars().take(24).collect::<String>()
297                        ));
298                    }
299                }
300            }
301            given.insert(block, spans);
302        }
303        if let Some(&(lineno, line)) = lines.get(pos) {
304            return Err(format!(
305                "sentences line {lineno} ({:?}) has no text left to match",
306                line.chars().take(24).collect::<String>()
307            ));
308        }
309        self.given = given;
310        self.tiers.borrow_mut().clear();
311        self.token_index = Default::default();
312        self.attribute_spans();
313        Ok(())
314    }
315
316    /// The virtual sentences under a block, in order.
317    pub(super) fn tier_sentences(&self, block: usize) -> Vec<NodeId> {
318        let t = self.tier(block);
319        (0..t.sents.len() as u32)
320            .map(|k| sentence_id(block, k))
321            .collect()
322    }
323
324    /// Every virtual token of the document in reading order, with
325    /// its form and its sentence: the cast and places matching
326    /// walk it as they walked the stored tokens.
327    pub(super) fn tier_words(&self) -> Vec<(NodeId, String, NodeId)> {
328        let mut out = Vec::new();
329        for block in self.tier_blocks() {
330            let t = self.tier(block);
331            for (k, tok) in t.toks.iter().enumerate() {
332                if tok.class != TokenClass::Word {
333                    continue;
334                }
335                out.push((
336                    token_id(block, k as u32),
337                    self.form(block, tok).to_string(),
338                    sentence_id(block, tok.sent),
339                ));
340            }
341        }
342        out
343    }
344
345    /// The document-wide ordinals: for each prose block in reading
346    /// order, the count of tokens and of sentences before it. One
347    /// pass over the prose, building every tier.
348    fn ords(&self) -> &std::collections::HashMap<usize, (u32, u32)> {
349        self.ords.get_or_init(|| {
350            let mut map = std::collections::HashMap::new();
351            let (mut n, mut s) = (0u32, 0u32);
352            for block in self.tier_blocks() {
353                map.insert(block, (n, s));
354                let t = self.tier(block);
355                n += t.toks.len() as u32;
356                s += t.sents.len() as u32;
357            }
358            map
359        })
360    }
361
362    pub(super) fn form(&self, block: usize, t: &Tok) -> &str {
363        &self.tier_text(block)[t.at as usize..t.end as usize]
364    }
365
366    fn sentence_text(&self, block: usize, k: u32) -> Option<String> {
367        let t = self.tier(block);
368        let (lo, hi) = *t.sents.get(k as usize)?;
369        self.tier_text(block)
370            .get(lo as usize..hi as usize)
371            .map(|s| s.trim().to_string())
372    }
373
374    /// A byte offset in the block's text as a character offset.
375    pub(super) fn chars_to(&self, block: usize, byte: u32) -> i64 {
376        self.tier_text(block)
377            .get(..byte as usize)
378            .map_or(0, |s| s.chars().count() as i64)
379    }
380
381    fn tok(&self, block: usize, k: u32) -> Option<Tok> {
382        self.tier(block).toks.get(k as usize).copied()
383    }
384
385    /// A node's parent, stored or virtual.
386    pub(super) fn any_parent(&self, id: NodeId) -> Option<NodeId> {
387        match virt(id) {
388            Some(v) => self.v_parent(v),
389            None => self.nodes[id.0 as usize].parent,
390        }
391    }
392
393    /// The locator of a virtual node: the block's, then the
394    /// sentence and the token by their ordinals among their
395    /// same-named siblings (indexed only where there are several).
396    pub(super) fn v_locator(&self, v: Virt) -> String {
397        let (block, k) = match v {
398            Virt::Sentence { block, k } | Virt::Token { block, k } => (block, k),
399            Virt::Ann { row } => return self.a_locator(row),
400        };
401        let t = self.tier(block);
402        let base = self.locator(NodeId(block as u64));
403        let seg = |name: &str, i: usize, n: usize| {
404            if n > 1 {
405                format!("{name}[{}]", i + 1)
406            } else {
407                name.to_string()
408            }
409        };
410        match v {
411            Virt::Ann { row } => self.a_locator(row),
412            Virt::Sentence { .. } => {
413                format!("{base}/{}", seg("sentence", k as usize, t.sents.len()))
414            }
415            Virt::Token { .. } => {
416                let Some(tok) = t.toks.get(k as usize) else {
417                    return base;
418                };
419                let range = t.tokens_of(tok.sent);
420                format!(
421                    "{base}/{}/{}",
422                    seg("sentence", tok.sent as usize, t.sents.len()),
423                    seg("token", (k - range.start) as usize, range.len())
424                )
425            }
426        }
427    }
428
429    pub(super) fn v_children(&self, v: Virt) -> Vec<NodeId> {
430        match v {
431            Virt::Ann { .. } => Vec::new(),
432            Virt::Sentence { block, k } => {
433                let t = self.tier(block);
434                t.tokens_of(k).map(|i| token_id(block, i)).collect()
435            }
436            Virt::Token { .. } => Vec::new(),
437        }
438    }
439
440    pub(super) fn v_parent(&self, v: Virt) -> Option<NodeId> {
441        match v {
442            Virt::Ann { row } => self.a_parent(row),
443            Virt::Sentence { block, .. } => Some(NodeId(block as u64)),
444            Virt::Token { block, k } => self.tok(block, k).map(|t| sentence_id(block, t.sent)),
445        }
446    }
447
448    pub(super) fn v_name(v: Virt) -> Option<String> {
449        Some(
450            match v {
451                Virt::Sentence { .. } => "sentence",
452                Virt::Token { .. } | Virt::Ann { .. } => "token",
453            }
454            .to_string(),
455        )
456    }
457
458    pub(super) fn v_traits(&self, v: Virt) -> Vec<String> {
459        match v {
460            Virt::Ann { row } => self.a_traits(row),
461            Virt::Sentence { .. } => vec!["derived".to_string()],
462            Virt::Token { block, k } => {
463                let Some(t) = self.tok(block, k) else {
464                    return Vec::new();
465                };
466                let mut out = vec![t.class.name().to_string()];
467                if t.class == TokenClass::Punct && is_quote(self.form(block, &t)) {
468                    out.push("quote".to_string());
469                }
470                out
471            }
472        }
473    }
474
475    pub(super) fn v_default_value(&self, v: Virt) -> Option<Value> {
476        match v {
477            Virt::Ann { row } => self.a_default_value(row),
478            Virt::Sentence { block, k } => self.sentence_text(block, k).map(Value::Str),
479            Virt::Token { block, k } => {
480                let t = self.tok(block, k)?;
481                Some(Value::Str(self.form(block, &t).to_string()))
482            }
483        }
484    }
485
486    /// The citation at a virtual node: the block's, at the token's
487    /// byte offset (a sentence's first token's).
488    fn v_cite(&self, v: Virt) -> Option<String> {
489        let (block, at) = match v {
490            Virt::Token { block, k } => (block, self.tok(block, k)?.at),
491            Virt::Sentence { block, k } => {
492                let t = self.tier(block);
493                let at = t
494                    .tokens_of(k)
495                    .next()
496                    .and_then(|i| t.toks.get(i as usize))
497                    .map(|t| t.at)
498                    .unwrap_or(0);
499                (block, at)
500            }
501            Virt::Ann { row } => return self.a_cite_row(row),
502        };
503        self.cite_in_block(block, at)
504    }
505
506    /// The citation at byte `at` of `block`: the structural one
507    /// when no milestone is declared, a note body's as its
508    /// callout, else the milestone in force.
509    pub(super) fn cite_in_block(&self, block: usize, at: u32) -> Option<String> {
510        let b = NodeId(block as u64);
511        let caches = self.caches();
512        if caches.cite_table.is_empty() {
513            return self.structural_cite(b);
514        }
515        // A note body, and everything in it, cites as its callout.
516        let n = &self.nodes[block];
517        let body = if matches!(n.kind, Kind::Footnote | Kind::Endnote | Kind::Aside) && !n.deixis {
518            Some(b)
519        } else {
520            self.note_body_of(b)
521        };
522        if let Some(body) = body {
523            let callout = caches.callout_of_body.get(&body).copied();
524            return callout.and_then(|c| self.cite(c));
525        }
526        self.cite_at(b, at, b)
527    }
528
529    /// The sentence's document-wide ordinal.
530    fn v_sentence_ordinal(&self, block: usize, k: u32) -> Option<i64> {
531        let (_, s) = *self.ords().get(&block)?;
532        Some(s as i64 + k as i64 + 1)
533    }
534
535    pub(super) fn v_property(&self, v: Virt, name: &str) -> Option<Value> {
536        if let Virt::Ann { row } = v {
537            return self.a_property(row, name);
538        }
539        if name == "cite" {
540            return self.v_cite(v).map(Value::Str);
541        }
542        match v {
543            Virt::Ann { row } => self.a_property(row, name),
544            Virt::Sentence { block, k } => match name {
545                "text" => self.sentence_text(block, k).map(Value::Str),
546                "taxis" | "ord" => self.v_sentence_ordinal(block, k).map(Value::Int),
547                _ => None,
548            },
549            Virt::Token { block, k } => {
550                let t = self.tok(block, k)?;
551                match name {
552                    "text" => Some(Value::Str(self.form(block, &t).to_string())),
553                    "lower" => Some(Value::Str(self.lower(self.form(block, &t)))),
554                    "modern" => Some(Value::Str(self.modern(self.form(block, &t)))),
555                    "class" => Some(Value::Str(t.class.name().to_string())),
556                    "sentence" => self.sentence_text(block, t.sent).map(Value::Str),
557                    "lemma" | "title" | "onym" | "hypograph" | "attribution" | "taxis" | "ord"
558                    | "grammata" | "body" | "mention" | "entity" => None,
559                    _ => self.v_token_field(v, block, &t, name),
560                }
561            }
562        }
563    }
564
565    /// A virtual token's field: the cast or places table's own
566    /// annotation first, then the spans covering the token
567    /// (innermost first), then the block and its ancestors.
568    fn v_token_field(&self, v: Virt, block: usize, t: &Tok, name: &str) -> Option<Value> {
569        let id = match v {
570            Virt::Token { block, k } => token_id(block, k),
571            Virt::Sentence { .. } | Virt::Ann { .. } => return None,
572        };
573        if let Some(f) = self.virt_fields.get(&id)
574            && let Some(val) = super::field_values(f, name)
575        {
576            return Some(val);
577        }
578        for s in self.covering_spans(block, t.at).into_iter().rev() {
579            if let Some(val) = super::field_values(&self.nodes[s.0 as usize].fields, name) {
580                return Some(val);
581            }
582        }
583        let mut at = Some(NodeId(block as u64));
584        while let Some(p) = at {
585            let n = &self.nodes[p.0 as usize];
586            if let Some(val) = super::field_values(&n.fields, name) {
587                return Some(val);
588            }
589            at = n.parent;
590        }
591        None
592    }
593
594    /// The spans under `block` whose byte range covers `at`,
595    /// outermost first (the tree's pre-order).
596    pub(super) fn covering_spans(&self, block: usize, at: u32) -> Vec<NodeId> {
597        let mut out = Vec::new();
598        let mut stack: Vec<NodeId> = self.nodes[block].children.iter().rev().copied().collect();
599        while let Some(c) = stack.pop() {
600            let n = &self.nodes[c.0 as usize];
601            if n.kind == Kind::Span {
602                if let Some((lo, hi)) = n.at
603                    && lo <= at
604                    && at < hi
605                {
606                    out.push(c);
607                }
608                stack.extend(n.children.iter().rev().copied());
609            }
610        }
611        out
612    }
613
614    pub(super) fn v_metadata(&self, v: Virt, key: &str) -> Option<Value> {
615        match v {
616            Virt::Ann { row } => self.a_metadata(row, key),
617            // `::::at` / `::::end`: the character offset and end in
618            // the block's text, the coordinate that survives a
619            // resegmentation (two mounts of one text join by span).
620            Virt::Sentence { block, k } => match key {
621                "n" | "sentence" => self.v_sentence_ordinal(block, k).map(Value::Int),
622                // The span trimmed of its whitespace at both ends,
623                // so `end - at` is the sentence's character count
624                // and the text between them is its `::`.
625                "at" | "end" => {
626                    let t = self.tier(block);
627                    let (lo, hi) = *t.sents.get(k as usize)?;
628                    let text = self.tier_text(block);
629                    let span = text.get(lo as usize..hi as usize)?;
630                    let lead = (span.len() - span.trim_start().len()) as u32;
631                    let trail = (span.len() - span.trim_end().len()) as u32;
632                    Some(Value::Int(self.chars_to(
633                        block,
634                        if key == "at" { lo + lead } else { hi - trail },
635                    )))
636                }
637                _ => None,
638            },
639            Virt::Token { block, k } => {
640                let t = self.tok(block, k)?;
641                match key {
642                    "n" => {
643                        let (n, _) = *self.ords().get(&block)?;
644                        Some(Value::Int(n as i64 + k as i64 + 1))
645                    }
646                    "sentence" => {
647                        let (_, s) = *self.ords().get(&block)?;
648                        Some(Value::Int(s as i64 + t.sent as i64 + 1))
649                    }
650                    "at" => Some(Value::Int(self.chars_to(block, t.at))),
651                    "end" => Some(Value::Int(self.chars_to(block, t.end))),
652                    _ => None,
653                }
654            }
655        }
656    }
657
658    pub(super) fn v_links(&self, v: Virt) -> Vec<(String, NodeId)> {
659        let id = match v {
660            Virt::Token { block, k } => token_id(block, k),
661            Virt::Ann { row } => return self.a_links(row),
662            Virt::Sentence { .. } => return Vec::new(),
663        };
664        let mut out = Vec::new();
665        if let Some(c) = self.virt_character.get(&id) {
666            out.push(("character".to_string(), *c));
667        }
668        if let Some(p) = self.virt_place.get(&id) {
669            out.push(("place".to_string(), *p));
670        }
671        out
672    }
673
674    /// The lit reading's spans over a virtual tier: each span takes
675    /// the tokens its byte range covers, from the block's table.
676    pub(super) fn attribute_spans_virtual(&mut self) {
677        let spans: Vec<usize> = (0..self.nodes.len())
678            .filter(|&i| self.nodes[i].kind == Kind::Span && self.nodes[i].at.is_some())
679            .collect();
680        for span in spans {
681            let (lo, hi) = self.nodes[span].at.expect("filtered");
682            let mut block = span;
683            while self.nodes[block].kind == Kind::Span
684                && let Some(p) = self.nodes[block].parent
685            {
686                block = p.0 as usize;
687            }
688            if !self.virtual_block(block) {
689                continue;
690            }
691            let t = self.tier(block);
692            let covered: Vec<NodeId> = t
693                .toks
694                .iter()
695                .enumerate()
696                .filter(|(_, tok)| lo <= tok.at && tok.at < hi)
697                .map(|(k, _)| token_id(block, k as u32))
698                .collect();
699            self.nodes[span].span_tokens = covered;
700        }
701    }
702
703    /// The field a cast or places row sets on a token, stored or
704    /// virtual.
705    pub(super) fn push_token_field(&mut self, id: NodeId, key: &str, value: String) {
706        match virt(id) {
707            Some(_) => {
708                let f = self.virt_fields.entry(id).or_default();
709                if !f.iter().any(|(k, _)| k == key) {
710                    f.push((key.to_string(), value));
711                }
712            }
713            None => {
714                let f = &mut self.nodes[id.0 as usize].fields;
715                if !f.iter().any(|(k, _)| k == key) {
716                    f.push((key.to_string(), value));
717                }
718            }
719        }
720    }
721
722    pub(super) fn has_token_field(&self, id: NodeId, key: &str) -> bool {
723        match virt(id) {
724            Some(_) => self
725                .virt_fields
726                .get(&id)
727                .is_some_and(|f| f.iter().any(|(k, _)| k == key)),
728            None => self.nodes[id.0 as usize]
729                .fields
730                .iter()
731                .any(|(k, _)| k == key),
732        }
733    }
734
735    /// The positional index over a virtual tier: every token's
736    /// lowercased form → its ids, built over every block's table.
737    pub(super) fn tier_index(&self) -> std::collections::HashMap<String, Vec<NodeId>> {
738        let mut map: std::collections::HashMap<String, Vec<NodeId>> = Default::default();
739        for block in self.tier_blocks() {
740            let t = self.tier(block);
741            for (k, tok) in t.toks.iter().enumerate() {
742                map.entry(self.lower(self.form(block, tok)))
743                    .or_default()
744                    .push(token_id(block, k as u32));
745            }
746        }
747        map
748    }
749}
750
751/// Where `line` ends in `text` when it matches at `cursor`
752/// whitespace-insensitively (the characters of both compared with
753/// every whitespace skipped); `None` on the first difference.
754fn match_line(text: &str, cursor: usize, line: &str) -> Option<usize> {
755    let mut want = line.chars().filter(|c| !c.is_whitespace());
756    let mut end = cursor;
757    let mut rest = text[cursor..].char_indices();
758    let mut next = want.next();
759    while let Some(w) = next {
760        let (i, c) = rest.next()?;
761        if c.is_whitespace() {
762            continue;
763        }
764        if c != w {
765            return None;
766        }
767        end = cursor + i + c.len_utf8();
768        next = want.next();
769    }
770    Some(end)
771}