Skip to main content

starbase_shell/
quoter.rs

1use crate::helpers::{get_var_regex, get_var_regex_bytes, quotable_into_string};
2use std::collections::HashMap;
3use std::sync::Arc;
4
5pub use shell_quote::Quotable;
6
7pub fn default_escape_chars() -> HashMap<char, &'static str> {
8    HashMap::from_iter([
9        // Zero byte
10        ('\0', "\x00"),
11        // Bell
12        ('\x07', "\\a"),
13        // Backspace
14        ('\x08', "\\b"),
15        // Horizontal tab
16        ('\x09', "\\t"),
17        ('\t', "\\t"),
18        // Newline
19        ('\x0A', "\\n"),
20        ('\n', "\\n"),
21        // Vertical tab
22        ('\x0B', "\\v"),
23        // Form feed
24        ('\x0C', "\\f"),
25        // Carriage return
26        ('\x0D', "\\r"),
27        ('\r', "\\r"),
28        // Escape
29        ('\x1B', "\\e"),
30        // Double quote
31        ('"', "\\\""),
32        // Backslash
33        ('\\', "\\\\"),
34    ])
35}
36
37pub fn apply_quote(
38    value: Quotable<'_>,
39    quotes: (&str, &str),
40    replacements: &HashMap<char, &str>,
41) -> String {
42    let value = quotable_into_string(value);
43    let (open, close) = quotes;
44
45    let mut out = String::with_capacity(open.len() + value.len() + close.len());
46    out.push_str(open);
47
48    for ch in value.chars() {
49        if let Some(replacement) = replacements.get(&ch) {
50            out.push_str(replacement);
51        } else {
52            out.push(ch);
53        }
54    }
55
56    out.push_str(close);
57    out
58}
59
60/// Types of syntax to check for to determine quoting.
61pub enum Syntax {
62    Symbol(String),
63    Pair(String, String),
64}
65
66/// Options for [`Quoter`].
67pub struct QuoterOptions<'a> {
68    /// List of start and end quotes for strings.
69    /// The boolean indicates whether the quotes should be used for expansion or not.
70    pub quote_pairs: Vec<(String, String, bool)>,
71
72    /// List of syntax and characters that must be quoted for expansion.
73    pub quoted_syntax: Vec<Syntax>,
74
75    /// List of syntax and characters that must not be quoted.
76    pub unquoted_syntax: Vec<Syntax>,
77
78    /// Handler to apply quoting for non-expansion, typically for single quotes.
79    pub on_quote: Option<Arc<dyn Fn(Quotable<'a>) -> String>>,
80
81    /// Handler to apply quoting for expansion, typically for double quotes.
82    pub on_quote_expansion: Option<Arc<dyn Fn(Quotable<'a>) -> String>>,
83
84    /// Map of characters to replace with during non-expansion, typically for escaping.
85    pub replacements: HashMap<char, &'a str>,
86
87    /// Map of characters to replace with during expansion, typically for escaping.
88    pub replacements_expansion: HashMap<char, &'a str>,
89}
90
91impl Default for QuoterOptions<'_> {
92    fn default() -> Self {
93        Self {
94            quote_pairs: vec![
95                ("'".into(), "'".into(), false),
96                ("\"".into(), "\"".into(), true),
97            ],
98            // https://www.gnu.org/software/bash/manual/bash.html#Shell-Expansions
99            quoted_syntax: vec![
100                // param
101                Syntax::Pair("${".into(), "}".into()),
102                // command
103                Syntax::Pair("$(".into(), ")".into()),
104                // arithmetic
105                Syntax::Pair("$((".into(), "))".into()),
106            ],
107            unquoted_syntax: vec![
108                // brace
109                Syntax::Pair("{".into(), "}".into()),
110                // process
111                Syntax::Pair("<(".into(), ")".into()),
112                Syntax::Pair(">(".into(), ")".into()),
113                // file, glob
114                Syntax::Symbol("**".into()),
115                Syntax::Symbol("*".into()),
116                Syntax::Symbol("?".into()),
117                Syntax::Pair("[".into(), "]".into()),
118                Syntax::Pair("?(".into(), ")".into()),
119                Syntax::Pair("*(".into(), ")".into()),
120                Syntax::Pair("+(".into(), ")".into()),
121                Syntax::Pair("@(".into(), ")".into()),
122                Syntax::Pair("!(".into(), ")".into()),
123            ],
124            on_quote: None,
125            on_quote_expansion: None,
126            replacements: HashMap::default(),
127            replacements_expansion: default_escape_chars(),
128        }
129    }
130}
131
132/// A utility for quoting a string.
133pub struct Quoter<'a> {
134    data: Quotable<'a>,
135    options: QuoterOptions<'a>,
136}
137
138impl<'a> Quoter<'a> {
139    /// Create a new instance.
140    pub fn new(data: impl Into<Quotable<'a>>, options: QuoterOptions<'a>) -> Quoter<'a> {
141        Self {
142            data: data.into(),
143            options,
144        }
145    }
146
147    /// Return true if the provided string is a bareword.
148    pub fn is_bareword(&self) -> bool {
149        fn is_bare(ch: u8) -> bool {
150            !ch.is_ascii_whitespace() && (ch.is_ascii_alphanumeric() || ch == b'_')
151        }
152
153        match &self.data {
154            Quotable::Bytes(bytes) => bytes.iter().all(|ch| is_bare(*ch)),
155            Quotable::Text(text) => text.chars().all(|ch| is_bare(ch as u8)),
156        }
157    }
158
159    /// Return true if the provided string is empty.
160    pub fn is_empty(&self) -> bool {
161        match &self.data {
162            Quotable::Bytes(bytes) => bytes.is_empty(),
163            Quotable::Text(text) => text.is_empty(),
164        }
165    }
166
167    /// Return true if the provided string is already quoted.
168    pub fn is_quoted(&self) -> bool {
169        for (sq, eq, _) in &self.options.quote_pairs {
170            match &self.data {
171                Quotable::Bytes(bytes) => {
172                    if bytes.starts_with(sq.as_bytes()) && bytes.ends_with(eq.as_bytes()) {
173                        return true;
174                    }
175                }
176                Quotable::Text(text) => {
177                    if text.starts_with(sq) && text.ends_with(eq) {
178                        return true;
179                    }
180                }
181            };
182        }
183
184        false
185    }
186
187    /// Maybe quote the provided string depending on certain conditions.
188    /// If it's already quoted, do nothing. If it requires expansion,
189    /// use shell-specific quotes. Otherwise quote as normal.
190    pub fn maybe_quote(self) -> String {
191        if self.is_empty() {
192            let pair = &self.options.quote_pairs[0];
193
194            return format!("{}{}", pair.0, pair.1);
195        }
196
197        if self.is_quoted() || self.is_bareword() {
198            return quotable_into_string(self.data);
199        }
200
201        if self.requires_expansion() {
202            return self.quote_expansion();
203        }
204
205        if self.requires_unquoted() {
206            return quotable_into_string(self.data);
207        }
208
209        self.quote()
210    }
211
212    /// Quote the provided string for expansion, substition, etc.
213    /// This assumes the string is not already quoted.
214    pub fn quote_expansion(self) -> String {
215        if let Some(on_quote_expansion) = &self.options.on_quote_expansion {
216            return on_quote_expansion(self.data);
217        }
218
219        let (open, close, _) = self
220            .options
221            .quote_pairs
222            .iter()
223            .find(|(_, _, is_expansion)| *is_expansion)
224            .or(self.options.quote_pairs.last())
225            .unwrap();
226
227        apply_quote(
228            self.data,
229            (open, close),
230            &self.options.replacements_expansion,
231        )
232    }
233
234    /// Quote the provided string.
235    /// This assumes the string is not already quoted.
236    pub fn quote(self) -> String {
237        if let Some(on_quote) = &self.options.on_quote {
238            return on_quote(self.data);
239        }
240
241        let (open, close, _) = self
242            .options
243            .quote_pairs
244            .iter()
245            .find(|(_, _, is_expansion)| !is_expansion)
246            .or(self.options.quote_pairs.first())
247            .unwrap();
248
249        apply_quote(self.data, (open, close), &self.options.replacements)
250    }
251
252    /// Return true if the provided string requires expansion.
253    pub fn requires_expansion(&self) -> bool {
254        // Unique syntax
255        if quotable_contains_syntax(&self.data, &self.options.quoted_syntax) {
256            return true;
257        }
258
259        // Variables
260        if match &self.data {
261            Quotable::Bytes(bytes) => get_var_regex_bytes().is_match(bytes),
262            Quotable::Text(text) => get_var_regex().is_match(text),
263        } {
264            return true;
265        }
266
267        // Replacements / escape chars
268        for ch in self.options.replacements_expansion.keys() {
269            match &self.data {
270                Quotable::Bytes(bytes) => {
271                    if bytes.contains(&(*ch as u8)) {
272                        return true;
273                    }
274                }
275                Quotable::Text(text) => {
276                    if text.contains(*ch) {
277                        return true;
278                    }
279                }
280            };
281        }
282
283        false
284    }
285
286    /// Return true if the provided string must be unquoted.
287    pub fn requires_unquoted(&self) -> bool {
288        quotable_contains_syntax(&self.data, &self.options.unquoted_syntax)
289    }
290}
291
292fn quotable_contains_syntax(data: &Quotable<'_>, syntaxes: &[Syntax]) -> bool {
293    for syntax in syntaxes {
294        match data {
295            Quotable::Bytes(bytes) => {
296                match syntax {
297                    Syntax::Symbol(symbol) => {
298                        let sbytes = symbol.as_bytes();
299
300                        if bytes.windows(sbytes.len()).any(|chunk| chunk == sbytes) {
301                            return true;
302                        }
303                    }
304                    Syntax::Pair(open, close) => {
305                        let obytes = open.as_bytes();
306                        let cbytes = close.as_bytes();
307
308                        if let Some(o) = bytes
309                            .windows(obytes.len())
310                            .position(|chunk| chunk == obytes)
311                        {
312                            if bytes[o..]
313                                .windows(cbytes.len())
314                                .any(|chunk| chunk == cbytes)
315                            {
316                                return true;
317                            }
318                        }
319                    }
320                };
321            }
322            Quotable::Text(text) => {
323                match syntax {
324                    Syntax::Symbol(symbol) => {
325                        if text.contains(symbol) {
326                            return true;
327                        }
328                    }
329                    Syntax::Pair(open, close) => {
330                        if let Some(o) = text.find(open) {
331                            if text[o..].contains(close) {
332                                return true;
333                            }
334                        }
335                    }
336                };
337            }
338        };
339    }
340
341    false
342}