The call stack

src/stack/stack.bash writes it, the rest of src/stack/ reads it. One instrument and one reader, shared by every tool that reports where a shell is.

What bash keeps

Five parallel arrays, maintained by the shell itself:

FUNCNAME     ('__bc_stack' 'BASHCAP' 'f__C' 'f__B' 'main')
BASH_SOURCE  (…)                                            aligned 1:1
BASH_LINENO  ('4' '8' '9' '10' '0')                         shifted by one
BASH_ARGC    ('2' '0' '0' '0' '1')                          aligned 1:1
BASH_ARGV    ('2' 'walk' 'x')            one flat stack, groups reversed

BASH_ARGC and BASH_ARGV exist only under extdebug; bashcap's book covers how that is turned on. Expanding an unset array is not an error, set -u included, so an instrument writes all five unconditionally.

The instrument

The whole instrument as shipped, from src/stack/stack.bash, whose header comment carries the contract:

__bc_stack() {
    declare -n __bc_stack_out="$1"

    __bc_stack_out+=(
        skip    "$2"
        pwd     "$PWD"
        funcs   "(${FUNCNAME[*]@Q})"
        sources "(${BASH_SOURCE[*]@Q})"
        lines   "(${BASH_LINENO[*]@Q})"
        argc    "(${BASH_ARGC[*]@Q})"
        argv    "(${BASH_ARGV[*]@Q})"
    )
}

Seven expansions, with nothing sliced, summed, reversed or looped over. Everything that decides what a walk means happens on the Rust side, where it can be checked without running a shell.

$PWD is there because a relative BASH_SOURCE is relative to something and nothing else records what. It changes under the subject's feet, so it rides with every walk rather than with what the shell said of itself once.

$1 names the caller's own array, so nesting works and no global is involved; see scoping.md. The nameref is __bc_stack_out, a name no caller would choose, because a nameref pointing at itself warns and discards the write instead of failing.

$2 is how many leading frames belong to the instrument, counting __bc_stack's own. Each caller passes what it is. bashcap's __bc_capture forwards the number the word gave it — 3 under BASHCAP, for the word, the capture and the walk, and 2 under WITH_BASHCAP, whose own frame is the call site. bashprof's __bp_begin passes 3 plus the shift a wrapper declared.

Each section is a bash array literal, read back with parse_array; see bash-strings: values.

The reader

