Skip to main content

logicaffeine_compile/semantics/
collections.rs

1//! Collection operations: indexing, length, membership, mutation, set algebra.
2
3use std::cell::RefCell;
4use std::rc::Rc;
5
6use crate::interpreter::RuntimeValue;
7
8use super::compare::values_equal;
9
10/// The interned single-character `Text` for an ASCII byte. `item k of text`
11/// on an ASCII string is the hot path of every character-scanning loop
12/// (string search, parsing); allocating a fresh one-char `Rc<String>` per
13/// access dominated those loops. The 128 ASCII single-char strings are
14/// immutable and shared per thread, so indexing collapses to a refcount
15/// bump. (Texts are never mutated in place, so sharing the `Rc` is safe.)
16fn ascii_char_text(b: u8) -> Rc<String> {
17    debug_assert!(b < 128, "caller guarantees an ASCII byte");
18    thread_local! {
19        static CACHE: RefCell<[Option<Rc<String>>; 128]> =
20            RefCell::new(std::array::from_fn(|_| None));
21    }
22    CACHE.with(|c| {
23        c.borrow_mut()[b as usize]
24            .get_or_insert_with(|| Rc::new((b as char).to_string()))
25            .clone()
26    })
27}
28
29/// Cached `(is_ascii, char_len)` for a `Text`, keyed by Rc identity AND byte
30/// length. A character-scanning loop (`item i of text` for i = 1..n) otherwise
31/// re-runs the O(n) `is_ascii()` / `chars().count()` on EVERY access — turning
32/// the scan O(n²). The only in-place Text mutation is append (`add_assign`),
33/// which grows the byte length, so a length mismatch detects staleness and
34/// recomputes; holding the Rc clone keeps the pointer from being reused while
35/// cached. (`index_set` has no Text arm — no same-length mutation exists — so
36/// the metadata cannot silently change.)
37fn text_metrics(s: &Rc<String>) -> (bool, usize) {
38    thread_local! {
39        static CACHE: RefCell<Vec<(Rc<String>, usize, bool, usize)>> =
40            const { RefCell::new(Vec::new()) };
41    }
42    CACHE.with(|c| {
43        let mut c = c.borrow_mut();
44        let len = s.len();
45        if let Some(e) = c.iter().find(|(rc, l, _, _)| *l == len && Rc::ptr_eq(rc, s)) {
46            return (e.2, e.3);
47        }
48        let ascii = s.is_ascii();
49        let char_len = if ascii { len } else { s.chars().count() };
50        if c.len() >= 8 {
51            c.remove(0);
52        }
53        c.push((s.clone(), len, ascii, char_len));
54        (ascii, char_len)
55    })
56}
57
58/// Whether a `Text` is pure ASCII, answered in O(1) per call via the same
59/// `(is_ascii, char_len)` cache the indexing hot path uses (the first call for
60/// a given `Rc`/length pays the vectorized `is_ascii()` scan, every later one is
61/// a cache hit). The VM's tier-up seam asks this on every hot back-edge crossing
62/// and again at every region entry: a Text-as-bytes pin is sound ONLY for ASCII
63/// (char index == byte index, char count == byte length), so a non-ASCII Text
64/// must never pin.
65pub fn text_is_ascii(s: &Rc<String>) -> bool {
66    text_metrics(s).0
67}
68
69/// Resolve a 1-based LOGOS index (negative = end-relative: `-1` is the last
70/// element) to a 0-based offset — the Result-shaped twin of the data crate's
71/// `resolve_logos_index` (same rule, the interp's catchable errors).
72fn resolve_index(i: i64, len: usize) -> Result<usize, String> {
73    if i >= 1 {
74        let idx = (i - 1) as usize;
75        if idx >= len {
76            return Err(format!("Index {} out of bounds", i));
77        }
78        Ok(idx)
79    } else if i <= -1 {
80        let back = i.unsigned_abs() as usize;
81        if back > len {
82            return Err(format!("Index {} out of bounds", i));
83        }
84        Ok(len - back)
85    } else {
86        Err("Index 0 out of bounds".to_string())
87    }
88}
89
90/// 1-based index for List/Tuple/Text; key lookup for Map; Text-keyed field
91/// read for Struct.
92pub fn index_get(coll: &RuntimeValue, idx: &RuntimeValue) -> Result<RuntimeValue, String> {
93    match (coll, idx) {
94        (RuntimeValue::List(items), RuntimeValue::Int(i)) => {
95            let items = items.borrow();
96            let idx = resolve_index(*i, items.len())?;
97            Ok(items.get(idx).expect("bounds checked above"))
98        }
99        (RuntimeValue::Tuple(items), RuntimeValue::Int(i)) => {
100            let idx = resolve_index(*i, items.len())?;
101            Ok(items[idx].clone())
102        }
103        (RuntimeValue::Text(s), RuntimeValue::Int(i)) => {
104            let i = *i;
105            // ASCII fast path: byte position == char position and the
106            // in-bounds check over bytes equals the check over chars. The
107            // `is_ascii` scan is vectorized — far cheaper than the per-char
108            // decode of `chars().nth` that the general path needs.
109            let bytes = s.as_bytes();
110            // Cached metrics: ASCII-ness and char length are O(1) per access
111            // (recomputed only when the string's byte length changes), so a
112            // scan loop stays O(n) instead of O(n²).
113            let (ascii, char_len) = text_metrics(s);
114            if i >= 1 && (i as usize) <= bytes.len() && ascii {
115                return Ok(RuntimeValue::Text(ascii_char_text(bytes[i as usize - 1])));
116            }
117            let idx = resolve_index(i, char_len)?;
118            Ok(RuntimeValue::Text(Rc::new(
119                s.chars().nth(idx).unwrap().to_string(),
120            )))
121        }
122        (RuntimeValue::Map(map), key) => {
123            let map = map.borrow();
124            match map.get(key) {
125                Some(val) => Ok(val.clone()),
126                None => Err(format!("Key '{}' not found in map", key.to_display_string())),
127            }
128        }
129        // Struct field read via index syntax (`item "field" of struct`).
130        (RuntimeValue::Struct(s), RuntimeValue::Text(field)) => {
131            match s.fields.get(field.as_str()) {
132                Some(val) => Ok(val.clone()),
133                None => Err(format!("Struct has no field '{}'", field)),
134            }
135        }
136        _ => Err(format!(
137            "Cannot index {} with {}",
138            coll.type_name(),
139            idx.type_name()
140        )),
141    }
142}
143
144/// `Set item idx of collection to value` — 1-based list set, or map insert.
145/// (Struct field set needs an environment reassign and stays engine-side.)
146pub fn index_set(coll: &RuntimeValue, idx: &RuntimeValue, value: RuntimeValue) -> Result<(), String> {
147    match (coll, idx) {
148        (RuntimeValue::List(items), RuntimeValue::Int(n)) => {
149            let mut items = items.borrow_mut();
150            let idx = resolve_index(*n, items.len())?;
151            items.set(idx, value);
152            Ok(())
153        }
154        (RuntimeValue::Map(map), key) => {
155            assert_hashable_key(key)?;
156            map.borrow_mut().insert(key.clone(), value);
157            Ok(())
158        }
159        (RuntimeValue::List(_), _) => Err("List index must be an integer".to_string()),
160        _ => Err(format!("Cannot index into {}", coll.type_name())),
161    }
162}
163
164/// A Map key must be TRANSITIVELY immutable: a List/Set/Map key (even one
165/// buried inside a tuple or struct) is a shared handle whose later mutation
166/// would silently corrupt the map, so the insert refuses it outright with a
167/// catchable error. Tuples and structs of immutable parts are VALUE keys.
168pub fn assert_hashable_key(key: &RuntimeValue) -> Result<(), String> {
169    match key {
170        RuntimeValue::List(_) | RuntimeValue::Set(_) | RuntimeValue::Map(_) => Err(format!(
171            "a {} cannot be a Map key — it is mutable, and mutating a live key \
172             would corrupt the map (use a Tuple of its values instead)",
173            key.type_name()
174        )),
175        RuntimeValue::Tuple(items) => {
176            for item in items.iter() {
177                assert_hashable_key(item)?;
178            }
179            Ok(())
180        }
181        RuntimeValue::Struct(s) => {
182            for value in s.fields.values() {
183                assert_hashable_key(value)?;
184            }
185            Ok(())
186        }
187        _ => Ok(()),
188    }
189}
190
191/// 1-indexed, inclusive-end slice of a List. Out-of-range slices are empty.
192pub fn slice(
193    coll: &RuntimeValue,
194    start: &RuntimeValue,
195    end: &RuntimeValue,
196) -> Result<RuntimeValue, String> {
197    match (coll, start, end) {
198        (RuntimeValue::List(items), RuntimeValue::Int(s), RuntimeValue::Int(e)) => {
199            let items = items.borrow();
200            let start = (*s as usize).saturating_sub(1);
201            let end = *e as usize;
202            // Same out-of-range semantics as `slice.get(start..end)`: empty.
203            let payload = if start < end && end <= items.len() {
204                items.slice(start, end - 1)
205            } else {
206                crate::interpreter::ListRepr::Boxed(Vec::new())
207            };
208            Ok(RuntimeValue::List(Rc::new(RefCell::new(payload))))
209        }
210        _ => Err("Slice requires List and Int indices".to_string()),
211    }
212}
213
214/// `length of x`. NOTE: Text length is BYTES (while Text indexing is chars) —
215/// a pinned tree-walker behavior.
216pub fn length_of(coll: &RuntimeValue) -> Result<RuntimeValue, String> {
217    match coll {
218        RuntimeValue::List(items) => Ok(RuntimeValue::Int(items.borrow().len() as i64)),
219        RuntimeValue::Tuple(items) => Ok(RuntimeValue::Int(items.len() as i64)),
220        RuntimeValue::Set(items) => Ok(RuntimeValue::Int(items.borrow().len() as i64)),
221        RuntimeValue::Text(s) => Ok(RuntimeValue::Int(s.len() as i64)),
222        RuntimeValue::Map(map) => Ok(RuntimeValue::Int(map.borrow().len() as i64)),
223        RuntimeValue::Crdt(c) => Ok(RuntimeValue::Int(c.borrow().len() as i64)),
224        _ => Err(format!("Cannot get length of {}", coll.type_name())),
225    }
226}
227
228/// Membership: `values_equal` scan for Set/List, key lookup for Map,
229/// substring/char for Text.
230pub fn contains(coll: &RuntimeValue, val: &RuntimeValue) -> Result<RuntimeValue, String> {
231    match coll {
232        RuntimeValue::List(items) => {
233            Ok(RuntimeValue::Bool(items.borrow().contains(val)))
234        }
235        RuntimeValue::Set(items) => {
236            let items = items.borrow();
237            let found = items.iter().any(|item| values_equal(item, val));
238            Ok(RuntimeValue::Bool(found))
239        }
240        RuntimeValue::Map(entries) => Ok(RuntimeValue::Bool(entries.borrow().contains_key(val))),
241        RuntimeValue::Text(s) => {
242            if let RuntimeValue::Text(needle) = val {
243                Ok(RuntimeValue::Bool(s.contains(needle.as_str())))
244            } else if let RuntimeValue::Char(c) = val {
245                Ok(RuntimeValue::Bool(s.contains(*c)))
246            } else {
247                Err(format!("Cannot check if Text contains {}", val.type_name()))
248            }
249        }
250        RuntimeValue::Crdt(c) => Ok(RuntimeValue::Bool(c.borrow().contains(val)?)),
251        _ => Err(format!("Cannot check contains on {}", coll.type_name())),
252    }
253}
254
255/// Set union — left's elements, then right's not already present.
256pub fn union(left: &RuntimeValue, right: &RuntimeValue) -> Result<RuntimeValue, String> {
257    match (left, right) {
258        (RuntimeValue::Set(a), RuntimeValue::Set(b)) => {
259            let a = a.borrow();
260            let b = b.borrow();
261            let mut result = a.clone();
262            for item in b.iter() {
263                if !result.iter().any(|x| values_equal(x, item)) {
264                    result.push(item.clone());
265                }
266            }
267            Ok(RuntimeValue::Set(Rc::new(RefCell::new(result))))
268        }
269        _ => Err(format!(
270            "Cannot union {} and {}",
271            left.type_name(),
272            right.type_name()
273        )),
274    }
275}
276
277/// Set intersection — left's elements present in right, in left's order.
278pub fn intersection(left: &RuntimeValue, right: &RuntimeValue) -> Result<RuntimeValue, String> {
279    match (left, right) {
280        (RuntimeValue::Set(a), RuntimeValue::Set(b)) => {
281            let a = a.borrow();
282            let b = b.borrow();
283            let result: Vec<RuntimeValue> = a
284                .iter()
285                .filter(|item| b.iter().any(|x| values_equal(x, item)))
286                .cloned()
287                .collect();
288            Ok(RuntimeValue::Set(Rc::new(RefCell::new(result))))
289        }
290        _ => Err(format!(
291            "Cannot intersect {} and {}",
292            left.type_name(),
293            right.type_name()
294        )),
295    }
296}
297
298/// `a to b` — inclusive integer range as a List.
299pub fn range(start: &RuntimeValue, end: &RuntimeValue) -> Result<RuntimeValue, String> {
300    match (start, end) {
301        (RuntimeValue::Int(s), RuntimeValue::Int(e)) => {
302            let range: Vec<i64> = (*s..=*e).collect();
303            Ok(RuntimeValue::List(Rc::new(RefCell::new(
304                crate::interpreter::ListRepr::Ints(range),
305            ))))
306        }
307        _ => Err("Range requires Int bounds".to_string()),
308    }
309}
310
311/// The `Repeat` iteration snapshot: the collection is materialized ONCE before
312/// the loop, so mutation inside the body cannot extend or shrink the
313/// iteration. Text iterates per char (as 1-char Texts); a Map yields (key,
314/// value) Tuples in its (nondeterministic) iteration order.
315pub fn iteration_snapshot(v: &RuntimeValue) -> Result<Vec<RuntimeValue>, String> {
316    match v {
317        RuntimeValue::List(list) => Ok(list.borrow().to_values()),
318        RuntimeValue::Set(set) => Ok(set.borrow().clone()),
319        RuntimeValue::Text(s) => Ok(s
320            .chars()
321            .map(|c| RuntimeValue::Text(Rc::new(c.to_string())))
322            .collect()),
323        RuntimeValue::Map(map) => Ok(map
324            .borrow()
325            .iter()
326            .map(|(k, v)| RuntimeValue::Tuple(Rc::new(vec![k.clone(), v.clone()])))
327            .collect()),
328        _ => Err(format!("Cannot iterate over {}", v.type_name())),
329    }
330}
331
332/// `Push value to obj's field` — pushes into a struct's List field through
333/// the shared allocation. Every error string is the spec.
334pub fn push_to_struct_field(
335    obj: &RuntimeValue,
336    field_name: &str,
337    val: RuntimeValue,
338) -> Result<(), String> {
339    if let RuntimeValue::Struct(s) = obj {
340        if let Some(RuntimeValue::List(items)) = s.fields.get(field_name) {
341            items.borrow_mut().push(val);
342            Ok(())
343        } else {
344            Err(format!("Field '{}' is not a List", field_name))
345        }
346    } else {
347        Err("Cannot push to field of non-struct".to_string())
348    }
349}
350
351thread_local! {
352    /// When > 0, the current thread runs collections under REFERENCE semantics
353    /// regardless of the global default. This is the bootstrap-scope: the
354    /// compile-time partial evaluator and self-interpreter (`pe_source.logos`,
355    /// `pe_mini_source.logos`, the core interpreter) are SELF-APPLICABLE — a
356    /// Futamura projection specializes them, and that only works when their
357    /// threaded state is mutated IN PLACE (a stable binding), which is exactly
358    /// reference semantics. They are compiler infrastructure, authored in
359    /// reference-semantics Logos; user programs (the residuals they emit) still
360    /// run under value semantics, which is the default everywhere else.
361    static FORCE_REFERENCE: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
362}
363
364/// RAII guard: run the closure / dynamic extent under reference semantics on this
365/// thread. Re-entrant (nested PE calls compose). See [`FORCE_REFERENCE`].
366pub struct ReferenceScope;
367
368impl ReferenceScope {
369    pub fn enter() -> Self {
370        FORCE_REFERENCE.with(|c| c.set(c.get() + 1));
371        ReferenceScope
372    }
373}
374
375impl Drop for ReferenceScope {
376    fn drop(&mut self) {
377        FORCE_REFERENCE.with(|c| c.set(c.get().saturating_sub(1)));
378    }
379}
380
381/// Run `f` with reference semantics forced on this thread (the bootstrap scope).
382pub fn with_reference_semantics<T>(f: impl FnOnce() -> T) -> T {
383    let _g = ReferenceScope::enter();
384    f()
385}
386
387/// Whether the current thread is inside a [`ReferenceScope`]. Consulted by the
388/// AOT runner so a child process compiled from PE source inherits reference
389/// semantics (`LOGOS_VALUE_SEMANTICS=0`).
390pub fn reference_scope_active() -> bool {
391    FORCE_REFERENCE.with(|c| c.get() > 0)
392}
393
394/// Whether Mutable Value Semantics (copy-on-write for collections) is enabled.
395/// Value semantics is the DEFAULT (all four tiers — tree-walker, VM, AOT, JIT —
396/// implement it). `LOGOS_VALUE_SEMANTICS=0` restores the historical reference
397/// semantics (escape hatch). A thread-local [`ReferenceScope`] also forces
398/// reference semantics for the duration of compile-time PE / self-interpreter
399/// execution (bootstrap-scope). The env var is read once and cached; the
400/// thread-local check is a single `Cell` read, so the hot path stays cheap.
401pub fn value_semantics_enabled() -> bool {
402    use std::sync::OnceLock;
403    static ON: OnceLock<bool> = OnceLock::new();
404    if reference_scope_active() {
405        return false;
406    }
407    *ON.get_or_init(|| std::env::var("LOGOS_VALUE_SEMANTICS").as_deref() != Ok("0"))
408}
409
410/// `Push value to list` — mutates the shared allocation in place.
411pub fn list_push(coll: &RuntimeValue, value: RuntimeValue) -> Result<(), String> {
412    match coll {
413        RuntimeValue::List(items) => {
414            items.borrow_mut().push(value);
415            Ok(())
416        }
417        _ => Err("Can only push to a List".to_string()),
418    }
419}
420
421/// `Pop from list` — removes and returns the last element, or Nothing when
422/// the list is empty (popping an empty list is NOT an error).
423pub fn list_pop(coll: &RuntimeValue) -> Result<RuntimeValue, String> {
424    match coll {
425        RuntimeValue::List(items) => {
426            Ok(items.borrow_mut().pop().unwrap_or(RuntimeValue::Nothing))
427        }
428        _ => Err("Can only pop from a List".to_string()),
429    }
430}
431
432/// `Add value to set` — dedups via `values_equal`.
433pub fn set_add(coll: &RuntimeValue, value: RuntimeValue) -> Result<(), String> {
434    match coll {
435        RuntimeValue::Set(items) => {
436            let already_present = items.borrow().iter().any(|x| values_equal(x, &value));
437            if !already_present {
438                items.borrow_mut().push(value);
439            }
440            Ok(())
441        }
442        RuntimeValue::Crdt(c) => c.borrow_mut().insert(&value),
443        _ => Err("Can only add to a Set".to_string()),
444    }
445}
446
447/// `Remove value from set/map`.
448pub fn remove_from(coll: &RuntimeValue, value: &RuntimeValue) -> Result<(), String> {
449    match coll {
450        RuntimeValue::Set(items) => {
451            items.borrow_mut().retain(|x| !values_equal(x, value));
452            Ok(())
453        }
454        RuntimeValue::Map(map) => {
455            map.borrow_mut().shift_remove(value);
456            Ok(())
457        }
458        RuntimeValue::Crdt(c) => c.borrow_mut().remove(value),
459        _ => Err("Can only remove from a Set or Map".to_string()),
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    fn list(items: Vec<RuntimeValue>) -> RuntimeValue {
468        RuntimeValue::List(Rc::new(RefCell::new(crate::interpreter::ListRepr::from_values(
469            items,
470        ))))
471    }
472
473    #[test]
474    fn index_is_one_based_with_end_relative_negatives() {
475        let xs = list(vec![RuntimeValue::Int(5), RuntimeValue::Int(6)]);
476        assert!(matches!(index_get(&xs, &RuntimeValue::Int(1)).unwrap(), RuntimeValue::Int(5)));
477        assert_eq!(index_get(&xs, &RuntimeValue::Int(0)).unwrap_err(), "Index 0 out of bounds");
478        assert_eq!(index_get(&xs, &RuntimeValue::Int(3)).unwrap_err(), "Index 3 out of bounds");
479        // Negative = end-relative: `-1` is the last element, `-2` the first.
480        assert!(matches!(index_get(&xs, &RuntimeValue::Int(-1)).unwrap(), RuntimeValue::Int(6)));
481        assert!(matches!(index_get(&xs, &RuntimeValue::Int(-2)).unwrap(), RuntimeValue::Int(5)));
482        // Out of range on the negative side is still loud.
483        assert_eq!(index_get(&xs, &RuntimeValue::Int(-3)).unwrap_err(), "Index -3 out of bounds");
484    }
485
486    #[test]
487    fn text_indexing_is_chars_but_length_is_bytes() {
488        let s = RuntimeValue::Text(Rc::new("héllo".to_string()));
489        // 5 chars, 6 bytes.
490        let c = index_get(&s, &RuntimeValue::Int(2)).unwrap();
491        assert!(matches!(&c, RuntimeValue::Text(t) if **t == "é"));
492        assert!(matches!(length_of(&s).unwrap(), RuntimeValue::Int(6)));
493    }
494
495    #[test]
496    fn slice_is_one_indexed_inclusive_and_oob_is_empty() {
497        let xs = list((1..=5).map(RuntimeValue::Int).collect());
498        let s = slice(&xs, &RuntimeValue::Int(2), &RuntimeValue::Int(4)).unwrap();
499        if let RuntimeValue::List(items) = &s {
500            let v: Vec<i64> = items
501                .borrow()
502                .to_values()
503                .iter()
504                .map(|x| if let RuntimeValue::Int(n) = x { *n } else { panic!() })
505                .collect();
506            assert_eq!(v, vec![2, 3, 4]);
507        } else {
508            panic!("slice did not return a list");
509        }
510        let s = slice(&xs, &RuntimeValue::Int(4), &RuntimeValue::Int(99)).unwrap();
511        if let RuntimeValue::List(items) = &s {
512            assert!(items.borrow().is_empty());
513        }
514    }
515
516    #[test]
517    fn pop_of_empty_list_is_nothing_not_error() {
518        let xs = list(vec![]);
519        assert!(matches!(list_pop(&xs).unwrap(), RuntimeValue::Nothing));
520    }
521
522    #[test]
523    fn set_add_dedups_with_ieee_equality() {
524        // IEEE equality: 0.1 + 0.2 is NOT 0.3 (the artifact is real), so they
525        // are two distinct set elements…
526        let s = RuntimeValue::Set(Rc::new(RefCell::new(vec![RuntimeValue::Float(0.3)])));
527        set_add(&s, RuntimeValue::Float(0.1 + 0.2)).unwrap();
528        if let RuntimeValue::Set(items) = &s {
529            assert_eq!(items.borrow().len(), 2, "IEEE-distinct floats stay distinct");
530        }
531        // …while a bit-equal float DOES dedup.
532        set_add(&s, RuntimeValue::Float(0.3)).unwrap();
533        if let RuntimeValue::Set(items) = &s {
534            assert_eq!(items.borrow().len(), 2, "bit-equal float must dedup");
535        }
536    }
537
538    #[test]
539    fn range_requires_int_bounds() {
540        assert_eq!(
541            range(&RuntimeValue::Int(1), &RuntimeValue::Float(2.5)).unwrap_err(),
542            "Range requires Int bounds"
543        );
544    }
545}