use std::collections::BTreeMap;
const SI_BASE: &[&str] = &["m", "kg", "s", "A", "K", "mol", "cd"];
const SI_DERIVED: &[&str] = &[
"rad", "sr", "Hz", "N", "Pa", "J", "W", "C", "V", "F", "ohm", "S", "Wb", "T", "H", "lm", "lx",
"Bq", "Gy", "Sv", "kat",
];
const NON_SI: &[&str] = &[
"min", "h", "d", "t", "L", "in", "ft", "yd", "mi", "nmi", "lb", "oz", "gal",
];
const PREFIXES: &[&str] = &[
"da", "Y", "Z", "E", "P", "T", "G", "M", "k", "h", "d", "c", "m", "u", "n", "p", "f", "a", "z",
"y",
];
fn prefixable(stem: &str) -> bool {
stem == "g"
|| stem == "L"
|| SI_DERIVED.contains(&stem)
|| (SI_BASE.contains(&stem) && stem != "kg")
}
pub fn builtin(name: &str) -> bool {
if name.starts_with('~') {
return false;
}
if name == "g"
|| SI_BASE.contains(&name)
|| SI_DERIVED.contains(&name)
|| NON_SI.contains(&name)
{
return true;
}
PREFIXES
.iter()
.any(|p| name.strip_prefix(p).is_some_and(prefixable))
}
pub fn members_ok(expr: &str, customs: &std::collections::BTreeSet<String>) -> bool {
let Some(names) = factor_names(expr) else {
return false;
};
names
.iter()
.all(|n| n == "1" || n.starts_with('~') || builtin(n) || customs.contains(n))
}
fn factor_names(expr: &str) -> Option<Vec<String>> {
canonicalize(expr)?;
let mut names = Vec::new();
for part in expr.split(['*', '/']) {
let name = part.split('^').next().unwrap_or(part);
names.push(name.to_string());
}
Some(names)
}
pub fn canonicalize(expr: &str) -> Option<String> {
if expr.is_empty() {
return None;
}
let cs: Vec<char> = expr.chars().collect();
let mut i = 0;
let mut exps: BTreeMap<String, i64> = BTreeMap::new();
let mut op = '*';
loop {
let name = if cs.get(i) == Some(&'1') {
i += 1;
"1".to_string()
} else {
let start = i;
if cs.get(i) == Some(&'~') {
i += 1;
}
while i < cs.len() && cs[i].is_ascii_alphabetic() {
i += 1;
}
if i == start || (cs[start] == '~' && i == start + 1) {
return None;
}
let name: String = cs[start..i].iter().collect();
if let Some(code) = name.strip_prefix('~') {
if code.len() != 3 || !code.bytes().all(|b| b.is_ascii_uppercase()) {
return None;
}
}
name
};
let mut exp: i64 = 1;
if cs.get(i) == Some(&'^') {
i += 1;
let neg = cs.get(i) == Some(&'-');
if neg {
i += 1;
}
let start = i;
while i < cs.len() && cs[i].is_ascii_digit() {
i += 1;
}
if i == start {
return None;
}
let n: i64 = cs[start..i].iter().collect::<String>().parse().ok()?;
if n == 0 {
return None;
}
exp = if neg { -n } else { n };
}
if name != "1" {
let signed = if op == '*' { exp } else { -exp };
*exps.entry(name).or_insert(0) += signed;
}
if i >= cs.len() {
break;
}
op = cs[i];
if op != '*' && op != '/' {
return None;
}
i += 1;
}
let fmt = |name: &str, e: i64| {
if e == 1 {
name.to_string()
} else {
format!("{name}^{e}")
}
};
let num: Vec<String> = exps
.iter()
.filter(|(_, &e)| e > 0)
.map(|(n, &e)| fmt(n, e))
.collect();
let den: Vec<String> = exps
.iter()
.filter(|(_, &e)| e < 0)
.map(|(n, &e)| fmt(n, -e))
.collect();
let mut out = if num.is_empty() {
"1".to_string()
} else {
num.join("*")
};
for d in den {
out.push('/');
out.push_str(&d);
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::canonicalize as c;
#[test]
fn spec_table() {
assert_eq!(c("m*kg/s^2").as_deref(), Some("kg*m/s^2"));
assert_eq!(c("s*A").as_deref(), Some("A*s"));
assert_eq!(c("m/s/s").as_deref(), Some("m/s^2"));
assert_eq!(c("m/s*kg").as_deref(), Some("kg*m/s"));
assert_eq!(c("kg*m^2/A/s^3").as_deref(), Some("kg*m^2/A/s^3"));
assert_eq!(c("N/s/m^2").as_deref(), Some("N/m^2/s"));
assert_eq!(c("m*s^-1").as_deref(), Some("m/s"));
assert_eq!(c("s^-1").as_deref(), Some("1/s"));
assert_eq!(c("m/m").as_deref(), Some("1"));
assert_eq!(c("kg*s/kg/s").as_deref(), Some("1"));
assert_eq!(c("m/m^2").as_deref(), Some("1/m"));
assert_eq!(c("1/s").as_deref(), Some("1/s"));
assert_eq!(c("km").as_deref(), Some("km"));
assert_eq!(c("~EUR").as_deref(), Some("~EUR"));
assert_eq!(c("~USD/h").as_deref(), Some("~USD/h"));
}
#[test]
fn membership() {
use super::members_ok;
use std::collections::BTreeSet;
let none = BTreeSet::new();
for u in [
"m", "kg", "ohm", "Pa", "km", "mA", "MHz", "mL", "mg", "ft", "min", "g",
] {
assert!(members_ok(u, &none), "{u} should be built-in");
}
for u in ["xyz", "kft", "ums"] {
assert!(!members_ok(u, &none), "{u} should be rejected");
}
for u in ["k~USD", "~usd", "~EURO", "~EU"] {
assert!(c(u).is_none(), "{u} should fail the grammar");
}
assert!(members_ok("~EUR/kg", &none));
assert_eq!(c("~EUR/kg").as_deref(), Some("~EUR/kg"));
let customs: BTreeSet<String> = ["au".to_string()].into();
assert!(members_ok("au/s", &customs));
assert!(!members_ok("au/s", &none));
}
}