diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 29acbe3..4370c19 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -23,6 +23,7 @@ //! the SIL Open Font License 1.1 (see `fonts/OFL.txt`). mod attach; +mod math_parse; mod terminal; use std::collections::HashMap; diff --git a/pmacs-gpu/src/math_parse.rs b/pmacs-gpu/src/math_parse.rs new file mode 100644 index 0000000..bbbe8e4 --- /dev/null +++ b/pmacs-gpu/src/math_parse.rs @@ -0,0 +1,417 @@ +//! LaTeX math-mode parser for the first inline-math slice. +//! +//! Framing: `docs/inline-math-slice-framing.md` (rev 3), Q#MS2. This parses +//! the deliberately small subset the slice renders — characters, groups, +//! sub/superscripts and fractions — and nothing else. Every other LaTeX +//! construct is an error, which Q#MS8 turns into "show the raw source". +//! +//! The AST is *semantic*, not presentational: `\alpha` resolves to `'α'` +//! here, but the math-italic mapping (Q#MS2's table) belongs to layout, which +//! is where a codepoint becomes a glyph. Keeping the split here means the AST +//! matches what the user wrote, and a future non-italic style context does +//! not have to unpick a decision the parser baked in. + +/// One node of the slice's math subset (Q#MS2). +/// +/// Rev 2 of the framing folded `Symbol` into `Char`: both carried a `char`, +/// and after symbol resolution layout cannot act on the difference. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MathNode { + /// A resolved codepoint: `x`, `2`, `+`, `α`. + Char(char), + /// A braced group, or the top-level expression. + Group(Vec), + /// A base with optional sub- and superscript. + Script { + base: Box, + sub: Option>, + sup: Option>, + }, + /// `\frac{num}{den}`. + Fraction { + num: Box, + den: Box, + }, +} + +/// Why a span could not be parsed. Q#MS8 renders the raw source for all of +/// these; the variants exist so tests can assert *which* rejection fired. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MathParseError { + /// The span held no math (`$$` after delimiter stripping). + Empty, + /// A `{` with no matching `}`, or a stray `}`. + UnbalancedBrace, + /// A control sequence outside the subset, e.g. `\sqrt`. + UnknownCommand(String), + /// `\frac` without two braced arguments. + MalformedCommand(&'static str), + /// `^` or `_` with nothing to attach to, or given twice for one base. + MalformedScript(&'static str), + /// A `$` inside the span: the delimiters are the caller's business, and + /// a bare one here means detection handed us something it should not + /// have (framing acceptance 15 — `$$x$$` degrades through this path). + UnexpectedDollar, +} + +/// Greek seed map (Q#MS2). Deliberately partial — growing it is mechanical. +const GREEK: &[(&str, char)] = &[ + ("alpha", 'α'), + ("beta", 'β'), + ("gamma", 'γ'), + ("delta", 'δ'), + ("epsilon", 'ε'), + ("zeta", 'ζ'), + ("eta", 'η'), + ("theta", 'θ'), + ("iota", 'ι'), + ("kappa", 'κ'), + ("lambda", 'λ'), + ("mu", 'μ'), + ("nu", 'ν'), + ("xi", 'ξ'), + ("pi", 'π'), + ("rho", 'ρ'), + ("sigma", 'σ'), + ("tau", 'τ'), + ("upsilon", 'υ'), + ("phi", 'φ'), + ("chi", 'χ'), + ("psi", 'ψ'), + ("omega", 'ω'), + ("Gamma", 'Γ'), + ("Delta", 'Δ'), + ("Theta", 'Θ'), + ("Lambda", 'Λ'), + ("Xi", 'Ξ'), + ("Pi", 'Π'), + ("Sigma", 'Σ'), + ("Upsilon", 'Υ'), + ("Phi", 'Φ'), + ("Psi", 'Ψ'), + ("Omega", 'Ω'), +]; + +/// Parse the *interior* of a math span — delimiters already stripped. +/// +/// # Errors +/// Returns [`MathParseError`] for anything outside the Q#MS2 subset. +pub fn parse(source: &str) -> Result { + let mut parser = Parser { + chars: source.chars().collect(), + pos: 0, + }; + let nodes = parser.parse_sequence(None)?; + if parser.pos < parser.chars.len() { + // Only a stray `}` can stop the top-level sequence early. + return Err(MathParseError::UnbalancedBrace); + } + if nodes.is_empty() { + return Err(MathParseError::Empty); + } + Ok(MathNode::Group(nodes)) +} + +struct Parser { + chars: Vec, + pos: usize, +} + +impl Parser { + fn peek(&self) -> Option { + self.chars.get(self.pos).copied() + } + + fn bump(&mut self) -> Option { + let ch = self.peek(); + if ch.is_some() { + self.pos += 1; + } + ch + } + + /// Parse until `close` (or end of input when `None`). + fn parse_sequence(&mut self, close: Option) -> Result, MathParseError> { + let mut out: Vec = Vec::new(); + loop { + // Whitespace is insignificant in math mode, and it must be + // skipped HERE rather than inside `parse_atom`: the `^`/`_` + // dispatch below happens before atoms are read, so leaving a + // space in front of a marker would make `x ^ 2` parse the caret + // as a literal character. + while self.peek().is_some_and(char::is_whitespace) { + self.pos += 1; + } + match self.peek() { + None => { + if close.is_some() { + return Err(MathParseError::UnbalancedBrace); + } + return Ok(out); + } + Some(ch) if Some(ch) == close => { + self.pos += 1; + return Ok(out); + } + // A `}` we were not asked to stop at is unbalanced. + Some('}') => return Err(MathParseError::UnbalancedBrace), + Some('$') => return Err(MathParseError::UnexpectedDollar), + Some('^' | '_') => { + let base = out.pop().ok_or(MathParseError::MalformedScript( + "sub/superscript with no base", + ))?; + out.push(self.parse_scripts(base)?); + } + Some(_) => { + let atom = self.parse_atom()?; + out.push(atom); + } + } + } + } + + /// One atom: a group, a command, or a single character. + fn parse_atom(&mut self) -> Result { + match self.bump() { + Some('{') => Ok(MathNode::Group(self.parse_sequence(Some('}'))?)), + Some('\\') => self.parse_command(), + Some(ch) => Ok(MathNode::Char(ch)), + None => Err(MathParseError::MalformedCommand("unexpected end of input")), + } + } + + fn parse_command(&mut self) -> Result { + let mut name = String::new(); + while let Some(ch) = self.peek() { + if ch.is_ascii_alphabetic() { + name.push(ch); + self.pos += 1; + } else { + break; + } + } + if name.is_empty() { + // `\$`, `\{` … — an escaped literal. + return match self.bump() { + Some(ch) => Ok(MathNode::Char(ch)), + None => Err(MathParseError::MalformedCommand("trailing backslash")), + }; + } + if name == "frac" { + let num = self.parse_required_group("\\frac numerator")?; + let den = self.parse_required_group("\\frac denominator")?; + return Ok(MathNode::Fraction { + num: Box::new(num), + den: Box::new(den), + }); + } + if let Some((_, ch)) = GREEK.iter().find(|(n, _)| *n == name) { + return Ok(MathNode::Char(*ch)); + } + Err(MathParseError::UnknownCommand(name)) + } + + /// A `{…}` argument, skipping leading whitespace. + fn parse_required_group(&mut self, what: &'static str) -> Result { + while self.peek().is_some_and(char::is_whitespace) { + self.pos += 1; + } + match self.peek() { + Some('{') => { + self.pos += 1; + Ok(MathNode::Group(self.parse_sequence(Some('}'))?)) + } + _ => Err(MathParseError::MalformedCommand(what)), + } + } + + /// Attach `^`/`_` to `base`, in either order, at most one each. + fn parse_scripts(&mut self, base: MathNode) -> Result { + let mut sub: Option> = None; + let mut sup: Option> = None; + while let Some(marker @ ('^' | '_')) = self.peek() { + self.pos += 1; + let slot = self.parse_script_operand()?; + match marker { + '^' if sup.is_some() => { + return Err(MathParseError::MalformedScript("double superscript")); + } + '_' if sub.is_some() => { + return Err(MathParseError::MalformedScript("double subscript")); + } + '^' => sup = Some(Box::new(slot)), + _ => sub = Some(Box::new(slot)), + } + } + Ok(MathNode::Script { + base: Box::new(base), + sub, + sup, + }) + } + + /// The operand of `^`/`_`: a braced group, or exactly one atom. + fn parse_script_operand(&mut self) -> Result { + while self.peek().is_some_and(char::is_whitespace) { + self.pos += 1; + } + match self.peek() { + None => Err(MathParseError::MalformedScript("script with no operand")), + Some('^' | '_') => Err(MathParseError::MalformedScript("script with no operand")), + Some('}') => Err(MathParseError::MalformedScript("script with no operand")), + Some(_) => self.parse_atom(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ch(c: char) -> MathNode { + MathNode::Char(c) + } + + fn group(nodes: Vec) -> MathNode { + MathNode::Group(nodes) + } + + #[test] + fn plain_characters_parse_in_order() { + assert_eq!(parse("x+1"), Ok(group(vec![ch('x'), ch('+'), ch('1')]))); + } + + #[test] + fn superscript_and_subscript_attach_to_the_preceding_atom() { + // Framing acceptance 1. + assert_eq!( + parse("x^2"), + Ok(group(vec![MathNode::Script { + base: Box::new(ch('x')), + sub: None, + sup: Some(Box::new(ch('2'))), + }])) + ); + assert_eq!( + parse("x_i"), + Ok(group(vec![MathNode::Script { + base: Box::new(ch('x')), + sub: Some(Box::new(ch('i'))), + sup: None, + }])) + ); + } + + #[test] + fn both_scripts_parse_in_either_order() { + let expected = MathNode::Script { + base: Box::new(ch('x')), + sub: Some(Box::new(ch('i'))), + sup: Some(Box::new(ch('2'))), + }; + assert_eq!(parse("x_i^2"), Ok(group(vec![expected.clone()]))); + assert_eq!(parse("x^2_i"), Ok(group(vec![expected]))); + } + + #[test] + fn braced_script_operands_group() { + assert_eq!( + parse("x^{i+1}"), + Ok(group(vec![MathNode::Script { + base: Box::new(ch('x')), + sub: None, + sup: Some(Box::new(group(vec![ch('i'), ch('+'), ch('1')]))), + }])) + ); + } + + #[test] + fn fraction_takes_two_braced_arguments() { + assert_eq!( + parse(r"\frac{a}{b}"), + Ok(group(vec![MathNode::Fraction { + num: Box::new(group(vec![ch('a')])), + den: Box::new(group(vec![ch('b')])), + }])) + ); + } + + #[test] + fn fractions_nest() { + // Framing acceptance 1 and 12's over-tall candidate. + let inner = MathNode::Fraction { + num: Box::new(group(vec![ch('a')])), + den: Box::new(group(vec![ch('b')])), + }; + assert_eq!( + parse(r"\frac{\frac{a}{b}}{c}"), + Ok(group(vec![MathNode::Fraction { + num: Box::new(group(vec![inner])), + den: Box::new(group(vec![ch('c')])), + }])) + ); + } + + #[test] + fn greek_seed_resolves_to_codepoints_not_markup() { + assert_eq!(parse(r"\alpha"), Ok(group(vec![ch('α')]))); + assert_eq!(parse(r"\Gamma"), Ok(group(vec![ch('Γ')]))); + // The AST stays semantic: no italic mapping here (that is layout's, + // per this module's header and Q#MS2). + assert_eq!(parse(r"\alpha x"), Ok(group(vec![ch('α'), ch('x')]))); + } + + #[test] + fn whitespace_is_insignificant() { + assert_eq!(parse("x ^ 2"), parse("x^2")); + assert_eq!(parse(r"\frac {a} {b}"), parse(r"\frac{a}{b}")); + } + + #[test] + fn subset_violations_are_errors_not_panics() { + // Framing acceptance 1 and 9. + assert_eq!(parse(""), Err(MathParseError::Empty)); + assert_eq!(parse(" "), Err(MathParseError::Empty)); + assert_eq!(parse("{a"), Err(MathParseError::UnbalancedBrace)); + assert_eq!(parse("a}"), Err(MathParseError::UnbalancedBrace)); + assert_eq!( + parse(r"\sqrt{2}"), + Err(MathParseError::UnknownCommand("sqrt".to_owned())) + ); + assert!(matches!( + parse(r"\frac{a}"), + Err(MathParseError::MalformedCommand(_)) + )); + assert!(matches!( + parse(r"\frac a b"), + Err(MathParseError::MalformedCommand(_)) + )); + assert!(matches!( + parse("^2"), + Err(MathParseError::MalformedScript(_)) + )); + assert!(matches!( + parse("x^"), + Err(MathParseError::MalformedScript(_)) + )); + assert!(matches!( + parse("x^2^3"), + Err(MathParseError::MalformedScript(_)) + )); + } + + #[test] + fn an_interior_dollar_is_rejected_so_display_math_degrades() { + // Framing acceptance 15: `$$x$$` reaches us as the interior `$x$` + // (outer delimiters stripped), and must degrade to source rather + // than half-render. The empty-span path covers `$$` alone. + assert_eq!(parse("$x$"), Err(MathParseError::UnexpectedDollar)); + assert_eq!(parse(""), Err(MathParseError::Empty)); + } + + #[test] + fn escaped_literals_survive_as_characters() { + assert_eq!(parse(r"\{"), Ok(group(vec![ch('{')]))); + assert_eq!(parse(r"\$"), Ok(group(vec![ch('$')]))); + } +}