Skip to main content

bash_interop/stack/
columns.rs

1//! The sections `stack.bash` writes, and the index arithmetic that undoes
2//! them.
3//!
4//! Bash keeps a stack in five parallel arrays and the instrument ships all
5//! five as they are — no slicing, no walk. Three things have to be undone, and
6//! all three are arithmetic:
7//!
8//! ```text
9//! FUNCNAME     ('__bc_stack' 'BASHCAP' 'f__C' 'f__B' 'main')
10//! BASH_SOURCE  (…)                                            aligned 1:1
11//! BASH_LINENO  ('4' '8' '9' '10' '0')                          shifted by one
12//! BASH_ARGC    ('2' '0' '0' '0' '1')                           aligned 1:1
13//! BASH_ARGV    ('2' 'payload' 'x')             one flat stack, groups reversed
14//! ```
15//!
16//! - **`skip`** — the leading frames belong to the instrument, not the
17//!   subject. It is at least 1, the emitter's own.
18//! - **the line shift** — `BASH_LINENO[i]` is where frame `i` *was called
19//!   from*, so where frame `i` is *executing* is `BASH_LINENO[i - 1]`. Since
20//!   `skip >= 1`, that index is in range for every reported frame.
21//! - **the argument stack** — `BASH_ARGC[i]` is the width of group `i`, whose
22//!   offset is the sum of the widths before it, and whose contents are
23//!   reversed within the group.
24//!
25//! The shift leaves `BASH_LINENO[n - 1]` over, and that cell is the whole of
26//! the last thing to undo. It is where the walk itself was entered. Bash
27//! pushes a frame for the top level of a script file and for nothing else, so
28//! for a script it is `0` — `main` was called from nowhere — and for a shell
29//! given a `-c` command line or fed on standard input it is a real line, the
30//! one frame `FUNCNAME` never names. A subject function called `main` does not
31//! change it either way.
32//!
33//! The columns say nothing about the shell they were taken in, and one of
34//! bash's own words needs it: `$0` stands in `BASH_SOURCE` for code bash was
35//! given rather than read from a file. So [`Columns::frames`] is handed the
36//! account that shell gave of itself when it joined, rather than shipping a
37//! fact that cannot change with every walk that can.
38
39use std::path::Path;
40
41use crate::failure::{Doing, Failure};
42use crate::rig::field;
43use crate::shell::Shell;
44use bash_strings::parse_array;
45
46use super::{Frame, Site, Source, Stack};
47
48/// `BASH_ARGC` and `BASH_ARGV`, as one instrument reported them.
49pub struct Args<'a> {
50    pub argc: &'a str,
51    pub argv: &'a str,
52}
53
54/// One frame walk, in the sections `stack.bash` writes.
55pub struct Columns<'a> {
56    /// How many leading frames belong to the instrument rather than the
57    /// subject. At least one — the emitter's own.
58    pub skip: usize,
59
60    /// The sending shell's `$PWD`, which a relative `BASH_SOURCE` is relative
61    /// to as far as anything can know.
62    pub pwd: &'a str,
63
64    pub funcs: &'a str,
65    pub sources: &'a str,
66    pub lines: &'a str,
67
68    /// Absent where an instrument does not report arguments at all.
69    pub args: Option<Args<'a>>,
70}
71
72impl<'a> Columns<'a> {
73    /// The sections out of a message's `key value` payload, which is the
74    /// shape `stack.bash` appends them in.
75    pub fn of(words: &'a [String]) -> Result<Self, Failure> {
76        let at = |key: &str| field(words, key).ok_or_else(|| broken(format!("no {key:?} section")));
77
78        let skip = at("skip")?;
79
80        Ok(Self {
81            skip: skip
82                .parse()
83                .map_err(|_| broken(format!("skip {skip:?} is not a count")))?,
84            pwd: at("pwd")?,
85            funcs: at("funcs")?,
86            sources: at("sources")?,
87            lines: at("lines")?,
88            args: match (
89                field(words, "argc"),
90                field(words, "argv"),
91            ) {
92                (Some(argc), Some(argv)) => Some(Args { argc, argv }),
93                (None, None) => None,
94                _ => {
95                    return Err(broken(
96                        "one of \"argc\"/\"argv\" without the other",
97                    ));
98                }
99            },
100        })
101    }
102
103    /// The subject's walk, read against the shell it was taken in.
104    ///
105    /// `shell` is what that shell said of itself when it joined, and it is
106    /// needed because `BASH_SOURCE` alone cannot say what its own words mean:
107    /// `$0` is a path in one shell and a stand-in for code in another.
108    pub fn frames(&self, shell: &Shell) -> Result<Stack, Failure> {
109        let column = |name: &str, text: &str| parse_array(text).doing(|| format!("reading the {name:?} column"));
110
111        let funcs = column("funcs", self.funcs)?;
112        let sources = column("sources", self.sources)?;
113        let lines = column("lines", self.lines)?;
114
115        // Bash keeps these three at one length, always.
116        if funcs.len() != sources.len() || funcs.len() != lines.len() {
117            return Err(broken(format!(
118                "columns of {} funcs, {} sources and {} lines",
119                funcs.len(),
120                sources.len(),
121                lines.len()
122            )));
123        }
124
125        // At least the emitter's own frame, and never past the end. Equal to
126        // the end is every reported frame being the instrument's, which happens
127        // where bash pushed none of its own — the entry line is what is left.
128        if self.skip < 1 || self.skip > funcs.len() {
129            return Err(broken(format!(
130                "skip {} of {} frames",
131                self.skip,
132                funcs.len()
133            )));
134        }
135
136        let numbered = |what: &str, text: &str| {
137            text.parse::<u32>()
138                .map_err(|_| broken(format!("{what} {text:?}")))
139        };
140        let entered_at = numbered("entry line", &lines[funcs.len() - 1])?;
141
142        let arguments = match &self.args {
143            Some(args) => arguments(args, funcs.len())?,
144            None => None,
145        };
146
147        let pwd = Path::new(self.pwd);
148        let mut frames: Vec<Frame> = (self.skip..funcs.len())
149            .map(|at| {
150                Ok(Frame {
151                    site: Site::of(&funcs[at]),
152                    source: Source::of(&sources[at], pwd, shell),
153                    // Where this frame is executing: the call site of the one
154                    // below it.
155                    lineno: numbered("line number", &lines[at - 1])?,
156                    args: arguments.as_ref().map(|groups| groups[at].clone()),
157                })
158            })
159            .collect::<Result<_, Failure>>()?;
160
161        // The frame bash did not push, outermost and last. `BASH_ARGC` has no
162        // group for it, so its arguments are absent in the field's own sense.
163        if entered_at != 0 {
164            frames.push(Frame {
165                site: Site::Shell,
166                source: Source::Shell,
167                lineno: entered_at,
168                args: None,
169            });
170        }
171
172        Stack::of(frames).ok_or_else(|| broken("a walk with no frames"))
173    }
174}
175
176/// One group per frame, in the order each call was written.
177///
178/// `BASH_ARGC` aligns 1:1 with `FUNCNAME` only where the shell was recording
179/// arguments; enabling `extdebug` part-way leaves it short, and short means
180/// every width belongs to a different frame. Alignment is the test, and an
181/// unaligned record is **absent** rather than wrong — which is what keeps
182/// "not recorded" distinct from "called with none".
183fn arguments(args: &Args<'_>, frames: usize) -> Result<Option<Vec<Vec<String>>>, Failure> {
184    let widths = parse_array(args.argc).doing(|| "reading the \"argc\" column".to_string())?;
185    if widths.len() != frames {
186        return Ok(None);
187    }
188
189    let flat = parse_array(args.argv).doing(|| "reading the \"argv\" column".to_string())?;
190    let mut groups = Vec::with_capacity(frames);
191    let mut from = 0usize;
192
193    for width in &widths {
194        let width: usize = width
195            .parse()
196            .map_err(|_| broken(format!("argument count {width:?}")))?;
197        let upto = from
198            .checked_add(width)
199            .filter(|&upto| upto <= flat.len())
200            .ok_or_else(|| {
201                broken(format!(
202                    "a group of {width} past {} arguments",
203                    flat.len()
204                ))
205            })?;
206
207        // A group is reversed within itself, so counting back down undoes it.
208        groups.push(flat[from..upto].iter().rev().cloned().collect());
209        from = upto;
210    }
211
212    if from != flat.len() {
213        return Err(broken(format!(
214            "{} arguments belong to no frame",
215            flat.len() - from
216        )));
217    }
218
219    Ok(Some(groups))
220}
221
222fn broken(what: impl Into<String>) -> Failure {
223    Failure::new("reading a call stack", what.into())
224}