""" Expression evaluator for S-expression DSL. Supports: - Arithmetic: +, -, *, /, mod, sqrt, pow, abs, floor, ceil, round, min, max, clamp - Comparison: =, <, >, <=, >= - Logic: and, or, not - Predicates: odd?, even?, zero?, nil? - Conditionals: if, cond, case - Data: list, dict/map construction, get - Lambda calls """ from typing import Any, Dict, List, Callable from .parser import Symbol, Keyword, Lambda, Binding class EvalError(Exception): """Error during expression evaluation.""" pass # Built-in functions BUILTINS: Dict[str, Callable] = {} def builtin(name: str): """Decorator to register a builtin function.""" def decorator(fn): BUILTINS[name] = fn return fn return decorator @builtin("+") def add(*args): return sum(args) @builtin("-") def sub(a, b=None): if b is None: return -a return a - b @builtin("*") def mul(*args): result = 1 for a in args: result *= a return result @builtin("/") def div(a, b): return a / b @builtin("mod") def mod(a, b): return a % b @builtin("sqrt") def sqrt(x): return x ** 0.5 @builtin("pow") def power(x, n): return x ** n @builtin("abs") def absolute(x): return abs(x) @builtin("floor") def floor_fn(x): import math return math.floor(x) @builtin("ceil") def ceil_fn(x): import math return math.ceil(x) @builtin("round") def round_fn(x, ndigits=0): return round(x, int(ndigits)) @builtin("min") def min_fn(*args): if len(args) == 1 and isinstance(args[0], (list, tuple)): return min(args[0]) return min(args) @builtin("max") def max_fn(*args): if len(args) == 1 and isinstance(args[0], (list, tuple)): return max(args[0]) return max(args) @builtin("clamp") def clamp(x, lo, hi): return max(lo, min(hi, x)) @builtin("=") def eq(a, b): return a == b @builtin("<") def lt(a, b): return a < b @builtin(">") def gt(a, b): return a > b @builtin("<=") def lte(a, b): return a <= b @builtin(">=") def gte(a, b): return a >= b @builtin("odd?") def is_odd(n): return n % 2 == 1 @builtin("even?") def is_even(n): return n % 2 == 0 @builtin("zero?") def is_zero(n): return n == 0 @builtin("nil?") def is_nil(x): return x is None @builtin("not") def not_fn(x): return not x @builtin("inc") def inc(n): return n + 1 @builtin("dec") def dec(n): return n - 1 @builtin("list") def make_list(*args): return list(args) @builtin("assert") def assert_true(condition, message="Assertion failed"): if not condition: raise RuntimeError(f"Assertion error: {message}") return True @builtin("get") def get(coll, key, default=None): if isinstance(coll, dict): # Try the key directly first result = coll.get(key, None) if result is not None: return result # If key is a Keyword, also try its string name (for Python dicts with string keys) if isinstance(key, Keyword): result = coll.get(key.name, None) if result is not None: return result # Return the default return default elif isinstance(coll, list): return coll[key] if 0 <= key < len(coll) else default else: raise EvalError(f"get: expected dict or list, got {type(coll).__name__}: {str(coll)[:100]}") @builtin("dict?") def is_dict(x): return isinstance(x, dict) @builtin("list?") def is_list(x): return isinstance(x, list) @builtin("nil?") def is_nil(x): return x is None @builtin("number?") def is_number(x): return isinstance(x, (int, float)) @builtin("string?") def is_string(x): return isinstance(x, str) @builtin("len") def length(coll): return len(coll) @builtin("first") def first(coll): return coll[0] if coll else None @builtin("last") def last(coll): return coll[-1] if coll else None @builtin("chunk-every") def chunk_every(coll, n): """Split collection into chunks of n elements.""" n = int(n) return [coll[i:i+n] for i in range(0, len(coll), n)] @builtin("rest") def rest(coll): return coll[1:] if coll else [] @builtin("nth") def nth(coll, n): return coll[n] if 0 <= n < len(coll) else None @builtin("concat") def concat(*colls): """Concatenate multiple lists/sequences.""" result = [] for c in colls: if c is not None: result.extend(c) return result @builtin("cons") def cons(x, coll): """Prepend x to collection.""" return [x] + list(coll) if coll else [x] @builtin("append") def append(coll, x): """Append x to collection.""" return list(coll) + [x] if coll else [x] @builtin("range") def make_range(start, end, step=1): """Create a range of numbers.""" return list(range(int(start), int(end), int(step))) @builtin("zip-pairs") def zip_pairs(coll): """Zip consecutive pairs: [a,b,c,d] -> [[a,b],[b,c],[c,d]].""" if not coll or len(coll) < 2: return [] return [[coll[i], coll[i+1]] for i in range(len(coll)-1)] @builtin("dict") def make_dict(*pairs): """Create dict from key-value pairs: (dict :a 1 :b 2).""" result = {} i = 0 while i < len(pairs) - 1: key = pairs[i] if isinstance(key, Keyword): key = key.name result[key] = pairs[i + 1] i += 2 return result @builtin("keys") def keys(d): """Get the keys of a dict as a list.""" if not isinstance(d, dict): raise EvalError(f"keys: expected dict, got {type(d).__name__}") return list(d.keys()) @builtin("vals") def vals(d): """Get the values of a dict as a list.""" if not isinstance(d, dict): raise EvalError(f"vals: expected dict, got {type(d).__name__}") return list(d.values()) @builtin("merge") def merge(*dicts): """Merge multiple dicts, later dicts override earlier.""" result = {} for d in dicts: if d is not None: if not isinstance(d, dict): raise EvalError(f"merge: expected dict, got {type(d).__name__}") result.update(d) return result @builtin("assoc") def assoc(d, *pairs): """Associate keys with values in a dict: (assoc d :a 1 :b 2).""" if d is None: result = {} elif isinstance(d, dict): result = dict(d) else: raise EvalError(f"assoc: expected dict or nil, got {type(d).__name__}") i = 0 while i < len(pairs) - 1: key = pairs[i] if isinstance(key, Keyword): key = key.name result[key] = pairs[i + 1] i += 2 return result @builtin("dissoc") def dissoc(d, *keys_to_remove): """Remove keys from a dict: (dissoc d :a :b).""" if d is None: return {} if not isinstance(d, dict): raise EvalError(f"dissoc: expected dict or nil, got {type(d).__name__}") result = dict(d) for key in keys_to_remove: if isinstance(key, Keyword): key = key.name result.pop(key, None) return result @builtin("into") def into(target, coll): """Convert a collection into another collection type. (into [] {:a 1 :b 2}) -> [["a" 1] ["b" 2]] (into {} [[:a 1] [:b 2]]) -> {"a": 1, "b": 2} (into [] [1 2 3]) -> [1 2 3] """ if isinstance(target, list): if isinstance(coll, dict): return [[k, v] for k, v in coll.items()] elif isinstance(coll, (list, tuple)): return list(coll) else: raise EvalError(f"into: cannot convert {type(coll).__name__} into list") elif isinstance(target, dict): if isinstance(coll, dict): return dict(coll) elif isinstance(coll, (list, tuple)): result = {} for item in coll: if isinstance(item, (list, tuple)) and len(item) >= 2: key = item[0] if isinstance(key, Keyword): key = key.name result[key] = item[1] else: raise EvalError(f"into: expected [key value] pairs, got {item}") return result else: raise EvalError(f"into: cannot convert {type(coll).__name__} into dict") else: raise EvalError(f"into: unsupported target type {type(target).__name__}") @builtin("filter") def filter_fn(pred, coll): """Filter collection by predicate. Pred must be a lambda.""" if not isinstance(pred, Lambda): raise EvalError(f"filter: expected lambda as predicate, got {type(pred).__name__}") result = [] for item in coll: # Evaluate predicate with item local_env = {} if pred.closure: local_env.update(pred.closure) local_env[pred.params[0]] = item # Inline evaluation of pred.body from . import evaluator if evaluator.evaluate(pred.body, local_env): result.append(item) return result @builtin("some") def some(pred, coll): """Return first truthy value of (pred item) for items in coll, or nil.""" if not isinstance(pred, Lambda): raise EvalError(f"some: expected lambda as predicate, got {type(pred).__name__}") for item in coll: local_env = {} if pred.closure: local_env.update(pred.closure) local_env[pred.params[0]] = item from . import evaluator result = evaluator.evaluate(pred.body, local_env) if result: return result return None @builtin("every?") def every(pred, coll): """Return true if (pred item) is truthy for all items in coll.""" if not isinstance(pred, Lambda): raise EvalError(f"every?: expected lambda as predicate, got {type(pred).__name__}") for item in coll: local_env = {} if pred.closure: local_env.update(pred.closure) local_env[pred.params[0]] = item from . import evaluator if not evaluator.evaluate(pred.body, local_env): return False return True @builtin("empty?") def is_empty(coll): """Return true if collection is empty.""" if coll is None: return True return len(coll) == 0 @builtin("contains?") def contains(coll, key): """Check if collection contains key (for dicts) or element (for lists).""" if isinstance(coll, dict): if isinstance(key, Keyword): key = key.name return key in coll elif isinstance(coll, (list, tuple)): return key in coll return False def evaluate(expr: Any, env: Dict[str, Any] = None) -> Any: """ Evaluate an S-expression in the given environment. Args: expr: The expression to evaluate env: Variable bindings (name -> value) Returns: The result of evaluation """ if env is None: env = {} # Literals if isinstance(expr, (int, float, str, bool)) or expr is None: return expr # Symbol - variable lookup if isinstance(expr, Symbol): name = expr.name if name in env: return env[name] if name in BUILTINS: return BUILTINS[name] if name == "true": return True if name == "false": return False if name == "nil": return None raise EvalError(f"Undefined symbol: {name}") # Keyword - return as-is (used as map keys) if isinstance(expr, Keyword): return expr.name # Lambda - return as-is (it's a value) if isinstance(expr, Lambda): return expr # Binding - return as-is (resolved at execution time) if isinstance(expr, Binding): return expr # Dict literal if isinstance(expr, dict): return {k: evaluate(v, env) for k, v in expr.items()} # List - function call or special form if isinstance(expr, list): if not expr: return [] head = expr[0] # If head is a string/number/etc (not Symbol), treat as data list if not isinstance(head, (Symbol, Lambda, list)): return [evaluate(x, env) for x in expr] # Special forms if isinstance(head, Symbol): name = head.name # if - conditional if name == "if": if len(expr) < 3: raise EvalError("if requires condition and then-branch") cond_result = evaluate(expr[1], env) if cond_result: return evaluate(expr[2], env) elif len(expr) > 3: return evaluate(expr[3], env) return None # cond - multi-way conditional # Supports both Clojure style: (cond test1 result1 test2 result2 :else default) # and Scheme style: (cond (test1 result1) (test2 result2) (else default)) if name == "cond": clauses = expr[1:] # Check if Clojure style (flat list) or Scheme style (nested pairs) # Scheme style: first clause is (test result) - exactly 2 elements # Clojure style: first clause is a test expression like (= x 0) - 3+ elements first_is_scheme_clause = ( clauses and isinstance(clauses[0], list) and len(clauses[0]) == 2 and not (isinstance(clauses[0][0], Symbol) and clauses[0][0].name in ('=', '<', '>', '<=', '>=', '!=', 'not=', 'and', 'or')) ) if first_is_scheme_clause: # Scheme style: ((test result) ...) for clause in clauses: if not isinstance(clause, list) or len(clause) < 2: raise EvalError("cond clause must be (test result)") test = clause[0] # Check for else/default if isinstance(test, Symbol) and test.name in ("else", ":else"): return evaluate(clause[1], env) if isinstance(test, Keyword) and test.name == "else": return evaluate(clause[1], env) if evaluate(test, env): return evaluate(clause[1], env) else: # Clojure style: test1 result1 test2 result2 ... i = 0 while i < len(clauses) - 1: test = clauses[i] result = clauses[i + 1] # Check for :else if isinstance(test, Keyword) and test.name == "else": return evaluate(result, env) if isinstance(test, Symbol) and test.name == ":else": return evaluate(result, env) if evaluate(test, env): return evaluate(result, env) i += 2 return None # case - switch on value # (case expr val1 result1 val2 result2 :else default) if name == "case": if len(expr) < 2: raise EvalError("case requires expression to match") match_val = evaluate(expr[1], env) clauses = expr[2:] i = 0 while i < len(clauses) - 1: test = clauses[i] result = clauses[i + 1] # Check for :else / else if isinstance(test, Keyword) and test.name == "else": return evaluate(result, env) if isinstance(test, Symbol) and test.name in (":else", "else"): return evaluate(result, env) # Evaluate test value and compare test_val = evaluate(test, env) if match_val == test_val: return evaluate(result, env) i += 2 return None # and - short-circuit if name == "and": result = True for arg in expr[1:]: result = evaluate(arg, env) if not result: return result return result # or - short-circuit if name == "or": result = False for arg in expr[1:]: result = evaluate(arg, env) if result: return result return result # let and let* - local bindings (both bind sequentially in this impl) if name in ("let", "let*"): if len(expr) < 3: raise EvalError(f"{name} requires bindings and body") bindings = expr[1] local_env = dict(env) if isinstance(bindings, list): # Check if it's ((name value) ...) style (Lisp let* style) if bindings and isinstance(bindings[0], list): for binding in bindings: if len(binding) != 2: raise EvalError(f"{name} binding must be (name value)") var_name = binding[0] if isinstance(var_name, Symbol): var_name = var_name.name value = evaluate(binding[1], local_env) local_env[var_name] = value # Vector-style [name value ...] elif len(bindings) % 2 == 0: for i in range(0, len(bindings), 2): var_name = bindings[i] if isinstance(var_name, Symbol): var_name = var_name.name value = evaluate(bindings[i + 1], local_env) local_env[var_name] = value else: raise EvalError(f"{name} bindings must be [name value ...] or ((name value) ...)") else: raise EvalError(f"{name} bindings must be a list") return evaluate(expr[2], local_env) # lambda / fn - create function with closure if name in ("lambda", "fn"): if len(expr) < 3: raise EvalError("lambda requires params and body") params = expr[1] if not isinstance(params, list): raise EvalError("lambda params must be a list") param_names = [] for p in params: if isinstance(p, Symbol): param_names.append(p.name) elif isinstance(p, str): param_names.append(p) else: raise EvalError(f"Invalid param: {p}") # Capture current environment as closure return Lambda(param_names, expr[2], dict(env)) # quote - return unevaluated if name == "quote": return expr[1] if len(expr) > 1 else None # bind - create binding to analysis data # (bind analysis-var) # (bind analysis-var :range [0.3 1.0]) # (bind analysis-var :range [0 100] :transform sqrt) if name == "bind": if len(expr) < 2: raise EvalError("bind requires analysis reference") analysis_ref = expr[1] if isinstance(analysis_ref, Symbol): symbol_name = analysis_ref.name # Look up the symbol in environment if symbol_name in env: resolved = env[symbol_name] # If resolved is actual analysis data (dict with times/values or # S-expression list with Keywords), keep the symbol name as reference # for later lookup at execution time if isinstance(resolved, dict) and ("times" in resolved or "values" in resolved): analysis_ref = symbol_name # Use name as reference, not the data elif isinstance(resolved, list) and any(isinstance(x, Keyword) for x in resolved): # Parsed S-expression analysis data ([:times [...] :duration ...]) analysis_ref = symbol_name else: analysis_ref = resolved else: raise EvalError(f"bind: undefined symbol '{symbol_name}' - must reference analysis data") # Parse optional :range [min max] and :transform range_min, range_max = 0.0, 1.0 transform = None i = 2 while i < len(expr): if isinstance(expr[i], Keyword): kw = expr[i].name if kw == "range" and i + 1 < len(expr): range_val = evaluate(expr[i + 1], env) # Evaluate to get actual value if isinstance(range_val, list) and len(range_val) >= 2: range_min = float(range_val[0]) range_max = float(range_val[1]) i += 2 elif kw == "transform" and i + 1 < len(expr): t = expr[i + 1] if isinstance(t, Symbol): transform = t.name elif isinstance(t, str): transform = t i += 2 else: i += 1 else: i += 1 return Binding(analysis_ref, range_min=range_min, range_max=range_max, transform=transform) # Vector literal [a b c] if name == "vec" or name == "vector": return [evaluate(e, env) for e in expr[1:]] # map - (map fn coll) if name == "map": if len(expr) != 3: raise EvalError("map requires fn and collection") fn = evaluate(expr[1], env) coll = evaluate(expr[2], env) if not isinstance(fn, Lambda): raise EvalError(f"map requires lambda, got {type(fn)}") result = [] for item in coll: local_env = {} if fn.closure: local_env.update(fn.closure) local_env.update(env) local_env[fn.params[0]] = item result.append(evaluate(fn.body, local_env)) return result # map-indexed - (map-indexed fn coll) if name == "map-indexed": if len(expr) != 3: raise EvalError("map-indexed requires fn and collection") fn = evaluate(expr[1], env) coll = evaluate(expr[2], env) if not isinstance(fn, Lambda): raise EvalError(f"map-indexed requires lambda, got {type(fn)}") if len(fn.params) < 2: raise EvalError("map-indexed lambda needs (i item) params") result = [] for i, item in enumerate(coll): local_env = {} if fn.closure: local_env.update(fn.closure) local_env.update(env) local_env[fn.params[0]] = i local_env[fn.params[1]] = item result.append(evaluate(fn.body, local_env)) return result # reduce - (reduce fn init coll) if name == "reduce": if len(expr) != 4: raise EvalError("reduce requires fn, init, and collection") fn = evaluate(expr[1], env) acc = evaluate(expr[2], env) coll = evaluate(expr[3], env) if not isinstance(fn, Lambda): raise EvalError(f"reduce requires lambda, got {type(fn)}") if len(fn.params) < 2: raise EvalError("reduce lambda needs (acc item) params") for item in coll: local_env = {} if fn.closure: local_env.update(fn.closure) local_env.update(env) local_env[fn.params[0]] = acc local_env[fn.params[1]] = item acc = evaluate(fn.body, local_env) return acc # for-each - (for-each fn coll) - iterate with side effects if name == "for-each": if len(expr) != 3: raise EvalError("for-each requires fn and collection") fn = evaluate(expr[1], env) coll = evaluate(expr[2], env) if not isinstance(fn, Lambda): raise EvalError(f"for-each requires lambda, got {type(fn)}") for item in coll: local_env = {} if fn.closure: local_env.update(fn.closure) local_env.update(env) local_env[fn.params[0]] = item evaluate(fn.body, local_env) return None # Function call fn = evaluate(head, env) args = [evaluate(arg, env) for arg in expr[1:]] # Call builtin if callable(fn): return fn(*args) # Call lambda if isinstance(fn, Lambda): if len(args) != len(fn.params): raise EvalError(f"Lambda expects {len(fn.params)} args, got {len(args)}") # Start with closure (captured env), then overlay calling env, then params local_env = {} if fn.closure: local_env.update(fn.closure) local_env.update(env) for name, value in zip(fn.params, args): local_env[name] = value return evaluate(fn.body, local_env) raise EvalError(f"Not callable: {fn}") raise EvalError(f"Cannot evaluate: {expr!r}") def make_env(**kwargs) -> Dict[str, Any]: """Create an environment with initial bindings.""" return dict(kwargs)