Skip to main content

shrub_parser/
prompt.rs

1//! Parser for shell prompt syntax (e.g., `PS1`).
2
3use crate::error;
4
5/// A piece of a prompt string.
6#[derive(Clone, Debug)]
7#[cfg_attr(
8    any(test, feature = "serde"),
9    derive(PartialEq, Eq, serde::Serialize, serde::Deserialize)
10)]
11pub enum PromptPiece {
12    /// An ASCII character.
13    AsciiCharacter(u32),
14    /// A backslash character.
15    Backslash,
16    /// The bell character.
17    BellCharacter,
18    /// A carriage return character.
19    CarriageReturn,
20    /// The current command number.
21    CurrentCommandNumber,
22    /// The current history number.
23    CurrentHistoryNumber,
24    /// The name of the current user.
25    CurrentUser,
26    /// Path to the current working directory.
27    CurrentWorkingDirectory {
28        /// Whether or not to apply tilde-replacement before expanding.
29        tilde_replaced: bool,
30        /// Whether or not to only expand to the basename of the directory.
31        basename: bool,
32    },
33    /// The current date, using the given format.
34    Date(PromptDateFormat),
35    /// The dollar or pound character.
36    DollarOrPound,
37    /// Special marker indicating the end of a non-printing sequence of
38    /// characters.
39    EndNonPrintingSequence,
40    /// The escape character.
41    EscapeCharacter,
42    /// An escaped sequence not otherwise recognized.
43    EscapedSequence(String),
44    /// The hostname of the system.
45    Hostname {
46        /// Whether or not to include only up to the first dot of the name.
47        only_up_to_first_dot: bool,
48    },
49    /// A literal string.
50    Literal(String),
51    /// A newline character.
52    Newline,
53    /// The number of actively managed jobs.
54    NumberOfManagedJobs,
55    /// The base name of the shell.
56    ShellBaseName,
57    /// The release of the shell.
58    ShellRelease,
59    /// The version of the shell.
60    ShellVersion,
61    /// Special marker indicating the start of a non-printing sequence of
62    /// characters.
63    StartNonPrintingSequence,
64    /// The base name of the terminal device.
65    TerminalDeviceBaseName,
66    /// The current time, using the given format.
67    Time(PromptTimeFormat),
68}
69
70/// Format for a date in a prompt.
71#[derive(Clone, Debug)]
72#[cfg_attr(
73    any(test, feature = "serde"),
74    derive(PartialEq, Eq, serde::Serialize, serde::Deserialize)
75)]
76pub enum PromptDateFormat {
77    /// A format including weekday, month, and date.
78    WeekdayMonthDate,
79    /// A customer string format.
80    Custom(String),
81}
82
83/// Format for a time in a prompt.
84#[derive(Clone, Debug)]
85#[cfg_attr(
86    any(test, feature = "serde"),
87    derive(PartialEq, Eq, serde::Serialize, serde::Deserialize)
88)]
89pub enum PromptTimeFormat {
90    /// A twelve-hour time format with AM/PM.
91    TwelveHourAM,
92    /// A twelve-hour time format (HHMMSS).
93    TwelveHourHHMMSS,
94    /// A twenty-four-hour time format (HHMM).
95    TwentyFourHourHHMM,
96    /// A twenty-four-hour time format (HHMMSS).
97    TwentyFourHourHHMMSS,
98}
99
100peg::parser! {
101    grammar prompt_parser() for str {
102        pub(crate) rule prompt() -> Vec<PromptPiece> =
103            pieces:prompt_piece()*
104
105        rule prompt_piece() -> PromptPiece =
106            special_sequence() /
107            literal_sequence()
108
109        //
110        // Reference: https://www.gnu.org/software/bash/manual/bash.html#Controlling-the-Prompt
111        //
112        rule special_sequence() -> PromptPiece =
113            "\\a" { PromptPiece::BellCharacter } /
114            "\\A" { PromptPiece::Time(PromptTimeFormat::TwentyFourHourHHMM) } /
115            "\\d" { PromptPiece::Date(PromptDateFormat::WeekdayMonthDate) } /
116            "\\D{" f:date_format() "}" { PromptPiece::Date(PromptDateFormat::Custom(f)) } /
117            "\\e" { PromptPiece::EscapeCharacter } /
118            "\\h" { PromptPiece::Hostname { only_up_to_first_dot: true } } /
119            "\\H" { PromptPiece::Hostname { only_up_to_first_dot: false } } /
120            "\\j" { PromptPiece::NumberOfManagedJobs } /
121            "\\l" { PromptPiece::TerminalDeviceBaseName } /
122            "\\n" { PromptPiece::Newline } /
123            "\\r" { PromptPiece::CarriageReturn } /
124            "\\s" { PromptPiece::ShellBaseName } /
125            "\\t" { PromptPiece::Time(PromptTimeFormat::TwentyFourHourHHMMSS ) } /
126            "\\T" { PromptPiece::Time(PromptTimeFormat::TwelveHourHHMMSS ) } /
127            "\\@" { PromptPiece::Time(PromptTimeFormat::TwelveHourAM ) } /
128            "\\u" { PromptPiece::CurrentUser } /
129            "\\v" { PromptPiece::ShellVersion } /
130            "\\V" { PromptPiece::ShellRelease } /
131            "\\w" { PromptPiece::CurrentWorkingDirectory { tilde_replaced: true, basename: false, } } /
132            "\\W" { PromptPiece::CurrentWorkingDirectory { tilde_replaced: true, basename: true, } } /
133            "\\!" { PromptPiece::CurrentHistoryNumber } /
134            "\\#" { PromptPiece::CurrentCommandNumber } /
135            "\\$" { PromptPiece::DollarOrPound } /
136            "\\" n:octal_number() { PromptPiece::AsciiCharacter(n) } /
137            "\\\\" { PromptPiece::Backslash } /
138            "\\[" { PromptPiece::StartNonPrintingSequence } /
139            "\\]" { PromptPiece::EndNonPrintingSequence } /
140            s:$("\\" [_]) { PromptPiece::EscapedSequence(s.to_owned()) }
141
142        rule literal_sequence() -> PromptPiece =
143            s:$((!special_sequence() [c])+) { PromptPiece::Literal(s.to_owned()) }
144
145        rule date_format() -> String =
146            s:$([c if c != '}']*) { s.to_owned() }
147
148        rule octal_number() -> u32 =
149            s:$(['0'..='7']*<1,3>) {? u32::from_str_radix(s, 8).or(Err("invalid octal number")) }
150    }
151}
152
153/// Parses a shell prompt string.
154///
155/// # Arguments
156///
157/// * `s` - The prompt string to parse.
158///
159/// # Errors
160///
161/// Returns [`crate::WordParseError::Prompt`] if `s` is not a valid prompt
162/// string.
163pub fn parse(s: &str) -> Result<Vec<PromptPiece>, error::WordParseError> {
164    let result = prompt_parser::prompt(s).map_err(|e| error::WordParseError::Prompt(e.into()))?;
165    Ok(result)
166}
167
168#[cfg(test)]
169mod tests {
170    use anyhow::Result;
171    use pretty_assertions::assert_eq;
172
173    use super::*;
174
175    #[test]
176    fn basic_prompt() -> Result<()> {
177        assert_eq!(
178            parse(r"\u@\h:\w$ ")?,
179            &[
180                PromptPiece::CurrentUser,
181                PromptPiece::Literal("@".to_owned()),
182                PromptPiece::Hostname {
183                    only_up_to_first_dot: true
184                },
185                PromptPiece::Literal(":".to_owned()),
186                PromptPiece::CurrentWorkingDirectory {
187                    tilde_replaced: true,
188                    basename: false
189                },
190                PromptPiece::Literal("$ ".to_owned()),
191            ]
192        );
193
194        Ok(())
195    }
196
197    #[test]
198    fn brackets_and_vars() -> Result<()> {
199        assert_eq!(
200            parse(r"\[$foo\]\u > ")?,
201            &[
202                PromptPiece::StartNonPrintingSequence,
203                PromptPiece::Literal("$foo".to_owned()),
204                PromptPiece::EndNonPrintingSequence,
205                PromptPiece::CurrentUser,
206                PromptPiece::Literal(" > ".to_owned()),
207            ]
208        );
209
210        Ok(())
211    }
212}