Skip to main content

fusevm/
value.rs

1//! Language-agnostic value system for fusevm.
2//!
3//! Every value in the VM is a `Value`. Frontends (stryke, zshrs, etc.)
4//! convert their native types to/from `Value` at the boundary.
5
6use serde::{Deserialize, Serialize};
7use std::borrow::Cow;
8use std::collections::HashMap;
9use std::hash::{Hash, Hasher};
10use std::sync::Arc;
11
12/// Core value type — what lives on the stack and in variables.
13///
14/// Designed to be small (1 word tag + 1-2 words payload) so the
15/// dispatch loop stays cache-friendly.
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
17#[cfg_attr(feature = "rkyv-archive", derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize))]
18#[cfg_attr(feature = "rkyv-archive", archive(check_bytes))]
19#[cfg_attr(feature = "rkyv-archive", archive(bound(
20    serialize = "__S: rkyv::ser::Serializer + rkyv::ser::ScratchSpace + rkyv::ser::SharedSerializeRegistry",
21    deserialize = "__D: rkyv::de::SharedDeserializeRegistry",
22)))]
23#[cfg_attr(feature = "rkyv-archive", archive_attr(check_bytes(
24    bound = "__C: rkyv::validation::ArchiveContext + rkyv::validation::SharedContext, <__C as rkyv::Fallible>::Error: std::error::Error"
25)))]
26pub enum Value {
27    /// No value / uninitialized
28    #[default]
29    Undef,
30    /// Boolean (from conditionals, `[[ ]]`, etc.)
31    Bool(bool),
32    /// 64-bit signed integer
33    Int(i64),
34    /// 64-bit float
35    Float(f64),
36    /// Heap-allocated string (Arc for cheap clone in closures)
37    Str(#[cfg_attr(feature = "rkyv-archive", omit_bounds, archive_attr(omit_bounds))] Arc<String>),
38    /// Ordered array of values.
39    ///
40    /// The payload is `Arc<Vec<Value>>` so cloning a `Value` — which the VM
41    /// does on every stack push, slot read, and global read — is a refcount
42    /// bump instead of a deep copy of the whole sequence. With a bare `Vec`
43    /// any per-element loop that re-loads the container (`seq[i]` lowered as
44    /// "load the collection, then index it") was O(n) per iteration and so
45    /// O(n²) overall.
46    ///
47    /// Semantics are unchanged: arrays are still **values**, not references.
48    /// Every mutation site goes through [`Value::array_mut`] /
49    /// `Arc::make_mut`, which copies when the buffer is shared, so a clone
50    /// taken before a mutation never observes that mutation. Copy-on-write is
51    /// observationally identical to the previous eager deep clone; it just
52    /// defers the copy to the writes, which are rare, from the reads, which
53    /// are the hot path.
54    ///
55    /// Construct with [`Value::array`] rather than the variant directly.
56    Array(#[cfg_attr(feature = "rkyv-archive", omit_bounds, archive_attr(omit_bounds))] Arc<Vec<Value>>),
57    /// Key-value associative array
58    Hash(#[cfg_attr(feature = "rkyv-archive", omit_bounds, archive_attr(omit_bounds))] HashMap<String, Value>),
59    /// Exit status code (shell-specific but universal enough)
60    Status(i32),
61    /// Reference to another value (for pass-by-reference, nested structures)
62    Ref(#[cfg_attr(feature = "rkyv-archive", omit_bounds, archive_attr(omit_bounds))] Box<Value>),
63    /// Native function pointer (builtin dispatch)
64    NativeFn(u16),
65    /// Opaque handle into a frontend object heap (e.g. elisp cons/symbol/vector
66    /// cells). The frontend's extension handler/host owns the pointed-to object;
67    /// fusevm just carries the handle through the stack, slots, and globals.
68    /// Identity-comparable (the handle is the identity), which is what elisp
69    /// `eq`/`setcar` need and `Array` could not provide.
70    Obj(u32),
71}
72
73impl Value {
74    // ── Constructors ──
75
76    /// Construct an integer `Value::Int(n)`.
77    pub fn int(n: i64) -> Self {
78        Value::Int(n)
79    }
80
81    /// Construct a float `Value::Float(f)`.
82    pub fn float(f: f64) -> Self {
83        Value::Float(f)
84    }
85
86    /// Construct a string `Value::Str` from any type that converts to `String`.
87    /// The payload is wrapped in `Arc` so clones are cheap.
88    pub fn str(s: impl Into<String>) -> Self {
89        Value::Str(Arc::new(s.into()))
90    }
91
92    /// Construct a boolean `Value::Bool(b)`.
93    pub fn bool(b: bool) -> Self {
94        Value::Bool(b)
95    }
96
97    /// Construct an array `Value::Array(v)`. The payload is wrapped in `Arc`
98    /// so subsequent clones of the `Value` are refcount bumps.
99    pub fn array(v: Vec<Value>) -> Self {
100        Value::Array(Arc::new(v))
101    }
102
103    /// Borrow the elements of a `Value::Array`, or `None` for any other
104    /// variant. Read-only; never copies.
105    pub fn as_array(&self) -> Option<&Vec<Value>> {
106        match self {
107            Value::Array(a) => Some(a),
108            _ => None,
109        }
110    }
111
112    /// Mutable access to the elements of a `Value::Array`, copying the buffer
113    /// first if it is shared with another `Value` (copy-on-write). Returns
114    /// `None` for any other variant.
115    ///
116    /// This is the only sanctioned way to mutate an array in place. Going
117    /// around it — e.g. `Arc::get_mut` — would let a mutation become visible
118    /// through a clone taken earlier and break the value semantics every
119    /// frontend relies on.
120    pub fn array_mut(&mut self) -> Option<&mut Vec<Value>> {
121        match self {
122            Value::Array(a) => Some(Arc::make_mut(a)),
123            _ => None,
124        }
125    }
126
127    /// Take the elements of a `Value::Array` by value, avoiding the copy when
128    /// this is the only owner of the buffer. `None` for any other variant.
129    pub fn into_array(self) -> Option<Vec<Value>> {
130        match self {
131            Value::Array(a) => Some(Arc::try_unwrap(a).unwrap_or_else(|a| (*a).clone())),
132            _ => None,
133        }
134    }
135
136    /// Construct a hash `Value::Hash(m)`.
137    pub fn hash(m: HashMap<String, Value>) -> Self {
138        Value::Hash(m)
139    }
140
141    /// Construct a `Value::Status(code)` — used for `$?` / pipeline-exit
142    /// values so a numeric exit code stays distinguishable from plain
143    /// `Value::Int(n)` in `is_truthy` / display logic.
144    pub fn status(code: i32) -> Self {
145        Value::Status(code)
146    }
147
148    // ── Coercions ──
149
150    /// Truthiness: 0, 0.0, "", undef, empty array/hash are false.
151    pub fn is_truthy(&self) -> bool {
152        match self {
153            Value::Undef => false,
154            Value::Bool(b) => *b,
155            Value::Int(n) => *n != 0,
156            Value::Float(f) => *f != 0.0,
157            Value::Str(s) => !s.is_empty() && s.as_str() != "0",
158            Value::Array(a) => !a.is_empty(),
159            Value::Hash(h) => !h.is_empty(),
160            Value::Status(c) => *c == 0, // shell: 0 = success = true
161            Value::Ref(_) => true,
162            Value::NativeFn(_) => true,
163            // A heap-object handle is always non-nil from fusevm's view; elisp
164            // nil maps to `Undef`, and elisp truthiness is decided frontend-side.
165            Value::Obj(_) => true,
166        }
167    }
168
169    /// Coerce to i64.
170    pub fn to_int(&self) -> i64 {
171        match self {
172            Value::Int(n) => *n,
173            Value::Float(f) => *f as i64,
174            Value::Bool(b) => *b as i64,
175            Value::Str(s) => s.parse().unwrap_or(0),
176            Value::Status(c) => *c as i64,
177            Value::Array(a) => a.len() as i64,
178            _ => 0,
179        }
180    }
181
182    /// Coerce to f64.
183    pub fn to_float(&self) -> f64 {
184        match self {
185            Value::Float(f) => *f,
186            Value::Int(n) => *n as f64,
187            Value::Bool(b) if *b => 1.0,
188            Value::Str(s) => s.parse().unwrap_or(0.0),
189            Value::Status(c) => *c as f64,
190            _ => 0.0,
191        }
192    }
193
194    /// Coerce to string.
195    pub fn to_str(&self) -> String {
196        self.as_str_cow().into_owned()
197    }
198
199    /// Coerce to string, borrowing when possible to avoid allocation.
200    /// Returns `Cow::Borrowed` for `Str`, `Undef`, `Bool`, `Hash`, `Ref` variants.
201    pub fn as_str_cow(&self) -> Cow<'_, str> {
202        match self {
203            Value::Str(s) => Cow::Borrowed(s.as_str()),
204            Value::Int(n) => Cow::Owned(n.to_string()),
205            Value::Float(f) => Cow::Owned(f.to_string()),
206            Value::Bool(b) => Cow::Borrowed(if *b { "1" } else { "" }),
207            Value::Undef => Cow::Borrowed(""),
208            Value::Status(c) => Cow::Owned(c.to_string()),
209            Value::Array(a) => {
210                Cow::Owned(a.iter().map(|v| v.to_str()).collect::<Vec<_>>().join(" "))
211            }
212            Value::Hash(_) => Cow::Borrowed("(hash)"),
213            Value::Ref(_) => Cow::Borrowed("(ref)"),
214            Value::NativeFn(id) => Cow::Owned(format!("(builtin:{})", id)),
215            Value::Obj(id) => Cow::Owned(format!("(obj:{})", id)),
216        }
217    }
218
219    /// String length or array length or hash size.
220    pub fn len(&self) -> usize {
221        match self {
222            Value::Str(s) => s.len(),
223            Value::Array(a) => a.len(),
224            Value::Hash(h) => h.len(),
225            _ => self.to_str().len(),
226        }
227    }
228
229    /// `len() == 0` shorthand — clippy requires this when `len` is `pub`.
230    pub fn is_empty(&self) -> bool {
231        self.len() == 0
232    }
233}
234
235impl Hash for Value {
236    fn hash<H: Hasher>(&self, state: &mut H) {
237        std::mem::discriminant(self).hash(state);
238        match self {
239            Value::Undef => {}
240            Value::Bool(b) => b.hash(state),
241            Value::Int(n) => n.hash(state),
242            Value::Float(f) => f.to_bits().hash(state),
243            Value::Str(s) => s.hash(state),
244            Value::Array(a) => a.hash(state),
245            Value::Hash(h) => {
246                h.len().hash(state);
247                for (k, v) in h {
248                    k.hash(state);
249                    v.hash(state);
250                }
251            }
252            Value::Status(c) => c.hash(state),
253            Value::Ref(b) => b.hash(state),
254            Value::NativeFn(id) => id.hash(state),
255            Value::Obj(id) => id.hash(state),
256        }
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use std::collections::HashMap;
264
265    #[test]
266    fn test_truthiness() {
267        assert!(!Value::Undef.is_truthy());
268        assert!(!Value::Int(0).is_truthy());
269        assert!(Value::Int(1).is_truthy());
270        assert!(!Value::str("").is_truthy());
271        assert!(!Value::str("0").is_truthy());
272        assert!(Value::str("hello").is_truthy());
273        assert!(Value::Status(0).is_truthy()); // shell: 0 = success
274        assert!(!Value::Status(1).is_truthy());
275    }
276
277    #[test]
278    fn test_coercions() {
279        assert_eq!(Value::str("42").to_int(), 42);
280        assert_eq!(Value::Int(42).to_str(), "42");
281        assert_eq!(Value::Float(3.25).to_int(), 3);
282        assert_eq!(Value::Bool(true).to_int(), 1);
283    }
284
285    #[test]
286    fn truthiness_for_floats() {
287        assert!(!Value::Float(0.0).is_truthy());
288        assert!(!Value::Float(-0.0).is_truthy());
289        assert!(Value::Float(0.1).is_truthy());
290        assert!(Value::Float(f64::NAN).is_truthy()); // NaN != 0.0 so truthy
291        assert!(Value::Float(f64::INFINITY).is_truthy());
292    }
293
294    #[test]
295    fn truthiness_for_collections() {
296        assert!(!Value::array(vec![]).is_truthy());
297        assert!(Value::array(vec![Value::Undef]).is_truthy()); // non-empty array
298        assert!(!Value::Hash(HashMap::new()).is_truthy());
299    }
300
301    #[test]
302    fn to_int_handles_all_variants() {
303        assert_eq!(Value::Undef.to_int(), 0);
304        assert_eq!(Value::Int(-7).to_int(), -7);
305        assert_eq!(Value::Float(3.99).to_int(), 3); // truncation
306        assert_eq!(Value::Float(-3.99).to_int(), -3); // truncation toward zero
307        assert_eq!(Value::Bool(true).to_int(), 1);
308        assert_eq!(Value::Bool(false).to_int(), 0);
309        assert_eq!(Value::Status(42).to_int(), 42);
310        assert_eq!(Value::Status(-1).to_int(), -1);
311        assert_eq!(Value::str("not a number").to_int(), 0);
312        assert_eq!(Value::array(vec![Value::Int(1), Value::Int(2)]).to_int(), 2);
313    }
314
315    #[test]
316    fn to_float_handles_all_variants() {
317        assert_eq!(Value::Int(5).to_float(), 5.0);
318        assert_eq!(Value::Float(2.5).to_float(), 2.5);
319        assert_eq!(Value::Bool(true).to_float(), 1.0);
320        assert_eq!(Value::Bool(false).to_float(), 0.0);
321        assert_eq!(Value::Status(7).to_float(), 7.0);
322        assert_eq!(Value::str("3.25").to_float(), 3.25);
323        assert_eq!(Value::str("garbage").to_float(), 0.0);
324    }
325
326    #[test]
327    fn as_str_cow_borrowed_for_str() {
328        let v = Value::str("hello");
329        match v.as_str_cow() {
330            Cow::Borrowed(s) => assert_eq!(s, "hello"),
331            Cow::Owned(_) => panic!("expected borrowed"),
332        }
333    }
334
335    #[test]
336    fn as_str_cow_owned_for_int() {
337        match Value::Int(42).as_str_cow() {
338            Cow::Owned(s) => assert_eq!(s, "42"),
339            Cow::Borrowed(_) => panic!("expected owned"),
340        }
341    }
342
343    #[test]
344    fn array_to_str_joins_with_space() {
345        let v = Value::array(vec![Value::Int(1), Value::Int(2), Value::Int(3)]);
346        assert_eq!(v.to_str(), "1 2 3");
347    }
348
349    #[test]
350    fn len_returns_correct_size_per_variant() {
351        assert_eq!(Value::str("abc").len(), 3);
352        assert_eq!(Value::array(vec![Value::Int(1); 5]).len(), 5);
353        assert_eq!(Value::Hash(HashMap::new()).len(), 0);
354        // Int falls through to to_str().len()
355        assert_eq!(Value::Int(12345).len(), 5);
356    }
357
358    #[test]
359    fn is_empty_matches_len_zero() {
360        assert!(Value::str("").is_empty());
361        assert!(!Value::str("x").is_empty());
362        assert!(Value::array(vec![]).is_empty());
363        assert!(!Value::array(vec![Value::Int(0)]).is_empty());
364    }
365
366    #[test]
367    fn equality_via_partial_eq() {
368        // PartialEq allows direct comparison — useful in tests.
369        assert_eq!(Value::Int(42), Value::Int(42));
370        assert_ne!(Value::Int(42), Value::Int(43));
371        assert_eq!(Value::str("hi"), Value::str("hi"));
372        // NaN != NaN per IEEE 754
373        assert_ne!(Value::Float(f64::NAN), Value::Float(f64::NAN));
374    }
375
376    #[test]
377    fn hash_impl_handles_floats_via_bits() {
378        // Hash impl must be consistent: f64 hashed as bit-pattern.
379        // Two NaN values with same bits hash equal even though NaN != NaN.
380        use std::collections::hash_map::DefaultHasher;
381        use std::hash::{Hash, Hasher};
382
383        let nan1 = Value::Float(f64::NAN);
384        let nan2 = Value::Float(f64::NAN);
385        let mut h1 = DefaultHasher::new();
386        nan1.hash(&mut h1);
387        let mut h2 = DefaultHasher::new();
388        nan2.hash(&mut h2);
389        assert_eq!(h1.finish(), h2.finish());
390    }
391
392    #[test]
393    fn as_str_cow_for_undef_and_bool_is_borrowed() {
394        assert!(matches!(Value::Undef.as_str_cow(), Cow::Borrowed("")));
395        assert!(matches!(Value::Bool(true).as_str_cow(), Cow::Borrowed("1")));
396        assert!(matches!(Value::Bool(false).as_str_cow(), Cow::Borrowed("")));
397    }
398
399    #[test]
400    fn as_str_cow_for_status_and_float_is_owned() {
401        match Value::Status(127).as_str_cow() {
402            Cow::Owned(s) => assert_eq!(s, "127"),
403            _ => panic!("expected owned"),
404        }
405        match Value::Float(1.5).as_str_cow() {
406            Cow::Owned(s) => assert_eq!(s, "1.5"),
407            _ => panic!("expected owned"),
408        }
409    }
410
411    #[test]
412    fn native_fn_to_str_formats_id() {
413        assert_eq!(Value::NativeFn(42).to_str(), "(builtin:42)");
414    }
415
416    #[test]
417    fn hash_to_str_is_placeholder() {
418        let mut m = HashMap::new();
419        m.insert("k".to_string(), Value::Int(1));
420        assert_eq!(Value::Hash(m).to_str(), "(hash)");
421    }
422
423    #[test]
424    fn ref_to_str_is_placeholder_and_is_truthy() {
425        let r = Value::Ref(Box::new(Value::Int(0)));
426        assert!(r.is_truthy(), "Ref is always truthy regardless of inner");
427        assert_eq!(r.to_str(), "(ref)");
428    }
429
430    #[test]
431    fn nested_array_to_str_recurses() {
432        let inner = Value::array(vec![Value::Int(1), Value::Int(2)]);
433        let outer = Value::array(vec![inner, Value::Int(3)]);
434        // Inner array stringifies to "1 2", then outer joins with space.
435        assert_eq!(outer.to_str(), "1 2 3");
436    }
437
438    #[test]
439    fn to_int_from_negative_string_parses() {
440        assert_eq!(Value::str("-123").to_int(), -123);
441    }
442
443    #[test]
444    fn to_int_unhandled_variants_return_zero() {
445        assert_eq!(Value::Hash(HashMap::new()).to_int(), 0);
446        assert_eq!(Value::Ref(Box::new(Value::Int(99))).to_int(), 0);
447        assert_eq!(Value::NativeFn(5).to_int(), 0);
448    }
449
450    #[test]
451    fn to_float_unhandled_variants_return_zero() {
452        assert_eq!(Value::Undef.to_float(), 0.0);
453        assert_eq!(Value::array(vec![Value::Int(1)]).to_float(), 0.0);
454        assert_eq!(Value::Bool(false).to_float(), 0.0);
455    }
456
457    #[test]
458    fn len_for_hash_counts_entries() {
459        let mut m = HashMap::new();
460        m.insert("a".to_string(), Value::Int(1));
461        m.insert("b".to_string(), Value::Int(2));
462        assert_eq!(Value::Hash(m).len(), 2);
463    }
464
465    #[test]
466    fn constructors_produce_expected_variants() {
467        assert!(matches!(Value::int(1), Value::Int(1)));
468        assert!(matches!(Value::float(1.0), Value::Float(_)));
469        assert!(matches!(Value::bool(true), Value::Bool(true)));
470        assert!(matches!(Value::status(7), Value::Status(7)));
471        assert!(matches!(Value::array(vec![]), Value::Array(_)));
472    }
473
474    #[test]
475    fn str_constructor_accepts_string_and_str() {
476        let from_str = Value::str("hi");
477        let from_string = Value::str(String::from("hi"));
478        assert_eq!(from_str, from_string);
479    }
480
481    #[test]
482    fn clone_of_str_shares_arc() {
483        // Arc<String> means clones are cheap and point to same allocation.
484        let a = Value::str("hello");
485        let b = a.clone();
486        if let (Value::Str(sa), Value::Str(sb)) = (&a, &b) {
487            assert!(Arc::ptr_eq(sa, sb));
488        } else {
489            panic!("expected Str variants");
490        }
491    }
492
493    #[test]
494    fn default_is_undef() {
495        let v: Value = Default::default();
496        assert_eq!(v, Value::Undef);
497    }
498
499    #[test]
500    fn hash_distinguishes_variants_with_same_payload_bytes() {
501        // Int(0) and Bool(false) and Status(0) all have payload 0, but their
502        // discriminants must make the hashes differ.
503        use std::collections::hash_map::DefaultHasher;
504        let h = |v: &Value| {
505            let mut hs = DefaultHasher::new();
506            v.hash(&mut hs);
507            hs.finish()
508        };
509        let a = h(&Value::Int(0));
510        let b = h(&Value::Bool(false));
511        let c = h(&Value::Status(0));
512        assert_ne!(a, b);
513        assert_ne!(b, c);
514        assert_ne!(a, c);
515    }
516
517    #[test]
518    fn hash_for_hashmap_value_is_deterministic_per_value() {
519        // Hashing the SAME Value::Hash twice yields identical hashes even
520        // though HashMap iteration order is unspecified — because the impl
521        // accumulates contributions and discriminant.
522        use std::collections::hash_map::DefaultHasher;
523        let mut m = HashMap::new();
524        m.insert("a".to_string(), Value::Int(1));
525        m.insert("b".to_string(), Value::Int(2));
526        let v = Value::Hash(m);
527        let mut h1 = DefaultHasher::new();
528        v.hash(&mut h1);
529        let mut h2 = DefaultHasher::new();
530        v.hash(&mut h2);
531        assert_eq!(h1.finish(), h2.finish());
532    }
533
534    #[test]
535    fn serde_roundtrip_preserves_value() {
536        // Verify Value survives serialization without information loss.
537        let cases = vec![
538            Value::Undef,
539            Value::Bool(true),
540            Value::Int(-42),
541            Value::Float(3.25),
542            Value::str("hello"),
543            Value::Status(127),
544        ];
545        for original in cases {
546            let json = serde_json::to_string(&original).unwrap();
547            let restored: Value = serde_json::from_str(&json).unwrap();
548            assert_eq!(original, restored);
549        }
550    }
551
552    // ─── Coercion table pins ─────────────────────────────────────────
553    //
554    // The to_int / to_float / is_truthy triples are the foundation
555    // every fusevm-hosted language depends on. Pin the cross-type
556    // matrix so a single-branch refactor can't silently change e.g.
557    // `if @arr` semantics from "non-empty" to "always true".
558
559    #[test]
560    fn to_int_string_with_non_numeric_falls_back_to_zero() {
561        assert_eq!(Value::str("nope").to_int(), 0);
562    }
563
564    #[test]
565    fn to_int_array_returns_length_not_first_element() {
566        // Perl-/awk-style numeric coercion of an array is its length.
567        let v = Value::array(vec![Value::int(99), Value::int(100), Value::int(101)]);
568        assert_eq!(v.to_int(), 3, "array's to_int must be length, not [0]");
569    }
570
571    #[test]
572    fn to_int_bool_true_is_one_false_is_zero() {
573        assert_eq!(Value::bool(true).to_int(), 1);
574        assert_eq!(Value::bool(false).to_int(), 0);
575    }
576
577    #[test]
578    fn to_int_status_preserves_exit_code() {
579        // `$?` users grep for specific exit codes; coercion must
580        // round-trip them, not collapse to 0/1.
581        for code in [0_i32, 1, 2, 42, 127, 128, 255] {
582            assert_eq!(Value::Status(code).to_int(), code as i64);
583        }
584    }
585
586    #[test]
587    fn to_int_float_truncates_toward_zero() {
588        assert_eq!(Value::Float(3.9).to_int(), 3);
589        assert_eq!(Value::Float(-3.9).to_int(), -3);
590        assert_eq!(Value::Float(0.5).to_int(), 0);
591    }
592
593    #[test]
594    fn to_float_string_non_numeric_falls_back_to_zero() {
595        assert_eq!(Value::str("nope").to_float(), 0.0);
596    }
597
598    #[test]
599    fn to_float_bool_only_true_is_one() {
600        // The to_float branch for Bool is only the `true` arm; pin
601        // that false drops through to the default 0.0.
602        assert_eq!(Value::bool(true).to_float(), 1.0);
603        assert_eq!(Value::bool(false).to_float(), 0.0);
604    }
605
606    #[test]
607    fn is_truthy_empty_collections_are_false() {
608        assert!(!Value::array(Vec::new()).is_truthy());
609        assert!(!Value::hash(HashMap::new()).is_truthy());
610        assert!(!Value::str("").is_truthy());
611        assert!(!Value::int(0).is_truthy());
612        assert!(!Value::Float(0.0).is_truthy());
613        assert!(!Value::Undef.is_truthy());
614    }
615
616    #[test]
617    fn is_truthy_single_element_array_is_true() {
618        assert!(Value::array(vec![Value::int(0)]).is_truthy());
619    }
620
621    // ─── as_str_cow / to_str string-rep pins ─────────────────────────
622    //
623    // The string representation is hit on every `print` / interpolation
624    // call. Drift here silently changes every script's output. Pin
625    // each non-trivial branch.
626
627    #[test]
628    fn as_str_cow_bool_renders_as_perl_compatible() {
629        // Perl convention: true → "1", false → "" (empty string).
630        // gawk and Ruby differ; if a host language frontend swaps
631        // these, downstream `length($flag)` checks break.
632        assert_eq!(Value::bool(true).to_str(), "1");
633        assert_eq!(Value::bool(false).to_str(), "");
634    }
635
636    #[test]
637    fn as_str_cow_undef_renders_as_empty_string() {
638        assert_eq!(Value::Undef.to_str(), "");
639    }
640
641    #[test]
642    fn as_str_cow_array_joins_with_single_space() {
643        // Pin the separator — Perl's default `$,` is empty but the
644        // common host convention is single-space; printf("%s", @a)
645        // semantics rely on this.
646        let v = Value::array(vec![Value::int(1), Value::int(2), Value::int(3)]);
647        assert_eq!(v.to_str(), "1 2 3");
648    }
649
650    #[test]
651    fn as_str_cow_hash_renders_as_opaque_label() {
652        // Hashes don't have a canonical ordering, so the host
653        // chose `(hash)` as a label. Pin so a future refactor
654        // doesn't accidentally start exposing internal state.
655        let mut h = HashMap::new();
656        h.insert("x".into(), Value::int(1));
657        assert_eq!(Value::hash(h).to_str(), "(hash)");
658    }
659
660    #[test]
661    fn as_str_cow_status_renders_as_exit_code_only() {
662        // $? must stringify as the bare integer, not "exit:N" or
663        // similar — shell scripts grep for the bare code.
664        assert_eq!(Value::Status(0).to_str(), "0");
665        assert_eq!(Value::Status(127).to_str(), "127");
666        assert_eq!(Value::Status(-1).to_str(), "-1");
667    }
668
669    // ─── len() / is_empty() pins ─────────────────────────────────────
670
671    #[test]
672    fn len_of_str_is_byte_count_not_char_count() {
673        // Pin `len()` as byte count for Str (Rust's String::len
674        // semantic). If a refactor switches to char count, all
675        // `length()` builtins drift on UTF-8 input.
676        let v = Value::str("é"); // 2 UTF-8 bytes
677        assert_eq!(v.len(), 2);
678    }
679
680    #[test]
681    fn len_of_array_is_element_count() {
682        let v = Value::array(vec![Value::int(1); 7]);
683        assert_eq!(v.len(), 7);
684    }
685
686    #[test]
687    fn len_of_int_falls_through_to_str_form() {
688        assert_eq!(Value::int(12345).len(), 5);
689        assert_eq!(Value::int(-99).len(), 3); // "-99"
690    }
691
692    #[test]
693    fn is_empty_matches_len_zero_full_matrix() {
694        assert!(Value::str("").is_empty());
695        assert!(Value::array(Vec::new()).is_empty());
696        assert!(Value::hash(HashMap::new()).is_empty());
697        assert!(!Value::int(0).is_empty()); // "0" has len 1
698        assert!(!Value::str("x").is_empty());
699        assert!(Value::Undef.is_empty()); // Undef stringifies to ""
700    }
701}