use crate::error::PipelineError;
use crate::lexer::{lex, FileKind, LineKind};
use crate::unit;
use std::collections::BTreeMap;
#[derive(Debug, Clone)]
pub struct UnitLib {
pub library: String,
pub units: BTreeMap<String, UnitDef>,
}
#[derive(Debug, Clone, Default)]
pub struct UnitDef {
pub dimension: String,
pub factor: Option<String>,
pub rate_source: Option<String>,
pub alias_of: Option<String>,
}
pub(crate) struct DefLine {
pub dimension: String,
pub factor: Option<String>,
pub rate_source: Option<String>,
}
pub(crate) fn parse_def_line(s: &str) -> Option<DefLine> {
let mut toks = s.split([' ', '\t']).filter(|t| !t.is_empty());
let dim = toks.next()?;
if dim != "$" && unit::canonicalize(dim).is_none() {
return None;
}
let mut def = DefLine {
dimension: dim.to_string(),
factor: None,
rate_source: None,
};
match toks.next() {
None => {
if dim != "$" {
return None;
}
}
Some(t) => {
if let Some(url) = t.strip_prefix('@') {
if dim != "$" || url.is_empty() {
return None; }
def.rate_source = Some(url.to_string());
} else {
if dim == "$" || !decimal_ok(t) {
return None; }
def.factor = Some(t.to_string());
}
}
}
if toks.next().is_some() {
return None;
}
Some(def)
}
fn decimal_ok(s: &str) -> bool {
let (mantissa, exp) = match s.split_once(['e', 'E']) {
Some((m, e)) => (m, Some(e)),
None => (s, None),
};
let mantissa_ok = match mantissa.split_once('.') {
Some((i, f)) => {
!i.is_empty()
&& !f.is_empty()
&& i.bytes().all(|b| b.is_ascii_digit())
&& f.bytes().all(|b| b.is_ascii_digit())
}
None => !mantissa.is_empty() && mantissa.bytes().all(|b| b.is_ascii_digit()),
};
let exp_ok = exp.is_none_or(|e| {
let e = e.strip_prefix(['+', '-']).unwrap_or(e);
!e.is_empty() && e.bytes().all(|b| b.is_ascii_digit())
});
mantissa_ok && exp_ok
}
pub fn parse_faiv(input: &[u8]) -> Result<UnitLib, PipelineError> {
let lines = lex(input, FileKind::UnitLib).map_err(PipelineError::Lex)?;
let mut library = String::new();
let mut pending: Option<DefLine> = None;
let mut units: BTreeMap<String, UnitDef> = BTreeMap::new();
for line in &lines {
match &line.kind {
LineKind::Blank | LineKind::Comment(_) | LineKind::Doc(_) => {}
LineKind::Decl(s) => {
if let Some(rest) = s.strip_prefix(".!kaivunit") {
let mut toks = rest.split_ascii_whitespace();
let _version = toks.next();
if let Some(lib) = toks.next() {
library = lib.to_string();
}
}
}
LineKind::Meta(s) => {
let def = parse_def_line(s).ok_or_else(|| {
PipelineError::Other(format!("bad definition line in .faiv: {s}"))
})?;
if def.dimension != "$" {
let known: std::collections::BTreeSet<String> = units.keys().cloned().collect();
if !unit::members_ok(&def.dimension, &known) {
return Err(PipelineError::Other(format!(
"unknown unit in .faiv dimension: {}",
def.dimension
)));
}
}
pending = Some(def);
}
LineKind::Content { left, value } => {
let name = left.strip_prefix('&').ok_or_else(|| {
PipelineError::Other(format!("unexpected .faiv content line: {left}"))
})?;
let def = if value.is_empty() {
let d = pending.take().ok_or_else(|| {
PipelineError::Other(format!(
"definition &{name}= has no dimension line above it"
))
})?;
UnitDef {
dimension: d.dimension,
factor: d.factor,
rate_source: d.rate_source,
alias_of: None,
}
} else {
let target = units.get(*value).cloned().ok_or_else(|| {
PipelineError::Other(format!("alias &{name}={value} has no target"))
})?;
UnitDef {
alias_of: Some((*value).to_string()),
..target
}
};
if units.insert(name.to_string(), def).is_some() {
return Err(PipelineError::Other(format!(
"duplicate unit definition in .faiv: &{name}"
)));
}
}
other => {
return Err(PipelineError::Other(format!(
"unsupported .faiv line: {other:?}"
)))
}
}
}
if library.is_empty() {
return Err(PipelineError::Other(
"missing .!kaivunit declaration in .faiv".into(),
));
}
Ok(UnitLib { library, units })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_library() {
let src = b".!kaivunit 1 astro/units\n\n// Astronomical unit\nm 1.495978707e11\n&au=\n&astronomical_unit=au\n\n// Custom currency with a rate source\n$ @https://rates.example.com/v1?code={code}&at={timestamp}\n&~XYZ=\n";
let lib = parse_faiv(src).unwrap();
assert_eq!(lib.library, "astro/units");
let au = &lib.units["au"];
assert_eq!(au.dimension, "m");
assert_eq!(au.factor.as_deref(), Some("1.495978707e11"));
assert_eq!(
lib.units["astronomical_unit"].alias_of.as_deref(),
Some("au")
);
let xyz = &lib.units["~XYZ"];
assert_eq!(xyz.dimension, "$");
assert!(xyz.factor.is_none());
assert!(xyz.rate_source.as_deref().unwrap().contains("{timestamp}"));
}
#[test]
fn def_line_rules() {
assert!(parse_def_line("m 1.495978707e11").is_some());
assert!(parse_def_line("kg*m/s^2 4.44822").is_some());
assert!(parse_def_line("$").is_some()); assert!(parse_def_line("$ @https://r.example/x?a=b").is_some());
assert!(parse_def_line("m").is_none()); assert!(parse_def_line("$ 1.5").is_none()); assert!(parse_def_line("m @https://r.example").is_none()); assert!(parse_def_line("m -2.5").is_none()); }
}