Skip to main content

gix_pathspec/
parse.rs

1use std::borrow::Cow;
2
3use bstr::{BStr, BString, ByteSlice};
4
5use crate::{Defaults, MagicSignature, Pattern, SearchMode};
6
7/// The error returned by [parse()][crate::parse()].
8#[derive(thiserror::Error, Debug)]
9#[expect(missing_docs)]
10pub enum Error {
11    #[error("An empty string is not a valid pathspec")]
12    EmptyString,
13    #[error("Found {keyword:?} in signature, which is not a valid keyword")]
14    InvalidKeyword { keyword: BString },
15    #[error("Unimplemented short keyword: {short_keyword:?}")]
16    Unimplemented { short_keyword: char },
17    #[error("Missing ')' at the end of pathspec signature")]
18    MissingClosingParenthesis,
19    #[error("Attribute has non-ascii characters or starts with '-': {attribute:?}")]
20    InvalidAttribute { attribute: BString },
21    #[error("Invalid character in attribute value: {character:?}")]
22    InvalidAttributeValue { character: char },
23    #[error(r"Escape character '\' is not allowed as the last character in an attribute value")]
24    TrailingEscapeCharacter,
25    #[error("Attribute specification cannot be empty")]
26    EmptyAttribute,
27    #[error("Only one attribute specification is allowed in the same pathspec")]
28    MultipleAttributeSpecifications,
29    #[error("'literal' and 'glob' keywords cannot be used together in the same pathspec")]
30    IncompatibleSearchModes,
31}
32
33impl Pattern {
34    /// Try to parse a path-spec pattern from the given `input` bytes.
35    pub fn from_bytes(
36        input: &[u8],
37        Defaults {
38            signature,
39            search_mode,
40            literal,
41        }: Defaults,
42    ) -> Result<Self, Error> {
43        if input.is_empty() {
44            return Err(Error::EmptyString);
45        }
46        if literal {
47            return Ok(Self::from_literal(input, signature));
48        }
49        if input.as_bstr() == ":" {
50            return Ok(Pattern {
51                nil: true,
52                ..Default::default()
53            });
54        }
55
56        let mut p = Pattern {
57            signature,
58            search_mode: SearchMode::default(),
59            ..Default::default()
60        };
61
62        let mut cursor = 0;
63        if input.first() == Some(&b':') {
64            cursor += 1;
65            p.signature |= parse_short_keywords(input, &mut cursor)?;
66            if let Some(b'(') = input.get(cursor) {
67                cursor += 1;
68                parse_long_keywords(input, &mut p, &mut cursor)?;
69            }
70        }
71
72        if search_mode != Default::default() && p.search_mode == Default::default() {
73            p.search_mode = search_mode;
74        }
75        let mut path = &input[cursor..];
76        if path.last() == Some(&b'/') {
77            p.signature |= MagicSignature::MUST_BE_DIR;
78            path = &path[..path.len() - 1];
79        }
80        p.path = path.into();
81        Ok(p)
82    }
83
84    /// Take `input` literally without parsing anything. This will also set our mode to `literal` to allow this pathspec to match `input` verbatim, and
85    /// use `default_signature` as magic signature.
86    pub fn from_literal(input: &[u8], default_signature: MagicSignature) -> Self {
87        Pattern {
88            path: input.into(),
89            signature: default_signature,
90            search_mode: SearchMode::Literal,
91            ..Default::default()
92        }
93    }
94}
95
96fn parse_short_keywords(input: &[u8], cursor: &mut usize) -> Result<MagicSignature, Error> {
97    let unimplemented_chars = b"\"#%&'-',;<=>@_`~";
98
99    let mut signature = MagicSignature::empty();
100    while let Some(&b) = input.get(*cursor) {
101        *cursor += 1;
102        signature |= match b {
103            b'/' => MagicSignature::TOP,
104            b'^' | b'!' => MagicSignature::EXCLUDE,
105            b':' => break,
106            _ if unimplemented_chars.contains(&b) => {
107                return Err(Error::Unimplemented {
108                    short_keyword: b.into(),
109                });
110            }
111            _ => {
112                *cursor -= 1;
113                break;
114            }
115        }
116    }
117
118    Ok(signature)
119}
120
121fn parse_long_keywords(input: &[u8], p: &mut Pattern, cursor: &mut usize) -> Result<(), Error> {
122    let end = input.find(")").ok_or(Error::MissingClosingParenthesis)?;
123
124    let input = &input[*cursor..end];
125    *cursor = end + 1;
126
127    if input.is_empty() {
128        return Ok(());
129    }
130
131    split_on_non_escaped_char(input, b',', |keyword| {
132        // Git skips empty keywords instead of rejecting them, so `:(top,)`, `:(,top)` and
133        // `:(top,,icase)` are all valid there.
134        if keyword.is_empty() {
135            return Ok(());
136        }
137        let attr_prefix = b"attr:";
138        match keyword {
139            b"attr" => {}
140            b"top" => p.signature |= MagicSignature::TOP,
141            b"icase" => p.signature |= MagicSignature::ICASE,
142            b"exclude" => p.signature |= MagicSignature::EXCLUDE,
143            b"literal" => match p.search_mode {
144                SearchMode::PathAwareGlob => return Err(Error::IncompatibleSearchModes),
145                _ => p.search_mode = SearchMode::Literal,
146            },
147            b"glob" => match p.search_mode {
148                SearchMode::Literal => return Err(Error::IncompatibleSearchModes),
149                _ => p.search_mode = SearchMode::PathAwareGlob,
150            },
151            _ if keyword.starts_with(attr_prefix) => {
152                if p.attributes.is_empty() {
153                    p.attributes = parse_attributes(&keyword[attr_prefix.len()..])?;
154                } else {
155                    return Err(Error::MultipleAttributeSpecifications);
156                }
157            }
158            _ => {
159                return Err(Error::InvalidKeyword {
160                    keyword: BString::from(keyword),
161                });
162            }
163        }
164        Ok(())
165    })
166}
167
168fn split_on_non_escaped_char(
169    input: &[u8],
170    split_char: u8,
171    mut f: impl FnMut(&[u8]) -> Result<(), Error>,
172) -> Result<(), Error> {
173    // Mirrors `strcspn_escaped()` in Git's `pathspec.c`: a backslash consumes the byte that
174    // follows it, so `\,` is a literal comma while `\\,` is an escaped backslash followed by a
175    // separator. Scanning byte-by-byte also lets a separator at index 0 be seen, which a
176    // two-byte window cannot.
177    let mut i = 0;
178    let mut last = 0;
179    while i < input.len() {
180        if input[i] == b'\\' {
181            i += 2;
182            continue;
183        }
184        if input[i] == split_char {
185            f(&input[last..i])?;
186            last = i + 1;
187        }
188        i += 1;
189    }
190    f(&input[last..])
191}
192
193fn parse_attributes(input: &[u8]) -> Result<Vec<gix_attributes::Assignment>, Error> {
194    if input.is_empty() {
195        return Err(Error::EmptyAttribute);
196    }
197
198    input
199        .split(|&b| b == b' ')
200        .filter(|attr| !attr.is_empty())
201        .map(|attr| {
202            let (name, state) = match attr.first() {
203                Some(b'!') => (&attr[1..], gix_attributes::State::Unspecified),
204                Some(b'-') => (&attr[1..], gix_attributes::State::Unset),
205                _ => match attr.find_byte(b'=') {
206                    Some(pos) => {
207                        let (name, value) = attr.split_at(pos);
208                        let value = &value[1..];
209                        let value = if value.contains(&b'\\') {
210                            Cow::Owned(unescape_and_check_attr_value(value.into())?)
211                        } else {
212                            check_attribute_value(value.into())?;
213                            Cow::Borrowed(value.as_bstr())
214                        };
215                        (name, gix_attributes::StateRef::from_bytes(value.as_ref()).to_owned())
216                    }
217                    None => (attr, gix_attributes::State::Set),
218                },
219            };
220            let name = gix_attributes::NameRef::try_from(name.as_bstr()).map_err(|err| Error::InvalidAttribute {
221                attribute: err.attribute,
222            })?;
223            Ok(gix_attributes::Assignment {
224                name: name.to_owned(),
225                state,
226            })
227        })
228        .collect()
229}
230
231fn unescape_and_check_attr_value(value: &BStr) -> Result<BString, Error> {
232    let mut out = BString::from(Vec::with_capacity(value.len()));
233    let mut bytes = value.iter();
234    while let Some(mut b) = bytes.next().copied() {
235        if b == b'\\' {
236            b = *bytes.next().ok_or(Error::TrailingEscapeCharacter)?;
237        }
238
239        out.push(validated_attr_value_byte(b)?);
240    }
241    Ok(out)
242}
243
244fn check_attribute_value(input: &BStr) -> Result<(), Error> {
245    match input.iter().copied().find(|b| !is_valid_attr_value(*b)) {
246        Some(b) => Err(Error::InvalidAttributeValue { character: b as char }),
247        None => Ok(()),
248    }
249}
250
251fn is_valid_attr_value(byte: u8) -> bool {
252    byte.is_ascii_alphanumeric() || b",-_".contains(&byte)
253}
254
255fn validated_attr_value_byte(byte: u8) -> Result<u8, Error> {
256    if is_valid_attr_value(byte) {
257        Ok(byte)
258    } else {
259        Err(Error::InvalidAttributeValue {
260            character: byte as char,
261        })
262    }
263}