Skip to main content

knf/
glob.rs

1//! Validated glob predicates and quoted, dotted key-path selectors.
2
3use std::str::FromStr;
4
5/// An invalid glob pattern, with a byte offset into its source.
6pub use fast_glob::Error as GlobError;
7
8/// A case-sensitive byte glob, validated once before matching.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct GlobPattern(Vec<u8>);
11
12impl FromStr for GlobPattern {
13    type Err = GlobError;
14
15    fn from_str(pattern: &str) -> Result<Self, Self::Err> {
16        Self::from_bytes(pattern.as_bytes().to_vec())
17    }
18}
19
20impl GlobPattern {
21    fn from_bytes(pattern: Vec<u8>) -> Result<Self, GlobError> {
22        fast_glob::validate(&pattern)?;
23        Ok(Self(pattern))
24    }
25
26    /// Match the entire candidate. `?` matches one byte, not one Unicode character.
27    pub fn matches(&self, candidate: impl AsRef<[u8]>) -> bool {
28        fast_glob::glob_match(&self.0, candidate)
29    }
30}
31
32/// Why a quoted key-path glob was rejected.
33#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
34pub enum KeyGlobError {
35    /// No selector was supplied. A quoted empty key (`''`) is valid.
36    #[error("empty key-path glob; use '*' to select top-level keys")]
37    Empty,
38    /// A literal span has no closing single quote.
39    #[error("unclosed quote at byte {index}")]
40    UnclosedQuote {
41        /// Byte offset of the opening quote in the original expression.
42        index: usize,
43    },
44    /// Invalid glob syntax, with the offset mapped to the original expression.
45    #[error("{0}")]
46    Glob(GlobError),
47}
48
49/// A glob selecting full key paths for wholesale replacement.
50///
51/// Unquoted dots separate keys; single-quoted spans are literal, including
52/// wildcard characters. Thus `foo.*` selects children of `foo`, whereas
53/// `'foo.bar'` selects a single key containing a dot. Backslash escapes the
54/// next byte literally inside quotes; outside quotes it follows fast-glob.
55/// Arrays are never traversed and brackets are character classes, not indices.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct KeyGlobPattern(GlobPattern);
58
59// These bytes cannot occur in UTF-8 keys. Encoding literal separators keeps
60// the matching grammar independent of std::path::is_separator on Windows.
61const LITERAL_SLASH: u8 = 0xff;
62const LITERAL_BACKSLASH: u8 = 0xfe;
63
64fn encode(byte: u8) -> u8 {
65    match byte {
66        b'/' => LITERAL_SLASH,
67        b'\\' => LITERAL_BACKSLASH,
68        byte => byte,
69    }
70}
71
72impl KeyGlobPattern {
73    /// Match actual key segments, never a dotted diagnostic rendering.
74    /// The empty slice denotes the document root and is never selected.
75    pub fn matches_keys(&self, keys: &[String]) -> bool {
76        if keys.is_empty() {
77            return false;
78        }
79        let mut candidate = Vec::new();
80        for (index, key) in keys.iter().enumerate() {
81            if index != 0 {
82                candidate.push(b'/');
83            }
84            candidate.extend(key.bytes().map(encode));
85        }
86        self.0.matches(candidate)
87    }
88}
89
90struct Normalizer<'a> {
91    source: &'a [u8],
92    bytes: Vec<u8>,
93    offsets: Vec<usize>,
94}
95
96impl Normalizer<'_> {
97    fn push(&mut self, byte: u8, offset: usize) {
98        self.bytes.push(byte);
99        self.offsets.push(offset);
100    }
101
102    fn literal(&mut self, byte: u8, offset: usize) {
103        let byte = encode(byte);
104        if matches!(byte, b'*' | b'?' | b'[' | b']' | b'{' | b'}' | b',' | b'!') {
105            self.push(b'\\', offset);
106        }
107        self.push(byte, offset);
108    }
109
110    fn glob_error(&self, kind: fast_glob::ErrorKind, index: usize) -> KeyGlobError {
111        KeyGlobError::Glob(GlobError { kind, index })
112    }
113
114    // Decode exactly fast-glob's escapes outside quoted literal spans.
115    fn escaped(&self, index: &mut usize) -> Result<u8, KeyGlobError> {
116        let offset = *index;
117        let mut byte = self.source[*index];
118        if byte == b'\\' {
119            *index += 1;
120            byte = *self
121                .source
122                .get(*index)
123                .ok_or_else(|| self.glob_error(fast_glob::ErrorKind::TrailingBackslash, offset))?;
124            byte = match byte {
125                b'b' => 8,
126                b'n' => b'\n',
127                b'r' => b'\r',
128                b't' => b'\t',
129                byte => byte,
130            };
131        }
132        *index += 1;
133        Ok(byte)
134    }
135
136    fn class(&mut self, index: &mut usize) -> Result<(), KeyGlobError> {
137        let start = *index;
138        *index += 1;
139        let negated = matches!(self.source.get(*index), Some(b'!' | b'^'));
140        if negated {
141            *index += 1;
142        }
143        let mut members = [false; 256];
144        let mut first = true;
145        loop {
146            let Some(&byte) = self.source.get(*index) else {
147                return Err(self.glob_error(fast_glob::ErrorKind::UnclosedBracket, start));
148            };
149            if byte == b']' && !first {
150                *index += 1;
151                break;
152            }
153            let low = self.escaped(index)?;
154            let high = if self.source.get(*index) == Some(&b'-')
155                && self.source.get(*index + 1).is_some_and(|b| *b != b']')
156            {
157                *index += 1;
158                self.escaped(index)?
159            } else {
160                low
161            };
162            if low <= high {
163                members[usize::from(low)..=usize::from(high)].fill(true);
164            }
165            first = false;
166        }
167
168        self.push(b'[', start);
169        let mut emitted = false;
170        // 0xfe and 0xff cannot occur in an original UTF-8 key. Every other
171        // original byte maps injectively, including slash and backslash.
172        for original in 0..=0xfd_u8 {
173            if members[usize::from(original)] != negated {
174                let byte = encode(original);
175                if matches!(byte, b']' | b'-' | b'^' | b'!' | b'[') {
176                    self.push(b'\\', start);
177                }
178                self.push(byte, start);
179                emitted = true;
180            }
181        }
182        if !emitted {
183            // A class containing only the internal separator never matches.
184            self.push(b'/', start);
185        }
186        self.push(b']', start);
187        Ok(())
188    }
189}
190
191impl FromStr for KeyGlobPattern {
192    type Err = KeyGlobError;
193
194    fn from_str(pattern: &str) -> Result<Self, Self::Err> {
195        if pattern.is_empty() {
196            return Err(KeyGlobError::Empty);
197        }
198        let mut norm = Normalizer {
199            source: pattern.as_bytes(),
200            bytes: Vec::new(),
201            offsets: Vec::new(),
202        };
203        let mut index = 0;
204        while index < norm.source.len() {
205            let offset = index;
206            match norm.source[index] {
207                b'\'' => {
208                    index += 1;
209                    loop {
210                        let Some(&byte) = norm.source.get(index) else {
211                            return Err(KeyGlobError::UnclosedQuote { index: offset });
212                        };
213                        if byte == b'\'' {
214                            index += 1;
215                            break;
216                        }
217                        let literal_offset = index;
218                        if byte == b'\\' {
219                            index += 1;
220                            if index == norm.source.len() {
221                                return Err(norm.glob_error(
222                                    fast_glob::ErrorKind::TrailingBackslash,
223                                    literal_offset,
224                                ));
225                            }
226                        }
227                        norm.literal(norm.source[index], literal_offset);
228                        index += 1;
229                    }
230                }
231                b'[' => norm.class(&mut index)?,
232                b'\\' => {
233                    let byte = norm.escaped(&mut index)?;
234                    norm.literal(byte, offset);
235                }
236                b'.' => {
237                    norm.push(b'/', offset);
238                    index += 1;
239                }
240                byte => {
241                    norm.push(encode(byte), offset);
242                    index += 1;
243                }
244            }
245        }
246        GlobPattern::from_bytes(norm.bytes)
247            .map(Self)
248            .map_err(|mut err| {
249                err.index = norm
250                    .offsets
251                    .get(err.index)
252                    .copied()
253                    .unwrap_or(pattern.len());
254                KeyGlobError::Glob(err)
255            })
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    fn matches(pattern: &str, keys: &[&str]) -> bool {
264        pattern
265            .parse::<KeyGlobPattern>()
266            .unwrap()
267            .matches_keys(&keys.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>())
268    }
269
270    #[test]
271    fn selectors_match_full_paths_and_respect_depth() {
272        for (pattern, keys, expected) in [
273            ("*", vec!["foo"], true),
274            ("*", vec!["foo.bar/baz\\qux"], true),
275            ("*", vec!["foo", "bar"], false),
276            ("foo", vec!["foo"], true),
277            ("foo", vec!["parent", "foo"], false),
278            ("foo.*", vec!["foo", "bar"], true),
279            ("foo.*", vec!["foo", "bar", "baz"], false),
280            ("foo.*", vec!["foo"], false),
281            ("foo.**", vec!["foo", "bar", "baz"], true),
282            ("**.cache", vec!["cache"], true),
283            ("**.cache", vec!["foo", "bar", "cache"], true),
284            ("**.cache", vec!["foo", "cache", "bar"], false),
285            ("{foo,bar}.*", vec!["bar", "baz"], true),
286            ("{foo.bar,baz}.*", vec!["foo", "bar", "qux"], true),
287            ("{foo,bar}.*", vec!["baz", "qux"], false),
288            ("!foo", vec!["foo"], false),
289            ("!foo", vec!["bar"], true),
290            ("!!foo", vec!["foo"], true),
291            ("!foo.*", vec!["foo"], true),
292            ("*", vec![], false),
293            ("''", vec![""], true),
294            ("''", vec![], false),
295            ("foo.''", vec!["foo", ""], true),
296        ] {
297            assert_eq!(matches(pattern, &keys), expected, "{pattern:?}: {keys:?}");
298        }
299    }
300
301    #[test]
302    fn literals_do_not_collide_with_nested_paths_or_glob_syntax() {
303        for (pattern, keys, expected) in [
304            ("'foo.bar'", vec!["foo.bar"], true),
305            ("'foo.bar'", vec!["foo", "bar"], false),
306            ("foo.bar", vec!["foo.bar"], false),
307            ("'foo.bar'.*", vec!["foo.bar", "pool"], true),
308            (r"foo\.bar", vec!["foo.bar"], true),
309            ("foo/bar", vec!["foo/bar"], true),
310            ("foo/bar", vec!["foo", "bar"], false),
311            ("'foo/bar'", vec!["foo/bar"], true),
312            (r"'foo\\bar'", vec![r"foo\bar"], true),
313            (r"foo\\bar", vec![r"foo\bar"], true),
314            (r"'it\'s'", vec!["it's"], true),
315            ("'*'", vec!["*"], true),
316            ("'*'", vec!["anything"], false),
317            ("'!foo'", vec!["!foo"], true),
318            ("'!foo'", vec!["foo"], false),
319            ("'{a,b}[0]?'", vec!["{a,b}[0]?"], true),
320            ("{'foo.bar',baz}.*", vec!["foo.bar", "x"], true),
321            ("foo'bar'.*", vec!["foobar", "x"], true),
322            ("'µ'", vec!["µ"], true),
323            ("?", vec!["µ"], false),
324            ("??", vec!["µ"], true),
325            ("?", vec!["/"], true),
326            ("?", vec!["\\"], true),
327            ("?", vec!["", ""], false),
328            (r"\n", vec!["\n"], true),
329            (r"'\n'", vec!["n"], true),
330        ] {
331            assert_eq!(matches(pattern, &keys), expected, "{pattern:?}: {keys:?}");
332        }
333    }
334
335    #[test]
336    fn classes_preserve_original_byte_membership() {
337        for pattern in [
338            "[a-z]", "[!a-z]", "[^a-z]", "[.]", "[/]", r"[\\]", "[.-0]", r"[Z-\^]", "[]]", "[[]",
339            "[-]", "[z-a]", "[!z-a]", "['.]", r"[\n]",
340        ] {
341            let selector: KeyGlobPattern = pattern.parse().unwrap();
342            // A class always selects one byte within a key. Unlike filesystem
343            // matching, slash and backslash are ordinary key bytes here.
344            for byte in 0..=127_u8 {
345                let reference = pattern.as_bytes().to_vec();
346                // Compare the original class against a non-separator byte;
347                // separators are checked explicitly below instead.
348                if matches!(byte, b'/' | b'\\') {
349                    continue;
350                }
351                assert_eq!(
352                    selector.matches_keys(&[String::from_utf8(vec![byte]).unwrap()]),
353                    fast_glob::glob_match(&reference, [byte]),
354                    "{pattern:?} byte {byte}"
355                );
356            }
357        }
358        for (pattern, key, expected) in [
359            ("[/]", "/", true),
360            (r"[\\]", "\\", true),
361            ("[.-0]", "/", true),
362            ("[!.-0]", "/", false),
363            (r"[Z-\^]", "\\", true),
364            ("[!a-z]", "/", true),
365            ("[.]", ".", true),
366            ("[.]", "/", false),
367            ("[z-a]", "z", false),
368        ] {
369            assert_eq!(matches(pattern, &[key]), expected, "{pattern:?}: {key:?}");
370        }
371        assert!(!matches("[!a-z]", &["", ""]));
372    }
373
374    #[test]
375    fn errors_name_original_source_offsets() {
376        assert_eq!("".parse::<KeyGlobPattern>(), Err(KeyGlobError::Empty));
377        assert_eq!(
378            "µ.'unclosed".parse::<KeyGlobPattern>(),
379            Err(KeyGlobError::UnclosedQuote { index: 3 })
380        );
381        for (pattern, kind, index) in [
382            (
383                "'long literal'.{foo",
384                fast_glob::ErrorKind::UnclosedBrace,
385                15,
386            ),
387            ("[!a-z].{foo", fast_glob::ErrorKind::UnclosedBrace, 7),
388            ("foo.[bar", fast_glob::ErrorKind::UnclosedBracket, 4),
389            ("foo.\\", fast_glob::ErrorKind::TrailingBackslash, 4),
390            ("'foo\\", fast_glob::ErrorKind::TrailingBackslash, 4),
391        ] {
392            let err = pattern.parse::<KeyGlobPattern>().unwrap_err();
393            assert_eq!(err, KeyGlobError::Glob(GlobError { kind, index }));
394            assert!(!err.to_string().ends_with('\n'));
395            assert!(!err.to_string().contains("--shallow"));
396        }
397    }
398
399    proptest::proptest! {
400        #[test]
401        fn quoted_keys_round_trip_without_separator_collisions(key in ".*") {
402            let quoted = format!("'{}'", key.replace('\\', "\\\\").replace('\'', "\\'"));
403            let selector: KeyGlobPattern = quoted.parse().unwrap();
404            proptest::prop_assert!(selector.matches_keys(std::slice::from_ref(&key)));
405            if key.contains('/') {
406                let nested = key.split('/').map(String::from).collect::<Vec<_>>();
407                proptest::prop_assert!(!selector.matches_keys(&nested));
408            }
409        }
410    }
411}