diff --git a/README.md b/README.md index 09b9ef5..6512330 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,9 @@ Requires Bash 4.2+. On macOS, use Homebrew Bash instead of the system `/bin/bash - [`lib/bash/arg/lib_arg.sh`](lib/bash/arg/README.md) Argument parsing helpers built on the stdlib for exact flag and value options without hidden parser globals. +- [`lib/bash/list/lib_list.sh`](lib/bash/list/README.md) + Indexed-array helpers built on the stdlib for in-place mutation, + membership checks, deduplication, and length results. See [`lib/bash/README.md`](lib/bash/README.md) for the package layout. @@ -66,6 +69,7 @@ import "$base_bash_libs_prefix/libexec/lib/bash/file/lib_file.sh" import "$base_bash_libs_prefix/libexec/lib/bash/git/lib_git.sh" import "$base_bash_libs_prefix/libexec/lib/bash/str/lib_str.sh" import "$base_bash_libs_prefix/libexec/lib/bash/arg/lib_arg.sh" +import "$base_bash_libs_prefix/libexec/lib/bash/list/lib_list.sh" ``` ### Source Checkout @@ -93,6 +97,7 @@ import "$base_bash_libs_dir/lib/bash/file/lib_file.sh" import "$base_bash_libs_dir/lib/bash/git/lib_git.sh" import "$base_bash_libs_dir/lib/bash/str/lib_str.sh" import "$base_bash_libs_dir/lib/bash/arg/lib_arg.sh" +import "$base_bash_libs_dir/lib/bash/list/lib_list.sh" ``` ### Vendored or Submodule Layout @@ -109,6 +114,7 @@ import "$base_bash_libs_dir/lib/bash/file/lib_file.sh" import "$base_bash_libs_dir/lib/bash/git/lib_git.sh" import "$base_bash_libs_dir/lib/bash/str/lib_str.sh" import "$base_bash_libs_dir/lib/bash/arg/lib_arg.sh" +import "$base_bash_libs_dir/lib/bash/list/lib_list.sh" ``` After `lib_std.sh` is sourced, `BASE_BASH_LIBS_VERSION` contains the package diff --git a/STANDARDS.md b/STANDARDS.md index 7b9f309..bb99d82 100644 --- a/STANDARDS.md +++ b/STANDARDS.md @@ -16,6 +16,7 @@ physical `.sh` file at its library boundary: - `lib/bash/git/lib_git.sh` - `lib/bash/str/lib_str.sh` - `lib/bash/arg/lib_arg.sh` +- `lib/bash/list/lib_list.sh` Do not split one library into internal concern files such as separate logging, path, string, prompt, or command-runner fragments. That kind of split adds a diff --git a/lib/bash/README.md b/lib/bash/README.md index 926c1c9..ee23368 100644 --- a/lib/bash/README.md +++ b/lib/bash/README.md @@ -15,6 +15,8 @@ Reusable Bash libraries for command wrappers and other Bash tooling. String helpers built on top of the stdlib. - `arg/` Argument parsing helpers built on top of the stdlib. +- `list/` + Indexed-array helpers built on top of the stdlib. - `tests/` Common BATS helpers for Bash library test suites. diff --git a/lib/bash/list/README.md b/lib/bash/list/README.md new file mode 100644 index 0000000..daee019 --- /dev/null +++ b/lib/bash/list/README.md @@ -0,0 +1,47 @@ +# `lib_list.sh` + +Indexed-array helpers for Base-style Bash scripts. + +## Dependency + +Source `lib/bash/std/lib_std.sh` before this library so validation and error +helpers are available. + +## Public API + +- `list_append` + Append one or more values to a named indexed array. +- `list_prepend` + Prepend one or more values to a named indexed array. +- `list_remove` + Remove all exact matches from a named indexed array. +- `list_contains` + Predicate that checks whether a named indexed array contains a value. +- `list_unique` + Store first-seen unique values in a named result array. +- `list_length` + Store an array length in a named result variable. + +## Usage + +```bash +source "/absolute/path/to/lib/bash/std/lib_std.sh" +source "/absolute/path/to/lib/bash/list/lib_list.sh" + +declare -a packages=("jq") + +list_append packages "shellcheck" "bats-core" +list_prepend packages "bash" + +if list_contains "shellcheck" packages; then + log_info "ShellCheck validation is available." +fi +``` + +Mutating helpers update the caller-owned array in place. Result helpers accept +the name of the output variable, validate it with `assert_variable_name`, and +avoid stdout capture for caller state. + +## Tests + +BATS coverage lives in `tests/lib_list.bats`. diff --git a/lib/bash/list/lib_list.sh b/lib/bash/list/lib_list.sh new file mode 100644 index 0000000..2915e76 --- /dev/null +++ b/lib/bash/list/lib_list.sh @@ -0,0 +1,101 @@ +# shellcheck shell=bash +# +# lib_list.sh - Bash helpers for caller-owned indexed arrays. +# + +[[ -n "${__lib_list_sourced__:-}" ]] && return 0 +if [[ "${BASE_BASH_LIBS_STDLIB_LOADED:-}" != "1" ]]; then + printf '%s\n' "Error: lib_list.sh requires lib_std.sh to be sourced first." >&2 + return 1 2>/dev/null || exit 1 +fi +readonly __lib_list_sourced__=1 + +list_append() { + local __list_array_name="${1-}" + local -a __list_values=() + + if (($# < 2)); then + fatal_error "list_append: usage: list_append [value...]" + fi + + assert_variable_name "$__list_array_name" + shift + __list_values=("$@") + eval "$__list_array_name+=(\"\${__list_values[@]}\")" +} + +list_prepend() { + local __list_array_name="${1-}" + local -a __list_values=() __list_current=() + + if (($# < 2)); then + fatal_error "list_prepend: usage: list_prepend [value...]" + fi + + assert_variable_name "$__list_array_name" + shift + __list_values=("$@") + eval "__list_current=(\"\${${__list_array_name}[@]}\")" + eval "$__list_array_name=(\"\${__list_values[@]}\" \"\${__list_current[@]}\")" +} + +list_remove() { + local __list_array_name="${1-}" __list_needle="${2-}" __list_item + local -a __list_current=() __list_filtered=() + + assert_arg_count "$#" 2 + assert_variable_name "$__list_array_name" + + eval "__list_current=(\"\${${__list_array_name}[@]}\")" + for __list_item in "${__list_current[@]}"; do + [[ "$__list_item" == "$__list_needle" ]] && continue + __list_filtered+=("$__list_item") + done + + eval "$__list_array_name=(\"\${__list_filtered[@]}\")" +} + +list_contains() { + local __list_needle="${1-}" __list_array_name="${2-}" __list_item + local -a __list_current=() + + assert_arg_count "$#" 2 + assert_variable_name "$__list_array_name" + + eval "__list_current=(\"\${${__list_array_name}[@]}\")" + for __list_item in "${__list_current[@]}"; do + [[ "$__list_item" == "$__list_needle" ]] && return 0 + done + + return 1 +} + +list_unique() { + local __list_result_name="${1-}" __list_array_name="${2-}" __list_item __list_key + local -a __list_current=() __list_unique=() + local -A __list_seen=() + + assert_arg_count "$#" 2 + assert_variable_name "$__list_result_name" "$__list_array_name" + + eval "__list_current=(\"\${${__list_array_name}[@]}\")" + for __list_item in "${__list_current[@]}"; do + __list_key="v:$__list_item" + [[ -n "${__list_seen[$__list_key]+set}" ]] && continue + __list_seen["$__list_key"]=1 + __list_unique+=("$__list_item") + done + + eval "$__list_result_name=(\"\${__list_unique[@]}\")" +} + +list_length() { + local __list_result_name="${1-}" __list_array_name="${2-}" + local -a __list_current=() + + assert_arg_count "$#" 2 + assert_variable_name "$__list_result_name" "$__list_array_name" + + eval "__list_current=(\"\${${__list_array_name}[@]}\")" + printf -v "$__list_result_name" '%s' "${#__list_current[@]}" +} diff --git a/lib/bash/list/tests/lib_list.bats b/lib/bash/list/tests/lib_list.bats new file mode 100644 index 0000000..b1f4f93 --- /dev/null +++ b/lib/bash/list/tests/lib_list.bats @@ -0,0 +1,114 @@ +#!/usr/bin/env bats + +load ../../tests/test_helper.sh + +setup() { + setup_test_tmpdir + source "$BASE_BASH_DIR/std/lib_std.sh" + source "$BASE_BASH_DIR/list/lib_list.sh" +} + +create_script() { + local script_path="$1" + cat > "$script_path" + chmod +x "$script_path" +} + +@test "lib_list can be sourced more than once" { + source "$BASE_BASH_DIR/list/lib_list.sh" + + [ "$(type -t list_append)" = "function" ] +} + +@test "lib_list fails clearly when sourced without stdlib" { + bats_run bash -c 'source "$1"; rc=$?; printf "source-rc=%s\n" "$rc"; exit "$rc"' bash "$BASE_BASH_DIR/list/lib_list.sh" + + [ "$status" -eq 1 ] + [[ "$output" == *"lib_list.sh requires lib_std.sh to be sourced first"* ]] + [[ "$output" == *"source-rc=1"* ]] + [[ "$output" != *"command not found"* ]] +} + +@test "lib_list requires the stdlib loaded marker" { + bats_run bash -c 'log_error() { :; }; log_debug() { :; }; source "$1"; rc=$?; printf "source-rc=%s\n" "$rc"; exit "$rc"' bash "$BASE_BASH_DIR/list/lib_list.sh" + + [ "$status" -eq 1 ] + [[ "$output" == *"lib_list.sh requires lib_std.sh to be sourced first"* ]] + [[ "$output" == *"source-rc=1"* ]] +} + +@test "list_append and list_prepend mutate caller arrays in place" { + local -a values=("middle") + + list_append values "tail one" "" + list_prepend values "head" + + [ "${#values[@]}" -eq 4 ] + [ "${values[0]}" = "head" ] + [ "${values[1]}" = "middle" ] + [ "${values[2]}" = "tail one" ] + [ "${values[3]}" = "" ] +} + +@test "list_remove deletes matching values and preserves order" { + local -a values=("alpha" "beta" "alpha" "" "gamma") + + list_remove values "alpha" + list_remove values "" + + [ "${#values[@]}" -eq 2 ] + [ "${values[0]}" = "beta" ] + [ "${values[1]}" = "gamma" ] +} + +@test "list_contains checks membership without printing" { + local -a values=("alpha" "beta gamma" "") + local stdout_file="$TEST_TMPDIR/list-contains.out" + + list_contains "beta gamma" values >"$stdout_file" + list_contains "" values >>"$stdout_file" + + if list_contains "delta" values; then + return 1 + fi + [ ! -s "$stdout_file" ] +} + +@test "list_unique stores deduplicated values in a named result array" { + local -a values=("alpha" "beta" "alpha" "" "beta" "") + local -a unique=() + + list_unique unique values + + [ "${#unique[@]}" -eq 3 ] + [ "${unique[0]}" = "alpha" ] + [ "${unique[1]}" = "beta" ] + [ "${unique[2]}" = "" ] +} + +@test "list_length stores the array length in a named variable" { + local -a values=("alpha" "beta gamma" "") + local count="" + + list_length count values + + [ "$count" = "3" ] +} + +@test "list helpers reject invalid variable names without echoing values" { + local script="$TEST_TMPDIR/list-invalid-vars.sh" + + create_script "$script" </dev/null