Skip to main content

ccalc_engine/
env.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::rc::Rc;
4
5use indexmap::IndexMap;
6use ndarray::Array2;
7use num_complex::Complex;
8
9use crate::io::IoContext;
10
11/// A type-erased callable for anonymous functions (lambdas).
12///
13/// Stores a heap-allocated closure that captures the lambda's body expression
14/// and the lexical environment at the point of definition.
15/// Two `LambdaFn` values are equal only if they are the exact same allocation.
16type LambdaFnInner = Rc<dyn Fn(&[Value], Option<&mut IoContext>) -> Result<Value, String>>;
17
18/// A compiled anonymous function closure with its source text.
19///
20/// The first field is the callable closure; the second is the display source
21/// (e.g. `@(x) x^2`) used by `format_value` and the `who` command.
22#[derive(Clone)]
23pub struct LambdaFn(pub LambdaFnInner, pub String);
24
25impl std::fmt::Debug for LambdaFn {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        write!(f, "{}", self.1)
28    }
29}
30
31impl PartialEq for LambdaFn {
32    fn eq(&self, other: &Self) -> bool {
33        Rc::ptr_eq(&self.0, &other.0)
34    }
35}
36
37/// A value held in the variable environment.
38#[derive(Debug, Clone, PartialEq)]
39pub enum Value {
40    /// No display value — returned by side-effectful functions like `fprintf`.
41    Void,
42    /// A single real number.
43    Scalar(f64),
44    /// A 2-D real matrix (row-major). Scalars are represented as 1×1 matrices
45    /// only when produced by matrix operations; standalone numbers use `Scalar`.
46    Matrix(Array2<f64>),
47    /// A 2-D complex matrix (row-major). Used when any element has a non-zero imaginary part.
48    ///
49    /// Produced by complex matrix literals, FFT output, or mixed real/complex arithmetic.
50    /// Workspace save skips this type (same policy as all non-scalar types).
51    ComplexMatrix(Array2<Complex<f64>>),
52    /// Complex number `re + im*i`.
53    Complex(f64, f64),
54    /// Character array (single-quoted string). Represents a 1×N row of char values.
55    Str(String),
56    /// String object (double-quoted string).
57    StringObj(String),
58    /// Anonymous function: `@(params) expr`. Stores a pre-compiled closure
59    /// that captures the lexical environment at definition time.
60    Lambda(LambdaFn),
61    /// Named user-defined function: `function [outputs] = name(params) ... end`.
62    ///
63    /// The body is stored as raw source text and re-parsed on each call.
64    /// Named functions execute in an isolated scope (only params are visible,
65    /// plus built-in constants `i`, `j`).
66    Function {
67        /// Output variable names in declaration order (e.g. `["y"]` for `function y = f(x)`).
68        outputs: Vec<String>,
69        /// Parameter names in declaration order (e.g. `["x", "n"]`).
70        params: Vec<String>,
71        /// Raw source text of the function body (text between `function` header and `end`).
72        body_source: String,
73        /// Local helper functions defined in the same function file (MATLAB-style scoping).
74        /// Populated when a function file is sourced; empty for inline definitions.
75        locals: IndexMap<String, Value>,
76        /// Documentation string extracted from `%`-prefixed lines immediately before the
77        /// `function` keyword. `None` when no leading comment block is present.
78        doc: Option<String>,
79    },
80    /// Multiple return values from a multi-output function call (internal use).
81    ///
82    /// Produced by calling a function with `outputs.len() > 1`.
83    /// Consumed by `Stmt::MultiAssign` in exec.rs. Not directly user-visible.
84    Tuple(Vec<Value>),
85    /// Heterogeneous 1-D container: each element may be any `Value`.
86    ///
87    /// Created with `{1, 'hello', [1 2 3]}` syntax. Indexed with `c{i}` (1-based).
88    /// 2-D cell arrays are deferred; all cells are flat `Vec<Value>` for now.
89    Cell(Vec<Value>),
90    /// Scalar struct: ordered field map, field names preserved in insertion order.
91    ///
92    /// Created with `s.field = val` or `struct('k', v, ...)`.
93    /// Fields can hold any `Value`, including nested structs.
94    Struct(IndexMap<String, Value>),
95    /// 1-D array of structs (all sharing the same field schema).
96    ///
97    /// Created with `s(i).field = val` (1-based). Indexed with `s(i)` which returns
98    /// a `Value::Struct`. `s.field` collects the field across all elements.
99    StructArray(Vec<IndexMap<String, Value>>),
100    /// A UTC timestamp: seconds since 1970-01-01 00:00:00 UTC.
101    ///
102    /// `f64::NAN` represents the `NaT` (Not-a-Time) sentinel.
103    DateTime(f64),
104    /// An elapsed duration stored as a fractional number of seconds.
105    ///
106    /// May be negative (e.g. `t1 - t2` when `t1 < t2`).
107    Duration(f64),
108    /// An ordered sequence of UTC timestamps (seconds since epoch).
109    ///
110    /// Each element follows the same `NaT`-as-NaN convention as [`Value::DateTime`].
111    DateTimeArray(Vec<f64>),
112    /// An ordered sequence of elapsed durations (seconds, fractional).
113    DurationArray(Vec<f64>),
114}
115
116impl Value {
117    /// Returns the inner `f64` if this value is a [`Value::Scalar`], otherwise `None`.
118    ///
119    /// # Examples
120    ///
121    /// ```
122    /// use ccalc_engine::env::Value;
123    ///
124    /// assert_eq!(Value::Scalar(3.14).as_scalar(), Some(3.14));
125    /// assert_eq!(Value::Void.as_scalar(), None);
126    /// ```
127    pub fn as_scalar(&self) -> Option<f64> {
128        match self {
129            Value::Scalar(n) => Some(*n),
130            Value::Void
131            | Value::Matrix(_)
132            | Value::ComplexMatrix(_)
133            | Value::Complex(_, _)
134            | Value::Str(_)
135            | Value::StringObj(_)
136            | Value::Lambda(_)
137            | Value::Function { .. }
138            | Value::Tuple(_)
139            | Value::Cell(_)
140            | Value::Struct(_)
141            | Value::StructArray(_)
142            | Value::DateTime(_)
143            | Value::Duration(_)
144            | Value::DateTimeArray(_)
145            | Value::DurationArray(_) => None,
146        }
147    }
148}
149
150/// Variable environment: maps names to values.
151///
152/// `ans` is the reserved name for the result of the last expression
153/// that was not assigned to a named variable (Octave/MATLAB convention).
154pub type Env = HashMap<String, Value>;
155
156/// Returns the platform-specific configuration directory for ccalc.
157///
158/// On Linux/macOS this is typically `~/.config/ccalc`; on Windows `%APPDATA%\ccalc`.
159/// Falls back to the current directory if the platform config dir cannot be determined.
160pub fn config_dir() -> PathBuf {
161    dirs::config_dir()
162        .unwrap_or_else(|| PathBuf::from("."))
163        .join("ccalc")
164}
165
166fn workspace_path() -> PathBuf {
167    config_dir().join("workspace.toml")
168}
169
170/// Serializes one `Value` to a workspace line value string.
171/// Returns `None` for types that cannot be persisted (Matrix, Complex, Void,
172/// or strings containing characters that would break the format).
173fn serialize_value(v: &Value) -> Option<String> {
174    match v {
175        Value::Scalar(n) => Some(format!("{n}")),
176        // Char arrays: wrap in single quotes; skip if contains ' or newline
177        Value::Str(s) if !s.contains('\'') && !s.contains('\n') => Some(format!("'{s}'")),
178        // String objects: wrap in double quotes; skip if contains " or newline
179        Value::StringObj(s) if !s.contains('"') && !s.contains('\n') => Some(format!("\"{s}\"")),
180        _ => None,
181    }
182}
183
184/// Saves scalars and strings from `env` to `path`.
185/// Matrices, complex values, and strings with unsafe characters are skipped.
186/// Format: `name = value` per line, where value is a raw f64, `'str'`, or `"strobj"`.
187pub fn save_workspace(env: &Env, path: &Path) -> Result<(), String> {
188    if let Some(parent) = path.parent() {
189        std::fs::create_dir_all(parent).map_err(|e| format!("Cannot create config dir: {e}"))?;
190    }
191    let mut entries: Vec<(&String, String)> = env
192        .iter()
193        .filter_map(|(k, v)| serialize_value(v).map(|s| (k, s)))
194        .collect();
195    entries.sort_by_key(|(k, _)| k.as_str());
196    let mut content = String::new();
197    for (name, val) in entries {
198        content.push_str(&format!("{name} = {val}\n"));
199    }
200    std::fs::write(path, &content).map_err(|e| format!("Cannot write {}: {e}", path.display()))
201}
202
203/// Saves only the named variables from `env` to `path`.
204/// Variables not present in `env` are silently ignored.
205pub fn save_workspace_vars(env: &Env, path: &Path, vars: &[&str]) -> Result<(), String> {
206    let filtered: Env = env
207        .iter()
208        .filter(|(k, _)| vars.contains(&k.as_str()))
209        .map(|(k, v)| (k.clone(), v.clone()))
210        .collect();
211    save_workspace(&filtered, path)
212}
213
214/// Loads variables from `path` into a new `Env`.
215/// Recognises: `name = 3.14` (Scalar), `name = 'str'` (Str), `name = "str"` (StringObj).
216/// Unrecognised lines are silently skipped.
217pub fn load_workspace(path: &Path) -> Result<Env, String> {
218    let content = std::fs::read_to_string(path)
219        .map_err(|e| format!("Cannot read {}: {e}", path.display()))?;
220    let mut env = Env::new();
221    for line in content.lines() {
222        let line = line.trim();
223        if line.is_empty() || line.starts_with('%') {
224            continue;
225        }
226        if let Some((key, val)) = line.split_once('=') {
227            let key = key.trim();
228            let val = val.trim();
229            if !is_valid_ident(key) {
230                continue;
231            }
232            let value = if val.starts_with('\'') && val.ends_with('\'') && val.len() >= 2 {
233                Value::Str(val[1..val.len() - 1].to_string())
234            } else if val.starts_with('"') && val.ends_with('"') && val.len() >= 2 {
235                Value::StringObj(val[1..val.len() - 1].to_string())
236            } else if let Ok(n) = val.parse::<f64>() {
237                Value::Scalar(n)
238            } else {
239                continue;
240            };
241            env.insert(key.to_string(), value);
242        }
243    }
244    Ok(env)
245}
246
247/// Saves scalars and strings from `env` to the default workspace path (`~/.config/ccalc/workspace.toml`).
248pub fn save_workspace_default(env: &Env) -> Result<(), String> {
249    save_workspace(env, &workspace_path())
250}
251
252/// Loads variables from the default workspace path (`~/.config/ccalc/workspace.toml`) into a new [`Env`].
253pub fn load_workspace_default() -> Result<Env, String> {
254    load_workspace(&workspace_path())
255}
256
257fn is_valid_ident(s: &str) -> bool {
258    let mut chars = s.chars();
259    match chars.next() {
260        Some(c) if c.is_alphabetic() || c == '_' => chars.all(|c| c.is_alphanumeric() || c == '_'),
261        _ => false,
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[allow(clippy::approx_constant)]
270    #[test]
271    fn test_save_load_roundtrip() {
272        let path = std::env::temp_dir().join("ccalc_test_workspace_roundtrip.toml");
273        let mut env = Env::new();
274        env.insert("x".to_string(), Value::Scalar(42.0));
275        env.insert("y".to_string(), Value::Scalar(-3.14));
276        env.insert("ans".to_string(), Value::Scalar(10.0));
277        save_workspace(&env, &path).unwrap();
278
279        let loaded = load_workspace(&path).unwrap();
280        assert_eq!(loaded.get("x"), Some(&Value::Scalar(42.0)));
281        assert_eq!(loaded.get("y"), Some(&Value::Scalar(-3.14)));
282        assert_eq!(loaded.get("ans"), Some(&Value::Scalar(10.0)));
283        std::fs::remove_file(&path).ok();
284    }
285
286    #[test]
287    fn test_save_empty_workspace() {
288        let path = std::env::temp_dir().join("ccalc_test_workspace_empty.toml");
289        save_workspace(&Env::new(), &path).unwrap();
290        let content = std::fs::read_to_string(&path).unwrap();
291        assert!(content.is_empty());
292        std::fs::remove_file(&path).ok();
293    }
294
295    #[test]
296    fn test_load_nonexistent_returns_error() {
297        let path = std::env::temp_dir().join("ccalc_test_workspace_nonexistent_xyz.toml");
298        let _ = std::fs::remove_file(&path);
299        assert!(load_workspace(&path).is_err());
300    }
301
302    #[test]
303    fn test_load_ignores_invalid_lines() {
304        let path = std::env::temp_dir().join("ccalc_test_workspace_invalid.toml");
305        std::fs::write(&path, "# comment\n\nx = 5\n1bad = 9\ngood = abc\n").unwrap();
306        let env = load_workspace(&path).unwrap();
307        assert_eq!(env.get("x"), Some(&Value::Scalar(5.0)));
308        assert!(!env.contains_key("1bad"));
309        assert!(!env.contains_key("good")); // value not a float
310        std::fs::remove_file(&path).ok();
311    }
312
313    #[test]
314    fn test_is_valid_ident() {
315        assert!(is_valid_ident("x"));
316        assert!(is_valid_ident("my_var"));
317        assert!(is_valid_ident("_private"));
318        assert!(is_valid_ident("var1"));
319        assert!(is_valid_ident("ans"));
320        assert!(!is_valid_ident("1x"));
321        assert!(!is_valid_ident(""));
322        assert!(!is_valid_ident("a b"));
323        assert!(!is_valid_ident("a-b"));
324    }
325
326    #[test]
327    fn test_save_skips_matrices() {
328        use ndarray::array;
329        let path = std::env::temp_dir().join("ccalc_test_workspace_matrix_skip.toml");
330        let mut env = Env::new();
331        env.insert("x".to_string(), Value::Scalar(5.0));
332        env.insert(
333            "m".to_string(),
334            Value::Matrix(array![[1.0, 2.0], [3.0, 4.0]]),
335        );
336        save_workspace(&env, &path).unwrap();
337        let content = std::fs::read_to_string(&path).unwrap();
338        assert!(content.contains("x = 5"));
339        assert!(!content.contains("m"));
340        std::fs::remove_file(&path).ok();
341    }
342
343    #[test]
344    fn test_save_load_strings() {
345        let path = std::env::temp_dir().join("ccalc_test_workspace_strings.toml");
346        let mut env = Env::new();
347        env.insert("name".to_string(), Value::Str("hello".to_string()));
348        env.insert("tag".to_string(), Value::StringObj("world".to_string()));
349        env.insert("n".to_string(), Value::Scalar(1.0));
350        save_workspace(&env, &path).unwrap();
351
352        let loaded = load_workspace(&path).unwrap();
353        assert_eq!(loaded.get("name"), Some(&Value::Str("hello".to_string())));
354        assert_eq!(
355            loaded.get("tag"),
356            Some(&Value::StringObj("world".to_string()))
357        );
358        assert_eq!(loaded.get("n"), Some(&Value::Scalar(1.0)));
359        std::fs::remove_file(&path).ok();
360    }
361
362    #[test]
363    fn test_save_skips_string_with_unsafe_chars() {
364        let path = std::env::temp_dir().join("ccalc_test_workspace_unsafe_str.toml");
365        let mut env = Env::new();
366        env.insert("s".to_string(), Value::Str("it's".to_string())); // embedded quote
367        env.insert("x".to_string(), Value::Scalar(5.0));
368        save_workspace(&env, &path).unwrap();
369
370        let content = std::fs::read_to_string(&path).unwrap();
371        assert!(content.contains("x = 5"));
372        assert!(!content.contains("it's")); // unsafe string skipped
373        std::fs::remove_file(&path).ok();
374    }
375
376    #[test]
377    fn test_save_workspace_vars_selective() {
378        let path = std::env::temp_dir().join("ccalc_test_workspace_vars.toml");
379        let mut env = Env::new();
380        env.insert("x".to_string(), Value::Scalar(1.0));
381        env.insert("y".to_string(), Value::Scalar(2.0));
382        env.insert("z".to_string(), Value::Scalar(3.0));
383        save_workspace_vars(&env, &path, &["x", "z"]).unwrap();
384
385        let loaded = load_workspace(&path).unwrap();
386        assert_eq!(loaded.get("x"), Some(&Value::Scalar(1.0)));
387        assert_eq!(loaded.get("z"), Some(&Value::Scalar(3.0)));
388        assert!(!loaded.contains_key("y")); // not in the list
389        std::fs::remove_file(&path).ok();
390    }
391}