use std::collections::BTreeMap;
use std::fmt;
use std::str::FromStr;
#[derive(Clone, Debug, PartialEq)]
pub enum Value {
Number(f64),
Bool(bool),
Text(String),
}
impl Value {
pub fn from_f32(value: f32) -> Self {
Self::Number(value.to_string().parse().unwrap_or(value as f64))
}
pub fn as_number(&self) -> Option<f64> {
match self {
Self::Number(number) => Some(*number),
Self::Bool(true) => Some(1.0),
Self::Bool(false) => Some(0.0),
Self::Text(_) => None,
}
}
pub fn as_bool(&self) -> Option<bool> {
match self {
Self::Bool(flag) => Some(*flag),
Self::Number(number) => Some(*number != 0.0),
Self::Text(_) => None,
}
}
pub fn as_text(&self) -> Option<&str> {
match self {
Self::Text(text) => Some(text),
_ => None,
}
}
fn kind(&self) -> &'static str {
match self {
Self::Number(_) => "a number",
Self::Bool(_) => "a boolean",
Self::Text(_) => "text",
}
}
}
impl From<f64> for Value {
fn from(value: f64) -> Self {
Self::Number(value)
}
}
impl From<f32> for Value {
fn from(value: f32) -> Self {
Self::from_f32(value)
}
}
impl From<i32> for Value {
fn from(value: i32) -> Self {
Self::Number(value as f64)
}
}
impl From<bool> for Value {
fn from(value: bool) -> Self {
Self::Bool(value)
}
}
impl From<&str> for Value {
fn from(value: &str) -> Self {
Self::Text(value.to_string())
}
}
impl From<String> for Value {
fn from(value: String) -> Self {
Self::Text(value)
}
}
impl fmt::Display for Value {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Number(number) => write!(f, "{number}"),
Self::Bool(flag) => write!(f, "{flag}"),
Self::Text(text) => write!(f, "\"{}\"", escape(text)),
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum ParamError {
Syntax { at: usize, message: String },
Unknown { key: String, known: Vec<String> },
Type {
key: String,
wanted: &'static str,
got: &'static str,
},
Range {
key: String,
value: f64,
min: f64,
max: f64,
},
Unsupported { message: String },
}
impl fmt::Display for ParamError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Syntax { at, message } => {
write!(f, "parameter syntax error at byte {at}: {message}")
}
Self::Unknown { key, known } => {
write!(f, "no parameter called '{key}'")?;
if !known.is_empty() {
let shown = known.len().min(12);
write!(f, " (has: {}", known[..shown].join(", "))?;
if known.len() > shown {
write!(f, ", and {} more", known.len() - shown)?;
}
write!(f, ")")?;
}
Ok(())
}
Self::Type { key, wanted, got } => {
write!(f, "'{key}' wants {wanted}, got {got}")
}
Self::Range {
key,
value,
min,
max,
} => write!(f, "'{key}' is {value}, outside {min}..={max}"),
Self::Unsupported { message } => write!(f, "{message}"),
}
}
}
impl std::error::Error for ParamError {}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ParamKind {
Number,
Toggle,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ParamSpec {
pub name: String,
pub unit: String,
pub kind: ParamKind,
pub min: f64,
pub max: f64,
pub default: f64,
pub about: String,
}
impl ParamSpec {
pub fn number(
name: &str,
unit: &str,
min: f64,
max: f64,
default: f64,
about: &str,
) -> Self {
Self {
name: name.to_string(),
unit: unit.to_string(),
kind: ParamKind::Number,
min,
max,
default,
about: about.to_string(),
}
}
pub fn toggle(name: &str, default: bool, about: &str) -> Self {
Self {
name: name.to_string(),
unit: String::new(),
kind: ParamKind::Toggle,
min: 0.0,
max: 1.0,
default: if default { 1.0 } else { 0.0 },
about: about.to_string(),
}
}
pub fn check(&self, value: &Value) -> Result<f64, ParamError> {
let number = match self.kind {
ParamKind::Toggle => value
.as_bool()
.map(|flag| if flag { 1.0 } else { 0.0 })
.ok_or_else(|| ParamError::Type {
key: self.name.clone(),
wanted: "a boolean",
got: value.kind(),
})?,
ParamKind::Number => value.as_number().ok_or_else(|| ParamError::Type {
key: self.name.clone(),
wanted: "a number",
got: value.kind(),
})?,
};
if number < self.min || number > self.max {
return Err(ParamError::Range {
key: self.name.clone(),
value: number,
min: self.min,
max: self.max,
});
}
Ok(number)
}
}
impl fmt::Display for ParamSpec {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.kind {
ParamKind::Toggle => write!(
f,
"{:<16} {:<22} {}",
self.name,
format!("(default {})", self.default != 0.0),
self.about
),
ParamKind::Number => write!(
f,
"{:<16} {:<22} {}",
self.name,
format!(
"{}..={}{}{}",
self.min,
self.max,
if self.unit.is_empty() { "" } else { " " },
self.unit
),
self.about
),
}
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Params {
values: BTreeMap<String, Value>,
}
impl Params {
pub fn new() -> Self {
Self::default()
}
pub fn parse(text: &str) -> Result<Self, ParamError> {
Parser::new(text).parse()
}
#[must_use]
pub fn with(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
self.set(key, value);
self
}
pub fn set(&mut self, key: impl Into<String>, value: impl Into<Value>) {
self.values.insert(key.into(), value.into());
}
pub fn get(&self, key: &str) -> Option<&Value> {
self.values.get(key)
}
pub fn number(&self, key: &str) -> Option<f64> {
self.get(key)?.as_number()
}
pub fn bool(&self, key: &str) -> Option<bool> {
self.get(key)?.as_bool()
}
pub fn text(&self, key: &str) -> Option<&str> {
self.get(key)?.as_text()
}
pub fn remove(&mut self, key: &str) -> Option<Value> {
self.values.remove(key)
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &Value)> {
self.values.iter().map(|(key, value)| (key.as_str(), value))
}
pub fn len(&self) -> usize {
self.values.len()
}
pub fn is_empty(&self) -> bool {
self.values.is_empty()
}
}
impl FromStr for Params {
type Err = ParamError;
fn from_str(text: &str) -> Result<Self, Self::Err> {
Self::parse(text)
}
}
impl<K: Into<String>, V: Into<Value>> FromIterator<(K, V)> for Params {
fn from_iter<I: IntoIterator<Item = (K, V)>>(pairs: I) -> Self {
Self {
values: pairs
.into_iter()
.map(|(key, value)| (key.into(), value.into()))
.collect(),
}
}
}
impl fmt::Display for Params {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("{")?;
for (index, (key, value)) in self.values.iter().enumerate() {
if index > 0 {
f.write_str(",")?;
}
write!(f, " \"{}\": {value}", escape(key))?;
}
f.write_str(if self.values.is_empty() { "}" } else { " }" })
}
}
fn escape(text: &str) -> String {
text.replace('\\', "\\\\").replace('"', "\\\"")
}
struct Parser<'a> {
text: &'a [u8],
source: &'a str,
at: usize,
}
impl<'a> Parser<'a> {
fn new(source: &'a str) -> Self {
Self {
text: source.as_bytes(),
source,
at: 0,
}
}
fn parse(mut self) -> Result<Params, ParamError> {
let mut values = BTreeMap::new();
self.space();
let braced = self.eat(b'{');
self.space();
while self.at < self.text.len() {
if braced && self.peek() == Some(b'}') {
break;
}
let key = self.key()?;
self.space();
if !self.eat(b':') && !self.eat(b'=') {
return Err(self.error("expected ':' after the parameter name"));
}
self.space();
let value = self.value()?;
values.insert(key, value);
self.space();
if !self.eat(b',') {
break;
}
self.space();
}
self.space();
if braced && !self.eat(b'}') {
return Err(self.error("expected '}' to close the parameter object"));
}
self.space();
if self.at < self.text.len() {
return Err(self.error("unexpected trailing text"));
}
Ok(Params { values })
}
fn key(&mut self) -> Result<String, ParamError> {
if self.peek() == Some(b'"') {
return self.string();
}
let start = self.at;
while let Some(byte) = self.peek() {
if byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-' || byte == b'.' {
self.at += 1;
} else {
break;
}
}
if self.at == start {
return Err(self.error("expected a parameter name"));
}
Ok(self.source[start..self.at].to_string())
}
fn value(&mut self) -> Result<Value, ParamError> {
match self.peek() {
Some(b'"') => self.string().map(Value::Text),
Some(b't') if self.word("true") => Ok(Value::Bool(true)),
Some(b'f') if self.word("false") => Ok(Value::Bool(false)),
Some(byte) if byte == b'-' || byte == b'+' || byte.is_ascii_digit() || byte == b'.' => {
self.number()
}
Some(_) => Err(self.error("expected a number, a boolean, or a quoted string")),
None => Err(self.error("expected a value")),
}
}
fn number(&mut self) -> Result<Value, ParamError> {
let start = self.at;
if matches!(self.peek(), Some(b'-') | Some(b'+')) {
self.at += 1;
}
while matches!(self.peek(), Some(byte) if byte.is_ascii_digit() || byte == b'.') {
self.at += 1;
}
if matches!(self.peek(), Some(b'e') | Some(b'E')) {
self.at += 1;
if matches!(self.peek(), Some(b'-') | Some(b'+')) {
self.at += 1;
}
while matches!(self.peek(), Some(byte) if byte.is_ascii_digit()) {
self.at += 1;
}
}
self.source[start..self.at]
.parse::<f64>()
.map(Value::Number)
.map_err(|_| {
let at = start;
ParamError::Syntax {
at,
message: format!("'{}' is not a number", &self.source[start..self.at]),
}
})
}
fn string(&mut self) -> Result<String, ParamError> {
self.at += 1;
let mut out = String::new();
loop {
match self.peek() {
None => return Err(self.error("unterminated string")),
Some(b'"') => {
self.at += 1;
return Ok(out);
}
Some(b'\\') => {
self.at += 1;
match self.peek() {
Some(b'"') => out.push('"'),
Some(b'\\') => out.push('\\'),
Some(b'n') => out.push('\n'),
Some(b't') => out.push('\t'),
Some(_) => return Err(self.error("unknown escape")),
None => return Err(self.error("unterminated escape")),
}
self.at += 1;
}
Some(_) => {
let rest = &self.source[self.at..];
let character = rest.chars().next().expect("non-empty by the match");
out.push(character);
self.at += character.len_utf8();
}
}
}
}
fn peek(&self) -> Option<u8> {
self.text.get(self.at).copied()
}
fn eat(&mut self, byte: u8) -> bool {
if self.peek() == Some(byte) {
self.at += 1;
true
} else {
false
}
}
fn word(&mut self, word: &str) -> bool {
let end = self.at + word.len();
if self.source.get(self.at..end) != Some(word) {
return false;
}
if matches!(self.text.get(end), Some(byte) if byte.is_ascii_alphanumeric() || *byte == b'_')
{
return false;
}
self.at = end;
true
}
fn space(&mut self) {
while matches!(self.peek(), Some(byte) if byte.is_ascii_whitespace()) {
self.at += 1;
}
}
fn error(&self, message: &str) -> ParamError {
ParamError::Syntax {
at: self.at,
message: message.to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_a_json_object() {
let params =
Params::parse(r#"{ "threshold_db": -18.5, "ratio": 4, "auto": true, "mode": "peak" }"#)
.expect("valid");
assert_eq!(params.number("threshold_db"), Some(-18.5));
assert_eq!(params.number("ratio"), Some(4.0));
assert_eq!(params.bool("auto"), Some(true));
assert_eq!(params.text("mode"), Some("peak"));
}
#[test]
fn braces_and_quotes_are_optional() {
let params = Params::parse("threshold_db: -18, ratio: 4").expect("valid");
assert_eq!(params.number("threshold_db"), Some(-18.0));
assert_eq!(params.number("ratio"), Some(4.0));
}
#[test]
fn equals_reads_as_a_colon() {
let params = Params::parse("gain_db = -6").expect("valid");
assert_eq!(params.number("gain_db"), Some(-6.0));
}
#[test]
fn a_trailing_comma_is_allowed() {
let params = Params::parse("{ a: 1, b: 2, }").expect("valid");
assert_eq!(params.len(), 2);
}
#[test]
fn an_empty_object_is_an_empty_set() {
assert!(Params::parse("{}").expect("valid").is_empty());
assert!(Params::parse("").expect("valid").is_empty());
assert!(Params::parse(" ").expect("valid").is_empty());
}
#[test]
fn reads_exponents_and_signs() {
let params = Params::parse("a: 1e3, b: -2.5e-2, c: +7").expect("valid");
assert_eq!(params.number("a"), Some(1000.0));
assert_eq!(params.number("b"), Some(-0.025));
assert_eq!(params.number("c"), Some(7.0));
}
#[test]
fn the_last_value_for_a_name_wins() {
let params = Params::parse("gain_db: 0, gain_db: -6").expect("valid");
assert_eq!(params.number("gain_db"), Some(-6.0));
}
#[test]
fn round_trips_through_display() {
let original =
Params::parse(r#"{"a": 1.5, "b": true, "c": "text", "d": -2}"#).expect("valid");
let printed = original.to_string();
let reparsed = Params::parse(&printed).expect("its own output");
assert_eq!(original, reparsed, "printed as {printed}");
}
#[test]
fn an_empty_set_prints_as_an_empty_object() {
assert_eq!(Params::new().to_string(), "{}");
assert_eq!(Params::parse("{}").unwrap(), Params::new());
}
#[test]
fn quotes_in_a_value_survive_a_round_trip() {
let params = Params::new().with("mode", "a \"quoted\" word");
let reparsed = Params::parse(¶ms.to_string()).expect("its own output");
assert_eq!(reparsed.text("mode"), Some("a \"quoted\" word"));
}
#[test]
fn rejects_what_is_not_a_parameter_object() {
for bad in [
"{",
"{ a: 1",
"a:",
"a: ,",
": 1",
"a: 1 b: 2",
"{ a: 1 } trailing",
"a: [1, 2]",
"a: null",
"a: \"unterminated",
"a: truex",
] {
assert!(
Params::parse(bad).is_err(),
"{bad:?} should not have parsed"
);
}
}
#[test]
fn a_syntax_error_says_where() {
let Err(ParamError::Syntax { at, .. }) = Params::parse("ratio: 4, : 2") else {
panic!("expected a syntax error");
};
assert_eq!(at, 10, "should point at the missing name");
}
#[test]
fn an_f32_does_not_widen_into_noise() {
assert_eq!(Value::from_f32(1.4), Value::Number(1.4));
assert_eq!(Value::from_f32(0.3), Value::Number(0.3));
assert_eq!(Value::from_f32(-24.0), Value::Number(-24.0));
assert_eq!(Value::from_f32(1.4).to_string(), "1.4");
}
#[test]
fn numbers_and_booleans_convert_between_each_other() {
assert_eq!(Value::Bool(true).as_number(), Some(1.0));
assert_eq!(Value::Number(0.0).as_bool(), Some(false));
assert_eq!(Value::Number(0.5).as_bool(), Some(true));
assert_eq!(Value::Text("4".into()).as_number(), None);
assert_eq!(Value::Number(1.0).as_text(), None);
}
#[test]
fn a_spec_checks_range_and_kind() {
let spec = ParamSpec::number("ratio", "", 1.0, 20.0, 4.0, "");
assert_eq!(spec.check(&Value::Number(4.0)), Ok(4.0));
assert!(matches!(
spec.check(&Value::Number(40.0)),
Err(ParamError::Range { .. })
));
assert!(matches!(
spec.check(&Value::Text("loud".into())),
Err(ParamError::Type { .. })
));
assert_eq!(spec.check(&Value::Number(1.0)), Ok(1.0));
assert_eq!(spec.check(&Value::Number(20.0)), Ok(20.0));
}
#[test]
fn a_toggle_takes_a_bool_or_a_number() {
let spec = ParamSpec::toggle("bypass", false, "");
assert_eq!(spec.check(&Value::Bool(true)), Ok(1.0));
assert_eq!(spec.check(&Value::Number(0.0)), Ok(0.0));
assert!(matches!(
spec.check(&Value::Text("on".into())),
Err(ParamError::Type { .. })
));
}
#[test]
fn an_unknown_parameter_lists_what_there_is() {
let error = ParamError::Unknown {
key: "ratioo".into(),
known: vec!["ratio".into(), "threshold_db".into()],
};
let message = error.to_string();
assert!(message.contains("ratioo"), "{message}");
assert!(message.contains("ratio, threshold_db"), "{message}");
}
}