use std::borrow::Cow;
use std::fmt::Write as _;
use polars::prelude::{AnyValue, DataType};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Grouping {
#[default]
None,
Thousands,
Indian,
}
impl Grouping {
fn separator_count(self, digits: usize) -> usize {
match self {
Grouping::None => 0,
Grouping::Thousands => digits.saturating_sub(1) / 3,
Grouping::Indian => {
if digits <= 3 {
0
} else {
1 + (digits - 4) / 2
}
}
}
}
#[inline]
fn breaks_after(self, digits_emitted: u32) -> bool {
match self {
Grouping::None => false,
Grouping::Thousands => digits_emitted.is_multiple_of(3),
Grouping::Indian => {
digits_emitted == 3 || (digits_emitted > 3 && digits_emitted % 2 == 1)
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct NumberFormat {
pub grouping: Grouping,
pub group_sep: char,
pub decimal_sep: char,
pub floats: bool,
pub float_precision: Option<u8>,
}
impl Default for NumberFormat {
fn default() -> Self {
Self::PLAIN
}
}
impl NumberFormat {
pub const PLAIN: Self = Self {
grouping: Grouping::None,
group_sep: ',',
decimal_sep: '.',
floats: true,
float_precision: None,
};
pub fn preset(name: &str) -> Option<Self> {
let base = Self::PLAIN;
Some(match name {
"none" | "plain" => base,
"thousands" => Self {
grouping: Grouping::Thousands,
group_sep: ',',
decimal_sep: '.',
..base
},
"european" => Self {
grouping: Grouping::Thousands,
group_sep: '.',
decimal_sep: ',',
..base
},
"si" => Self {
grouping: Grouping::Thousands,
group_sep: '\u{202f}', decimal_sep: '.',
..base
},
"swiss" => Self {
grouping: Grouping::Thousands,
group_sep: '\'',
decimal_sep: '.',
..base
},
"indian" => Self {
grouping: Grouping::Indian,
group_sep: ',',
decimal_sep: '.',
..base
},
"underscore" => Self {
grouping: Grouping::Thousands,
group_sep: '_',
decimal_sep: '.',
..base
},
_ => return None,
})
}
pub const CHROME: Self = Self {
grouping: Grouping::Thousands,
group_sep: ',',
decimal_sep: '.',
floats: true,
float_precision: None,
};
pub const PRESET_NAMES: &'static [&'static str] = &[
"none",
"thousands",
"european",
"si",
"swiss",
"indian",
"underscore",
];
pub fn is_noop(&self) -> bool {
self.grouping == Grouping::None && self.decimal_sep == '.' && self.float_precision.is_none()
}
#[inline]
fn groups(&self) -> bool {
self.grouping != Grouping::None
}
pub fn width_i64(&self, v: i64) -> usize {
self.width_u64(v.unsigned_abs()) + usize::from(v < 0)
}
pub fn width_u64(&self, v: u64) -> usize {
let digits = digit_count(v);
let seps = if self.groups() {
self.grouping.separator_count(digits)
} else {
0
};
digits + seps
}
pub fn write_i64(&self, v: i64, out: &mut String) -> usize {
self.write_magnitude(v.unsigned_abs(), v < 0, out)
}
pub fn write_u64(&self, v: u64, out: &mut String) -> usize {
self.write_magnitude(v, false, out)
}
fn write_magnitude(&self, mag: u64, negative: bool, out: &mut String) -> usize {
let mut buf = [0u8; 64];
let mut pos = buf.len();
let mut width = 0usize;
let group = self.groups();
let mut sep_bytes = [0u8; 4];
let sep = self.group_sep.encode_utf8(&mut sep_bytes);
let sep = sep.as_bytes();
let mut n = mag;
let mut emitted: u32 = 0;
loop {
let d = (n % 10) as u8;
n /= 10;
pos -= 1;
buf[pos] = b'0' + d;
emitted += 1;
width += 1;
if n == 0 {
break;
}
if group && self.grouping.breaks_after(emitted) {
pos -= sep.len();
buf[pos..pos + sep.len()].copy_from_slice(sep);
width += 1;
}
}
if negative {
pos -= 1;
buf[pos] = b'-';
width += 1;
}
debug_assert!(std::str::from_utf8(&buf[pos..]).is_ok());
match std::str::from_utf8(&buf[pos..]) {
Ok(s) => {
out.push_str(s);
width
}
Err(_) => 0,
}
}
pub fn write_f64(&self, v: f64, scratch: &mut String, out: &mut String) -> usize {
scratch.clear();
match self.float_precision {
Some(p) => {
let _ = write!(scratch, "{:.*}", p as usize, v);
}
None => {
let _ = write!(scratch, "{}", v);
}
}
self.regroup_decimal(scratch, out)
}
pub fn regroup_decimal(&self, src: &str, out: &mut String) -> usize {
let body = src.strip_prefix('-').unwrap_or(src);
let negative = body.len() != src.len();
let (int_part, frac_part) = match body.find('.') {
Some(i) => (&body[..i], Some(&body[i + 1..])),
None => (body, None),
};
let plain = !int_part.is_empty()
&& int_part.bytes().all(|b| b.is_ascii_digit())
&& frac_part.is_none_or(|f| f.bytes().all(|b| b.is_ascii_digit()));
if !plain {
out.push_str(src);
return src.chars().count();
}
let mut width = 0usize;
if negative {
out.push('-');
width += 1;
}
let digits = int_part.len();
if self.groups() {
for (i, ch) in int_part.chars().enumerate() {
let remaining = (digits - i) as u32;
if i > 0 && self.grouping.breaks_after(remaining) {
out.push(self.group_sep);
width += 1;
}
out.push(ch);
width += 1;
}
} else {
out.push_str(int_part);
width += digits;
}
if let Some(frac) = frac_part {
out.push(self.decimal_sep);
width += 1 + frac.len();
out.push_str(frac);
}
width
}
}
pub fn group_chrome(n: usize) -> String {
let mut out = String::new();
NumberFormat::CHROME.write_u64(n as u64, &mut out);
out
}
#[inline]
fn digit_count(n: u64) -> usize {
n.checked_ilog10().map_or(0, |l| l as usize) + 1
}
#[derive(Debug, Clone, PartialEq)]
pub enum CellFormatter {
Passthrough,
Number(NumberFormat),
}
impl CellFormatter {
#[inline]
pub fn is_passthrough(&self) -> bool {
matches!(self, CellFormatter::Passthrough)
}
}
pub fn is_numeric_dtype(dtype: &DataType) -> bool {
matches!(
dtype,
DataType::Int8
| DataType::Int16
| DataType::Int32
| DataType::Int64
| DataType::UInt8
| DataType::UInt16
| DataType::UInt32
| DataType::UInt64
| DataType::Float32
| DataType::Float64
)
}
pub fn is_right_aligned_dtype(dtype: &DataType) -> bool {
is_numeric_dtype(dtype)
}
#[derive(Debug, Clone)]
pub struct NumberFormatSettings {
pub format: NumberFormat,
pub enabled: bool,
pub exclude: Vec<Glob>,
pub align_numeric_right: bool,
}
impl Default for NumberFormatSettings {
fn default() -> Self {
Self {
format: NumberFormat::PLAIN,
enabled: true,
exclude: Vec::new(),
align_numeric_right: true,
}
}
}
impl NumberFormatSettings {
pub fn formatter_for(&self, col_name: &str, dtype: &DataType) -> CellFormatter {
if !self.enabled || self.format.is_noop() || !is_numeric_dtype(dtype) {
return CellFormatter::Passthrough;
}
if self.exclude.iter().any(|g| g.matches(col_name)) {
return CellFormatter::Passthrough;
}
let mut fmt = self.format.clone();
if !fmt.floats && matches!(dtype, DataType::Float32 | DataType::Float64) {
fmt.grouping = Grouping::None;
if fmt.is_noop() {
return CellFormatter::Passthrough;
}
}
CellFormatter::Number(fmt)
}
}
pub fn format_any_value<'v>(
fmt: &CellFormatter,
value: &'v AnyValue<'v>,
scratch: &mut String,
) -> Cow<'v, str> {
if matches!(value, AnyValue::Null) {
return Cow::Borrowed("");
}
let nf = match fmt {
CellFormatter::Passthrough => return value.str_value(),
CellFormatter::Number(nf) => nf,
};
let mut out = String::new();
match *value {
AnyValue::Int8(v) => nf.write_i64(v as i64, &mut out),
AnyValue::Int16(v) => nf.write_i64(v as i64, &mut out),
AnyValue::Int32(v) => nf.write_i64(v as i64, &mut out),
AnyValue::Int64(v) => nf.write_i64(v, &mut out),
AnyValue::UInt8(v) => nf.write_u64(v as u64, &mut out),
AnyValue::UInt16(v) => nf.write_u64(v as u64, &mut out),
AnyValue::UInt32(v) => nf.write_u64(v as u64, &mut out),
AnyValue::UInt64(v) => nf.write_u64(v, &mut out),
AnyValue::Float32(f) => match nf.float_precision {
Some(_) => nf.write_f64(f as f64, scratch, &mut out),
None => nf.regroup_decimal(&value.str_value(), &mut out),
},
AnyValue::Float64(f) => match nf.float_precision {
Some(_) => nf.write_f64(f, scratch, &mut out),
None => nf.regroup_decimal(&value.str_value(), &mut out),
},
_ => return value.str_value(),
};
Cow::Owned(out)
}
pub fn display_width(fmt: &CellFormatter, value: &AnyValue, scratch: &mut String) -> usize {
if matches!(value, AnyValue::Null) {
return 0;
}
if let CellFormatter::Number(nf) = fmt {
match *value {
AnyValue::Int8(v) => return nf.width_i64(v as i64),
AnyValue::Int16(v) => return nf.width_i64(v as i64),
AnyValue::Int32(v) => return nf.width_i64(v as i64),
AnyValue::Int64(v) => return nf.width_i64(v),
AnyValue::UInt8(v) => return nf.width_u64(v as u64),
AnyValue::UInt16(v) => return nf.width_u64(v as u64),
AnyValue::UInt32(v) => return nf.width_u64(v as u64),
AnyValue::UInt64(v) => return nf.width_u64(v),
_ => {}
}
}
format_any_value(fmt, value, scratch).chars().count()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Glob {
pattern: String,
has_wildcard: bool,
}
impl Glob {
pub fn new(pattern: impl Into<String>) -> Self {
let pattern = pattern.into();
let has_wildcard = pattern.contains('*') || pattern.contains('?');
Self {
pattern,
has_wildcard,
}
}
pub fn matches(&self, name: &str) -> bool {
if !self.has_wildcard {
return self.pattern == name;
}
let p: Vec<char> = self.pattern.chars().collect();
let n: Vec<char> = name.chars().collect();
let (mut pi, mut ni) = (0usize, 0usize);
let (mut star, mut mark) = (usize::MAX, 0usize);
while ni < n.len() {
if pi < p.len() && (p[pi] == '?' || p[pi] == n[ni]) {
pi += 1;
ni += 1;
} else if pi < p.len() && p[pi] == '*' {
star = pi;
mark = ni;
pi += 1;
} else if star != usize::MAX {
pi = star + 1;
mark += 1;
ni = mark;
} else {
return false;
}
}
while pi < p.len() && p[pi] == '*' {
pi += 1;
}
pi == p.len()
}
}
pub fn preset_for_locale_tag(tag: &str) -> &'static str {
let base = tag
.split(['.', '@'])
.next()
.unwrap_or(tag)
.replace('_', "-");
let lower = base.to_ascii_lowercase();
let lang = lower.split('-').next().unwrap_or(&lower);
let region = lower.split('-').nth(1).unwrap_or("");
if region == "ch" {
return "swiss";
}
match lang {
"de" | "es" | "it" | "pt" | "nl" | "id" | "tr" | "da" | "el" | "ro" | "ca" | "vi"
| "sl" | "hr" | "sr" | "is" => "european",
"fr" | "nb" | "no" | "sv" | "fi" | "cs" | "sk" | "pl" | "ru" | "uk" | "hu" | "lv"
| "lt" | "et" | "bg" => "si",
"hi" | "bn" | "ta" | "te" | "mr" | "gu" | "kn" | "ml" | "pa" | "or" | "as" | "ne" => {
"indian"
}
_ => "thousands",
}
}
pub fn system_locale_tag() -> Option<String> {
for var in ["LC_ALL", "LC_NUMERIC", "LANG"] {
if let Ok(v) = std::env::var(var) {
let v = v.trim();
if v.is_empty() {
continue;
}
if v == "C" || v == "POSIX" || v.starts_with("C.") {
return None;
}
return Some(v.to_string());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn fmt_i64(nf: &NumberFormat, v: i64) -> String {
let mut s = String::new();
let w = nf.write_i64(v, &mut s);
assert_eq!(w, s.chars().count(), "reported width disagrees with output");
assert_eq!(w, nf.width_i64(v), "width_i64 disagrees with write_i64");
s
}
fn thousands() -> NumberFormat {
NumberFormat::preset("thousands").unwrap()
}
#[test]
fn digit_count_basics() {
assert_eq!(digit_count(0), 1);
assert_eq!(digit_count(9), 1);
assert_eq!(digit_count(10), 2);
assert_eq!(digit_count(999), 3);
assert_eq!(digit_count(1000), 4);
assert_eq!(digit_count(u64::MAX), 20);
}
#[test]
fn every_value_in_a_formatted_column_is_grouped() {
let nf = thousands();
assert_eq!(fmt_i64(&nf, 0), "0");
assert_eq!(fmt_i64(&nf, 999), "999");
assert_eq!(fmt_i64(&nf, 1000), "1,000");
assert_eq!(fmt_i64(&nf, 2024), "2,024");
assert_eq!(fmt_i64(&nf, 9999), "9,999");
assert_eq!(fmt_i64(&nf, 10000), "10,000");
assert_eq!(fmt_i64(&nf, 1234567), "1,234,567");
}
#[test]
fn negatives_and_extremes() {
let nf = thousands();
assert_eq!(fmt_i64(&nf, -1234567), "-1,234,567");
assert_eq!(fmt_i64(&nf, -999), "-999");
assert_eq!(fmt_i64(&nf, i64::MIN), "-9,223,372,036,854,775,808");
assert_eq!(fmt_i64(&nf, i64::MAX), "9,223,372,036,854,775,807");
let mut s = String::new();
let w = nf.write_u64(u64::MAX, &mut s);
assert_eq!(s, "18,446,744,073,709,551,615");
assert_eq!(w, s.chars().count());
assert_eq!(w, nf.width_u64(u64::MAX));
}
#[test]
fn bed_style_coordinates() {
let nf = thousands();
assert_eq!(fmt_i64(&nf, 248_956_422), "248,956,422");
assert_eq!(fmt_i64(&nf, 3_088_269_832), "3,088,269,832");
}
#[test]
fn indian_grouping() {
let nf = NumberFormat::preset("indian").unwrap();
assert_eq!(fmt_i64(&nf, 100), "100");
assert_eq!(fmt_i64(&nf, 1000), "1,000");
assert_eq!(fmt_i64(&nf, 12345), "12,345");
assert_eq!(fmt_i64(&nf, 123456), "1,23,456");
assert_eq!(fmt_i64(&nf, 1234567), "12,34,567");
assert_eq!(fmt_i64(&nf, 12345678), "1,23,45,678");
assert_eq!(fmt_i64(&nf, -12345678), "-1,23,45,678");
}
#[test]
fn all_presets_render() {
let cases = [
("none", "1234567"),
("thousands", "1,234,567"),
("european", "1.234.567"),
("si", "1\u{202f}234\u{202f}567"),
("swiss", "1'234'567"),
("indian", "12,34,567"),
("underscore", "1_234_567"),
];
for (name, expected) in cases {
let nf = NumberFormat::preset(name).unwrap();
assert_eq!(fmt_i64(&nf, 1234567), expected, "preset {name}");
}
assert!(NumberFormat::preset("klingon").is_none());
for name in NumberFormat::PRESET_NAMES {
assert!(NumberFormat::preset(name).is_some(), "preset {name}");
}
}
#[test]
fn width_matches_rendered_length_across_range() {
for nf in NumberFormat::PRESET_NAMES
.iter()
.map(|n| NumberFormat::preset(n).unwrap())
{
let mut v: i64 = 1;
for _ in 0..19 {
for probe in [v, v - 1, -v, v * 3 / 2] {
let mut s = String::new();
let w = nf.write_i64(probe, &mut s);
assert_eq!(w, s.chars().count(), "{:?} on {probe}", nf.grouping);
assert_eq!(w, nf.width_i64(probe), "{:?} on {probe}", nf.grouping);
}
v = v.saturating_mul(10);
}
}
}
#[test]
fn floats_regroup_integer_part_only() {
let nf = thousands();
let mut s = String::new();
let w = nf.regroup_decimal("1234567.891", &mut s);
assert_eq!(s, "1,234,567.891");
assert_eq!(w, s.chars().count());
s.clear();
nf.regroup_decimal("-1234.5", &mut s);
assert_eq!(s, "-1,234.5");
}
#[test]
fn european_swaps_decimal_separator() {
let nf = NumberFormat::preset("european").unwrap();
let mut s = String::new();
let w = nf.regroup_decimal("1234567.89", &mut s);
assert_eq!(s, "1.234.567,89");
assert_eq!(w, s.chars().count());
}
#[test]
fn non_decimal_strings_pass_through_untouched() {
let nf = thousands();
for src in ["NaN", "inf", "-inf", "1e300", "1.5e-8", ""] {
let mut s = String::new();
let w = nf.regroup_decimal(src, &mut s);
assert_eq!(s, src, "{src} should pass through");
assert_eq!(w, src.chars().count());
}
}
#[test]
fn float_precision_is_applied() {
let nf = NumberFormat {
float_precision: Some(2),
..thousands()
};
let (mut scratch, mut out) = (String::new(), String::new());
let w = nf.write_f64(1234.5678, &mut scratch, &mut out);
assert_eq!(out, "1,234.57");
assert_eq!(w, out.chars().count());
out.clear();
nf.write_f64(-0.5, &mut scratch, &mut out);
assert_eq!(out, "-0.50");
}
#[test]
fn is_noop_detects_the_free_path() {
assert!(NumberFormat::PLAIN.is_noop());
assert!(!thousands().is_noop());
assert!(!NumberFormat {
float_precision: Some(2),
..NumberFormat::PLAIN
}
.is_noop());
assert!(!NumberFormat {
decimal_sep: ',',
..NumberFormat::PLAIN
}
.is_noop());
}
#[test]
fn chrome_grouping_is_unconditional() {
assert_eq!(group_chrome(0), "0");
assert_eq!(group_chrome(999), "999");
assert_eq!(group_chrome(1234), "1,234");
assert_eq!(group_chrome(1_234_567), "1,234,567");
assert_eq!(group_chrome(usize::MAX), "18,446,744,073,709,551,615");
}
#[test]
fn glob_matching() {
assert!(Glob::new("year").matches("year"));
assert!(!Glob::new("year").matches("years"));
assert!(Glob::new("*_id").matches("sample_id"));
assert!(Glob::new("*_id").matches("_id"));
assert!(!Glob::new("*_id").matches("id_sample"));
assert!(Glob::new("chrom*").matches("chromStart"));
assert!(Glob::new("*").matches("anything"));
assert!(Glob::new("c?rom").matches("chrom"));
assert!(!Glob::new("c?rom").matches("chhrom"));
assert!(Glob::new("a*b*c").matches("axxbyyc"));
assert!(!Glob::new("a*b*c").matches("axxbyy"));
}
#[test]
fn settings_resolve_per_column() {
let settings = NumberFormatSettings {
format: thousands(),
enabled: true,
exclude: vec![Glob::new("*_id"), Glob::new("year")],
align_numeric_right: true,
};
assert!(!settings
.formatter_for("chromStart", &DataType::Int64)
.is_passthrough());
assert!(settings
.formatter_for("chrom", &DataType::String)
.is_passthrough());
assert!(settings
.formatter_for("when", &DataType::Date)
.is_passthrough());
assert!(settings
.formatter_for("sample_id", &DataType::Int64)
.is_passthrough());
assert!(settings
.formatter_for("year", &DataType::Int32)
.is_passthrough());
}
#[test]
fn settings_disabled_is_all_passthrough() {
let settings = NumberFormatSettings {
format: thousands(),
enabled: false,
..Default::default()
};
assert!(settings
.formatter_for("chromStart", &DataType::Int64)
.is_passthrough());
}
#[test]
fn plain_format_is_always_passthrough() {
let settings = NumberFormatSettings::default();
assert!(settings
.formatter_for("chromStart", &DataType::Int64)
.is_passthrough());
}
#[test]
fn floats_flag_disables_grouping_for_floats_only() {
let settings = NumberFormatSettings {
format: NumberFormat {
floats: false,
..thousands()
},
..Default::default()
};
assert!(!settings
.formatter_for("count", &DataType::Int64)
.is_passthrough());
assert!(settings
.formatter_for("ratio", &DataType::Float64)
.is_passthrough());
}
#[test]
fn any_value_formatting_and_width_agree() {
let fmt = CellFormatter::Number(thousands());
let mut scratch = String::new();
let cases: Vec<AnyValue> = vec![
AnyValue::Int32(1234567),
AnyValue::Int64(-9876543),
AnyValue::UInt32(4000000),
AnyValue::UInt64(u64::MAX),
AnyValue::Int8(-12),
];
for v in cases {
let s = format_any_value(&fmt, &v, &mut scratch).into_owned();
assert_eq!(
display_width(&fmt, &v, &mut scratch),
s.chars().count(),
"width mismatch for {v:?} -> {s}"
);
}
assert_eq!(
format_any_value(&fmt, &AnyValue::Int32(1234567), &mut scratch),
"1,234,567"
);
}
#[test]
fn nulls_and_strings_are_untouched() {
let fmt = CellFormatter::Number(thousands());
let mut scratch = String::new();
assert_eq!(format_any_value(&fmt, &AnyValue::Null, &mut scratch), "");
assert_eq!(
format_any_value(&fmt, &AnyValue::String("chr1"), &mut scratch),
"chr1"
);
assert_eq!(
format_any_value(
&CellFormatter::Passthrough,
&AnyValue::Int64(1234567),
&mut scratch
),
"1234567"
);
}
#[test]
fn number_format_values_match_presets() {
let mut cli: Vec<&str> = datui_cli::NUMBER_FORMAT_VALUES.to_vec();
let mut expected: Vec<&str> = NumberFormat::PRESET_NAMES.to_vec();
expected.push("system");
cli.sort_unstable();
expected.sort_unstable();
assert_eq!(
cli, expected,
"datui_cli::NUMBER_FORMAT_VALUES is out of sync with NumberFormat::PRESET_NAMES"
);
}
#[test]
fn locale_tags_map_to_presets() {
assert_eq!(preset_for_locale_tag("en_US.UTF-8"), "thousands");
assert_eq!(preset_for_locale_tag("de_DE.UTF-8"), "european");
assert_eq!(preset_for_locale_tag("de_DE.UTF-8@euro"), "european");
assert_eq!(preset_for_locale_tag("fr_FR"), "si");
assert_eq!(preset_for_locale_tag("hi_IN"), "indian");
assert_eq!(preset_for_locale_tag("de_CH"), "swiss");
assert_eq!(preset_for_locale_tag("it-CH"), "swiss");
assert_eq!(preset_for_locale_tag("ja_JP"), "thousands");
assert_eq!(preset_for_locale_tag("xx_YY"), "thousands");
}
}