Skip to main content

grit_lib/
quote_path.rs

1//! C-style path quoting compatible with Git's `quote.c` / `core.quotepath`.
2//!
3//! Git quotes pathnames for human-facing output (`ls-files`, `diff --name-only`,
4//! `ls-tree --name-only`) using a byte lookup table and optional full octal
5//! escaping for non-ASCII bytes when `quote_path_fully` is set (`core.quotepath`,
6//! default true).
7
8/// Lookup table from Git `quote.c` (`cq_lookup`). Values:
9/// - `1`: emit as `\<ooo>` three-digit octal
10/// - `-1`: safe byte (no escape needed inside a quoted string)
11/// - `>= 32`: letter escape (`\n`, `\t`, `\"`, …)
12/// - `0` for `0x80..=0xFF`: octal only when `quote_fully` is true
13const fn cq_byte(b: u8) -> i8 {
14    match b {
15        0x00..=0x06 => 1,
16        0x07 => b'a' as i8,
17        0x08 => b'b' as i8,
18        0x09 => b't' as i8,
19        0x0a => b'n' as i8,
20        0x0b => b'v' as i8,
21        0x0c => b'f' as i8,
22        0x0d => b'r' as i8,
23        0x0e..=0x1f => 1,
24        0x20 | 0x21 => -1,
25        0x22 => b'"' as i8,
26        0x23..=0x5b => -1,
27        0x5c => b'\\' as i8,
28        0x5d..=0x7e => -1,
29        0x7f => 1,
30        0x80..=0xff => 0,
31    }
32}
33
34const fn cq_lookup_table() -> [i8; 256] {
35    let mut t = [0i8; 256];
36    let mut i = 0usize;
37    while i < 256 {
38        t[i] = cq_byte(i as u8);
39        i += 1;
40    }
41    t
42}
43
44static CQ_LOOKUP: [i8; 256] = cq_lookup_table();
45
46#[inline]
47fn cq_must_quote(byte: u8, quote_fully: bool) -> bool {
48    i32::from(CQ_LOOKUP[byte as usize]) + i32::from(quote_fully) > 0
49}
50
51fn quote_c_style_inner(path: &str, quote_fully: bool, force_quotes: bool) -> String {
52    let bytes = path.as_bytes();
53    let mut any = force_quotes;
54    if !any {
55        for &b in bytes {
56            if cq_must_quote(b, quote_fully) {
57                any = true;
58                break;
59            }
60        }
61    }
62    if !any {
63        return path.to_owned();
64    }
65
66    let mut out = String::with_capacity(path.len() + 2);
67    out.push('"');
68    let mut p = 0usize;
69    while p < bytes.len() {
70        let mut len = 0usize;
71        while p + len < bytes.len() && !cq_must_quote(bytes[p + len], quote_fully) {
72            len += 1;
73        }
74        out.push_str(path.get(p..p + len).unwrap_or(""));
75        p += len;
76        if p >= bytes.len() {
77            break;
78        }
79        let ch = bytes[p];
80        p += 1;
81        out.push('\\');
82        let cq = CQ_LOOKUP[ch as usize];
83        if cq >= b' ' as i8 {
84            out.push(cq as u8 as char);
85        } else {
86            out.push(char::from(((ch >> 6) & 3) + b'0'));
87            out.push(char::from(((ch >> 3) & 7) + b'0'));
88            out.push(char::from((ch & 7) + b'0'));
89        }
90    }
91    out.push('"');
92    out
93}
94
95/// Quote `path` in Git C style when needed, matching `quote_c_style` + `core.quotepath`.
96///
97/// When `quote_fully` is true (Git default, `core.quotepath=true`), non-ASCII bytes are
98/// emitted as `\ooo` escapes. When false, UTF-8 / high bytes are copied literally and only
99/// ASCII special characters are escaped.
100#[must_use]
101pub fn quote_c_style(path: &str, quote_fully: bool) -> String {
102    quote_c_style_inner(path, quote_fully, false)
103}
104
105/// Quote for `ls-tree` default output: same as [`quote_c_style`], but paths containing `,`
106/// are always wrapped in quotes (Git `quote_path` with `ls_tree` mode).
107#[must_use]
108pub fn quote_path_for_tree_listing(path: &str, quote_fully: bool) -> String {
109    let force = path.as_bytes().contains(&b',');
110    quote_c_style_inner(path, quote_fully, force)
111}
112
113/// Format one side of a `diff --git` / `---` / `+++` line: either `prefix/path` or
114/// `"prefix<escaped-path>"` when Git would C-quote the path (see `t3300-funny-names`).
115#[must_use]
116pub fn format_diff_path_with_prefix(prefix: &str, path: &str, quote_fully: bool) -> String {
117    let quoted = quote_c_style(path, quote_fully);
118    if quoted == path {
119        format!("{prefix}{path}")
120    } else {
121        let inner = quoted
122            .strip_prefix('"')
123            .and_then(|s| s.strip_suffix('"'))
124            .unwrap_or(path);
125        format!("\"{prefix}{inner}\"")
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn ascii_safe_unchanged() {
135        assert_eq!(quote_c_style("Name", true), "Name");
136        assert_eq!(quote_c_style("With SP in it", true), "With SP in it");
137    }
138
139    #[test]
140    fn t3902_expect_quoted() {
141        assert_eq!(quote_c_style("Name and a\nLF", true), "\"Name and a\\nLF\"");
142        assert_eq!(
143            quote_c_style("Name and an\tHT", true),
144            "\"Name and an\\tHT\""
145        );
146        assert_eq!(quote_c_style("Name\"", true), "\"Name\\\"\"");
147    }
148
149    #[test]
150    fn t3902_expect_raw_mode() {
151        let s = "濱野\t純";
152        assert_eq!(quote_c_style(s, false), "\"濱野\\t純\"");
153        let s2 = "濱野 純";
154        assert_eq!(quote_c_style(s2, false), "濱野 純");
155    }
156
157    #[test]
158    fn comma_forces_ls_tree_style_quotes() {
159        assert_eq!(quote_path_for_tree_listing("a,b", true), "\"a,b\"");
160        assert_eq!(quote_c_style("a,b", true), "a,b");
161    }
162
163    #[test]
164    fn diff_git_prefix_quoting() {
165        let p = "tabs\t,\" (dq) and spaces";
166        assert_eq!(
167            format_diff_path_with_prefix("a/", p, true),
168            "\"a/tabs\\t,\\\" (dq) and spaces\""
169        );
170        assert_eq!(format_diff_path_with_prefix("b/", "plain", true), "b/plain");
171    }
172}