Files
k-launcher/crates/plugins/plugin-calc/src/lib.rs
Gabriel Kaszewski ff9b2b5712 fix(review): bugs, arch violations, design smells
P1 bugs:
- unix_launcher: shell_split respects quoted args (was split_whitespace)
- plugin-host: 5s timeout on external plugin search
- ui: handle engine init panic, wire error state
- ui-egui: read window config instead of always using defaults
- plugin-url: use OpenPath action instead of SpawnProcess+xdg-open

Architecture:
- remove WindowConfig (mirror of WindowCfg); use WindowCfg directly
- remove on_select closure from SearchResult (domain leakage)
- remove LaunchAction::Custom; add Plugin::on_selected + SearchEngine::on_selected
- apps: record frecency via on_selected instead of embedded closure

Design smells:
- frecency: extract decay_factor helper, write outside mutex
- apps: remove cfg(test) cache_path hack; add new_for_test ctor
- apps: stable ResultId using name+exec to prevent collision
- files: stable ResultId using full path instead of index
- plugin-host: remove k-launcher-os-bridge dep (WindowConfig gone)
2026-03-18 13:45:48 +01:00

155 lines
5.6 KiB
Rust

use async_trait::async_trait;
use evalexpr::eval_number_with_context;
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
use std::sync::LazyLock;
pub struct CalcPlugin;
impl CalcPlugin {
pub fn new() -> Self {
Self
}
}
impl Default for CalcPlugin {
fn default() -> Self {
Self::new()
}
}
fn strip_numeric_separators(expr: &str) -> String {
expr.replace('_', "")
}
const MATH_FNS: &[&str] = &[
"sqrt", "sin", "cos", "tan", "asin", "acos", "atan", "ln", "log2", "log10", "exp", "abs",
"ceil", "floor", "round",
];
fn should_eval(query: &str) -> bool {
let q = query.strip_prefix('=').unwrap_or(query);
q.chars()
.next()
.map(|c| c.is_ascii_digit() || c == '(' || c == '-')
.unwrap_or(false)
|| query.starts_with('=')
|| MATH_FNS.iter().any(|f| q.starts_with(f))
}
static MATH_CTX: LazyLock<evalexpr::HashMapContext<evalexpr::DefaultNumericTypes>> = LazyLock::new(
|| {
use evalexpr::*;
context_map! {
"pi" => float std::f64::consts::PI,
"e" => float std::f64::consts::E,
"sqrt" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.sqrt()))),
"sin" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.sin()))),
"cos" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.cos()))),
"tan" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.tan()))),
"asin" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.asin()))),
"acos" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.acos()))),
"atan" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.atan()))),
"ln" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.ln()))),
"log2" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.log2()))),
"log10" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.log10()))),
"exp" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.exp()))),
"abs" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.abs()))),
"ceil" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.ceil()))),
"floor" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.floor()))),
"round" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.round())))
}
.expect("static math context must be valid")
},
);
#[async_trait]
impl Plugin for CalcPlugin {
fn name(&self) -> &str {
"calc"
}
async fn search(&self, query: &str) -> Vec<SearchResult> {
if !should_eval(query) {
return vec![];
}
let raw = query.strip_prefix('=').unwrap_or(query);
let expr_owned = strip_numeric_separators(raw);
let expr = expr_owned.as_str();
match eval_number_with_context(expr, &*MATH_CTX) {
Ok(n) if n.is_finite() => {
let value_str = if n.fract() == 0.0 {
format!("{}", n as i64)
} else {
format!("{n}")
};
let display = format!("= {value_str}");
vec![SearchResult {
id: ResultId::new("calc-result"),
title: ResultTitle::new(display),
description: Some(format!("{expr_owned} · Enter to copy")),
icon: None,
score: Score::new(90),
action: LaunchAction::CopyToClipboard(value_str),
}]
}
_ => vec![],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn calc_valid_expr() {
let p = CalcPlugin::new();
let results = p.search("2+2").await;
assert_eq!(results[0].title.as_str(), "= 4");
}
#[tokio::test]
async fn calc_non_numeric_returns_empty() {
let p = CalcPlugin::new();
assert!(p.search("firefox").await.is_empty());
}
#[tokio::test]
async fn calc_bad_expr_returns_empty() {
let p = CalcPlugin::new();
assert!(p.search("1/0").await.is_empty());
}
#[tokio::test]
async fn calc_sqrt() {
let p = CalcPlugin::new();
let results = p.search("sqrt(9)").await;
assert_eq!(results[0].title.as_str(), "= 3");
}
#[tokio::test]
async fn calc_sin_pi() {
let p = CalcPlugin::new();
let results = p.search("sin(pi)").await;
assert!(!results.is_empty());
let title = results[0].title.as_str();
let val: f64 = title.trim_start_matches("= ").parse().unwrap();
assert!(val.abs() < 1e-10, "sin(pi) should be near zero, got {val}");
}
#[tokio::test]
async fn calc_underscore_separator() {
let p = CalcPlugin::new();
let results = p.search("1_000 * 2").await;
assert_eq!(results[0].title.as_str(), "= 2000");
assert_eq!(
results[0].description.as_deref(),
Some("1000 * 2 · Enter to copy")
);
assert!(matches!(
&results[0].action,
LaunchAction::CopyToClipboard(v) if v == "2000"
));
}
}