From 17e37ea47c281bdf0601fb965658c59d6a61aa4e Mon Sep 17 00:00:00 2001 From: John Moore Date: Mon, 4 May 2026 18:13:35 -0400 Subject: [PATCH] fix: support `export -a NAME` (declare-as-array + export) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `export` builtin now accepts `-a`, mirroring bash's combined "declare as indexed array + mark exported" behavior. This is what `mise activate bash` relies on when seeding `chpwd_functions` early in its initialization, so before this change every mise-activated brush session printed `error: unexpected argument '-a' found` to stderr (mise still loaded, but the `chpwd_functions` array wasn't properly set up). Behavior: - `export -a NAME` on an existing variable: convert it to an indexed array and mark it exported. - `export -a NAME` on a name that doesn't yet exist: create it as an unset indexed array with the export attribute. - `export -a NAME=value` (scalar): auto-promote to indexed array with `[0]=value` and mark exported, matching bash's `declare -ax`. - `export -a NAME=(a b c)` (array literal): standard array initialization with the export attribute. - `export -an NAME`: combined with `-n`, removes the export attribute on an indexed array. Adds compat tests for each shape under `brush-shell/tests/cases/compat/builtins/export.yaml`. Note: bash is *lazy* about showing the `-a` attribute in `declare -p` output for a declared-but-unassigned indexed array (it shows `-x` until the array is populated, then upgrades to `-ax`); brush is *eager* (shows `-ax` immediately after the declaration). The functional behavior is identical — only the cosmetic `declare -p` output for an empty declared array differs. The new tests exercise the assignment paths, where bash and brush agree exactly. Closes #1132. Assisted-by: Claude (Anthropic) (JMO's friend) --- brush-builtins/src/export.rs | 30 ++++++++++++++++++- .../tests/cases/compat/builtins/export.yaml | 30 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/brush-builtins/src/export.rs b/brush-builtins/src/export.rs index af7870cd1..2900bc319 100644 --- a/brush-builtins/src/export.rs +++ b/brush-builtins/src/export.rs @@ -6,12 +6,16 @@ use brush_core::{ ExecutionExitCode, ExecutionResult, builtins, env::{EnvironmentLookup, EnvironmentScope}, parser::ast, - variables, + variables::{self, ShellValue, ShellValueUnsetType, ShellVariable}, }; /// Add or update exported shell variables. #[derive(Parser)] pub(crate) struct ExportCommand { + /// Mark names as indexed arrays (combined with the export attribute). + #[arg(short = 'a')] + make_indexed_array: bool, + /// Names are treated as function names. #[arg(short = 'f')] names_are_functions: bool, @@ -88,12 +92,30 @@ impl ExportCommand { // Try to find the variable already present; if we find it, then mark it // exported. else if let Some((_, variable)) = context.shell.env_mut().get_mut(s) { + if self.make_indexed_array { + variable.convert_to_indexed_array()?; + } if self.unexport { variable.unexport(); } else { variable.export(); } } + // If `-a` was passed and the name doesn't yet exist, create it as an unset + // indexed array with the export attribute (mirrors `declare -ax NAME`). This + // is what bash does for `export -a NAME` and what `mise activate bash` relies + // on when seeding `chpwd_functions`. + else if self.make_indexed_array { + let mut var = + ShellVariable::new(ShellValue::Unset(ShellValueUnsetType::IndexedArray)); + if !self.unexport { + var.export(); + } + context + .shell + .env_mut() + .add(s.clone(), var, EnvironmentScope::Global)?; + } } brush_core::CommandArg::Assignment(assignment) => { let name = match &assignment.name { @@ -118,10 +140,16 @@ impl ExportCommand { }; // Update the variable with the provided value and then mark it exported. + // When `-a` is set, ensure the variable is converted to an indexed array + // before the assignment lands (so a scalar assignment like `export -a foo=x` + // ends up as `foo=([0]="x")`, matching bash's `declare -ax foo=([0]="x")`). context.shell.env_mut().update_or_add( name, value, |var| { + if self.make_indexed_array { + var.convert_to_indexed_array()?; + } if self.unexport { var.unexport(); } else { diff --git a/brush-shell/tests/cases/compat/builtins/export.yaml b/brush-shell/tests/cases/compat/builtins/export.yaml index c4d5949cf..ea09492f4 100644 --- a/brush-shell/tests/cases/compat/builtins/export.yaml +++ b/brush-shell/tests/cases/compat/builtins/export.yaml @@ -34,3 +34,33 @@ cases: export ref="exported_value" echo "MY_EXPORT_VAR: $MY_EXPORT_VAR" env | grep MY_EXPORT_VAR + + - name: "export -a marks indexed array and exports (with array element assignment)" + stdin: | + export -a arr + arr[0]=hello + arr[1]=world + declare -p arr + + - name: "export -a with scalar assignment auto-promotes to array" + stdin: | + export -a arr=hello + declare -p arr + + - name: "export -a with array literal assignment" + stdin: | + export -a arr=(a b c) + declare -p arr + + - name: "export -a converts an existing scalar to an indexed array" + stdin: | + foo=hello + export -a foo + foo[1]=world + declare -p foo + + - name: "export -an unmarks an indexed array's export attribute" + stdin: | + export -a arr=(x y z) + export -n arr + declare -p arr