#[must_use]
pub fn is_valid_units_string(units: &str) -> bool {
let bytes = units.as_bytes();
if units.is_empty() || !units.is_ascii() {
return false;
}
let mut pos = 0usize;
if bytes.first() == Some(&b'/') {
pos += 1;
}
match parse_term(bytes, pos) {
Some(end) => end == bytes.len(),
None => false,
}
}
fn parse_term(b: &[u8], mut pos: usize) -> Option<usize> {
pos = parse_component(b, pos)?;
while matches!(b.get(pos), Some(b'.' | b'/')) {
pos = parse_component(b, pos + 1)?;
}
Some(pos)
}
fn parse_component(b: &[u8], mut pos: usize) -> Option<usize> {
let &opener = b.get(pos)?;
if opener == b'(' {
pos = parse_term(b, pos + 1)?;
if b.get(pos) != Some(&b')') {
return None;
}
pos += 1;
pos = parse_exponent_opt(b, pos);
return Some(pos);
}
if opener == b'{' {
return parse_annotation(b, pos);
}
pos = parse_atom_or_factor(b, pos)?;
pos = parse_exponent_opt(b, pos);
if b.get(pos) == Some(&b'{') {
pos = parse_annotation(b, pos)?;
}
Some(pos)
}
fn parse_atom_or_factor(b: &[u8], mut pos: usize) -> Option<usize> {
let start = pos;
while let Some(&c) = b.get(pos) {
match c {
b'[' => {
pos += 1;
while let Some(&inner) = b.get(pos) {
if inner == b']' {
break;
}
if !(0x21..=0x7e).contains(&inner) || inner == b'[' {
return None;
}
pos += 1;
}
if pos >= b.len() {
return None; }
pos += 1; }
b'.' | b'/' | b'(' | b')' | b'{' | b'}' | b']' => break,
c if (0x21..=0x7e).contains(&c) => pos += 1,
_ => return None, }
}
(pos > start).then_some(pos)
}
fn parse_exponent_opt(b: &[u8], mut pos: usize) -> usize {
if matches!(b.get(pos), Some(b'+' | b'-')) {
let mut digits = pos + 1;
while b.get(digits).is_some_and(u8::is_ascii_digit) {
digits += 1;
}
if digits > pos + 1 {
pos = digits;
}
}
pos
}
fn parse_annotation(b: &[u8], mut pos: usize) -> Option<usize> {
debug_assert_eq!(
b.get(pos),
Some(&b'{'),
"parse_annotation must be entered on the opening brace of an annotation"
);
pos += 1;
while let Some(&c) = b.get(pos) {
if c == b'}' {
break;
}
if !(0x21..=0x7e).contains(&c) || c == b'{' {
return None;
}
pos += 1;
}
(pos < b.len()).then_some(pos + 1)
}
#[cfg(test)]
mod tests {
use super::is_valid_units_string;
#[test]
fn ucum_syntax() {
for ok in [
"kg",
"mm[Hg]",
"kg/m2",
"ms-1",
"km/h",
"1",
"1/d",
"/min",
"10*3/ul",
"cm2",
"mol/l",
"%",
"Cel",
"m.s-2",
"{beats}/min",
"mg{total}",
] {
assert!(is_valid_units_string(ok), "{ok} must be valid UCUM syntax");
}
for bad in ["", "°C", "kg m", "mm[Hg", "a{b{c}}", "kg//m", "(kg"] {
assert!(!is_valid_units_string(bad), "{bad:?} must be invalid");
}
}
}