gravel_provider_calculator/
lib.rs

1//! Calculator provider based on [`mexprp`].
2//!
3//! Whenever the input can be parsed as a mathematical expression, shows the
4//! result as the first hit.
5//!
6//! Selecting the hit copies the calculated value to the system's clipboard.
7
8use abi_stable::reexports::SelfOps;
9use gravel_ffi::prelude::*;
10use mexprp::Answer;
11use serde::Deserialize;
12
13const DEFAULT_CONFIG: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/config.yml"));
14
15struct CalculatorProvider {
16	config: Config,
17}
18
19#[gravel_provider("calculator")]
20impl Provider for CalculatorProvider {
21	fn new(config: &PluginConfigAdapter<'_>) -> Self {
22		Self {
23			config: config.get(DEFAULT_CONFIG),
24		}
25	}
26
27	fn query(&self, query: &str) -> ProviderResult {
28		let query = query.trim();
29
30		eval(query)
31			.filter(|r| !query_was_const(query, r))
32			.map(|r| self.get_hit(r))
33			.piped(ProviderResult::from_option)
34	}
35}
36
37impl CalculatorProvider {
38	fn get_hit(&self, result: String) -> SimpleHit {
39		SimpleHit::new(result, self.config.subtitle.clone(), move |hit, ctx| {
40			ctx.set_clipboard_text(hit.title().to_string().into_c());
41			ctx.hide_frontend();
42		})
43		.with_secondary(|hit, ctx| {
44			ctx.set_query(hit.title().as_str().to_owned().into_c());
45		})
46		.with_score(MAX_SCORE)
47	}
48}
49
50// queries that do not require any calculation should be ignored
51fn query_was_const(query: &str, result: &str) -> bool {
52	query == result || matches!(query, "e" | "pi" | "i")
53}
54
55fn eval(expression: &str) -> Option<String> {
56	match mexprp::eval(expression) {
57		Ok(Answer::Single(result)) => Some(result),
58		Ok(Answer::Multiple(results)) => results.into_iter().next(),
59		_ => None,
60	}
61	.map(|r| round(r, 10).to_string())
62}
63
64fn round(number: f64, precision: u32) -> f64 {
65	let factor = 10_u64.pow(precision) as f64;
66	(number * factor).round() / factor
67}
68
69#[derive(Deserialize, Debug)]
70struct Config {
71	pub subtitle: String,
72}
73
74#[cfg(test)]
75mod tests {
76	use super::*;
77	use rstest::rstest;
78
79	#[rstest]
80	#[case("1", "1")]
81	#[case("1 + 1", "2")]
82	#[case("1 - 1", "0")]
83	#[case("1 / 1", "1")]
84	#[case("1 * 1", "1")]
85	#[case("3 * 0.2", "0.6")]
86	#[case("1 / 20", "0.05")]
87	#[case("2 ^ 10", "1024")]
88	#[case("0.1 + 0.2", "0.3")]
89	#[case("(2 + 3) * (3 - 5)", "-10")]
90	#[case("-2 ^ 3", "-8")]
91	#[case("round(2pi)", "6")]
92	#[case("sqrt(2)", "1.4142135624")]
93	#[case("sin(asin(0.5))", "0.5")]
94	fn should_eval(#[case] expression: &str, #[case] expected: &str) {
95		let actual = eval(expression);
96		assert_eq!(Some(expected), actual.as_deref(), "{expression}");
97	}
98
99	#[rstest]
100	#[case("clippy")]
101	#[case("1 1")]
102	#[case("1 / 0")]
103	#[case("x + 5")]
104	fn should_fail(#[case] expression: &str) {
105		let actual = eval(expression);
106		assert_eq!(None, actual, "{expression}");
107	}
108}