Skip to main content

ucum/
lib.rs

1#![forbid(unsafe_code)]
2#![warn(missing_docs)]
3#![doc = include_str!("../README.md")]
4
5mod analysis;
6mod dimension;
7mod display;
8mod error;
9mod parser;
10mod quantity;
11mod tables;
12
13pub use dimension::Dimension;
14pub use error::UcumError;
15pub use parser::UnitExpr;
16pub use quantity::Quantity;
17
18use analysis::{Resolved, Special};
19
20/// Case-sensitivity mode for parsing and lookup.
21///
22/// UCUM defines a case-sensitive (`c/s`) and a case-insensitive (`c/i`) form.
23/// `c/s` is the default for data interchange and the one the free functions
24/// ([`parse`], [`analyze`], …) use. Use [`Ucum::case_insensitive`] to opt into
25/// `c/i`, where codes are matched against the upper-case `CODE` column.
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
27pub enum Case {
28    /// The `c/s` form: `m` (meter) ≠ `M` (mega). The default.
29    #[default]
30    Sensitive,
31    /// The `c/i` form: case-insensitive matching (`L`, `l`, and `MOL` resolve).
32    Insensitive,
33}
34
35/// The result of analyzing a unit expression: its dimension and the linear
36/// (and affine) relationship of its magnitude to the canonical UCUM base units.
37///
38/// For an ordinary unit, a magnitude `v` in this unit equals `factor · v` in
39/// base units. For an affine unit (e.g. `Cel`), it equals `factor · v + offset`.
40/// For a logarithmic or arbitrary special unit, `is_special` is `true` and the
41/// linear fields are informational only; see [`Ucum::convert`] for the
42/// conversion rules.
43#[derive(Clone, Copy, Debug, PartialEq)]
44#[non_exhaustive]
45pub struct Analysis {
46    /// The dimensional exponent vector.
47    pub dimension: Dimension,
48    /// Multiplicative factor to canonical base units.
49    pub factor: f64,
50    /// Affine offset to canonical base units (`0.0` for multiplicative units).
51    pub offset: f64,
52    /// Whether the unit is dimensionless.
53    pub is_dimensionless: bool,
54    /// Whether the unit is a special (non-multiplicative) UCUM unit.
55    pub is_special: bool,
56}
57
58impl Analysis {
59    fn from_resolved(r: Resolved) -> Analysis {
60        Analysis {
61            dimension: r.dim,
62            factor: r.factor,
63            offset: r.offset,
64            is_dimensionless: r.dim.is_dimensionless(),
65            is_special: !matches!(r.special, Special::None),
66        }
67    }
68}
69
70/// A configured UCUM facade carrying a [`Case`] mode.
71///
72/// The crate-level free functions are shorthands for the case-sensitive
73/// instance. Construct a [`Ucum`] when you need case-insensitive (`c/i`)
74/// handling:
75///
76/// ```
77/// use ucum::Ucum;
78/// let ci = Ucum::case_insensitive();
79/// assert!(ci.validate("MOL").is_ok());     // mole, case-insensitively
80/// assert!(ci.validate("L").is_ok());
81/// ```
82#[derive(Clone, Copy, Debug, Default)]
83pub struct Ucum {
84    case: Case,
85}
86
87impl Ucum {
88    /// A case-sensitive (`c/s`) facade, the UCUM default.
89    #[must_use]
90    pub const fn case_sensitive() -> Self {
91        Ucum {
92            case: Case::Sensitive,
93        }
94    }
95
96    /// A case-insensitive (`c/i`) facade.
97    #[must_use]
98    pub const fn case_insensitive() -> Self {
99        Ucum {
100            case: Case::Insensitive,
101        }
102    }
103
104    /// The configured case mode.
105    #[must_use]
106    pub const fn case(&self) -> Case {
107        self.case
108    }
109
110    /// Parse a UCUM expression into an AST. Total. Parsing is case-independent;
111    /// atom identity is resolved later.
112    pub fn parse(&self, expr: &str) -> Result<UnitExpr, UcumError> {
113        parser::parse(expr)
114    }
115
116    /// Validate syntax and that all atoms are known. Total.
117    pub fn validate(&self, expr: &str) -> Result<(), UcumError> {
118        let ast = parser::parse(expr)?;
119        analysis::evaluate(&ast, self.case)?;
120        Ok(())
121    }
122
123    /// Analyze an expression into its dimension and conversion factor/offset.
124    /// Total.
125    pub fn analyze(&self, expr: &str) -> Result<Analysis, UcumError> {
126        let ast = parser::parse(expr)?;
127        Ok(Analysis::from_resolved(analysis::evaluate(
128            &ast, self.case,
129        )?))
130    }
131
132    /// Return `true` when two expressions share the same dimension and neither
133    /// is an arbitrary unit (arbitrary units are commensurable with nothing).
134    /// Total.
135    pub fn is_comparable(&self, a: &str, b: &str) -> Result<bool, UcumError> {
136        let ra = analysis::evaluate(&parser::parse(a)?, self.case)?;
137        let rb = analysis::evaluate(&parser::parse(b)?, self.case)?;
138        if matches!(ra.special, Special::Arbitrary) || matches!(rb.special, Special::Arbitrary) {
139            return Ok(false);
140        }
141        Ok(ra.dim == rb.dim)
142    }
143
144    /// Convert a magnitude between two commensurable units, handling affine
145    /// offsets (temperature) and logarithmic units (`B`, `dB`, `Np`, `[pH]`, …).
146    /// Total.
147    ///
148    /// Returns [`UcumError::NotComparable`] if the dimensions differ, or
149    /// [`UcumError::UnsupportedSpecial`] if either side is an arbitrary unit or
150    /// a special unit used inside a compound term (where its meaning is lost).
151    pub fn convert(&self, value: f64, from: &str, to: &str) -> Result<f64, UcumError> {
152        let a = analysis::evaluate(&parser::parse(from)?, self.case)?;
153        let b = analysis::evaluate(&parser::parse(to)?, self.case)?;
154
155        if !a.is_convertible() {
156            return Err(UcumError::UnsupportedSpecial {
157                unit: from.to_string(),
158            });
159        }
160        if !b.is_convertible() {
161            return Err(UcumError::UnsupportedSpecial {
162                unit: to.to_string(),
163            });
164        }
165        if a.dim != b.dim {
166            return Err(UcumError::NotComparable {
167                from: from.to_string(),
168                to: to.to_string(),
169            });
170        }
171        Ok(b.magnitude_from_base(a.magnitude_to_base(value)))
172    }
173
174    /// Return a normalized UCUM string for display. Total. See [`canonical`].
175    pub fn canonical(&self, expr: &str) -> Result<String, UcumError> {
176        Ok(parser::parse(expr)?.to_string())
177    }
178
179    /// Generate a human-readable display name, e.g. `mm` → `(millimeter)`.
180    /// Total.
181    pub fn display_name(&self, expr: &str) -> Result<String, UcumError> {
182        display::display_name(expr, self.case)
183    }
184}
185
186// --------------------------------------------------------------------------
187// Case-sensitive free-function shorthands
188// --------------------------------------------------------------------------
189
190/// Parse a UCUM expression into an abstract syntax tree (case-sensitive).
191///
192/// This is a *total* function: it never panics and never hangs. Syntactically
193/// malformed input yields a [`UcumError::Parse`] carrying the byte offset.
194/// Atoms are **not** checked for existence here; use [`validate`] or
195/// [`analyze`] for that.
196///
197/// ```
198/// use ucum::UcumError;
199/// assert!(ucum::parse("kg.m/s2").is_ok());
200/// assert!(ucum::parse("/s").is_ok());        // leading-slash reciprocal
201/// assert!(ucum::parse("(m/s)").is_ok());
202/// // A malformed expression reports *where* it went wrong.
203/// assert!(matches!(ucum::parse("m/"), Err(UcumError::Parse { pos: 2, .. })));
204/// ```
205pub fn parse(expr: &str) -> Result<UnitExpr, UcumError> {
206    parser::parse(expr)
207}
208
209/// Validate that an expression is well-formed UCUM *and* references only known
210/// atoms (case-sensitive). Total.
211///
212/// ```
213/// use ucum::UcumError;
214/// assert!(ucum::validate("mg/dL").is_ok());
215/// assert!(ucum::validate("[ft_i]").is_ok());  // bracketed customary unit
216/// assert!(ucum::validate("1").is_ok());        // the dimensionless unity
217/// // The failure tells you which atom is unknown.
218/// assert!(matches!(
219///     ucum::validate("flurble"),
220///     Err(UcumError::UnknownAtom { code }) if code == "flurble"
221/// ));
222/// ```
223pub fn validate(expr: &str) -> Result<(), UcumError> {
224    Ucum::case_sensitive().validate(expr)
225}
226
227/// Analyze an expression into its dimension and conversion factor/offset
228/// (case-sensitive). Total.
229///
230/// ```
231/// let a = ucum::analyze("m2").unwrap();
232/// assert_eq!(a.dimension, ucum::Dimension([2, 0, 0, 0, 0, 0, 0]));
233///
234/// let unity = ucum::analyze("1").unwrap();
235/// assert!(unity.is_dimensionless);
236/// ```
237pub fn analyze(expr: &str) -> Result<Analysis, UcumError> {
238    Ucum::case_sensitive().analyze(expr)
239}
240
241/// Return `true` when two expressions share the same dimension and neither is
242/// an arbitrary unit (case-sensitive). Total.
243///
244/// ```
245/// assert!(ucum::is_comparable("km", "[ft_i]").unwrap());   // both length
246/// assert!(!ucum::is_comparable("kg", "m").unwrap());        // mass vs length
247/// ```
248pub fn is_comparable(a: &str, b: &str) -> Result<bool, UcumError> {
249    Ucum::case_sensitive().is_comparable(a, b)
250}
251
252/// Convert a magnitude between two commensurable units (case-sensitive),
253/// handling affine offsets (temperature) and logarithmic units. Total.
254///
255/// Returns [`UcumError::NotComparable`] if the units have different dimensions,
256/// or [`UcumError::UnsupportedSpecial`] if either side is an arbitrary unit or a
257/// special unit used inside a compound term.
258///
259/// ```
260/// use ucum::{convert, UcumError};
261/// assert!((convert(1.0, "[ft_i]", "m").unwrap() - 0.3048).abs() < 1e-12);
262/// assert!((convert(1.0, "bar", "Pa").unwrap() - 1e5).abs() < 1e-3);
263/// assert!((convert(0.0, "Cel", "K").unwrap() - 273.15).abs() < 1e-9);
264/// assert!((convert(2.0, "B", "1").unwrap() - 100.0).abs() < 1e-9);  // bels: log
265///
266/// // Units of different dimensions cannot be converted.
267/// assert!(matches!(
268///     convert(1.0, "kg", "m"),
269///     Err(UcumError::NotComparable { .. })
270/// ));
271/// ```
272pub fn convert(value: f64, from: &str, to: &str) -> Result<f64, UcumError> {
273    Ucum::case_sensitive().convert(value, from, to)
274}
275
276/// Return a normalized UCUM string for display (case-sensitive). Total.
277///
278/// The result is the input re-serialized from its parse tree: redundant
279/// parentheses are removed (`((m))` → `m`), while parentheses required to
280/// preserve UCUM's left-associative grouping are kept (`kg/(m.s)`). Exponent
281/// and number formatting are normalized.
282///
283/// This is a *syntactic* normalization, **not** a full algebraic canonical
284/// form: it does not reorder commutative factors (`m.s` and `s.m` stay
285/// distinct) nor reduce units to base dimensions. It also does **not** check
286/// that atoms are known, only that the expression parses, so a well-formed
287/// but unknown unit still round-trips. Use [`validate`] or [`analyze`] for
288/// semantic checks, and [`analyze`] when you need dimensional equivalence.
289///
290/// ```
291/// assert_eq!(ucum::canonical("kg.m/s2").unwrap(), "kg.m/s2");
292/// assert_eq!(ucum::canonical("((m))").unwrap(), "m");      // redundant parens dropped
293/// assert_eq!(ucum::canonical("kg/(m.s)").unwrap(), "kg/(m.s)"); // necessary parens kept
294/// assert_eq!(ucum::canonical("flurble").unwrap(), "flurble"); // not validated!
295/// ```
296pub fn canonical(expr: &str) -> Result<String, UcumError> {
297    Ucum::case_sensitive().canonical(expr)
298}
299
300/// Generate a human-readable display name (case-sensitive). Total.
301///
302/// ```
303/// assert_eq!(ucum::display_name("mm").unwrap(), "(millimeter)");
304/// assert_eq!(ucum::display_name("rad2").unwrap(), "(radian ^ 2)");
305/// ```
306pub fn display_name(expr: &str) -> Result<String, UcumError> {
307    Ucum::case_sensitive().display_name(expr)
308}