Skip to main content

fmd_math/
spanmap.rs

1//! The span map (§11.3): querying a [`Layout`] by source provenance.
2//!
3//! The Reference obtains substring→glyph maps by rendering every string
4//! *twice* through its black-box typesetters with injected color labels
5//! and aligning the two renders. This engine is not a black box: every
6//! output primitive already names the byte range it came from, so the
7//! span map is a **query**, not a reconstruction.
8//!
9//! Semantics: [`Layout::select`] returns the primitives whose spans are
10//! **contained** in the query range. Containment (not overlap) is the
11//! sound choice for substring maps: the `π` glyph of `\pi` carries the
12//! whole command's span, so a query for the source letter `i` inside
13//! `\pi` selects nothing — no false positives on command-name substrings.
14//! [`find_occurrences`] locates a needle's byte occurrences in the
15//! source, which composes with `select` into exactly the
16//! `tex_to_color_map` / `isolate` / `TransformMatchingTex` consumption
17//! pattern: match by *source identity*, never by shape correlation.
18//!
19//! ## The synthetic-span policy (documented for the inspector)
20//!
21//! Every primitive span points into the source string and is non-empty:
22//!
23//! - character glyphs carry their exact token span; text-run characters
24//!   carry per-character spans (escapes cover their two source bytes,
25//!   collapsed whitespace its run);
26//! - primes carry their own `'` token spans;
27//! - glyphs produced by a command (`\pi`, operator-name letters, accent
28//!   marks, delimiters after `\left`) carry the producing command's span —
29//!   the expansion site, exactly as macro expansion should;
30//! - rules carry their construct's span (a fraction bar belongs to the
31//!   whole fraction);
32//! - built-in default-pack expansions (`\minus`, `\mathds`) map to the
33//!   command occurrence in the source.
34
35use crate::mbox::Layout;
36use crate::node::Span;
37
38/// Indices of the primitives a span query selected.
39#[derive(Clone, Debug, Default, PartialEq, Eq)]
40pub struct Selection {
41    /// Indices into [`Layout::glyphs`].
42    pub glyphs: Vec<usize>,
43    /// Indices into [`Layout::rules`].
44    pub rules: Vec<usize>,
45    /// Indices into [`Layout::paths`].
46    pub paths: Vec<usize>,
47}
48
49impl Selection {
50    /// True when nothing was selected.
51    #[must_use]
52    pub fn is_empty(&self) -> bool {
53        self.glyphs.is_empty() && self.rules.is_empty() && self.paths.is_empty()
54    }
55
56    /// Total number of selected primitives.
57    #[must_use]
58    pub fn len(&self) -> usize {
59        self.glyphs.len() + self.rules.len() + self.paths.len()
60    }
61}
62
63const fn contained(inner: &Span, outer: Span) -> bool {
64    inner.start >= outer.start && inner.end <= outer.end
65}
66
67const fn overlaps(a: &Span, b: Span) -> bool {
68    a.start < b.end && b.start < a.end
69}
70
71impl Layout {
72    /// The primitives whose spans are contained in `range` — the substring
73    /// map (`isolate` / `tex_to_color_map` semantics).
74    #[must_use]
75    pub fn select(&self, range: Span) -> Selection {
76        Selection {
77            glyphs: indices(self.glyphs.iter().map(|g| &g.span), |s| contained(s, range)),
78            rules: indices(self.rules.iter().map(|r| &r.span), |s| contained(s, range)),
79            paths: indices(self.paths.iter().map(|p| &p.span), |s| contained(s, range)),
80        }
81    }
82
83    /// The primitives whose spans overlap `range` — the inspector's
84    /// hit-test semantics (what did this byte produce, in any part).
85    #[must_use]
86    pub fn select_touching(&self, range: Span) -> Selection {
87        Selection {
88            glyphs: indices(self.glyphs.iter().map(|g| &g.span), |s| overlaps(s, range)),
89            rules: indices(self.rules.iter().map(|r| &r.span), |s| overlaps(s, range)),
90            paths: indices(self.paths.iter().map(|p| &p.span), |s| overlaps(s, range)),
91        }
92    }
93}
94
95fn indices<'a>(
96    spans: impl Iterator<Item = &'a Span>,
97    mut keep: impl FnMut(&Span) -> bool,
98) -> Vec<usize> {
99    spans
100        .enumerate()
101        .filter_map(|(i, s)| keep(s).then_some(i))
102        .collect()
103}
104
105/// Byte-level occurrences of `needle` in `source` (non-overlapping, left
106/// to right) — the string side of the `t2c`/`isolate` consumption pattern.
107#[must_use]
108pub fn find_occurrences(source: &str, needle: &str) -> Vec<Span> {
109    if needle.is_empty() {
110        return Vec::new();
111    }
112    source
113        .match_indices(needle)
114        .map(|(i, m)| Span::new(i, i + m.len()))
115        .collect()
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn occurrences_are_byte_spans() {
124        assert_eq!(
125            find_occurrences("a+a", "a"),
126            vec![Span::new(0, 1), Span::new(2, 3)]
127        );
128        assert!(find_occurrences("abc", "").is_empty());
129        assert_eq!(find_occurrences(r"\pi\pi", r"\pi").len(), 2);
130    }
131}