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