#![allow(unused)]
fn main() {
pub struct Frame {
    pub site: Site,
    pub source: Source,
    pub lineno: u32,
    pub args: Option<Vec<String>>,
}

/// What a frame is. `main` and `source` are bash's own words, not names;
/// `Shell` is a frame bash records no word for at all.
pub enum Site { Function(String), Script, Sourced, Shell }

/// Where its code came from. Only `File` is a path.
pub enum Source { File(PathBuf), Environment, Prompt, Shell }

impl Source {
    pub fn found(&self) -> Option<&Path>;     // a file, and it is there
    pub fn missing(&self) -> Option<&Path>;   // a file, and it is not
}

/// A walk, innermost first. Never empty, and one array in JSON.
pub struct Stack { /* private */ }

impl Stack {
    pub fn of(frames: Vec<Frame>) -> Option<Self>;   // None for no frames
    pub fn top(&self) -> &Frame;                     // where the walk was taken
    pub fn below(&self) -> &[Frame];                 // the frames above it
    pub fn frames(&self) -> impl Iterator<Item = &Frame>;
}

pub struct Args<'a>    { pub argc: &'a str, pub argv: &'a str }
pub struct Columns<'a> { pub skip: usize, pub pwd: &'a str, pub funcs: &'a str,
                         pub sources: &'a str, pub lines: &'a str,
                         pub args: Option<Args<'a>> }

impl<'a> Columns<'a> {
    pub fn of(words: &'a [String]) -> Result<Self, Failure>;

    /// Against the shell the walk was taken in — see `shell.md`.
    pub fn frames(&self, shell: &Bash) -> Result<Stack, Failure>;
}
}

A walk is one value rather than a head and a tail. Which frame is the call site is at(), and a Stack cannot be empty: Stack::of is the one place that can say so, and Columns::frames turns that into a Failure where the message is read, so nothing downstream carries the question.

Three indices are undone on the Rust side, all of them arithmetic. skip drops the instrument's own frames, and is at least 1 and never past the end of the walk. The line shift and the argument stack have sections of their own below.

The line each frame is executing

BASH_LINENO[i] is where frame i was called from, so where frame i is executing is BASH_LINENO[i - 1]. LINENO holds the missing cell at the innermost end, and the two together are the whole vector:

frame:            report  inner  outer  main
executing at:        3      9     12     14      = [LINENO] ++ BASH_LINENO[..n-1]
BASH_LINENO   = (   9  ,  12  ,  14  ,  0  )
LINENO        =     3

LINENO is not shipped, since it would be the emitter's own line, and `skip

= 1` drops that frame by construction.

The last BASH_LINENO cell is left over, and it holds where the walk itself was entered. Bash pushes a frame for the top level of a script file and for nothing else, so that cell tells the two apart. Measured on 5.3.9:

how bash was startedlast cell
a script file0
a script file defining a function called main0
a file sourced from a script file0
bash -c '…'the line the walk was entered from
a shell fed on standard inputthe same
a file sourced from either of thosethe same

Where it is not 0 there is one frame above the outermost that FUNCNAME never names, and the cell is its line. That frame is Site::Shell, built from what bash did report. A make recipe is the everyday form of it, since make runs each one through $(SHELL) -c.

Bash's own words

Measured against 5.3.9. eval, traps, subshells and command substitution add no frame.

in FUNCNAME
mainthe top level of the script bash was given
sourcethe top level of a file the subject sourced
in BASH_SOURCE
environmentthe function came in through the environment (export -f)
mainthe function was defined at an interactive prompt
$0the code came from a -c command line or from standard input

The last is whatever $0 is — bash, or any name a caller passed — so a walk alone cannot tell it from a file of the same name. $0 and how bash was started are in what the shell said when it joined, and Columns::frames is handed that: the word reads as Source::Shell only in a shell bash was given no script file for, and where it was, $0 is that script and reads as the path it is. A script defining a function called main or source is indistinguishable from bash's own use of those words, since bash reports the same string either way.

Where a source path lands

BASH_SOURCE holds the path as it was written, relative or not, and never normalised:

$ cd probe && bash sub/main.bash
BASH_SOURCE=('sub/../lib.bash' 'sub/main.bash')

stack.bash therefore ships $PWD with the walk, and Source::File is that joined with what bash said: absolute, with nothing resolved, no symlink followed and no .. collapsed.

Bash records what a relative path was relative to at the time of the walk, never at the time the file was sourced. A subject that changed directory in between leaves a path that resolves to nothing, which is what missing reports. The path was true when it was written; a reading reports it as it chooses, and a rig whose reading outlives the run keeps its own workspace so the instrument's frames stay readable, as rigs.md covers.

Because skip >= 1, the i - 1 index above is in range for every reported frame, so the off-by-one is unrepresentable rather than guarded.

The argument stack

BASH_ARGV is one flat stack of words, and BASH_ARGC[i] is the width of frame i's group in it. A group's offset is the sum of the widths before it, and its contents are stored reversed. Summing forward and reading each group backward gives the arguments in the order the call was written.

When arguments are absent

BASH_ARGC aligns 1:1 with FUNCNAME only where the shell was recording. Turn extdebug on part-way and it is short, and short means every width belongs to a different frame.

Alignment is the test rather than shopt -q, and an unaligned record is carried as absent. Frame::args is therefore an Option, where None is not recorded and Some([]) is called with none. A tool that never wants arguments omits the two sections entirely and gets the same None.

A record that lines up but claims more arguments than were sent is corrupt, and fails the run.

Columns rather than rows

An instrument could assemble whole frames in bash and ship them as an array of arrays. That costs one more level of @Q, which re-escapes every quote, and a walk over BASH_ARGV written in bash. Measured at depth 8 with three arguments per frame, 4000 iterations, against an empty-loop floor of 2.7 µs:

µs/oppayload bytes
assembling rows, with the argument walk201522
six raw ${arr[*]@Q} expansions21314

The columns are also closer to what bash keeps. BASH_ARGC plus BASH_ARGV is a width-prefixed flat word stream, which is LinkedArr's shape. Shipping them as they are puts the index arithmetic where the compiler can see it, and where it is checked without running bash.

Who uses it

Any word that reports where a shell is. It reaches the walk through stack::with_walk, which puts stack.bash in front of the rig's definitions in Rig::bash, and it passes its own instrument depth: one frame for the word and one for the walk. Each tool's own book lists which words it defines and when they record arguments.

See also