Skip to main content

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