use std::ops::Range;
use ratatui::style::Color;
use serde::{Deserialize, Serialize};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use these_macros_should_be_illegal::*;
use crate::backend::{CollectionKind, M2Value, OPTION_ARROW, SPACED_OPTION_ARROW};
use crate::ui::block::TextSpan;
use crate::ui::config::UI;
use crate::ui::controller::settings::theme::groups::{Groups, HLGroup};
use crate::ui::cycle::cyclic;
use crate::ui::graphics;
use crate::ui::present::{wrap, SpanLine};
use crate::ui::wrap::WordWrap;
use super::markup;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Math {
Atom { text: String, kind: HLGroup },
Space,
Row(Vec<Math>),
Power {
base: Box<Math>,
exponent: Box<Math>,
},
Index {
base: Box<Math>,
subscript: Box<Math>,
},
Fraction {
numerator: Box<Math>,
denominator: Box<Math>,
},
Delimited {
left: char,
right: char,
body: Box<Math>,
},
Grid {
rows: Vec<Vec<Math>>,
kind: GridKind,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GridKind {
Bare,
Matrix,
Cases,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UnsupportedTeX(pub String);
cyclic! {
#[discriminated_str(label)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FractionStyle {
#[default]
Stacked = "stacked",
Underlined = "underlined",
Inline = "inline",
}
}
cyclic! {
#[discriminated_str(label)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum MathDisplay {
#[default]
Unicode = "unicode",
Ascii = "ascii",
#[serde(alias = "ratex", alias = "latex")]
Typeset = "typeset",
}
}
impl MathDisplay {
pub fn available(self) -> bool {
self != Self::Typeset || graphics::available()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MathStyle {
pub fractions: FractionStyle,
pub blackboard: bool,
pub display: MathDisplay,
pub matrix_row_spacing_percent: u16,
}
impl Default for MathStyle {
fn default() -> Self {
Self {
fractions: FractionStyle::default(),
blackboard: false,
display: MathDisplay::default(),
matrix_row_spacing_percent: UI.matrix_row_spacing_percent,
}
}
}
impl From<FractionStyle> for MathStyle {
fn from(fractions: FractionStyle) -> Self {
Self {
fractions,
..Self::default()
}
}
}
pub fn spans(
value: &M2Value,
width: usize,
style: impl Into<MathStyle>,
) -> Result<Vec<SpanLine>, UnsupportedTeX> {
let style = style.into();
match value {
M2Value::Type(inner) => {
let mut rows = spans(inner, width, style)
.unwrap_or_else(|_| MathBlock::fitted(vec![MathBlock::value(inner, style)], width));
for span in rows.iter_mut().flatten() {
span.kind = HLGroup::Type;
}
Ok(rows)
}
M2Value::Tex { source, .. } => {
Ok(Math::from_tex(source, style.blackboard)?.wrapped(width, style.fractions))
}
M2Value::Collection { kind, items } if *kind != CollectionKind::VerticalList => Ok(
MathBlock::fitted(MathBlock::listed(*kind, items, width, style), width),
),
M2Value::Collection { .. } | M2Value::HashTable(_) | M2Value::Option { .. } => Ok(
MathBlock::fitted(vec![MathBlock::value(value, style)], width),
),
M2Value::Token { text, role } => Ok(prose(text, role.group(), width)),
M2Value::Net(text) => Ok(prose(text, HLGroup::String, width)),
M2Value::Markup { html, .. } => markup::spans(html, width)
.ok_or_else(|| UnsupportedTeX("markup that does not read as HTML".into())),
}
}
fn prose(text: &str, group: HLGroup, width: usize) -> Vec<SpanLine> {
let width = u16::try_from(width).unwrap_or(u16::MAX);
text.lines()
.flat_map(|line| wrap(&[TextSpan::new(line, group)], width, &WordWrap))
.collect()
}
pub fn annotation(parts: &[M2Value], width: usize, style: impl Into<MathStyle>) -> Vec<SpanLine> {
let style = style.into();
let line = MathBlock::row(parts.iter().map(|part| MathBlock::value(part, style)));
MathBlock::fitted(vec![line.retag(HLGroup::Type)], width)
}
pub fn inline(tex: &str, blackboard: bool) -> Option<SpanLine> {
let rows = Math::parse_with(tex, blackboard)
.ok()?
.layout(FractionStyle::Inline)
.into_rows();
let [row] = <[SpanLine; 1]>::try_from(rows).ok()?;
Some(row)
}
pub fn typewriter(text: &str) -> String {
let mut tex = String::from(r"\texttt{");
for character in text.chars() {
match TEXT_SYMBOLS
.iter()
.find(|(_, symbol)| symbol.starts_with(character))
{
Some((command, _)) => {
tex.push('\\');
tex.push_str(command);
tex.push_str("{}");
}
None if TEXT_ESCAPED.contains(character) => {
tex.push('\\');
tex.push(character);
}
None => tex.push(character),
}
}
tex.push('}');
tex
}
pub fn pictured_tex(tex: &str) -> String {
PICTURED_FORMS
.iter()
.fold(tex.to_string(), |tex, (set, drawn)| tex.replace(set, drawn))
}
pub fn colored_tex(source: &str, groups: &Groups) -> String {
let mut input = Input::new(source, true);
if input.expression(&[]).is_err() || !input.rest().is_empty() {
return source.to_string();
}
let source = input.source;
let mut ranges = input.highlights;
ranges.sort_by_key(|(range, _)| (range.start, std::cmp::Reverse(range.end)));
let mut result = String::new();
let mut ends = Vec::new();
let mut at = 0;
for (range, kind) in ranges {
while ends.last().is_some_and(|end| *end <= range.start) {
let end = ends.pop().expect("an end was checked");
result.push_str(&source[at..end]);
result.push('}');
at = end;
}
let ink = groups.style(kind);
let color = if kind == HLGroup::FractionRule {
ink.underline_color.or(ink.fg)
} else {
ink.fg
};
let Some(Color::Rgb(r, g, b)) = color else {
continue;
};
result.push_str(&source[at..range.start]);
result.push_str(&format!("{{\\color{{#{r:02x}{g:02x}{b:02x}}}"));
ends.push(range.end);
at = range.start;
}
while let Some(end) = ends.pop() {
result.push_str(&source[at..end]);
result.push('}');
at = end;
}
result.push_str(&source[at..]);
result
}
struct Input<'a> {
source: &'a str,
at: usize,
blackboard: bool,
highlights: Vec<(Range<usize>, HLGroup)>,
}
impl<'a> Input<'a> {
fn new(source: &'a str, blackboard: bool) -> Self {
Self {
source: source.trim(),
at: 0,
blackboard,
highlights: Vec::new(),
}
}
fn rest(&self) -> &'a str {
&self.source[self.at..]
}
fn skip_trivia(&mut self) {
loop {
match self.rest().chars().next() {
Some(space) if space.is_whitespace() => {
self.at += space.len_utf8();
continue;
}
_ => {}
}
if self.eat(PHANTOM) {
self.skip_group();
continue;
}
match SPACING
.iter()
.find(|space| self.rest().starts_with(**space))
{
Some(space) => self.at += space.len(),
None => return,
}
}
}
fn text_group(&mut self) -> Result<String, UnsupportedTeX> {
self.at += self.rest().len() - self.rest().trim_start().len();
if !self.eat("{") {
return Err(UnsupportedTeX("text without a braced argument".into()));
}
let mut text = String::new();
let mut depth = 0usize;
while let Some(next) = self.rest().chars().next() {
self.at += next.len_utf8();
match next {
'{' => depth += 1,
'}' if depth == 0 => return Ok(text),
'}' => depth -= 1,
'~' => text.push(' '),
'\\' => match self.rest().chars().next() {
Some(escaped) if !escaped.is_ascii_alphabetic() => {
self.at += escaped.len_utf8();
text.push(escaped);
}
_ => {
let name = self.name();
let (_, symbol) = TEXT_SYMBOLS
.iter()
.find(|(command, _)| *command == name)
.ok_or_else(|| UnsupportedTeX(format!("\\{name} in text")))?;
text.push_str(symbol);
}
},
character => text.push(character),
}
}
Err(UnsupportedTeX("unclosed text".into()))
}
fn skip_group(&mut self) {
self.at += self.rest().len() - self.rest().trim_start().len();
if !self.eat("{") {
return;
}
let mut depth = 1;
while depth > 0 {
let Some(next) = self.rest().chars().next() else {
return;
};
self.at += next.len_utf8();
depth += i32::from(next == '{') - i32::from(next == '}');
}
}
fn eat(&mut self, token: &str) -> bool {
if self.rest().starts_with(token) {
self.at += token.len();
return true;
}
false
}
fn expression(&mut self, stops: &[&str]) -> Result<Math, UnsupportedTeX> {
let mut pieces = Vec::new();
loop {
self.skip_trivia();
if self.rest().is_empty() || stops.iter().any(|stop| self.rest().starts_with(stop)) {
break;
}
pieces.push(self.term()?);
}
Ok(match pieces.len() {
1 => pieces.pop().expect("length checked"),
_ => Math::Row(pieces),
})
}
fn term(&mut self) -> Result<Math, UnsupportedTeX> {
let mut base = self.atom()?;
loop {
if self.eat("^") {
base = Math::Power {
base: Box::new(base),
exponent: Box::new(self.atom()?),
};
} else if self.eat("_") {
let start = self.at;
let first_highlight = self.highlights.len();
let mut subscript = self.atom()?;
if base.is_type_name() && matches!(&subscript, Math::Atom { .. }) {
subscript = subscript.retag(HLGroup::Type);
self.highlights.truncate(first_highlight);
self.highlights.push((start..self.at, HLGroup::Type));
}
base = Math::Index {
base: Box::new(base),
subscript: Box::new(subscript),
};
} else {
return Ok(base);
}
}
}
fn atom(&mut self) -> Result<Math, UnsupportedTeX> {
self.skip_trivia();
let start = self.at;
let first_highlight = self.highlights.len();
let parsed = self.atom_inner()?;
let kind = match &parsed {
Math::Atom { kind, .. } => {
self.highlights.truncate(first_highlight);
Some(*kind)
}
Math::Fraction { .. } => Some(HLGroup::FractionRule),
Math::Delimited { .. } | Math::Grid { .. } => Some(HLGroup::Delimiter),
_ => None,
};
if let Some(kind) = kind {
self.highlights.push((start..self.at, kind));
}
Ok(parsed)
}
fn atom_inner(&mut self) -> Result<Math, UnsupportedTeX> {
let rest = self.rest();
let Some(next) = rest.chars().next() else {
return Err(UnsupportedTeX("expected an atom, found nothing".into()));
};
match next {
'{' => {
self.at += 1;
let body = self.expression(&["}"])?;
match self.eat("}") {
true => Ok(body),
false => Err(UnsupportedTeX("unclosed group".into())),
}
}
'\\' => self.command(),
_ if next.is_ascii_digit() => Ok(self.numeral()),
_ if next.is_alphabetic() => Ok(self.run(
|character| character.is_ascii_alphanumeric(),
HLGroup::Variable,
)),
_ if OPERATORS.contains(next) => {
self.at += next.len_utf8();
Ok(Math::atom(next, HLGroup::Operator))
}
',' => {
self.at += 1;
self.skip_trivia();
while self.eat(THIN_SPACE) {
self.skip_trivia();
}
Ok(Math::atom(", ", HLGroup::Punctuation))
}
_ if DELIMITERS.contains(next) => {
self.at += next.len_utf8();
Ok(Math::atom(next, HLGroup::Delimiter))
}
_ => Err(UnsupportedTeX(format!("unexpected character {next:?}"))),
}
}
fn run(&mut self, accept: impl Fn(&char) -> bool, kind: HLGroup) -> Math {
let start = self.at;
while self.rest().chars().next().is_some_and(|next| accept(&next)) {
self.at += self.rest().chars().next().expect("just checked").len_utf8();
}
Math::atom(&self.source[start..self.at], kind)
}
fn numeral(&mut self) -> Math {
let mut digits = String::new();
loop {
let start = self.at;
self.at += self.rest().bytes().take_while(u8::is_ascii_digit).count();
digits.push_str(&self.source[start..self.at]);
let Some(after) = self.rest().strip_prefix(THIN_SPACE) else {
break;
};
let group = after.bytes().take_while(u8::is_ascii_digit).count();
if group != DIGIT_GROUP {
break;
}
self.at += THIN_SPACE.len();
}
Math::atom(digits, HLGroup::Number)
}
fn command(&mut self) -> Result<Math, UnsupportedTeX> {
self.at += 1;
let name = self.name();
match name.as_str() {
"frac" => Ok(Math::Fraction {
numerator: Box::new(self.atom()?),
denominator: Box::new(self.atom()?),
}),
"begin" => self.environment(),
"cdot" => Ok(Math::atom(PRODUCT, HLGroup::Operator)),
"square" => Ok(Math::atom("□", HLGroup::Type)),
"," => Ok(Math::Space),
"leftarrow" | "longleftarrow" | "gets" => Ok(Math::atom("\u{27F5}", HLGroup::Operator)),
"rightarrow" | "longrightarrow" | "to" => Ok(Math::atom("\u{27F6}", HLGroup::Operator)),
"mapsto" | "longmapsto" => Ok(Math::atom("\u{27FC}", HLGroup::Operator)),
"backslash" => Ok(Math::atom("\\", HLGroup::Operator)),
"setminus" => Ok(Math::atom("\u{2216}", HLGroup::Operator)),
brace @ ("{" | "}") => Ok(Math::atom(brace, HLGroup::Delimiter)),
"left" => self.fences(),
text if TEXT_COMMANDS.contains(&text) => {
let words = Math::atom(self.text_group()?, HLGroup::Text);
Ok(match text {
"texttt" => words.named_in_code(),
_ => words,
})
}
font if FONTS.contains(&font) => {
let body = self.atom()?;
let constant = matches!(
&body,
Math::Atom { text, .. } if CONSTANT_LETTERS.contains(&(font, text.as_str()))
);
Ok(match font {
_ if constant => body.retag(HLGroup::Builtin),
"mathbb" => Math::retag(
body.lettered(&DOUBLE_STRUCK, self.blackboard),
HLGroup::Type,
),
"texttt" => body.named_in_code(),
"mathfrak" => {
Math::retag(body.lettered(&FRAKTUR, self.blackboard), HLGroup::Type)
}
"mathcal" => {
Math::retag(body.lettered(&SCRIPT, self.blackboard), HLGroup::Type)
}
_ => body,
})
}
other => CONSTANT_COMMANDS
.iter()
.find(|(command, _)| *command == other)
.map(|(_, glyph)| Math::atom(*glyph, HLGroup::Builtin))
.or_else(|| self.operator(other))
.ok_or_else(|| UnsupportedTeX(format!("\\{other}"))),
}
}
fn operator(&mut self, name: &str) -> Option<Math> {
let (command, glyph) = OPERATOR_COMMANDS
.iter()
.filter(|(command, _)| name.starts_with(command))
.max_by_key(|(command, _)| command.len())?;
self.at -= name.len() - command.len();
Some(Math::atom(*glyph, HLGroup::Operator))
}
fn name(&mut self) -> String {
let start = self.at;
while self
.rest()
.chars()
.next()
.is_some_and(|next| next.is_ascii_alphabetic())
{
self.at += 1;
}
if self.at == start {
self.at += self.rest().chars().next().map_or(0, char::len_utf8);
}
self.source[start..self.at].to_string()
}
fn environment(&mut self) -> Result<Math, UnsupportedTeX> {
if !self.eat("{") {
return Err(UnsupportedTeX("\\begin without an environment".into()));
}
let name = self.name();
if !self.eat("}") {
return Err(UnsupportedTeX("unclosed environment name".into()));
}
let kind = match name.as_str() {
"matrix" | "array" => GridKind::Bare,
"pmatrix" => GridKind::Matrix,
"cases" => GridKind::Cases,
other => return Err(UnsupportedTeX(format!("environment {other}"))),
};
if self.eat("{") {
self.name();
if !self.eat("}") {
return Err(UnsupportedTeX("unclosed column specification".into()));
}
}
let closing = format!("\\end{{{name}}}");
let rows = self.rows(&closing)?;
match self.eat(&closing) {
true => Ok(Math::Grid { rows, kind }),
false => Err(UnsupportedTeX(format!("missing {closing}"))),
}
}
fn rows(&mut self, closing: &str) -> Result<Vec<Vec<Math>>, UnsupportedTeX> {
let mut rows = vec![Vec::new()];
loop {
self.skip_trivia();
if self.rest().starts_with(closing) {
return Ok(rows);
}
if self.rest().is_empty() {
return Err(UnsupportedTeX(format!("missing {closing}")));
}
rows.last_mut()
.expect("a row is always open")
.push(self.expression(&["&", "\\\\", closing])?);
self.skip_trivia();
if self.eat("\\\\") {
rows.push(Vec::new());
} else if !self.eat("&") && !self.rest().starts_with(closing) {
return Err(UnsupportedTeX(format!("unexpected {}", self.rest())));
}
}
}
fn fences(&mut self) -> Result<Math, UnsupportedTeX> {
self.skip_trivia();
let left = self.fence()?;
let body = self.expression(&["\\right"])?;
if !self.eat("\\right") {
return Err(UnsupportedTeX("\\left without \\right".into()));
}
self.skip_trivia();
let right = self.fence()?;
Ok(Math::Delimited {
left,
right,
body: Box::new(body),
})
}
fn fence(&mut self) -> Result<char, UnsupportedTeX> {
self.skip_trivia();
for (command, fence) in FENCE_COMMANDS {
if self.eat(command) {
return Ok(fence);
}
}
self.eat("\\");
match self.rest().chars().next() {
Some(fence) if DELIMITERS.contains(fence) => {
self.at += fence.len_utf8();
Ok(fence)
}
Some(other) => Err(UnsupportedTeX(format!("{other:?} is not a fence"))),
None => Err(UnsupportedTeX("expected a fence".into())),
}
}
}
const PRODUCT: &str = "\u{22C5}";
const THIN_SPACE: &str = "\\,";
const PHANTOM: &str = "\\vphantom";
const DIGIT_GROUP: usize = 3;
const SPACING: [&str; 8] = [
"\\qquad",
"\\quad",
"\\;",
"\\:",
"\\!",
"\\ ",
"\\mathopen{}",
"\\mathclose{}",
];
const OPERATORS: &str = "+-=<>/*@:~?!%";
const CONSTANT_COMMANDS: [(&str, &str); 3] = [
("pi", "\u{03C0}"),
("gamma", "\u{03B3}"),
("infty", "\u{221E}"),
];
const CONSTANT_LETTERS: [(&str, &str); 3] = [("mathbf", "i"), ("mathrm", "i"), ("mathrm", "G")];
const PICTURED_FORMS: [(&str, &str); 1] = [(r"\mathbf{i}", r"\mathrm{i}")];
const BOOLEAN_WORDS: [&str; 2] = ["true", "false"];
const NULL_WORD: &str = "null";
const OPERATOR_COMMANDS: [(&str, &str); 19] = [
("wedge", "\u{2227}"),
("vee", "\u{2228}"),
("otimes", "\u{2297}"),
("oplus", "\u{2295}"),
("circ", "\u{2218}"),
("cap", "\u{2229}"),
("cup", "\u{222A}"),
("times", "\u{00D7}"),
("ast", "\u{2217}"),
("mid", "|"),
("Rightarrow", OPTION_ARROW),
("Leftarrow", "\u{21D0}"),
("Leftrightarrow", "\u{21D4}"),
("Longrightarrow", "\u{27F9}"),
("Longleftarrow", "\u{27F8}"),
("leq", "\u{2264}"),
("geq", "\u{2265}"),
("cdots", "\u{22EF}"),
("ldots", "..."),
];
const DELIMITERS: &str = "()[]{}|.";
const FENCE_COMMANDS: [(&str, char); 2] = [("\\langle", '\u{27E8}'), ("\\rangle", '\u{27E9}')];
pub const TALL_FENCES: [(&str, [&str; 4]); 7] = [
("(", ["\u{239B}", "\u{239C}", "\u{239D}", "\u{239C}"]),
(")", ["\u{239E}", "\u{239F}", "\u{23A0}", "\u{239F}"]),
("[", ["\u{23A1}", "\u{23A2}", "\u{23A3}", "\u{23A2}"]),
("]", ["\u{23A4}", "\u{23A5}", "\u{23A6}", "\u{23A5}"]),
("{", ["\u{23A7}", "\u{23AA}", "\u{23A9}", "\u{23A8}"]),
("}", ["\u{23AB}", "\u{23AA}", "\u{23AD}", "\u{23AC}"]),
("|", ["\u{23AA}"; 4]),
];
pub const SHORT_BRACES: [(&str, [&str; 4]); 2] = [
("{", ["\u{23B0}", "", "\u{23B1}", ""]),
("}", ["\u{23B1}", "", "\u{23B0}", ""]),
];
struct Alphabet {
first_capital: u32,
letterlike: &'static [(char, char)],
doubled_in_code: bool,
}
impl Alphabet {
fn glyph(&self, letter: char) -> Option<char> {
letter.is_ascii_uppercase().then(|| {
self.letterlike
.iter()
.find(|(plain, _)| *plain == letter)
.map_or_else(
|| {
char::from_u32(self.first_capital + u32::from(letter) - u32::from('A'))
.expect("a capital of the alphabet")
},
|(_, glyph)| *glyph,
)
})
}
}
const DOUBLE_STRUCK: Alphabet = Alphabet {
first_capital: 0x1D538,
letterlike: &[
('C', 'ℂ'),
('H', 'ℍ'),
('N', 'ℕ'),
('P', 'ℙ'),
('Q', 'ℚ'),
('R', 'ℝ'),
('Z', 'ℤ'),
],
doubled_in_code: true,
};
const FRAKTUR: Alphabet = Alphabet {
first_capital: 0x1D504,
letterlike: &[('C', 'ℭ'), ('H', 'ℌ'), ('I', 'ℑ'), ('R', 'ℜ'), ('Z', 'ℨ')],
doubled_in_code: false,
};
const SCRIPT: Alphabet = Alphabet {
first_capital: 0x1D49C,
letterlike: &[
('B', 'ℬ'),
('E', 'ℰ'),
('F', 'ℱ'),
('H', 'ℋ'),
('I', 'ℐ'),
('L', 'ℒ'),
('M', 'ℳ'),
('R', 'ℛ'),
],
doubled_in_code: false,
};
const FONTS: [&str; 8] = [
"mathbb", "mathbf", "mathcal", "mathrm", "mathfrak", "mathsf", "mathtt", "mathit",
];
const TEXT_COMMANDS: [&str; 3] = ["texttt", "textrm", "text"];
const TEXT_SYMBOLS: [(&str, &str); 3] = [
("textbackslash", "\\"),
("textasciitilde", "~"),
("textasciicircum", "^"),
];
const TEXT_ESCAPED: &str = "#$%&_{}";
impl Math {
#[cfg(test)]
pub fn parse(source: &str) -> Result<Self, UnsupportedTeX> {
Self::parse_with(source, false)
}
fn parse_with(source: &str, blackboard: bool) -> Result<Self, UnsupportedTeX> {
let mut input = Input::new(source, blackboard);
let parsed = input.expression(&[])?;
match input.rest().is_empty() {
true => Ok(parsed),
false => Err(UnsupportedTeX(format!("trailing input: {}", input.rest()))),
}
}
fn atom(text: impl Into<String>, kind: HLGroup) -> Self {
Self::Atom {
text: text.into(),
kind,
}
}
fn lettered(self, alphabet: &Alphabet, glyphs: bool) -> Self {
match self {
Self::Atom { text, kind } if text.chars().count() == 1 => {
let letter = text.chars().next().expect("one character");
let text = match alphabet.glyph(letter).filter(|_| glyphs) {
Some(glyph) => glyph.to_string(),
None if alphabet.doubled_in_code => text.repeat(2),
None => text,
};
Self::Atom { text, kind }
}
other => other,
}
}
fn named_in_code(self) -> Self {
let kind = match &self {
Self::Atom { text, .. } if BOOLEAN_WORDS.contains(&text.as_str()) => HLGroup::Boolean,
Self::Atom { text, .. } if text == NULL_WORD => HLGroup::Builtin,
Self::Atom { text, .. } if text.starts_with(char::is_uppercase) => HLGroup::Type,
_ => HLGroup::Function,
};
self.retag(kind)
}
fn is_type_name(&self) -> bool {
match self {
Self::Atom { kind, .. } => *kind == HLGroup::Type,
Self::Row(pieces) => !pieces.is_empty() && pieces.iter().all(Self::is_type_name),
_ => false,
}
}
fn retag(self, kind: HLGroup) -> Self {
match self {
Self::Atom { text, .. } => Self::Atom { text, kind },
Self::Row(pieces) => {
Self::Row(pieces.into_iter().map(|piece| piece.retag(kind)).collect())
}
other => other,
}
}
pub fn layout(&self, fractions: FractionStyle) -> MathBlock {
match self {
Self::Atom { text, kind } => MathBlock::atom(text, *kind),
Self::Space => MathBlock::empty(),
Self::Row(pieces) => MathBlock::row(Self::chunks(pieces, fractions)),
Self::Fraction {
numerator,
denominator,
} => match fractions {
FractionStyle::Stacked => {
MathBlock::fraction(numerator.layout(fractions), denominator.layout(fractions))
}
FractionStyle::Underlined => MathBlock::underlined(
numerator.layout(fractions),
denominator.layout(fractions),
),
FractionStyle::Inline => MathBlock::row([
numerator.inline_operand(fractions),
MathBlock::atom("/", HLGroup::Operator),
denominator.inline_operand(fractions),
]),
},
Self::Delimited { left, right, body } => body
.layout(fractions)
.fenced(&left.to_string(), &right.to_string()),
Self::Power { base, exponent } => {
let base = base.layout(fractions);
match exponent.inline_script(Shift::Raised) {
Some(inline) => base.beside(inline),
None => base
.beside(MathBlock::atom("^", HLGroup::Operator))
.beside(exponent.inline_operand(FractionStyle::Inline)),
}
}
Self::Index { base, subscript } => {
let base = base.layout(fractions);
match subscript.inline_script(Shift::Lowered) {
Some(inline) => base.beside(inline),
None => {
let written = match subscript.as_ref() {
Self::Row(_) => {
subscript.layout(FractionStyle::Inline).fenced("{", "}")
}
_ => subscript.inline_operand(FractionStyle::Inline),
};
let separator = if subscript.is_type_name() {
HLGroup::Type
} else {
HLGroup::Operator
};
base.beside(MathBlock::atom("_", separator)).beside(written)
}
}
}
Self::Grid { rows, kind } => MathBlock::grid(rows, *kind, fractions),
}
}
fn inline_operand(&self, fractions: FractionStyle) -> MathBlock {
let laid = self.layout(fractions);
match self {
Self::Row(_) | Self::Fraction { .. } => laid.fenced("(", ")"),
_ => laid,
}
}
fn wrapped(&self, width: usize, fractions: FractionStyle) -> Vec<SpanLine> {
let lines = match self {
Self::Row(pieces) => MathBlock::pack(Self::chunks(pieces, fractions), width),
other => vec![other.layout(fractions)],
};
MathBlock::fitted(lines, width)
}
fn from_tex(source: &str, blackboard: bool) -> Result<Self, UnsupportedTeX> {
let maths = source
.strip_prefix('$')
.and_then(|tex| tex.strip_suffix('$'))
.ok_or_else(|| UnsupportedTeX("not maths: no $ delimiters".into()))?;
Self::parse_with(maths, blackboard)
}
fn chunks(pieces: &[Self], fractions: FractionStyle) -> Vec<MathBlock> {
let mut chunks = vec![Vec::new()];
for (index, piece) in pieces.iter().enumerate() {
let beside_binary = index
.checked_sub(1)
.and_then(|before| pieces.get(before))
.into_iter()
.chain(pieces.get(index + 1))
.any(Self::is_binary);
if *piece == Self::Space {
if !beside_binary {
let between_numerals =
[index.checked_sub(1), Some(index + 1)]
.into_iter()
.all(|neighbour| {
neighbour
.and_then(|neighbour| pieces.get(neighbour))
.is_some_and(|neighbour| {
matches!(
neighbour,
Self::Atom {
kind: HLGroup::Number,
..
}
)
})
});
if between_numerals {
chunks
.last_mut()
.expect("a chunk is always open")
.push(MathBlock::gap());
}
chunks.push(Vec::new());
}
continue;
}
let apart = index > 0 && piece.is_binary();
if apart {
chunks
.last_mut()
.expect("a chunk is always open")
.push(MathBlock::gap());
chunks.push(Vec::new());
}
let runs_into_factor = fractions == FractionStyle::Inline
&& matches!(piece, Self::Fraction { .. })
&& pieces.get(index + 1).is_some_and(|next| !next.is_binary());
let chunk = chunks.last_mut().expect("a chunk is always open");
chunk.push(piece.layout(fractions));
if apart || runs_into_factor {
chunk.push(MathBlock::gap());
}
}
chunks
.into_iter()
.filter(|chunk| !chunk.is_empty())
.map(MathBlock::row)
.collect()
}
fn is_binary(&self) -> bool {
matches!(
self,
Self::Atom { text, kind: HLGroup::Operator }
if matches!(
text.as_str(),
"+" | "-"
| "="
| "<"
| ">"
| "\\"
| "\u{2216}"
| "\u{27F5}"
| "\u{27F6}"
| "\u{27FC}"
)
)
}
fn inline_script(&self, shift: Shift) -> Option<MathBlock> {
let Self::Atom { text, kind } = self else {
return None;
};
shift.form(text).map(|text| MathBlock::atom(&text, *kind))
}
}
#[derive(Clone, Copy)]
pub enum Shift {
Raised,
Lowered,
}
impl Shift {
pub fn form(self, text: &str) -> Option<String> {
text.chars()
.map(|character| {
Some(match (self, character) {
(Self::Raised, '0'..='9') => "\u{2070}\u{00B9}\u{00B2}\u{00B3}\u{2074}\u{2075}\u{2076}\u{2077}\u{2078}\u{2079}"
.chars()
.nth(character as usize - '0' as usize)?,
(Self::Raised, '+') => '\u{207A}',
(Self::Raised, '-') => '\u{207B}',
(Self::Raised, 'n') => '\u{207F}',
(Self::Raised, 'i') => '\u{2071}',
(Self::Lowered, '0'..='9') => {
char::from_u32(0x2080 + (character as u32 - '0' as u32))?
}
(Self::Lowered, '+') => '\u{208A}',
(Self::Lowered, '-') => '\u{208B}',
_ => return None,
})
})
.collect()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MathBlock {
rows: Vec<SpanLine>,
baseline: usize,
}
impl MathBlock {
fn retag(mut self, kind: HLGroup) -> Self {
for span in self.rows.iter_mut().flatten() {
span.kind = kind;
}
self
}
fn atom(text: &str, kind: HLGroup) -> Self {
Self {
rows: vec![vec![TextSpan::new(text, kind)]],
baseline: 0,
}
}
fn empty() -> Self {
Self {
rows: vec![Vec::new()],
baseline: 0,
}
}
fn gap() -> Self {
Self::atom(" ", HLGroup::Canvas)
}
#[cfg(test)]
pub fn rows(&self) -> &[SpanLine] {
&self.rows
}
pub(super) fn into_rows(self) -> Vec<SpanLine> {
self.rows
}
pub const fn baseline(&self) -> usize {
self.baseline
}
pub fn width(&self) -> usize {
self.rows.iter().map(Self::row_width).max().unwrap_or(0)
}
pub fn height(&self) -> usize {
self.rows.len()
}
fn row_width(row: &SpanLine) -> usize {
row.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_str()))
.sum()
}
fn columns(row: &SpanLine, range: Range<usize>) -> SpanLine {
let mut column = 0;
let mut start = 0;
row.iter()
.filter_map(|span| {
let kept: String = span
.content
.chars()
.filter(|character| {
let width = UnicodeWidthChar::width(*character).unwrap_or(0);
if width > 0 {
start = column;
column += width;
}
range.contains(&start)
})
.collect();
(!kept.is_empty()).then(|| span.with_content(kept))
})
.collect()
}
fn reach(&self) -> (usize, usize) {
(self.baseline, self.rows.len() - self.baseline - 1)
}
fn justify(mut self, width: usize) -> Self {
for row in &mut self.rows {
let pad = width - Self::row_width(row);
if pad > 0 {
row.push(TextSpan::new(" ".repeat(pad), HLGroup::Canvas));
}
}
self
}
fn beside(self, other: Self) -> Self {
let (above, below) = self.reach();
let (other_above, other_below) = other.reach();
let baseline = above.max(other_above);
let height = baseline + below.max(other_below) + 1;
let (left, right) = (self.width(), other.width());
let mut rows = vec![Vec::new(); height];
for (block, width) in [(self, left), (other, right)] {
let offset = baseline - block.baseline;
let reached = block.rows.len();
for (index, mut row) in block.justify(width).rows.into_iter().enumerate() {
rows[offset + index].append(&mut row);
}
for (index, row) in rows.iter_mut().enumerate() {
if index < offset || index >= offset + reached {
row.push(TextSpan::new(" ".repeat(width), HLGroup::Canvas));
}
}
}
Self { rows, baseline }
}
fn row(blocks: impl IntoIterator<Item = Self>) -> Self {
blocks
.into_iter()
.reduce(Self::beside)
.unwrap_or_else(Self::empty)
}
fn value(value: &M2Value, style: MathStyle) -> Self {
match value {
M2Value::Type(value) => Self::value(value, style).retag(HLGroup::Type),
M2Value::Tex {
source, fallback, ..
} => Math::from_tex(source, style.blackboard).map_or_else(
|_| Self::text(fallback, HLGroup::Value),
|math| math.layout(style.fractions),
),
M2Value::Net(text) => Self::text(text, HLGroup::String),
M2Value::Markup { fallback, .. } => Self::text(fallback, HLGroup::Value),
M2Value::Token { text, role } => Self::text(text, role.group()),
M2Value::Collection {
kind: CollectionKind::VerticalList,
items,
} => Self::braced(items.iter().map(|item| Self::value(item, style)).collect()),
M2Value::Collection { kind, items } => Self::listed(*kind, items, usize::MAX, style)
.into_iter()
.next()
.expect("an unbounded width takes a collection on one line"),
M2Value::Option { key, value } => Self::row([
Self::value(key, style),
Self::atom(SPACED_OPTION_ARROW, HLGroup::Operator),
Self::value(value, style),
]),
M2Value::HashTable(entries) => Self::braced(
entries
.iter()
.map(|[key, value]| {
Self::row([
Self::value(key, style),
Self::atom(SPACED_OPTION_ARROW, HLGroup::Operator),
Self::value(value, style),
])
})
.collect(),
),
}
}
fn listed(
kind: CollectionKind,
items: &[M2Value],
width: usize,
style: MathStyle,
) -> Vec<Self> {
let (open, close) = kind.drawn_delimiters();
let last = items.len().saturating_sub(1);
let entries = items
.iter()
.enumerate()
.map(|(index, item)| {
let entry = Self::value(item, style);
match index == last {
true => entry,
false => Self::row([entry, Self::atom(",", HLGroup::Punctuation), Self::gap()]),
}
})
.collect();
let indent = UnicodeWidthStr::width(open);
let budget = width.saturating_sub(indent + UnicodeWidthStr::width(close));
let mut lines = Self::pack(entries, budget);
if lines.is_empty() {
lines.push(Self::empty());
}
let count = lines.len();
lines
.into_iter()
.enumerate()
.map(|(index, line)| {
let (height, baseline) = (line.height(), line.baseline);
let lead = match index {
0 => Self::fence(open, height, baseline),
_ => Self::atom(&" ".repeat(indent), HLGroup::Canvas),
};
let tail = (index + 1 == count).then(|| Self::fence(close, height, baseline));
Self::row([lead, line].into_iter().chain(tail))
})
.collect()
}
fn braced(rows: Vec<Self>) -> Self {
if rows.is_empty() {
return Self::atom("{}", HLGroup::Delimiter);
}
let height: usize = rows.iter().map(Self::height).sum();
let body = Self::stack(rows, height.saturating_sub(1) / 2, Align::Left);
Self::row([Self::gap(), body, Self::gap()]).fenced("{", "}")
}
fn text(text: &str, kind: HLGroup) -> Self {
let rows: Vec<SpanLine> = text
.lines()
.map(|line| vec![TextSpan::new(line, kind)])
.collect();
match rows.is_empty() {
true => Self::empty(),
false => Self {
baseline: (rows.len() - 1) / 2,
rows,
},
}
}
fn fitted(lines: Vec<Self>, width: usize) -> Vec<SpanLine> {
lines
.into_iter()
.flat_map(|line| line.slabs(width))
.flat_map(Self::into_rows)
.map(Self::trimmed)
.collect()
}
fn slabs(self, width: usize) -> Vec<Self> {
let total = self.visible_width();
if total <= width || width == 0 {
return vec![self];
}
(0..total)
.step_by(width)
.map(|from| Self {
rows: self
.rows
.iter()
.map(|row| Self::columns(row, from..from + width))
.collect(),
baseline: self.baseline,
})
.collect()
}
fn pad(mut self, width: usize, align: Align) -> Self {
for row in &mut self.rows {
let slack = width - Self::row_width(row);
let before = match align {
Align::Left => 0,
Align::Centre => slack / 2,
};
if before > 0 {
row.insert(0, TextSpan::new(" ".repeat(before), HLGroup::Canvas));
}
if slack > before {
row.push(TextSpan::new(" ".repeat(slack - before), HLGroup::Canvas));
}
}
self
}
fn stack(blocks: Vec<Self>, baseline: usize, align: Align) -> Self {
let width = blocks.iter().map(Self::width).max().unwrap_or(0);
Self {
rows: blocks
.into_iter()
.flat_map(|block| block.pad(width, align).rows)
.collect(),
baseline,
}
}
fn fraction(numerator: Self, denominator: Self) -> Self {
let width = numerator.width().max(denominator.width()).max(1);
let baseline = numerator.height();
let rule = Self {
rows: vec![vec![TextSpan::new(
"\u{2500}".repeat(width),
HLGroup::Operator,
)]],
baseline: 0,
};
Self::stack(vec![numerator, rule, denominator], baseline, Align::Centre)
}
fn underlined(numerator: Self, denominator: Self) -> Self {
let width = numerator.width().max(denominator.width()).max(1);
let baseline = numerator.height() - 1;
let mut numerator = numerator.pad(width, Align::Centre);
if let Some(bottom) = numerator.rows.last_mut() {
*bottom = bottom
.drain(..)
.map(|span| span.marked(HLGroup::FractionRule))
.collect();
}
Self::stack(vec![numerator, denominator], baseline, Align::Centre)
}
fn fenced(self, left: &str, right: &str) -> Self {
let (height, baseline) = (self.height(), self.baseline);
Self::fence(left, height, baseline)
.beside(self)
.beside(Self::fence(right, height, baseline))
}
pub(super) fn fence(fence: &str, height: usize, baseline: usize) -> Self {
if fence == "." {
return Self::empty();
}
let short = match height {
2 => &SHORT_BRACES[..],
_ => &[],
};
let Some(&(_, [top, extension, bottom, tip])) = short
.iter()
.chain(&TALL_FENCES)
.find(|(family, _)| *family == fence)
.filter(|_| height > 1)
else {
return Self::atom(fence, HLGroup::Delimiter);
};
let pointed = baseline.clamp(1, height.saturating_sub(2).max(1));
Self {
rows: (0..height)
.map(|row| {
let glyph = match row {
0 => top,
_ if row + 1 == height => bottom,
_ if row == pointed => tip,
_ => extension,
};
vec![TextSpan::new(glyph, HLGroup::Delimiter)]
})
.collect(),
baseline,
}
}
fn grid(rows: &[Vec<Math>], kind: GridKind, fractions: FractionStyle) -> Self {
let laid: Vec<Vec<Self>> = rows
.iter()
.map(|row| row.iter().map(|entry| entry.layout(fractions)).collect())
.collect();
let columns = laid.iter().map(Vec::len).max().unwrap_or(0);
let widths: Vec<usize> = (0..columns)
.map(|column| {
laid.iter()
.filter_map(|row| row.get(column))
.map(Self::width)
.max()
.unwrap_or(0)
})
.collect();
let column_gap = || Self::atom(" ", HLGroup::Canvas);
let stacked: Vec<Self> = laid
.into_iter()
.map(|row| {
row.into_iter()
.enumerate()
.map(|(column, entry)| entry.pad(widths[column], Align::Centre))
.reduce(|left, right| left.beside(column_gap()).beside(right))
.unwrap_or_else(Self::empty)
})
.collect();
let height: usize = stacked.iter().map(Self::height).sum();
let body = Self::stack(stacked, height.saturating_sub(1) / 2, Align::Left);
match kind {
GridKind::Bare => body,
GridKind::Matrix => body.fenced("(", ")"),
GridKind::Cases => Self::fence("{", body.height(), body.baseline).beside(body),
}
}
fn pack(chunks: Vec<Self>, width: usize) -> Vec<Self> {
let mut lines: Vec<Vec<Self>> = Vec::new();
let mut filled = 0;
for chunk in chunks {
match lines.last_mut() {
Some(line) if filled + chunk.visible_width() <= width => {
filled += chunk.width();
line.push(chunk);
}
_ => {
filled = chunk.width();
lines.push(vec![chunk]);
}
}
}
lines.into_iter().map(Self::row).collect()
}
fn visible_width(&self) -> usize {
self.rows
.iter()
.map(|row| Self::row_width(&Self::trimmed(row.clone())))
.max()
.unwrap_or(0)
}
fn trimmed(mut row: SpanLine) -> SpanLine {
while let Some(last) = row.last_mut() {
if last.mark.is_some() {
break;
}
let kept = last.content.trim_end().len();
if kept > 0 {
last.content.truncate(kept);
break;
}
row.pop();
}
row
}
}
#[derive(Clone, Copy)]
enum Align {
Left,
Centre,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::Role;
fn atom(text: &str, kind: HLGroup) -> Math {
Math::atom(text, kind)
}
#[test]
fn cycling_the_display_offers_pictures_the_terminal_cannot_show() {
assert!(!graphics::available());
let forward = MathDisplay::Ascii.cycle(1);
let backward = MathDisplay::Unicode.cycle(-1);
assert_eq!(forward, MathDisplay::Typeset);
assert_eq!(backward, MathDisplay::Typeset);
assert!(!MathDisplay::Typeset.available());
assert!(MathDisplay::Unicode.available());
}
#[test]
fn a_polynomial_separates_numerals_names_and_operators() {
let parsed = Math::parse("x^{2} + 3 y").expect("a polynomial parses");
assert_eq!(
parsed,
Math::Row(vec![
Math::Power {
base: Box::new(atom("x", HLGroup::Variable)),
exponent: Box::new(atom("2", HLGroup::Number)),
},
atom("+", HLGroup::Operator),
atom("3", HLGroup::Number),
atom("y", HLGroup::Variable),
])
);
}
#[test]
fn a_fraction_keeps_its_halves_apart() {
let parsed = Math::parse(r"\frac{1}{3}").expect("a fraction parses");
assert_eq!(
parsed,
Math::Fraction {
numerator: Box::new(atom("1", HLGroup::Number)),
denominator: Box::new(atom("3", HLGroup::Number)),
}
);
}
#[test]
fn a_matrix_keeps_its_rows_and_columns() {
let parsed = Math::parse(r"\begin{matrix}1&x\\y&2\end{matrix}").expect("a matrix parses");
let Math::Grid { rows, kind } = parsed else {
panic!("expected a grid");
};
assert_eq!(kind, GridKind::Bare);
assert_eq!(rows.len(), 2);
assert_eq!(
rows[0],
vec![atom("1", HLGroup::Number), atom("x", HLGroup::Variable)]
);
assert_eq!(
rows[1],
vec![atom("y", HLGroup::Variable), atom("2", HLGroup::Number)]
);
}
#[test]
fn cases_are_a_grid_that_knows_it_wants_one_brace() {
let parsed = Math::parse(r"\begin{cases}1\\2\end{cases}").expect("cases parse");
let Math::Grid { rows, kind } = parsed else {
panic!("expected a grid");
};
assert_eq!(kind, GridKind::Cases);
assert_eq!(rows.len(), 2);
}
#[test]
fn grown_fences_survive_around_their_body() {
let parsed = Math::parse(r"\left(\!\begin{matrix}1\end{matrix}\!\right)")
.expect("fenced matrix parses");
let Math::Delimited { left, right, body } = parsed else {
panic!("expected delimiters");
};
assert_eq!((left, right), ('(', ')'));
assert!(matches!(*body, Math::Grid { .. }));
}
#[test]
fn blackboard_bold_is_a_type_rather_than_a_variable() {
let parsed = Math::parse(r"\mathbb{Z}").expect("a ring parses");
assert_eq!(parsed, atom("ZZ", HLGroup::Type));
}
#[test]
fn a_rings_precision_subscript_shares_its_type_colour() {
let groups = Groups::default();
for precision in ["53", "p"] {
let source = format!(r"{{\mathbb R}}_{{{precision}}}");
let rows = Math::parse(&source)
.unwrap()
.wrapped(80, FractionStyle::Stacked);
assert!(rows.iter().flatten().all(|span| span.kind == HLGroup::Type));
let painted = colored_tex(&source, &groups);
let Color::Rgb(r, g, b) = groups.style(HLGroup::Type).fg.unwrap() else {
panic!("the theme has an RGB type colour");
};
assert!(
painted.contains(&format!(
r"_{{\color{{#{r:02x}{g:02x}{b:02x}}}{{{precision}}}}}"
)),
"{painted}"
);
}
let rows = Math::parse("x_{2}")
.unwrap()
.wrapped(80, FractionStyle::Stacked);
assert!(rows
.iter()
.flatten()
.any(|span| span.kind == HLGroup::Number));
}
#[test]
fn interval_fields_keep_their_type_highlights() {
let groups = crate::ui::controller::settings::theme::groups::scheme();
for (source, letter) in [(r"\square\mathbb R", "RR"), (r"\square\mathbb C", "CC")] {
assert_eq!(
Math::parse(source).expect("an interval field parses"),
Math::Row(vec![atom("□", HLGroup::Type), atom(letter, HLGroup::Type)])
);
let mut input = Input::new(source, true);
input.expression(&[]).expect("an interval field parses");
assert!(input.rest().is_empty());
assert_eq!(
input.highlights,
[(0..7, HLGroup::Type), (7..source.len(), HLGroup::Type)]
);
assert_ne!(colored_tex(source, groups), source);
}
}
#[test]
fn the_forms_macaulay2_emits_need_no_rewriting_first() {
assert_eq!(
Math::parse(r"\mathit{x2}").expect("mathit parses"),
atom("x2", HLGroup::Variable)
);
assert_eq!(
Math::parse(r"{\mathbb ZZ}").expect("a braced font parses"),
atom("ZZ", HLGroup::Type)
);
let parsed = Math::parse(r"\begin{array}{cc}1&2\\3&4\end{array}").expect("an array parses");
assert!(matches!(
parsed,
Math::Grid {
kind: GridKind::Bare,
..
}
));
}
#[test]
fn constants_and_code_words_are_painted_as_code_paints_them() {
for (source, group) in [
(r"\pi", HLGroup::Builtin),
(r"\gamma", HLGroup::Builtin),
(r"\infty", HLGroup::Builtin),
(r"\mathbf{i}", HLGroup::Builtin),
(r"\mathrm{i}", HLGroup::Builtin),
(r"\mathrm{G}", HLGroup::Builtin),
(r"{2}\,\mathbf{i}", HLGroup::Builtin),
(r"x_{\texttt{true}}", HLGroup::Boolean),
(r"x_{\texttt{false}}", HLGroup::Boolean),
(r"x_{\texttt{null}}", HLGroup::Builtin),
(r"x_{\texttt{print}}", HLGroup::Function),
(r"x_{\pi}", HLGroup::Builtin),
] {
let mut input = Input::new(source, true);
input.expression(&[]).expect("parses");
assert!(
input.highlights.iter().any(|(_, kind)| *kind == group),
"{source} should hold {group:?}: {:?}",
input.highlights
);
}
}
#[test]
fn an_unknown_command_is_refused_by_name() {
assert_eq!(
Math::parse(r"\sqrt{2}").err(),
Some(UnsupportedTeX(r"\sqrt".into()))
);
}
#[test]
fn a_real_matrix_from_macaulay2_parses_whole() {
let source = "\\left(\\!\\begin{matrix}{cc}\n1&x\\\\\nx^{2}+1&\\frac{1}{3}x2\n\\end{matrix}\\!\\right)";
let Math::Delimited { left, right, body } =
Math::parse(source).expect("M2's own matrix parses")
else {
panic!("expected fences");
};
assert_eq!((left, right), ('(', ')'));
let Math::Grid { rows, kind } = *body else {
panic!("expected a grid");
};
assert_eq!(kind, GridKind::Bare);
assert_eq!(rows.len(), 2);
assert_eq!(
rows[1][0],
Math::Row(vec![
Math::Power {
base: Box::new(atom("x", HLGroup::Variable)),
exponent: Box::new(atom("2", HLGroup::Number)),
},
atom("+", HLGroup::Operator),
atom("1", HLGroup::Number),
])
);
assert!(matches!(rows[1][1], Math::Row(ref pieces)
if matches!(pieces[0], Math::Fraction { .. })));
}
#[test]
fn a_thin_space_product_juxtaposes_its_factors() {
assert_eq!(
Math::parse(r"xx\,yy").expect("a thin-space product parses"),
Math::Row(vec![
atom("xx", HLGroup::Variable),
Math::Space,
atom("yy", HLGroup::Variable),
])
);
assert_eq!(picture(r"3\,x\,y^{2}"), ["3xy\u{00B2}"]);
assert_eq!(picture(r"xx \cdot yy"), ["xx\u{22C5}yy"]);
assert_eq!(
Math::parse("xxyy").unwrap(),
atom("xxyy", HLGroup::Variable)
);
}
#[test]
fn a_digit_grouped_numeral_is_one_number() {
assert_eq!(
Math::parse(r"1\,267\,650\,600").expect("a grouped numeral parses"),
atom("1267650600", HLGroup::Number)
);
assert_eq!(picture(r"12\,345\,678\,x^{2}y"), ["12345678x\u{00B2}y"]);
assert_eq!(picture(r"12\,5"), ["12 5"]);
}
fn text(rows: &[SpanLine]) -> Vec<String> {
rows.iter()
.map(|row| row.iter().map(|span| span.content.as_str()).collect())
.collect()
}
fn wrapped_picture(math: &str, width: usize) -> Vec<String> {
text(
&Math::parse(math)
.expect("parses")
.wrapped(width, FractionStyle::Stacked),
)
}
#[test]
fn a_long_polynomial_wraps_within_the_width() {
let source = r"-7\,x\,y+12\,x^{3}y-5\,y^{4}+8\,x\,y\,z-3\,z^{2}+1";
let whole = picture(source).concat();
for width in [6, 10, 16, 25] {
let rows = wrapped_picture(source, width);
assert!(
rows.iter()
.all(|row| row.trim_end().chars().count() <= width),
"{rows:?} overflows {width}"
);
assert_eq!(
rows.concat().replace(' ', ""),
whole.replace(' ', ""),
"wrapping to {width} must keep every glyph, in order"
);
}
assert_eq!(wrapped_picture(source, 80), [whole]);
}
#[test]
fn a_wrapped_sum_breaks_between_terms() {
let rows = wrapped_picture("a+b+c+d+e+f+g+h", 7);
assert!(rows.len() > 1, "{rows:?} did not wrap");
assert!(rows[1..].iter().all(|row| row.starts_with('+')), "{rows:?}");
assert!(
rows.iter().all(|row| !row.trim_end().ends_with('+')),
"{rows:?}"
);
}
fn tex(maths: &str) -> M2Value {
M2Value::Tex {
source: format!("${maths}$"),
fallback: maths.into(),
code: String::new(),
}
}
fn collection(kind: CollectionKind, items: Vec<M2Value>) -> M2Value {
M2Value::Collection { kind, items }
}
#[test]
fn a_collection_colours_each_entry_by_its_own_role() {
let list = collection(CollectionKind::List, vec![tex("1"), tex("x^{2}")]);
let rows = spans(&list, 80, FractionStyle::Stacked).expect("a list renders");
assert_eq!(text(&rows), ["{1, x\u{00B2}}"]);
let roles: Vec<_> = rows[0]
.iter()
.map(|span| (span.content.as_str(), span.kind))
.collect();
for role in [
("{", HLGroup::Delimiter),
("1", HLGroup::Number),
("x", HLGroup::Variable),
] {
assert!(roles.contains(&role), "{role:?} missing from {roles:?}");
}
}
#[test]
fn each_kind_keeps_its_delimiters_and_nests() {
let sequence = collection(
CollectionKind::Sequence,
vec![
tex("1"),
collection(CollectionKind::Array, vec![tex("2"), tex("3")]),
collection(CollectionKind::AngleBarList, Vec::new()),
],
);
assert_eq!(
text(&spans(&sequence, 80, FractionStyle::Stacked).expect("renders")),
["(1, [2, 3], \u{27E8}||\u{27E9})"]
);
}
#[test]
fn a_string_is_never_read_as_maths() {
let string = M2Value::Tex {
source: "hello world".into(),
fallback: "hello world".into(),
code: String::new(),
};
assert!(
spans(&string, 80, FractionStyle::Stacked).is_err(),
"left to the plain text path"
);
let rows = spans(
&collection(CollectionKind::List, vec![string]),
80,
FractionStyle::Stacked,
)
.expect("renders");
assert_eq!(text(&rows), ["{hello world}"]);
assert!(rows[0]
.iter()
.any(|span| span.content == "hello world" && span.kind == HLGroup::Value));
}
#[test]
fn a_long_list_wraps_between_entries() {
let items = (1..=12).map(|item| tex(&item.to_string())).collect();
let rows = text(
&spans(
&collection(CollectionKind::List, items),
10,
FractionStyle::Stacked,
)
.expect("renders"),
);
assert!(rows.len() > 1, "{rows:?} did not wrap");
assert!(
rows.iter()
.all(|row| matches!(row.trim_end().chars().last(), Some(',' | '}'))),
"{rows:?}"
);
}
#[test]
fn packing_fills_each_line_before_breaking() {
assert_eq!(
wrapped_picture("a+b+c+d+e+f+g+h", 7),
["a + b", "+ c + d", "+ e + f", "+ g + h"]
);
let items = (1..=12).map(|item| tex(&item.to_string())).collect();
assert_eq!(
text(
&spans(
&collection(CollectionKind::List, items),
10,
FractionStyle::Stacked
)
.expect("renders")
),
["{1, 2, 3,", " 4, 5, 6,", " 7, 8, 9,", " 10, 11,", " 12}"]
);
}
fn token(text: &str, role: Role) -> M2Value {
M2Value::Token {
text: text.into(),
role,
}
}
fn roles(rows: &[SpanLine]) -> Vec<(String, HLGroup)> {
rows.iter()
.flatten()
.map(|span| (span.content.clone(), span.kind))
.collect()
}
#[test]
fn a_token_is_coloured_as_code_colours_its_role() {
let list = collection(
CollectionKind::List,
vec![
token("null", Role::Null),
token("true", Role::Boolean),
token("hi", Role::String),
token("print", Role::Function),
token("Type", Role::Type),
],
);
let roles = roles(&spans(&list, 80, FractionStyle::Stacked).expect("renders"));
for (text, group) in [
("null", HLGroup::Builtin),
("true", HLGroup::Boolean),
("hi", HLGroup::String),
("print", HLGroup::Function),
("Type", HLGroup::Type),
] {
assert!(
roles.contains(&(text.to_string(), group)),
"{text} should be {group:?} in {roles:?}"
);
}
}
#[test]
fn a_name_is_coloured_as_a_symbol_in_code() {
let rows = spans(&tex("x^{2}+q"), 80, FractionStyle::Stacked).expect("renders");
assert!(roles(&rows).contains(&("q".to_string(), HLGroup::Variable)));
}
fn fraction_picture(math: &str, fractions: FractionStyle) -> Vec<String> {
text(&spans(&tex(math), 80, fractions).expect("renders"))
}
#[test]
fn a_fraction_sets_in_the_chosen_style() {
let fraction = r"x+\frac{1}{3}";
assert_eq!(
fraction_picture(fraction, FractionStyle::Stacked),
[" 1", "x + \u{2500}", " 3"]
);
assert_eq!(
fraction_picture(fraction, FractionStyle::Underlined),
["x + 1", " 3"]
);
assert_eq!(
fraction_picture(fraction, FractionStyle::Inline),
["x + 1/3"]
);
}
#[test]
fn an_inline_fraction_cannot_be_misread() {
assert_eq!(
fraction_picture(r"\frac{x+1}{2}", FractionStyle::Inline),
["(x + 1)/2"]
);
assert_eq!(
fraction_picture(r"\frac{1}{3}x", FractionStyle::Inline),
["1/3 x"]
);
}
#[test]
fn an_underlined_fraction_rules_its_full_width() {
let rows = spans(&tex(r"\frac{3}{100}"), 80, FractionStyle::Underlined).expect("renders");
assert_eq!(text(&rows), [" 3 ", "100"]);
assert!(rows[0]
.iter()
.all(|span| span.mark == Some(HLGroup::FractionRule)));
assert!(rows[1].iter().all(|span| span.mark.is_none()));
}
#[test]
fn a_container_grows_its_brackets_around_a_tall_entry() {
let sequence = collection(
CollectionKind::Sequence,
vec![tex(r"\frac{1}{2}"), tex("3")],
);
assert_eq!(
text(&spans(&sequence, 80, FractionStyle::Stacked).expect("renders")),
[
"\u{239B}1 \u{239E}",
"\u{239C}\u{2500}, 3\u{239F}",
"\u{239D}2 \u{23A0}"
]
);
}
#[test]
fn a_numeral_wider_than_the_screen_is_cut_into_slabs() {
assert_eq!(
wrapped_picture(r"1\,267\,650\,600\,228", 5),
["12676", "50600", "228"]
);
}
fn picture(math: &str) -> Vec<String> {
Math::parse(math)
.expect("parses")
.layout(FractionStyle::Stacked)
.rows()
.iter()
.map(|row| row.iter().map(|span| span.content.as_str()).collect())
.collect()
}
#[test]
fn a_simple_power_stays_on_one_row() {
assert_eq!(picture("x^{2} + 1"), ["x\u{00B2} + 1"]);
assert_eq!(picture("x^{10}"), ["x\u{00B9}\u{2070}"]);
}
#[test]
fn a_power_without_superscripts_is_written_with_a_caret() {
assert_eq!(picture("x^{x}"), ["x^x"]);
assert_eq!(picture("x^{y+1} + 1"), ["x^(y + 1) + 1"]);
assert_eq!(picture(r"x^{\frac{1}{2}}"), ["x^(1/2)"]);
}
#[test]
fn a_fraction_puts_its_rule_on_the_baseline() {
let block = Math::parse(r"\frac{1}{3}")
.expect("parses")
.layout(FractionStyle::Stacked);
assert_eq!(picture(r"\frac{1}{3}"), ["1", "\u{2500}", "3"]);
assert_eq!(block.baseline(), 1, "the rule is what neighbours align to");
}
#[test]
fn a_term_beside_a_fraction_sits_level_with_the_rule() {
assert_eq!(
picture(r"x + \frac{1}{3}"),
[" 1", "x + \u{2500}", " 3"]
);
}
#[test]
fn a_fraction_centres_the_shorter_half() {
assert_eq!(
picture(r"\frac{1}{100}"),
[" 1 ", "\u{2500}\u{2500}\u{2500}", "100"]
);
}
#[test]
fn an_environment_that_fences_itself_grows_those_fences() {
assert_eq!(
picture(r"\begin{pmatrix}1&x\\y&2\end{pmatrix}"),
["\u{239B}1 x\u{239E}", "\u{239D}y 2\u{23A0}"]
);
}
#[test]
fn a_fenced_bare_grid_gets_exactly_one_set_of_brackets() {
assert_eq!(
picture(r"\left(\!\begin{matrix}1&x\\y&2\end{matrix}\!\right)"),
["\u{239B}1 x\u{239E}", "\u{239D}y 2\u{23A0}"]
);
assert_eq!(
picture(r"\begin{matrix}1&x\\y&2\end{matrix}"),
["1 x", "y 2"]
);
}
#[test]
fn cases_take_one_brace_on_the_left() {
let rows = picture(r"\begin{cases}1\\2\end{cases}");
assert_eq!(rows, ["\u{23B0}1", "\u{23B1}2"]);
}
#[test]
fn a_brace_never_draws_as_a_parenthesis() {
assert_eq!(
picture(r"\begin{cases}1\\2\\3\end{cases}"),
["\u{23A7}1", "\u{23A8}2", "\u{23A9}3"]
);
assert_ne!(
picture(r"\left\{\begin{matrix}1\\2\end{matrix}\right\}"),
picture(r"\left(\begin{matrix}1\\2\end{matrix}\right)")
);
}
#[test]
fn columns_are_padded_to_their_widest_entry() {
assert_eq!(
picture(r"\begin{matrix}1&x\\x^{2}+1&y\end{matrix}"),
[" 1 x", "x\u{00B2} + 1 y"]
);
}
#[test]
fn arrows_render_long_and_stand_apart_from_their_operands() {
assert_eq!(
picture(r"R^{2} \longleftarrow R^{2}"),
["R\u{00B2} \u{27F5} R\u{00B2}"]
);
assert_eq!(picture(r"R \leftarrow R"), picture(r"R \longleftarrow R"));
}
#[test]
fn a_backslash_operator_renders_rather_than_signalling_failure() {
assert_eq!(picture(r"f \backslash L"), [r"f \ L"]);
assert_eq!(picture(r"A \setminus B"), ["A \u{2216} B"]);
}
#[test]
fn what_is_not_handled_names_itself() {
for renders in [r"x^{2}", r"x^{a}", r"x^{n+1}", r"x_{1}", r"\frac{a}{b+c}"] {
assert!(Math::parse(renders).is_ok(), "{renders} should render");
}
for (source, blamed) in [
(r"x^{\alpha}", r"\alpha"),
(r"a \nabla b", r"\nabla"),
(r"\sqrt{2}", r"\sqrt"),
] {
assert_eq!(
Math::parse(source).err(),
Some(UnsupportedTeX(blamed.into())),
"{source} should blame {blamed} rather than failing anonymously"
);
}
}
const POLYNOMIAL_RING: &str =
r"${\mathbb C}_{53}\mathopen{}\left[x_{\left\{1,\:1\right\}}\right]$";
fn rendered(source: &str, blackboard: bool) -> String {
Math::from_tex(source, blackboard)
.expect("parses")
.wrapped(80, FractionStyle::Stacked)
.iter()
.map(|row| {
row.iter()
.map(|span| span.content.as_str())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn a_subscript_without_a_lowered_form_stays_on_its_bases_row() {
assert_eq!(rendered(POLYNOMIAL_RING, false), "CC₅₃[x_{1, 1}]");
assert_eq!(rendered("$x_{a}$", false), "x_a");
assert_eq!(rendered("$x_{12}$", false), "x₁₂");
}
#[test]
fn blackboard_glyphs_are_drawn_only_when_asked_for() {
assert_eq!(rendered("${\\mathbb Z}$", false), "ZZ");
assert_eq!(rendered("${\\mathbb Z}$", true), "ℤ");
assert_eq!(rendered("${\\mathbb P}$", true), "ℙ");
assert_eq!(rendered("${\\mathbb A}$", true), "𝔸");
}
#[test]
fn the_operators_macaulay2_spells_are_drawn_and_coloured_as_operators() {
for (source, drawn) in [
(r"$x\wedge\wedge x$", "x∧∧x"),
(r"${x}\otimes {x}$", "x⊗x"),
(r"$x@@x$", "x@@x"),
(r"$x\midx$", "x|x"),
(r"$x\mid\midx$", "x||x"),
(r"$x\Longrightarrow x$", "x⟹x"),
] {
assert_eq!(rendered(source, false), drawn);
let kinds = Math::from_tex(source, false)
.expect("parses")
.wrapped(80, FractionStyle::Stacked)
.into_iter()
.flatten()
.filter(|span| span.content != "x")
.map(|span| span.kind)
.collect::<Vec<_>>();
assert!(
kinds.iter().all(|kind| *kind == HLGroup::Operator),
"{source} should colour its operators alone, got {kinds:?}"
);
}
}
#[test]
fn ellipses_are_drawn_on_the_row_of_their_neighbours() {
assert_eq!(rendered(r"$x,\cdots$", false), "x, ⋯");
assert_eq!(rendered(r"$x,\ldots$", false), "x, ...");
}
#[test]
fn script_capitals_are_drawn_only_when_asked_for() {
assert_eq!(rendered("${\\mathcal O}_{X}$", false), "O_X");
assert_eq!(rendered("${\\mathcal O}_{X}$", true), "𝒪_X");
assert_eq!(rendered("${\\mathcal L}$", true), "ℒ");
assert_eq!(rendered("${\\mathfrak g}$", true), "g");
assert_eq!(rendered("${\\mathfrak P}$", true), "𝔓");
assert_eq!(rendered("${\\mathfrak R}$", true), "ℜ");
}
#[test]
fn text_keeps_its_spaces_and_spells_out_its_escapes() {
assert_eq!(rendered(r"$\texttt{ring R}$", false), "ring R");
assert_eq!(
rendered(r"$\texttt{ -- get the ring}$", false),
" -- get the ring"
);
assert_eq!(rendered(r"$\textrm{a\_b\{c\}}$", false), "a_b{c}");
assert_eq!(rendered(r"$\text{x \textbackslash{} y}$", false), "x \\ y");
assert_eq!(rendered(r"$\mathtt{ring R}$", false), "ringR");
}
#[test]
fn typewriter_text_reads_back_as_written() {
let text = r" a\b{c}_d^e~f#g$h%i&j k";
assert_eq!(rendered(&format!("${}$", typewriter(text)), false), text);
}
#[test]
fn a_name_in_typewriter_face_is_coloured_as_code_names_it() {
let kinds = |source| {
let rows = Math::from_tex(source, false)
.expect("parses")
.wrapped(80, FractionStyle::Stacked);
rows.iter()
.flatten()
.map(|span| span.kind)
.collect::<Vec<_>>()
};
assert_eq!(kinds("$\\texttt{image}$"), [HLGroup::Function]);
assert_eq!(kinds("$\\texttt{CoherentSheaf}$"), [HLGroup::Type]);
}
#[test]
fn a_phantom_reserves_nothing_in_a_terminal() {
let sheaf = "$\\begin{array}{l}\\left\\{1\\right\\}\\vphantom{-y0}\\end{array}\\left(\\!\\begin{array}{c}\n\\vphantom{\\left\\{1\\right\\}}-y\n\\end{array}\\!\\right)$";
assert_eq!(
rendered(sheaf, false),
rendered(r"$\left\{1\right\}\left(-y\right)$", false)
);
}
#[test]
fn a_quotient_ring_is_parsed_and_so_coloured_by_structure() {
let ring = r"$\frac{{\mathbb Q}\mathopen{}\left[x\right]}{x^{3}-1}$";
let rows = Math::from_tex(ring, false)
.expect("a quotient ring parses")
.wrapped(80, FractionStyle::Stacked);
assert_eq!(rows.len(), 3);
let kinds: Vec<_> = rows.iter().flatten().map(|span| span.kind).collect();
assert!(kinds.contains(&HLGroup::Type) && kinds.contains(&HLGroup::Number));
assert!(Math::from_tex(
r"$\frac{{\mathbb Z}\mathopen{}\left[x\,{.}{.}\,y\right]}{x^{2}}$",
false
)
.is_ok());
}
}