From cbf7782726b3e6bd797feeac1cc31bd581d72fbd Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 20:19:40 -0400 Subject: [PATCH] =?UTF-8?q?fix(math):=20review=20round=203=20=E2=80=94=20m?= =?UTF-8?q?apping=20bug,=20whitespace=20defect,=20real=20MATH=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1 was a real bug pinned by my own committed test. `end` in ChunkSource::MathBox is EXCLUSIVE, so source position `end` is the first byte AFTER the span — but the arm claimed it for the box's left edge, and the test asserted that wrong value while calling the byte "interior". Consequences it would have caused once overlays land: a search match starting just after a span washes the whole box it does not intersect, violating Q#MS11; a peer caret after the span draws at the box's left edge; caret geometry jumps backwards. The same class existed in projected_to_source for a line-FINAL box, where `within` clamps to the run length and the arm returned `start` unconditionally, so a click past end-of-line landed on the span start. Both committed hit tests put a chunk after the box, so that edge was never exercised; there is now a test with the box last. F2: parse_scripts peeked for the next marker without skipping whitespace, so `x^2 _i` built a NESTED script — drawing the subscript displaced right by the superscript's width — and `x^2 ^3` parsed where TeX errors, contradicting the module's own "whitespace is insignificant" rule. F3: layout is now fallible. A character the math font cannot draw used to yield zero metrics and still emit a Glyph item, rendering tofu at zero advance over its neighbour. Q#MS8's rule is "failure is always show the source", and the draw pass needs a refusal signal — changed now, before that pass consumes the API. F4: the fraction gap was a hardcoded `thickness * 2.0` while the MATH table's FractionNumeratorGapMin / FractionDenominatorGapMin went unread. Reading them moved the flagship \frac{a}{b} from 0.732 to 0.867 and the fallback boundary from depth 3 to depth 5. The round-2 review's hand-arithmetic estimate of ~0.85 was right; my 0.732 was inflated by the guess. The depth-SEARCHING test absorbed the change without edits, which is the property it was written for. F5: TeX's \epsilon and \phi are the lunate/symbol forms (U+03F5, U+03D5), not U+03B5/U+03C6. Their italic mappings had to land with the seed change, since both sit outside math_italic's U+03B1..03C9 run and would otherwise render upright beside italic neighbours. F7: the line-box budget derivation moved out of the test into `line_box_budget`, so the draw pass and the acceptance test cannot compute different splits while both stay green. F8: the live-code clippy items are cleared. The 25 that remain are all dead-code awaiting the draw pass. 189 pmacs-gpu tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- docs/inline-math-slice-framing.md | 19 ++-- pmacs-gpu/src/main.rs | 58 ++++++++--- pmacs-gpu/src/math_layout.rs | 154 +++++++++++++++++++++++------- pmacs-gpu/src/math_parse.rs | 47 +++++++-- 4 files changed, 219 insertions(+), 59 deletions(-) diff --git a/docs/inline-math-slice-framing.md b/docs/inline-math-slice-framing.md index a100073..2c9f626 100644 --- a/docs/inline-math-slice-framing.md +++ b/docs/inline-math-slice-framing.md @@ -529,13 +529,20 @@ something reaches the screen runs on a real device through | expression | ascent | descent | scale | | --- | --- | --- | --- | | `x^2`, `\alpha x` | 13.27 | 0.18 | 1.000 | - | `\frac{a}{b}` | 11.57 | 6.40 | **0.732** | - | `\frac{x^2}{y}` | 15.91 | 5.75 | 0.814 | - | nesting depth 2 | — | — | 0.744 | - | nesting depth 3 | — | — | **0.580** | + | `\frac{a}{b}` | 10.57 | 5.40 | **0.867** | + | `\frac{x^2}{y}` | 14.91 | 4.75 | 0.986 | + | nesting depth 2 | — | — | 0.872 | + | nesting depth 4 | — | — | 0.613 | + | nesting depth 5 | — | — | **0.540** | - So **B6 holds** — the flagship fraction renders at 0.732 — and the - fallback case is **depth 3**, not the doubly-nested one rev 3 guessed. + So **B6 holds** — the flagship fraction renders at 0.867 — and the + fallback case is **depth 5**. Rev 3 guessed depth 2; the first + measurement said depth 3 while the fraction gap was still a hardcoded + `2 × thickness` guess; reading the MATH table's real + `FractionNumeratorGapMin` / `FractionDenominatorGapMin` (round-3 F4) + moved the flagship from 0.732 to 0.867 and the boundary to depth 5. The + round-2 hand-arithmetic estimate of ~0.85 was right all along; the 0.732 + was inflated by the guessed gap. Round 2 predicted exactly this trap. Two things worth keeping: depth 2 scores *higher* than depth 1 because the binding constraint flips from descent to ascent as nesting grows asymmetrically, so "deeper is always diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 961e0fc..677a0be 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -7743,9 +7743,6 @@ struct ProjectedRun { source: ChunkSource, } -/// Build the projected→source run map plus the projected text's line -/// start table (cosmic-text reports hits as line + byte-within-line). - #[cfg(test)] mod math_chunk_tests { use super::*; @@ -7812,13 +7809,38 @@ mod math_chunk_tests { ]; assert_eq!(source_to_projected(&chunks, 0), Some(0)); assert_eq!(source_to_projected(&chunks, 2), Some(2)); - // Interior source bytes collapse onto the left edge. + // Interior source bytes collapse onto the left edge. `end` (7) is + // EXCLUSIVE and therefore NOT interior — an earlier revision of this + // test asserted Some(2) for it and pinned the bug. assert_eq!(source_to_projected(&chunks, 4), Some(2)); - assert_eq!(source_to_projected(&chunks, 7), Some(2)); - // The first byte after the span lands past the reserved width. + assert_eq!(source_to_projected(&chunks, 6), Some(2)); + assert_eq!(source_to_projected(&chunks, 7), Some(5), "end is exclusive"); assert_eq!(source_to_projected(&chunks, 8), Some(6)); } + /// A box at the END of a line has no following chunk to catch the + /// trailing boundary, which is exactly where the interior-snap rule used + /// to send a click back to the span start. + #[test] + fn a_line_final_math_box_maps_its_trailing_boundary_after_the_span() { + let chunks = vec![ + RichChunk { + text: "ab".to_owned(), + color: None, + source: ChunkSource::Source { start: 0 }, + }, + spacer_chunk(2, 7, 3), + ]; + let (runs, _) = build_hit_runs(&chunks); + assert_eq!(projected_to_source(&runs, 2), Some(2), "interior snaps"); + assert_eq!(projected_to_source(&runs, 4), Some(2), "interior snaps"); + assert_eq!( + projected_to_source(&runs, 5), + Some(7), + "the trailing boundary lands after the span, not on its start" + ); + } + /// A math chunk carries generated spacer text, so tab expansion must /// leave it alone rather than treating a space as a source tab. #[test] @@ -7834,6 +7856,8 @@ mod math_chunk_tests { } } +/// Build the projected→source run map plus the projected text's line +/// start table (cosmic-text reports hits as line + byte-within-line). fn build_hit_runs(chunks: &[RichChunk]) -> (Vec, Vec) { let mut runs = Vec::with_capacity(chunks.len()); let mut line_starts = vec![0u64]; @@ -7872,9 +7896,11 @@ fn projected_to_source(runs: &[ProjectedRun], projected: u64) -> Option { ChunkSource::Source { start } => Some(start + within), ChunkSource::SourceTab { start } => Some(start + u64::from(within > 0)), ChunkSource::Adornment { anchor } => Some(anchor), - // Q#MS4: the box has no interior byte map, so every boundary inside - // it snaps to the span start rather than inventing a sub-position. - ChunkSource::MathBox { start, .. } => Some(start), + // Q#MS4: interior boundaries snap to the span start, since the box + // has no interior byte map. The TRAILING boundary is not interior — + // it maps after the span, matching `SourceTab`'s rule and keeping a + // click past a line-final box off the span start. + ChunkSource::MathBox { start, end } => Some(if within >= run.len { end } else { start }), } } @@ -7910,14 +7936,16 @@ fn source_to_projected(chunks: &[RichChunk], source: u64) -> Option { } } ChunkSource::MathBox { start, end } => { - // Anywhere within the suppressed range maps to the box's - // left edge; past it, the box's full reserved width applies. - if source <= start { - return Some(projected); - } - if source <= end { + // `end` is EXCLUSIVE: it is the first byte AFTER the span, so + // it must NOT claim the box's left edge. Letting it fall + // through gives it the position past the reserved width — + // which also keeps caret geometry continuous and stops a + // search match starting at `end` from washing a box it does + // not intersect (Q#MS11). + if source < end { return Some(projected); } + let _ = start; } } projected += len; diff --git a/pmacs-gpu/src/math_layout.rs b/pmacs-gpu/src/math_layout.rs index be0cec1..b7a2490 100644 --- a/pmacs-gpu/src/math_layout.rs +++ b/pmacs-gpu/src/math_layout.rs @@ -12,7 +12,7 @@ use ttf_parser::Face; /// Bundled math font (GUST Font License — see `fonts/GUST-FONT-LICENSE.txt`). /// -/// Distinct from `fonts/OFL.txt`, which covers JetBrains Mono only: Latin +/// Distinct from `fonts/OFL.txt`, which covers `JetBrains` Mono only: Latin /// Modern Math is GFL, an LPPL-derived licence, not the SIL OFL (framing F6). pub const LATIN_MODERN_MATH: &[u8] = include_bytes!("../fonts/latinmodern-math.otf"); @@ -36,6 +36,10 @@ pub struct MathConstants { pub subscript_shift_down: i16, /// Thickness of the fraction rule. pub fraction_rule_thickness: i16, + /// Minimum gap between the numerator and the rule. + pub fraction_numerator_gap_min: i16, + /// Minimum gap between the rule and the denominator. + pub fraction_denominator_gap_min: i16, } /// Why the bundled font could not supply math metrics. @@ -51,6 +55,12 @@ pub enum MathFontError { NoMathTable, /// MATH table present but missing a constant the subset needs. MissingConstant(&'static str), + /// The math font cannot draw this codepoint (F3). Q#MS8's rule is + /// "failure is always show the source", so layout REFUSES rather than + /// emitting a zero-width item that would render tofu over its neighbour. + /// Layout is fallible for this reason alone; the draw pass needs a + /// refusal signal, and it must exist before that pass consumes the API. + UncoverableGlyph(char), } impl MathConstants { @@ -72,6 +82,8 @@ impl MathConstants { superscript_shift_up: constants.superscript_shift_up().value, subscript_shift_down: constants.subscript_shift_down().value, fraction_rule_thickness: constants.fraction_rule_thickness().value, + fraction_numerator_gap_min: constants.fraction_numerator_gap_min().value, + fraction_denominator_gap_min: constants.fraction_denominator_gap_min().value, }) } @@ -118,6 +130,12 @@ pub fn math_italic(ch: char) -> char { 'a'..='z' => 0x1D44E + (ch as u32 - 'a' as u32), // Lowercase Greek α..ω → MATHEMATICAL ITALIC SMALL ALPHA..OMEGA. '\u{3B1}'..='\u{3C9}' => 0x1D6FC + (ch as u32 - 0x3B1), + // The SYMBOL forms TeX's \epsilon and \phi resolve to sit OUTSIDE + // that run, so they need explicit italic mappings — without them the + // seed map's correction would render them upright beside italic + // neighbours, which is the defect it was fixing. + '\u{3F5}' => 0x1D716, // ϵ lunate epsilon + '\u{3D5}' => 0x1D719, // ϕ phi symbol // Uppercase Greek, digits, operators: upright, per TeX. _ => return ch, }; @@ -243,6 +261,26 @@ impl MathBox { } } +/// The line-box height budget a math box must fit (Q#MS10), as +/// `(above_baseline, below_baseline)` pixels. +/// +/// Extracted rather than left inside a test: the draw pass must compute the +/// SAME split the acceptance test asserts, and a duplicated derivation is +/// exactly how a renderer and its test drift apart while both stay green. +/// +/// The baseline is placed by the CODE font, not the math font — using the +/// math font's own metrics understates the descent budget badly enough to +/// make a plain fraction appear not to fit. +#[must_use] +pub fn line_box_budget(code_font: &Face<'_>, font_size_px: f32, line_height_px: f32) -> (f32, f32) { + const MARGIN_PX: f32 = 1.0; + let upem = f32::from(code_font.units_per_em().max(1)); + let baseline_from_top = f32::from(code_font.ascender()) * font_size_px / upem; + let above = (baseline_from_top - MARGIN_PX).max(0.0); + let below = (line_height_px - baseline_from_top - MARGIN_PX).max(0.0); + (above, below) +} + /// The smallest uniform scale the slice will apply before giving up (Q#MS10). pub const MIN_FIT_SCALE: f32 = 0.6; @@ -296,8 +334,15 @@ impl<'a> MathLayout<'a> { } /// Lay `node` out at `size_px`. - #[must_use] - pub fn layout(&self, node: &crate::math_parse::MathNode, size_px: f32) -> MathBox { + /// + /// # Errors + /// [`MathFontError::UncoverableGlyph`] when the math font has no glyph + /// for a character, so the caller can fall back to source (Q#MS8). + pub fn layout( + &self, + node: &crate::math_parse::MathNode, + size_px: f32, + ) -> Result { use crate::math_parse::MathNode; match node { MathNode::Char(ch) => self.layout_char(*ch, size_px), @@ -305,19 +350,21 @@ impl<'a> MathLayout<'a> { let mut out = MathBox::empty(); let mut pen = 0.0; for child in children { - let child_box = self.layout(child, size_px); + let child_box = self.layout(child, size_px)?; out.absorb(&child_box, pen, 0.0); pen += child_box.width; } out.width = pen; - out + Ok(out) + } + MathNode::Script { base, sub, sup } => { + self.layout_script(base, sub.as_deref(), sup.as_deref(), size_px) } - MathNode::Script { base, sub, sup } => self.layout_script(base, sub, sup, size_px), MathNode::Fraction { num, den } => self.layout_fraction(num, den, size_px), } } - fn layout_char(&self, ch: char, size_px: f32) -> MathBox { + fn layout_char(&self, ch: char, size_px: f32) -> Result { let presented = math_italic(ch); let upem = f32::from(self.constants.units_per_em.max(1)); let (advance, ascent, descent) = self @@ -347,8 +394,10 @@ impl<'a> MathLayout<'a> { ); (adv, asc.max(0.0), desc.max(0.0)) }) - .unwrap_or((0.0, 0.0, 0.0)); - MathBox { + // F3: no glyph means no honest box. Emitting a zero-width item + // would draw tofu on top of the next character. + .ok_or(MathFontError::UncoverableGlyph(ch))?; + Ok(MathBox { width: advance, ascent, descent, @@ -358,23 +407,23 @@ impl<'a> MathLayout<'a> { baseline: 0.0, size_px, }], - } + }) } fn layout_script( &self, base: &crate::math_parse::MathNode, - sub: &Option>, - sup: &Option>, + sub: Option<&crate::math_parse::MathNode>, + sup: Option<&crate::math_parse::MathNode>, size_px: f32, - ) -> MathBox { - let base_box = self.layout(base, size_px); + ) -> Result { + let base_box = self.layout(base, size_px)?; let script_px = size_px * self.constants.script_scale(); let mut out = MathBox::empty(); out.absorb(&base_box, 0.0, 0.0); let mut widest = base_box.width; if let Some(sup) = sup { - let sup_box = self.layout(sup, script_px); + let sup_box = self.layout(sup, script_px)?; let shift = self .constants .to_px(self.constants.superscript_shift_up, size_px); @@ -382,7 +431,7 @@ impl<'a> MathLayout<'a> { widest = widest.max(base_box.width + sup_box.width); } if let Some(sub) = sub { - let sub_box = self.layout(sub, script_px); + let sub_box = self.layout(sub, script_px)?; let shift = self .constants .to_px(self.constants.subscript_shift_down, size_px); @@ -390,7 +439,7 @@ impl<'a> MathLayout<'a> { widest = widest.max(base_box.width + sub_box.width); } out.width = widest; - out + Ok(out) } fn layout_fraction( @@ -398,25 +447,36 @@ impl<'a> MathLayout<'a> { num: &crate::math_parse::MathNode, den: &crate::math_parse::MathNode, size_px: f32, - ) -> MathBox { + ) -> Result { // TeX sets an inline \frac's operands one style down, which is also // what the parent framing's Tier 3 specifies (70%). It is load-bearing // for Q#MS10: full-size operands would not fit the line at all. let operand_px = size_px * self.constants.script_scale(); - let num_box = self.layout(num, operand_px); - let den_box = self.layout(den, operand_px); + let num_box = self.layout(num, operand_px)?; + let den_box = self.layout(den, operand_px)?; let axis = self.constants.to_px(self.constants.axis_height, size_px); let thickness = self .constants .to_px(self.constants.fraction_rule_thickness, size_px) .max(1.0); - let gap = thickness * 2.0; + // F4: the gaps come from the MATH table, not a guess. An earlier + // revision used `thickness * 2.0`, which made fractions roughly twice + // as airy as the font specifies and inflated the height budget the + // fit-to-line scale is measured against. + let num_gap = self + .constants + .to_px(self.constants.fraction_numerator_gap_min, size_px) + .max(thickness); + let den_gap = self + .constants + .to_px(self.constants.fraction_denominator_gap_min, size_px) + .max(thickness); let width = num_box.width.max(den_box.width); let mut out = MathBox::empty(); // Numerator sits above the bar, denominator below it. - let num_baseline = axis + thickness / 2.0 + gap + num_box.descent; - let den_baseline = axis - thickness / 2.0 - gap - den_box.ascent; + let num_baseline = axis + thickness / 2.0 + num_gap + num_box.descent; + let den_baseline = axis - thickness / 2.0 - den_gap - den_box.ascent; out.absorb(&num_box, (width - num_box.width) / 2.0, num_baseline); out.absorb(&den_box, (width - den_box.width) / 2.0, den_baseline); out.items.push(MathItem::Rule { @@ -428,7 +488,7 @@ impl<'a> MathLayout<'a> { out.ascent = out.ascent.max(axis + thickness / 2.0); out.descent = out.descent.max(-(axis - thickness / 2.0)); out.width = width; - out + Ok(out) } } @@ -456,6 +516,22 @@ mod tests { use crate::math_parse::parse; + #[test] + fn tex_symbol_greek_forms_are_italicised_too() { + // F5's trap: correcting the seed map alone leaves these upright, + // because they sit outside the U+03B1..03C9 run. + assert_eq!(math_italic('\u{3F5}'), '\u{1D716}'); + assert_eq!(math_italic('\u{3D5}'), '\u{1D719}'); + let face = Face::parse(LATIN_MODERN_MATH, 0).expect("face"); + for ch in ['\u{3F5}', '\u{3D5}'] { + assert!( + face.glyph_index(math_italic(ch)).is_some(), + "no glyph for the italic form of U+{:04X}", + ch as u32 + ); + } + } + #[test] fn spacer_quantizes_up_to_whole_advances() { // Exactly two advances stays two; a sliver over rounds up, so the @@ -496,7 +572,21 @@ mod tests { fn lay(src: &str, size: f32) -> MathBox { let node = parse(src).expect("parses"); - engine().layout(&node, size) + engine().layout(&node, size).expect("lays out") + } + + /// F3 — a codepoint the math font cannot draw REFUSES, so the caller can + /// fall back to source (Q#MS8) instead of drawing tofu at zero advance + /// on top of the next character. + #[test] + fn an_uncoverable_character_refuses_layout_instead_of_emitting_a_void() { + let node = parse("x日").expect("parses — coverage is layout's problem"); + assert_eq!( + engine().layout(&node, 16.0), + Err(MathFontError::UncoverableGlyph('日')) + ); + // The covered neighbour on its own still lays out. + assert!(engine().layout(&parse("x").unwrap(), 16.0).is_ok()); } /// Framing acceptance 3, including its bite: the MATH constant must be @@ -517,11 +607,11 @@ mod tests { .iter() .find_map(|i| match *i { MathItem::Glyph { - ch, + ch: '2', baseline, size_px, .. - } if ch == '2' => Some((baseline, size_px)), + } => Some((baseline, size_px)), _ => None, }) .expect("the 2 is emitted"); @@ -613,11 +703,11 @@ mod tests { // (JetBrains Mono at BASE_CODE_FONT_SIZE inside BASE_CODE_LINE_HEIGHT), // NOT where the math font's own metrics would. let code = Face::parse(crate::JETBRAINS_MONO, 0).expect("code face"); - let code_upem = f32::from(code.units_per_em()); - let baseline_from_top = f32::from(code.ascender()) * crate::BASE_CODE_FONT_SIZE / code_upem; - let margin = 1.0; - let asc_budget = baseline_from_top - margin; - let desc_budget = crate::BASE_CODE_LINE_HEIGHT - baseline_from_top - margin; + let (asc_budget, desc_budget) = line_box_budget( + &code, + crate::BASE_CODE_FONT_SIZE, + crate::BASE_CODE_LINE_HEIGHT, + ); assert!( asc_budget > 0.0 && desc_budget > 0.0, "budget must be positive: {asc_budget} / {desc_budget}" diff --git a/pmacs-gpu/src/math_parse.rs b/pmacs-gpu/src/math_parse.rs index 73833d6..e93bf3e 100644 --- a/pmacs-gpu/src/math_parse.rs +++ b/pmacs-gpu/src/math_parse.rs @@ -60,7 +60,8 @@ const GREEK: &[(&str, char)] = &[ ("beta", 'β'), ("gamma", 'γ'), ("delta", 'δ'), - ("epsilon", 'ε'), + // TeX's \epsilon is LUNATE (U+03F5); U+03B5 is \varepsilon. + ("epsilon", '\u{3F5}'), ("zeta", 'ζ'), ("eta", 'η'), ("theta", 'θ'), @@ -75,7 +76,8 @@ const GREEK: &[(&str, char)] = &[ ("sigma", 'σ'), ("tau", 'τ'), ("upsilon", 'υ'), - ("phi", 'φ'), + // TeX's \phi is U+03D5; U+03C6 is \varphi. + ("phi", '\u{3D5}'), ("chi", 'χ'), ("psi", 'ψ'), ("omega", 'ω'), @@ -229,7 +231,19 @@ impl Parser { fn parse_scripts(&mut self, base: MathNode) -> Result { let mut sub: Option> = None; let mut sup: Option> = None; - while let Some(marker @ ('^' | '_')) = self.peek() { + loop { + // Whitespace is insignificant, here too: without this skip + // `x^2 _i` builds a nested Script instead of one merged double + // script (drawing the subscript displaced right by the + // superscript's width), and `x^2 ^3` parses where TeX errors. + let resume = self.pos; + while self.peek().is_some_and(char::is_whitespace) { + self.pos += 1; + } + let Some(marker @ ('^' | '_')) = self.peek() else { + self.pos = resume; + break; + }; self.pos += 1; let slot = self.parse_script_operand()?; match marker { @@ -256,9 +270,9 @@ impl Parser { 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")), + None | Some('^' | '_' | '}') => { + Err(MathParseError::MalformedScript("script with no operand")) + } Some(_) => self.parse_atom(), } } @@ -440,6 +454,27 @@ mod tests { assert_eq!(mixed.len(), 1, "{mixed:?}"); } + #[test] + fn whitespace_before_a_script_marker_still_merges_the_scripts() { + // F2: without skipping whitespace in `parse_scripts`, `x^2 _i` built + // a NESTED script and drew the subscript displaced right. + assert_eq!(parse("x^2 _i"), parse("x^2_i")); + assert_eq!(parse("x _i ^2"), parse("x_i^2")); + // And a doubled script is still an error with space between. + assert!(matches!( + parse("x^2 ^3"), + Err(MathParseError::MalformedScript(_)) + )); + } + + #[test] + fn the_greek_seed_uses_tex_letter_forms() { + // F5: TeX's \epsilon is lunate and \phi is the symbol form; the + // U+03B5 / U+03C6 glyphs are \varepsilon / \varphi. + assert_eq!(parse(r"\epsilon"), Ok(group(vec![ch('\u{3F5}')]))); + assert_eq!(parse(r"\phi"), Ok(group(vec![ch('\u{3D5}')]))); + } + #[test] fn plain_characters_parse_in_order() { assert_eq!(parse("x+1"), Ok(group(vec![ch('x'), ch('+'), ch('1')])));