Skip to main content

badness_parser/semantic/
math.rs

1//! Static math-atom classification over the lossless CST.
2//!
3//! The generated baseline comes from unicode-math; a deliberately small curated
4//! tier supplies LaTeX aliases, primitive class constructors, and the exceptions
5//! where a TeX spacing class is not a pairable delimiter. Classification is pure
6//! over shipped data and source shape—package scope and user definitions do not
7//! participate.
8
9use rowan::{TextRange, TextSize};
10
11use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode, SyntaxToken};
12
13/// The useful TeX math-atom class family.
14#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum MathClass {
16    #[default]
17    Ord,
18    Op,
19    Bin,
20    Rel,
21    Open,
22    Close,
23    Punct,
24    Fence,
25    Inner,
26}
27
28/// Whether an atom is a genuinely pairable delimiter, independently of its TeX
29/// spacing class. For example, `\sqrt` is `Open` but has no delimiter role.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31pub enum DelimiterRole {
32    Open,
33    Close,
34    Fence,
35}
36
37/// Class metadata without a source location.
38#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
39pub struct MathAtomInfo {
40    pub class: MathClass,
41    pub delimiter: Option<DelimiterRole>,
42}
43
44/// One virtual math atom and the exact source bytes that produced it.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
46pub struct MathAtom {
47    pub range: TextRange,
48    pub class: MathClass,
49    pub delimiter: Option<DelimiterRole>,
50}
51
52const fn info(class: MathClass, delimiter: Option<DelimiterRole>) -> MathAtomInfo {
53    MathAtomInfo { class, delimiter }
54}
55
56type MathCommandMap = phf::Map<&'static str, MathAtomInfo>;
57
58include!(concat!(env!("OUT_DIR"), "/math_symbols.rs"));
59
60/// LaTeX and amsmath's named, upright function operators.
61///
62/// This shared vocabulary also drives the `math-operator-name` lint, which
63/// diagnoses these spellings when their leading backslash is omitted.
64pub const NAMED_MATH_OPERATORS: &[&str] = &[
65    "arccos", "arcsin", "arctan", "arg", "cos", "cosh", "cot", "coth", "csc", "deg", "det", "dim",
66    "exp", "gcd", "hom", "inf", "ker", "lg", "lim", "liminf", "limsup", "ln", "log", "max", "min",
67    "Pr", "sec", "sin", "sinh", "sup", "tan", "tanh",
68];
69
70/// Classify a control-sequence name without its leading backslash.
71///
72/// Unknown commands conservatively behave as ordinary atoms.
73pub fn math_command_info(name: &str) -> MathAtomInfo {
74    curated_command_info(name)
75        .or_else(|| UNICODE_MATH_COMMANDS.get(name).copied())
76        .unwrap_or_default()
77}
78
79/// Classify a literal Unicode scalar. Unknown characters are ordinary atoms.
80pub fn math_char_info(character: char) -> MathAtomInfo {
81    curated_char_info(character)
82        .or_else(|| {
83            UNICODE_MATH_CHARS
84                .binary_search_by_key(&character, |(candidate, _)| *candidate)
85                .ok()
86                .map(|index| UNICODE_MATH_CHARS[index].1)
87        })
88        .unwrap_or_default()
89}
90
91/// Virtual atoms for one CST element. A coalesced `WORD` yields one atom per
92/// Unicode scalar; structural nodes remain one source-spanning atom.
93pub fn math_atoms(element: &SyntaxElement) -> MathAtoms<'_> {
94    match element {
95        SyntaxElement::Token(token) if token.kind() == SyntaxKind::WORD => MathAtoms {
96            inner: MathAtomsInner::Word {
97                text: token.text(),
98                start: token.text_range().start(),
99                offset: 0,
100            },
101        },
102        SyntaxElement::Token(token) => MathAtoms {
103            inner: MathAtomsInner::One(Some(atom(token.text_range(), token_info(token)))),
104        },
105        SyntaxElement::Node(node) => MathAtoms {
106            inner: MathAtomsInner::One(Some(atom(node.text_range(), node_info(node)))),
107        },
108    }
109}
110
111/// Iterator returned by [`math_atoms`].
112pub struct MathAtoms<'a> {
113    inner: MathAtomsInner<'a>,
114}
115
116enum MathAtomsInner<'a> {
117    Word {
118        text: &'a str,
119        start: TextSize,
120        offset: usize,
121    },
122    One(Option<MathAtom>),
123}
124
125impl Iterator for MathAtoms<'_> {
126    type Item = MathAtom;
127
128    fn next(&mut self) -> Option<Self::Item> {
129        match &mut self.inner {
130            MathAtomsInner::One(atom) => atom.take(),
131            MathAtomsInner::Word {
132                text,
133                start,
134                offset,
135            } => {
136                let character = text.get(*offset..)?.chars().next()?;
137                let len = character.len_utf8();
138                let atom_start = *start + TextSize::from(*offset as u32);
139                *offset += len;
140                let atom_end = *start + TextSize::from(*offset as u32);
141                let value = math_char_info(character);
142                Some(MathAtom {
143                    range: TextRange::new(atom_start, atom_end),
144                    class: value.class,
145                    delimiter: value.delimiter,
146                })
147            }
148        }
149    }
150}
151
152fn atom(range: TextRange, value: MathAtomInfo) -> MathAtom {
153    MathAtom {
154        range,
155        class: value.class,
156        delimiter: value.delimiter,
157    }
158}
159
160fn token_info(token: &SyntaxToken) -> MathAtomInfo {
161    match token.kind() {
162        SyntaxKind::CONTROL_WORD | SyntaxKind::CONTROL_SYMBOL => token
163            .text()
164            .strip_prefix('\\')
165            .map_or_else(MathAtomInfo::default, math_command_info),
166        _ => {
167            let mut characters = token.text().chars();
168            match (characters.next(), characters.next()) {
169                (Some(character), None) => math_char_info(character),
170                _ => MathAtomInfo::default(),
171            }
172        }
173    }
174}
175
176fn node_info(node: &SyntaxNode) -> MathAtomInfo {
177    match node.kind() {
178        SyntaxKind::COMMAND => node
179            .children_with_tokens()
180            .filter_map(SyntaxElement::into_token)
181            .find(|token| {
182                matches!(
183                    token.kind(),
184                    SyntaxKind::CONTROL_WORD | SyntaxKind::CONTROL_SYMBOL
185                )
186            })
187            .as_ref()
188            .map_or_else(MathAtomInfo::default, token_info),
189        SyntaxKind::SCRIPTED => node
190            .children_with_tokens()
191            .find(|element| {
192                !matches!(
193                    element.kind(),
194                    SyntaxKind::WHITESPACE
195                        | SyntaxKind::NEWLINE
196                        | SyntaxKind::SUBSCRIPT
197                        | SyntaxKind::SUPERSCRIPT
198                )
199            })
200            .and_then(|base| math_atoms(&base).next())
201            .map_or_else(MathAtomInfo::default, |base| {
202                info(base.class, base.delimiter)
203            }),
204        SyntaxKind::GROUP
205        | SyntaxKind::OPTIONAL
206        | SyntaxKind::LEFT_RIGHT
207        | SyntaxKind::ENVIRONMENT => info(MathClass::Inner, None),
208        _ => MathAtomInfo::default(),
209    }
210}
211
212fn curated_char_info(character: char) -> Option<MathAtomInfo> {
213    match character {
214        // unicode-math applies these ASCII remaps outside its symbol table.
215        '*' | '-' => Some(info(MathClass::Bin, None)),
216        // TeX spacing classes do not imply a paired delimiter.
217        '!' => Some(info(MathClass::Close, None)),
218        '√' => Some(info(MathClass::Ord, None)),
219        '∛' | '∜' | '⟌' => Some(info(MathClass::Open, None)),
220        _ => None,
221    }
222}
223
224fn curated_command_info(name: &str) -> Option<MathAtomInfo> {
225    if NAMED_MATH_OPERATORS.contains(&name) {
226        return Some(info(MathClass::Op, None));
227    }
228    let value = match name {
229        // `\operatorname` constructs an operator from its argument.
230        "operatorname" => info(MathClass::Op, None),
231
232        // Primitive class constructors classify the whole command result, but do
233        // not promise that arbitrary content passed to `\mathopen` is pairable.
234        "mathord" => info(MathClass::Ord, None),
235        "mathop" => info(MathClass::Op, None),
236        "mathbin" => info(MathClass::Bin, None),
237        "mathrel" => info(MathClass::Rel, None),
238        "mathopen" => info(MathClass::Open, None),
239        "mathclose" => info(MathClass::Close, None),
240        "mathpunct" => info(MathClass::Punct, None),
241        "mathinner" => info(MathClass::Inner, None),
242
243        // The formatter's established relation vocabulary, including trusted
244        // kernel/mathtools aliases not present as primary unicode-math commands.
245        "le" | "leq" | "ge" | "geq" | "ne" | "neq" | "equiv" | "approx" | "approxeq" | "sim"
246        | "simeq" | "cong" | "propto" | "asymp" | "doteq" | "models" | "vdash" | "dashv"
247        | "perp" | "parallel" | "mid" | "in" | "ni" | "notin" | "subset" | "subseteq"
248        | "subsetneq" | "supset" | "supseteq" | "supsetneq" | "sqsubseteq" | "sqsupseteq"
249        | "prec" | "preceq" | "succ" | "succeq" | "ll" | "gg" | "lll" | "ggg" | "to"
250        | "rightarrow" | "longrightarrow" | "Rightarrow" | "Longrightarrow" | "implies"
251        | "impliedby" | "iff" | "mapsto" | "longmapsto" | "leftarrow" | "Leftarrow" | "gets"
252        | "leftrightarrow" | "Leftrightarrow" | "Longleftrightarrow" | "hookrightarrow"
253        | "hookleftarrow" | "triangleq" | "coloneq" | "Coloneq" | "coloneqq" | "Coloneqq"
254        | "eqcolon" | "Eqcolon" | "eqqcolon" | "Eqqcolon" | "colonapprox" | "Colonapprox"
255        | "colonsim" | "Colonsim" | "lesssim" | "gtrsim" => info(MathClass::Rel, None),
256
257        // The established binary vocabulary. `bigtriangledown` deliberately
258        // overrides unicode-math's `Ord` classification for compatibility.
259        "pm" | "mp" | "times" | "div" | "cdot" | "ast" | "star" | "circ" | "bullet" | "cup"
260        | "cap" | "uplus" | "sqcup" | "sqcap" | "vee" | "wedge" | "lor" | "land" | "oplus"
261        | "ominus" | "otimes" | "oslash" | "odot" | "setminus" | "amalg" | "diamond" | "wr"
262        | "dagger" | "ddagger" | "bigtriangleup" | "bigtriangledown" | "triangleleft"
263        | "triangleright" => info(MathClass::Bin, None),
264
265        // Control-symbol delimiters and LaTeX's directional vertical-bar aliases.
266        "{" | "lvert" | "lVert" => info(MathClass::Open, Some(DelimiterRole::Open)),
267        "}" | "rvert" | "rVert" => info(MathClass::Close, Some(DelimiterRole::Close)),
268        "|" => info(MathClass::Fence, Some(DelimiterRole::Fence)),
269
270        // These upstream `Open`/`Close` classes affect TeX spacing but do not form
271        // pairs for Badness's structural bracket accounting.
272        "sqrt" | "cuberoot" | "fourthroot" | "longdivision" => info(MathClass::Open, None),
273        "mathexclam" => info(MathClass::Close, None),
274        _ => return None,
275    };
276    Some(value)
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use crate::parser::parse;
283
284    fn root(source: &str) -> SyntaxNode {
285        SyntaxNode::new_root(parse(source).green)
286    }
287
288    #[test]
289    fn generated_and_curated_lookups_share_one_default() {
290        assert_eq!(UNICODE_MATH_COMMANDS.len(), 2448);
291        assert_eq!(math_command_info("nleq").class, MathClass::Rel);
292        assert_eq!(math_command_info("sin").class, MathClass::Op);
293        assert_eq!(math_command_info("bigtriangledown").class, MathClass::Bin);
294        assert_eq!(math_char_info('≤').class, MathClass::Rel);
295        assert_eq!(math_char_info(',').class, MathClass::Punct);
296        assert_eq!(math_command_info("vert").class, MathClass::Fence);
297        assert_eq!(math_char_info('-').class, MathClass::Bin);
298        assert_eq!(math_char_info('/').class, MathClass::Ord);
299        assert_eq!(
300            math_command_info("not-a-real-command"),
301            MathAtomInfo::default()
302        );
303        assert_eq!(math_char_info('🦀'), MathAtomInfo::default());
304    }
305
306    #[test]
307    fn delimiter_role_is_independent_of_spacing_class() {
308        assert_eq!(
309            math_command_info("langle").delimiter,
310            Some(DelimiterRole::Open)
311        );
312        assert_eq!(math_command_info("sqrt"), info(MathClass::Open, None));
313        assert_eq!(math_char_info('!'), info(MathClass::Close, None));
314        assert_eq!(math_char_info('√'), info(MathClass::Ord, None));
315    }
316
317    #[test]
318    fn word_atoms_have_exact_multibyte_source_spans() {
319        let tree = root("$a≤b$");
320        let word = tree
321            .descendants_with_tokens()
322            .filter_map(SyntaxElement::into_token)
323            .find(|token| token.text() == "a≤b")
324            .expect("coalesced math word");
325        let atoms: Vec<_> = math_atoms(&word.into()).collect();
326        assert_eq!(
327            atoms.iter().map(|atom| atom.class).collect::<Vec<_>>(),
328            [MathClass::Ord, MathClass::Rel, MathClass::Ord]
329        );
330        assert_eq!(
331            atoms.iter().map(|atom| atom.range).collect::<Vec<_>>(),
332            [
333                TextRange::new(1.into(), 2.into()),
334                TextRange::new(2.into(), 5.into()),
335                TextRange::new(5.into(), 6.into()),
336            ]
337        );
338    }
339
340    #[test]
341    fn commands_and_scripted_bases_are_single_atoms() {
342        let tree = root("$\\leq \\}^{1/2}$");
343        let command = tree
344            .descendants()
345            .find(|node| node.kind() == SyntaxKind::COMMAND)
346            .expect("relation command");
347        let command_atom = math_atoms(&command.clone().into()).next().unwrap();
348        assert_eq!(command_atom.class, MathClass::Rel);
349        assert_eq!(command_atom.range, command.text_range());
350
351        let scripted = tree
352            .descendants()
353            .find(|node| node.kind() == SyntaxKind::SCRIPTED)
354            .expect("scripted delimiter");
355        let scripted_atom = math_atoms(&scripted.clone().into()).next().unwrap();
356        assert_eq!(scripted_atom.delimiter, Some(DelimiterRole::Close));
357        assert_eq!(scripted_atom.range, scripted.text_range());
358    }
359
360    #[test]
361    fn structural_subformulas_are_inner_atoms() {
362        let tree = root("${x}$");
363        let group = tree
364            .descendants()
365            .find(|node| node.kind() == SyntaxKind::GROUP)
366            .expect("math group");
367        assert_eq!(
368            math_atoms(&group.into()).next().unwrap().class,
369            MathClass::Inner
370        );
371    }
372}