Scoping

Every bash file this crate ships — the prelude, the walk — and every tool's instrument built on it runs inside the subject's frames rather than beside them. Where a name binds therefore decides what a helper writes and what its continuation reads. This chapter is the closed set of scoping facts the shipped bash stands on, each measured against bash 5.3.9.

One stack, resolved by name at run time

Variables live in a stack of scopes: the global scope at the bottom, one frame per live function call above it. A name is resolved by walking from the innermost frame outward to the first frame holding a binding for it, so what a function sees depends on who called it.

local and declare inside a function are the same builtin behaviour. They create a binding in the current frame, shadowing any outer one, and it is released when the function returns. declare -g writes the global scope instead. The shipped bash says declare throughout, because it also works at a script's top level, where local is an error — and the words are written to be called from either.

A bare assignment writes the innermost existing binding, and creates a global when there is none:

where X=(…) inside a callee lands
a caller declared Xthat caller's frame
no frame declared Xthe global scope, outliving every frame

When the walk finds nothing: set -u

Under set -u an expansion whose name binds nowhere is an expansion error rather than a command failure. It happens while the command is being built, so there is no command to fail and no status to test:

echo "$nope" || echo caught        # 'caught' is never printed

The shell exits, whatever frame it was in and whether it was running a script or sourcing one. The || __BC_BAIL and || __BC_THROW discipline sits one layer above this and cannot see it (wire.md). The expansion itself is the only place it is answerable.

A name the instrument did not set carries its default at every expansion of it; a name it set one line earlier carries none. Where the tool set the name, an unbound one is a defect and killing the shell is the right outcome. The first list is short and closed:

unbound means
${1-}, ${__BC__at:-?}, ${BASH_SOURCE[1]:-?}a client called a word wrong

A tool's effect keeps its own short list under the same rule; bashprof's ${__BP_inside-} reads as the outermost call, stated in its own book.

${x-} rather than ${x:-} wherever empty and unset are different facts.

Which forms are safe is not guessable, and these were measured on 5.3.9:

"$@", "$*" with no positional parametersfine — exempt since 4.4
"${arr[@]}", "${arr[*]@Q}" on an unset arrayfine
"${arr[0]}", "${#arr[@]}" on an unset arrayfatal
"$1" with no argumentfatal
${!PREFIX@} with no matches, "${BASH_REMATCH[@]}" unsetfine

declare x leaves x unset; declare x= sets it empty. BASHPROF_TIMETHIS depends on the second, since after an empty hook has run $__BP_id has to be empty rather than unbound.

The two ways in differ in what set -u sees

Under a provisioned run, bash reads BASH_ENV while the shell is still starting, before the subject's own set -u line, so only function bodies later run under it. A client that joins by its own lines has set -u on first, so the top level of everything it sources — the prelude, the rig's definitions — and its own join line run under it too. Every __BC__* name is assigned before anything reads it, which is what makes the second case hold.

IFS comes from the subject, and [*] reads it

"${arr[*]}" joins with the first character of $IFS, and the subject is free to set that to anything. A shipped file that joins an array takes an IFS of its own for that frame:

__bc_account() { declare IFS=' '; … }   # prelude: the version is "(${BASH_VERSINFO[*]@Q})"
__bc_say()     { declare IFS=' '; … }   # prelude: the line is "(${*@Q})"
__bc_capture() { declare IFS=' '; … }   # bashcap's effect does the same

declare IFS=' ' is released on return, including where the subject had IFS unset: the binding is dropped rather than restored to a value, so the subject's own state comes back whichever it was. A subject running under IFS=, is what finds this, since the array arrives comma-joined and reads back as one element.

[@] does not join and needs nothing. Neither does printf -v x '%s ' "${@@Q}", which writes its own separator.

Proved by tests/proofs/transparency.rs::a_clients_own_trap_and_ifs_are_untouched, which sets IFS=, and then reads the version back off the shell.

