diff --git a/src/evaluator.rs b/src/evaluator.rs index 6212469..7c470c8 100644 --- a/src/evaluator.rs +++ b/src/evaluator.rs @@ -279,6 +279,9 @@ fn evaluate_node(ast_node: &AstNode) -> Result { mod tests { use crate::eval; use super::*; + use crate::currency; + use crate::extra_units; + use crate::units::UnitType; fn eval_default<'a>(input: &'a str) -> Number { let result = eval(input, true, false).unwrap(); @@ -336,4 +339,57 @@ mod tests { assert_eq!(eval_num("sin(2)"), "0.9092974268256816953960198659117448427"); assert_eq!(eval_num("sin(-2)"), "-0.9092974268256816953960198659117448427"); } + + #[test] + fn test_divide_currency_and_mass_by_volume() { + // Ensure a clean registry so the IDs are deterministic + extra_units::clear_for_tests(); + + // Minimal rates (USD base). 1 USD = 7.07 CNY + let json = r#"{"base":"USD","rates":{"USD":1,"CNY":7.07}}"#; + currency::register_currencies_from_exchange_rate_api_json(json).unwrap(); + + let x = eval_default("5.55 usd / 64 fl oz"); + assert_eq!(x.unit.category(), UnitType::CurrencyPerVolume); + assert_eq!(x.unit.singular(), "USD/floz"); + + let y = eval_default("5.55 g / 64 fl oz"); + assert_eq!(y.unit.category(), UnitType::MassPerVolume); + assert_eq!(y.unit.singular(), "g/floz"); + + // Conversion should work on the derived units: + let z = eval_default("5.55 usd / 64 fl oz to cny/l"); + assert_eq!(z.unit.singular(), "CNY/l"); + + let w = eval_default("5.55 g / 64 fl oz to g/ml"); + assert_eq!(w.unit.singular(), "g/ml"); + } + + #[test] + fn test_multiply_cancels_derived_units() { + extra_units::clear_for_tests(); + + let json = r#"{"base":"USD","rates":{"USD":1,"CNY":7.07}}"#; + currency::register_currencies_from_exchange_rate_api_json(json).unwrap(); + + // Your example: (USD/L) * floz -> USD + let out = eval_default("(1.48-1/3) usd / 1.25 l * 144 fl oz"); + assert_eq!(out.unit.category(), UnitType::Currency); + assert_eq!(out.unit.singular(), "USD"); + assert!(!out.value.is_nan()); + + let assert_close = |actual: D128, expected: D128| { + // Derived unit weights use division, so allow tiny rounding differences. + assert!((actual - expected).abs() < d!(1e-24)); + }; + + // Cancellation sanity checks + let usd_back = eval_default("5.55 usd / 64 fl oz * 64 fl oz"); + assert_eq!(usd_back.unit.singular(), "USD"); + assert_close(usd_back.value, d!(5.55)); + + let g_back = eval_default("5.55 g / 64 fl oz * 64 fl oz"); + assert_eq!(g_back.unit.singular(), "gram"); + assert_close(g_back.value, d!(5.55)); + } } diff --git a/src/lexer.rs b/src/lexer.rs index 374aa81..5157ba8 100644 --- a/src/lexer.rs +++ b/src/lexer.rs @@ -909,7 +909,10 @@ pub fn lex(input: &str, remove_trailing_operator: bool) -> Result, St (Token::LexerKeyword(Revolution), Token::LexerKeyword(Per), Token::Unit(Minute)) => { tokens[token_index-2] = Token::Unit(RevolutionsPerMinute); }, - // currency per {mass|volume|length|area}, for example usd/lb, cny/kg, usd/l, usd/m, usd/m2 + // composite units: + // - currency per {mass|volume|length|area} (usd/lb, cny/kg, usd/l, usd/m, usd/m2) + // - mass per volume (g/ml, kg/l, lb/gal) + // - volume per mass (ml/g, l/kg) (Token::Unit(left_u), Token::LexerKeyword(Per), Token::Unit(right_u)) => { let left_cat = left_u.category(); let right_cat = right_u.category(); @@ -1002,6 +1005,86 @@ pub fn lex(input: &str, remove_trailing_operator: bool) -> Result, St weight, ); tokens[token_index-2] = Token::Unit(composite); + } else if left_cat == UnitType::Mass && right_cat == UnitType::Volume { + let mass_abbrev: &str = match *left_u { + Milligram => "mg", + Gram => "g", + Hectogram => "hg", + Kilogram => "kg", + MetricTon => "t", + Ounce => "oz", + Pound => "lb", + Stone => "st", + ShortTon => "ston", + LongTon => "lton", + _ => left_u.singular(), + }; + let vol_abbrev: &str = match *right_u { + Milliliter => "ml", + Centiliter => "cl", + Deciliter => "dl", + Liter => "l", + Teaspoon => "tsp", + Tablespoon => "tbsp", + FluidOunce => "floz", + Cup => "cup", + Pint => "pt", + Quart => "qt", + Gallon => "gal", + OilBarrel => "bbl", + _ => right_u.singular(), + }; + let display = format!("{}/{}", mass_abbrev, vol_abbrev); + let key = format!("{mass_abbrev}_per_{vol_abbrev}"); + let weight = left_u.weight() / right_u.weight(); // grams per cubic-mm + let composite = extra_units::upsert_unit( + &key, + &display, + &display, + UnitType::MassPerVolume, + weight, + ); + tokens[token_index-2] = Token::Unit(composite); + } else if left_cat == UnitType::Volume && right_cat == UnitType::Mass { + let vol_abbrev: &str = match *left_u { + Milliliter => "ml", + Centiliter => "cl", + Deciliter => "dl", + Liter => "l", + Teaspoon => "tsp", + Tablespoon => "tbsp", + FluidOunce => "floz", + Cup => "cup", + Pint => "pt", + Quart => "qt", + Gallon => "gal", + OilBarrel => "bbl", + _ => left_u.singular(), + }; + let mass_abbrev: &str = match *right_u { + Milligram => "mg", + Gram => "g", + Hectogram => "hg", + Kilogram => "kg", + MetricTon => "t", + Ounce => "oz", + Pound => "lb", + Stone => "st", + ShortTon => "ston", + LongTon => "lton", + _ => right_u.singular(), + }; + let display = format!("{}/{}", vol_abbrev, mass_abbrev); + let key = format!("{vol_abbrev}_per_{mass_abbrev}"); + let weight = left_u.weight() / right_u.weight(); // cubic-mm per gram + let composite = extra_units::upsert_unit( + &key, + &display, + &display, + UnitType::VolumePerMass, + weight, + ); + tokens[token_index-2] = Token::Unit(composite); } else { replaced = false; } diff --git a/src/units.rs b/src/units.rs index 0b1968b..cf2f106 100644 --- a/src/units.rs +++ b/src/units.rs @@ -50,6 +50,10 @@ pub enum UnitType { CurrencyPerLength, /// A currency-per-area unit, for example USD/m2 or EUR/ft2 CurrencyPerArea, + /// A mass-per-volume unit (density / concentration), e.g. g/ml + MassPerVolume, + /// A volume-per-mass unit (specific volume), e.g. ml/g + VolumePerMass, } use UnitType::*; @@ -566,6 +570,49 @@ pub fn multiply(left: Number, right: Number) -> Result { actual_multiply(left, right, false) } +fn parse_currency_numerator(unit: Unit) -> Option { + let code_lower = unit.singular().split('/').next()?.to_ascii_lowercase(); + extra_units::lookup_unit(&code_lower) +} + +fn parse_mass_numerator(unit: Unit) -> Option { + let s = unit.singular().split('/').next()?.to_ascii_lowercase(); + let u = match s.as_str() { + "mg" => Milligram, + "g" => Gram, + "hg" => Hectogram, + "kg" => Kilogram, + "t" => MetricTon, + "oz" => Ounce, + "lb" => Pound, + "st" => Stone, + "ston" => ShortTon, + "lton" => LongTon, + _ => return None, + }; + Some(u) +} + +fn parse_volume_numerator(unit: Unit) -> Option { + let s = unit.singular().split('/').next()?.to_ascii_lowercase(); + let u = match s.as_str() { + "ml" => Milliliter, + "cl" => Centiliter, + "dl" => Deciliter, + "l" => Liter, + "tsp" => Teaspoon, + "tbsp" => Tablespoon, + "floz" => FluidOunce, + "cup" => Cup, + "pt" => Pint, + "qt" => Quart, + "gal" => Gallon, + "bbl" => OilBarrel, + _ => return None, + }; + Some(u) +} + fn actual_multiply(left: Number, right: Number, swapped: bool) -> Result { let lcat = left.unit.category(); let rcat = right.unit.category(); @@ -645,6 +692,81 @@ fn actual_multiply(left: Number, right: Number, swapped: bool) -> Result Result { let bits_per_second = convert(right, BitsPerSecond)?; let seconds = Number::new(bits.value / bits_per_second.value, Second); Ok(to_ideal_unit(seconds)) + } else if lcat == Currency && rcat == Volume { + // 5 usd / 64 floz => usd/floz (convertible to usd/l, cny/l, etc) + let cur_upper = left.unit.singular().to_ascii_uppercase(); + let cur_lower = cur_upper.to_ascii_lowercase(); + let denom_abbrev: &str = match right.unit { + Milliliter => "ml", + Centiliter => "cl", + Deciliter => "dl", + Liter => "l", + Teaspoon => "tsp", + Tablespoon => "tbsp", + FluidOunce => "floz", + Cup => "cup", + Pint => "pt", + Quart => "qt", + Gallon => "gal", + OilBarrel => "bbl", + _ => right.unit.singular(), + }; + let display = format!("{}/{}", cur_upper, denom_abbrev); + let key = format!("{cur_lower}_per_{denom_abbrev}"); + let composite = crate::extra_units::upsert_unit( + &key, + &display, + &display, + CurrencyPerVolume, + left.unit.weight() / right.unit.weight(), + ); + Ok(Number::new(left.value / right.value, composite)) + } else if lcat == Currency && rcat == Mass { + // usd/kg etc + let cur_upper = left.unit.singular().to_ascii_uppercase(); + let cur_lower = cur_upper.to_ascii_lowercase(); + let denom_abbrev: &str = match right.unit { + Milligram => "mg", + Gram => "g", + Hectogram => "hg", + Kilogram => "kg", + MetricTon => "t", + Ounce => "oz", + Pound => "lb", + Stone => "st", + ShortTon => "ston", + LongTon => "lton", + _ => right.unit.singular(), + }; + let display = format!("{}/{}", cur_upper, denom_abbrev); + let key = format!("{cur_lower}_per_{denom_abbrev}"); + let composite = crate::extra_units::upsert_unit( + &key, + &display, + &display, + CurrencyPerMass, + left.unit.weight() / right.unit.weight(), + ); + Ok(Number::new(left.value / right.value, composite)) + } else if lcat == Currency && rcat == Length { + // usd/m, etc + let cur_upper = left.unit.singular().to_ascii_uppercase(); + let cur_lower = cur_upper.to_ascii_lowercase(); + let denom_abbrev: &str = match right.unit { + Millimeter => "mm", + Centimeter => "cm", + Decimeter => "dm", + Meter => "m", + Kilometer => "km", + Inch => "in", + Foot => "ft", + Yard => "yd", + Mile => "mi", + NauticalMile => "nmi", + LightYear => "ly", + LightSecond => "lightsec", + _ => right.unit.singular(), + }; + let display = format!("{}/{}", cur_upper, denom_abbrev); + let key = format!("{cur_lower}_per_{denom_abbrev}"); + let composite = crate::extra_units::upsert_unit( + &key, + &display, + &display, + CurrencyPerLength, + left.unit.weight() / right.unit.weight(), + ); + Ok(Number::new(left.value / right.value, composite)) + } else if lcat == Currency && rcat == Area { + // usd/m2, etc + let cur_upper = left.unit.singular().to_ascii_uppercase(); + let cur_lower = cur_upper.to_ascii_lowercase(); + let denom_abbrev: &str = match right.unit { + SquareMillimeter => "mm2", + SquareCentimeter => "cm2", + SquareDecimeter => "dm2", + SquareMeter => "m2", + SquareKilometer => "km2", + SquareInch => "in2", + SquareFoot => "ft2", + SquareYard => "yd2", + SquareMile => "mi2", + Are => "a", + Decare => "daa", + Hectare => "ha", + Acre => "acre", + _ => right.unit.singular(), + }; + let display = format!("{}/{}", cur_upper, denom_abbrev); + let key = format!("{cur_lower}_per_{denom_abbrev}"); + let composite = crate::extra_units::upsert_unit( + &key, + &display, + &display, + CurrencyPerArea, + left.unit.weight() / right.unit.weight(), + ); + Ok(Number::new(left.value / right.value, composite)) + } else if lcat == Mass && rcat == Volume { + // 5 g / 64 floz => g/floz (convertible to g/ml, kg/l, etc) + let mass_abbrev: &str = match left.unit { + Milligram => "mg", + Gram => "g", + Hectogram => "hg", + Kilogram => "kg", + MetricTon => "t", + Ounce => "oz", + Pound => "lb", + Stone => "st", + ShortTon => "ston", + LongTon => "lton", + _ => left.unit.singular(), + }; + let vol_abbrev: &str = match right.unit { + Milliliter => "ml", + Centiliter => "cl", + Deciliter => "dl", + Liter => "l", + Teaspoon => "tsp", + Tablespoon => "tbsp", + FluidOunce => "floz", + Cup => "cup", + Pint => "pt", + Quart => "qt", + Gallon => "gal", + OilBarrel => "bbl", + _ => right.unit.singular(), + }; + let display = format!("{}/{}", mass_abbrev, vol_abbrev); + let key = format!("{mass_abbrev}_per_{vol_abbrev}"); + let composite = crate::extra_units::upsert_unit( + &key, + &display, + &display, + MassPerVolume, + left.unit.weight() / right.unit.weight(), + ); + Ok(Number::new(left.value / right.value, composite)) + } else if lcat == Volume && rcat == Mass { + // ml/g etc + let vol_abbrev: &str = match left.unit { + Milliliter => "ml", + Centiliter => "cl", + Deciliter => "dl", + Liter => "l", + Teaspoon => "tsp", + Tablespoon => "tbsp", + FluidOunce => "floz", + Cup => "cup", + Pint => "pt", + Quart => "qt", + Gallon => "gal", + OilBarrel => "bbl", + _ => left.unit.singular(), + }; + let mass_abbrev: &str = match right.unit { + Milligram => "mg", + Gram => "g", + Hectogram => "hg", + Kilogram => "kg", + MetricTon => "t", + Ounce => "oz", + Pound => "lb", + Stone => "st", + ShortTon => "ston", + LongTon => "lton", + _ => right.unit.singular(), + }; + let display = format!("{}/{}", vol_abbrev, mass_abbrev); + let key = format!("{vol_abbrev}_per_{mass_abbrev}"); + let composite = crate::extra_units::upsert_unit( + &key, + &display, + &display, + VolumePerMass, + left.unit.weight() / right.unit.weight(), + ); + Ok(Number::new(left.value / right.value, composite)) } else if lcat == Power && rcat == ElectricCurrent { // 1 watt / 1 ampere = 1 volt let result = (left.value * left.unit.weight()) / (right.value * right.unit.weight());