From bc1c9fe560e93ee34cbfa7d4c3766d1ee499b21d Mon Sep 17 00:00:00 2001 From: printfn Date: Fri, 10 Nov 2023 11:17:34 +0000 Subject: [PATCH] Add support for custom unit definitions --- CHANGELOG.md | 20 +++++ cli/src/config.rs | 14 +++- cli/src/context.rs | 8 ++ cli/src/custom_units.rs | 162 ++++++++++++++++++++++++++++++++++++ cli/src/default_config.toml | 44 ++++++++++ cli/src/main.rs | 1 + core/src/lib.rs | 38 +++++++++ core/src/units.rs | 58 ++++++++----- core/src/units/builtin.rs | 29 +++++-- 9 files changed, 344 insertions(+), 30 deletions(-) create mode 100644 cli/src/custom_units.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 41984178..3b93911d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ ## Changelog +### Unreleased + +* Add support for custom unit definitions: you can now define your own units + in the `~/.config/fend/config.toml` configuration file. For example: + + ```toml + [[custom-units]] + singular = 'fortnight' + plural = 'fortnights' # plural form is optional, defaults + # to singular if not specified + definition = '14 days' + attribute = 'allow-long-prefix' # this makes it possible to combine this + # unit with prefixes like 'milli-' or 'giga-' + ``` + + See the [default config file](https://github.com/printfn/fend/blob/main/cli/src/default_config.toml) for more examples. +* You can now tab-complete greek letters in the CLI, e.g. `\alpha` becomes α + (by [@Markos-Th09](https://github.com/Markos-Th09)) +* Add CGS units (by [@Markos-Th09](https://github.com/Markos-Th09)) + ### v1.3.1 (2023-10-26) * Add support for additional imperial and US customary units diff --git a/cli/src/config.rs b/cli/src/config.rs index 7661914d..2599825b 100644 --- a/cli/src/config.rs +++ b/cli/src/config.rs @@ -1,4 +1,4 @@ -use crate::color; +use crate::{color, custom_units::CustomUnitDefinition}; use std::{env, fmt, fs, io}; #[derive(Debug, Eq, PartialEq)] @@ -10,6 +10,7 @@ pub struct Config { pub max_history_size: usize, pub enable_internet_access: bool, pub exchange_rate_source: ExchangeRateSource, + pub custom_units: Vec, unknown_settings: UnknownSettings, unknown_keys: Vec, } @@ -75,6 +76,7 @@ impl<'de> serde::de::Visitor<'de> for ConfigVisitor { let mut seen_max_hist_size = false; let mut seen_enable_internet_access = false; let mut seen_exchange_rate_source = false; + let mut seen_custom_units = false; while let Some(key) = map.next_key::()? { match key.as_str() { "prompt" => { @@ -148,6 +150,13 @@ impl<'de> serde::de::Visitor<'de> for ConfigVisitor { } }; } + "custom-units" => { + if seen_custom_units { + return Err(serde::de::Error::duplicate_field("custom-units")); + } + result.custom_units = map.next_value()?; + seen_custom_units = true; + } unknown_key => { // this may occur if the user has multiple fend versions installed map.next_value::()?; @@ -184,8 +193,9 @@ impl Default for Config { max_history_size: 1000, enable_internet_access: true, unknown_settings: UnknownSettings::Warn, - unknown_keys: vec![], exchange_rate_source: ExchangeRateSource::UnitedNations, + custom_units: vec![], + unknown_keys: vec![], } } } diff --git a/cli/src/context.rs b/cli/src/context.rs index a98ad07a..9efe1296 100644 --- a/cli/src/context.rs +++ b/cli/src/context.rs @@ -38,6 +38,14 @@ impl InnerCtx { if config.coulomb_and_farad { res.core_ctx.use_coulomb_and_farad(); } + for custom_unit in &config.custom_units { + res.core_ctx.define_custom_unit_v1( + &custom_unit.singular, + &custom_unit.plural, + &custom_unit.definition, + custom_unit.attribute.to_fend_core(), + ); + } res } } diff --git a/cli/src/custom_units.rs b/cli/src/custom_units.rs new file mode 100644 index 00000000..624621cb --- /dev/null +++ b/cli/src/custom_units.rs @@ -0,0 +1,162 @@ +use std::fmt; + +#[derive(Clone, PartialEq, Eq, Debug, Hash, Default)] +pub enum CustomUnitAttribute { + #[default] + None, + AllowLongPrefix, + AllowShortPrefix, + IsLongPrefix, + Alias, +} + +struct CustomUnitAttributeVisitor; + +impl<'de> serde::de::Visitor<'de> for CustomUnitAttributeVisitor { + type Value = CustomUnitAttribute; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str( + "`none`, `allow-long-prefix`, `allow-short-prefix`, `is-long-prefix` or `alias`", + ) + } + + fn visit_str(self, v: &str) -> Result { + Ok(match v { + "none" => CustomUnitAttribute::None, + "allow-long-prefix" => CustomUnitAttribute::AllowLongPrefix, + "allow-short-prefix" => CustomUnitAttribute::AllowShortPrefix, + "is-long-prefix" => CustomUnitAttribute::IsLongPrefix, + "alias" => CustomUnitAttribute::Alias, + unknown => { + return Err(serde::de::Error::unknown_variant( + unknown, + &[ + "none", + "allow-long-prefix", + "allow-short-prefix", + "is-long-prefix", + "alias", + ], + )) + } + }) + } +} + +impl<'de> serde::Deserialize<'de> for CustomUnitAttribute { + fn deserialize>(deserializer: D) -> Result { + deserializer.deserialize_str(CustomUnitAttributeVisitor) + } +} + +impl CustomUnitAttribute { + pub fn to_fend_core(&self) -> fend_core::CustomUnitAttribute { + match self { + CustomUnitAttribute::None => fend_core::CustomUnitAttribute::None, + CustomUnitAttribute::AllowLongPrefix => fend_core::CustomUnitAttribute::AllowLongPrefix, + CustomUnitAttribute::AllowShortPrefix => { + fend_core::CustomUnitAttribute::AllowShortPrefix + } + CustomUnitAttribute::IsLongPrefix => fend_core::CustomUnitAttribute::IsLongPrefix, + CustomUnitAttribute::Alias => fend_core::CustomUnitAttribute::Alias, + } + } +} + +#[derive(Debug, Eq, PartialEq)] +pub struct CustomUnitDefinition { + pub singular: String, + pub plural: String, + pub definition: String, + pub attribute: CustomUnitAttribute, +} + +impl<'de> serde::Deserialize<'de> for CustomUnitDefinition { + fn deserialize>(deserializer: D) -> Result { + const FIELDS: &[&str] = &["singular", "plural", "definition", "attribute"]; + + struct CustomUnitDefinitionVisitor; + + impl<'de> serde::de::Visitor<'de> for CustomUnitDefinitionVisitor { + type Value = CustomUnitDefinition; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a custom unit definition, with properties `singular`, `plural`, `definition` and `attribute`") + } + + fn visit_map>( + self, + mut map: V, + ) -> Result { + let mut result = CustomUnitDefinition { + attribute: CustomUnitAttribute::None, + definition: String::new(), + singular: String::new(), + plural: String::new(), + }; + let mut seen_singular = false; + let mut seen_plural = false; + let mut seen_definition = false; + let mut seen_attribute = false; + while let Some(key) = map.next_key::()? { + match key.as_str() { + "singular" => { + if seen_singular { + return Err(serde::de::Error::duplicate_field("singular")); + } + result.singular = map.next_value()?; + seen_singular = true; + } + "plural" => { + if seen_plural { + return Err(serde::de::Error::duplicate_field("plural")); + } + result.plural = map.next_value()?; + if result.plural.is_empty() { + return Err(serde::de::Error::invalid_value( + serde::de::Unexpected::Str(&result.plural), + &"a non-empty string describing the plural form of this unit", + )); + } + seen_plural = true; + } + "definition" => { + if seen_definition { + return Err(serde::de::Error::duplicate_field("definition")); + } + result.definition = map.next_value()?; + if result.definition.is_empty() { + return Err(serde::de::Error::invalid_value( + serde::de::Unexpected::Str(&result.definition), + &"a non-empty string that contains the definition of this unit", + )); + } + seen_definition = true; + } + "attribute" => { + if seen_attribute { + return Err(serde::de::Error::duplicate_field("attribute")); + } + result.attribute = map.next_value()?; + seen_attribute = true; + } + unknown_key => { + map.next_value::()?; + return Err(serde::de::Error::unknown_field(unknown_key, FIELDS)); + } + } + } + if result.singular.is_empty() { + return Err(serde::de::Error::missing_field("singular")); + } + if result.definition.is_empty() { + return Err(serde::de::Error::missing_field("definition")); + } + Ok(result) + } + } + + deserializer.deserialize_struct("CustomUnitDefinition", FIELDS, CustomUnitDefinitionVisitor) + } +} diff --git a/cli/src/default_config.toml b/cli/src/default_config.toml index b854dd27..cc40c8cf 100644 --- a/cli/src/default_config.toml +++ b/cli/src/default_config.toml @@ -58,3 +58,47 @@ keyword = { foreground = 'blue', bold = true } built-in-function = { foreground = 'blue', bold = true } date = {} other = {} + +# This section defines custom units. If there's a unit you +# would like to see added to fend, please consider making +# a pull request to add it to the built-in unit definitions. +[custom_units] + +# Example syntax: +# ``` +# [[custom_units]] +# singular = 'mile' +# plural = 'miles' # plural name can be omitted if it is +# # the same as the singular name +# definition = '1609.344 meters' +# attribute = 'allow_long_prefix' +# ``` +# +# If the singular and plural names are the same, you can omit +# the `plural` setting. +# +# The `attribute` setting is optional. It can be set +# to one of these values: +# * 'none': this unit cannot be used with prefixes (default) +# * 'allow-long-prefix': allow prefixes like +# 'milli-' or 'giga-' with this unit +# * 'allow-short-prefix': allow abbreviated +# prefixes like 'm' or 'G' (for 'milli' and 'giga' respectively) +# * 'is-long-prefix': allow using this unit +# as a long prefix with another unit +# * 'alias': always expand this unit to its definition +# +# Here are some more examples of how you could define custom units: +# +# ``` +# [[custom_units]] +# singular = 'milli' +# definition = '0.001' +# attribute = 'is_long_prefix' +# +# [[custom_units]] +# singular = 'byte' +# plural = 'bytes' +# definition = '!' # an exclamation mark defines a new base unit +# attribute = 'allow_long_prefix' +# ``` diff --git a/cli/src/main.rs b/cli/src/main.rs index fc1d70f4..f539da50 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -10,6 +10,7 @@ mod args; mod color; mod config; mod context; +mod custom_units; mod exchange_rates; mod file_paths; mod helper; diff --git a/core/src/lib.rs b/core/src/lib.rs index 2ab916d8..b7013f4f 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -204,6 +204,7 @@ pub struct Context { random_u32: Option u32>, output_mode: OutputMode, get_exchange_rate: Option>, + custom_units: Vec<(String, String, String)>, } impl fmt::Debug for Context { @@ -235,6 +236,7 @@ impl Context { random_u32: None, output_mode: OutputMode::SimpleText, get_exchange_rate: None, + custom_units: vec![], } } @@ -335,6 +337,42 @@ impl Context { ) { self.get_exchange_rate = Some(Arc::new(get_exchange_rate)); } + + pub fn define_custom_unit_v1( + &mut self, + singular: &str, + plural: &str, + definition: &str, + attribute: CustomUnitAttribute, + ) { + let definition_prefix = match attribute { + CustomUnitAttribute::None => "", + CustomUnitAttribute::AllowLongPrefix => "l@", + CustomUnitAttribute::AllowShortPrefix => "s@", + CustomUnitAttribute::IsLongPrefix => "lp@", + CustomUnitAttribute::Alias => "=", + }; + self.custom_units.push(( + singular.to_string(), + plural.to_string(), + format!("{definition_prefix}{definition}"), + )); + } +} + +/// These attributes make is possible to change the behaviour of custom units +#[non_exhaustive] +pub enum CustomUnitAttribute { + /// Don't allow using prefixes with this custom unit + None, + /// Support long prefixes (e.g. `milli-`, `giga-`) with this unit + AllowLongPrefix, + /// Support short prefixes (e.g. `k` for `kilo`) with this unit + AllowShortPrefix, + /// Allow using this unit as a long prefix with another unit + IsLongPrefix, + /// This unit definition is an alias and will always be replaced with its definition. + Alias, } /// This function evaluates a string using the given context. Any evaluation using this diff --git a/core/src/units.rs b/core/src/units.rs index 0c5b72fa..9923158c 100644 --- a/core/src/units.rs +++ b/core/src/units.rs @@ -21,14 +21,14 @@ pub(crate) enum PrefixRule { #[derive(Debug)] pub(crate) struct UnitDef { - singular: &'static str, - plural: &'static str, + singular: Cow<'static, str>, + plural: Cow<'static, str>, prefix_rule: PrefixRule, value: Value, } fn expr_unit( - unit_def: (&'static str, &'static str, &'static str), + unit_def: (Cow<'static, str>, Cow<'static, str>, Cow<'static, str>), attrs: Attrs, context: &mut crate::Context, int: &I, @@ -39,7 +39,7 @@ fn expr_unit( let Some(exchange_rate_fn) = &context.get_exchange_rate else { return Err(FendError::NoExchangeRatesAvailable); }; - let one_base_in_currency = exchange_rate_fn.relative_to_base_currency(singular)?; + let one_base_in_currency = exchange_rate_fn.relative_to_base_currency(&singular)?; let value = evaluate_to_value( format!("(1/{one_base_in_currency}) BASE_CURRENCY").as_str(), None, @@ -51,8 +51,8 @@ fn expr_unit( let value = Number::create_unit_value_from_value( &value, Cow::Borrowed(""), - Cow::Owned(singular.to_string()), - Cow::Owned(plural.to_string()), + singular.clone(), + plural.clone(), int, )?; return Ok(UnitDef { @@ -82,8 +82,8 @@ fn expr_unit( if definition == "!" { return Ok(UnitDef { value: Value::Num(Box::new(Number::new_base_unit( - Cow::Borrowed(singular), - Cow::Borrowed(plural), + singular.clone(), + plural.clone(), ))), prefix_rule: rule, singular, @@ -98,8 +98,8 @@ fn expr_unit( num = Number::create_unit_value_from_value( &num, Cow::Borrowed(""), - Cow::Borrowed(singular), - Cow::Borrowed(plural), + singular.clone(), + plural.clone(), int, )?; } @@ -118,13 +118,8 @@ fn construct_prefixed_unit( ) -> Result { let product = a.value.expect_num()?.mul(b.value.expect_num()?, int)?; assert_eq!(a.singular, a.plural); - let unit = Number::create_unit_value_from_value( - &product, - Cow::Borrowed(a.singular), - Cow::Borrowed(b.singular), - Cow::Borrowed(b.plural), - int, - )?; + let unit = + Number::create_unit_value_from_value(&product, a.singular, b.singular, b.plural, int)?; Ok(Value::Num(Box::new(unit))) } @@ -217,12 +212,35 @@ fn query_unit_internal( case_sensitive: bool, whole_unit: bool, context: &mut crate::Context, -) -> Result<(&'static str, &'static str, &'static str), FendError> { +) -> Result<(Cow<'static, str>, Cow<'static, str>, Cow<'static, str>), FendError> { + if !short_prefixes { + for (s, p, d) in &context.custom_units { + let p = if p.is_empty() { s } else { p }; + if (ident == s || ident == p) + || (!case_sensitive + && (s.eq_ignore_ascii_case(ident) || p.eq_ignore_ascii_case(ident))) + { + return Ok(( + s.to_string().into(), + p.to_string().into(), + d.to_string().into(), + )); + } + } + } if whole_unit && context.fc_mode == crate::FCMode::CelsiusFahrenheit { if ident == "C" { - return Ok(("C", "C", "=\u{b0}C")); + return Ok(( + Cow::Borrowed("C"), + Cow::Borrowed("C"), + Cow::Borrowed("=\u{b0}C"), + )); } else if ident == "F" { - return Ok(("F", "F", "=\u{b0}F")); + return Ok(( + Cow::Borrowed("F"), + Cow::Borrowed("F"), + Cow::Borrowed("=\u{b0}F"), + )); } } if let Some(unit_def) = builtin::query_unit(ident, short_prefixes, case_sensitive) { diff --git a/core/src/units/builtin.rs b/core/src/units/builtin.rs index 18f09c6c..810031bd 100644 --- a/core/src/units/builtin.rs +++ b/core/src/units/builtin.rs @@ -1,3 +1,5 @@ +use std::borrow::Cow; + #[derive(Eq, PartialEq, PartialOrd, Ord, Clone, Copy)] struct UnitDef { singular: &'static str, @@ -505,7 +507,7 @@ const COMMON_PHYSICAL_UNITS: &[UnitTuple] = &[ ]; const CGS_UNITS: &[UnitTuple] = &[ - ("gal", "gals", "cm/s^2", "accerelation"), + ("gal", "gals", "cm/s^2", "acceleration"), ("dyne", "dynes", "g*gal", "force"), ("erg", "ergs", "g*cm^2/s^2", "work, energy"), ("barye", "baryes", "g/(cm*s^2)", "pressure"), @@ -531,7 +533,6 @@ const CGS_UNITS: &[UnitTuple] = &[ ("G", "", "gauss", ""), ("Mx", "", "maxwell", ""), ("ph", "", "phot", ""), - ("P", "", "poise", ""), ]; const IMPERIAL_UNITS: &[UnitTuple] = &[ @@ -778,11 +779,11 @@ pub(crate) fn query_unit( ident: &str, short_prefixes: bool, case_sensitive: bool, -) -> Option<(&'static str, &'static str, &'static str)> { +) -> Option<(Cow<'static, str>, Cow<'static, str>, Cow<'static, str>)> { if short_prefixes { for (name, def) in SHORT_PREFIXES { if *name == ident || (!case_sensitive && name.eq_ignore_ascii_case(ident)) { - return Some((name, name, def)); + return Some((Cow::Borrowed(name), Cow::Borrowed(name), Cow::Borrowed(def))); } } } @@ -795,7 +796,11 @@ pub(crate) fn query_unit( .as_str(), ) { let name = CURRENCY_IDENTIFIERS[idx]; - return Some((name, name, "$CURRENCY")); + return Some(( + Cow::Borrowed(name), + Cow::Borrowed(name), + Cow::Borrowed("$CURRENCY"), + )); } let mut candidates = vec![]; for group in ALL_UNIT_DEFS { @@ -806,18 +811,26 @@ pub(crate) fn query_unit( definition: def.2, }; if def.singular == ident || def.plural == ident { - return Some((def.singular, def.plural, def.definition)); + return Some(( + Cow::Borrowed(def.singular), + Cow::Borrowed(def.plural), + Cow::Borrowed(def.definition), + )); } if !case_sensitive && (def.singular.eq_ignore_ascii_case(ident) || def.plural.eq_ignore_ascii_case(ident)) { - candidates.push(Some((def.singular, def.plural, def.definition))); + candidates.push(Some(( + Cow::Borrowed(def.singular), + Cow::Borrowed(def.plural), + Cow::Borrowed(def.definition), + ))); } } } if candidates.len() == 1 { - return candidates[0]; + return candidates.into_iter().next().unwrap(); } None }