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}