Files
gridcraft/crates/numfmt/src/parse.rs
T
c35234e5f5 Number formats: honour the B1/B2 calendar prefixes (Hijri dates for B2) (#1918)
* M12.AR6: honour the B1/B2 calendar prefixes in number formats (Hijri for B2)

* M12.AR6: match Excel's B1/B2 behaviour measured in review

Changes from a reviewer's Excel run (TEXT and Range.NumberFormat):
- A [$-...] locale tag before B2 keeps the date Gregorian (prefix still
  hidden); B2[$-401] stays Hijri.
- Any other unquoted B and digit (B3dd, dd/mm/yyyyB2, dd B2 mm), and B2
  before a number format (B20.00), make the code invalid: TEXT gives
  #VALUE!, a cell shows General, as for a code over 255 characters.
- Under B2, ddd and dddd show the Arabic weekday name (full name for both).
- Serial 60 has no Hijri date: TEXT gives #VALUE!, a cell shows ####.
- Serial 0 shows as serial 1 (29/08/1317).

Also fixes a break from merging main: days_from_civil now returns 0 for
day 0 and 29 February 1900, which sent serials 0 and 60 to 1970
(23/10/1389) under B2. The Hijri day count no longer passes those dates.

Docs: localization-parity states the behaviour, including the one
measured case not matched (mmmm next to dddd prints the weekday in Excel).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M12.AR6: Hijri leap year in cycle year 15, not 16, as Excel shows

A reviewer compared Excel's B2yyyy-mm-dd on 43,512 serials with this
formula: it disagreed by one day on 1,372, all in cycle years 15 and 16,
and moving the leap year from 16 to 15 left no disagreement. The leap
years are now 2, 5, 7, 10, 13, 15, 18, 21, 24, 26 and 29 (still 10,631
days a cycle).

The count of leap years before a year becomes (4 + 11y) div 30; it
differs from the old (3 + 11y) div 30 only when y = 16 (mod 30), which is
exactly the move from 16 to 15. The inverse's year estimate can now be one
year off at a boundary and is corrected by one step.

Tests: Excel values for 2 March 2005 (1426-01-21) and 29 January 2006
(1426-12-29) plus the 1425/1426 year boundaries they imply, the reviewer's
other anchors, and exhaustive checks: year, month and 30-year cycle
lengths for Hijri years 1 to 10,000; day -> Hijri -> day round trip and
day-by-day succession for every day from 1 Muharram 1 AH to one cycle past
31 December 9999; every 1900-system serial 1 to 2,958,465 (except 60).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M12.AR6: match B1/B2 details checked in Excel 16.0 build 20527

Compared GridCraft with Excel 16.0 (build 20527, en-US) through COM: TEXT
with B2dd/mm/yyyy on every serial from 1 to 2,958,465 (no difference), and
about 1,750 format/value pairs through both a cell's NumberFormat and TEXT.
Fixed what differed and is in scope:

- Two-word Hijri month names use a no-break space (U+00A0), as Excel does.
- B1 before a number format is rejected like B2 (B10.00, B1#,##0).
- Prefix runs: a repeated prefix is accepted (B2B2, B2b2, [Red]B2B2, B1B1);
  a mixed one (B1B2, B2B1), more digits (B12, B21, B02), a prefix after a
  tag or space (B2[Red]B2, B2 B2) and an escaped \B2 are rejected.
- Serial 60 under B2 is #VALUE! for any date or clock part (hh:mm, ss,
  AM/PM), not only date parts; [h] and literal-only sections still show.

Regression tests use the Excel values (all 12 month and 7 weekday names,
the accepted and rejected codes, serial 60). Not changed, listed in the
docs: Excel's weekday/month-name mix-ups when ddd(d) and mmm(m) share a B2
section, weekday-only B2 sections (real weekday for serials 0-59, #VALUE!
for the last days of 9999), and Buddhist-year b/bb/bbbb tokens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-09 15:54:05 +01:00

754 lines
25 KiB
Rust

//! Format code parsing: sections, bracket tags and tokens.
use crate::FormatColor;
use crate::calendar::Calendar;
/// At most this many sections are honoured (`pos;neg;zero;text`); extra ones are ignored.
const MAX_SECTIONS: usize = 4;
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum DatePart {
Year(u8),
Month(u8),
Minute(u8),
Day(u8),
Hour(u8),
Second(u8),
/// `.0`, `.00`, `.000` after seconds.
SubSec(u8),
ElapsedH(u8),
ElapsedM(u8),
ElapsedS(u8),
AmPm,
/// `A/P` with the letters as typed.
AP(char, char),
}
impl DatePart {
/// `[h]`, `[m]` or `[s]`.
pub(crate) fn is_elapsed(self) -> bool {
matches!(self, DatePart::ElapsedH(_) | DatePart::ElapsedM(_) | DatePart::ElapsedS(_))
}
pub(crate) fn is_calendar(self) -> bool {
matches!(self, DatePart::Year(_) | DatePart::Month(_) | DatePart::Day(_))
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Tok {
Lit(String),
/// `0`, `#` or `?`.
Digit(char),
/// The decimal point (only the first one in a number section).
Point,
/// Unresolved comma (resolved during analysis into thousands/scale markers or a literal).
Comma,
Percent,
Exp {
upper: bool,
plus: bool,
},
/// The fraction bar (only after analysis decides the section is a fraction).
Slash,
/// A fixed fraction denominator such as the `8` in `?/8`.
FixedDenom(String),
Fill(char),
At,
General,
Date(DatePart),
}
impl Tok {
/// The text this token shows when it has no special meaning in its section.
pub(crate) fn literal_text(&self) -> String {
match self {
Tok::Lit(s) | Tok::FixedDenom(s) => s.clone(),
Tok::Digit(c) => c.to_string(),
Tok::Point => ".".into(),
Tok::Comma => ",".into(),
Tok::Percent => "%".into(),
Tok::Exp { upper, plus } => format!("{}{}", if *upper { 'E' } else { 'e' }, if *plus { '+' } else { '-' }),
Tok::Slash => "/".into(),
Tok::Fill(_) | Tok::At | Tok::General | Tok::Date(_) => String::new(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum CondOp {
Eq,
Ne,
Lt,
Le,
Gt,
Ge,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Cond {
pub op: CondOp,
pub value: f64,
}
impl Cond {
pub(crate) fn test(&self, v: f64) -> bool {
match self.op {
CondOp::Eq => v == self.value,
CondOp::Ne => v != self.value,
CondOp::Lt => v < self.value,
CondOp::Le => v <= self.value,
CondOp::Gt => v > self.value,
CondOp::Ge => v >= self.value,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum SecKind {
Number,
Date,
Text,
General,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Section {
pub toks: Vec<Tok>,
pub color: Option<FormatColor>,
pub cond: Option<Cond>,
pub kind: SecKind,
pub thousands: bool,
/// Number of scaling commas (each divides by 1000).
pub scale: i32,
pub percent: i32,
pub has_at: bool,
pub has_digits: bool,
pub exp: bool,
pub fraction: bool,
pub currency: bool,
pub fill: bool,
pub calendar: Calendar,
/// Excel rejects the whole code because of this section: a `B` and digit that is not a
/// calendar prefix, or a prefix before a number format (`B20.00`).
pub invalid: bool,
}
/// Splits a code into sections at unquoted, unescaped, unbracketed semicolons.
///
/// A `[` opens a bracket only when a `]` follows it; the bracket then runs to the next `]` (quotes
/// inside it are not special), exactly as `tokenize` reads it. An unclosed `[` is a literal in both
/// passes, so a later `;` still separates sections. Linear: each `[` either jumps over its bracket
/// or, with no `]` left in the code, is a literal at once.
fn split_sections(code: &str) -> Vec<String> {
let cs: Vec<char> = code.chars().collect();
let last_close = cs.iter().rposition(|&c| c == ']');
let mut out = vec![String::new()];
let mut quoted = false;
let mut escape = false;
let mut i = 0usize;
while let Some(&c) = cs.get(i) {
i += 1;
let cur = match out.last_mut() {
Some(c) => c,
None => break,
};
if escape {
cur.push(c);
escape = false;
continue;
}
match c {
'"' => {
quoted = !quoted;
cur.push(c);
}
'\\' | '_' | '*' if !quoted => {
escape = true;
cur.push(c);
}
'[' if !quoted && last_close.is_some_and(|l| l >= i) => {
// `i` is already past the `[`; the closing bracket is at or after it.
cur.push(c);
while let Some(&d) = cs.get(i) {
i += 1;
cur.push(d);
if d == ']' {
break;
}
}
}
';' if !quoted => {
if out.len() >= MAX_SECTIONS {
break;
}
out.push(String::new());
}
_ => cur.push(c),
}
}
out
}
fn starts_with_ci(cs: &[char], i: usize, pat: &str) -> bool {
let mut k = i;
for p in pat.chars() {
match cs.get(k) {
Some(c) if c.eq_ignore_ascii_case(&p) => k += 1,
_ => return false,
}
}
true
}
fn push_lit(toks: &mut Vec<Tok>, s: &str) {
if s.is_empty() {
return;
}
if let Some(Tok::Lit(last)) = toks.last_mut() {
last.push_str(s);
} else {
toks.push(Tok::Lit(s.to_string()));
}
}
fn is_currency_char(c: char) -> bool {
matches!(c, '$' | '€' | '£' | '¥' | '₹' | '₩' | '₽' | '¢' | '₪' | '₫' | '฿')
}
fn parse_color(lc: &str) -> Option<FormatColor> {
Some(match lc {
"black" => FormatColor::Black,
"blue" => FormatColor::Blue,
"cyan" => FormatColor::Cyan,
"green" => FormatColor::Green,
"magenta" => FormatColor::Magenta,
"red" => FormatColor::Red,
"white" => FormatColor::White,
"yellow" => FormatColor::Yellow,
_ => {
let n: u8 = lc.strip_prefix("color")?.trim().parse().ok()?;
if (1..=56).contains(&n) {
FormatColor::Indexed(n)
} else {
return None;
}
}
})
}
fn parse_cond(s: &str) -> Option<Cond> {
let s = s.trim();
let (op, rest) = if let Some(r) = s.strip_prefix("<=") {
(CondOp::Le, r)
} else if let Some(r) = s.strip_prefix(">=") {
(CondOp::Ge, r)
} else if let Some(r) = s.strip_prefix("<>") {
(CondOp::Ne, r)
} else if let Some(r) = s.strip_prefix('<') {
(CondOp::Lt, r)
} else if let Some(r) = s.strip_prefix('>') {
(CondOp::Gt, r)
} else {
(CondOp::Eq, s.strip_prefix('=')?)
};
let value: f64 = rest.trim().parse().ok()?;
value.is_finite().then_some(Cond { op, value })
}
struct Raw {
toks: Vec<Tok>,
color: Option<FormatColor>,
cond: Option<Cond>,
currency: bool,
calendar: Calendar,
/// The section's calendar prefix as written, `'1'` or `'2'` (whether or not a locale tag
/// cancels it).
prefix: Option<char>,
/// An unquoted `B` and digit that is not a calendar prefix: Excel rejects the code.
invalid: bool,
}
/// Where a section's body starts: after its leading `[...]` tags, read as `tokenize` reads them.
/// Also whether one of those tags is a `[$...]` locale tag.
fn after_leading_brackets(cs: &[char]) -> (usize, bool) {
let mut i = 0usize;
let mut locale = false;
while cs.get(i) == Some(&'[') {
match cs.get(i + 1..).and_then(|r| r.iter().position(|&d| d == ']')) {
Some(p) => {
locale |= cs.get(i + 1) == Some(&'$');
i += p + 2;
}
None => break,
}
}
(i, locale)
}
fn tokenize(src: &str) -> Raw {
let cs: Vec<char> = src.chars().collect();
let mut raw = Raw { toks: Vec::new(), color: None, cond: None, currency: false, calendar: Calendar::Gregorian, prefix: None, invalid: false };
let toks = &mut raw.toks;
// With no `]` at or after a `[`, that `[` is a literal; checking this once keeps the scan linear.
let last_close = cs.iter().rposition(|&c| c == ']');
let (body_start, locale_first) = after_leading_brackets(&cs);
// Where the next calendar prefix may stand: the body's start, or right after another prefix.
let mut prefix_at = body_start;
let mut i = 0usize;
while let Some(&c) = cs.get(i) {
match c {
// A calendar prefix, never shown: `B1` or `B2`, any case, which Excel accepts repeated
// (`B2B2`) but not mixed (`B2B1`) or with more digits (`B12`, `B02`).
'B' | 'b' if i == prefix_at && cs.get(i + 1).is_some_and(char::is_ascii_digit) => {
let d = cs.get(i + 1).copied();
let ok = matches!(d, Some('1' | '2')) && !cs.get(i + 2).is_some_and(char::is_ascii_digit) && raw.prefix.is_none_or(|p| Some(p) == d);
raw.invalid |= !ok;
raw.prefix = d;
i += 2;
prefix_at = i;
continue;
}
// Any other `B` and digit (`B3dd`, `dd/mm/yyyyB2`, `dd B2 mm`, `B2[Red]B2`) makes Excel
// reject the code.
'B' | 'b' if cs.get(i + 1).is_some_and(char::is_ascii_digit) => {
raw.invalid = true;
push_lit(toks, c.encode_utf8(&mut [0; 4]));
}
'"' => {
let mut s = String::new();
i += 1;
while let Some(&d) = cs.get(i) {
i += 1;
if d == '"' {
break;
}
s.push(d);
}
if s.chars().any(is_currency_char) {
raw.currency = true;
}
push_lit(toks, &s);
continue;
}
'\\' => {
if let Some(&d) = cs.get(i + 1) {
if is_currency_char(d) {
raw.currency = true;
}
// Excel rejects an escaped `\B2` as well.
raw.invalid |= matches!(d, 'B' | 'b') && cs.get(i + 2).is_some_and(char::is_ascii_digit);
push_lit(toks, d.encode_utf8(&mut [0; 4]));
}
i += 2;
continue;
}
'_' => {
if cs.get(i + 1).is_some() {
push_lit(toks, " ");
}
i += 2;
continue;
}
'*' => {
if let Some(&d) = cs.get(i + 1) {
toks.push(Tok::Fill(d));
}
i += 2;
continue;
}
'[' => {
let close = if last_close.is_some_and(|l| l > i) { cs.get(i + 1..).and_then(|r| r.iter().position(|&d| d == ']')) } else { None };
if let Some(p) = close {
let content: String = cs.get(i + 1..i + 1 + p).unwrap_or(&[]).iter().collect();
bracket(&mut raw_parts(toks, &mut raw.color, &mut raw.cond, &mut raw.currency), &content);
i += p + 2;
} else {
push_lit(toks, "[");
i += 1;
}
continue;
}
'0' | '#' | '?' => toks.push(Tok::Digit(c)),
'.' => toks.push(Tok::Point),
',' => toks.push(Tok::Comma),
'%' => toks.push(Tok::Percent),
'@' => toks.push(Tok::At),
'/' => {
toks.push(Tok::Slash);
i += 1;
if matches!(cs.get(i), Some('1'..='9')) {
let mut s = String::new();
while let Some(&d) = cs.get(i) {
if !d.is_ascii_digit() {
break;
}
s.push(d);
i += 1;
}
toks.push(Tok::FixedDenom(s));
}
continue;
}
'e' | 'E' if matches!(cs.get(i + 1), Some('+' | '-')) => {
toks.push(Tok::Exp { upper: c == 'E', plus: cs.get(i + 1) == Some(&'+') });
i += 2;
continue;
}
'G' | 'g' if starts_with_ci(&cs, i, "general") => {
toks.push(Tok::General);
i += 7;
continue;
}
'A' | 'a' if starts_with_ci(&cs, i, "am/pm") => {
toks.push(Tok::Date(DatePart::AmPm));
i += 5;
continue;
}
'A' | 'a' if starts_with_ci(&cs, i, "a/p") => {
let p = cs.get(i + 2).copied().unwrap_or('P');
toks.push(Tok::Date(DatePart::AP(c, p)));
i += 3;
continue;
}
'y' | 'Y' | 'm' | 'M' | 'd' | 'D' | 'h' | 'H' | 's' | 'S' | 'e' | 'E' => {
let lc = c.to_ascii_lowercase();
let mut n = 0usize;
while cs.get(i + n).is_some_and(|d| d.to_ascii_lowercase() == lc) {
n += 1;
}
let n8 = n.min(255) as u8;
toks.push(Tok::Date(match lc {
'y' => DatePart::Year(n8),
'e' => DatePart::Year(4),
'm' => DatePart::Month(n8),
'd' => DatePart::Day(n8),
'h' => DatePart::Hour(n8),
_ => DatePart::Second(n8),
}));
i += n.max(1);
continue;
}
_ => {
if is_currency_char(c) {
raw.currency = true;
}
push_lit(toks, c.encode_utf8(&mut [0; 4]));
}
}
i += 1;
}
// Excel keeps the date Gregorian when a `[$-401]`-style locale or currency tag comes before
// `B2` (`B2[$-401]` is still Hijri).
if raw.prefix == Some('2') && !locale_first {
raw.calendar = Calendar::Hijri;
}
raw
}
struct RawParts<'a> {
toks: &'a mut Vec<Tok>,
color: &'a mut Option<FormatColor>,
cond: &'a mut Option<Cond>,
currency: &'a mut bool,
}
fn raw_parts<'a>(toks: &'a mut Vec<Tok>, color: &'a mut Option<FormatColor>, cond: &'a mut Option<Cond>, currency: &'a mut bool) -> RawParts<'a> {
RawParts { toks, color, cond, currency }
}
/// Handles the content of a `[...]` tag.
fn bracket(p: &mut RawParts<'_>, content: &str) {
let lc = content.trim().to_ascii_lowercase();
if let Some(color) = parse_color(&lc) {
*p.color = Some(color);
return;
}
if lc.starts_with(['<', '>', '=']) {
if let Some(c) = parse_cond(&lc) {
*p.cond = Some(c);
}
return;
}
if let Some(body) = content.strip_prefix('$') {
let sym = body.split('-').next().unwrap_or("");
if !sym.is_empty() {
*p.currency = true;
push_lit(p.toks, sym);
}
return;
}
let mut chars = lc.chars();
if let Some(first) = chars.next()
&& matches!(first, 'h' | 'm' | 's')
&& lc.chars().all(|c| c == first)
{
let n = lc.chars().count().min(255) as u8;
p.toks.push(Tok::Date(match first {
'h' => DatePart::ElapsedH(n),
'm' => DatePart::ElapsedM(n),
_ => DatePart::ElapsedS(n),
}));
}
// Anything else ([DBNum1], [natural], locale tags…) is ignored.
}
fn date_part(t: &Tok) -> Option<DatePart> {
if let Tok::Date(d) = t { Some(*d) } else { None }
}
fn analyze(raw: Raw) -> Section {
let Raw { toks, color, cond, currency, calendar, prefix, invalid } = raw;
let is_date = toks.iter().any(|t| matches!(t, Tok::Date(_)));
let has_general = toks.iter().any(|t| matches!(t, Tok::General));
let mut sec = Section {
toks: Vec::new(),
color,
cond,
kind: SecKind::Number,
thousands: false,
scale: 0,
percent: 0,
has_at: toks.iter().any(|t| matches!(t, Tok::At)),
has_digits: false,
exp: false,
fraction: false,
currency,
fill: toks.iter().any(|t| matches!(t, Tok::Fill(_))),
calendar,
invalid,
};
if is_date {
sec.kind = SecKind::Date;
return analyze_date(toks, sec);
}
if has_general {
sec.kind = SecKind::General;
return analyze_general(toks, sec);
}
sec.has_digits = toks.iter().any(|t| matches!(t, Tok::Digit(_)));
if sec.has_at && !sec.has_digits {
sec.kind = SecKind::Text;
return analyze_text(toks, sec);
}
// Number section. Excel rejects a calendar prefix before digits (`B20.00`, `B1#,##0`);
// `B2General`, `B2@` and `B2"x"` show as without the prefix.
sec.invalid |= prefix.is_some() && sec.has_digits;
analyze_number(toks, sec)
}
fn analyze_date(mut toks: Vec<Tok>, mut sec: Section) -> Section {
// m/mm after hours or before seconds means minutes.
for i in 0..toks.len() {
let Some(Tok::Date(DatePart::Month(n))) = toks.get(i) else { continue };
let n = *n;
if n > 2 {
continue;
}
let prev = toks.get(..i).and_then(|s| s.iter().rev().find_map(date_part));
let next = toks.get(i + 1..).and_then(|s| s.iter().find_map(date_part));
let after_h = matches!(prev, Some(DatePart::Hour(_) | DatePart::ElapsedH(_)));
let before_s = matches!(next, Some(DatePart::Second(_) | DatePart::ElapsedS(_)));
if (after_h || before_s)
&& let Some(t) = toks.get_mut(i)
{
*t = Tok::Date(DatePart::Minute(n));
}
}
let mut out = Vec::with_capacity(toks.len());
let mut i = 0;
while let Some(t) = toks.get(i) {
if matches!(t, Tok::Point) && toks.get(i + 1) == Some(&Tok::Digit('0')) {
let mut n = 0usize;
while toks.get(i + 1 + n) == Some(&Tok::Digit('0')) {
n += 1;
}
out.push(Tok::Date(DatePart::SubSec(n.min(255) as u8)));
i += 1 + n;
continue;
}
match t {
Tok::Date(_) | Tok::Fill(_) => out.push(t.clone()),
other => push_lit(&mut out, &other.literal_text()),
}
i += 1;
}
sec.toks = out;
sec
}
fn analyze_general(toks: Vec<Tok>, mut sec: Section) -> Section {
let mut out = Vec::with_capacity(toks.len());
for t in &toks {
match t {
Tok::General | Tok::Fill(_) => out.push(t.clone()),
other => push_lit(&mut out, &other.literal_text()),
}
}
sec.toks = out;
sec
}
fn analyze_text(toks: Vec<Tok>, mut sec: Section) -> Section {
let mut out = Vec::with_capacity(toks.len());
for t in &toks {
match t {
Tok::At | Tok::Fill(_) => out.push(t.clone()),
other => push_lit(&mut out, &other.literal_text()),
}
}
sec.toks = out;
sec
}
fn analyze_number(mut toks: Vec<Tok>, mut sec: Section) -> Section {
// Number section.
let mut seen_point = false;
let mut seen_exp = false;
let mut seen_digit = false;
let mut fraction_slash = false;
// Precomputed so each comma and slash is O(1) (a long run of them stays linear): whether the
// first non-comma token after a position is a digit, and where the last exponent marker is.
let mut digit_after_commas = vec![false; toks.len()];
let mut after = false;
for (j, t) in toks.iter().enumerate().rev() {
if let Some(slot) = digit_after_commas.get_mut(j) {
*slot = after;
}
if !matches!(t, Tok::Comma) {
after = matches!(t, Tok::Digit(_));
}
}
let last_exp = toks.iter().rposition(|t| matches!(t, Tok::Exp { .. }));
let scientific = toks.iter().skip_while(|t| !matches!(t, Tok::Digit(_))).any(|t| matches!(t, Tok::Exp { .. }));
for i in 0..toks.len() {
let Some(t) = toks.get(i).cloned() else { break };
let replace = match t {
Tok::Digit(_) => {
seen_digit = true;
None
}
Tok::Point => {
if seen_point || seen_exp {
Some(Tok::Lit(".".into()))
} else {
seen_point = true;
None
}
}
Tok::Percent => {
sec.percent += 1;
None
}
Tok::Exp { .. } => {
if seen_exp || !seen_digit {
Some(Tok::Lit(t.literal_text()))
} else {
seen_exp = true;
sec.exp = true;
None
}
}
Tok::Comma => {
let next_digit = digit_after_commas.get(i).copied().unwrap_or(false);
if seen_digit && next_digit && !seen_point && !seen_exp {
sec.thousands = true;
Some(Tok::Lit(String::new()))
} else if seen_digit && scientific {
// Observed: a scientific section never scales; any other comma after the
// first digit shows nothing (`#,e+#`, `#e+#,`, `#e+000,00`).
Some(Tok::Lit(String::new()))
} else if seen_digit && !next_digit {
sec.scale += 1;
Some(Tok::Lit(String::new()))
} else {
Some(Tok::Lit(",".into()))
}
}
Tok::Slash => {
// Literal text may sit between the bar and the denominator (`#/ #`, `# ?=/=?`).
let next_ok = matches!(toks.get(i + 1), Some(Tok::FixedDenom(_)))
|| matches!(toks.get(i + 1..).and_then(|s| s.iter().find(|t| !matches!(t, Tok::Lit(_)))), Some(Tok::Digit(_)));
// An exponent kept before this bar (`seen_exp`) or any marker still ahead of it.
let has_exp = seen_exp || last_exp.is_some_and(|l| l > i);
if !fraction_slash && seen_digit && next_ok && !seen_point && !has_exp {
fraction_slash = true;
sec.fraction = true;
None
} else {
Some(Tok::Lit("/".into()))
}
}
Tok::FixedDenom(ref s) => {
if matches!(i.checked_sub(1).and_then(|p| toks.get(p)), Some(Tok::Slash)) && sec.fraction {
None
} else {
Some(Tok::Lit(s.clone()))
}
}
_ => None,
};
if let Some(r) = replace
&& let Some(slot) = toks.get_mut(i)
{
*slot = r;
}
}
if sec.fraction {
// A decimal point in a fraction section is literal.
for t in toks.iter_mut() {
if matches!(t, Tok::Point) {
*t = Tok::Lit(".".into());
}
}
}
// Merge literals (removed commas left empty ones).
let mut out = Vec::with_capacity(toks.len());
for t in toks {
match t {
Tok::Lit(s) => push_lit(&mut out, &s),
other => out.push(other),
}
}
sec.toks = out;
sec
}
/// Parses a whole format code into its sections.
pub(crate) fn parse_sections(code: &str) -> Vec<Section> {
split_sections(code).iter().map(|s| analyze(tokenize(s))).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sections_split() {
assert_eq!(split_sections("0;-0;;@").len(), 4);
assert_eq!(split_sections("\"a;b\"0").len(), 1);
assert_eq!(split_sections("\\;0").len(), 1);
assert_eq!(split_sections("0;0;0;0;0;0").len(), 4);
}
#[test]
fn minutes_vs_months() {
let s = parse_sections("h:mm");
assert!(s[0].toks.contains(&Tok::Date(DatePart::Minute(2))));
let s = parse_sections("m/d");
assert!(s[0].toks.contains(&Tok::Date(DatePart::Month(1))));
let s = parse_sections("mm:ss");
assert!(s[0].toks.contains(&Tok::Date(DatePart::Minute(2))));
}
#[test]
fn commas() {
let s = &parse_sections("#,##0,")[0];
assert!(s.thousands);
assert_eq!(s.scale, 1);
let s = &parse_sections("0.0,,")[0];
assert!(!s.thousands);
assert_eq!(s.scale, 2);
}
}