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}