Skip to main content

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}