Skip to main content

voxgig_struct/
mini.rs

1// Copyright (c) 2025-2026 Voxgig Ltd. MIT LICENSE.
2// VERSION: @voxgig/struct 0.1.0
3//
4// Minor utilities — port of the predicate / accessor / string-and-JSON
5// helpers from StructUtility.ts. Names are idiomatic snake_case; see the
6// TS->Rust table in README.md.
7
8use crate::ordered_map::OrderedMap;
9use crate::re::{Captures, Regex, RegexError};
10
11use crate::consts::*;
12use crate::value::{is_integer_f64, js_string, js_to_number, num_to_string, Value};
13
14const MIN_SAFE_INTEGER: i64 = -9007199254740991;
15const MAX_SAFE_INTEGER: i64 = 9007199254740991;
16
17// ---- type names / type codes ------------------------------------------
18
19/// `typename(t)` — human name for a type bit-flag.
20pub fn type_name(t: i64) -> String {
21    let idx = (t as u32).leading_zeros() as usize;
22    TYPENAME
23        .get(idx)
24        .map(|s| s.to_string())
25        .unwrap_or_else(|| TYPENAME[0].to_string())
26}
27
28/// `typify(value)` — type bit-code for a value.
29pub fn typify(value: &Value) -> i64 {
30    match value {
31        Value::Noval => T_NOVAL as i64,
32        Value::Null => (T_SCALAR | T_NULL) as i64,
33        Value::Num(n) => {
34            if is_integer_f64(*n) {
35                (T_SCALAR | T_NUMBER | T_INTEGER) as i64
36            } else if n.is_nan() {
37                T_NOVAL as i64
38            } else {
39                (T_SCALAR | T_NUMBER | T_DECIMAL) as i64
40            }
41        }
42        Value::Str(_) => (T_SCALAR | T_STRING) as i64,
43        Value::Bool(_) => (T_SCALAR | T_BOOLEAN) as i64,
44        Value::Func(_) => (T_SCALAR | T_FUNCTION) as i64,
45        Value::List(_) => (T_NODE | T_LIST) as i64,
46        // Sentinels are `{ '`$SKIP`': true }` plain objects in TS.
47        Value::Map(_) | Value::Sentinel(_) => (T_NODE | T_MAP) as i64,
48    }
49}
50
51// ---- predicates -------------------------------------------------------
52
53pub fn get_def(val: Value, alt: Value) -> Value {
54    if val.is_noval() {
55        alt
56    } else {
57        val
58    }
59}
60
61pub fn is_node(val: &Value) -> bool {
62    matches!(val, Value::List(_) | Value::Map(_))
63}
64
65pub fn is_map(val: &Value) -> bool {
66    matches!(val, Value::Map(_))
67}
68
69pub fn is_list(val: &Value) -> bool {
70    matches!(val, Value::List(_))
71}
72
73pub fn is_key(key: &Value) -> bool {
74    match key {
75        Value::Str(s) => !s.is_empty(),
76        Value::Num(_) => true,
77        _ => false,
78    }
79}
80
81pub fn is_empty(val: &Value) -> bool {
82    match val {
83        Value::Noval | Value::Null => true,
84        Value::Str(s) => s.is_empty(),
85        Value::List(l) => l.borrow().is_empty(),
86        Value::Map(m) => m.borrow().is_empty(),
87        _ => false,
88    }
89}
90
91pub fn is_func(val: &Value) -> bool {
92    matches!(val, Value::Func(_))
93}
94
95/// `size(val)` — length for lists/strings, key count for maps, integer
96/// part for numbers, 1/0 for booleans, 0 otherwise.
97pub fn size(val: &Value) -> i64 {
98    match val {
99        Value::List(l) => l.borrow().len() as i64,
100        Value::Map(m) => m.borrow().len() as i64,
101        Value::Str(s) => s.encode_utf16().count() as i64,
102        Value::Num(n) if n.is_finite() => n.floor() as i64,
103        Value::Bool(b) => i64::from(*b),
104        _ => 0,
105    }
106}
107
108// ---- slice ------------------------------------------------------------
109
110/// `slice(val, start?, end?, mutate?)` — sub-section of a list, string, or
111/// bounded number. When `val` is a list and `mutate` is true, the list is
112/// truncated/shifted in place (and the same list value is returned).
113pub fn slice(val: Value, start: Option<i64>, end: Option<i64>, mutate: bool) -> Value {
114    if let Value::Num(n) = val {
115        let s = start.unwrap_or(MIN_SAFE_INTEGER);
116        let e = end.unwrap_or(MAX_SAFE_INTEGER) - 1;
117        let lo = n.max(s as f64);
118        let r = lo.min(e as f64);
119        return Value::Num(r);
120    }
121
122    let vlen = size(&val);
123
124    let mut start = start;
125    if end.is_some() && start.is_none() {
126        start = Some(0);
127    }
128
129    if let Some(mut s) = start {
130        let mut e: Option<i64>;
131        if s < 0 {
132            let mut ee = vlen + s;
133            if ee < 0 {
134                ee = 0;
135            }
136            e = Some(ee);
137            s = 0;
138        } else if let Some(mut ee) = end {
139            if ee < 0 {
140                ee += vlen;
141                if ee < 0 {
142                    ee = 0;
143                }
144            } else if vlen < ee {
145                ee = vlen;
146            }
147            e = Some(ee);
148        } else {
149            e = Some(vlen);
150        }
151
152        if vlen < s {
153            s = vlen;
154        }
155
156        let e = e.take().unwrap_or(vlen);
157
158        if -1 < s && s <= e && e <= vlen {
159            match &val {
160                Value::List(l) => {
161                    if mutate {
162                        let mut lb = l.borrow_mut();
163                        let sub: Vec<Value> = lb[s as usize..e as usize].to_vec();
164                        *lb = sub;
165                        drop(lb);
166                        return val;
167                    } else {
168                        let lb = l.borrow();
169                        return Value::list(lb[s as usize..e as usize].to_vec());
170                    }
171                }
172                Value::Str(st) => {
173                    // substring by UTF-16 units to match JS .substring
174                    let units: Vec<u16> = st.encode_utf16().collect();
175                    let sub: Vec<u16> = units[s as usize..e as usize].to_vec();
176                    return Value::Str(String::from_utf16_lossy(&sub));
177                }
178                _ => {}
179            }
180        } else {
181            match &val {
182                Value::List(_) => return Value::empty_list(),
183                Value::Str(_) => return Value::Str(String::new()),
184                _ => {}
185            }
186        }
187    }
188
189    val
190}
191
192// ---- pad --------------------------------------------------------------
193
194pub fn pad(s: Value, padding: Option<i64>, padchar: Option<String>) -> String {
195    let mut s = match s {
196        Value::Str(s) => s,
197        other => stringify(&other, None, false),
198    };
199    let padding = padding.unwrap_or(44);
200    let padchar = {
201        let mut pc = padchar.unwrap_or_default();
202        pc.push(' ');
203        pc.chars().next().unwrap()
204    };
205    let cur = s.encode_utf16().count() as i64;
206    if padding > -1 {
207        if cur < padding {
208            for _ in 0..(padding - cur) {
209                s.push(padchar);
210            }
211        }
212        s
213    } else {
214        let target = -padding;
215        if cur < target {
216            let mut out = String::new();
217            for _ in 0..(target - cur) {
218                out.push(padchar);
219            }
220            out.push_str(&s);
221            out
222        } else {
223            s
224        }
225    }
226}
227
228// ---- node accessors ---------------------------------------------------
229
230/// `getelem(list, key, alt?)` — list lookup by integer key, negative counts
231/// from the end. If the element is absent and `alt` is a callable value, it
232/// is invoked (with the uniform `(inj, val, ref, store)` shape — a fresh
233/// throwaway injection, `Noval` value/store, empty ref) and its result used,
234/// mirroring the canonical `alt()` call.
235pub fn get_elem(val: &Value, key: &Value, alt: Value) -> Value {
236    let out = get_elem_or_else(val, key, || Value::Noval);
237    if !out.is_nullish() {
238        return out;
239    }
240    match &alt {
241        Value::Func(f) => {
242            let inj = crate::major::Injection::from_def(None);
243            f(&inj, &Value::Noval, "", &Value::Noval)
244        }
245        _ => alt,
246    }
247}
248
249pub fn get_elem_or_else<F: FnOnce() -> Value>(val: &Value, key: &Value, alt: F) -> Value {
250    if val.is_noval() || key.is_noval() {
251        return alt();
252    }
253    let out = if let Value::List(l) = val {
254        let keystr = js_string(key);
255        if R_INTEGER_KEY.is_match(&keystr) {
256            match keystr.parse::<i64>() {
257                Ok(mut n) => {
258                    let lb = l.borrow();
259                    if n < 0 {
260                        n += lb.len() as i64;
261                    }
262                    if n >= 0 && (n as usize) < lb.len() {
263                        lb[n as usize].clone()
264                    } else {
265                        Value::Noval
266                    }
267                }
268                Err(_) => Value::Noval,
269            }
270        } else {
271            Value::Noval
272        }
273    } else {
274        Value::Noval
275    };
276    // Group A rule: a null (or absent) slot counts as "no value" -> alt
277    // (canonical TS getelem `if (null == out) return alt`).
278    if out.is_nullish() {
279        alt()
280    } else {
281        out
282    }
283}
284
285/// `getprop(node, key, alt?)` — safe property lookup on a map or list.
286pub fn get_prop(val: &Value, key: &Value, alt: Value) -> Value {
287    if val.is_noval() || key.is_noval() {
288        return alt;
289    }
290    let out = match val {
291        Value::List(l) => {
292            let lb = l.borrow();
293            // Array index: a non-negative integer (string or number).
294            let ks = js_string(key);
295            ks.parse::<usize>()
296                .ok()
297                .and_then(|i| lb.get(i).cloned())
298                .unwrap_or(Value::Noval)
299        }
300        Value::Map(m) => m
301            .borrow()
302            .get(&js_string(key))
303            .cloned()
304            .unwrap_or(Value::Noval),
305        Value::Sentinel(s) => {
306            if js_string(key) == s.tag {
307                Value::Bool(true)
308            } else {
309                Value::Noval
310            }
311        }
312        _ => Value::Noval,
313    };
314    // Group A rule: a stored JSON null at a key counts as "no value", same as
315    // absent (canonical TS `if (null == out) return alt`).
316    if out.is_nullish() {
317        alt
318    } else {
319        out
320    }
321}
322
323/// Internal raw lookup that PRESERVES a stored JSON null (Group B). Mirrors the
324/// canonical TS `_lookup` (StructUtility.ts:477): Group B callers — validate /
325/// transform commands / builders / inject internals — use this when they need
326/// the raw stored value at a slot regardless of whether it is null. The public
327/// `get_prop` / `get_elem` / `has_key` APIs treat null as absent (Group A) per
328/// UNDEF_SPEC.md. Returns `Noval` only when the key is genuinely absent.
329pub fn lookup(val: &Value, key: &Value) -> Value {
330    if val.is_noval() || key.is_noval() {
331        return Value::Noval;
332    }
333    match val {
334        Value::List(l) => {
335            let lb = l.borrow();
336            let keystr = js_string(key);
337            if R_INTEGER_KEY.is_match(&keystr) {
338                match keystr.parse::<i64>() {
339                    Ok(mut n) => {
340                        if n < 0 {
341                            n += lb.len() as i64;
342                        }
343                        if n >= 0 && (n as usize) < lb.len() {
344                            lb[n as usize].clone()
345                        } else {
346                            Value::Noval
347                        }
348                    }
349                    Err(_) => Value::Noval,
350                }
351            } else {
352                Value::Noval
353            }
354        }
355        Value::Map(m) => m
356            .borrow()
357            .get(&js_string(key))
358            .cloned()
359            .unwrap_or(Value::Noval),
360        Value::Sentinel(s) => {
361            if js_string(key) == s.tag {
362                Value::Bool(true)
363            } else {
364                Value::Noval
365            }
366        }
367        _ => Value::Noval,
368    }
369}
370
371/// `strkey(key)` — coerce a key to its canonical string form (`""` if invalid).
372pub fn str_key(key: Value) -> String {
373    let t = typify(&key);
374    if t & (T_STRING as i64) != 0 {
375        return key.as_str().unwrap_or("").to_string();
376    }
377    if t & (T_BOOLEAN as i64) != 0 {
378        return String::new();
379    }
380    if t & (T_NUMBER as i64) != 0 {
381        if let Value::Num(n) = key {
382            return if n.fract() == 0.0 {
383                num_to_string(n)
384            } else {
385                num_to_string(n.floor())
386            };
387        }
388    }
389    String::new()
390}
391
392/// `keysof(node)` — sorted keys of a map, or list indices as strings.
393pub fn keysof_vec(val: &Value) -> Vec<String> {
394    match val {
395        Value::Map(m) => {
396            let mut ks: Vec<String> = m.borrow().keys().cloned().collect();
397            ks.sort();
398            ks
399        }
400        Value::List(l) => (0..l.borrow().len()).map(|i| i.to_string()).collect(),
401        _ => Vec::new(),
402    }
403}
404
405pub fn keys_of(val: &Value) -> Value {
406    Value::list(keysof_vec(val).into_iter().map(Value::Str).collect())
407}
408
409pub fn has_key(val: &Value, key: &Value) -> bool {
410    !get_prop(val, key, Value::Noval).is_noval()
411}
412
413/// `items(node)` — `[key, value]` pairs (keys sorted, as strings). Group B:
414/// preserves a stored JSON null in the value slot (canonical TS uses `val[k]`
415/// direct access), so `lookup` is used rather than the null-unifying get_prop.
416pub fn items_vec(val: &Value) -> Vec<(String, Value)> {
417    keysof_vec(val)
418        .into_iter()
419        .map(|k| {
420            let v = lookup(val, &Value::str(k.clone()));
421            (k, v)
422        })
423        .collect()
424}
425
426pub fn items(val: &Value) -> Value {
427    Value::list(
428        items_vec(val)
429            .into_iter()
430            .map(|(k, v)| Value::list(vec![Value::Str(k), v]))
431            .collect(),
432    )
433}
434
435// ---- flatten / filter -------------------------------------------------
436
437pub fn flatten(list: &Value, depth: Option<i64>) -> Value {
438    match list {
439        Value::List(l) => {
440            let d = depth.unwrap_or(1);
441            Value::list(flat_slice(&l.borrow(), d))
442        }
443        other => other.clone(),
444    }
445}
446
447fn flat_slice(v: &[Value], depth: i64) -> Vec<Value> {
448    let mut out = Vec::new();
449    for item in v {
450        match item {
451            Value::List(inner) if depth > 0 => out.extend(flat_slice(&inner.borrow(), depth - 1)),
452            other => out.push(other.clone()),
453        }
454    }
455    out
456}
457
458/// `flatten([a, b, [c]])` — internal helper taking a Vec directly. Default
459/// depth 1 (matches `flatten(...)` calls with no depth arg).
460#[allow(dead_code)]
461pub fn flatten_vals(v: Vec<Value>, depth: i64) -> Vec<Value> {
462    flat_slice(&v, depth)
463}
464
465pub fn filter_vals<F: Fn(&(String, Value)) -> bool>(val: &Value, check: F) -> Vec<Value> {
466    let all = items_vec(val);
467    let mut out = Vec::new();
468    for item in &all {
469        if check(item) {
470            out.push(item.1.clone());
471        }
472    }
473    out
474}
475
476pub fn filter<F: Fn(&(String, Value)) -> bool>(val: &Value, check: F) -> Value {
477    Value::list(filter_vals(val, check))
478}
479
480// ---- escaping / replace ----------------------------------------------
481
482/// `escre(s)` — escape regex metacharacters.
483pub fn esc_re(s: &Value) -> String {
484    let rs = coerce_for_replace(s);
485    R_ESCAPE_REGEXP
486        .replace_all(&rs, |caps: &Captures<'_>| format!("\\{}", &caps[0]))
487        .into_owned()
488}
489
490// ---------------------------------------------------------------------------
491// Regex utility — uniform re_* API (see /REGEX_API.md). Backed by the
492// in-tree pure-Rust Thompson NFA engine (crate::re), no third-party crate.
493// ---------------------------------------------------------------------------
494
495/// Compile a pattern. Mirrors `re_compile(pattern)`.
496pub fn re_compile(pattern: &str) -> Result<Regex, RegexError> {
497    Regex::new(pattern)
498}
499
500/// First match. Returns `Some([whole, capture1, ...])` or `None`.
501pub fn re_find(pattern: &str, input: &str) -> Option<Vec<String>> {
502    let re = Regex::new(pattern).ok()?;
503    let m = re.captures(input)?;
504    Some(
505        m.iter()
506            .map(|c| c.map(|x| x.as_str().to_string()).unwrap_or_default())
507            .collect(),
508    )
509}
510
511/// All non-overlapping matches.
512pub fn re_find_all(pattern: &str, input: &str) -> Vec<Vec<String>> {
513    let re = match Regex::new(pattern) {
514        Ok(r) => r,
515        Err(_) => return Vec::new(),
516    };
517    re.captures_iter(input)
518        .map(|caps| {
519            caps.iter()
520                .map(|c| c.map(|x| x.as_str().to_string()).unwrap_or_default())
521                .collect()
522        })
523        .collect()
524}
525
526/// Replace every match. Supports `$&` (whole match) and `$1`..`$9` (captures).
527pub fn re_replace(pattern: &str, input: &str, replacement: &str) -> String {
528    let re = match Regex::new(pattern) {
529        Ok(r) => r,
530        Err(_) => return input.to_string(),
531    };
532    re.replace_all(input, replacement).into_owned()
533}
534
535/// Boolean test.
536pub fn re_test(pattern: &str, input: &str) -> bool {
537    Regex::new(pattern)
538        .map(|re| re.is_match(input))
539        .unwrap_or(false)
540}
541
542/// Alias of `esc_re`.
543pub fn re_escape(s: &str) -> String {
544    esc_re(&Value::from(s))
545}
546
547/// `escurl(s)` — `encodeURIComponent`.
548pub fn esc_url(s: &Value) -> String {
549    let s = match s {
550        Value::Str(s) => s.clone(),
551        Value::Noval | Value::Null => String::new(),
552        other => js_string(other),
553    };
554    let mut out = String::new();
555    for b in s.bytes() {
556        let c = b as char;
557        if c.is_ascii_alphanumeric() || "-_.!~*'()".contains(c) {
558            out.push(c);
559        } else {
560            out.push_str(&format!("%{:02X}", b));
561        }
562    }
563    out
564}
565
566/// The `replace`-helper's string coercion: undefined -> "", everything else
567/// via `stringify` (which returns a string unchanged).
568fn coerce_for_replace(s: &Value) -> String {
569    match s {
570        Value::Str(s) => s.clone(),
571        Value::Noval => String::new(),
572        other => stringify(other, None, false),
573    }
574}
575
576/// `replace(s, regex, literal-with-$1-$2)` — JS-style replace-all (the `g`
577/// behaviour). `$1`, `$2` etc. refer to capture groups; use `$$` for a
578/// literal `$`. (`$&` is not used by any call site; would be `${0}` here.)
579#[allow(dead_code)]
580pub fn replace_str(s: &Value, from: &Regex, to: &str) -> String {
581    let rs = coerce_for_replace(s);
582    from.replace_all(&rs, to).to_string()
583}
584
585// ---- join -------------------------------------------------------------
586
587pub fn join(arr: &Value, sep: Option<&str>, url: bool) -> String {
588    let sepdef = sep.unwrap_or(S_CM).to_string();
589    let sarr = size(arr);
590    let sepre: Option<String> = if sepdef.encode_utf16().count() == 1 {
591        Some(esc_re(&Value::str(sepdef.clone())))
592    } else {
593        None
594    };
595
596    // filter to string non-empty entries
597    let str_entries: Vec<Value> =
598        filter_vals(arr, |n| matches!(&n.1, Value::Str(s) if !s.is_empty()));
599
600    let processed: Vec<String> = items_vec(&Value::list(str_entries))
601        .into_iter()
602        .map(|(idx_str, v)| {
603            let i = idx_str.parse::<i64>().unwrap_or(0);
604            let mut s = v.as_str().unwrap_or("").to_string();
605            if let Some(sre) = &sepre {
606                if !sre.is_empty() {
607                    if url && i == 0 {
608                        s = re_replace_first(&format!("{sre}+$"), &s, "");
609                        return s;
610                    }
611                    if i > 0 {
612                        s = re_replace_first(&format!("^{sre}+"), &s, "");
613                    }
614                    if i < sarr - 1 || !url {
615                        s = re_replace_first(&format!("{sre}+$"), &s, "");
616                    }
617                    let pat = format!("([^{sre}]){sre}+([^{sre}])");
618                    let repl = format!("${{1}}{sepdef}${{2}}");
619                    s = re_replace_first(&pat, &s, &repl);
620                }
621            }
622            s
623        })
624        .collect();
625
626    processed
627        .into_iter()
628        .filter(|s| !s.is_empty())
629        .collect::<Vec<_>>()
630        .join(&sepdef)
631}
632
633/// `join` over an already-built `Vec<Value>`.
634pub fn join_vals(arr: &[Value], sep: Option<&str>, url: bool) -> String {
635    join(&Value::list(arr.to_vec()), sep, url)
636}
637
638fn re_replace_first(pat: &str, s: &str, repl: &str) -> String {
639    match Regex::new(pat) {
640        Ok(re) => re.replace(s, repl).to_string(),
641        Err(_) => s.to_string(),
642    }
643}
644
645// ---- jsonify / stringify ---------------------------------------------
646
647pub struct JsonFlags {
648    pub indent: usize,
649    pub offset: usize,
650}
651
652impl Default for JsonFlags {
653    fn default() -> Self {
654        JsonFlags {
655            indent: 2,
656            offset: 0,
657        }
658    }
659}
660
661/// `jsonify(val, flags?)` — strict JSON serialisation, default 2-space indent.
662pub fn jsonify(val: &Value, flags: Option<&JsonFlags>) -> String {
663    let def = JsonFlags::default();
664    let flags = flags.unwrap_or(&def);
665    if val.is_nullish() {
666        return S_null.to_string();
667    }
668    let mut s = match json_encode(val, flags.indent, 0) {
669        Some(s) => s,
670        None => return S_null.to_string(),
671    };
672    if flags.offset > 0 {
673        // Left-offset every line but the first by `offset` spaces.
674        let lines: Vec<&str> = s.split('\n').collect();
675        let mut out = String::from("{\n");
676        let mut parts: Vec<String> = Vec::new();
677        for line in lines.iter().skip(1) {
678            parts.push(pad(
679                Value::str(*line),
680                Some(0 - flags.offset as i64 - line.encode_utf16().count() as i64),
681                None,
682            ));
683        }
684        out.push_str(&parts.join("\n"));
685        s = out;
686    }
687    s
688}
689
690fn json_encode(val: &Value, indent: usize, level: usize) -> Option<String> {
691    match val {
692        Value::Noval => None,
693        Value::Func(_) => None,
694        Value::Null => Some("null".to_string()),
695        Value::Bool(b) => Some(b.to_string()),
696        Value::Num(n) => {
697            if n.is_finite() {
698                Some(num_to_string(*n))
699            } else {
700                Some("null".to_string())
701            }
702        }
703        Value::Str(s) => Some(json_quote(s)),
704        Value::Sentinel(_) => {
705            // a `{ '`$SKIP`': true }` map
706            let mut m = OrderedMap::new();
707            let tag = match val {
708                Value::Sentinel(s) => s.tag.to_string(),
709                _ => unreachable!(),
710            };
711            m.insert(tag, Value::Bool(true));
712            json_encode(&Value::map(m), indent, level)
713        }
714        Value::List(l) => {
715            let lb = l.borrow();
716            if lb.is_empty() {
717                return Some("[]".to_string());
718            }
719            let inner_pad = " ".repeat(indent * (level + 1));
720            let outer_pad = " ".repeat(indent * level);
721            let nl = if indent > 0 { "\n" } else { "" };
722            let sep = if indent > 0 { ",\n" } else { "," };
723            let items: Vec<String> = lb
724                .iter()
725                .map(|v| {
726                    let enc =
727                        json_encode(v, indent, level + 1).unwrap_or_else(|| "null".to_string());
728                    if indent > 0 {
729                        format!("{inner_pad}{enc}")
730                    } else {
731                        enc
732                    }
733                })
734                .collect();
735            Some(format!("[{nl}{}{nl}{outer_pad}]", items.join(sep)))
736        }
737        Value::Map(m) => {
738            let mb = m.borrow();
739            // drop undefined / function-valued keys
740            let entries: Vec<(&String, &Value)> = mb
741                .iter()
742                .filter(|(_, v)| !matches!(v, Value::Noval | Value::Func(_)))
743                .collect();
744            if entries.is_empty() {
745                return Some("{}".to_string());
746            }
747            let inner_pad = " ".repeat(indent * (level + 1));
748            let outer_pad = " ".repeat(indent * level);
749            let nl = if indent > 0 { "\n" } else { "" };
750            let colon = if indent > 0 { ": " } else { ":" };
751            let sep = if indent > 0 { ",\n" } else { "," };
752            let items: Vec<String> = entries
753                .iter()
754                .map(|(k, v)| {
755                    let enc =
756                        json_encode(v, indent, level + 1).unwrap_or_else(|| "null".to_string());
757                    if indent > 0 {
758                        format!("{inner_pad}{}{colon}{enc}", json_quote(k))
759                    } else {
760                        format!("{}{colon}{enc}", json_quote(k))
761                    }
762                })
763                .collect();
764            Some(format!("{{{nl}{}{nl}{outer_pad}}}", items.join(sep)))
765        }
766    }
767}
768
769fn json_quote(s: &str) -> String {
770    let mut out = String::from("\"");
771    for c in s.chars() {
772        match c {
773            '"' => out.push_str("\\\""),
774            '\\' => out.push_str("\\\\"),
775            '\n' => out.push_str("\\n"),
776            '\r' => out.push_str("\\r"),
777            '\t' => out.push_str("\\t"),
778            '\u{0008}' => out.push_str("\\b"),
779            '\u{000C}' => out.push_str("\\f"),
780            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
781            c => out.push(c),
782        }
783    }
784    out.push('"');
785    out
786}
787
788/// `stringify(val, maxlen?, pretty?)` — compact, human-friendly string form.
789pub fn stringify(val: &Value, maxlen: Option<i64>, pretty: bool) -> String {
790    let mut valstr;
791    if val.is_noval() {
792        return if pretty {
793            "<>".to_string()
794        } else {
795            String::new()
796        };
797    }
798    if let Value::Str(s) = val {
799        valstr = s.clone();
800    } else {
801        match human_json(val) {
802            Some(s) => {
803                // remove all double-quotes (a deliberate quirk)
804                valstr = s.replace('"', "");
805            }
806            None => return "__STRINGIFY_FAILED__".to_string(),
807        }
808    }
809
810    if let Some(m) = maxlen {
811        if m >= 0 {
812            let m = m as usize;
813            let chars: Vec<char> = valstr.chars().collect();
814            if chars.len() > m {
815                let keep = m.saturating_sub(3);
816                let head: String = chars.iter().take(keep).collect();
817                valstr = format!("{head}...");
818            }
819        }
820    }
821
822    if pretty {
823        return ansi_colour(&valstr);
824    }
825
826    valstr
827}
828
829/// Compact JSON with object keys sorted (used by `stringify`). Functions and
830/// `undefined` map values are dropped. Cycles are not detected -> caller maps
831/// the failure to `__STRINGIFY_FAILED__` only if recursion overflows (which
832/// would actually panic); we approximate with a depth guard.
833fn human_json(val: &Value) -> Option<String> {
834    human_json_depth(val, 0)
835}
836
837fn human_json_depth(val: &Value, depth: usize) -> Option<String> {
838    if depth > 1_000 {
839        return None;
840    }
841    match val {
842        Value::Noval => None,
843        Value::Func(_) => None,
844        Value::Null => Some("null".to_string()),
845        Value::Bool(b) => Some(b.to_string()),
846        Value::Num(n) => Some(if n.is_finite() {
847            num_to_string(*n)
848        } else {
849            "null".to_string()
850        }),
851        Value::Str(s) => Some(json_quote(s)),
852        Value::Sentinel(s) => Some(format!("{{\"{}\":true}}", s.tag)),
853        Value::List(l) => {
854            let lb = l.borrow();
855            let parts: Vec<String> = lb
856                .iter()
857                .map(|v| human_json_depth(v, depth + 1).unwrap_or_else(|| "null".to_string()))
858                .collect();
859            Some(format!("[{}]", parts.join(",")))
860        }
861        Value::Map(m) => {
862            let mb = m.borrow();
863            let mut keys: Vec<&String> = mb
864                .iter()
865                .filter(|(_, v)| !matches!(v, Value::Noval | Value::Func(_)))
866                .map(|(k, _)| k)
867                .collect();
868            keys.sort();
869            let parts: Vec<String> = keys
870                .iter()
871                .map(|k| {
872                    let v = mb.get(k).unwrap();
873                    format!(
874                        "{}:{}",
875                        json_quote(k),
876                        human_json_depth(v, depth + 1).unwrap_or_else(|| "null".to_string())
877                    )
878                })
879                .collect();
880            Some(format!("{{{}}}", parts.join(",")))
881        }
882    }
883}
884
885fn ansi_colour(valstr: &str) -> String {
886    let colours = [
887        81, 118, 213, 39, 208, 201, 45, 190, 129, 51, 160, 121, 226, 33, 207, 69,
888    ];
889    let c: Vec<String> = colours.iter().map(|n| format!("\x1b[38;5;{n}m")).collect();
890    let r = "\x1b[0m";
891    let mut d: i64 = 0;
892    let mut o = c[0].clone();
893    let mut t = o.clone();
894    for ch in valstr.chars() {
895        if ch == '{' || ch == '[' {
896            d += 1;
897            o = c[(d as usize) % c.len()].clone();
898            t.push_str(&o);
899            t.push(ch);
900        } else if ch == '}' || ch == ']' {
901            t.push_str(&o);
902            t.push(ch);
903            d -= 1;
904            o = c[(d.rem_euclid(c.len() as i64)) as usize].clone();
905        } else {
906            t.push_str(&o);
907            t.push(ch);
908        }
909    }
910    t.push_str(r);
911    t
912}
913
914// ---- pathify ----------------------------------------------------------
915
916pub fn pathify(val: &Value, startin: Option<i64>, endin: Option<i64>) -> String {
917    let path: Option<Vec<Value>> = match val {
918        Value::List(l) => Some(l.borrow().clone()),
919        Value::Str(_) | Value::Num(_) => Some(vec![val.clone()]),
920        _ => None,
921    };
922    let start = match startin {
923        None => 0,
924        Some(s) if -1 < s => s,
925        _ => 0,
926    };
927    let end = match endin {
928        None => 0,
929        Some(e) if -1 < e => e,
930        _ => 0,
931    };
932
933    let mut pathstr: Option<String> = None;
934    if let Some(path) = &path {
935        if start >= 0 {
936            let plen = path.len() as i64;
937            let sliced = slice(
938                Value::list(path.clone()),
939                Some(start),
940                Some(plen - end),
941                false,
942            );
943            let sv: Vec<Value> = sliced
944                .as_list()
945                .map(|l| l.borrow().clone())
946                .unwrap_or_default();
947            if sv.is_empty() {
948                pathstr = Some("<root>".to_string());
949            } else {
950                let filtered = filter_vals(&Value::list(sv), |n| is_key(&n.1));
951                let mapped: Vec<Value> = filtered
952                    .iter()
953                    .map(|p| match p {
954                        Value::Num(n) => Value::str(num_to_string(n.floor())),
955                        _ => Value::str(p.as_str().unwrap_or("").replace('.', "")),
956                    })
957                    .collect();
958                pathstr = Some(join_vals(&mapped, Some("."), false));
959            }
960        }
961    }
962
963    pathstr.unwrap_or_else(|| {
964        let tail = if val.is_noval() {
965            String::new()
966        } else {
967            format!("{}{}", S_CN, stringify(val, Some(47), false))
968        };
969        format!("<unknown-path{tail}>")
970    })
971}
972
973// ---- clone ------------------------------------------------------------
974
975/// `clone(val)` — deep copy. Functions and sentinels are copied (not cloned).
976pub fn clone(val: &Value) -> Value {
977    match val {
978        Value::Noval => Value::Noval,
979        Value::Null => Value::Null,
980        Value::Bool(b) => Value::Bool(*b),
981        Value::Num(n) => Value::Num(*n),
982        Value::Str(s) => Value::Str(s.clone()),
983        Value::List(l) => Value::list(l.borrow().iter().map(clone).collect()),
984        Value::Map(m) => {
985            let mut nm = OrderedMap::new();
986            for (k, v) in m.borrow().iter() {
987                nm.insert(k.clone(), clone(v));
988            }
989            Value::map(nm)
990        }
991        Value::Func(f) => Value::Func(f.clone()),
992        Value::Sentinel(s) => Value::Sentinel(s),
993    }
994}
995
996// ---- delprop / setprop -----------------------------------------------
997
998pub fn del_prop(parent: Value, key: &Value) -> Value {
999    if !is_key(key) {
1000        return parent;
1001    }
1002    match &parent {
1003        Value::Map(m) => {
1004            let k = str_key(key.clone());
1005            m.borrow_mut().shift_remove(&k);
1006        }
1007        Value::List(l) => {
1008            let ki = js_to_number(key);
1009            if ki.is_nan() {
1010                return parent;
1011            }
1012            let ki = ki.floor() as i64;
1013            let mut lb = l.borrow_mut();
1014            if ki >= 0 && (ki as usize) < lb.len() {
1015                lb.remove(ki as usize);
1016            }
1017        }
1018        _ => {}
1019    }
1020    parent
1021}
1022
1023pub fn set_prop(parent: Value, key: &Value, val: Value) -> Value {
1024    if !is_key(key) {
1025        return parent;
1026    }
1027    match &parent {
1028        Value::Map(m) => {
1029            let k = js_string(key);
1030            m.borrow_mut().insert(k, val);
1031        }
1032        Value::List(l) => {
1033            let ki = js_to_number(key);
1034            if ki.is_nan() {
1035                return parent;
1036            }
1037            let ki = ki.floor() as i64;
1038            let mut lb = l.borrow_mut();
1039            if ki >= 0 {
1040                let len = lb.len() as i64;
1041                let idx = ki.min(len).max(0) as usize;
1042                if idx < lb.len() {
1043                    lb[idx] = val;
1044                } else {
1045                    lb.push(val);
1046                }
1047            } else {
1048                lb.insert(0, val);
1049            }
1050        }
1051        _ => {}
1052    }
1053    parent
1054}
1055
1056// ---- builders ---------------------------------------------------------
1057
1058pub fn jm(kv: &[Value]) -> Value {
1059    let mut o = OrderedMap::new();
1060    let n = kv.len();
1061    let mut i = 0;
1062    while i < n {
1063        let k = match kv.get(i) {
1064            Some(Value::Str(s)) => s.clone(),
1065            Some(other) => stringify(other, None, false),
1066            None => format!("$KEY{i}"),
1067        };
1068        let v = kv.get(i + 1).cloned().unwrap_or(Value::Null);
1069        o.insert(k, v);
1070        i += 2;
1071    }
1072    Value::map(o)
1073}
1074
1075pub fn jt(v: &[Value]) -> Value {
1076    Value::list(
1077        v.iter()
1078            .map(|x| if x.is_noval() { Value::Null } else { x.clone() })
1079            .collect(),
1080    )
1081}