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.
158pub fn parse(s: &str) -> Result<Vec<PromptPiece>, error::WordParseError> {
159    let result = prompt_parser::prompt(s).map_err(|e| error::WordParseError::Prompt(e.into()))?;
160    Ok(result)
161}
162
163#[cfg(test)]
164mod tests {
165    use anyhow::Result;
166    use pretty_assertions::assert_eq;
167
168    use super::*;
169
170    #[test]
171    fn basic_prompt() -> Result<()> {
172        assert_eq!(
173            parse(r"\u@\h:\w$ ")?,
174            &[
175                PromptPiece::CurrentUser,
176                PromptPiece::Literal("@".to_owned()),
177                PromptPiece::Hostname {
178                    only_up_to_first_dot: true
179                },
180                PromptPiece::Literal(":".to_owned()),
181                PromptPiece::CurrentWorkingDirectory {
182                    tilde_replaced: true,
183                    basename: false
184                },
185                PromptPiece::Literal("$ ".to_owned()),
186            ]
187        );
188
189        Ok(())
190    }
191
192    #[test]
193    fn brackets_and_vars() -> Result<()> {
194        assert_eq!(
195            parse(r"\[$foo\]\u > ")?,
196            &[
197                PromptPiece::StartNonPrintingSequence,
198                PromptPiece::Literal("$foo".to_owned()),
199                PromptPiece::EndNonPrintingSequence,
200                PromptPiece::CurrentUser,
201                PromptPiece::Literal(" > ".to_owned()),
202            ]
203        );
204
205        Ok(())
206    }
207}