Shared Rust kernels
value_text
All modules · quoted at commit 9e1884369a67 of DnaCalc/DnaCalc. Comments are the source's own.
measured_host_reader · 6 lines · calc-func/src/value_text.rs on GitHub/// The numeric-text host grammar measured by the fn8 scalar questions.
pub(crate) fn measured_host_reader(profile: &FormatProfile) -> bool {
matches!(profile.id, LocaleId::EnZa | LocaleId::CurrentExcelHost)
&& profile.decimal_separator == "." && profile.thousands_separator == " "
&& profile.currency_symbol == "R"
}
Used by 14 functions.
measured_space · 6 lines · calc-func/src/value_text.rs on GitHub/// Spaces with retained negative numeric-text controls. A configured positive
/// separator remains subject to the existing not-modelled treatment below.
pub(crate) fn measured_space(ch: char, profile: &FormatProfile) -> bool {
matches!(ch, '\u{00a0}' | '\u{2003}' | '\u{202f}' | '\u{3000}')
|| (ch == '\u{2009}' && measured_host_reader(profile))
}
Used by 14 functions.
read · 74 lines · calc-func/src/value_text.rs on GitHub/// Reads `text` (UTF-16 code units).
pub(crate) fn read(units: &[u16], context: &ReadContext<'_>) -> Result<Reading, Unread> {
let mut text = String::with_capacity(units.len());
let ascii_profile = [
context.profile.decimal_separator,
context.profile.thousands_separator,
context.profile.currency_symbol,
]
.iter()
.all(|s| s.is_ascii());
let measured_digits = ascii_profile
&& matches!(context.profile.id, LocaleId::EnZa | LocaleId::CurrentExcelHost);
let mut translated_digits = false;
let mut beyond = false;
let mut has_measured_space = false;
for decoded in char::decode_utf16(units.iter().copied()) {
match decoded {
Ok(c) if c.is_ascii() => text.push(c),
Ok('€') if measured_host_reader(context.profile) => text.push('€'),
Ok(c) if measured_space(c, context.profile) => {
text.push(c);
has_measured_space = true;
}
// Fn4 qt031..qt096 accepts these four digit scripts in the measured profiles.
// Fn7 live SQRT/DATEVALUE controls also confirm mixed-script numbers, exponent
// digits and three-component dates; unmeasured fraction/time forms stay refused.
Ok(c @ ('\u{0660}'..='\u{0669}' | '\u{06f0}'..='\u{06f9}'
| '\u{0966}'..='\u{096f}' | '\u{0e50}'..='\u{0e59}')) if measured_digits => {
translated_digits = true;
let zero = match c {
'\u{0660}'..='\u{0669}' => 0x0660,
'\u{06f0}'..='\u{06f9}' => 0x06f0,
'\u{0966}'..='\u{096f}' => 0x0966,
_ => 0x0e50,
};
text.push(char::from_u32(u32::from('0') + u32::from(c) - zero).unwrap());
}
Ok('\u{ff10}'..='\u{ff19}') if measured_digits => return Err(Unread::Rejected),
// A digit of another script, a space other than ASCII's, or a profile whose
// separators or currency lie beyond ASCII: what Excel reads is not modelled.
Ok(c) if c.is_numeric() || c.is_whitespace() || !ascii_profile => {
return Err(Unread::NotModelled);
}
// Any other character beyond ASCII is in no number, date or time form.
Ok(_) => beyond = true,
Err(_) => return Err(Unread::NotModelled),
}
}
if beyond {
return Err(Unread::Rejected);
}
let text = text.trim_matches(' ');
if text.is_empty() {
return Err(Unread::Rejected);
}
if let Some(n) = number(text, context.profile)? {
if has_measured_space {
return Err(Unread::NotModelled);
}
return Ok(Reading::Number(n));
}
// Only ASCII spaces belong to the measured fraction/date/time grammar. The numeric
// parser above can consume a configured separator; none of these spaces is trimming.
if has_measured_space {
return Err(Unread::Rejected);
}
if let Some(n) = fraction(text) {
if translated_digits {
return Err(Unread::NotModelled);
}
return n.map(Reading::Number);
}
date_time(text, context, !translated_digits).map(Reading::DateTime)
}
Used by 14 functions.
number · 162 lines · calc-func/src/value_text.rs on GitHub/// Number text, `None` when the text is not in the number form (it may be a date or a fraction).
fn number(text: &str, profile: &FormatProfile) -> Result<Option<f64>, Unread> {
let bytes = text.as_bytes();
let currency = profile.currency_symbol.as_bytes();
let euro = "€".as_bytes();
let currency_length = |at: usize| {
if !currency.is_empty() && bytes[at..].starts_with(currency) { currency.len() }
else if measured_host_reader(profile) && bytes[at..].starts_with(euro) { euro.len() }
else { 0 }
};
let group = profile.thousands_separator.as_bytes();
let point = profile.decimal_separator.as_bytes();
let mut at = 0;
let (mut signed, mut negative, mut paren, mut has_currency, mut percent) =
(false, false, false, false, false);
let skip = |at: &mut usize| {
while bytes.get(*at) == Some(&b' ') {
*at += 1;
}
};
loop {
skip(&mut at);
match bytes.get(at) {
Some(b'+' | b'-') if !signed && !paren => {
signed = true;
negative = bytes[at] == b'-';
at += 1;
}
Some(b'(') if !signed && !paren => {
paren = true;
at += 1;
}
Some(b'%') if !percent => {
percent = true;
at += 1;
}
_ if !has_currency && currency_length(at) > 0 => {
has_currency = true;
at += currency_length(at);
}
_ => break,
}
}
// The mantissa: digits with group separators, then a decimal point and digits.
let mut digits = String::new();
let mut fraction_digits = 0i64;
let mut any = false;
let mut group_run: Option<usize> = None;
loop {
match bytes.get(at) {
Some(d) if d.is_ascii_digit() => {
digits.push(char::from(*d));
any = true;
if let Some(run) = group_run.as_mut() {
*run += 1;
}
at += 1;
}
_ if any
&& !group.is_empty()
&& bytes[at..].starts_with(group)
&& bytes.get(at + group.len()).is_some_and(u8::is_ascii_digit) =>
{
if group_run.is_some_and(|run| run < 3) {
return Ok(None);
}
group_run = Some(0);
at += group.len();
}
_ => break,
}
}
if group_run.is_some_and(|run| run < 3) {
return Ok(None);
}
if bytes[at..].starts_with(point) {
at += point.len();
while let Some(d) = bytes.get(at).filter(|d| d.is_ascii_digit()) {
digits.push(char::from(*d));
fraction_digits += 1;
any = true;
at += 1;
}
}
if !any {
return Ok(None);
}
let mut exponent = 0i64;
if matches!(bytes.get(at), Some(b'e' | b'E')) {
let mut cursor = at + 1;
let exp_negative = match bytes.get(cursor) {
Some(b'-') => {
cursor += 1;
true
}
Some(b'+') => {
cursor += 1;
false
}
_ => false,
};
let start = cursor;
while bytes.get(cursor).is_some_and(u8::is_ascii_digit) {
exponent = (exponent * 10 + i64::from(bytes[cursor] - b'0')).min(1_000_000);
cursor += 1;
}
if cursor == start {
return Ok(None);
}
if exp_negative {
exponent = -exponent;
}
at = cursor;
}
// Euro may also follow the amount, inside or outside parentheses;
// the configured Rand remains prefix-only (fn8 paired currency controls).
let take_euro = |at: &mut usize, has_currency: &mut bool| {
if !*has_currency && measured_host_reader(profile) && bytes[*at..].starts_with(euro) {
*has_currency = true;
*at += euro.len();
skip(at);
}
};
skip(&mut at);
take_euro(&mut at, &mut has_currency);
if bytes.get(at) == Some(&b'%') && !percent {
percent = true;
at += 1;
skip(&mut at);
}
if paren {
if bytes.get(at) != Some(&b')') {
return Ok(None);
}
at += 1;
negative = true;
skip(&mut at);
}
take_euro(&mut at, &mut has_currency);
// W111 numeric-text-core-heldout: the percent may follow the complete parenthesized
// amount, with ASCII spaces between. The same flag rejects a second percent.
if bytes.get(at) == Some(&b'%') && !percent {
percent = true;
at += 1;
skip(&mut at);
}
if at != bytes.len() {
return Ok(None);
}
if has_currency && percent && measured_host_reader(profile) {
return Err(Unread::Rejected);
}
let scale = exponent - fraction_digits - if percent { 2 } else { 0 };
// W111 percent-bounds: a zero coefficient still validates the percent-adjusted
// exponent. decimal()'s zero fast return otherwise bypasses this admission rule.
if percent && digits.bytes().all(|digit| digit == b'0') && !(-308..=308).contains(&scale) {
return Err(Unread::Rejected);
}
decimal(&digits, scale)
.map(|n| Some(if negative && n != 0.0 { -n } else { n }))
.ok_or(Unread::Rejected)
}
Used by 14 functions.
decimal · 17 lines · calc-func/src/value_text.rs on GitHub/// The binary64 nearest `digits` (ASCII decimal digits) times 10^scale, its significant digits
/// cut to 15; `None` when the value's decimal point lies outside -308..=308, as Excel's number
/// text reading rejects it.
pub(crate) fn decimal(digits: &str, scale: i64) -> Option<f64> {
let trimmed = digits.trim_start_matches('0');
if trimmed.is_empty() {
return Some(0.0);
}
let kept = &trimmed[..trimmed.len().min(15)];
let dropped = (trimmed.len() - kept.len()) as i64;
let position = scale + trimmed.len() as i64;
if !(-308..=308).contains(&position) {
return None;
}
let n: f64 = format!("{kept}e{}", scale + dropped).parse().ok()?;
Some(if n.is_subnormal() { 0.0 } else { n })
}
Used by 14 functions.
fraction · 26 lines · calc-func/src/value_text.rs on GitHub/// A whole number and a fraction: `[sign] int spaces num/den`. `None` when the text is not in
/// this form; #VALUE! for a zero denominator.
fn fraction(text: &str) -> Option<Result<f64, Unread>> {
let (negative, body) = match text.as_bytes().first() {
Some(b'-') => (true, text[1..].trim_start_matches(' ')),
Some(b'+') => (false, text[1..].trim_start_matches(' ')),
_ => (false, text),
};
let (whole, rest) = body.split_once(' ')?;
let rest = rest.trim_start_matches(' ');
let (numerator, denominator) = rest.split_once('/')?;
let all_digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) && s.len() <= 15;
if !all_digits(whole) || !all_digits(numerator) || !all_digits(denominator) {
return None;
}
let (w, n, d): (f64, f64, f64) = (
whole.parse().ok()?,
numerator.parse().ok()?,
denominator.parse().ok()?,
);
if d == 0.0 {
return Some(Err(Unread::Rejected));
}
let value = w + n / d;
Some(Ok(if negative { -value } else { value }))
}
Used by 14 functions.
tokens · 36 lines · calc-func/src/value_text.rs on GitHubfn tokens(text: &str) -> Result<Vec<Token>, Unread> {
let bytes = text.as_bytes();
let mut out = Vec::new();
let mut at = 0;
while at < bytes.len() {
let b = bytes[at];
if b.is_ascii_digit() {
let start = at;
while at < bytes.len() && bytes[at].is_ascii_digit() {
at += 1;
}
let run = &text[start..at];
if run.len() > 9 {
return Err(Unread::Rejected);
}
out.push(Token::Number(run.parse().expect("digits"), run.len()));
} else if b.is_ascii_alphabetic() {
let start = at;
while at < bytes.len() && bytes[at].is_ascii_alphabetic() {
at += 1;
}
out.push(Token::Word(text[start..at].to_ascii_lowercase()));
} else if b == b' ' {
while at < bytes.len() && bytes[at] == b' ' {
at += 1;
}
out.push(Token::Sep(' '));
} else if matches!(b, b'/' | b'-' | b':' | b'.' | b',') {
out.push(Token::Sep(char::from(b)));
at += 1;
} else {
return Err(Unread::Rejected);
}
}
Ok(out)
}
Used by 14 functions.
month_named · 12 lines · calc-func/src/value_text.rs on GitHubfn month_named(word: &str) -> Option<i64> {
const NAMES: [&str; 12] = [
"january", "february", "march", "april", "may", "june", "july", "august", "september",
"october", "november", "december",
];
if word == "sept" {
return Some(9);
}
NAMES.iter().position(|name| {
*name == word || (word.len() == 3 && name.starts_with(word))
}).map(|at| at as i64 + 1)
}
Used by 14 functions.
time_at · 66 lines · calc-func/src/value_text.rs on GitHub/// The time at the start of `tokens[from..]`, its fraction of a day and the tokens it took.
fn time_at(tokens: &[Token], from: usize) -> Option<(f64, usize)> {
let mut at = from;
let Token::Number(hour, hour_len) = tokens.get(at)? else {
return None;
};
let (mut hour, hour_len) = (*hour, *hour_len);
at += 1;
let mut minute = 0i64;
let mut second = 0f64;
let mut colon = false;
if tokens.get(at) == Some(&Token::Sep(':')) {
colon = true;
at += 1;
if let Some(Token::Number(m, len)) = tokens.get(at) {
if *len > 2 {
return None;
}
minute = *m;
at += 1;
if tokens.get(at) == Some(&Token::Sep(':')) {
at += 1;
if let Some(Token::Number(s, len)) = tokens.get(at) {
if *len > 2 {
return None;
}
second = *s as f64;
at += 1;
if tokens.get(at) == Some(&Token::Sep('.'))
&& let Some(Token::Number(f, len)) = tokens.get(at + 1)
{
second += *f as f64 / 10f64.powi(*len as i32);
at += 2;
}
}
}
}
}
if hour_len > 4 {
return None;
}
// An AM/PM marker after a space.
let mut marked = false;
if tokens.get(at) == Some(&Token::Sep(' '))
&& let Some(Token::Word(word)) = tokens.get(at + 1)
&& matches!(word.as_str(), "a" | "am" | "p" | "pm")
{
if hour > 12 {
return None;
}
let pm = word.starts_with('p');
if hour == 12 {
hour = 0;
}
if pm {
hour += 12;
}
marked = true;
at += 2;
}
if !colon && !marked {
return None;
}
let seconds = (hour * 3600 + minute * 60) as f64 + second;
Some((seconds / 86_400.0, at - from))
}
Used by 14 functions.
full_year · 12 lines · calc-func/src/value_text.rs on GitHub/// A two-digit year as Excel reads it: below 30 in the 2000s, else in the 1900s.
fn full_year(year: i64, len: usize) -> i64 {
if len <= 2 {
if year < 30 {
2000 + year
} else {
1900 + year
}
} else {
year
}
}
Used by 14 functions.
valid_day · 5 lines · calc-func/src/value_text.rs on GitHubfn valid_day(year: i64, month: i64, day: i64) -> bool {
(1..=12).contains(&month)
&& day >= 1
&& (day <= days_in_month(year, month) || (year == 1900 && month == 2 && day == 29))
}
Used by 14 functions.
this_year · 8 lines · calc-func/src/value_text.rs on GitHub/// The calculation's year, from its clock.
fn this_year(context: &ReadContext<'_>) -> Result<i64, Unread> {
let now = context.now.ok_or(Unread::NotModelled)?;
if !(0.0..2_958_466.0).contains(&now) {
return Err(Unread::NotModelled);
}
Ok(ymd_1900(now.trunc() as i64).0)
}
Used by 14 functions.
serial_1900 · 7 lines · calc-func/src/value_text.rs on GitHub/// The 1900 serial of a date, or #VALUE! outside 1900..=9999.
fn serial_1900(year: i64, month: i64, day: i64) -> Result<i64, Unread> {
if !(1900..=9999).contains(&year) || !valid_day(year, month, day) {
return Err(Unread::Rejected);
}
Ok(crate::ported::date_kernels::serial_unbounded_1900(year, month, day))
}
Used by 14 functions.
date_of · 131 lines · calc-func/src/value_text.rs on GitHub/// The date that `tokens` (without a time) name, as a 1900 serial.
fn date_of(tokens: &[Token], context: &ReadContext<'_>) -> Result<i64, Unread> {
// The elements and the separators between them.
let mut elements: Vec<&Token> = Vec::new();
let mut separators: Vec<Option<char>> = Vec::new();
let mut pending: Option<char> = None;
let mut had_separator = false;
for token in tokens {
match token {
Token::Sep(c) => {
if had_separator && !(pending == Some(',') && *c == ' ') {
return Err(Unread::Rejected);
}
if elements.is_empty() {
return Err(Unread::Rejected);
}
pending = Some(match (pending, *c) {
(Some(','), ' ') => ',',
(_, c) => c,
});
had_separator = true;
}
_ => {
if !elements.is_empty() {
separators.push(pending.take());
}
elements.push(token);
had_separator = false;
pending = None;
}
}
}
if had_separator || elements.is_empty() {
return Err(Unread::Rejected);
}
let months: Vec<usize> = elements
.iter()
.enumerate()
.filter(|(_, t)| matches!(t, Token::Word(_)))
.map(|(i, _)| i)
.collect();
let number = |t: &Token| match t {
Token::Number(n, len) => Some((*n, *len)),
_ => None,
};
match months.as_slice() {
[] => {
// Numbers only, separated by / or -.
if separators.iter().any(|s| !matches!(s, Some('/' | '-'))) {
return Err(Unread::Rejected);
}
let nums: Vec<(i64, usize)> = elements.iter().filter_map(|t| number(t)).collect();
match nums.as_slice() {
[a, b, c] => {
let (y, m, d) = match context.profile.short_date_order {
DateComponentOrder::Ymd => (a, b, c),
DateComponentOrder::Mdy | DateComponentOrder::Dmy => {
return Err(Unread::NotModelled);
}
};
// Fn4 list-controls ql041..ql085: month/day tokens have one or two
// digits. A three/four-digit final token is not a day with leading zeros.
if matches!(context.profile.id, LocaleId::EnZa | LocaleId::CurrentExcelHost)
&& (m.1 > 2 || d.1 > 2) {
return Err(Unread::Rejected);
}
serial_1900(full_year(y.0, y.1), m.0, d.0)
}
[a, b] => {
if context.profile.short_date_order != DateComponentOrder::Ymd {
return Err(Unread::NotModelled);
}
// Month and day, or day and month, of the clock's year (2024, a leap year,
// stands in to test them when there is no clock); else year and month.
let year = this_year(context);
let test_year = *year.as_ref().unwrap_or(&2024);
if valid_day(test_year, a.0, b.0) {
serial_1900(year?, a.0, b.0)
} else if valid_day(test_year, b.0, a.0) {
serial_1900(year?, b.0, a.0)
} else if (1..=12).contains(&b.0) {
serial_1900(full_year(a.0, a.1), b.0, 1)
} else {
Err(Unread::Rejected)
}
}
_ => Err(Unread::Rejected),
}
}
[at] => {
let Token::Word(word) = elements[*at] else {
unreachable!()
};
let month = month_named(word).ok_or(Unread::Rejected)?;
// A comma may follow the month name only.
for (i, s) in separators.iter().enumerate() {
if *s == Some(',') && i != *at {
return Err(Unread::Rejected);
}
if matches!(s, Some('/' | ':' | '.')) {
return Err(Unread::Rejected);
}
}
let nums: Vec<(i64, usize)> = elements.iter().filter_map(|t| number(t)).collect();
match (*at, nums.as_slice()) {
// day month year
(1, [d, y]) if elements.len() == 3 => serial_1900(full_year(y.0, y.1), month, d.0),
// day month
(1, [d]) if elements.len() == 2 => {
let year = this_year(context)?;
serial_1900(year, month, d.0)
}
// month day-or-year
(0, [n]) if elements.len() == 2 => {
if separators[0] == Some(',') {
return Err(Unread::Rejected);
}
let year = this_year(context);
let test_year = *year.as_ref().unwrap_or(&2024);
if n.1 <= 2 && valid_day(test_year, month, n.0) {
serial_1900(year?, month, n.0)
} else {
serial_1900(full_year(n.0, n.1), month, 1)
}
}
_ => Err(Unread::Rejected),
}
}
_ => Err(Unread::Rejected),
}
}
Used by 14 functions.
date_time · 75 lines · calc-func/src/value_text.rs on GitHubfn date_time(text: &str, context: &ReadContext<'_>, allow_time: bool) -> Result<f64, Unread> {
let tokens = tokens(text)?;
// A time at the start, or after the last space.
let mut time = None;
let mut date_tokens: &[Token] = &tokens;
if let Some((fraction, taken)) = time_at(&tokens, 0) {
let rest = &tokens[taken..];
let rest = match rest.first() {
None => rest,
Some(Token::Sep(' ')) => &rest[1..],
_ => return Err(Unread::Rejected),
};
time = Some(fraction);
date_tokens = rest;
} else {
for start in (1..tokens.len()).rev() {
if tokens[start - 1] != Token::Sep(' ') {
continue;
}
if let Some((fraction, taken)) = time_at(&tokens, start)
&& start + taken == tokens.len()
{
time = Some(fraction);
date_tokens = &tokens[..start - 1];
break;
}
}
}
// A trailing colon after the time ("12:" and "1:30:").
if time.is_none()
&& let Some(Token::Sep(':')) = tokens.last()
&& let Some((fraction, taken)) = time_at(&tokens[..tokens.len() - 1], 0)
&& taken == tokens.len() - 1
{
time = Some(fraction);
date_tokens = &[];
}
if time.is_none() && tokens.len() >= 2 {
let trimmed = &tokens[..tokens.len() - 1];
if tokens.last() == Some(&Token::Sep(':'))
&& let Some((fraction, taken)) = time_at(trimmed, 0)
&& taken == trimmed.len()
{
time = Some(fraction);
date_tokens = &[];
}
}
if time.is_some() && !allow_time {
return Err(Unread::NotModelled);
}
let date = if date_tokens.is_empty() {
None
} else {
Some(date_of(date_tokens, context)?)
};
if date.is_none() && time.is_none() {
return Err(Unread::Rejected);
}
let serial = match (date, context.date_system) {
(None, _) => 0.0,
(Some(serial), DateSystem::System1900) => serial as f64,
(Some(serial), DateSystem::System1904) => {
let serial = serial as f64 - OFFSET_1904;
if serial < 0.0 {
return Err(Unread::Rejected);
}
serial
}
};
Ok(match time {
Some(fraction) if date.is_some() => serial + fraction,
Some(fraction) => fraction,
None => serial,
})
}
Used by 14 functions.