zsh/extensions/bash_prompt.rs
1//! bash `$PS1` prompt-escape expansion for the `--bash` drop-in.
2//!
3//! **zshrs-only — no zsh C counterpart.** zsh prompts use `%` escapes;
4//! bash prompts use BACKSLASH escapes (`\u@\h \w\$ `), and the two sets
5//! are disjoint. Without this, every bash user's prompt rendered as
6//! literal backslash text — `[\u@\h \W]\$` on screen — which makes the
7//! `--bash` drop-in unusable as a real login shell, since `PS1=` is the
8//! one line essentially every `.bashrc` sets.
9//!
10//! Rather than reimplement host / cwd / time / job-count lookups, this
11//! TRANSLATES bash's escape set into the equivalent zsh prompt string and
12//! hands it to the existing, tested `%`-expander in `ported::prompt`. The
13//! mapping is bash(1) PROMPTING against zshall(1) EXPANSION OF PROMPT
14//! SEQUENCES:
15//!
16//! | bash | zsh | meaning |
17//! |------|-----|---------|
18//! | `\u` | `%n` | user name |
19//! | `\h` | `%m` | host up to the first `.` |
20//! | `\H` | `%M` | full host name |
21//! | `\w` | `%~` | `$PWD`, `$HOME` as `~` |
22//! | `\W` | `%1~` | basename of `$PWD`, `$HOME` as `~` |
23//! | `\$` | `%(!.#.$)` | `#` when euid 0, else `$` — NOT zsh's `%#`, which yields `%` |
24//! | `\!` | `%!` | history number |
25//! | `\j` | `%j` | jobs the shell is managing |
26//! | `\l` | `%l` | tty basename |
27//! | `\t` | `%D{%H:%M:%S}` | 24-hour time with seconds |
28//! | `\T` | `%D{%I:%M:%S}` | 12-hour time with seconds |
29//! | `\@` | `%D{%I:%M %p}` | 12-hour am/pm |
30//! | `\A` | `%D{%H:%M}` | 24-hour, no seconds |
31//! | `\d` | `%D{%a %b %d}` | "Tue May 26" |
32//! | `\D{f}` | `%D{f}` | strftime, passed through |
33//! | `\[` `\]` | `%{` `%}` | begin / end zero-width sequence |
34//!
35//! `\s`, `\v`, `\V`, `\#`, `\a`, `\e`, `\n`, `\r`, `\\` and `\nnn` are
36//! substituted literally here — they need no prompt-engine support.
37//!
38//! Two details that are easy to get wrong and are handled explicitly:
39//!
40//! * A literal `%` in a bash prompt must be doubled to `%%`, or zsh's
41//! expander eats it. `PS1='100%% done'` in bash prints `100% done`;
42//! an untranslated `%` would have been read as an escape introducer.
43//! * `\$` is NOT zsh's `%#`. zsh prints `%` for an unprivileged user
44//! where bash prints `$`, so the ternary `%(!.#.$)` is used instead.
45//!
46//! Not modelled: `PROMPT_DIRTRIM` (bash truncates `\w` to that many
47//! trailing components), and the ordering nuance that bash expands
48//! backslash escapes BEFORE the `promptvars` word expansion while this
49//! runs the word expansion first — visible only when a command
50//! substitution's OUTPUT itself contains `%` or a backslash escape.
51
52/// Translate a bash prompt string into the equivalent zsh prompt string.
53///
54/// The result is fed to `ported::prompt::expand_prompt`, so every escape
55/// that has a zsh counterpart keeps zsh's tested implementation.
56pub fn translate(ps: &str) -> String {
57 let mut out = String::with_capacity(ps.len() + 16);
58 let mut chars = ps.chars().peekable();
59 while let Some(c) = chars.next() {
60 // A literal `%` must survive zsh's expander untouched.
61 if c == '%' {
62 out.push_str("%%");
63 continue;
64 }
65 if c != '\\' {
66 out.push(c);
67 continue;
68 }
69 let Some(esc) = chars.next() else {
70 // A trailing lone backslash is literal, as in bash.
71 out.push('\\');
72 break;
73 };
74 match esc {
75 'u' => out.push_str("%n"),
76 'h' => out.push_str("%m"),
77 'H' => out.push_str("%M"),
78 'w' => out.push_str("%~"),
79 'W' => out.push_str("%1~"),
80 'j' => out.push_str("%j"),
81 'l' => out.push_str("%l"),
82 '!' => out.push_str("%!"),
83 // bash: `#` for euid 0, `$` otherwise. zsh's `%#` prints `%`
84 // for the unprivileged case, so the ternary is required.
85 '$' => out.push_str("%(!.#.$)"),
86 // bash's command number. zsh has no counterpart; the history
87 // number is the closest observable and matches for a session
88 // that has not re-read its history file.
89 '#' => out.push_str("%!"),
90 't' => out.push_str("%D{%H:%M:%S}"),
91 'T' => out.push_str("%D{%I:%M:%S}"),
92 '@' => out.push_str("%D{%I:%M %p}"),
93 'A' => out.push_str("%D{%H:%M}"),
94 // bash zero-pads the day: `\d` on the 4th prints "Fri Sep 04",
95 // not "Fri Sep 4". `%e` (space-padded) was wrong; `%d` is the
96 // zero-padded field bash uses. Caught by the prompt fuzz.
97 'd' => out.push_str("%D{%a %b %d}"),
98 // `\D{format}` — pass the format straight through to `%D{…}`.
99 // A `\D` with no brace is bash's default `%X` locale time.
100 'D' => {
101 if chars.peek() == Some(&'{') {
102 chars.next();
103 let mut fmt = String::new();
104 for f in chars.by_ref() {
105 if f == '}' {
106 break;
107 }
108 fmt.push(f);
109 }
110 out.push_str("%D{");
111 out.push_str(&fmt);
112 out.push('}');
113 } else {
114 out.push_str("%D{%X}");
115 }
116 }
117 // Zero-width regions: bash's `\[`/`\]` are zsh's `%{`/`%}`.
118 '[' => out.push_str("%{"),
119 ']' => out.push_str("%}"),
120 'n' => out.push('\n'),
121 'r' => out.push('\r'),
122 'a' => out.push('\u{07}'),
123 'e' => out.push('\u{1b}'),
124 '\\' => out.push('\\'),
125 's' => out.push_str(&shell_name()),
126 'v' => out.push_str(&format!(
127 "{}.{}",
128 crate::dash_mode::BASH_VERSION_MAJOR,
129 crate::dash_mode::BASH_VERSION_MINOR
130 )),
131 'V' => out.push_str(&format!(
132 "{}.{}.{}",
133 crate::dash_mode::BASH_VERSION_MAJOR,
134 crate::dash_mode::BASH_VERSION_MINOR,
135 crate::dash_mode::BASH_VERSION_PATCH
136 )),
137 // `\nnn` — up to three octal digits, as one character.
138 '0'..='7' => {
139 let mut oct = String::from(esc);
140 while oct.len() < 3 {
141 match chars.peek() {
142 Some(d @ '0'..='7') => {
143 oct.push(*d);
144 chars.next();
145 }
146 _ => break,
147 }
148 }
149 match u32::from_str_radix(&oct, 8).ok().and_then(char::from_u32) {
150 Some(ch) if ch == '%' => out.push_str("%%"),
151 Some(ch) => out.push(ch),
152 None => out.push_str(&oct),
153 }
154 }
155 // bash leaves an unknown escape as backslash + the character.
156 other => {
157 out.push('\\');
158 if other == '%' {
159 out.push_str("%%");
160 } else {
161 out.push(other);
162 }
163 }
164 }
165 }
166 out
167}
168
169/// `\s` — bash(1): "the name of the shell, the basename of `$0`".
170fn shell_name() -> String {
171 crate::ported::params::getsparam("0")
172 .map(|z| {
173 let bare = z.trim_start_matches('-');
174 std::path::Path::new(bare)
175 .file_name()
176 .and_then(|n| n.to_str())
177 .unwrap_or(bare)
178 .to_string()
179 })
180 .unwrap_or_else(|| "bash".to_string())
181}
182
183thread_local! {
184 /// Set while a bash prompt is being translated and expanded, so the
185 /// `%` sequences [`translate`] produces are not translated a second
186 /// time by a nested `expand_prompt` (`%D{…}`, PROMPTSUBST, …).
187 static IN_TRANSLATION: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
188}
189
190/// Clears [`IN_TRANSLATION`] when the prompt expansion it guards returns.
191pub struct TranslationGuard;
192
193impl Drop for TranslationGuard {
194 fn drop(&mut self) {
195 IN_TRANSLATION.with(|c| c.set(false));
196 }
197}
198
199/// The hook `ported::prompt::expand_prompt` calls on entry.
200///
201/// Returns the translated prompt plus a guard to hold for the rest of the
202/// expansion, or `None` when this is not a bash-mode prompt (or is a
203/// nested expansion of one already translated), in which case the caller
204/// expands the string unchanged.
205pub fn begin_translation(s: &str) -> Option<(String, TranslationGuard)> {
206 if !crate::dash_mode::bash_mode() || IN_TRANSLATION.with(|c| c.get()) {
207 return None;
208 }
209 let translated = translate(s);
210 IN_TRANSLATION.with(|c| c.set(true));
211 Some((translated, TranslationGuard))
212}
213
214/// Expand a bash prompt string the way bash's `${var@P}` does: translate
215/// the escapes, then run the shared prompt expander.
216pub fn expand(ps: &str) -> String {
217 // `expand_prompt` performs the translation itself, through
218 // `begin_translation`. Translating here as well would double it —
219 // `\u` would become `%n` and then `%%n`, which expands to the literal
220 // text `%n` instead of the user name.
221 let expanded = crate::ported::prompt::expand_prompt(ps);
222 // `\[` / `\]` become the readline "ignore this run when measuring
223 // width" markers (`\x01` / `\x02`) on the way to the line editor, but
224 // `${v@P}` hands the user a plain string: bash's own `${PS1@P}` emits
225 // the escape sequence with no markers around it. Strip them here so
226 // the reported string matches byte for byte; the display path keeps
227 // its markers because it never goes through this function.
228 if expanded.contains(['\u{01}', '\u{02}']) {
229 expanded
230 .chars()
231 .filter(|c| *c != '\u{01}' && *c != '\u{02}')
232 .collect()
233 } else {
234 expanded
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 /// The mappings that are pure text, checked without a shell.
243 #[test]
244 fn escapes_translate_to_their_zsh_counterparts() {
245 assert_eq!(translate(r"\u@\h"), "%n@%m");
246 assert_eq!(translate(r"\u@\H"), "%n@%M");
247 assert_eq!(translate(r"\w"), "%~");
248 assert_eq!(translate(r"\W"), "%1~");
249 assert_eq!(translate(r"\j \l \!"), "%j %l %!");
250 assert_eq!(
251 translate(r"\t|\T|\A"),
252 "%D{%H:%M:%S}|%D{%I:%M:%S}|%D{%H:%M}"
253 );
254 assert_eq!(translate(r"\D{%F}"), "%D{%F}");
255 // Zero-padded day, matching bash's own `\d` output.
256 assert_eq!(translate(r"\d"), "%D{%a %b %d}");
257 assert_eq!(translate(r"\D"), "%D{%X}");
258 assert_eq!(translate(r"\[\e[0m\]"), "%{\u{1b}[0m%}");
259 }
260
261 /// `\$` must be the `#`/`$` ternary, NOT zsh's `%#` — zsh's prints a
262 /// literal `%` for an unprivileged user, which is the wrong character
263 /// and the most visible thing on a bash prompt.
264 #[test]
265 fn dollar_escape_is_not_zsh_percent_hash() {
266 let t = translate(r"\$");
267 assert_eq!(t, "%(!.#.$)");
268 assert!(!t.contains("%#"), "must not map onto zsh's %# ");
269 }
270
271 /// A literal `%` has to be doubled or zsh's expander consumes it.
272 /// `PS1='100% done'` is a real thing people write.
273 #[test]
274 fn literal_percent_is_doubled() {
275 assert_eq!(translate("100% done"), "100%% done");
276 assert_eq!(translate(r"\u 50%"), "%n 50%%");
277 // …including one produced by an octal escape.
278 assert_eq!(translate(r"\045"), "%%");
279 }
280
281 /// Octal escapes, including the short forms and the boundary where a
282 /// following digit is NOT part of the escape.
283 #[test]
284 fn octal_escapes_decode() {
285 assert_eq!(translate(r"\101"), "A");
286 assert_eq!(translate(r"\1011"), "A1");
287 assert_eq!(translate(r"\7"), "\u{07}");
288 }
289
290 /// Backslash handling at the edges: a doubled backslash is literal, a
291 /// trailing lone one is literal, and an unknown escape keeps both
292 /// characters exactly as bash leaves them.
293 #[test]
294 fn backslash_edge_cases_match_bash() {
295 assert_eq!(translate(r"\\"), r"\");
296 assert_eq!(translate(r"end\"), r"end\");
297 assert_eq!(translate(r"\q"), r"\q");
298 assert_eq!(translate(r"\z\u"), r"\z%n");
299 }
300
301 /// The C-style control escapes are emitted as the characters
302 /// themselves, so a prompt can embed a newline or an ESC directly.
303 #[test]
304 fn control_escapes_become_characters() {
305 assert_eq!(translate(r"a\nb"), "a\nb");
306 assert_eq!(translate(r"a\rb"), "a\rb");
307 assert_eq!(translate(r"\a"), "\u{07}");
308 assert_eq!(translate(r"\e"), "\u{1b}");
309 }
310}