use crate::{
error::{VBError, VBResult},
value::{VBLong, VBString, VBVariant},
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum NamedNumericFormat {
GeneralNumber,
Currency,
Fixed,
Standard,
Percent,
Scientific,
YesNo,
TrueFalse,
OnOff,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum NamedDateFormat {
GeneralDate,
LongDate,
MediumDate,
ShortDate,
LongTime,
MediumTime,
ShortTime,
}
pub fn format_dollar(
expression: &VBVariant,
format: Option<&VBString>,
firstdayofweek: Option<&VBLong>,
firstweekofyear: Option<&VBLong>,
) -> VBResult<VBString> {
if expression.is_null() {
return Ok(VBString::from(""));
}
let first_day_of_week = firstdayofweek.map_or(1, |v| {
let n = v.as_i32();
if (1..=7).contains(&n) {
n
} else {
1
}
});
let first_week_of_year = firstweekofyear.map_or(1, |v| v.as_i32());
let Some(fmt) = format else {
let result = if expression.is_date() {
match date_parts(expression.as_date_serial()?) {
Some(parts) => general_date_string(&parts),
None => return Err(VBError::type_mismatch()),
}
} else {
expression.as_string()?
};
return Ok(VBString::from(result));
};
let fmt_str = fmt.as_str();
if fmt_str.trim().is_empty() {
return Ok(VBString::from(expression.as_string()?));
}
if let Some(name) = named_numeric_format_name(fmt_str) {
let value = expression.as_f64()?;
return Ok(VBString::from(format_named_numeric(name, value)?));
}
if let Some(name) = named_date_format_name(fmt_str) {
let serial = expression.as_date_serial()?;
let parts = date_parts(serial).ok_or_else(VBError::type_mismatch)?;
return Ok(VBString::from(format_named_date(name, &parts)));
}
if has_string_placeholder(fmt_str) {
let input = expression.as_string()?;
return Ok(VBString::from(format_string_custom(&input, fmt_str)));
}
if has_date_letters(fmt_str) {
let serial = expression.as_date_serial()?;
return Ok(VBString::from(format_date_custom(
serial,
fmt_str,
first_day_of_week,
first_week_of_year,
)?));
}
let value = expression.as_f64()?;
Ok(VBString::from(format_number_custom(value, fmt_str)?))
}
fn named_numeric_format_name(fmt: &str) -> Option<NamedNumericFormat> {
match fmt.trim().to_ascii_lowercase().as_str() {
"general number" => Some(NamedNumericFormat::GeneralNumber),
"currency" => Some(NamedNumericFormat::Currency),
"fixed" => Some(NamedNumericFormat::Fixed),
"standard" => Some(NamedNumericFormat::Standard),
"percent" => Some(NamedNumericFormat::Percent),
"scientific" => Some(NamedNumericFormat::Scientific),
"yes/no" => Some(NamedNumericFormat::YesNo),
"true/false" => Some(NamedNumericFormat::TrueFalse),
"on/off" => Some(NamedNumericFormat::OnOff),
_ => None,
}
}
fn format_named_numeric(name: NamedNumericFormat, value: f64) -> VBResult<String> {
match name {
NamedNumericFormat::GeneralNumber => Ok(general_number(value)),
NamedNumericFormat::Currency => format_number_custom(value, "$#,##0.00;($#,##0.00)"),
NamedNumericFormat::Fixed => format_number_custom(value, "0.00"),
NamedNumericFormat::Standard => format_number_custom(value, "#,##0.00"),
NamedNumericFormat::Percent => format_number_custom(value, "0.00%"),
NamedNumericFormat::Scientific => format_number_custom(value, "0.00E+00"),
NamedNumericFormat::YesNo => Ok(if value == 0.0 { "No" } else { "Yes" }.to_string()),
NamedNumericFormat::TrueFalse => {
Ok(if value == 0.0 { "False" } else { "True" }.to_string())
}
NamedNumericFormat::OnOff => Ok(if value == 0.0 { "Off" } else { "On" }.to_string()),
}
}
fn named_date_format_name(fmt: &str) -> Option<NamedDateFormat> {
match fmt.trim().to_ascii_lowercase().as_str() {
"general date" => Some(NamedDateFormat::GeneralDate),
"long date" => Some(NamedDateFormat::LongDate),
"medium date" => Some(NamedDateFormat::MediumDate),
"short date" => Some(NamedDateFormat::ShortDate),
"long time" => Some(NamedDateFormat::LongTime),
"medium time" => Some(NamedDateFormat::MediumTime),
"short time" => Some(NamedDateFormat::ShortTime),
_ => None,
}
}
pub(crate) fn format_named_date(name: NamedDateFormat, parts: &DateParts) -> String {
match name {
NamedDateFormat::GeneralDate => general_date_string(parts),
NamedDateFormat::LongDate => format!(
"{}, {} {}, {}",
DAY_NAMES[(parts.weekday - 1) as usize],
MONTH_NAMES[(parts.month - 1) as usize],
parts.day,
parts.year
),
NamedDateFormat::MediumDate => format!(
"{:02}-{}-{:02}",
parts.day,
&MONTH_NAMES[(parts.month - 1) as usize][..3],
parts.year % 100
),
NamedDateFormat::ShortDate => format!("{}/{}/{}", parts.month, parts.day, parts.year),
NamedDateFormat::LongTime => format!(
"{}:{:02}:{:02} {}",
hour12(parts.hour),
parts.minute,
parts.second,
ampm(parts.hour)
),
NamedDateFormat::MediumTime => format!(
"{:02}:{:02} {}",
hour12(parts.hour),
parts.minute,
ampm(parts.hour)
),
NamedDateFormat::ShortTime => format!("{:02}:{:02}", parts.hour, parts.minute),
}
}
fn has_string_placeholder(fmt: &str) -> bool {
let mut in_quote = false;
let mut chars = fmt.chars();
while let Some(c) = chars.next() {
match c {
'"' => in_quote = !in_quote,
'\\' => {
chars.next();
}
c if !in_quote && matches!(c, '@' | '&' | '<' | '>' | '!') => return true,
_ => {}
}
}
false
}
fn format_string_custom(input: &str, format: &str) -> String {
let chars: Vec<char> = format.chars().collect();
let mut has_gt = false;
let mut has_lt = false;
let mut left_align = false;
let mut placeholders = 0usize;
let mut in_quote = false;
let mut i = 0;
while i < chars.len() {
let c = chars[i];
match c {
'"' => in_quote = !in_quote,
'\\' => i += 1,
c if !in_quote => match c {
'@' | '&' => placeholders += 1,
'>' => has_gt = true,
'<' => has_lt = true,
'!' => left_align = true,
_ => {}
},
_ => {}
}
i += 1;
}
let apply_case = |s: String| -> String {
if has_lt {
s.to_lowercase()
} else if has_gt {
s.to_uppercase()
} else {
s
}
};
if placeholders == 0 {
return apply_case(input.to_string());
}
let input_chars: Vec<char> = input.chars().collect();
let used = placeholders.min(input_chars.len());
let leading_pad = if left_align { 0 } else { placeholders - used };
let mut out = String::new();
let mut consumed = 0usize;
let mut ph_index = 0usize;
i = 0;
while i < chars.len() {
let c = chars[i];
match c {
'@' | '&' => {
if ph_index < leading_pad {
if c == '@' {
out.push(' ');
}
} else if consumed < used {
out.push(input_chars[consumed]);
consumed += 1;
} else if c == '@' {
out.push(' ');
}
ph_index += 1;
i += 1;
}
'<' | '>' | '!' => i += 1,
'"' => {
i += 1;
while i < chars.len() && chars[i] != '"' {
out.push(chars[i]);
i += 1;
}
if i < chars.len() {
i += 1;
}
}
'\\' => {
i += 1;
if i < chars.len() {
out.push(chars[i]);
i += 1;
}
}
_ => {
out.push(c);
i += 1;
}
}
}
apply_case(out)
}
struct NumericSection {
prefix: String,
int_placeholders: Vec<char>,
frac_placeholders: Vec<char>,
has_thousands: bool,
scaling: u32,
percent: u32,
exponent: Option<(char, bool, usize)>,
suffix: String,
}
fn split_sections(format: &str) -> Vec<String> {
let mut sections = vec![String::new()];
let mut in_quote = false;
let mut chars = format.chars();
while let Some(c) = chars.next() {
match c {
'"' => {
in_quote = !in_quote;
sections.last_mut().unwrap().push(c);
}
'\\' => {
sections.last_mut().unwrap().push(c);
if let Some(next) = chars.next() {
sections.last_mut().unwrap().push(next);
}
}
';' if !in_quote => sections.push(String::new()),
_ => sections.last_mut().unwrap().push(c),
}
}
sections
}
fn is_num_body_start(c: char) -> bool {
matches!(c, '0' | '#' | '.' | ',' | '%' | 'E' | 'e')
}
fn read_literal(chars: &[char], i: &mut usize) -> String {
let mut out = String::new();
while *i < chars.len() {
let c = chars[*i];
if c == '"' {
*i += 1;
while *i < chars.len() && chars[*i] != '"' {
out.push(chars[*i]);
*i += 1;
}
if *i < chars.len() {
*i += 1;
}
} else if c == '\\' {
*i += 1;
if *i < chars.len() {
out.push(chars[*i]);
*i += 1;
}
} else {
break;
}
}
out
}
fn parse_exponent(chars: &[char], i: usize) -> Option<(char, bool, usize, usize)> {
let letter = chars[i];
let mut j = i + 1;
if j >= chars.len() {
return None;
}
let forced_plus = match chars[j] {
'+' => true,
'-' => false,
_ => return None,
};
j += 1;
let mut digits = 0usize;
while j < chars.len() && (chars[j] == '0' || chars[j] == '#') {
digits += 1;
j += 1;
}
if digits == 0 {
return None;
}
Some((letter, forced_plus, digits, j))
}
fn parse_numeric_section(section: &str) -> NumericSection {
let chars: Vec<char> = section.chars().collect();
let n = chars.len();
let mut parsed = NumericSection {
prefix: String::new(),
int_placeholders: Vec::new(),
frac_placeholders: Vec::new(),
has_thousands: false,
scaling: 0,
percent: 0,
exponent: None,
suffix: String::new(),
};
let mut i = 0;
while i < n && !is_num_body_start(chars[i]) {
let mut idx = i;
let literal = read_literal(&chars, &mut idx);
if idx > i {
parsed.prefix.push_str(&literal);
i = idx;
} else {
parsed.prefix.push(chars[i]);
i += 1;
}
}
let mut is_frac = false;
let mut pending_comma = false;
while i < n {
let c = chars[i];
match c {
'0' | '#' => {
if pending_comma {
parsed.has_thousands = true;
pending_comma = false;
}
if is_frac {
parsed.frac_placeholders.push(c);
} else {
parsed.int_placeholders.push(c);
}
i += 1;
}
'.' => {
if pending_comma {
parsed.scaling += 1;
pending_comma = false;
}
if is_frac {
parsed.suffix.push(c);
} else {
is_frac = true;
}
i += 1;
}
',' => {
if parsed.int_placeholders.is_empty() && !is_frac {
parsed.suffix.push(c);
} else {
pending_comma = true;
}
i += 1;
}
'%' => {
if pending_comma {
parsed.scaling += 1;
pending_comma = false;
}
parsed.percent += 1;
i += 1;
}
'E' | 'e' => {
if let Some((letter, forced_plus, digits, next)) = parse_exponent(&chars, i) {
parsed.exponent = Some((letter, forced_plus, digits));
i = next;
} else {
if pending_comma {
parsed.scaling += 1;
pending_comma = false;
}
parsed.suffix.push(c);
i += 1;
}
}
'"' | '\\' => {
if pending_comma {
parsed.scaling += 1;
pending_comma = false;
}
let mut idx = i;
parsed.suffix.push_str(&read_literal(&chars, &mut idx));
i = idx;
}
_ => {
if pending_comma {
parsed.scaling += 1;
pending_comma = false;
}
parsed.suffix.push(c);
i += 1;
}
}
}
if pending_comma {
parsed.scaling += 1;
}
parsed
}
fn round_half_away(x: f64) -> f64 {
(x + 0.5).floor()
}
fn group_thousands(s: &str) -> String {
let bytes: Vec<char> = s.chars().collect();
let len = bytes.len();
let mut out = String::new();
for (i, c) in bytes.iter().enumerate() {
if i > 0 && (len - i).is_multiple_of(3) {
out.push(',');
}
out.push(*c);
}
out
}
fn format_digits(value: f64, parsed: &NumericSection) -> String {
let frac_count = parsed.frac_placeholders.len();
let factor = 10.0f64.powi(frac_count as i32);
let scaled = round_half_away(value * factor);
let scaled_i = scaled as i128;
let factor_i = 10i128.pow(frac_count as u32);
let int_val = scaled_i / factor_i;
let frac_val = (scaled_i % factor_i).unsigned_abs();
let int_digits: Vec<char> = int_val.to_string().chars().collect();
let frac_digits: Vec<char> = if frac_count > 0 {
format!("{:0>width$}", frac_val, width = frac_count)
.chars()
.collect()
} else {
Vec::new()
};
let mut int_out = String::new();
let ph = parsed.int_placeholders.len();
if ph == 0 {
int_out.push('0');
} else if int_digits.len() >= ph {
int_out = int_digits.iter().collect();
} else {
let pad = ph - int_digits.len();
for (k, &p) in parsed.int_placeholders.iter().enumerate() {
if k < pad {
if p == '0' {
int_out.push('0');
}
} else {
int_out.push(int_digits[k - pad]);
}
}
}
if parsed.has_thousands {
int_out = group_thousands(&int_out);
}
let mut frac_out = String::new();
for (k, &p) in parsed.frac_placeholders.iter().enumerate() {
frac_out.push(frac_digits[k]);
if p == '#' && k + 1 == parsed.frac_placeholders.len() {
let _ = ();
}
}
let mut frac_ph = parsed.frac_placeholders.clone();
while let Some(&p) = frac_ph.last() {
if p == '#' && frac_out.ends_with('0') {
frac_out.pop();
frac_ph.pop();
} else {
break;
}
}
let mut body = int_out;
if !frac_out.is_empty() {
body.push('.');
body.push_str(&frac_out);
}
body
}
fn format_exponent_body(
value: f64,
parsed: &NumericSection,
letter: char,
forced_plus: bool,
digits: usize,
) -> String {
let (mantissa, exponent) = if value == 0.0 {
(0.0, 0)
} else {
let e = value.abs().log10().floor() as i32;
(value / 10.0f64.powi(e), e)
};
let mantissa_body = format_digits(mantissa, parsed);
let mut exp_str = String::new();
exp_str.push(letter);
if forced_plus && exponent >= 0 {
exp_str.push('+');
} else if exponent < 0 {
exp_str.push('-');
}
let magnitude = exponent.abs().to_string();
if magnitude.len() < digits {
exp_str.push_str(&"0".repeat(digits - magnitude.len()));
}
exp_str.push_str(&magnitude);
format!("{mantissa_body}{exp_str}")
}
fn format_number_custom(value: f64, format: &str) -> VBResult<String> {
let sections = split_sections(format);
let negative = value < 0.0;
let zero = value == 0.0;
let index = if negative {
if sections.len() > 1 {
1
} else {
0
}
} else if zero && sections.len() > 2 {
2
} else {
0
};
let section = §ions[index.min(sections.len() - 1)];
let parsed = parse_numeric_section(section);
if parsed.int_placeholders.is_empty()
&& parsed.frac_placeholders.is_empty()
&& parsed.exponent.is_none()
{
let mut literal = parsed.prefix;
literal.push_str(&parsed.suffix);
return Ok(literal);
}
let mut v = value.abs();
if parsed.percent > 0 {
v *= 100.0f64.powi(parsed.percent as i32);
}
if parsed.scaling > 0 {
v /= 1000.0f64.powi(parsed.scaling as i32);
}
let body = if let Some((letter, forced_plus, digits)) = parsed.exponent {
format_exponent_body(v, &parsed, letter, forced_plus, digits)
} else {
format_digits(v, &parsed)
};
let mut out = String::new();
out.push_str(&parsed.prefix);
if negative && sections.len() == 1 && !parsed.prefix.contains('-') {
out.push('-');
}
out.push_str(&body);
for _ in 0..parsed.percent {
out.push('%');
}
out.push_str(&parsed.suffix);
Ok(out)
}
pub(crate) fn format_style_number(
value: f64,
decimals: usize,
group: bool,
leading: bool,
parens: bool,
prefix: &str,
suffix: &str,
) -> String {
let decimals = decimals.min(30);
let negative = value < 0.0;
let abs = value.abs();
let factor = 10.0f64.powi(decimals as i32);
let scaled = round_half_away(abs * factor);
let scaled_i = scaled as i128;
let factor_i = 10i128.pow(decimals as u32);
let int_val = scaled_i / factor_i;
let frac_val = (scaled_i % factor_i).unsigned_abs();
let mut int_str = int_val.to_string();
if !leading && int_val == 0 {
int_str.clear();
}
if group {
int_str = group_thousands(&int_str);
}
let frac_str = if decimals > 0 {
format!(".{:0>width$}", frac_val, width = decimals)
} else {
String::new()
};
let body = format!("{prefix}{int_str}{frac_str}{suffix}");
if negative && parens {
format!("({body})")
} else if negative {
format!("-{body}")
} else {
body
}
}
#[derive(Clone, Copy)]
pub(crate) enum NumericStyle {
Number,
Currency,
Percent,
}
impl NumericStyle {
fn prefix(self) -> &'static str {
match self {
NumericStyle::Currency => "$",
_ => "",
}
}
fn suffix(self) -> &'static str {
match self {
NumericStyle::Percent => "%",
_ => "",
}
}
fn scale(self) -> f64 {
match self {
NumericStyle::Percent => 100.0,
_ => 1.0,
}
}
}
fn tristate(v: Option<&VBLong>, default: bool) -> bool {
match v.map(|x| x.as_i32()) {
None | Some(-2) => default,
Some(-1) => true,
_ => false,
}
}
pub(crate) fn format_number_family(
expression: &VBVariant,
numdigitsafterdecimal: Option<&VBLong>,
includeleadingdigit: Option<&VBLong>,
useparensfornegativenumbers: Option<&VBLong>,
groupdigits: Option<&VBLong>,
style: NumericStyle,
) -> VBResult<VBVariant> {
if expression.is_null() {
return Ok(VBVariant::Null);
}
let value = expression.as_f64()?;
let digits = match numdigitsafterdecimal {
None => 2,
Some(v) => {
let n = v.as_i32();
if n == -1 {
2
} else if n < 0 {
return Err(VBError::invalid_procedure_call());
} else {
n
}
}
};
let leading = tristate(includeleadingdigit, true);
let parens = tristate(useparensfornegativenumbers, false);
let grouped = tristate(groupdigits, true);
let result = format_style_number(
value * style.scale(),
digits as usize,
grouped,
leading,
parens,
style.prefix(),
style.suffix(),
);
Ok(VBVariant::from_string(result))
}
fn general_number(value: f64) -> String {
if value.is_finite() && value == value.trunc() && value.abs() < 1.0e15 {
format!("{}", value as i64)
} else {
format!("{}", value)
}
}
const DAY_NAMES: [&str; 7] = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
const MONTH_NAMES: [&str; 12] = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
pub(crate) struct DateParts {
year: i16,
month: u8,
day: u8,
hour: u8,
minute: u8,
second: u8,
weekday: i8,
day_of_year: i16,
}
pub(crate) fn date_parts(serial: f64) -> Option<DateParts> {
let dt = crate::value::date_serial_to_datetime(serial)?;
Some(DateParts {
year: dt.year(),
month: dt.month() as u8,
day: dt.day() as u8,
hour: dt.hour() as u8,
minute: dt.minute() as u8,
second: dt.second() as u8,
weekday: dt.date().weekday().to_sunday_one_offset(),
day_of_year: dt.date().day_of_year(),
})
}
fn hour12(hour: u8) -> u8 {
let r = hour % 12;
if r == 0 {
12
} else {
r
}
}
fn ampm(hour: u8) -> &'static str {
if hour < 12 {
"AM"
} else {
"PM"
}
}
fn pad2(v: i32) -> String {
format!("{v:02}")
}
fn general_date_string(parts: &DateParts) -> String {
let date = format!("{}/{}/{}", parts.month, parts.day, parts.year);
if parts.hour == 0 && parts.minute == 0 && parts.second == 0 {
date
} else {
format!(
"{date} {}:{:02}:{:02} {}",
hour12(parts.hour),
parts.minute,
parts.second,
ampm(parts.hour)
)
}
}
fn vb_weekday(sunday_based: i32, fdow: i32) -> i32 {
((sunday_based - fdow).rem_euclid(7)) + 1
}
fn jan1_weekday(parts: &DateParts) -> i32 {
let offset = (parts.day_of_year as i32 - 1) % 7;
(((parts.weekday as i32 - 1 - offset) % 7) + 7) % 7 + 1
}
fn week_of_year(parts: &DateParts, fdow: i32) -> i32 {
let jan1_wd = vb_weekday(jan1_weekday(parts), fdow);
((parts.day_of_year as i32 - 1 + jan1_wd - 1) / 7) + 1
}
fn has_date_letters(fmt: &str) -> bool {
let mut in_quote = false;
let mut chars = fmt.chars();
while let Some(c) = chars.next() {
match c {
'"' => in_quote = !in_quote,
'\\' => {
chars.next();
}
c if !in_quote && "dmyhnsqwc".contains(c.to_ascii_lowercase()) => return true,
_ => {}
}
}
false
}
fn starts_ci(chars: &[char], i: usize, token: &str) -> bool {
let t: Vec<char> = token.chars().collect();
i + t.len() <= chars.len()
&& t.iter()
.zip(&chars[i..i + t.len()])
.all(|(a, b)| a.eq_ignore_ascii_case(b))
}
fn format_date_custom(serial: f64, format: &str, fdow: i32, _fwoy: i32) -> VBResult<String> {
let parts = date_parts(serial).ok_or_else(VBError::type_mismatch)?;
let chars: Vec<char> = format.chars().collect();
let lower_format = format.to_ascii_lowercase();
let has_ampm = lower_format.contains("am/pm") || lower_format.contains("a/p");
let mut out = String::new();
let mut i = 0;
let mut last_was_hour = false;
while i < chars.len() {
let c = chars[i];
if c == '"' || c == '\\' {
let mut idx = i;
out.push_str(&read_literal(&chars, &mut idx));
i = idx;
continue;
}
let c_lower = c.to_ascii_lowercase();
if (c_lower == 'a' || c_lower == 'p') && starts_ci(&chars, i, "am/pm") {
out.push_str(ampm(parts.hour));
i += 5;
last_was_hour = false;
continue;
}
if (c_lower == 'a' || c_lower == 'p') && starts_ci(&chars, i, "a/p") {
out.push_str(if parts.hour < 12 { "A" } else { "P" });
i += 3;
last_was_hour = false;
continue;
}
let mut j = i;
while j < chars.len() && chars[j].eq_ignore_ascii_case(&c) {
j += 1;
}
let run_len = j - i;
let lower = c.to_ascii_lowercase();
let mut handled = false;
let mut sets_hour = false;
match lower {
'c' if run_len == 1 => {
out.push_str(&general_date_string(&parts));
handled = true;
}
'd' => {
let text = match run_len {
4 => DAY_NAMES[(parts.weekday - 1) as usize].to_string(),
3 => DAY_NAMES[(parts.weekday - 1) as usize][..3].to_string(),
2 => pad2(parts.day as i32),
_ => parts.day.to_string(),
};
out.push_str(&text);
handled = true;
}
'm' => {
let text = if last_was_hour {
match run_len {
2 => pad2(parts.minute as i32),
_ => parts.minute.to_string(),
}
} else {
match run_len {
4 => MONTH_NAMES[(parts.month - 1) as usize].to_string(),
3 => MONTH_NAMES[(parts.month - 1) as usize][..3].to_string(),
2 => pad2(parts.month as i32),
_ => parts.month.to_string(),
}
};
out.push_str(&text);
handled = true;
}
'y' => {
let text = match run_len {
2 => pad2((parts.year % 100) as i32),
_ => parts.year.to_string(),
};
out.push_str(&text);
handled = true;
}
'h' => {
let hour = if has_ampm {
hour12(parts.hour)
} else {
parts.hour
};
let text = if run_len >= 2 {
pad2(hour as i32)
} else {
hour.to_string()
};
out.push_str(&text);
handled = true;
sets_hour = true;
}
'n' => {
let text = if run_len >= 2 {
pad2(parts.minute as i32)
} else {
parts.minute.to_string()
};
out.push_str(&text);
handled = true;
}
's' => {
let text = if run_len >= 2 {
pad2(parts.second as i32)
} else {
parts.second.to_string()
};
out.push_str(&text);
handled = true;
}
'q' => {
out.push_str(&((parts.month as i32 - 1) / 3 + 1).to_string());
handled = true;
}
'w' => {
let text = if run_len >= 2 {
week_of_year(&parts, fdow).to_string()
} else {
vb_weekday(parts.weekday as i32, fdow).to_string()
};
out.push_str(&text);
handled = true;
}
_ => {}
}
if !handled {
out.extend(chars[i..j].iter());
} else {
last_was_hour = sets_hour;
}
i = j;
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::err_number;
fn fmt(expr: VBVariant, format: &str) -> String {
format_dollar(&expr, Some(&VBString::from(format)), None, None)
.unwrap()
.into_inner()
}
fn fmt_opt(expr: VBVariant, format: Option<&str>) -> String {
format_dollar(&expr, format.map(VBString::from).as_ref(), None, None)
.unwrap()
.into_inner()
}
#[test]
fn no_format_converts_like_str() {
assert_eq!(fmt_opt(VBVariant::Long(1234), None), "1234");
assert_eq!(fmt_opt(VBVariant::Long(-1234), None), "-1234");
assert_eq!(fmt_opt(VBVariant::Double(1234.5), None), "1234.5");
assert_eq!(fmt_opt(VBVariant::from_string("hello"), None), "hello");
assert_eq!(fmt_opt(VBVariant::Boolean(true), None), "True");
assert_eq!(
fmt_opt(VBVariant::from_date_serial(45_662.0), None),
"1/5/2025"
);
}
#[test]
fn null_returns_empty_string() {
assert_eq!(fmt_opt(VBVariant::Null, None), "");
assert_eq!(fmt(VBVariant::Null, "0.00"), "");
}
#[test]
fn named_numeric_formats() {
assert_eq!(fmt(VBVariant::Double(1234.5), "General Number"), "1234.5");
assert_eq!(fmt(VBVariant::Double(1234.5), "Currency"), "$1,234.50");
assert_eq!(fmt(VBVariant::Double(-1234.5), "Currency"), "($1,234.50)");
assert_eq!(fmt(VBVariant::Double(1234.5), "Fixed"), "1234.50");
assert_eq!(fmt(VBVariant::Double(1234.5), "Standard"), "1,234.50");
assert_eq!(fmt(VBVariant::Double(0.075), "Percent"), "7.50%");
assert_eq!(
fmt(VBVariant::Double(12_345_678.0), "Scientific"),
"1.23E+07"
);
assert_eq!(fmt(VBVariant::Double(5.0), "Yes/No"), "Yes");
assert_eq!(fmt(VBVariant::Double(0.0), "Yes/No"), "No");
assert_eq!(fmt(VBVariant::Double(5.0), "True/False"), "True");
assert_eq!(fmt(VBVariant::Double(0.0), "On/Off"), "Off");
}
#[test]
fn custom_numeric_formats() {
assert_eq!(fmt(VBVariant::Double(1234.5), "0000.00"), "1234.50");
assert_eq!(fmt(VBVariant::Long(42), "000000"), "000042");
assert_eq!(fmt(VBVariant::Long(7), "00"), "07");
assert_eq!(fmt(VBVariant::Double(0.075), "0.00%"), "7.50%");
assert_eq!(fmt(VBVariant::Double(12_345_678.0), "0.00E+00"), "1.23E+07");
assert_eq!(fmt(VBVariant::Double(0.000_012_3), "0.00E-00"), "1.23E-05");
assert_eq!(
fmt(VBVariant::Double(1234.56), "#,##0.00;(#,##0.00)"),
"1,234.56"
);
assert_eq!(
fmt(VBVariant::Double(-1234.56), "#,##0.00;(#,##0.00)"),
"(1,234.56)"
);
}
#[test]
fn custom_numeric_sections() {
assert_eq!(fmt(VBVariant::Double(1.5), "+0.00;-0.00;Zero"), "+1.50");
assert_eq!(fmt(VBVariant::Double(-1.5), "+0.00;-0.00;Zero"), "-1.50");
assert_eq!(fmt(VBVariant::Double(0.0), "+0.00;-0.00;Zero"), "Zero");
}
#[test]
fn custom_numeric_rounding() {
assert_eq!(fmt(VBVariant::Double(2.5), "0"), "3");
assert_eq!(fmt(VBVariant::Double(2.4), "0"), "2");
assert_eq!(fmt(VBVariant::Double(-2.5), "0.0"), "-2.5");
}
#[test]
fn named_date_formats() {
assert_eq!(
fmt(VBVariant::from_date_serial(45_662.0), "Long Date"),
"Sunday, January 5, 2025"
);
assert_eq!(
fmt(VBVariant::from_date_serial(45_662.0), "Medium Date"),
"05-Jan-25"
);
assert_eq!(
fmt(VBVariant::from_date_serial(45_662.0), "Short Date"),
"1/5/2025"
);
assert_eq!(
fmt(VBVariant::from_date_serial(45_662.656_597_222), "Long Time"),
"3:45:30 PM"
);
assert_eq!(
fmt(
VBVariant::from_date_serial(45_662.656_597_222),
"Medium Time"
),
"03:45 PM"
);
assert_eq!(
fmt(
VBVariant::from_date_serial(45_662.656_597_222),
"Short Time"
),
"15:45"
);
assert_eq!(
fmt(
VBVariant::from_date_serial(45_662.656_597_222),
"General Date"
),
"1/5/2025 3:45:30 PM"
);
}
#[test]
fn custom_date_formats() {
assert_eq!(
fmt(VBVariant::from_date_serial(45_662.0), "yyyy-mm-dd"),
"2025-01-05"
);
assert_eq!(
fmt(VBVariant::from_date_serial(45_662.0), "dddd, mmmm d, yyyy"),
"Sunday, January 5, 2025"
);
assert_eq!(
fmt(VBVariant::from_date_serial(45_662.0), "dd-mmm-yy"),
"05-Jan-25"
);
assert_eq!(
fmt(
VBVariant::from_date_serial(45_662.656_597_222),
"yyyymmdd_hhnnss"
),
"20250105_154530"
);
assert_eq!(
fmt(
VBVariant::from_date_serial(45_662.656_597_222),
"hh:nn:ss AM/PM"
),
"03:45:30 PM"
);
assert_eq!(
fmt(VBVariant::from_date_serial(45_662.656_597_222), "hh:mm:ss"),
"15:45:30"
);
assert_eq!(
fmt(
VBVariant::from_date_serial(45_662.656_597_222),
"mm/dd/yyyy"
),
"01/05/2025"
);
assert_eq!(
fmt(
VBVariant::from_date_serial(45_662.656_597_222),
"h:mm AM/PM"
),
"3:45 PM"
);
}
#[test]
fn m_following_h_is_minutes() {
assert_eq!(
fmt(VBVariant::from_date_serial(45_662.656_597_222), "hh:mm"),
"15:45"
);
assert_eq!(
fmt(VBVariant::from_date_serial(45_662.656_597_222), "mm"),
"01"
);
}
#[test]
fn string_formats() {
assert_eq!(fmt(VBVariant::from_string("john doe"), ">"), "JOHN DOE");
assert_eq!(fmt(VBVariant::from_string("JOHN DOE"), "<"), "john doe");
assert_eq!(
fmt(VBVariant::from_string("5551234567"), "(@@@) @@@-@@@@"),
"(555) 123-4567"
);
assert_eq!(
fmt(VBVariant::from_string("hello"), "@@@@@@@@@@"),
" hello"
);
}
#[test]
fn type_mismatch_for_incompatible_values() {
assert_eq!(
format_dollar(
&VBVariant::from_string("abc"),
Some(&VBString::from("Currency")),
None,
None
)
.unwrap_err()
.number,
err_number::TYPE_MISMATCH
);
assert_eq!(
format_dollar(
&VBVariant::from_string("abc"),
Some(&VBString::from("0.00")),
None,
None
)
.unwrap_err()
.number,
err_number::TYPE_MISMATCH
);
assert_eq!(
format_dollar(
&VBVariant::from_string("abc"),
Some(&VBString::from("yyyy-mm-dd")),
None,
None
)
.unwrap_err()
.number,
err_number::TYPE_MISMATCH
);
}
#[test]
fn empty_format_behaves_like_no_format() {
assert_eq!(fmt(VBVariant::Double(1234.5), ""), "1234.5");
}
}