Skip to main content

lex_runtime/
builtins.rs

1//! Pure stdlib builtins — string, numeric, list, option, result, json
2//! ops dispatched via the same `EffectHandler` interface as effects, but
3//! without policy gates (they have no observable side effects).
4
5use lex_bytecode::{MapKey, Value};
6use std::collections::{BTreeMap, BTreeSet, HashMap};
7use std::sync::{Mutex, OnceLock};
8
9/// Returns `true` if `(kind, op)` will be handled by the pure-builtin
10/// path (no side effects, no policy gate needed). Used by the effect
11/// handler to decide whether to consume `args` by value.
12pub fn is_pure_call(kind: &str, op: &str) -> bool {
13    if !is_pure_module(kind) { return false; }
14    !matches!(
15        (kind, op),
16        ("crypto", "random")
17        | ("crypto", "random_str_hex")
18        | ("datetime", "now")
19        | ("http", "send")
20        | ("http", "get")
21        | ("http", "post")
22    )
23}
24
25/// Dispatch a pure-builtin call with owned args (no clone of arg values).
26/// Callers must first verify `is_pure_call(kind, op)` to ensure args
27/// ownership is only transferred for known-pure ops.
28///
29/// `list.cons` is handled here with move semantics so the tail `Vec<Value>`
30/// is extended without cloning each element (#405).
31pub fn call_pure_builtin(kind: &str, op: &str, args: Vec<Value>) -> Result<Value, String> {
32    if (kind, op) == ("list", "cons") {
33        let mut it = args.into_iter();
34        let head = it.next().unwrap_or(Value::Unit);
35        let tail = match it.next() {
36            Some(Value::List(v)) => v,
37            Some(other) => return Err(format!("list.cons: expected List, got {other:?}")),
38            None => vec![],
39        };
40        let mut out = Vec::with_capacity(1 + tail.len());
41        out.push(head);
42        out.extend(tail);
43        return Ok(Value::List(out));
44    }
45    dispatch(kind, op, &args)
46}
47
48/// Returns Some(...) if `(kind, op)` names a known pure builtin.
49/// `None` means "not handled here; fall through to effect dispatch".
50///
51/// Prefer `is_pure_call` + `call_pure_builtin` in hot paths — this
52/// variant takes `&[Value]` and must clone args for operations like
53/// `list.cons`; kept for external callers that already hold a slice.
54pub fn try_pure_builtin(kind: &str, op: &str, args: &[Value]) -> Option<Result<Value, String>> {
55    if !is_pure_call(kind, op) { return None; }
56    Some(dispatch(kind, op, args))
57}
58
59/// `kind` is one of the known pure module aliases — used by the policy
60/// walk to skip pure builtins that programs reference via imports.
61pub fn is_pure_module(kind: &str) -> bool {
62    matches!(kind, "str" | "int" | "float" | "bool" | "list" | "iter"
63        | "option" | "result" | "tuple" | "json" | "bytes" | "flow" | "math"
64        | "map" | "set" | "crypto" | "regex" | "deque" | "datetime" | "duration" | "http"
65        | "toml" | "yaml" | "dotenv" | "csv" | "test" | "random" | "parser"
66        | "cli")
67}
68
69fn dispatch(kind: &str, op: &str, args: &[Value]) -> Result<Value, String> {
70    match (kind, op) {
71        // -- str --
72        ("str", "is_empty") => Ok(Value::Bool(expect_str(args.first())?.is_empty())),
73        ("str", "len") => Ok(Value::Int(expect_str(args.first())?.len() as i64)),
74        ("str", "concat") => {
75            let a = expect_str(args.first())?;
76            let b = expect_str(args.get(1))?;
77            Ok(Value::Str(format!("{a}{b}")))
78        }
79        ("str", "to_int") => {
80            let s = expect_str(args.first())?;
81            match s.parse::<i64>() {
82                Ok(n) => Ok(some(Value::Int(n))),
83                Err(_) => Ok(none()),
84            }
85        }
86        ("str", "split") => {
87            let s = expect_str(args.first())?;
88            let sep = expect_str(args.get(1))?;
89            let items: Vec<Value> = if sep.is_empty() {
90                s.chars().map(|c| Value::Str(c.to_string())).collect()
91            } else {
92                s.split(sep.as_str()).map(|p| Value::Str(p.to_string())).collect()
93            };
94            Ok(Value::List(items))
95        }
96        ("str", "join") => {
97            let parts = expect_list(args.first())?;
98            let sep = expect_str(args.get(1))?;
99            let mut out = String::new();
100            for (i, p) in parts.iter().enumerate() {
101                if i > 0 { out.push_str(&sep); }
102                match p {
103                    Value::Str(s) => out.push_str(s),
104                    other => return Err(format!("str.join element must be Str, got {other:?}")),
105                }
106            }
107            Ok(Value::Str(out))
108        }
109        ("str", "starts_with") => {
110            let s = expect_str(args.first())?;
111            let prefix = expect_str(args.get(1))?;
112            Ok(Value::Bool(s.starts_with(prefix.as_str())))
113        }
114        ("str", "ends_with") => {
115            let s = expect_str(args.first())?;
116            let suffix = expect_str(args.get(1))?;
117            Ok(Value::Bool(s.ends_with(suffix.as_str())))
118        }
119        ("str", "contains") => {
120            let s = expect_str(args.first())?;
121            let needle = expect_str(args.get(1))?;
122            Ok(Value::Bool(s.contains(needle.as_str())))
123        }
124        ("str", "replace") => {
125            let s = expect_str(args.first())?;
126            let from = expect_str(args.get(1))?;
127            let to = expect_str(args.get(2))?;
128            Ok(Value::Str(s.replace(from.as_str(), to.as_str())))
129        }
130        ("str", "trim") => Ok(Value::Str(expect_str(args.first())?.trim().to_string())),
131        ("str", "to_upper") => Ok(Value::Str(expect_str(args.first())?.to_uppercase())),
132        ("str", "to_lower") => Ok(Value::Str(expect_str(args.first())?.to_lowercase())),
133        ("str", "strip_prefix") => {
134            let s = expect_str(args.first())?;
135            let prefix = expect_str(args.get(1))?;
136            Ok(match s.strip_prefix(prefix.as_str()) {
137                Some(rest) => some(Value::Str(rest.to_string())),
138                None => none(),
139            })
140        }
141        ("str", "strip_suffix") => {
142            let s = expect_str(args.first())?;
143            let suffix = expect_str(args.get(1))?;
144            Ok(match s.strip_suffix(suffix.as_str()) {
145                Some(rest) => some(Value::Str(rest.to_string())),
146                None => none(),
147            })
148        }
149        ("str", "slice") => {
150            // Half-open byte-range slice. `hi` is clamped to `s.len()`
151            // and a negative `lo` / `hi` clamps to `0`, mirroring
152            // Python's `s[lo:hi]` semantics (and matching what
153            // production users expect when slicing fixed sizes off
154            // a possibly-shorter string — e.g. the first 64 chars
155            // of a license header). Reversed ranges (`lo > hi` after
156            // clamping) error since that's a caller logic bug. A
157            // mid-codepoint `lo` after clamping still errors so
158            // silent UTF-8 truncation never sneaks through.
159            let s = expect_str(args.first())?;
160            let lo_i = expect_int(args.get(1))?;
161            let hi_i = expect_int(args.get(2))?;
162            let lo = (lo_i.max(0) as usize).min(s.len());
163            let hi = (hi_i.max(0) as usize).min(s.len());
164            if lo > hi {
165                return Err(format!(
166                    "str.slice: reversed range [{lo}..{hi}] (after clamping to len {})",
167                    s.len()));
168            }
169            if !s.is_char_boundary(lo) || !s.is_char_boundary(hi) {
170                return Err(format!("str.slice: [{lo}..{hi}] not on char boundaries"));
171            }
172            Ok(Value::Str(s[lo..hi].to_string()))
173        }
174
175        // -- int / float --
176        ("int", "to_str") => Ok(Value::Str(expect_int(args.first())?.to_string())),
177        ("int", "to_float") => Ok(Value::Float(expect_int(args.first())? as f64)),
178        ("float", "to_int") => Ok(Value::Int(expect_float(args.first())? as i64)),
179        ("float", "to_str") => Ok(Value::Str(expect_float(args.first())?.to_string())),
180        ("str", "to_float") => {
181            let s = expect_str(args.first())?;
182            match s.parse::<f64>() {
183                Ok(f) => Ok(some(Value::Float(f))),
184                Err(_) => Ok(none()),
185            }
186        }
187
188        // -- list --
189        ("list", "len") => Ok(Value::Int(expect_list(args.first())?.len() as i64)),
190        ("list", "is_empty") => Ok(Value::Bool(expect_list(args.first())?.is_empty())),
191        ("list", "head") => {
192            let xs = expect_list(args.first())?;
193            match xs.first() {
194                Some(v) => Ok(some(v.clone())),
195                None => Ok(none()),
196            }
197        }
198        ("list", "tail") => {
199            let xs = expect_list(args.first())?;
200            if xs.is_empty() { Ok(Value::List(Vec::new())) }
201            else { Ok(Value::List(xs[1..].to_vec())) }
202        }
203        ("list", "range") => {
204            let lo = expect_int(args.first())?;
205            let hi = expect_int(args.get(1))?;
206            Ok(Value::List((lo..hi).map(Value::Int).collect()))
207        }
208        ("list", "concat") => {
209            let mut out = expect_list(args.first())?.clone();
210            out.extend(expect_list(args.get(1))?.iter().cloned());
211            Ok(Value::List(out))
212        }
213        ("list", "reverse") => {
214            let mut out = expect_list(args.first())?.clone();
215            out.reverse();
216            Ok(Value::List(out))
217        }
218        // #334: cons — prepend a single element to a list.
219        ("list", "cons") => {
220            let head = args.first().cloned().unwrap_or(Value::Unit);
221            let mut out = vec![head];
222            out.extend(expect_list(args.get(1))?.iter().cloned());
223            Ok(Value::List(out))
224        }
225        ("list", "enumerate") => {
226            let xs = expect_list(args.first())?;
227            let pairs = xs.iter().cloned().enumerate()
228                .map(|(i, v)| Value::Tuple(vec![Value::Int(i as i64), v]))
229                .collect::<Vec<_>>();
230            Ok(Value::List(pairs))
231        }
232
233        // -- tuple --
234        // Per §11.1: fst, snd, third for 2- and 3-tuples. Index out of
235        // range is an error rather than a panic so calling `tuple.third`
236        // on a 2-tuple is a clean failure instead of a host crash.
237        ("tuple", "fst")   => tuple_index(first_arg(args)?, 0),
238        ("tuple", "snd")   => tuple_index(first_arg(args)?, 1),
239        ("tuple", "third") => tuple_index(first_arg(args)?, 2),
240        ("tuple", "len") => match first_arg(args)? {
241            Value::Tuple(items) => Ok(Value::Int(items.len() as i64)),
242            other => Err(format!("tuple.len: expected Tuple, got {other:?}")),
243        },
244
245        // -- option --
246        ("option", "unwrap_or") => {
247            let opt = first_arg(args)?;
248            let default = args.get(1).cloned().unwrap_or(Value::Unit);
249            match opt {
250                Value::Variant { name, args } if name == "Some" && !args.is_empty() => Ok(args[0].clone()),
251                Value::Variant { name, .. } if name == "None" => Ok(default),
252                other => Err(format!("option.unwrap_or expected Option, got {other:?}")),
253            }
254        }
255        // option.unwrap_or_else: lazy default via thunk — only called when None.
256        // Handled inline by the bytecode compiler; this arm is the interpreter
257        // fallback path (thunk is pre-applied as a Value::Unit default since the
258        // runtime cannot call closures itself — the compiler path is canonical).
259        ("option", "unwrap_or_else") => {
260            let opt = first_arg(args)?;
261            match opt {
262                Value::Variant { name, args } if name == "Some" && !args.is_empty() => Ok(args[0].clone()),
263                Value::Variant { name, .. } if name == "None" => {
264                    // The closure argument cannot be invoked from pure-builtin
265                    // context; callers that reach this path have already
266                    // evaluated the thunk and passed its result as args[1].
267                    Ok(args.get(1).cloned().unwrap_or(Value::Unit))
268                }
269                other => Err(format!("option.unwrap_or_else expected Option, got {other:?}")),
270            }
271        }
272        ("option", "is_some") => match first_arg(args)? {
273            Value::Variant { name, .. } => Ok(Value::Bool(name == "Some")),
274            other => Err(format!("option.is_some expected Option, got {other:?}")),
275        },
276        ("option", "is_none") => match first_arg(args)? {
277            Value::Variant { name, .. } => Ok(Value::Bool(name == "None")),
278            other => Err(format!("option.is_none expected Option, got {other:?}")),
279        },
280
281        // -- result --
282        ("result", "is_ok") => match first_arg(args)? {
283            Value::Variant { name, .. } => Ok(Value::Bool(name == "Ok")),
284            other => Err(format!("result.is_ok expected Result, got {other:?}")),
285        },
286        ("result", "is_err") => match first_arg(args)? {
287            Value::Variant { name, .. } => Ok(Value::Bool(name == "Err")),
288            other => Err(format!("result.is_err expected Result, got {other:?}")),
289        },
290        ("result", "unwrap_or") => {
291            let res = first_arg(args)?;
292            let default = args.get(1).cloned().unwrap_or(Value::Unit);
293            match res {
294                Value::Variant { name, args } if name == "Ok" && !args.is_empty() => Ok(args[0].clone()),
295                Value::Variant { name, .. } if name == "Err" => Ok(default),
296                other => Err(format!("result.unwrap_or expected Result, got {other:?}")),
297            }
298        }
299
300        // -- json --
301        ("json", "stringify") => {
302            let v = first_arg(args)?;
303            Ok(Value::Str(serde_json::to_string(&value_to_json(v)).unwrap_or_default()))
304        }
305        ("json", "parse") => {
306            let s = expect_str(args.first())?;
307            match serde_json::from_str::<serde_json::Value>(&s) {
308                Ok(v) => Ok(ok_v(json_to_value(&v))),
309                Err(e) => Ok(err_v(Value::Str(format!("{e}")))),
310            }
311        }
312        // Tactical fix for #168: validate required fields before
313        // returning Ok. #322: also validate field types via schema.
314        ("json", "parse_strict") => {
315            let s = expect_str(args.first())?;
316            let required = required_field_names(args.get(1))?;
317            let schema = extract_type_schema(args.get(2));
318            match serde_json::from_str::<serde_json::Value>(&s) {
319                Ok(v) => {
320                    if let Err(e) = check_required_fields(&v, &required) {
321                        return Ok(err_v(Value::Str(e)));
322                    }
323                    if let Err(e) = validate_field_types(&v, &schema) {
324                        return Ok(err_v(Value::Str(e)));
325                    }
326                    Ok(ok_v(json_to_value(&v)))
327                }
328                Err(e) => Ok(err_v(Value::Str(format!("{e}")))),
329            }
330        }
331
332        // -- toml (config parser; routes through serde_json::Value
333        // so the parsed shape composes with the existing json
334        // tooling. Datetimes become RFC 3339 strings — the only
335        // info-losing step) --
336        ("toml", "parse") => {
337            let s = expect_str(args.first())?;
338            match toml::from_str::<serde_json::Value>(&s) {
339                Ok(mut v) => {
340                    unwrap_toml_datetime_markers(&mut v);
341                    Ok(ok_v(json_to_value(&v)))
342                }
343                Err(e) => Ok(err_v(Value::Str(format!("{e}")))),
344            }
345        }
346        // Compiler-emitted variant of parse_strict that carries the type
347        // schema injected by the type-checker rewrite pass (#322).
348        // Identical to parse_strict but the 3rd arg (schema) is always present.
349        ("json", "parse_strict_typed") => {
350            let s = expect_str(args.first())?;
351            let required = required_field_names(args.get(1))?;
352            let schema = extract_type_schema(args.get(2));
353            match serde_json::from_str::<serde_json::Value>(&s) {
354                Ok(v) => {
355                    if let Err(e) = check_required_fields(&v, &required) {
356                        return Ok(err_v(Value::Str(e)));
357                    }
358                    if let Err(e) = validate_field_types(&v, &schema) {
359                        return Ok(err_v(Value::Str(e)));
360                    }
361                    Ok(ok_v(json_to_value(&v)))
362                }
363                Err(e) => Ok(err_v(Value::Str(format!("{e}")))),
364            }
365        }
366
367        // Tactical fix for #168: validate required fields before
368        // returning Ok. #322: also validate field types via schema.
369        ("toml", "parse_strict") => {
370            let s = expect_str(args.first())?;
371            let required = required_field_names(args.get(1))?;
372            let schema = extract_type_schema(args.get(2));
373            match toml::from_str::<serde_json::Value>(&s) {
374                Ok(mut v) => {
375                    unwrap_toml_datetime_markers(&mut v);
376                    if let Err(e) = check_required_fields(&v, &required) {
377                        return Ok(err_v(Value::Str(e)));
378                    }
379                    if let Err(e) = validate_field_types(&v, &schema) {
380                        return Ok(err_v(Value::Str(e)));
381                    }
382                    Ok(ok_v(json_to_value(&v)))
383                }
384                Err(e) => Ok(err_v(Value::Str(format!("{e}")))),
385            }
386        }
387        ("toml", "parse_strict_typed") => {
388            let s = expect_str(args.first())?;
389            let required = required_field_names(args.get(1))?;
390            let schema = extract_type_schema(args.get(2));
391            match toml::from_str::<serde_json::Value>(&s) {
392                Ok(mut v) => {
393                    unwrap_toml_datetime_markers(&mut v);
394                    if let Err(e) = check_required_fields(&v, &required) {
395                        return Ok(err_v(Value::Str(e)));
396                    }
397                    if let Err(e) = validate_field_types(&v, &schema) {
398                        return Ok(err_v(Value::Str(e)));
399                    }
400                    Ok(ok_v(json_to_value(&v)))
401                }
402                Err(e) => Ok(err_v(Value::Str(format!("{e}")))),
403            }
404        }
405        ("toml", "stringify") => {
406            let v = first_arg(args)?;
407            // serde_json::Value → toml::Value via its serde impls.
408            // TOML's grammar is stricter than JSON's (top-level
409            // must be a table; no `null`; no mixed-type arrays),
410            // so the conversion can fail — surface as Result::Err
411            // rather than panic.
412            let json = value_to_json(v);
413            match toml::to_string(&json) {
414                Ok(s)  => Ok(ok_v(Value::Str(s))),
415                Err(e) => Ok(err_v(Value::Str(format!("toml.stringify: {e}")))),
416            }
417        }
418
419        // -- yaml -- mirrors std.toml. Wraps serde_yaml so values
420        // map to the same Lex shape as JSON. YAML's Tag/Anchor
421        // features are folded out by serde_yaml's deserialize-to-
422        // Value path; non-representable shapes (e.g. non-string
423        // map keys when stringifying) surface as Result::Err.
424        ("yaml", "parse") => {
425            let s = expect_str(args.first())?;
426            match serde_yaml::from_str::<serde_json::Value>(&s) {
427                Ok(v)  => Ok(ok_v(json_to_value(&v))),
428                Err(e) => Ok(err_v(Value::Str(format!("{e}")))),
429            }
430        }
431        // Tactical fix for #168 — same shape as toml.parse_strict.
432        // #322: also validate field types via schema.
433        ("yaml", "parse_strict") => {
434            let s = expect_str(args.first())?;
435            let required = required_field_names(args.get(1))?;
436            let schema = extract_type_schema(args.get(2));
437            match serde_yaml::from_str::<serde_json::Value>(&s) {
438                Ok(v) => {
439                    if let Err(e) = check_required_fields(&v, &required) {
440                        return Ok(err_v(Value::Str(e)));
441                    }
442                    if let Err(e) = validate_field_types(&v, &schema) {
443                        return Ok(err_v(Value::Str(e)));
444                    }
445                    Ok(ok_v(json_to_value(&v)))
446                }
447                Err(e) => Ok(err_v(Value::Str(format!("{e}")))),
448            }
449        }
450        ("yaml", "parse_strict_typed") => {
451            let s = expect_str(args.first())?;
452            let required = required_field_names(args.get(1))?;
453            let schema = extract_type_schema(args.get(2));
454            match serde_yaml::from_str::<serde_json::Value>(&s) {
455                Ok(v) => {
456                    if let Err(e) = check_required_fields(&v, &required) {
457                        return Ok(err_v(Value::Str(e)));
458                    }
459                    if let Err(e) = validate_field_types(&v, &schema) {
460                        return Ok(err_v(Value::Str(e)));
461                    }
462                    Ok(ok_v(json_to_value(&v)))
463                }
464                Err(e) => Ok(err_v(Value::Str(format!("{e}")))),
465            }
466        }
467        ("yaml", "stringify") => {
468            let v = first_arg(args)?;
469            let json = value_to_json(v);
470            match serde_yaml::to_string(&json) {
471                Ok(s)  => Ok(ok_v(Value::Str(s))),
472                Err(e) => Ok(err_v(Value::Str(format!("yaml.stringify: {e}")))),
473            }
474        }
475
476        // -- dotenv -- KEY=VALUE pair files. Hand-rolled parser
477        // because the dotenvy crate's API is geared at loading
478        // into the process env, not parsing-to-data. The grammar
479        // we accept: blank lines, `# comment` lines, and
480        // `KEY=VALUE` (optional surrounding `"..."` or `'...'`,
481        // unescaped). Simple but covers the .env files in the
482        // wild that aren't trying to be shell.
483        ("dotenv", "parse") => {
484            use std::collections::BTreeMap;
485            use lex_bytecode::MapKey;
486            let s = expect_str(args.first())?;
487            match parse_dotenv(&s) {
488                Ok(map) => {
489                    let mut bt: BTreeMap<MapKey, Value> = BTreeMap::new();
490                    for (k, v) in map {
491                        bt.insert(MapKey::Str(k), Value::Str(v));
492                    }
493                    Ok(ok_v(Value::Map(bt)))
494                }
495                Err(e) => Ok(err_v(Value::Str(e))),
496            }
497        }
498
499        // -- csv -- rows-as-lists; first row is whatever the file
500        // has. The caller decides whether row 0 is a header. We
501        // could ship a `parse_with_headers` later that returns a
502        // List[Map[Str, Str]]; v1 keeps the surface tight.
503        ("csv", "parse") => {
504            let s = expect_str(args.first())?;
505            let mut rdr = csv::ReaderBuilder::new()
506                .has_headers(false)
507                .flexible(true)
508                .from_reader(s.as_bytes());
509            let mut rows: Vec<Value> = Vec::new();
510            for r in rdr.records() {
511                match r {
512                    Ok(rec) => {
513                        let row: Vec<Value> = rec.iter()
514                            .map(|f| Value::Str(f.to_string()))
515                            .collect();
516                        rows.push(Value::List(row));
517                    }
518                    Err(e) => return Ok(err_v(Value::Str(format!("csv.parse: {e}")))),
519                }
520            }
521            Ok(ok_v(Value::List(rows)))
522        }
523        ("csv", "stringify") => {
524            // List[List[Str]] → CSV string. Mixed-type rows are
525            // not allowed (CSV is text-only); non-Str cells get
526            // stringified via to_json since that's already the
527            // convention for `json.stringify` etc.
528            let v = first_arg(args)?;
529            let rows = match v {
530                Value::List(rs) => rs,
531                _ => return Ok(err_v(Value::Str("csv.stringify expects List[List[Str]]".into()))),
532            };
533            let mut out = Vec::new();
534            {
535                let mut wtr = csv::WriterBuilder::new()
536                    .has_headers(false)
537                    .from_writer(&mut out);
538                for row in rows {
539                    let cells = match row {
540                        Value::List(cs) => cs,
541                        _ => return Ok(err_v(Value::Str("csv.stringify row must be List[Str]".into()))),
542                    };
543                    let strs: Vec<String> = cells.iter().map(|c| match c {
544                        Value::Str(s) => s.clone(),
545                        other => serde_json::to_string(&other.to_json())
546                            .unwrap_or_else(|_| String::new()),
547                    }).collect();
548                    if let Err(e) = wtr.write_record(&strs) {
549                        return Ok(err_v(Value::Str(format!("csv.stringify: {e}"))));
550                    }
551                }
552                if let Err(e) = wtr.flush() {
553                    return Ok(err_v(Value::Str(format!("csv.stringify flush: {e}"))));
554                }
555            }
556            match String::from_utf8(out) {
557                Ok(s) => Ok(ok_v(Value::Str(s))),
558                Err(e) => Ok(err_v(Value::Str(format!("csv.stringify utf8: {e}")))),
559            }
560        }
561
562        // -- test -- tiny assertion library. Each helper is pure
563        // and returns `Result[Unit, Str]` so tests are themselves
564        // functions returning a Result. A suite is a List the user
565        // iterates with `list.fold`; no Rust-side Suite/Runner
566        // types in v1, so the whole thing is 4 builtins + a few
567        // Lex-source helpers callers can copy into their tests/.
568        ("test", "assert_eq") => {
569            let a = first_arg(args)?;
570            let b = args.get(1).ok_or("test.assert_eq: missing second arg")?;
571            if a == b {
572                Ok(ok_v(Value::Unit))
573            } else {
574                Ok(err_v(Value::Str(format!("assert_eq: lhs {} != rhs {}",
575                    value_to_json(a), value_to_json(b)))))
576            }
577        }
578        ("test", "assert_ne") => {
579            let a = first_arg(args)?;
580            let b = args.get(1).ok_or("test.assert_ne: missing second arg")?;
581            if a != b {
582                Ok(ok_v(Value::Unit))
583            } else {
584                Ok(err_v(Value::Str(format!("assert_ne: both sides are {}",
585                    value_to_json(a)))))
586            }
587        }
588        ("test", "assert_true") => {
589            match first_arg(args)? {
590                Value::Bool(true) => Ok(ok_v(Value::Unit)),
591                Value::Bool(false) => Ok(err_v(Value::Str("assert_true: was false".into()))),
592                other => Err(format!("test.assert_true expects Bool, got {other:?}")),
593            }
594        }
595        ("test", "assert_false") => {
596            match first_arg(args)? {
597                Value::Bool(false) => Ok(ok_v(Value::Unit)),
598                Value::Bool(true)  => Ok(err_v(Value::Str("assert_false: was true".into()))),
599                other => Err(format!("test.assert_false expects Bool, got {other:?}")),
600            }
601        }
602
603        // -- bytes --
604        ("bytes", "len") => {
605            let b = expect_bytes(args.first())?;
606            Ok(Value::Int(b.len() as i64))
607        }
608        ("bytes", "eq") => {
609            let a = expect_bytes(args.first())?;
610            let b = expect_bytes(args.get(1))?;
611            Ok(Value::Bool(a == b))
612        }
613        ("bytes", "from_str") => {
614            let s = expect_str(args.first())?;
615            Ok(Value::Bytes(s.into_bytes()))
616        }
617        ("bytes", "to_str") => {
618            let b = expect_bytes(args.first())?;
619            match String::from_utf8(b.to_vec()) {
620                Ok(s) => Ok(ok_v(Value::Str(s))),
621                Err(e) => Ok(err_v(Value::Str(format!("{e}")))),
622            }
623        }
624        ("bytes", "slice") => {
625            let b = expect_bytes(args.first())?;
626            let lo = expect_int(args.get(1))? as usize;
627            let hi = expect_int(args.get(2))? as usize;
628            if lo > hi || hi > b.len() {
629                return Err(format!("bytes.slice: out of range [{lo}..{hi}] of {}", b.len()));
630            }
631            Ok(Value::Bytes(b[lo..hi].to_vec()))
632        }
633        ("bytes", "is_empty") => {
634            let b = expect_bytes(args.first())?;
635            Ok(Value::Bool(b.is_empty()))
636        }
637
638        // -- math --
639        // Matrices are stored as the F64Array fast-lane variant (a flat
640        // row-major Vec<f64> with shape). Lex code treats them as the
641        // type alias `Matrix = { rows :: Int, cols :: Int, data ::
642        // List[Float] }`; field access is unsupported, so all
643        // introspection happens through these helpers.
644        ("math", "exp")   => Ok(Value::Float(expect_float(args.first())?.exp())),
645        ("math", "log")   => Ok(Value::Float(expect_float(args.first())?.ln())),
646        ("math", "log2")  => Ok(Value::Float(expect_float(args.first())?.log2())),
647        ("math", "log10") => Ok(Value::Float(expect_float(args.first())?.log10())),
648        ("math", "sqrt")  => Ok(Value::Float(expect_float(args.first())?.sqrt())),
649        ("math", "abs")   => Ok(Value::Float(expect_float(args.first())?.abs())),
650        ("math", "sin")   => Ok(Value::Float(expect_float(args.first())?.sin())),
651        ("math", "cos")   => Ok(Value::Float(expect_float(args.first())?.cos())),
652        ("math", "tan")   => Ok(Value::Float(expect_float(args.first())?.tan())),
653        ("math", "asin")  => Ok(Value::Float(expect_float(args.first())?.asin())),
654        ("math", "acos")  => Ok(Value::Float(expect_float(args.first())?.acos())),
655        ("math", "atan")  => Ok(Value::Float(expect_float(args.first())?.atan())),
656        ("math", "floor") => Ok(Value::Float(expect_float(args.first())?.floor())),
657        ("math", "ceil")  => Ok(Value::Float(expect_float(args.first())?.ceil())),
658        ("math", "round") => Ok(Value::Float(expect_float(args.first())?.round())),
659        ("math", "trunc") => Ok(Value::Float(expect_float(args.first())?.trunc())),
660        ("math", "pow") => {
661            let a = expect_float(args.first())?;
662            let b = expect_float(args.get(1))?;
663            Ok(Value::Float(a.powf(b)))
664        }
665        ("math", "atan2") => {
666            let y = expect_float(args.first())?;
667            let x = expect_float(args.get(1))?;
668            Ok(Value::Float(y.atan2(x)))
669        }
670        ("math", "min") => {
671            let a = expect_float(args.first())?;
672            let b = expect_float(args.get(1))?;
673            Ok(Value::Float(a.min(b)))
674        }
675        ("math", "max") => {
676            let a = expect_float(args.first())?;
677            let b = expect_float(args.get(1))?;
678            Ok(Value::Float(a.max(b)))
679        }
680        ("math", "zeros") => {
681            let r = expect_int(args.first())?;
682            let c = expect_int(args.get(1))?;
683            if r < 0 || c < 0 {
684                return Err(format!("math.zeros: negative dim {r}x{c}"));
685            }
686            let r = r as usize; let c = c as usize;
687            Ok(Value::F64Array { rows: r as u32, cols: c as u32, data: vec![0.0; r * c] })
688        }
689        ("math", "ones") => {
690            let r = expect_int(args.first())?;
691            let c = expect_int(args.get(1))?;
692            if r < 0 || c < 0 {
693                return Err(format!("math.ones: negative dim {r}x{c}"));
694            }
695            let r = r as usize; let c = c as usize;
696            Ok(Value::F64Array { rows: r as u32, cols: c as u32, data: vec![1.0; r * c] })
697        }
698        ("math", "from_lists") => {
699            let rows = expect_list(args.first())?;
700            let r = rows.len();
701            if r == 0 {
702                return Ok(Value::F64Array { rows: 0, cols: 0, data: Vec::new() });
703            }
704            let first_row = match &rows[0] {
705                Value::List(xs) => xs,
706                other => return Err(format!("math.from_lists: row 0 not List, got {other:?}")),
707            };
708            let c = first_row.len();
709            let mut data = Vec::with_capacity(r * c);
710            for (i, row) in rows.iter().enumerate() {
711                let row = match row {
712                    Value::List(xs) => xs,
713                    other => return Err(format!("math.from_lists: row {i} not List, got {other:?}")),
714                };
715                if row.len() != c {
716                    return Err(format!("math.from_lists: row {i} has {} cols, expected {c}", row.len()));
717                }
718                for (j, v) in row.iter().enumerate() {
719                    let f = match v {
720                        Value::Float(f) => *f,
721                        Value::Int(n) => *n as f64,
722                        other => return Err(format!("math.from_lists: ({i},{j}) not numeric, got {other:?}")),
723                    };
724                    data.push(f);
725                }
726            }
727            Ok(Value::F64Array { rows: r as u32, cols: c as u32, data })
728        }
729        ("math", "from_flat") => {
730            let r = expect_int(args.first())?;
731            let c = expect_int(args.get(1))?;
732            let xs = expect_list(args.get(2))?;
733            if r < 0 || c < 0 {
734                return Err(format!("math.from_flat: negative dim {r}x{c}"));
735            }
736            let r = r as usize; let c = c as usize;
737            if xs.len() != r * c {
738                return Err(format!("math.from_flat: list len {} != {}*{}", xs.len(), r, c));
739            }
740            let mut data = Vec::with_capacity(r * c);
741            for v in xs {
742                data.push(match v {
743                    Value::Float(f) => *f,
744                    Value::Int(n)   => *n as f64,
745                    other => return Err(format!("math.from_flat: non-numeric element {other:?}")),
746                });
747            }
748            Ok(Value::F64Array { rows: r as u32, cols: c as u32, data })
749        }
750        ("math", "rows") => {
751            let (r, _, _) = unpack_matrix(first_arg(args)?)?;
752            Ok(Value::Int(r as i64))
753        }
754        ("math", "cols") => {
755            let (_, c, _) = unpack_matrix(first_arg(args)?)?;
756            Ok(Value::Int(c as i64))
757        }
758        ("math", "get") => {
759            let (r, c, data) = unpack_matrix(first_arg(args)?)?;
760            let i = expect_int(args.get(1))? as usize;
761            let j = expect_int(args.get(2))? as usize;
762            if i >= r || j >= c {
763                return Err(format!("math.get: ({i},{j}) out of {r}x{c}"));
764            }
765            Ok(Value::Float(data[i * c + j]))
766        }
767        ("math", "to_flat") => {
768            let (_, _, data) = unpack_matrix(first_arg(args)?)?;
769            Ok(Value::List(data.into_iter().map(Value::Float).collect()))
770        }
771        ("math", "transpose") => {
772            let (r, c, data) = unpack_matrix(first_arg(args)?)?;
773            let mut out = vec![0.0; r * c];
774            for i in 0..r {
775                for j in 0..c {
776                    out[j * r + i] = data[i * c + j];
777                }
778            }
779            Ok(Value::F64Array { rows: c as u32, cols: r as u32, data: out })
780        }
781        ("math", "matmul") => {
782            let (m, k1, a) = unpack_matrix(first_arg(args)?)?;
783            let (k2, n, b) = unpack_matrix(args.get(1).ok_or("math.matmul: missing arg 1")?)?;
784            if k1 != k2 {
785                return Err(format!("math.matmul: dim mismatch {m}x{k1} · {k2}x{n}"));
786            }
787            // Plain triple loop. For the small matrices used in the ML
788            // demo (n<200, k<10) this is well under a millisecond and
789            // avoids pulling in matrixmultiply for the runtime crate.
790            let mut c = vec![0.0; m * n];
791            for i in 0..m {
792                for kk in 0..k1 {
793                    let aik = a[i * k1 + kk];
794                    for j in 0..n {
795                        c[i * n + j] += aik * b[kk * n + j];
796                    }
797                }
798            }
799            Ok(Value::F64Array { rows: m as u32, cols: n as u32, data: c })
800        }
801        ("math", "scale") => {
802            let s = expect_float(args.first())?;
803            let (r, c, mut data) = unpack_matrix(args.get(1).ok_or("math.scale: missing arg 1")?)?;
804            for x in &mut data { *x *= s; }
805            Ok(Value::F64Array { rows: r as u32, cols: c as u32, data })
806        }
807        ("math", "add") | ("math", "sub") => {
808            let (ar, ac, a) = unpack_matrix(first_arg(args)?)?;
809            let (br, bc, b) = unpack_matrix(args.get(1).ok_or("math.add/sub: missing arg 1")?)?;
810            if ar != br || ac != bc {
811                return Err(format!("math.{op}: shape mismatch {ar}x{ac} vs {br}x{bc}"));
812            }
813            let neg = op == "sub";
814            let mut out = a;
815            for (i, x) in out.iter_mut().enumerate() {
816                if neg { *x -= b[i] } else { *x += b[i] }
817            }
818            Ok(Value::F64Array { rows: ar as u32, cols: ac as u32, data: out })
819        }
820        ("math", "sigmoid") => {
821            let (r, c, mut data) = unpack_matrix(first_arg(args)?)?;
822            for x in &mut data { *x = 1.0 / (1.0 + (-*x).exp()); }
823            Ok(Value::F64Array { rows: r as u32, cols: c as u32, data })
824        }
825
826        // -- map --
827        ("map", "new") => Ok(Value::Map(BTreeMap::new())),
828        ("map", "size") => Ok(Value::Int(expect_map(args.first())?.len() as i64)),
829        ("map", "has") => {
830            let m = expect_map(args.first())?;
831            let k = MapKey::from_value(args.get(1).ok_or("map.has: missing key")?)?;
832            Ok(Value::Bool(m.contains_key(&k)))
833        }
834        ("map", "get") => {
835            let m = expect_map(args.first())?;
836            let k = MapKey::from_value(args.get(1).ok_or("map.get: missing key")?)?;
837            Ok(match m.get(&k) {
838                Some(v) => some(v.clone()),
839                None    => none(),
840            })
841        }
842        ("map", "set") => {
843            let mut m = expect_map(args.first())?.clone();
844            let k = MapKey::from_value(args.get(1).ok_or("map.set: missing key")?)?;
845            let v = args.get(2).ok_or("map.set: missing value")?.clone();
846            m.insert(k, v);
847            Ok(Value::Map(m))
848        }
849        ("map", "delete") => {
850            let mut m = expect_map(args.first())?.clone();
851            let k = MapKey::from_value(args.get(1).ok_or("map.delete: missing key")?)?;
852            m.remove(&k);
853            Ok(Value::Map(m))
854        }
855        ("map", "keys") => {
856            let m = expect_map(args.first())?;
857            Ok(Value::List(m.keys().cloned().map(MapKey::into_value).collect()))
858        }
859        ("map", "values") => {
860            let m = expect_map(args.first())?;
861            Ok(Value::List(m.values().cloned().collect()))
862        }
863        ("map", "entries") => {
864            let m = expect_map(args.first())?;
865            Ok(Value::List(m.iter()
866                .map(|(k, v)| Value::Tuple(vec![k.as_value(), v.clone()]))
867                .collect()))
868        }
869        ("map", "from_list") => {
870            let pairs = expect_list(args.first())?;
871            let mut m = BTreeMap::new();
872            for p in pairs {
873                let items = match p {
874                    Value::Tuple(items) if items.len() == 2 => items,
875                    other => return Err(format!(
876                        "map.from_list element must be a 2-tuple, got {other:?}")),
877                };
878                let k = MapKey::from_value(&items[0])?;
879                m.insert(k, items[1].clone());
880            }
881            Ok(Value::Map(m))
882        }
883
884        // -- set --
885        ("set", "new") => Ok(Value::Set(BTreeSet::new())),
886        ("set", "size") => Ok(Value::Int(expect_set(args.first())?.len() as i64)),
887        ("set", "has") => {
888            let s = expect_set(args.first())?;
889            let k = MapKey::from_value(args.get(1).ok_or("set.has: missing element")?)?;
890            Ok(Value::Bool(s.contains(&k)))
891        }
892        ("set", "add") => {
893            let mut s = expect_set(args.first())?.clone();
894            let k = MapKey::from_value(args.get(1).ok_or("set.add: missing element")?)?;
895            s.insert(k);
896            Ok(Value::Set(s))
897        }
898        ("set", "delete") => {
899            let mut s = expect_set(args.first())?.clone();
900            let k = MapKey::from_value(args.get(1).ok_or("set.delete: missing element")?)?;
901            s.remove(&k);
902            Ok(Value::Set(s))
903        }
904        ("set", "to_list") => {
905            let s = expect_set(args.first())?;
906            Ok(Value::List(s.iter().cloned().map(MapKey::into_value).collect()))
907        }
908        ("set", "from_list") => {
909            let xs = expect_list(args.first())?;
910            let mut s = BTreeSet::new();
911            for x in xs {
912                s.insert(MapKey::from_value(x)?);
913            }
914            Ok(Value::Set(s))
915        }
916        ("set", "union") => {
917            let a = expect_set(args.first())?;
918            let b = expect_set(args.get(1))?;
919            Ok(Value::Set(a.union(b).cloned().collect()))
920        }
921        ("set", "intersect") => {
922            let a = expect_set(args.first())?;
923            let b = expect_set(args.get(1))?;
924            Ok(Value::Set(a.intersection(b).cloned().collect()))
925        }
926        ("set", "diff") => {
927            let a = expect_set(args.first())?;
928            let b = expect_set(args.get(1))?;
929            Ok(Value::Set(a.difference(b).cloned().collect()))
930        }
931        ("set", "is_empty") => Ok(Value::Bool(expect_set(args.first())?.is_empty())),
932        ("set", "is_subset") => {
933            let a = expect_set(args.first())?;
934            let b = expect_set(args.get(1))?;
935            Ok(Value::Bool(a.is_subset(b)))
936        }
937
938        // -- map helpers --
939        ("map", "merge") => {
940            // b's entries override a's. We construct a new BTreeMap
941            // by extending a with b's pairs.
942            let a = expect_map(args.first())?.clone();
943            let b = expect_map(args.get(1))?;
944            let mut out = a;
945            for (k, v) in b {
946                out.insert(k.clone(), v.clone());
947            }
948            Ok(Value::Map(out))
949        }
950        ("map", "is_empty") => Ok(Value::Bool(expect_map(args.first())?.is_empty())),
951
952        // -- deque --
953        ("deque", "new") => Ok(Value::Deque(std::collections::VecDeque::new())),
954        ("deque", "size") => Ok(Value::Int(expect_deque(args.first())?.len() as i64)),
955        ("deque", "is_empty") => Ok(Value::Bool(expect_deque(args.first())?.is_empty())),
956        ("deque", "push_back") => {
957            let mut d = expect_deque(args.first())?.clone();
958            let x = args.get(1).ok_or("deque.push_back: missing value")?.clone();
959            d.push_back(x);
960            Ok(Value::Deque(d))
961        }
962        ("deque", "push_front") => {
963            let mut d = expect_deque(args.first())?.clone();
964            let x = args.get(1).ok_or("deque.push_front: missing value")?.clone();
965            d.push_front(x);
966            Ok(Value::Deque(d))
967        }
968        ("deque", "pop_back") => {
969            let mut d = expect_deque(args.first())?.clone();
970            match d.pop_back() {
971                Some(x) => Ok(Value::Variant {
972                    name: "Some".into(),
973                    args: vec![Value::Tuple(vec![x, Value::Deque(d)])],
974                }),
975                None => Ok(Value::Variant { name: "None".into(), args: vec![] }),
976            }
977        }
978        ("deque", "pop_front") => {
979            let mut d = expect_deque(args.first())?.clone();
980            match d.pop_front() {
981                Some(x) => Ok(Value::Variant {
982                    name: "Some".into(),
983                    args: vec![Value::Tuple(vec![x, Value::Deque(d)])],
984                }),
985                None => Ok(Value::Variant { name: "None".into(), args: vec![] }),
986            }
987        }
988        ("deque", "peek_back") => {
989            let d = expect_deque(args.first())?;
990            match d.back() {
991                Some(x) => Ok(Value::Variant {
992                    name: "Some".into(),
993                    args: vec![x.clone()],
994                }),
995                None => Ok(Value::Variant { name: "None".into(), args: vec![] }),
996            }
997        }
998        ("deque", "peek_front") => {
999            let d = expect_deque(args.first())?;
1000            match d.front() {
1001                Some(x) => Ok(Value::Variant {
1002                    name: "Some".into(),
1003                    args: vec![x.clone()],
1004                }),
1005                None => Ok(Value::Variant { name: "None".into(), args: vec![] }),
1006            }
1007        }
1008        ("deque", "from_list") => {
1009            let xs = expect_list(args.first())?;
1010            Ok(Value::Deque(xs.iter().cloned().collect()))
1011        }
1012        ("deque", "to_list") => {
1013            let d = expect_deque(args.first())?;
1014            Ok(Value::List(d.iter().cloned().collect()))
1015        }
1016
1017        // -- crypto (pure ops; crypto.random is effectful and routes
1018        // through the handler under [random], see try_pure_builtin) --
1019        ("crypto", "sha256") => {
1020            use sha2::{Digest, Sha256};
1021            let data = expect_bytes(args.first())?;
1022            let mut h = Sha256::new();
1023            h.update(data);
1024            Ok(Value::Bytes(h.finalize().to_vec()))
1025        }
1026        ("crypto", "sha512") => {
1027            use sha2::{Digest, Sha512};
1028            let data = expect_bytes(args.first())?;
1029            let mut h = Sha512::new();
1030            h.update(data);
1031            Ok(Value::Bytes(h.finalize().to_vec()))
1032        }
1033        ("crypto", "md5") => {
1034            use md5::{Digest, Md5};
1035            let data = expect_bytes(args.first())?;
1036            let mut h = Md5::new();
1037            h.update(data);
1038            Ok(Value::Bytes(h.finalize().to_vec()))
1039        }
1040        // BLAKE2b (#382) — 64-byte digest, faster than SHA-512 on most
1041        // CPUs with the same security level. Backed by the `blake2`
1042        // crate; uses `Blake2b512` (the standard 512-bit variant).
1043        ("crypto", "blake2b") => {
1044            use blake2::{Blake2b512, Digest};
1045            let data = expect_bytes(args.first())?;
1046            let mut h = Blake2b512::new();
1047            h.update(data);
1048            Ok(Value::Bytes(h.finalize().to_vec()))
1049        }
1050        // Hex-string convenience hashers (#382). Equivalent to
1051        // `hex_encode(shaN(bytes_of_str(s)))` for the common case
1052        // where the caller has a Str and wants a hex Str digest.
1053        ("crypto", "sha256_str") => {
1054            use sha2::{Digest, Sha256};
1055            let s = expect_str(args.first())?;
1056            let mut h = Sha256::new();
1057            h.update(s.as_bytes());
1058            Ok(Value::Str(hex::encode(h.finalize())))
1059        }
1060        ("crypto", "sha512_str") => {
1061            use sha2::{Digest, Sha512};
1062            let s = expect_str(args.first())?;
1063            let mut h = Sha512::new();
1064            h.update(s.as_bytes());
1065            Ok(Value::Str(hex::encode(h.finalize())))
1066        }
1067        ("crypto", "hmac_sha256") => {
1068            use hmac::{Hmac, KeyInit, Mac};
1069            type HmacSha256 = Hmac<sha2::Sha256>;
1070            let key = expect_bytes(args.first())?;
1071            let data = expect_bytes(args.get(1))?;
1072            let mut mac = HmacSha256::new_from_slice(key)
1073                .map_err(|e| format!("hmac_sha256 key: {e}"))?;
1074            mac.update(data);
1075            Ok(Value::Bytes(mac.finalize().into_bytes().to_vec()))
1076        }
1077        ("crypto", "hmac_sha512") => {
1078            use hmac::{Hmac, KeyInit, Mac};
1079            type HmacSha512 = Hmac<sha2::Sha512>;
1080            let key = expect_bytes(args.first())?;
1081            let data = expect_bytes(args.get(1))?;
1082            let mut mac = HmacSha512::new_from_slice(key)
1083                .map_err(|e| format!("hmac_sha512 key: {e}"))?;
1084            mac.update(data);
1085            Ok(Value::Bytes(mac.finalize().into_bytes().to_vec()))
1086        }
1087        ("crypto", "base64_encode") => {
1088            use base64::{Engine, engine::general_purpose::STANDARD};
1089            let data = expect_bytes(args.first())?;
1090            Ok(Value::Str(STANDARD.encode(data)))
1091        }
1092        ("crypto", "base64_decode") => {
1093            use base64::{Engine, engine::general_purpose::STANDARD};
1094            let s = expect_str(args.first())?;
1095            match STANDARD.decode(s) {
1096                Ok(b)  => Ok(ok_v(Value::Bytes(b))),
1097                Err(e) => Ok(err_v(Value::Str(format!("base64: {e}")))),
1098            }
1099        }
1100        // URL-safe base64 (#382). Alphabet `-_` instead of `+/`,
1101        // padding stripped. Use for JWT segments, signed cookies, any
1102        // token that travels in a URL or path component.
1103        ("crypto", "base64url_encode") => {
1104            use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
1105            let data = expect_bytes(args.first())?;
1106            Ok(Value::Str(URL_SAFE_NO_PAD.encode(data)))
1107        }
1108        ("crypto", "base64url_decode") => {
1109            use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
1110            let s = expect_str(args.first())?;
1111            match URL_SAFE_NO_PAD.decode(s) {
1112                Ok(b)  => Ok(ok_v(Value::Bytes(b))),
1113                Err(e) => Ok(err_v(Value::Str(format!("base64url: {e}")))),
1114            }
1115        }
1116        ("crypto", "hex_encode") => {
1117            let data = expect_bytes(args.first())?;
1118            Ok(Value::Str(hex::encode(data)))
1119        }
1120        ("crypto", "hex_decode") => {
1121            let s = expect_str(args.first())?;
1122            match hex::decode(s) {
1123                Ok(b)  => Ok(ok_v(Value::Bytes(b))),
1124                Err(e) => Ok(err_v(Value::Str(format!("hex: {e}")))),
1125            }
1126        }
1127        ("crypto", "constant_time_eq") | ("crypto", "eq") => {
1128            use subtle::ConstantTimeEq;
1129            let a = expect_bytes(args.first())?;
1130            let b = expect_bytes(args.get(1))?;
1131            // `subtle` returns Choice; comparison only meaningful when
1132            // lengths match. For mismatched lengths return false in
1133            // constant time (length itself isn't secret, but we want
1134            // a single comparison shape).
1135            //
1136            // `eq` (#382) is the recommended spelling — same semantics,
1137            // shorter name. `constant_time_eq` stays as an alias for
1138            // existing callers.
1139            let eq = if a.len() == b.len() {
1140                a.ct_eq(b).into()
1141            } else {
1142                false
1143            };
1144            Ok(Value::Bool(eq))
1145        }
1146        // Constant-time string equality (#382). Compares the bytes of
1147        // both strings; semantics identical to `eq` after `.as_bytes()`.
1148        ("crypto", "eq_str") => {
1149            use subtle::ConstantTimeEq;
1150            let a = expect_str(args.first())?;
1151            let b = expect_str(args.get(1))?;
1152            let eq = if a.len() == b.len() {
1153                a.as_bytes().ct_eq(b.as_bytes()).into()
1154            } else {
1155                false
1156            };
1157            Ok(Value::Bool(eq))
1158        }
1159
1160        // -- AEAD (#382 AEAD slice). Pure: same key + nonce + aad +
1161        // plaintext always produce the same ciphertext + tag. The
1162        // `[random]` effect lives one level up at the caller, where the
1163        // nonce is generated; AEAD ops themselves are deterministic and
1164        // therefore pure.
1165        //
1166        // AES-GCM key length is 128 / 192 / 256 bits; we pick the
1167        // variant from the key size at runtime so callers don't have
1168        // to choose between three near-identical wrappers.
1169        ("crypto", "aes_gcm_seal") => Ok(aes_gcm_seal_impl(args)),
1170        ("crypto", "aes_gcm_open") => Ok(aes_gcm_open_impl(args)),
1171        ("crypto", "chacha20_poly1305_seal") => Ok(chacha20_seal_impl(args)),
1172        ("crypto", "chacha20_poly1305_open") => Ok(chacha20_open_impl(args)),
1173        ("crypto", "pbkdf2_sha256") => Ok(pbkdf2_sha256_impl(args)),
1174        ("crypto", "hkdf_sha256")   => Ok(hkdf_sha256_impl(args)),
1175        ("crypto", "argon2id")      => Ok(argon2id_impl(args)),
1176
1177        // -- random (#219): pure, seeded RNG. Backed by SplitMix64;
1178        // state is the u64 mixer state stored as a single i64 in
1179        // `Rng = { state :: Int }`. Threading the Rng through the
1180        // call site is the user's responsibility — there is no
1181        // global RNG and therefore no `[random]` effect tag for
1182        // pure-seeded usage. --
1183        ("random", "seed") => {
1184            let s = args.first().ok_or("random.seed: missing arg")?.as_int();
1185            // Hash the user-supplied seed once before installing it.
1186            // SplitMix64 is fine when seeded with any u64, but
1187            // hashing first protects against pathological seeds
1188            // (e.g., 0) that would make the very first draw zero.
1189            let mixed = splitmix64(s as u64).0;
1190            Ok(rng_value(mixed))
1191        }
1192        ("random", "int") => {
1193            let state = rng_decode(args.first())?;
1194            let lo = args.get(1).ok_or("random.int: missing lo")?.as_int();
1195            let hi = args.get(2).ok_or("random.int: missing hi")?.as_int();
1196            if hi < lo {
1197                return Err(format!(
1198                    "random.int: hi ({hi}) must be >= lo ({lo})"));
1199            }
1200            let span = (hi as i128) - (lo as i128) + 1;
1201            let (raw, next_state) = splitmix64(state);
1202            // Reduce uniformly to [lo, hi]. The bias from a plain
1203            // modulo is at most `(u64::MAX % span) / u64::MAX`,
1204            // which for any practical span is invisible. Crypto
1205            // applications should use `crypto.random` instead.
1206            let drawn = lo as i128 + (raw as u128 % span as u128) as i128;
1207            Ok(Value::Tuple(vec![
1208                Value::Int(drawn as i64),
1209                rng_value(next_state),
1210            ]))
1211        }
1212        ("random", "float") => {
1213            let state = rng_decode(args.first())?;
1214            let (raw, next_state) = splitmix64(state);
1215            // Take the top 53 bits and divide by 2^53 to land in
1216            // [0.0, 1.0); this is the standard f64 uniform draw.
1217            let f = ((raw >> 11) as f64) / ((1u64 << 53) as f64);
1218            Ok(Value::Tuple(vec![Value::Float(f), rng_value(next_state)]))
1219        }
1220        ("random", "choose") => {
1221            let state = rng_decode(args.first())?;
1222            let xs = match args.get(1) {
1223                Some(Value::List(xs)) => xs,
1224                _ => return Err("random.choose: expected List".into()),
1225            };
1226            if xs.is_empty() {
1227                return Ok(Value::Variant {
1228                    name: "None".into(), args: vec![],
1229                });
1230            }
1231            let (raw, next_state) = splitmix64(state);
1232            let idx = (raw as usize) % xs.len();
1233            let pick = xs[idx].clone();
1234            Ok(Value::Variant {
1235                name: "Some".into(),
1236                args: vec![Value::Tuple(vec![pick, rng_value(next_state)])],
1237            })
1238        }
1239
1240        // -- parser (#217): parser combinators. Parser values are
1241        // tagged Records — `{ kind: "Char", ch: "x" }` etc. — so
1242        // canonical equality follows from the canonical Record
1243        // encoding. The interpreter is `parser_run_impl`. --
1244        ("parser", "char") => {
1245            let s = expect_str(args.first())?;
1246            if s.chars().count() != 1 {
1247                return Err(format!(
1248                    "parser.char: expected 1-character string, got {s:?}"));
1249            }
1250            Ok(parser_node("Char", &[("ch", Value::Str(s))]))
1251        }
1252        ("parser", "string") => {
1253            let s = expect_str(args.first())?;
1254            Ok(parser_node("String", &[("s", Value::Str(s))]))
1255        }
1256        ("parser", "digit") => Ok(parser_node("Digit", &[])),
1257        ("parser", "alpha") => Ok(parser_node("Alpha", &[])),
1258        ("parser", "whitespace") => Ok(parser_node("Whitespace", &[])),
1259        ("parser", "eof") => Ok(parser_node("Eof", &[])),
1260        ("parser", "seq") => {
1261            let a = args.first().cloned()
1262                .ok_or_else(|| "parser.seq: missing first parser".to_string())?;
1263            let b = args.get(1).cloned()
1264                .ok_or_else(|| "parser.seq: missing second parser".to_string())?;
1265            Ok(parser_node("Seq", &[("a", a), ("b", b)]))
1266        }
1267        ("parser", "alt") => {
1268            let a = args.first().cloned()
1269                .ok_or_else(|| "parser.alt: missing first parser".to_string())?;
1270            let b = args.get(1).cloned()
1271                .ok_or_else(|| "parser.alt: missing second parser".to_string())?;
1272            Ok(parser_node("Alt", &[("a", a), ("b", b)]))
1273        }
1274        ("parser", "many") => {
1275            let p = args.first().cloned()
1276                .ok_or_else(|| "parser.many: missing inner parser".to_string())?;
1277            Ok(parser_node("Many", &[("p", p)]))
1278        }
1279        ("parser", "optional") => {
1280            let p = args.first().cloned()
1281                .ok_or_else(|| "parser.optional: missing inner parser".to_string())?;
1282            Ok(parser_node("Optional", &[("p", p)]))
1283        }
1284        // `parser.map` and `parser.and_then` (#221): closure-bearing
1285        // combinators. Constructors only — actual closure invocation
1286        // happens at parser.run time via the Vm-level interpreter.
1287        ("parser", "map") => {
1288            let p = args.first().cloned()
1289                .ok_or_else(|| "parser.map: missing parser".to_string())?;
1290            let f = args.get(1).cloned()
1291                .ok_or_else(|| "parser.map: missing closure".to_string())?;
1292            Ok(parser_node("Map", &[("p", p), ("f", f)]))
1293        }
1294        ("parser", "and_then") => {
1295            let p = args.first().cloned()
1296                .ok_or_else(|| "parser.and_then: missing parser".to_string())?;
1297            let f = args.get(1).cloned()
1298                .ok_or_else(|| "parser.and_then: missing closure".to_string())?;
1299            Ok(parser_node("AndThen", &[("p", p), ("f", f)]))
1300        }
1301        // `parser.run` is handled at the Vm level (lex-bytecode's
1302        // `Op::EffectCall` intercept) — it needs reentrant Vm access
1303        // to invoke the closures inside `Map` / `AndThen` nodes. The
1304        // pure-builtin path doesn't have that, so we deliberately do
1305        // *not* have a `("parser", "run")` arm here.
1306
1307        // -- regex (the compiled `Regex` is stored as the pattern
1308        // string; the runtime caches the actual `regex::Regex` so
1309        // ops don't re-compile on every call) --
1310        ("regex", "compile") => {
1311            let pat = expect_str(args.first())?;
1312            match get_or_compile_regex(&pat) {
1313                Ok(_) => Ok(ok_v(Value::Str(pat))),
1314                Err(e) => Ok(err_v(Value::Str(e))),
1315            }
1316        }
1317        ("regex", "is_match") => {
1318            let pat = expect_str(args.first())?;
1319            let s = expect_str(args.get(1))?;
1320            let re = get_or_compile_regex(&pat).map_err(|e| format!("regex.is_match: {e}"))?;
1321            Ok(Value::Bool(re.is_match(&s)))
1322        }
1323        // is_match_str :: Str, Str -> Bool
1324        // Compiles the first argument as a pattern on the fly (uses the shared
1325        // cache) and matches against the second.  Returns false on invalid
1326        // pattern rather than propagating an error, keeping the pure signature.
1327        ("regex", "is_match_str") => {
1328            let pat = expect_str(args.first())?;
1329            let s = expect_str(args.get(1))?;
1330            match get_or_compile_regex(&pat) {
1331                Ok(re) => Ok(Value::Bool(re.is_match(&s))),
1332                Err(_) => Ok(Value::Bool(false)),
1333            }
1334        }
1335        ("regex", "find") => {
1336            let pat = expect_str(args.first())?;
1337            let s = expect_str(args.get(1))?;
1338            let re = get_or_compile_regex(&pat).map_err(|e| format!("regex.find: {e}"))?;
1339            match re.captures(&s) {
1340                Some(caps) => Ok(Value::Variant {
1341                    name: "Some".into(),
1342                    args: vec![match_value(&caps)],
1343                }),
1344                None => Ok(Value::Variant { name: "None".into(), args: vec![] }),
1345            }
1346        }
1347        ("regex", "find_all") => {
1348            let pat = expect_str(args.first())?;
1349            let s = expect_str(args.get(1))?;
1350            let re = get_or_compile_regex(&pat).map_err(|e| format!("regex.find_all: {e}"))?;
1351            let items: Vec<Value> = re.captures_iter(&s).map(|caps| match_value(&caps)).collect();
1352            Ok(Value::List(items))
1353        }
1354        ("regex", "replace") => {
1355            let pat = expect_str(args.first())?;
1356            let s = expect_str(args.get(1))?;
1357            let rep = expect_str(args.get(2))?;
1358            let re = get_or_compile_regex(&pat).map_err(|e| format!("regex.replace: {e}"))?;
1359            Ok(Value::Str(re.replace(&s, rep.as_str()).into_owned()))
1360        }
1361        ("regex", "replace_all") => {
1362            let pat = expect_str(args.first())?;
1363            let s = expect_str(args.get(1))?;
1364            let rep = expect_str(args.get(2))?;
1365            let re = get_or_compile_regex(&pat).map_err(|e| format!("regex.replace_all: {e}"))?;
1366            Ok(Value::Str(re.replace_all(&s, rep.as_str()).into_owned()))
1367        }
1368        // -- datetime (pure ops; datetime.now is effectful and routes
1369        // through the handler under [time]) --
1370        ("datetime", "parse_iso") => {
1371            let s = expect_str(args.first())?;
1372            match chrono::DateTime::parse_from_rfc3339(&s) {
1373                Ok(dt) => Ok(ok_v(Value::Int(instant_from_chrono(dt)))),
1374                Err(e) => Ok(err_v(Value::Str(format!("parse_iso: {e}")))),
1375            }
1376        }
1377        ("datetime", "format_iso") => {
1378            let n = expect_int(args.first())?;
1379            Ok(Value::Str(format_iso(n)))
1380        }
1381        ("datetime", "parse") => {
1382            let s = expect_str(args.first())?;
1383            let fmt = expect_str(args.get(1))?;
1384            match chrono::NaiveDateTime::parse_from_str(&s, &fmt) {
1385                Ok(naive) => {
1386                    use chrono::TimeZone;
1387                    match chrono::Utc.from_local_datetime(&naive).single() {
1388                        Some(dt) => Ok(ok_v(Value::Int(instant_from_chrono(dt)))),
1389                        None => Ok(err_v(Value::Str("parse: ambiguous local time".into()))),
1390                    }
1391                }
1392                Err(e) => Ok(err_v(Value::Str(format!("parse: {e}")))),
1393            }
1394        }
1395        ("datetime", "format") => {
1396            let n = expect_int(args.first())?;
1397            let fmt = expect_str(args.get(1))?;
1398            let dt = chrono_from_instant(n);
1399            Ok(Value::Str(dt.format(&fmt).to_string()))
1400        }
1401        ("datetime", "to_components") => {
1402            let n = expect_int(args.first())?;
1403            let tz = match parse_tz_arg(args.get(1)) {
1404                Ok(t) => t,
1405                Err(e) => return Ok(err_v(Value::Str(e))),
1406            };
1407            match resolve_tz_to_components(n, &tz) {
1408                Ok(rec) => Ok(ok_v(rec)),
1409                Err(e) => Ok(err_v(Value::Str(e))),
1410            }
1411        }
1412        ("datetime", "from_components") => {
1413            let rec = match args.first() {
1414                Some(Value::Record(r)) => r.clone(),
1415                _ => return Err("from_components: expected DateTime record".into()),
1416            };
1417            match instant_from_components(&rec) {
1418                Ok(n) => Ok(ok_v(Value::Int(n))),
1419                Err(e) => Ok(err_v(Value::Str(e))),
1420            }
1421        }
1422        ("datetime", "add") => {
1423            let a = expect_int(args.first())?;
1424            let d = expect_int(args.get(1))?;
1425            Ok(Value::Int(a.saturating_add(d)))
1426        }
1427        ("datetime", "diff") => {
1428            let a = expect_int(args.first())?;
1429            let b = expect_int(args.get(1))?;
1430            Ok(Value::Int(a.saturating_sub(b)))
1431        }
1432        ("datetime", "duration_seconds") => {
1433            let s = expect_float(args.first())?;
1434            let nanos = (s * 1_000_000_000.0) as i64;
1435            Ok(Value::Int(nanos))
1436        }
1437        ("datetime", "duration_minutes") => {
1438            let m = expect_int(args.first())?;
1439            Ok(Value::Int(m.saturating_mul(60_000_000_000)))
1440        }
1441        ("datetime", "duration_days") => {
1442            let d = expect_int(args.first())?;
1443            Ok(Value::Int(d.saturating_mul(86_400_000_000_000)))
1444        }
1445        // #331: Instant comparison ops.
1446        ("datetime", "before") => {
1447            let a = expect_int(args.first())?;
1448            let b = expect_int(args.get(1))?;
1449            Ok(Value::Bool(a < b))
1450        }
1451        ("datetime", "after") => {
1452            let a = expect_int(args.first())?;
1453            let b = expect_int(args.get(1))?;
1454            Ok(Value::Bool(a > b))
1455        }
1456        ("datetime", "compare") => {
1457            let a = expect_int(args.first())?;
1458            let b = expect_int(args.get(1))?;
1459            Ok(Value::Int(a.cmp(&b) as i64))
1460        }
1461        // #331: Duration scalar extraction.
1462        ("duration", "seconds") => {
1463            let nanos = expect_int(args.first())?;
1464            Ok(Value::Int(nanos / 1_000_000_000))
1465        }
1466
1467        ("regex", "split") => {
1468            let pat = expect_str(args.first())?;
1469            let s = expect_str(args.get(1))?;
1470            let re = get_or_compile_regex(&pat).map_err(|e| format!("regex.split: {e}"))?;
1471            let parts: Vec<Value> = re.split(&s).map(|p| Value::Str(p.to_string())).collect();
1472            Ok(Value::List(parts))
1473        }
1474
1475        // -- http (builders + decoders; wire ops live in the
1476        // effect handler under `[net]`) --
1477        ("http", "with_header") => {
1478            let req = expect_record_pure(args.first())?.clone();
1479            let k = expect_str(args.get(1))?;
1480            let v = expect_str(args.get(2))?;
1481            Ok(Value::Record(http_set_header(req, &k, &v)))
1482        }
1483        ("http", "with_auth") => {
1484            let req = expect_record_pure(args.first())?.clone();
1485            let scheme = expect_str(args.get(1))?;
1486            let token = expect_str(args.get(2))?;
1487            let value = format!("{scheme} {token}");
1488            Ok(Value::Record(http_set_header(req, "Authorization", &value)))
1489        }
1490        ("http", "with_query") => {
1491            let req = expect_record_pure(args.first())?.clone();
1492            let params = match args.get(1) {
1493                Some(Value::Map(m)) => m.clone(),
1494                Some(other) => return Err(format!(
1495                    "http.with_query: params must be Map[Str, Str], got {other:?}")),
1496                None => return Err("http.with_query: missing params argument".into()),
1497            };
1498            Ok(Value::Record(http_append_query(req, &params)))
1499        }
1500        ("http", "with_timeout_ms") => {
1501            let req = expect_record_pure(args.first())?.clone();
1502            let ms = expect_int(args.get(1))?;
1503            let mut out = req;
1504            out.insert("timeout_ms".into(), Value::Variant {
1505                name: "Some".into(),
1506                args: vec![Value::Int(ms)],
1507            });
1508            Ok(Value::Record(out))
1509        }
1510        ("http", "json_body") => {
1511            let resp = expect_record_pure(args.first())?;
1512            let body = match resp.get("body") {
1513                Some(Value::Bytes(b)) => b.clone(),
1514                _ => return Err("http.json_body: HttpResponse.body must be Bytes".into()),
1515            };
1516            let s = match std::str::from_utf8(&body) {
1517                Ok(s) => s,
1518                Err(e) => return Ok(http_decode_err_pure(format!("body not UTF-8: {e}"))),
1519            };
1520            match serde_json::from_str::<serde_json::Value>(s) {
1521                Ok(j) => Ok(ok_v(Value::from_json(&j))),
1522                Err(e) => Ok(http_decode_err_pure(format!("json parse: {e}"))),
1523            }
1524        }
1525        ("http", "text_body") => {
1526            let resp = expect_record_pure(args.first())?;
1527            let body = match resp.get("body") {
1528                Some(Value::Bytes(b)) => b.clone(),
1529                _ => return Err("http.text_body: HttpResponse.body must be Bytes".into()),
1530            };
1531            match String::from_utf8(body) {
1532                Ok(s) => Ok(ok_v(Value::Str(s))),
1533                Err(e) => Ok(http_decode_err_pure(format!("body not UTF-8: {e}"))),
1534            }
1535        }
1536
1537        // -- std.cli (Rubric port): argparse-equivalent for end-user
1538        // programs. Specs are tagged Json values; the parser walks
1539        // argv against the spec and returns a CliParsed Json record.
1540        ("cli", "flag") => {
1541            let name = expect_str(args.first())?;
1542            let short = opt_str(args.get(1));
1543            let help = expect_str(args.get(2))?;
1544            Ok(value_from_json(crate::cli::flag_spec(&name, short.as_deref(), &help)))
1545        }
1546        ("cli", "option") => {
1547            let name = expect_str(args.first())?;
1548            let short = opt_str(args.get(1));
1549            let help = expect_str(args.get(2))?;
1550            let default = opt_str(args.get(3));
1551            Ok(value_from_json(crate::cli::option_spec(&name, short.as_deref(), &help, default.as_deref())))
1552        }
1553        ("cli", "positional") => {
1554            let name = expect_str(args.first())?;
1555            let help = expect_str(args.get(1))?;
1556            let required = expect_bool(args.get(2))?;
1557            Ok(value_from_json(crate::cli::positional_spec(&name, &help, required)))
1558        }
1559        ("cli", "spec") => {
1560            let name = expect_str(args.first())?;
1561            let help = expect_str(args.get(1))?;
1562            let arg_specs: Vec<serde_json::Value> = expect_list(args.get(2))?
1563                .iter().map(value_to_json).collect();
1564            let subs: Vec<serde_json::Value> = expect_list(args.get(3))?
1565                .iter().map(value_to_json).collect();
1566            Ok(value_from_json(crate::cli::build_spec(&name, &help, arg_specs, subs)))
1567        }
1568        ("cli", "parse") => {
1569            let spec = value_to_json(args.first().unwrap_or(&Value::Unit));
1570            let argv: Vec<String> = expect_list(args.get(1))?
1571                .iter().map(|v| match v {
1572                    Value::Str(s) => Ok(s.clone()),
1573                    other => Err(format!("cli.parse: argv must be List[Str], got {other:?}")),
1574                }).collect::<Result<_, _>>()?;
1575            match crate::cli::parse(&spec, &argv) {
1576                Ok(parsed) => Ok(ok_v(value_from_json(parsed))),
1577                Err(msg) => Ok(err_v(Value::Str(msg))),
1578            }
1579        }
1580        ("cli", "envelope") => {
1581            let ok = expect_bool(args.first())?;
1582            let cmd = expect_str(args.get(1))?;
1583            let data = value_to_json(args.get(2).unwrap_or(&Value::Unit));
1584            Ok(value_from_json(crate::cli::envelope(ok, &cmd, data)))
1585        }
1586        ("cli", "describe") => {
1587            let spec = value_to_json(args.first().unwrap_or(&Value::Unit));
1588            Ok(value_from_json(crate::cli::describe(&spec)))
1589        }
1590        ("cli", "help") => {
1591            let spec = value_to_json(args.first().unwrap_or(&Value::Unit));
1592            Ok(Value::Str(crate::cli::help_text(&spec)))
1593        }
1594
1595        _ => Err(format!("unknown pure builtin: {kind}.{op}")),
1596    }
1597}
1598
1599/// Extract `Option[Str]` arg as `Option<String>`. None and missing
1600/// arg both map to `None`. Used by the `cli` builders so callers can
1601/// pass `option.none()` or `Some("v")` interchangeably.
1602fn opt_str(arg: Option<&Value>) -> Option<String> {
1603    match arg {
1604        Some(Value::Variant { name, args }) if name == "Some" => {
1605            args.first().and_then(|v| match v {
1606                Value::Str(s) => Some(s.clone()),
1607                _ => None,
1608            })
1609        }
1610        _ => None,
1611    }
1612}
1613
1614fn value_from_json(v: serde_json::Value) -> Value { Value::from_json(&v) }
1615
1616/// Process-wide cache of compiled regexes, keyed by the pattern
1617/// string. Compilation is the only cost we want to amortize; matching
1618/// the same `Regex` from multiple threads is safe (`regex::Regex` is
1619/// `Send + Sync`).
1620fn regex_cache() -> &'static Mutex<HashMap<String, regex::Regex>> {
1621    static CACHE: OnceLock<Mutex<HashMap<String, regex::Regex>>> = OnceLock::new();
1622    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
1623}
1624
1625fn get_or_compile_regex(pattern: &str) -> Result<regex::Regex, String> {
1626    let cache = regex_cache();
1627    {
1628        let guard = cache.lock().unwrap();
1629        if let Some(re) = guard.get(pattern) {
1630            return Ok(re.clone());
1631        }
1632    }
1633    let re = regex::Regex::new(pattern).map_err(|e| format!("invalid regex: {e}"))?;
1634    let mut guard = cache.lock().unwrap();
1635    guard.insert(pattern.to_string(), re.clone());
1636    Ok(re)
1637}
1638
1639/// Build a `Match` record value: `{ text, start, end, groups }` where
1640/// `groups` is the captured groups in order (group 0 is the full match).
1641/// Missing optional groups become empty strings.
1642fn match_value(caps: &regex::Captures) -> Value {
1643    let m0 = caps.get(0).expect("regex match always has group 0");
1644    let mut rec = indexmap::IndexMap::new();
1645    rec.insert("text".into(), Value::Str(m0.as_str().to_string()));
1646    rec.insert("start".into(), Value::Int(m0.start() as i64));
1647    rec.insert("end".into(), Value::Int(m0.end() as i64));
1648    let groups: Vec<Value> = (1..caps.len())
1649        .map(|i| {
1650            Value::Str(
1651                caps.get(i)
1652                    .map(|m| m.as_str().to_string())
1653                    .unwrap_or_default(),
1654            )
1655        })
1656        .collect();
1657    rec.insert("groups".into(), Value::List(groups));
1658    Value::Record(rec)
1659}
1660
1661fn expect_map(v: Option<&Value>) -> Result<&BTreeMap<MapKey, Value>, String> {
1662    match v {
1663        Some(Value::Map(m)) => Ok(m),
1664        other => Err(format!("expected Map, got {other:?}")),
1665    }
1666}
1667
1668fn expect_set(v: Option<&Value>) -> Result<&BTreeSet<MapKey>, String> {
1669    match v {
1670        Some(Value::Set(s)) => Ok(s),
1671        other => Err(format!("expected Set, got {other:?}")),
1672    }
1673}
1674
1675/// Unpack any matrix-shaped Value into (rows, cols, flat row-major data).
1676/// Accepts the F64Array fast lane and the legacy `Record { rows, cols,
1677/// data: List[Float] }` shape for compatibility with hand-built matrices.
1678fn unpack_matrix(v: &Value) -> Result<(usize, usize, Vec<f64>), String> {
1679    if let Value::F64Array { rows, cols, data } = v {
1680        return Ok((*rows as usize, *cols as usize, data.clone()));
1681    }
1682    let rec = match v {
1683        Value::Record(r) => r,
1684        other => return Err(format!("expected matrix, got {other:?}")),
1685    };
1686    let rows = match rec.get("rows") {
1687        Some(Value::Int(n)) => *n as usize,
1688        _ => return Err("matrix: missing/invalid `rows`".into()),
1689    };
1690    let cols = match rec.get("cols") {
1691        Some(Value::Int(n)) => *n as usize,
1692        _ => return Err("matrix: missing/invalid `cols`".into()),
1693    };
1694    let data = match rec.get("data") {
1695        Some(Value::List(items)) => {
1696            let mut out = Vec::with_capacity(items.len());
1697            for it in items {
1698                out.push(match it {
1699                    Value::Float(f) => *f,
1700                    Value::Int(n) => *n as f64,
1701                    other => return Err(format!("matrix data: not numeric, got {other:?}")),
1702                });
1703            }
1704            out
1705        }
1706        _ => return Err("matrix: missing/invalid `data`".into()),
1707    };
1708    if data.len() != rows * cols {
1709        return Err(format!("matrix: data len {} != {rows}*{cols}", data.len()));
1710    }
1711    Ok((rows, cols, data))
1712}
1713
1714fn expect_bytes(v: Option<&Value>) -> Result<&Vec<u8>, String> {
1715    match v {
1716        Some(Value::Bytes(b)) => Ok(b),
1717        Some(other) => Err(format!("expected Bytes, got {other:?}")),
1718        None => Err("missing argument".into()),
1719    }
1720}
1721
1722fn first_arg(args: &[Value]) -> Result<&Value, String> {
1723    args.first().ok_or_else(|| "missing argument".into())
1724}
1725
1726fn tuple_index(v: &Value, i: usize) -> Result<Value, String> {
1727    match v {
1728        Value::Tuple(items) => items.get(i).cloned()
1729            .ok_or_else(|| format!("tuple index {i} out of range (len={})", items.len())),
1730        other => Err(format!("expected Tuple, got {other:?}")),
1731    }
1732}
1733
1734fn expect_str(v: Option<&Value>) -> Result<String, String> {
1735    match v {
1736        Some(Value::Str(s)) => Ok(s.clone()),
1737        Some(other) => Err(format!("expected Str, got {other:?}")),
1738        None => Err("missing argument".into()),
1739    }
1740}
1741
1742fn expect_int(v: Option<&Value>) -> Result<i64, String> {
1743    match v {
1744        Some(Value::Int(n)) => Ok(*n),
1745        Some(other) => Err(format!("expected Int, got {other:?}")),
1746        None => Err("missing argument".into()),
1747    }
1748}
1749
1750fn expect_float(v: Option<&Value>) -> Result<f64, String> {
1751    match v {
1752        Some(Value::Float(f)) => Ok(*f),
1753        Some(other) => Err(format!("expected Float, got {other:?}")),
1754        None => Err("missing argument".into()),
1755    }
1756}
1757
1758fn expect_list(v: Option<&Value>) -> Result<&Vec<Value>, String> {
1759    match v {
1760        Some(Value::List(xs)) => Ok(xs),
1761        Some(other) => Err(format!("expected List, got {other:?}")),
1762        None => Err("missing argument".into()),
1763    }
1764}
1765
1766fn expect_bool(v: Option<&Value>) -> Result<bool, String> {
1767    match v {
1768        Some(Value::Bool(b)) => Ok(*b),
1769        Some(other) => Err(format!("expected Bool, got {other:?}")),
1770        None => Err("missing argument".into()),
1771    }
1772}
1773
1774fn expect_deque(v: Option<&Value>) -> Result<&std::collections::VecDeque<Value>, String> {
1775    match v {
1776        Some(Value::Deque(d)) => Ok(d),
1777        Some(other) => Err(format!("expected Deque, got {other:?}")),
1778        None => Err("missing argument".into()),
1779    }
1780}
1781
1782fn some(v: Value) -> Value { Value::Variant { name: "Some".into(), args: vec![v] } }
1783fn none() -> Value { Value::Variant { name: "None".into(), args: Vec::new() } }
1784fn ok_v(v: Value) -> Value { Value::Variant { name: "Ok".into(), args: vec![v] } }
1785fn err_v(v: Value) -> Value { Value::Variant { name: "Err".into(), args: vec![v] } }
1786
1787// -- std.parser helpers (#217) ----------------------------------------
1788
1789/// Construct a tagged parser-AST node. The runtime representation is
1790/// `{ kind: "Char" | "Seq" | ..., ...children }`; the type system
1791/// treats these as opaque `Parser[T]` so user code can't poke at the
1792/// fields. Encoding is canonical because `IndexMap` insertion order
1793/// is stable and we always insert `kind` first.
1794fn parser_node(kind: &str, fields: &[(&str, Value)]) -> Value {
1795    let mut r = indexmap::IndexMap::new();
1796    r.insert("kind".into(), Value::Str(kind.into()));
1797    for (k, v) in fields {
1798        r.insert((*k).into(), v.clone());
1799    }
1800    Value::Record(r)
1801}
1802
1803// `parser.run` interpretation lives in `lex-bytecode::parser_runtime`
1804// (#221) — it needs reentrant Vm access to invoke closures inside
1805// `Map` / `AndThen` nodes, which the pure-builtin path doesn't have.
1806
1807// -- std.random helpers (#219) ----------------------------------------
1808
1809/// SplitMix64 — single-`u64` state PRNG that is byte-identical
1810/// across platforms (no float math, no platform-dependent reductions).
1811/// Returns `(drawn, next_state)`. Constants are the canonical
1812/// SplitMix64 mixer from the original 2014 paper.
1813fn splitmix64(state: u64) -> (u64, u64) {
1814    let next = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
1815    let mut z = next;
1816    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
1817    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
1818    let z = z ^ (z >> 31);
1819    (z, next)
1820}
1821
1822/// Encode a SplitMix64 state as the user-facing `Rng` value.
1823/// `Rng = { state :: Int }`; the type-checker treats `Rng` as
1824/// opaque so users can't poke at the field.
1825fn rng_value(state: u64) -> Value {
1826    let mut fields = indexmap::IndexMap::new();
1827    fields.insert("state".into(), Value::Int(state as i64));
1828    Value::Record(fields)
1829}
1830
1831/// Pull the SplitMix64 state out of a `Value::Record { state }`.
1832fn rng_decode(v: Option<&Value>) -> Result<u64, String> {
1833    let rec = match v {
1834        Some(Value::Record(r)) => r,
1835        Some(other) => return Err(format!("expected Rng, got {other:?}")),
1836        None => return Err("missing Rng arg".into()),
1837    };
1838    match rec.get("state") {
1839        Some(Value::Int(n)) => Ok(*n as u64),
1840        _ => Err("malformed Rng: missing `state :: Int`".into()),
1841    }
1842}
1843
1844// -- helpers for `std.http` builders / decoders --
1845
1846fn expect_record_pure(v: Option<&Value>) -> Result<&indexmap::IndexMap<String, Value>, String> {
1847    match v {
1848        Some(Value::Record(r)) => Ok(r),
1849        Some(other) => Err(format!("expected Record, got {other:?}")),
1850        None => Err("missing Record argument".into()),
1851    }
1852}
1853
1854fn http_decode_err_pure(msg: String) -> Value {
1855    let inner = Value::Variant {
1856        name: "DecodeError".into(),
1857        args: vec![Value::Str(msg)],
1858    };
1859    err_v(inner)
1860}
1861
1862/// Apply or replace a header in an `HttpRequest` record's `headers`
1863/// field. Header names are normalized to lowercase to match HTTP/1.1
1864/// case-insensitivity; an existing entry under any casing is
1865/// overwritten by the new value.
1866fn http_set_header(
1867    mut req: indexmap::IndexMap<String, Value>,
1868    name: &str,
1869    value: &str,
1870) -> indexmap::IndexMap<String, Value> {
1871    use lex_bytecode::MapKey;
1872    let mut headers = match req.shift_remove("headers") {
1873        Some(Value::Map(m)) => m,
1874        _ => std::collections::BTreeMap::new(),
1875    };
1876    let key = MapKey::Str(name.to_lowercase());
1877    // Drop any case variant of the same header name first so casing
1878    // flips don't accumulate duplicates.
1879    let lowered = name.to_lowercase();
1880    headers.retain(|k, _| match k {
1881        MapKey::Str(s) => s.to_lowercase() != lowered,
1882        _ => true,
1883    });
1884    headers.insert(key, Value::Str(value.to_string()));
1885    req.insert("headers".into(), Value::Map(headers));
1886    req
1887}
1888
1889/// Append `?k=v&...` (URL-encoded) to the `url` field of an
1890/// `HttpRequest` record. Existing query string is preserved and
1891/// extended with `&`. Iteration order is the input map's natural
1892/// order (`BTreeMap` → sorted by key) so the produced URL is
1893/// deterministic.
1894fn http_append_query(
1895    mut req: indexmap::IndexMap<String, Value>,
1896    params: &std::collections::BTreeMap<lex_bytecode::MapKey, Value>,
1897) -> indexmap::IndexMap<String, Value> {
1898    use lex_bytecode::MapKey;
1899    let url = match req.get("url") {
1900        Some(Value::Str(s)) => s.clone(),
1901        _ => return req,
1902    };
1903    let mut pieces = Vec::new();
1904    for (k, v) in params {
1905        let kk = match k { MapKey::Str(s) => s.clone(), _ => continue };
1906        let vv = match v { Value::Str(s) => s.clone(), _ => continue };
1907        pieces.push(format!("{}={}", url_encode(&kk), url_encode(&vv)));
1908    }
1909    if pieces.is_empty() { return req; }
1910    let sep = if url.contains('?') { '&' } else { '?' };
1911    let new_url = format!("{url}{sep}{}", pieces.join("&"));
1912    req.insert("url".into(), Value::Str(new_url));
1913    req
1914}
1915
1916/// Minimal RFC-3986 percent-encode for `application/x-www-form-
1917/// urlencoded` query values. Pulling in `urlencoding` for one
1918/// callsite would drag a dep into the runtime; the inline version is
1919/// short and easy to audit.
1920fn url_encode(s: &str) -> String {
1921    let mut out = String::with_capacity(s.len());
1922    for b in s.bytes() {
1923        match b {
1924            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1925                out.push(b as char);
1926            }
1927            _ => out.push_str(&format!("%{:02X}", b)),
1928        }
1929    }
1930    out
1931}
1932
1933fn value_to_json(v: &Value) -> serde_json::Value { v.to_json() }
1934
1935/// The `toml` crate's serde adapter wraps datetimes in a sentinel
1936/// object `{"$__toml_private_datetime": "<rfc3339>"}` so that the
1937/// `Datetime` type round-trips through `serde::Value`. For Lex's
1938/// purposes a plain RFC-3339 string is what we want — callers can
1939/// then pipe through `datetime.parse_iso` if they need an
1940/// `Instant`. Walk the tree and replace each wrapper with its
1941/// inner string, in-place.
1942fn unwrap_toml_datetime_markers(v: &mut serde_json::Value) {
1943    use serde_json::Value as J;
1944    match v {
1945        J::Object(map) => {
1946            // Detect single-key marker objects and replace them
1947            // with their inner string. We have to take care to
1948            // avoid borrow conflicts.
1949            if map.len() == 1 {
1950                if let Some(J::String(s)) = map.get("$__toml_private_datetime") {
1951                    let s = s.clone();
1952                    *v = J::String(s);
1953                    return;
1954                }
1955            }
1956            for (_, child) in map.iter_mut() {
1957                unwrap_toml_datetime_markers(child);
1958            }
1959        }
1960        J::Array(items) => {
1961            for item in items.iter_mut() {
1962                unwrap_toml_datetime_markers(item);
1963            }
1964        }
1965        _ => {}
1966    }
1967}
1968
1969fn json_to_value(v: &serde_json::Value) -> Value { Value::from_json(v) }
1970
1971/// Extract the `List[Str]` of required field names from the second
1972/// argument of `*.parse_strict`. The list is allowed to be empty
1973/// (the parse degenerates to plain `parse`); other shapes are a
1974/// caller bug rather than a parse error.
1975fn required_field_names(arg: Option<&Value>) -> Result<Vec<String>, String> {
1976    let list = expect_list(arg)?;
1977    let mut out = Vec::with_capacity(list.len());
1978    for v in list {
1979        match v {
1980            Value::Str(s) => out.push(s.clone()),
1981            other => return Err(format!(
1982                "parse_strict: required-fields list must contain Str, got {other:?}"
1983            )),
1984        }
1985    }
1986    Ok(out)
1987}
1988
1989/// Verify that `value` is an object containing every entry in
1990/// `required`. A required entry may be a plain field name (must
1991/// exist at the top level) or a dotted path (`"project.license"`)
1992/// which descends through nested objects. Returns a stable,
1993/// human-readable error listing every missing path so the agent's
1994/// verifier can surface it directly.
1995///
1996/// Tactical fix for #168 — gives users a way to make `parse[T]`
1997/// errors propagate as `Result::Err` instead of as runtime
1998/// `GetField` errors at access time. The full type-driven fix
1999/// (deriving `required` from `T` at type-check time so plain
2000/// `parse[T]` works, including auto-wrapping `Option[F]` fields
2001/// as not-required) is the cleaner endgame; see #168.
2002///
2003/// Path semantics:
2004/// * `"name"` → top-level `name` must be present (any value).
2005/// * `"a.b.c"` → walk `a`, then `b`, then check `c` exists. Each
2006///   intermediate value must itself be an object.
2007/// * `\\.` is the literal-dot escape (e.g. `"weird\\.key"` for a
2008///   field that genuinely contains a dot in its name).
2009fn check_required_fields(
2010    value: &serde_json::Value,
2011    required: &[String],
2012) -> Result<(), String> {
2013    if required.is_empty() {
2014        return Ok(());
2015    }
2016    if !matches!(value, serde_json::Value::Object(_)) {
2017        return Err(format!(
2018            "parse_strict: expected top-level object with fields {:?}, got {value}",
2019            required
2020        ));
2021    }
2022    let mut missing: Vec<String> = Vec::new();
2023    for path in required {
2024        if !path_exists(value, path) {
2025            missing.push(path.clone());
2026        }
2027    }
2028    if missing.is_empty() {
2029        Ok(())
2030    } else {
2031        Err(format!("missing required field(s): {}", missing.join(", ")))
2032    }
2033}
2034
2035/// Walk `value` along the dotted `path` and report whether the
2036/// terminal segment exists. Intermediate non-object stops surface
2037/// as "missing" — a path can't traverse through a string, list, or
2038/// scalar.
2039fn path_exists(value: &serde_json::Value, path: &str) -> bool {
2040    let mut cursor = value;
2041    let segments = split_dotted_path(path);
2042    for seg in &segments {
2043        match cursor {
2044            serde_json::Value::Object(o) => match o.get(seg.as_str()) {
2045                Some(next) => cursor = next,
2046                None => return false,
2047            },
2048            _ => return false,
2049        }
2050    }
2051    true
2052}
2053
2054/// Split `"a.b.c"` into `["a", "b", "c"]`, with `\.` recognised
2055/// as a literal-dot escape so legitimate dotted field names
2056/// (e.g. `"package\.json"`) don't accidentally start a descent.
2057fn split_dotted_path(path: &str) -> Vec<String> {
2058    let mut out: Vec<String> = Vec::new();
2059    let mut cur = String::new();
2060    let mut iter = path.chars().peekable();
2061    while let Some(c) = iter.next() {
2062        if c == '\\' {
2063            // Backslash at end is preserved; only `\.` is special.
2064            if let Some(&'.') = iter.peek() {
2065                cur.push('.');
2066                iter.next();
2067                continue;
2068            }
2069            cur.push(c);
2070        } else if c == '.' {
2071            out.push(std::mem::take(&mut cur));
2072        } else {
2073            cur.push(c);
2074        }
2075    }
2076    out.push(cur);
2077    out
2078}
2079
2080/// Extract the `List[(Str, Str)]` type schema from the third argument
2081/// of `*.parse_strict` (#322). If the argument is absent or malformed,
2082/// returns an empty vec — callers treat that as "skip type validation".
2083fn extract_type_schema(v: Option<&Value>) -> Vec<(String, String)> {
2084    match v {
2085        Some(Value::List(pairs)) => pairs.iter().filter_map(|p| {
2086            if let Value::Tuple(items) = p {
2087                if items.len() == 2 {
2088                    if let (Value::Str(name), Value::Str(tag)) = (&items[0], &items[1]) {
2089                        return Some((name.clone(), tag.clone()));
2090                    }
2091                }
2092            }
2093            None
2094        }).collect(),
2095        _ => vec![],
2096    }
2097}
2098
2099/// Validate each field in `json` against its declared type tag from
2100/// the schema. Returns `Err` for the first field whose JSON value
2101/// doesn't match its tag. Fields not present in the JSON object are
2102/// silently skipped (presence is enforced separately by
2103/// `check_required_fields`).
2104fn validate_field_types(
2105    json: &serde_json::Value,
2106    schema: &[(String, String)],
2107) -> Result<(), String> {
2108    if schema.is_empty() {
2109        return Ok(());
2110    }
2111    let obj = match json.as_object() {
2112        Some(o) => o,
2113        None => return Ok(()), // not an object — let other validation handle it
2114    };
2115    for (field, tag) in schema {
2116        if let Some(val) = obj.get(field) {
2117            if let Err(e) = check_json_type(val, tag) {
2118                return Err(format!("field `{field}`: {e}"));
2119            }
2120        }
2121    }
2122    Ok(())
2123}
2124
2125/// Recursively check that `val` conforms to the compact type `tag`.
2126fn check_json_type(val: &serde_json::Value, tag: &str) -> Result<(), String> {
2127    use serde_json::Value as J;
2128    match (tag, val) {
2129        ("Int", J::Number(n)) if n.is_i64() || n.is_u64() => Ok(()),
2130        ("Int", other) => Err(format!("expected Int, got {}", json_type_name(other))),
2131        ("Float", J::Number(_)) => Ok(()),
2132        ("Float", other) => Err(format!("expected Float, got {}", json_type_name(other))),
2133        ("Bool", J::Bool(_)) => Ok(()),
2134        ("Bool", other) => Err(format!("expected Bool, got {}", json_type_name(other))),
2135        ("Str", J::String(_)) => Ok(()),
2136        ("Str", other) => Err(format!("expected Str, got {}", json_type_name(other))),
2137        // Option[X]: null maps to None — any null is acceptable
2138        (tag, J::Null) if tag.starts_with("Option[") => Ok(()),
2139        (tag, val) if tag.starts_with("Option[") && tag.ends_with(']') => {
2140            let inner = &tag[7..tag.len() - 1]; // strip "Option[" and "]"
2141            check_json_type(val, inner)
2142        }
2143        // List[X]: validate each element
2144        (tag, J::Array(items)) if tag.starts_with("List[") && tag.ends_with(']') => {
2145            let inner = &tag[5..tag.len() - 1]; // strip "List[" and "]"
2146            for (i, item) in items.iter().enumerate() {
2147                if let Err(e) = check_json_type(item, inner) {
2148                    return Err(format!("[{i}]: {e}"));
2149                }
2150            }
2151            Ok(())
2152        }
2153        ("Record", _) => Ok(()), // opaque nested record — skip deep check
2154        ("Any", _) => Ok(()),    // unknown type — skip
2155        _ => Ok(()),             // unrecognized tag — skip
2156    }
2157}
2158
2159fn json_type_name(v: &serde_json::Value) -> &'static str {
2160    match v {
2161        serde_json::Value::Null => "null",
2162        serde_json::Value::Bool(_) => "Bool",
2163        serde_json::Value::Number(_) => "Number",
2164        serde_json::Value::String(_) => "Str",
2165        serde_json::Value::Array(_) => "Array",
2166        serde_json::Value::Object(_) => "Object",
2167    }
2168}
2169
2170/// Parse a `.env`-style file into key→value pairs. Accepts:
2171///
2172/// * Blank lines and `# comment` lines (ignored).
2173/// * `KEY=VALUE` with no spaces around `=`. Optional surrounding
2174///   `"..."` or `'...'` quotes on the value. No escape sequences,
2175///   no shell expansion — by design; we want this to be a *data*
2176///   parser, not a shell snippet evaluator.
2177///
2178/// Errors carry the offending line number (1-indexed) so the
2179/// agent's verifier can point a human at the right place.
2180fn parse_dotenv(src: &str) -> Result<indexmap::IndexMap<String, String>, String> {
2181    let mut out = indexmap::IndexMap::new();
2182    for (idx, raw) in src.lines().enumerate() {
2183        let line = raw.trim();
2184        if line.is_empty() || line.starts_with('#') {
2185            continue;
2186        }
2187        // Optional `export KEY=VALUE` shell form — accepted for
2188        // compat with files that grew out of `set -a` workflows.
2189        let after_export = line.strip_prefix("export ").unwrap_or(line);
2190        let (k, v) = match after_export.split_once('=') {
2191            Some(kv) => kv,
2192            None => return Err(format!("dotenv.parse line {}: missing `=`", idx + 1)),
2193        };
2194        let key = k.trim();
2195        if key.is_empty() {
2196            return Err(format!("dotenv.parse line {}: empty key", idx + 1));
2197        }
2198        let v_trim = v.trim();
2199        let value = if let Some(q) = v_trim.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
2200            q.to_string()
2201        } else if let Some(q) = v_trim.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) {
2202            q.to_string()
2203        } else {
2204            v_trim.to_string()
2205        };
2206        out.insert(key.to_string(), value);
2207    }
2208    Ok(out)
2209}
2210
2211// -- datetime helpers (Instant ↔ chrono::DateTime<Utc>) --
2212
2213/// Convert a `chrono::DateTime` (any `TimeZone`) into a Lex `Instant`,
2214/// represented as nanoseconds since the UTC unix epoch. Saturates on
2215/// out-of-range timestamps so the runtime never panics.
2216fn instant_from_chrono<Tz: chrono::TimeZone>(dt: chrono::DateTime<Tz>) -> i64 {
2217    dt.timestamp_nanos_opt().unwrap_or(i64::MAX)
2218}
2219
2220fn chrono_from_instant(n: i64) -> chrono::DateTime<chrono::Utc> {
2221    let secs = n.div_euclid(1_000_000_000);
2222    let nanos = n.rem_euclid(1_000_000_000) as u32;
2223    use chrono::TimeZone;
2224    chrono::Utc
2225        .timestamp_opt(secs, nanos)
2226        .single()
2227        .unwrap_or_else(chrono::Utc::now)
2228}
2229
2230fn format_iso(n: i64) -> String {
2231    chrono_from_instant(n).to_rfc3339()
2232}
2233
2234/// Parsed form of the user-side `Tz` variant. Mirrors the type
2235/// registered in `TypeEnv::new_with_builtins`.
2236enum TzArg {
2237    Utc,
2238    Local,
2239    /// Fixed offset in minutes east of UTC.
2240    Offset(i32),
2241    /// IANA name like `"America/New_York"`.
2242    Iana(String),
2243}
2244
2245fn parse_tz_arg(v: Option<&Value>) -> Result<TzArg, String> {
2246    match v {
2247        Some(Value::Variant { name, args }) => match (name.as_str(), args.as_slice()) {
2248            ("Utc", []) => Ok(TzArg::Utc),
2249            ("Local", []) => Ok(TzArg::Local),
2250            ("Offset", [Value::Int(m)]) => {
2251                let m = i32::try_from(*m).map_err(|_| {
2252                    format!("Tz::Offset: minutes out of range: {m}")
2253                })?;
2254                Ok(TzArg::Offset(m))
2255            }
2256            ("Iana", [Value::Str(s)]) => Ok(TzArg::Iana(s.clone())),
2257            (other, _) => Err(format!(
2258                "expected Tz variant (Utc | Local | Offset(Int) | Iana(Str)), got `{other}` with {} arg(s)",
2259                args.len()
2260            )),
2261        },
2262        Some(other) => Err(format!("expected Tz variant, got {other:?}")),
2263        None => Err("missing Tz argument".into()),
2264    }
2265}
2266
2267fn resolve_tz_to_components(n: i64, tz: &TzArg) -> Result<Value, String> {
2268    use chrono::{TimeZone, Datelike, Timelike, Offset};
2269    let utc_dt = chrono_from_instant(n);
2270    let (y, m, d, hh, mm, ss, ns, off_min) = match tz {
2271        TzArg::Utc => {
2272            let d = utc_dt;
2273            (d.year(), d.month() as i32, d.day() as i32,
2274             d.hour() as i32, d.minute() as i32, d.second() as i32,
2275             d.nanosecond() as i32, 0)
2276        }
2277        TzArg::Local => {
2278            let d = utc_dt.with_timezone(&chrono::Local);
2279            let off = d.offset().fix().local_minus_utc() / 60;
2280            (d.year(), d.month() as i32, d.day() as i32,
2281             d.hour() as i32, d.minute() as i32, d.second() as i32,
2282             d.nanosecond() as i32, off)
2283        }
2284        TzArg::Offset(off_min) => {
2285            let off_secs = off_min.saturating_mul(60);
2286            let fixed = chrono::FixedOffset::east_opt(off_secs)
2287                .ok_or("to_components: offset out of range")?;
2288            let d = utc_dt.with_timezone(&fixed);
2289            (d.year(), d.month() as i32, d.day() as i32,
2290             d.hour() as i32, d.minute() as i32, d.second() as i32,
2291             d.nanosecond() as i32, *off_min)
2292        }
2293        TzArg::Iana(name) => {
2294            let tz: chrono_tz::Tz = name.parse()
2295                .map_err(|e| format!("to_components: unknown timezone `{name}`: {e}"))?;
2296            let d = utc_dt.with_timezone(&tz);
2297            let off = d.offset().fix().local_minus_utc() / 60;
2298            (d.year(), d.month() as i32, d.day() as i32,
2299             d.hour() as i32, d.minute() as i32, d.second() as i32,
2300             d.nanosecond() as i32, off)
2301        }
2302    };
2303    let mut rec = indexmap::IndexMap::new();
2304    rec.insert("year".into(),    Value::Int(y as i64));
2305    rec.insert("month".into(),   Value::Int(m as i64));
2306    rec.insert("day".into(),     Value::Int(d as i64));
2307    rec.insert("hour".into(),    Value::Int(hh as i64));
2308    rec.insert("minute".into(),  Value::Int(mm as i64));
2309    rec.insert("second".into(),  Value::Int(ss as i64));
2310    rec.insert("nano".into(),    Value::Int(ns as i64));
2311    rec.insert("tz_offset_minutes".into(), Value::Int(off_min as i64));
2312    let _ = chrono::Utc.timestamp_opt(0, 0); // touch TimeZone to suppress unused-import lint paths
2313    Ok(Value::Record(rec))
2314}
2315
2316
2317fn instant_from_components(rec: &indexmap::IndexMap<String, Value>) -> Result<i64, String> {
2318    use chrono::TimeZone;
2319    fn get_int(rec: &indexmap::IndexMap<String, Value>, k: &str) -> Result<i64, String> {
2320        match rec.get(k) {
2321            Some(Value::Int(n)) => Ok(*n),
2322            other => Err(format!("from_components: missing or non-int field `{k}`: {other:?}")),
2323        }
2324    }
2325    let y = get_int(rec, "year")? as i32;
2326    let m = get_int(rec, "month")? as u32;
2327    let d = get_int(rec, "day")? as u32;
2328    let hh = get_int(rec, "hour")? as u32;
2329    let mm = get_int(rec, "minute")? as u32;
2330    let ss = get_int(rec, "second")? as u32;
2331    let ns = get_int(rec, "nano")? as u32;
2332    let off_min = get_int(rec, "tz_offset_minutes")? as i32;
2333    let off = chrono::FixedOffset::east_opt(off_min * 60)
2334        .ok_or("from_components: offset out of range")?;
2335    let dt = off
2336        .with_ymd_and_hms(y, m, d, hh, mm, ss)
2337        .single()
2338        .ok_or("from_components: invalid or ambiguous date/time")?;
2339    let dt = dt + chrono::Duration::nanoseconds(ns as i64);
2340    Ok(instant_from_chrono(dt))
2341}
2342
2343// ── AEAD helpers (#382 AEAD slice) ────────────────────────────────────
2344//
2345// Each `*_seal_impl` returns a `Result[AeadResult, Str]` Lex Variant:
2346// `Ok(AeadResult { ciphertext, tag })` on success, `Err(msg)` on input
2347// validation failure (wrong key/nonce length). Each `*_open_impl`
2348// returns `Result[Bytes, Str]` — authentication failure (bad tag /
2349// modified ciphertext) surfaces as `Err`, not a panic.
2350//
2351// Pure ops: every output is a deterministic function of the inputs;
2352// no syscalls, no clock reads, no entropy. Live in the pure-builtin
2353// dispatch table so callers don't need an effect grant beyond
2354// whatever they used to obtain key + nonce in the first place.
2355
2356/// `(key, nonce, aad, plaintext)` references unpacked from a 4-arg
2357/// AEAD seal call. Aliased so the `type_complexity` clippy lint stays
2358/// quiet on the tuple of four borrows.
2359type Aead4<'a> = (&'a Vec<u8>, &'a Vec<u8>, &'a Vec<u8>, &'a Vec<u8>);
2360
2361/// `(key, nonce, aad, ciphertext, tag)` references unpacked from a
2362/// 5-arg AEAD open call.
2363type Aead5<'a> = (&'a Vec<u8>, &'a Vec<u8>, &'a Vec<u8>, &'a Vec<u8>, &'a Vec<u8>);
2364
2365fn unpack4_bytes<'a>(
2366    args: &'a [Value],
2367    op: &str,
2368) -> Result<Aead4<'a>, String> {
2369    let pick = |i: usize, name: &str| -> Result<&'a Vec<u8>, String> {
2370        match args.get(i) {
2371            Some(Value::Bytes(b)) => Ok(b),
2372            Some(other) => Err(format!("{op}: {name} must be Bytes, got {other:?}")),
2373            None => Err(format!("{op}: missing {name} argument")),
2374        }
2375    };
2376    Ok((pick(0, "key")?, pick(1, "nonce")?, pick(2, "aad")?, pick(3, "plaintext")?))
2377}
2378
2379fn unpack5_bytes<'a>(
2380    args: &'a [Value],
2381    op: &str,
2382) -> Result<Aead5<'a>, String> {
2383    let pick = |i: usize, name: &str| -> Result<&'a Vec<u8>, String> {
2384        match args.get(i) {
2385            Some(Value::Bytes(b)) => Ok(b),
2386            Some(other) => Err(format!("{op}: {name} must be Bytes, got {other:?}")),
2387            None => Err(format!("{op}: missing {name} argument")),
2388        }
2389    };
2390    Ok((
2391        pick(0, "key")?,
2392        pick(1, "nonce")?,
2393        pick(2, "aad")?,
2394        pick(3, "ciphertext")?,
2395        pick(4, "tag")?,
2396    ))
2397}
2398
2399fn aead_result(ciphertext: Vec<u8>, tag: Vec<u8>) -> Value {
2400    let mut rec = indexmap::IndexMap::new();
2401    rec.insert("ciphertext".into(), Value::Bytes(ciphertext));
2402    rec.insert("tag".into(), Value::Bytes(tag));
2403    Value::Record(rec)
2404}
2405
2406fn aead_err(msg: impl Into<String>) -> Value {
2407    err_v(Value::Str(msg.into()))
2408}
2409
2410fn aes_gcm_seal_impl(args: &[Value]) -> Value {
2411    use aes_gcm::aead::{Aead, KeyInit, Payload};
2412    use aes_gcm::{Aes128Gcm, Aes256Gcm, Nonce};
2413    let (key, nonce, aad, plaintext) = match unpack4_bytes(args, "aes_gcm_seal") {
2414        Ok(t) => t,
2415        Err(e) => return aead_err(e),
2416    };
2417    if nonce.len() != 12 {
2418        return aead_err(format!(
2419            "aes_gcm_seal: nonce must be exactly 12 bytes, got {}", nonce.len()
2420        ));
2421    }
2422    let n = Nonce::from_slice(nonce);
2423    let payload = Payload { msg: plaintext, aad };
2424    // Encrypts and appends the 16-byte tag. We split the tag back out so
2425    // the caller sees the structured AeadResult shape.
2426    let combined = match key.len() {
2427        16 => {
2428            let cipher = Aes128Gcm::new_from_slice(key)
2429                .map_err(|e| e.to_string());
2430            match cipher {
2431                Ok(c) => c.encrypt(n, payload).map_err(|e| format!("aes_gcm_seal: {e}")),
2432                Err(e) => Err(format!("aes_gcm_seal: {e}")),
2433            }
2434        }
2435        32 => {
2436            let cipher = Aes256Gcm::new_from_slice(key)
2437                .map_err(|e| e.to_string());
2438            match cipher {
2439                Ok(c) => c.encrypt(n, payload).map_err(|e| format!("aes_gcm_seal: {e}")),
2440                Err(e) => Err(format!("aes_gcm_seal: {e}")),
2441            }
2442        }
2443        // AES-192 is rarely used; the aes-gcm crate doesn't expose
2444        // Aes192Gcm in its default API. Reject other sizes explicitly.
2445        other => return aead_err(format!(
2446            "aes_gcm_seal: key must be 16 or 32 bytes, got {other}"
2447        )),
2448    };
2449    match combined {
2450        Ok(mut buf) => {
2451            // tag is the last 16 bytes.
2452            let tag_start = buf.len() - 16;
2453            let tag = buf.split_off(tag_start);
2454            ok_v(aead_result(buf, tag))
2455        }
2456        Err(e) => aead_err(e),
2457    }
2458}
2459
2460fn aes_gcm_open_impl(args: &[Value]) -> Value {
2461    use aes_gcm::aead::{Aead, KeyInit, Payload};
2462    use aes_gcm::{Aes128Gcm, Aes256Gcm, Nonce};
2463    let (key, nonce, aad, ciphertext, tag) = match unpack5_bytes(args, "aes_gcm_open") {
2464        Ok(t) => t,
2465        Err(e) => return err_v(Value::Str(e)),
2466    };
2467    if nonce.len() != 12 {
2468        return err_v(Value::Str(format!(
2469            "aes_gcm_open: nonce must be exactly 12 bytes, got {}", nonce.len()
2470        )));
2471    }
2472    if tag.len() != 16 {
2473        return err_v(Value::Str(format!(
2474            "aes_gcm_open: tag must be exactly 16 bytes, got {}", tag.len()
2475        )));
2476    }
2477    // Rebuild the "ciphertext || tag" buffer the aes-gcm crate expects.
2478    let mut combined = Vec::with_capacity(ciphertext.len() + tag.len());
2479    combined.extend_from_slice(ciphertext);
2480    combined.extend_from_slice(tag);
2481    let n = Nonce::from_slice(nonce);
2482    let payload = Payload { msg: &combined, aad };
2483    let plaintext = match key.len() {
2484        16 => Aes128Gcm::new_from_slice(key)
2485            .map_err(|e| format!("aes_gcm_open: {e}"))
2486            .and_then(|c| c.decrypt(n, payload).map_err(|e| format!("aes_gcm_open: {e}"))),
2487        32 => Aes256Gcm::new_from_slice(key)
2488            .map_err(|e| format!("aes_gcm_open: {e}"))
2489            .and_then(|c| c.decrypt(n, payload).map_err(|e| format!("aes_gcm_open: {e}"))),
2490        other => return err_v(Value::Str(format!(
2491            "aes_gcm_open: key must be 16 or 32 bytes, got {other}"
2492        ))),
2493    };
2494    match plaintext {
2495        Ok(p) => ok_v(Value::Bytes(p)),
2496        Err(e) => err_v(Value::Str(e)),
2497    }
2498}
2499
2500fn chacha20_seal_impl(args: &[Value]) -> Value {
2501    use chacha20poly1305::aead::{Aead, KeyInit, Payload};
2502    use chacha20poly1305::{ChaCha20Poly1305, Nonce};
2503    let (key, nonce, aad, plaintext) = match unpack4_bytes(args, "chacha20_poly1305_seal") {
2504        Ok(t) => t,
2505        Err(e) => return aead_err(e),
2506    };
2507    if key.len() != 32 {
2508        return aead_err(format!(
2509            "chacha20_poly1305_seal: key must be exactly 32 bytes, got {}", key.len()
2510        ));
2511    }
2512    if nonce.len() != 12 {
2513        return aead_err(format!(
2514            "chacha20_poly1305_seal: nonce must be exactly 12 bytes, got {}", nonce.len()
2515        ));
2516    }
2517    let cipher = ChaCha20Poly1305::new_from_slice(key)
2518        .map_err(|e| format!("chacha20_poly1305_seal: {e}"));
2519    let n = Nonce::from_slice(nonce);
2520    let payload = Payload { msg: plaintext, aad };
2521    let combined = match cipher {
2522        Ok(c) => c.encrypt(n, payload).map_err(|e| format!("chacha20_poly1305_seal: {e}")),
2523        Err(e) => Err(e),
2524    };
2525    match combined {
2526        Ok(mut buf) => {
2527            let tag_start = buf.len() - 16;
2528            let tag = buf.split_off(tag_start);
2529            ok_v(aead_result(buf, tag))
2530        }
2531        Err(e) => aead_err(e),
2532    }
2533}
2534
2535fn chacha20_open_impl(args: &[Value]) -> Value {
2536    use chacha20poly1305::aead::{Aead, KeyInit, Payload};
2537    use chacha20poly1305::{ChaCha20Poly1305, Nonce};
2538    let (key, nonce, aad, ciphertext, tag) = match unpack5_bytes(args, "chacha20_poly1305_open") {
2539        Ok(t) => t,
2540        Err(e) => return err_v(Value::Str(e)),
2541    };
2542    if key.len() != 32 {
2543        return err_v(Value::Str(format!(
2544            "chacha20_poly1305_open: key must be exactly 32 bytes, got {}", key.len()
2545        )));
2546    }
2547    if nonce.len() != 12 {
2548        return err_v(Value::Str(format!(
2549            "chacha20_poly1305_open: nonce must be exactly 12 bytes, got {}", nonce.len()
2550        )));
2551    }
2552    if tag.len() != 16 {
2553        return err_v(Value::Str(format!(
2554            "chacha20_poly1305_open: tag must be exactly 16 bytes, got {}", tag.len()
2555        )));
2556    }
2557    let mut combined = Vec::with_capacity(ciphertext.len() + tag.len());
2558    combined.extend_from_slice(ciphertext);
2559    combined.extend_from_slice(tag);
2560    let cipher = ChaCha20Poly1305::new_from_slice(key)
2561        .map_err(|e| format!("chacha20_poly1305_open: {e}"));
2562    let n = Nonce::from_slice(nonce);
2563    let payload = Payload { msg: &combined, aad };
2564    match cipher.and_then(|c| c.decrypt(n, payload).map_err(|e| format!("chacha20_poly1305_open: {e}"))) {
2565        Ok(p) => ok_v(Value::Bytes(p)),
2566        Err(e) => err_v(Value::Str(e)),
2567    }
2568}
2569
2570// ── KDFs (#382 KDF slice) ──────────────────────────────────────────────────
2571//
2572// All three primitives return Result[Bytes, Str] so caller-controlled
2573// inputs (iteration count, output length, argon2id work factors) that
2574// violate the underlying crate's contract surface as Err, never as a
2575// VM panic.
2576
2577/// `(password :: Bytes, salt :: Bytes, iterations :: Int, len :: Int)`
2578/// references unpacked from a 4-arg KDF call.
2579type Kdf4<'a> = (&'a Vec<u8>, &'a Vec<u8>, i64, i64);
2580
2581/// `(ikm :: Bytes, salt :: Bytes, info :: Bytes, len :: Int)`
2582/// references unpacked from a 4-arg HKDF call.
2583type Hkdf4<'a> = (&'a Vec<u8>, &'a Vec<u8>, &'a Vec<u8>, i64);
2584
2585/// `(password :: Bytes, salt :: Bytes, t_cost :: Int, m_cost :: Int, len :: Int)`
2586/// for argon2id.
2587type Argon5<'a> = (&'a Vec<u8>, &'a Vec<u8>, i64, i64, i64);
2588
2589fn pick_bytes<'a>(args: &'a [Value], i: usize, op: &str, name: &str)
2590    -> Result<&'a Vec<u8>, String>
2591{
2592    match args.get(i) {
2593        Some(Value::Bytes(b)) => Ok(b),
2594        Some(other) => Err(format!("{op}: {name} must be Bytes, got {other:?}")),
2595        None => Err(format!("{op}: missing {name} argument")),
2596    }
2597}
2598
2599fn pick_int(args: &[Value], i: usize, op: &str, name: &str) -> Result<i64, String> {
2600    match args.get(i) {
2601        Some(Value::Int(n)) => Ok(*n),
2602        Some(other) => Err(format!("{op}: {name} must be Int, got {other:?}")),
2603        None => Err(format!("{op}: missing {name} argument")),
2604    }
2605}
2606
2607fn unpack_kdf4<'a>(args: &'a [Value], op: &str) -> Result<Kdf4<'a>, String> {
2608    Ok((
2609        pick_bytes(args, 0, op, "password")?,
2610        pick_bytes(args, 1, op, "salt")?,
2611        pick_int(args, 2, op, "iterations")?,
2612        pick_int(args, 3, op, "len")?,
2613    ))
2614}
2615
2616fn unpack_hkdf4<'a>(args: &'a [Value], op: &str) -> Result<Hkdf4<'a>, String> {
2617    Ok((
2618        pick_bytes(args, 0, op, "ikm")?,
2619        pick_bytes(args, 1, op, "salt")?,
2620        pick_bytes(args, 2, op, "info")?,
2621        pick_int(args, 3, op, "len")?,
2622    ))
2623}
2624
2625fn unpack_argon5<'a>(args: &'a [Value], op: &str) -> Result<Argon5<'a>, String> {
2626    Ok((
2627        pick_bytes(args, 0, op, "password")?,
2628        pick_bytes(args, 1, op, "salt")?,
2629        pick_int(args, 2, op, "t_cost")?,
2630        pick_int(args, 3, op, "m_cost")?,
2631        pick_int(args, 4, op, "len")?,
2632    ))
2633}
2634
2635/// Output-length sanity check shared by all three KDFs. A negative or
2636/// absurdly large `len` is a programmer error, not a runtime concern;
2637/// we cap at 1 MiB to keep accidental `i64::MAX` calls from OOMing the
2638/// process.
2639const KDF_MAX_LEN: usize = 1024 * 1024;
2640
2641fn check_len(op: &str, len: i64) -> Result<usize, String> {
2642    if len <= 0 {
2643        return Err(format!("{op}: len must be > 0, got {len}"));
2644    }
2645    if (len as u64) > KDF_MAX_LEN as u64 {
2646        return Err(format!(
2647            "{op}: len must be <= {KDF_MAX_LEN}, got {len}"
2648        ));
2649    }
2650    Ok(len as usize)
2651}
2652
2653fn pbkdf2_sha256_impl(args: &[Value]) -> Value {
2654    use hmac::Hmac;
2655    use sha2::Sha256;
2656    let op = "pbkdf2_sha256";
2657    let (password, salt, iterations, len) = match unpack_kdf4(args, op) {
2658        Ok(t) => t,
2659        Err(e) => return err_v(Value::Str(e)),
2660    };
2661    if iterations <= 0 {
2662        return err_v(Value::Str(format!(
2663            "{op}: iterations must be > 0, got {iterations}"
2664        )));
2665    }
2666    let out_len = match check_len(op, len) {
2667        Ok(n) => n,
2668        Err(e) => return err_v(Value::Str(e)),
2669    };
2670    let rounds = match u32::try_from(iterations) {
2671        Ok(r) => r,
2672        Err(_) => {
2673            return err_v(Value::Str(format!(
2674                "{op}: iterations must fit in u32, got {iterations}"
2675            )))
2676        }
2677    };
2678    let mut out = vec![0u8; out_len];
2679    if let Err(e) = pbkdf2::pbkdf2::<Hmac<Sha256>>(password, salt, rounds, &mut out) {
2680        return err_v(Value::Str(format!("{op}: {e}")));
2681    }
2682    ok_v(Value::Bytes(out))
2683}
2684
2685fn hkdf_sha256_impl(args: &[Value]) -> Value {
2686    use hkdf::Hkdf;
2687    use sha2::Sha256;
2688    let op = "hkdf_sha256";
2689    let (ikm, salt, info, len) = match unpack_hkdf4(args, op) {
2690        Ok(t) => t,
2691        Err(e) => return err_v(Value::Str(e)),
2692    };
2693    let out_len = match check_len(op, len) {
2694        Ok(n) => n,
2695        Err(e) => return err_v(Value::Str(e)),
2696    };
2697    // RFC 5869 caps output at 255 * HashLen; the `expand` call below
2698    // returns InvalidLength when exceeded — surface that as Err.
2699    let salt_opt: Option<&[u8]> = if salt.is_empty() { None } else { Some(salt) };
2700    let hk = Hkdf::<Sha256>::new(salt_opt, ikm);
2701    let mut out = vec![0u8; out_len];
2702    match hk.expand(info, &mut out) {
2703        Ok(()) => ok_v(Value::Bytes(out)),
2704        Err(e) => err_v(Value::Str(format!("{op}: {e}"))),
2705    }
2706}
2707
2708fn argon2id_impl(args: &[Value]) -> Value {
2709    use argon2::{Algorithm, Argon2, Params, Version};
2710    let op = "argon2id";
2711    let (password, salt, t_cost, m_cost, len) = match unpack_argon5(args, op) {
2712        Ok(t) => t,
2713        Err(e) => return err_v(Value::Str(e)),
2714    };
2715    let out_len = match check_len(op, len) {
2716        Ok(n) => n,
2717        Err(e) => return err_v(Value::Str(e)),
2718    };
2719    let t = match u32::try_from(t_cost) {
2720        Ok(n) if n >= 1 => n,
2721        _ => return err_v(Value::Str(format!(
2722            "{op}: t_cost must be a u32 >= 1, got {t_cost}"
2723        ))),
2724    };
2725    let m = match u32::try_from(m_cost) {
2726        Ok(n) if n >= Params::MIN_M_COST => n,
2727        _ => return err_v(Value::Str(format!(
2728            "{op}: m_cost must be a u32 >= {}, got {m_cost}",
2729            Params::MIN_M_COST
2730        ))),
2731    };
2732    // p=1 is the default and what every interop spec assumes (PHC
2733    // string, libsodium's argon2id_str). We don't expose parallelism
2734    // as a knob for now to keep callers from picking a value that
2735    // makes hashes uncomparable across machines.
2736    let params = match Params::new(m, t, 1, Some(out_len)) {
2737        Ok(p) => p,
2738        Err(e) => return err_v(Value::Str(format!("{op}: {e}"))),
2739    };
2740    let hasher = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
2741    let mut out = vec![0u8; out_len];
2742    if let Err(e) = hasher.hash_password_into(password, salt, &mut out) {
2743        return err_v(Value::Str(format!("{op}: {e}")));
2744    }
2745    ok_v(Value::Bytes(out))
2746}