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