Skip to main content

zsh/extensions/
bash_arrays.rs

1//! Bash sparse-array support — a Rust-only extension with NO zsh C
2//! counterpart.
3//!
4//! zsh (and zshrs) arrays are DENSE `Vec<String>`: `a[5]=q` on a 3-element
5//! array pads indices 3,4 with empty strings, so `${#a[@]}` is 6. bash
6//! arrays are SPARSE maps: the same op leaves indices {0,1,2,5}, so
7//! `${#a[@]}` is 4, `${!a[@]}` is `0 1 2 5`, and `unset a[1]` removes index
8//! 1 leaving {0,2}.
9//!
10//! Rather than change the dense `Vec<String>` representation the whole
11//! param system (and `--zsh`, the primary target) depends on, this tracks
12//! the "holes" — the indices that are padding artifacts or were `unset`,
13//! and therefore NOT real elements — in a side table keyed by array name.
14//! It is consulted only in `--bash` mode; `${#}`/`${!}`/`${@}` subtract the
15//! holes, subscript-assign/append/full-reassign maintain them, and `unset
16//! a[i]` marks a hole. Everything else keeps operating on the dense Vec.
17
18use std::cell::RefCell;
19use std::collections::{BTreeSet, HashMap};
20
21thread_local! {
22    /// name → set of HOLE indices (padding / unset slots that are not real
23    /// elements). Absent name ⇒ fully dense (no holes).
24    static HOLES: RefCell<HashMap<String, BTreeSet<usize>>> = RefCell::new(HashMap::new());
25}
26
27/// Clear all hole tracking for `name` — the array became fully dense
28/// (a plain `a=(...)` reassign, `unset a`, or a non-bash mutation).
29pub fn clear(name: &str) {
30    HOLES.with(|h| {
31        h.borrow_mut().remove(name);
32    });
33}
34
35/// Record that a subscript assignment set index `i`, padding the dense Vec
36/// from `old_len` up to `i`. Indices `old_len..i` become holes; `i` itself
37/// is a real element (removed from the hole set).
38pub fn note_subscript_set(name: &str, old_len: usize, i: usize) {
39    HOLES.with(|h| {
40        let mut map = h.borrow_mut();
41        let set = map.entry(name.to_string()).or_default();
42        if i >= old_len {
43            for k in old_len..i {
44                set.insert(k);
45            }
46        }
47        set.remove(&i);
48        if set.is_empty() {
49            map.remove(name);
50        }
51    });
52}
53
54/// Mark index `i` a hole (an `unset a[i]` that leaves a gap rather than a
55/// dense empty). Returns nothing; callers keep the dense Vec's slot as "".
56pub fn note_unset(name: &str, i: usize) {
57    HOLES.with(|h| {
58        h.borrow_mut()
59            .entry(name.to_string())
60            .or_default()
61            .insert(i);
62    });
63}
64
65/// Record every slot of a freshly-written `0..len` array that the literal
66/// did NOT name as a hole.
67///
68/// An explicit-index array literal is sparse in bash: `a=([2]=x [5]=y)`
69/// holds indices {2,5}, not a dense 0..5 with empties. The dense `Vec`
70/// underneath has already been written by the caller's setter; this marks
71/// the slots between the named ones, which is what `${a[@]}`, `${!a[@]}`
72/// and `${#a[@]}` consult.
73///
74/// Shared by all three literal paths — the `a=(…)` builtin, `declare -a`'s
75/// bracket form, and `declare -a`'s Marker-triad form — so they cannot
76/// drift apart the way the first two did: the triad form had no marking at
77/// all, and `declare -a a=([5]=x [10]=y)` came out dense with eleven
78/// elements where bash reports two.
79pub fn note_holes_outside(name: &str, explicit: &BTreeSet<usize>, len: usize) {
80    for i in 0..len {
81        if !explicit.contains(&i) {
82            note_unset(name, i);
83        }
84    }
85}
86
87/// Drop any hole indices >= `new_len` — the array shrank (truncation), so
88/// trailing holes no longer exist.
89pub fn truncate(name: &str, new_len: usize) {
90    HOLES.with(|h| {
91        let mut map = h.borrow_mut();
92        if let Some(set) = map.get_mut(name) {
93            set.retain(|&k| k < new_len);
94            if set.is_empty() {
95                map.remove(name);
96            }
97        }
98    });
99}
100
101/// The hole indices for `name` (empty if dense).
102pub fn holes(name: &str) -> BTreeSet<usize> {
103    HOLES.with(|h| h.borrow().get(name).cloned().unwrap_or_default())
104}
105
106/// True if `name` has any tracked holes.
107pub fn has_holes(name: &str) -> bool {
108    HOLES.with(|h| h.borrow().get(name).map_or(false, |s| !s.is_empty()))
109}
110
111/// The live (non-hole) indices of a dense Vec of length `len` for `name`,
112/// in ascending order. With no holes this is simply `0..len`.
113pub fn live_indices(name: &str, len: usize) -> Vec<usize> {
114    let hs = holes(name);
115    (0..len).filter(|k| !hs.contains(k)).collect()
116}
117
118/// The live element COUNT for a dense Vec of length `dense_len` — `dense_len`
119/// minus the holes below it. Inherently a no-op (`== dense_len`) outside
120/// `--bash` mode, where no holes are ever recorded. Backs `${#a[@]}`.
121pub fn live_len(name: &str, dense_len: usize) -> usize {
122    HOLES.with(|h| match h.borrow().get(name) {
123        Some(set) => dense_len - set.iter().filter(|&&k| k < dense_len).count(),
124        None => dense_len,
125    })
126}
127
128/// Filter a dense whole-array Vec down to its live elements, dropping the
129/// hole slots (padding / `unset` gaps). Order-preserving. No-op outside
130/// `--bash` (no holes tracked). Backs the `${a[@]}` / `${a[*]}` splat.
131pub fn compact(name: &str, dense: Vec<String>) -> Vec<String> {
132    let hs = holes(name);
133    if hs.is_empty() {
134        return dense;
135    }
136    dense
137        .into_iter()
138        .enumerate()
139        .filter(|(k, _)| !hs.contains(k))
140        .map(|(_, v)| v)
141        .collect()
142}