Skip to main content

f00_format/
quoting.rs

1//! Filename quoting styles matching GNU `ls` (`-b`, `-Q`, `-N`, `--quoting-style`).
2
3use f00_core::QuotingStyle;
4
5/// Quote / escape a display name according to `style`.
6///
7/// When `hide_control` is true and the style is literal/locale, nongraphic
8/// characters are replaced with `?` (GNU `-q`).
9pub fn quote_name(name: &str, style: QuotingStyle, hide_control: bool) -> String {
10    match style {
11        QuotingStyle::Literal => {
12            if hide_control {
13                hide_control_chars(name)
14            } else {
15                name.to_string()
16            }
17        }
18        QuotingStyle::Locale => {
19            // Locale style: escape nongraphic similarly to shell-escape without
20            // forcing shell quotes when not needed; approximate as shell-escape.
21            if needs_shell_escape(name) {
22                shell_escape_quote(name, false)
23            } else if hide_control {
24                hide_control_chars(name)
25            } else {
26                name.to_string()
27            }
28        }
29        QuotingStyle::Shell => shell_quote(name, false),
30        QuotingStyle::ShellAlways => shell_quote(name, true),
31        QuotingStyle::ShellEscape => shell_escape_quote(name, false),
32        QuotingStyle::ShellEscapeAlways => shell_escape_quote(name, true),
33        QuotingStyle::C => c_quote(name, true),
34        QuotingStyle::Escape => c_quote(name, false),
35    }
36}
37
38fn hide_control_chars(name: &str) -> String {
39    name.chars()
40        .map(|c| if is_nongraphic(c) { '?' } else { c })
41        .collect()
42}
43
44fn is_nongraphic(c: char) -> bool {
45    // GNU treats non-printable (including DEL) and non-space whitespace specially.
46    c.is_control() || c == '\u{7f}'
47}
48
49fn is_shell_special(c: char) -> bool {
50    matches!(
51        c,
52        ' ' | '\t'
53            | '\n'
54            | '\''
55            | '"'
56            | '\\'
57            | '|'
58            | '&'
59            | ';'
60            | '('
61            | ')'
62            | '<'
63            | '>'
64            | '$'
65            | '`'
66            | '!'
67            | '*'
68            | '?'
69            | '['
70            | ']'
71            | '#'
72            | '~'
73            | '='
74            | '%'
75            | '{'
76            | '}'
77    ) || c == '\0'
78}
79
80fn needs_shell_quote(name: &str) -> bool {
81    name.is_empty() || name.chars().any(is_shell_special) || name.starts_with('-')
82}
83
84fn needs_shell_escape(name: &str) -> bool {
85    name.is_empty()
86        || name
87            .chars()
88            .any(|c| is_shell_special(c) || is_nongraphic(c))
89}
90
91/// Single-quote shell style; `always` forces quotes.
92fn shell_quote(name: &str, always: bool) -> String {
93    if !always && !needs_shell_quote(name) {
94        return name.to_string();
95    }
96    // 'foo'\''bar' for embedded single quotes.
97    let mut out = String::with_capacity(name.len() + 2);
98    out.push('\'');
99    for c in name.chars() {
100        if c == '\'' {
101            out.push_str("'\\''");
102        } else {
103            out.push(c);
104        }
105    }
106    out.push('\'');
107    out
108}
109
110/// Shell quoting with `$''` C-style escapes for nongraphic chars.
111fn shell_escape_quote(name: &str, always: bool) -> String {
112    let has_nongraphic = name.chars().any(is_nongraphic);
113    if has_nongraphic || (always && needs_shell_escape(name)) {
114        return dollar_quote(name);
115    }
116    if always || needs_shell_quote(name) {
117        return shell_quote(name, true);
118    }
119    name.to_string()
120}
121
122fn dollar_quote(name: &str) -> String {
123    let mut out = String::from("$'");
124    for c in name.chars() {
125        push_c_escape(&mut out, c, true);
126    }
127    out.push('\'');
128    out
129}
130
131/// C-style escapes; surround with `"` when `quoted` (`-Q` / style=c).
132fn c_quote(name: &str, quoted: bool) -> String {
133    let mut out = String::with_capacity(name.len() + 2);
134    if quoted {
135        out.push('"');
136    }
137    for c in name.chars() {
138        push_c_escape(&mut out, c, quoted);
139    }
140    if quoted {
141        out.push('"');
142    }
143    out
144}
145
146fn push_c_escape(out: &mut String, c: char, in_double_quotes: bool) {
147    match c {
148        '\\' => out.push_str("\\\\"),
149        '\n' => out.push_str("\\n"),
150        '\t' => out.push_str("\\t"),
151        '\r' => out.push_str("\\r"),
152        '\x08' => out.push_str("\\b"),
153        '\x0c' => out.push_str("\\f"),
154        '\x0b' => out.push_str("\\v"),
155        '\0' => out.push_str("\\0"),
156        '"' if in_double_quotes => out.push_str("\\\""),
157        '\'' if !in_double_quotes => {
158            // Outside double quotes (escape style / $'') — escape single quote as \'.
159            out.push_str("\\'");
160        }
161        c if is_nongraphic(c) => {
162            let u = c as u32;
163            if u <= 0xff {
164                out.push_str(&format!("\\{u:03o}"));
165            } else {
166                out.push_str(&format!("\\u{u:04x}"));
167            }
168        }
169        c => out.push(c),
170    }
171}
172
173/// Apply quoting for display; used by formatters.
174pub fn display_name(name: &str, style: QuotingStyle, hide_control: bool) -> String {
175    quote_name(name, style, hide_control)
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn literal_passthrough() {
184        assert_eq!(quote_name("hello", QuotingStyle::Literal, false), "hello");
185    }
186
187    #[test]
188    fn hide_control_replaces() {
189        assert_eq!(quote_name("a\nb", QuotingStyle::Literal, true), "a?b");
190    }
191
192    #[test]
193    fn escape_style_c_escapes() {
194        let q = quote_name("a\nb", QuotingStyle::Escape, false);
195        assert_eq!(q, "a\\nb");
196        let q2 = quote_name("x y", QuotingStyle::Escape, false);
197        assert_eq!(q2, "x y");
198    }
199
200    #[test]
201    fn c_style_double_quotes() {
202        let q = quote_name("hello", QuotingStyle::C, false);
203        assert_eq!(q, "\"hello\"");
204        let q2 = quote_name("a\"b", QuotingStyle::C, false);
205        assert_eq!(q2, "\"a\\\"b\"");
206        let q3 = quote_name("a\tb", QuotingStyle::C, false);
207        assert_eq!(q3, "\"a\\tb\"");
208    }
209
210    #[test]
211    fn shell_quotes_when_needed() {
212        assert_eq!(quote_name("plain", QuotingStyle::Shell, false), "plain");
213        let q = quote_name("a b", QuotingStyle::Shell, false);
214        assert_eq!(q, "'a b'");
215        let q2 = quote_name("it's", QuotingStyle::Shell, false);
216        assert!(q2.starts_with('\''));
217        assert!(q2.contains("\\'"));
218    }
219
220    #[test]
221    fn shell_always_quotes() {
222        assert_eq!(
223            quote_name("plain", QuotingStyle::ShellAlways, false),
224            "'plain'"
225        );
226    }
227
228    #[test]
229    fn shell_escape_nongraphic() {
230        let q = quote_name("a\nb", QuotingStyle::ShellEscape, false);
231        assert!(q.starts_with("$'"), "{q}");
232        assert!(q.contains("\\n"), "{q}");
233    }
234}