LC_ALL comes from the subject, and ${#s} reads it

${#s} and ${s:a:b} count in the shell's locale. The one place a shipped file has to count bytes, cutting the account into frames of at most PIPE_BUF, takes LC_ALL=C the same way, for that frame:

__bc_announce() {
    declare LC_ALL=C
    declare __bc_room=$(( 4096 - ${#1} - 4 )) __bc_from=0
    …
}

Two declares rather than one, because the words of a declare are expanded before it runs, so ${#1} in the same statement would still count characters. The same ordering is why a word's parameters cannot be set as a command prefix on the call that reads them — see below. An assignment to LC_ALL takes effect at once, and the return puts the subject's LC_ALL back, unset included (measurements.md).

The slot pattern

A helper that computes a value and then calls a continuation cannot hold that value in its own frame. The continuation runs while the helper is still on the stack, but the value belongs to the span rather than to the helper. The frame that owns the lifetime declares the slot, and the helper writes through to it.

span() {
    declare -a CAPTURED             # the slot; this frame owns its lifetime
    with_capture continuation "$@"
}

with_capture() {
    CAPTURED=(…)                    # resolves to span's binding
    "$@"                            # the continuation reads it from there
}

Three properties follow from the declare, and none hold without it. The write lands in span's frame, so the binding is released when span returns. A nested span declares its own, so an inner one leaves the outer one intact. And nothing reaches the global scope.

The initialiser is not part of the mechanism: declare -a CAPTURED and declare -a CAPTURED=(…) behave identically here, since nothing reads the slot between the declaration and the helper's write.

Nesting three spans deep, reading the slot after the sub-call returns:

with the declaration                  without it
BEGIN A   X='depth-A'                 BEGIN A   X='depth-A'
BEGIN B   X='depth-B'                 BEGIN B   X='depth-B'
BEGIN C   X='depth-C'                 BEGIN C   X='depth-C'
END   C   X='depth-C'                 END   C   X='depth-C'
END   B   X='depth-B'                 END   B   X='depth-C'
END   A   X='depth-A'                 END   A   X='depth-C'

The two ways it inverts

When the helper declares the slot, the binding is in the helper's frame. The continuation still reads the value, because it runs below the helper, and the span reads whatever it declared, because the helper's binding was released before control returned:

span() { declare -a X=(marker); helper cont; }   # span sees 'marker'
helper() { declare -a X=(computed); "$@"; }      # cont sees 'computed'

When nobody declares the slot it lands in the global scope. It survives the span, and a nested span overwrites the enclosing one's, as the right-hand column above shows.

Namerefs

declare -n out="$1" binds out to whatever name $1 holds, resolved by the same outward walk at each use. It carries the target's name explicitly rather than relying on both sides agreeing on one, and it nests: each caller passes its own slot's name.

A nameref whose own name equals its target warns and discards the write:

bash: local: warning: X: circular name reference

Execution continues and the assignment is lost, so a nameref parameter needs a name no caller would choose. The __bc_ prefix keeps this unreachable.

unset -n releases the binding without touching the target.

Aliases, and what they can carry

BC_SAY and BC_ASK are aliases, because an alias expands textually at the call site: what it expands to runs in the caller's frame, which is what lets an answer declare there. Everything below follows from that, and each was measured on 5.3.9.

An alias is expanded when the command using it is parsed, not when it runs. The prelude is sourced as its own command, so anything parsed afterwards sees the words — including a function defined later in the same file. What does not work is using one in the same parse unit that defines it:

{ source "$dir/prelude.bash"; BC_SAY hello; }   # the whole group is parsed first

An alias's trailing words attach to the last command of its expansion. BC_SAY expands to one command, so words written after it are the message. BC_ASK expands to two — the ask, then the answer — so words after it would become the answer's arguments; its payload goes in BC_ASK__ARGS instead.

A word built over the core is one command for the same reason. Written as a prefix assignment plus a call it composes wherever a command does:

alias STAGE='BC_SAY__ARG_LABEL=DEPLOY BC_SAY STAGE'

Written as several statements it does not. Only the first would be guarded by a ||, and the rest would run unconditionally:

alias STAGE='declare -- BC_SAY__ARG_LABEL=DEPLOY; BC_SAY STAGE'   # not this
false || STAGE compile        # the declare is skipped; the say happens anyway

A command prefix reaches the callee at run time and is released after it, which is what makes the one-command form work. It cannot be used for a value the same command expands, because a simple command expands its words before performing its assignments.

$? does not survive a word. The commands an alias runs before the payload set it, so a status is captured on its own line first:

some_command
declare -i rc=$?
STAGE "finished $rc"

An answer cannot name an alias. It runs as "${__BC__ANSWER[@]}", and that expansion names commands, so a rig gives scripts an alias and gives its own answers a function — eval is the exception, since it re-parses and expands aliases again.

The parameters are ordinary variables, so a frame that sets one is visible to anything it calls. Declaring them keeps that to the frame that meant it, and the <WORD>__ARG_ prefix keeps them out of a subject's way; a callee that reads one without setting it will see the value an enclosing frame set.

Subshells

A subshell receives a copy of the whole scope stack. Writes inside it resolve against that copy and do not return:

span() { declare -a X=(before); ( X=(inside) ); }   # X is still (before)

The slot pattern works unchanged inside a subshell and carries nothing out of one. This is the same boundary that makes a buffered record flushed from a subshell's EXIT unrecoverable (measurements.md).

$? and the frame

$? survives only as the first command of a block, and a right-hand side is expanded before any name in the same statement is assigned. A status is therefore captured on its own line, before any other command in the frame runs:

"$@"
declare __rc=$?

See also

  • measurements.md — traps, extdebug, mkfifo, and the rest of what bounds the bash design
  • wire.md — the guards, which are aliases so that return acts in the frame that failed