Files
Zecheng Zhang b8d13f5572 feat(shell): associative arrays and array-element arithmetic, both languages
declare -A / local -A / readonly -A build a real map: keyed and pair
literals, element and += writes, unset of one key, declare -p rendering
with GNU's trailing space and key quoting, kind conversion refusals,
[[ -v ]] on elements, and read/printf -v into an element. Arithmetic
gains element lvalues (a[i]++, m[k]+=2) through an ElementOps seam the
session fills, so $((a[i])) and ((m[k]=5)) resolve without the
evaluator learning what a session is. Two pinned divergences: keys walk
in sorted order, and a bare declare -A prints =().

Pinned against bash 5.2.37; 49 unit cases per language, 15 battery
cases on 22 targets, both hosts byte-identical.
2026-08-16 02:07:22 -07:00

153 lines
4.9 KiB
Python

from mirage.shell.array import (array_append, array_count, array_extent,
array_get, array_has, array_indices, array_set,
array_slice, array_unset, array_values,
build_assoc_literal, build_indexed_literal,
keyed_word, make_array)
def test_make_array_is_dense_from_zero():
arr = make_array(["a", "b"])
assert arr == ["a", "b"]
assert array_indices(arr) == [0, 1]
def test_set_pads_with_holes_not_empty_strings():
arr: list[str | None] = []
array_set(arr, 3, "v")
assert arr == [None, None, None, "v"]
# The hole is addressable but is not an element.
assert array_count(arr) == 1
assert array_values(arr) == ["v"]
assert array_indices(arr) == [3]
assert array_extent(arr) == 4
assert array_get(arr, 0) == ""
assert not array_has(arr, 0)
assert array_has(arr, 3)
def test_unset_interior_element_keeps_later_indices():
arr = make_array(["zero", "one", "two"])
array_unset(arr, 1)
assert arr == ["zero", None, "two"]
assert array_get(arr, 2) == "two"
assert array_count(arr) == 2
assert array_indices(arr) == [0, 2]
def test_unset_trailing_element_shrinks_the_extent():
arr = make_array(["x", "y", "z"])
array_unset(arr, 2)
assert arr == ["x", "y"]
assert array_extent(arr) == 2
def test_unset_trailing_element_drops_earlier_holes_too():
arr = make_array(["x", "y", "z"])
array_unset(arr, 1)
array_unset(arr, 2)
assert arr == ["x"]
def test_unset_out_of_range_is_a_no_op():
arr = make_array(["x"])
array_unset(arr, 5)
array_unset(arr, -1)
assert arr == ["x"]
def test_append_starts_at_the_extent():
arr = make_array(["x", "y", "z"])
array_unset(arr, 1)
array_append(arr, ["w"])
assert arr == ["x", None, "z", "w"]
assert array_indices(arr) == [0, 2, 3]
def test_append_refills_a_trailing_hole():
arr = make_array(["x", "y", "z"])
array_unset(arr, 2)
array_append(arr, ["w"])
assert arr == ["x", "y", "w"]
def test_set_reassigns_a_hole():
arr = make_array(["x", "y", "z"])
array_unset(arr, 1)
array_set(arr, 1, "new")
assert arr == ["x", "new", "z"]
assert array_count(arr) == 3
def test_empty_string_is_an_element_not_a_hole():
arr = make_array(["", "y"])
assert array_count(arr) == 2
assert array_indices(arr) == [0, 1]
assert array_values(arr) == ["", "y"]
def test_slice_keeps_subscripts_not_ordinals():
arr: list[str | None] = []
array_set(arr, 1, "b")
array_set(arr, 3, "d")
array_set(arr, 9, "j")
# Offset 2 means "index >= 2", not "skip the first two values".
assert array_slice(arr, 2, None) == ["d", "j"]
assert array_slice(arr, 0, None) == ["b", "d", "j"]
assert array_slice(arr, 4, None) == ["j"]
assert array_slice(arr, 20, None) == []
def test_slice_length_counts_elements_taken():
arr: list[str | None] = []
array_set(arr, 1, "b")
array_set(arr, 3, "d")
array_set(arr, 9, "j")
assert array_slice(arr, 2, 1) == ["d"]
assert array_slice(arr, 0, 2) == ["b", "d"]
assert array_slice(arr, 0, -1) == ["b", "d"]
def test_slice_negative_offset_counts_from_the_extent():
arr: list[str | None] = []
array_set(arr, 1, "b")
array_set(arr, 9, "j")
assert array_slice(arr, -1, None) == ["j"]
# Still negative after the extent is added: nothing, not everything.
assert array_slice(arr, -20, None) == []
assert array_slice(make_array(["x", "y", "z"]), -5, None) == []
assert array_slice(make_array(["x", "y", "z"]), -2, None) == ["y", "z"]
def test_keyed_word():
assert keyed_word("[a]=1") == ("a", "1")
assert keyed_word("[two words]=v x") == ("two words", "v x")
assert keyed_word("plain") is None
assert keyed_word("[]=x") is None
assert keyed_word("[k]=") == ("k", "")
# The split lands on the first `]=`, so a value may hold one.
assert keyed_word("[a]=x]=y") == ("a", "x]=y")
def test_build_indexed_literal_places_and_continues():
built = build_indexed_literal(None, ["[3]=x", "y", "[1]=z"], False,
lambda t: int(t))
assert built == [None, "z", None, "x", "y"]
# `+=` starts the cursor at the extent; last index wins.
appended = build_indexed_literal(["a"], ["b", "[0]=A"], True,
lambda t: int(t))
assert appended == ["A", "b"]
def test_build_assoc_literal_modes():
keyed, errors = build_assoc_literal(None, ["[a]=1", "b", "[a]=2"], False)
assert keyed == {"a": "2"}
assert errors == ["b"]
pairs, errors = build_assoc_literal(None, ["k1", "v1", "[a]=1"], False)
assert pairs == {"k1": "v1", "[a]=1": ""}
assert errors == []
merged, errors = build_assoc_literal({"a": "1"}, ["[b]=2"], True)
assert merged == {"a": "1", "b": "2"}
assert errors == []
replaced, _ = build_assoc_literal({"a": "1"}, ["[b]=2"], False)
assert replaced == {"b": "2"}