Skip to main content

sley_pathspec/
lib.rs

1//! Shared pathspec primitive for sley.
2//!
3//! This crate owns the byte-faithful port of git's wildmatch engine
4//! (`wildmatch.c::dowild`) and the single-item pathspec matcher
5//! (`match_pathspec_item`), plus a [`Pathspec`] type that parses git's
6//! pathspec *magic* prefixes (`:(exclude)`, `:(icase)`, `:(literal)`,
7//! `:(glob)`, `:(top)`, `:(attr:...)`, and the shorthand `:!`/`:^`/`:/`).
8//!
9//! Four clusters consume this primitive: the rev-walk (`sley-rev`), diff,
10//! the worktree walker (`sley-worktree`, which re-exports the engine for its
11//! `ls-files` path), and the CLI. Keeping the wildmatch port and the magic
12//! parser in one low-level crate (depending only on `sley-core`) means there
13//! is exactly one implementation of git's matching semantics to keep in sync
14//! with the 2.54 oracle.
15//!
16//! STAGE-A scope: parsing + per-path `matches`. The TREESAME / history
17//! simplification that *consumes* a `Pathspec` to prune the rev-walk is
18//! STAGE-B; this crate only provides the matching primitive that stage will
19//! drive.
20
21use sley_core::GitError;
22use std::cell::Cell;
23use std::fs;
24use std::path::Path;
25
26/// A parsed pathspec element: a single pattern plus its magic flags.
27///
28/// Mirrors git's `struct pathspec_item` for the subset sley needs today.
29/// Construct with [`PathspecElement::parse`]; query with
30/// [`PathspecElement::matches`].
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct PathspecElement {
33    /// The match pattern with any magic prefix stripped (git's `item.match`).
34    pattern: Vec<u8>,
35    /// `:(exclude)` / `:!` / `:^` — this element subtracts from the set.
36    exclude: bool,
37    /// `:(icase)` — case-insensitive matching.
38    icase: bool,
39    /// `:(literal)` — wildcards are matched literally (no globbing).
40    literal: bool,
41    /// `:(glob)` — pathname-aware globbing (`**` required to cross `/`).
42    glob: bool,
43    /// `:(top)` / `:/` — match from the repository root (sley already matches
44    /// repo-relative paths from the root, so this is parsed and surfaced but
45    /// does not change single-path matching; it affects prefix handling that
46    /// the consuming cluster applies).
47    top: bool,
48    /// `:(attr:...)` attribute requirements, stored verbatim. Attribute-based
49    /// selection is not yet evaluated (STAGE-B+); the labels are retained so a
50    /// pathspec carrying them round-trips and the consumer can reject/honor
51    /// them explicitly rather than silently dropping them.
52    attrs: Vec<Vec<u8>>,
53    attr_requirements: Vec<PathspecAttrRequirement>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum PathspecAttrRequirement {
58    Set(Vec<u8>),
59    Unset(Vec<u8>),
60    Unspecified(Vec<u8>),
61    Value { name: Vec<u8>, value: Vec<u8> },
62}
63
64impl PathspecElement {
65    /// Parse one pathspec argument, honoring git's magic prefixes.
66    ///
67    /// Recognizes both the long form `:(magic1,magic2,...)pattern` and the
68    /// shorthand sigils `:!`/`:^` (exclude), `:/` (top). Defaults
69    /// (`literal`/`glob`/`icase` from the global `--*-pathspecs` flags) are
70    /// supplied via `defaults` and overridden per-element by any explicit
71    /// magic. Unknown long-form magic words are an error, matching git.
72    pub fn parse(arg: &[u8], defaults: PathspecMatchMagic) -> Result<Self, PathspecParseError> {
73        let mut exclude = false;
74        let mut icase = defaults.icase;
75        let mut literal = defaults.literal;
76        let mut glob = defaults.glob;
77        let mut top = false;
78        let mut attrs: Vec<Vec<u8>> = Vec::new();
79        let mut attr_requirements: Vec<PathspecAttrRequirement> = Vec::new();
80        let mut explicit_literal = false;
81        let mut explicit_glob = false;
82
83        let rest = if defaults.literal_pathspecs {
84            arg
85        } else if let Some(after) = arg.strip_prefix(b":(") {
86            // Long form: :(magic[,magic...])pattern
87            let close = after
88                .iter()
89                .position(|&c| c == b')')
90                .ok_or(PathspecParseError::UnterminatedMagic)?;
91            let magic = &after[..close];
92            for word in split_magic(magic) {
93                match word.as_slice() {
94                    b"exclude" => exclude = true,
95                    b"icase" => icase = true,
96                    b"literal" => {
97                        explicit_literal = true;
98                        literal = true;
99                        glob = false;
100                    }
101                    b"glob" => {
102                        explicit_glob = true;
103                        glob = true;
104                        literal = false;
105                    }
106                    b"top" => top = true,
107                    other => {
108                        if let Some(attr) = other.strip_prefix(b"attr:") {
109                            if !attrs.is_empty() {
110                                return Err(PathspecParseError::MultipleAttrMagic);
111                            }
112                            attrs.push(attr.to_vec());
113                            attr_requirements = parse_attr_requirements(attr)?;
114                        } else if other.is_empty() {
115                            // Empty magic word (e.g. trailing comma) — ignore,
116                            // matching git's lenient split.
117                        } else {
118                            return Err(PathspecParseError::UnknownMagic(other.to_vec()));
119                        }
120                    }
121                }
122            }
123            &after[close + 1..]
124        } else if let Some(after) = arg.strip_prefix(b":") {
125            // Shorthand sigils. git consumes a run of leading sigils.
126            let mut idx = 0;
127            while idx < after.len() {
128                match after[idx] {
129                    b'!' | b'^' => exclude = true,
130                    b'/' => top = true,
131                    _ => break,
132                }
133                idx += 1;
134            }
135            &after[idx..]
136        } else {
137            arg
138        };
139
140        // `:(glob)` and `:(literal)` are mutually exclusive in git.
141        if (glob && literal) || (explicit_glob && explicit_literal) {
142            return Err(PathspecParseError::GlobLiteralConflict);
143        }
144
145        Ok(PathspecElement {
146            pattern: rest.to_vec(),
147            exclude,
148            icase,
149            literal,
150            glob,
151            top,
152            attrs,
153            attr_requirements,
154        })
155    }
156
157    /// Whether this element is an `:(exclude)` element.
158    pub fn is_exclude(&self) -> bool {
159        self.exclude
160    }
161
162    /// Whether this element carries `:(top)` / `:/` magic.
163    pub fn is_top(&self) -> bool {
164        self.top
165    }
166
167    /// The attribute requirements carried by `:(attr:...)`, if any.
168    pub fn attrs(&self) -> &[Vec<u8>] {
169        &self.attrs
170    }
171
172    pub fn attr_requirements(&self) -> &[PathspecAttrRequirement] {
173        &self.attr_requirements
174    }
175
176    /// Whether this element carries case-insensitive matching.
177    pub fn is_icase(&self) -> bool {
178        self.icase
179    }
180
181    /// Whether this element carries glob magic.
182    pub fn is_glob(&self) -> bool {
183        self.glob
184    }
185
186    /// The bare match pattern (magic prefix stripped).
187    pub fn pattern(&self) -> &[u8] {
188        &self.pattern
189    }
190
191    /// The [`PathspecMatchMagic`] this element matches under.
192    pub fn magic(&self) -> PathspecMatchMagic {
193        PathspecMatchMagic {
194            literal: self.literal,
195            glob: self.glob,
196            icase: self.icase,
197            literal_pathspecs: false,
198        }
199    }
200
201    /// Whether `name` (a repo-relative path, no leading slash) is selected by
202    /// this single element, ignoring its exclude polarity. Use
203    /// [`Pathspec::matches`] for the combined include/exclude semantics.
204    pub fn matches_path(&self, name: &[u8]) -> bool {
205        pathspec_item_matches(&self.pattern, name, self.magic())
206    }
207
208    pub fn with_pattern(mut self, pattern: Vec<u8>) -> Self {
209        self.pattern = pattern;
210        self
211    }
212}
213
214/// A full pathspec: an ordered list of [`PathspecElement`]s combining positive
215/// (include) and `:(exclude)` patterns.
216///
217/// Semantics (git `match_pathspec`): a path matches when at least one
218/// non-exclude element selects it AND no exclude element selects it. An
219/// all-exclude (or empty) pathspec matches everything not excluded — matching
220/// git, where `git log -- ':(exclude)foo'` keeps every path but `foo`.
221#[derive(Debug, Clone, Default, PartialEq, Eq)]
222pub struct Pathspec {
223    elements: Vec<PathspecElement>,
224}
225
226impl Pathspec {
227    /// Parse a list of raw pathspec arguments under the given global magic
228    /// defaults (from `--{glob,noglob,literal,icase}-pathspecs`).
229    pub fn parse<I, S>(args: I, defaults: PathspecMatchMagic) -> Result<Self, PathspecParseError>
230    where
231        I: IntoIterator<Item = S>,
232        S: AsRef<[u8]>,
233    {
234        let mut elements = Vec::new();
235        for arg in args {
236            elements.push(PathspecElement::parse(arg.as_ref(), defaults)?);
237        }
238        Ok(Pathspec { elements })
239    }
240
241    pub fn from_elements(elements: Vec<PathspecElement>) -> Self {
242        Self { elements }
243    }
244
245    /// An empty pathspec matches every path.
246    pub fn is_empty(&self) -> bool {
247        self.elements.is_empty()
248    }
249
250    /// The parsed elements, in order.
251    pub fn elements(&self) -> &[PathspecElement] {
252        &self.elements
253    }
254
255    /// Whether `path` (repo-relative, no leading slash) is selected.
256    ///
257    /// An empty pathspec, or one with only excludes, matches any path the
258    /// excludes don't subtract — exactly git's `match_pathspec` behavior.
259    pub fn matches(&self, path: &[u8]) -> bool {
260        if self.elements.is_empty() {
261            return true;
262        }
263        let mut have_include = false;
264        let mut included = false;
265        for element in &self.elements {
266            if element.exclude {
267                if element.matches_path(path) {
268                    return false;
269                }
270            } else {
271                have_include = true;
272                if element.matches_path(path) {
273                    included = true;
274                }
275            }
276        }
277        // With at least one include, the path must hit one of them. With only
278        // excludes, anything not excluded is kept.
279        if have_include { included } else { true }
280    }
281}
282
283pub struct LsFilesPathFilter {
284    pub original: String,
285    pub recursive: bool,
286    pub is_glob: bool,
287    pub element: PathspecElement,
288    pub matched: Cell<bool>,
289}
290
291impl LsFilesPathFilter {
292    pub fn is_exclude(&self) -> bool {
293        self.element.is_exclude()
294    }
295
296    pub fn matches(&self, path: &[u8]) -> bool {
297        // Byte-exact git `match_pathspec_item` for the tracked-index path. Handles
298        // exact / directory-prefix / wildcard matching under the active magic.
299        let path_no_slash = path.strip_suffix(b"/").unwrap_or(path);
300        self.element.matches_path(path)
301            || (path_no_slash.len() != path.len() && self.element.matches_path(path_no_slash))
302    }
303}
304
305pub fn pathspec_filters_match(filters: &[LsFilesPathFilter], path: &[u8]) -> bool {
306    pathspec_filters_match_with(filters, path, |filter, path| filter.matches(path))
307}
308
309pub fn pathspec_filters_have_include(filters: &[LsFilesPathFilter]) -> bool {
310    filters.iter().any(|filter| !filter.is_exclude())
311}
312
313pub fn pathspec_filters_match_with(
314    filters: &[LsFilesPathFilter],
315    path: &[u8],
316    mut matches: impl FnMut(&LsFilesPathFilter, &[u8]) -> bool,
317) -> bool {
318    let mut have_include = false;
319    let mut included = false;
320    for filter in filters {
321        if filter.is_exclude() {
322            if matches(filter, path) {
323                filter.matched.set(true);
324                return false;
325            }
326        } else {
327            have_include = true;
328            if matches(filter, path) {
329                filter.matched.set(true);
330                included = true;
331            }
332        }
333    }
334    !have_include || included
335}
336
337#[derive(Debug, Clone, PartialEq, Eq)]
338pub enum PathspecAttributeState {
339    Set,
340    Unset,
341    Value(Vec<u8>),
342}
343
344#[derive(Debug, Clone, PartialEq, Eq)]
345pub struct PathspecAttributeCheck {
346    pub attribute: Vec<u8>,
347    pub state: Option<PathspecAttributeState>,
348}
349
350pub fn pathspec_attrs_match_with(
351    element: &PathspecElement,
352    checks: impl FnOnce(&[Vec<u8>]) -> Vec<PathspecAttributeCheck>,
353) -> bool {
354    let requirements = element.attr_requirements();
355    if requirements.is_empty() {
356        return true;
357    }
358    let requested = requirements
359        .iter()
360        .map(|requirement| match requirement {
361            PathspecAttrRequirement::Set(name)
362            | PathspecAttrRequirement::Unset(name)
363            | PathspecAttrRequirement::Unspecified(name) => name.clone(),
364            PathspecAttrRequirement::Value { name, .. } => name.clone(),
365        })
366        .collect::<Vec<_>>();
367    let checks = checks(&requested);
368    requirements.iter().all(|requirement| {
369        let (name, expected) = match requirement {
370            PathspecAttrRequirement::Set(name) => (name, AttrRequirementKind::Set),
371            PathspecAttrRequirement::Unset(name) => (name, AttrRequirementKind::Unset),
372            PathspecAttrRequirement::Unspecified(name) => (name, AttrRequirementKind::Unspecified),
373            PathspecAttrRequirement::Value { name, value } => {
374                (name, AttrRequirementKind::Value(value))
375            }
376        };
377        let state = checks
378            .iter()
379            .find(|check| &check.attribute == name)
380            .and_then(|check| check.state.as_ref());
381        match expected {
382            AttrRequirementKind::Set => matches!(state, Some(PathspecAttributeState::Set)),
383            AttrRequirementKind::Unset => matches!(state, Some(PathspecAttributeState::Unset)),
384            AttrRequirementKind::Unspecified => state.is_none(),
385            AttrRequirementKind::Value(value) => {
386                matches!(state, Some(PathspecAttributeState::Value(actual)) if actual == value)
387            }
388        }
389    })
390}
391
392enum AttrRequirementKind<'a> {
393    Set,
394    Unset,
395    Unspecified,
396    Value(&'a [u8]),
397}
398
399pub fn parse_normalized_pathspec_element(
400    prefix: &[u8],
401    arg: &str,
402    magic: PathspecMatchMagic,
403) -> sley_core::Result<PathspecElement> {
404    let element = PathspecElement::parse(arg.as_bytes(), magic)
405        .map_err(|err| GitError::Command(format!("bad pathspec: {err}")))?;
406    let base = if element.is_top() {
407        b"".as_slice()
408    } else {
409        prefix
410    };
411    let pattern = normalize_ls_files_pathspec(base, &String::from_utf8_lossy(element.pattern()))?;
412    Ok(element.with_pattern(pattern))
413}
414
415pub fn normalized_revwalk_pathspec(
416    cwd: &Path,
417    worktree_root: Option<&Path>,
418    pathspecs: &[String],
419    magic: PathspecMatchMagic,
420) -> sley_core::Result<Pathspec> {
421    let (prefix, root_and_cwd) = if let Some(root) = worktree_root {
422        let root = fs::canonicalize(root)?;
423        let cwd = fs::canonicalize(cwd)?;
424        let prefix = cwd
425            .strip_prefix(&root)
426            .map(|relative| relative.to_string_lossy().replace('\\', "/").into_bytes())
427            .unwrap_or_default();
428        (prefix, Some((root, cwd)))
429    } else {
430        (Vec::new(), None)
431    };
432    let elements = pathspecs
433        .iter()
434        .map(|spec| {
435            let parse_spec = match root_and_cwd.as_ref() {
436                Some((root, cwd)) => normalize_absolute_pathspec_arg(root, cwd, spec)?,
437                None => spec.to_string(),
438            };
439            parse_normalized_pathspec_element(&prefix, &parse_spec, magic)
440        })
441        .collect::<sley_core::Result<Vec<_>>>()?;
442    Ok(Pathspec::from_elements(elements))
443}
444
445fn normalize_absolute_pathspec_arg(
446    root: &Path,
447    cwd: &Path,
448    arg: &str,
449) -> sley_core::Result<String> {
450    let path = Path::new(arg);
451    if !path.is_absolute() {
452        return Ok(arg.to_string());
453    }
454    let absolute = fs::canonicalize(path)?;
455    let relative = absolute
456        .strip_prefix(root)
457        .map_err(|_| GitError::InvalidPath(format!("pathspec {arg} is outside worktree")))?;
458    let repo_path = relative.to_string_lossy().replace('\\', "/");
459    if repo_path.is_empty() {
460        return Ok(":/".to_string());
461    }
462    if cwd == root {
463        return Ok(repo_path);
464    }
465    Ok(format!(":(top){repo_path}"))
466}
467
468pub fn normalize_ls_files_pathspec(prefix: &[u8], arg: &str) -> sley_core::Result<Vec<u8>> {
469    let mut components = prefix
470        .split(|byte| *byte == b'/')
471        .filter(|component| !component.is_empty())
472        .map(Vec::from)
473        .collect::<Vec<_>>();
474    for component in Path::new(arg).components() {
475        match component {
476            std::path::Component::CurDir => {}
477            std::path::Component::ParentDir => {
478                components.pop().ok_or_else(|| {
479                    GitError::InvalidPath(format!("pathspec {arg} is outside worktree"))
480                })?;
481            }
482            std::path::Component::Normal(name) => {
483                components.push(name.to_string_lossy().as_bytes().to_vec());
484            }
485            std::path::Component::RootDir | std::path::Component::Prefix(_) => {
486                return Err(GitError::Unsupported(
487                    "ls-files pathspecs currently support relative paths".into(),
488                ));
489            }
490        }
491    }
492    Ok(components.join(&b'/'))
493}
494
495/// Split a `:(...)` magic body on commas (git's `parse_long_magic` separator).
496fn split_magic(body: &[u8]) -> Vec<Vec<u8>> {
497    let mut words = Vec::new();
498    let mut word = Vec::new();
499    let mut escaped = false;
500    for &byte in body {
501        if escaped {
502            word.push(byte);
503            escaped = false;
504        } else if byte == b'\\' {
505            word.push(byte);
506            escaped = true;
507        } else if byte == b',' {
508            words.push(std::mem::take(&mut word));
509        } else {
510            word.push(byte);
511        }
512    }
513    words.push(word);
514    words
515}
516
517fn parse_attr_requirements(
518    body: &[u8],
519) -> Result<Vec<PathspecAttrRequirement>, PathspecParseError> {
520    if body.is_empty() {
521        return Err(PathspecParseError::EmptyAttrMagic);
522    }
523    let mut requirements = Vec::new();
524    for raw in body.split(|byte| byte.is_ascii_whitespace()) {
525        if raw.is_empty() {
526            continue;
527        }
528        requirements.push(parse_attr_requirement(raw)?);
529    }
530    if requirements.is_empty() {
531        return Err(PathspecParseError::EmptyAttrMagic);
532    }
533    Ok(requirements)
534}
535
536fn parse_attr_requirement(raw: &[u8]) -> Result<PathspecAttrRequirement, PathspecParseError> {
537    if let Some(rest) = raw.strip_prefix(b"-") {
538        if rest.contains(&b'=') {
539            return Err(PathspecParseError::InvalidAttrSpec(raw.to_vec()));
540        }
541        validate_attr_name(rest)?;
542        return Ok(PathspecAttrRequirement::Unset(rest.to_vec()));
543    }
544    if let Some(rest) = raw.strip_prefix(b"!") {
545        if rest.contains(&b'=') {
546            return Err(PathspecParseError::InvalidAttrSpec(raw.to_vec()));
547        }
548        validate_attr_name(rest)?;
549        return Ok(PathspecAttrRequirement::Unspecified(rest.to_vec()));
550    }
551    if let Some(equal) = raw.iter().position(|byte| *byte == b'=') {
552        let name = &raw[..equal];
553        let value = unescape_attr_value(&raw[equal + 1..])?;
554        validate_attr_name(name)?;
555        return Ok(PathspecAttrRequirement::Value {
556            name: name.to_vec(),
557            value,
558        });
559    }
560    validate_attr_name(raw)?;
561    Ok(PathspecAttrRequirement::Set(raw.to_vec()))
562}
563
564fn validate_attr_name(name: &[u8]) -> Result<(), PathspecParseError> {
565    if name.is_empty()
566        || !name
567            .iter()
568            .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'-' | b'_' | b'.'))
569    {
570        return Err(PathspecParseError::InvalidAttrSpec(name.to_vec()));
571    }
572    Ok(())
573}
574
575fn unescape_attr_value(value: &[u8]) -> Result<Vec<u8>, PathspecParseError> {
576    let mut out = Vec::with_capacity(value.len());
577    let mut idx = 0usize;
578    while idx < value.len() {
579        if value[idx] != b'\\' {
580            out.push(value[idx]);
581            idx += 1;
582            continue;
583        }
584        let Some(&next) = value.get(idx + 1) else {
585            return Err(PathspecParseError::AttrValueTrailingBackslash);
586        };
587        if next != b',' {
588            return Err(PathspecParseError::AttrValueUnsupportedBackslash);
589        }
590        out.push(next);
591        idx += 2;
592    }
593    Ok(out)
594}
595
596/// Error parsing a pathspec magic prefix.
597#[derive(Debug, Clone, PartialEq, Eq)]
598pub enum PathspecParseError {
599    /// A `:(` was not closed by a `)`.
600    UnterminatedMagic,
601    /// A long-form magic word git does not recognize.
602    UnknownMagic(Vec<u8>),
603    /// `:(glob)` and `:(literal)` were both requested.
604    GlobLiteralConflict,
605    EmptyAttrMagic,
606    MultipleAttrMagic,
607    InvalidAttrSpec(Vec<u8>),
608    AttrValueTrailingBackslash,
609    AttrValueUnsupportedBackslash,
610}
611
612impl core::fmt::Display for PathspecParseError {
613    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
614        match self {
615            PathspecParseError::UnterminatedMagic => {
616                write!(f, "Missing ')' at end of pathspec magic")
617            }
618            PathspecParseError::UnknownMagic(word) => {
619                write!(
620                    f,
621                    "Invalid pathspec magic '{}'",
622                    String::from_utf8_lossy(word)
623                )
624            }
625            PathspecParseError::GlobLiteralConflict => {
626                write!(f, "'literal' and 'glob' are incompatible")
627            }
628            PathspecParseError::EmptyAttrMagic => write!(f, "empty attr magic is not allowed"),
629            PathspecParseError::MultipleAttrMagic => {
630                write!(f, "Only one 'attr:' specification is allowed")
631            }
632            PathspecParseError::InvalidAttrSpec(spec) => write!(
633                f,
634                "invalid attribute specification '{}'",
635                String::from_utf8_lossy(spec)
636            ),
637            PathspecParseError::AttrValueTrailingBackslash => {
638                write!(
639                    f,
640                    "Escape character '\\' not allowed as last character in attr value"
641                )
642            }
643            PathspecParseError::AttrValueUnsupportedBackslash => {
644                write!(f, "Only '\\,' is supported for value matching")
645            }
646        }
647    }
648}
649
650impl std::error::Error for PathspecParseError {}
651
652/// Pathspec match magic, mirroring git's `PATHSPEC_LITERAL`/`PATHSPEC_GLOB`/
653/// `PATHSPEC_ICASE`. Constructed from the global `--{glob,noglob,icase,literal}-pathspecs`
654/// options. Drives [`pathspec_item_matches`].
655#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
656pub struct PathspecMatchMagic {
657    pub literal: bool,
658    pub glob: bool,
659    pub icase: bool,
660    /// `--literal-pathspecs` / `GIT_LITERAL_PATHSPECS`: the entire pathspec is
661    /// literal, including leading `:(...)` magic syntax.
662    pub literal_pathspecs: bool,
663}
664
665/// git `is_glob_special`: characters that make a pathspec a wildcard.
666fn is_glob_special(c: u8) -> bool {
667    matches!(c, b'*' | b'?' | b'[' | b'\\')
668}
669
670/// git `simple_length`: length of the literal prefix before the first glob-special
671/// character (or end of string).
672fn simple_length(s: &[u8]) -> usize {
673    for (i, &c) in s.iter().enumerate() {
674        if is_glob_special(c) {
675            return i;
676        }
677    }
678    s.len()
679}
680
681/// Case-aware byte comparison up to `n` bytes, honoring `icase` (git `ps_strncmp`).
682fn ps_strncmp(icase: bool, a: &[u8], b: &[u8], n: usize) -> bool {
683    // Returns true when the first `n` bytes are EQUAL (mirrors `!strncmp`).
684    let a = &a[..a.len().min(n)];
685    let b = &b[..b.len().min(n)];
686    if a.len() < n && b.len() < n && a.len() != b.len() {
687        return false;
688    }
689    let len = n.min(a.len()).min(b.len());
690    for i in 0..len {
691        let (mut ca, mut cb) = (a[i], b[i]);
692        if icase {
693            ca = ca.to_ascii_lowercase();
694            cb = cb.to_ascii_lowercase();
695        }
696        if ca != cb {
697            return false;
698        }
699    }
700    true
701}
702
703/// True if `path` contains a glob-special character.
704pub fn pathspec_is_glob(path: &[u8]) -> bool {
705    path.iter().any(|byte| is_glob_special(*byte))
706}
707
708/// Port of git's `match_pathspec_item` for the single-pathspec / single-name case
709/// (no prefix, no attr magic). `match_` is the pathspec, `name` is the candidate
710/// path. Returns whether the pathspec selects `name` (exactly, as a directory
711/// prefix, or via wildmatch). Byte-for-byte faithful to git 2.54 for the
712/// `ls-files -- <pathspec>` path that t3070 exercises.
713pub fn pathspec_item_matches(match_: &[u8], name: &[u8], magic: PathspecMatchMagic) -> bool {
714    let icase = magic.icase;
715    let matchlen = match_.len();
716    let namelen = name.len();
717
718    // nowildcard_len: with LITERAL magic the whole pattern is literal.
719    let nowildcard_len = if magic.literal {
720        matchlen
721    } else {
722        simple_length(match_)
723    };
724
725    // Empty pathspec matches everything (git: `if (!*match) return MATCHED_RECURSIVELY`).
726    if matchlen == 0 {
727        return true;
728    }
729
730    // Literal-prefix comparison.
731    if matchlen <= namelen && ps_strncmp(icase, match_, name, matchlen) {
732        if matchlen == namelen {
733            return true; // MATCHED_EXACTLY
734        }
735        if match_[matchlen - 1] == b'/' || name[matchlen] == b'/' {
736            return true; // MATCHED_RECURSIVELY
737        }
738    } else if match_[matchlen - 1] == b'/'
739        && namelen == matchlen - 1
740        && ps_strncmp(icase, match_, name, namelen)
741    {
742        // DO_MATCH_DIRECTORY case: pathspec `foo/` vs name `foo`.
743        return true;
744    }
745
746    // Wildcard match — git `git_fnmatch(item, match, name, nowildcard_len)`.
747    if nowildcard_len < matchlen {
748        // git strips the literal prefix off BOTH pattern and name before running
749        // wildmatch (so `foo**` vs `foo/bba/arr` becomes `**` vs `/bba/arr`).
750        if nowildcard_len > 0 && !ps_strncmp(icase, match_, name, nowildcard_len) {
751            return false;
752        }
753        let pat = &match_[nowildcard_len..];
754        if name.len() < nowildcard_len {
755            return false;
756        }
757        let str_ = &name[nowildcard_len..];
758
759        let flags = if magic.glob && !magic.literal {
760            WM_PATHNAME | if icase { WM_CASEFOLD } else { 0 }
761        } else {
762            // Default pathspec (no glob magic): pathmatch semantics.
763            if icase { WM_CASEFOLD } else { 0 }
764        };
765        if wildmatch(pat, str_, flags) {
766            return true;
767        }
768    }
769
770    false
771}
772
773/// Case-insensitive match flag (git `WM_CASEFOLD`).
774pub const WM_CASEFOLD: u32 = 1;
775/// Pathname-aware match flag (git `WM_PATHNAME`): `*`/`?` do not cross `/`,
776/// `**` is required to span directory separators.
777pub const WM_PATHNAME: u32 = 2;
778
779const WM_MATCH: i32 = 0;
780const WM_NOMATCH: i32 = 1;
781const WM_ABORT_ALL: i32 = -1;
782const WM_ABORT_TO_STARSTAR: i32 = -2;
783
784#[inline]
785fn wm_isascii(c: u8) -> bool {
786    c < 0x80
787}
788#[inline]
789fn wm_isupper(c: u8) -> bool {
790    wm_isascii(c) && c.is_ascii_uppercase()
791}
792#[inline]
793fn wm_islower(c: u8) -> bool {
794    wm_isascii(c) && c.is_ascii_lowercase()
795}
796#[inline]
797fn wm_tolower(c: u8) -> u8 {
798    c.to_ascii_lowercase()
799}
800#[inline]
801fn wm_toupper(c: u8) -> u8 {
802    c.to_ascii_uppercase()
803}
804#[inline]
805fn wm_is_glob_special(c: u8) -> bool {
806    matches!(c, b'*' | b'?' | b'[' | b'\\')
807}
808
809fn wm_cc_eq(class: &[u8], lit: &[u8]) -> bool {
810    class == lit
811}
812
813fn wm_class_matches(class: &[u8], t_ch: u8, flags: u32) -> Option<bool> {
814    // Returns Some(matched) for a recognized class, or None for a malformed
815    // class name (caller maps to WM_ABORT_ALL).
816    let m = if wm_cc_eq(class, b"alnum") {
817        wm_isascii(t_ch) && t_ch.is_ascii_alphanumeric()
818    } else if wm_cc_eq(class, b"alpha") {
819        wm_isascii(t_ch) && t_ch.is_ascii_alphabetic()
820    } else if wm_cc_eq(class, b"blank") {
821        wm_isascii(t_ch) && (t_ch == b' ' || t_ch == b'\t')
822    } else if wm_cc_eq(class, b"cntrl") {
823        wm_isascii(t_ch) && t_ch.is_ascii_control()
824    } else if wm_cc_eq(class, b"digit") {
825        wm_isascii(t_ch) && t_ch.is_ascii_digit()
826    } else if wm_cc_eq(class, b"graph") {
827        wm_isascii(t_ch) && t_ch.is_ascii_graphic()
828    } else if wm_cc_eq(class, b"lower") {
829        wm_islower(t_ch)
830    } else if wm_cc_eq(class, b"print") {
831        // ISPRINT: printable including space (0x20..=0x7e).
832        wm_isascii(t_ch) && (0x20..=0x7e).contains(&t_ch)
833    } else if wm_cc_eq(class, b"punct") {
834        wm_isascii(t_ch) && t_ch.is_ascii_punctuation()
835    } else if wm_cc_eq(class, b"space") {
836        wm_isascii(t_ch) && t_ch.is_ascii_whitespace()
837    } else if wm_cc_eq(class, b"upper") {
838        wm_isupper(t_ch) || ((flags & WM_CASEFOLD) != 0 && wm_islower(t_ch))
839    } else if wm_cc_eq(class, b"xdigit") {
840        wm_isascii(t_ch) && t_ch.is_ascii_hexdigit()
841    } else {
842        return None;
843    };
844    Some(m)
845}
846
847/// Faithful port of git's `wildmatch.c::dowild`. Returns one of the internal
848/// `WM_*` codes (`WM_MATCH`, `WM_NOMATCH`, `WM_ABORT_ALL`, `WM_ABORT_TO_STARSTAR`).
849fn dowild(pattern: &[u8], text: &[u8], flags: u32) -> i32 {
850    let p = pattern;
851    let mut pi = 0usize;
852    let mut ti = 0usize;
853
854    while pi < p.len() {
855        let mut p_ch = p[pi];
856        let t_ch_raw = if ti < text.len() { text[ti] } else { 0 };
857        let mut t_ch = t_ch_raw;
858
859        if t_ch == 0 && p_ch != b'*' {
860            return WM_ABORT_ALL;
861        }
862        if (flags & WM_CASEFOLD) != 0 && wm_isupper(t_ch) {
863            t_ch = wm_tolower(t_ch);
864        }
865        if (flags & WM_CASEFOLD) != 0 && wm_isupper(p_ch) {
866            p_ch = wm_tolower(p_ch);
867        }
868
869        match p_ch {
870            b'?' => {
871                if (flags & WM_PATHNAME) != 0 && t_ch == b'/' {
872                    return WM_NOMATCH;
873                }
874                // fallthrough: advance both
875                pi += 1;
876                ti += 1;
877                continue;
878            }
879            b'*' => {
880                pi += 1;
881                let match_slash: bool;
882                if pi < p.len() && p[pi] == b'*' {
883                    let prev_p = pi; // index of the second '*'
884                    while pi < p.len() && p[pi] == b'*' {
885                        pi += 1;
886                    }
887                    if (flags & WM_PATHNAME) == 0 {
888                        match_slash = true;
889                    } else if (prev_p < 2 || p[prev_p - 2] == b'/')
890                        && (pi == p.len()
891                            || p[pi] == b'/'
892                            || (p[pi] == b'\\' && pi + 1 < p.len() && p[pi + 1] == b'/'))
893                    {
894                        if pi < p.len()
895                            && p[pi] == b'/'
896                            && dowild(&p[pi + 1..], &text[ti..], flags) == WM_MATCH
897                        {
898                            return WM_MATCH;
899                        }
900                        match_slash = true;
901                    } else {
902                        match_slash = false;
903                    }
904                } else {
905                    match_slash = (flags & WM_PATHNAME) == 0;
906                }
907
908                if pi == p.len() {
909                    // Trailing "**" matches everything; trailing "*" matches only
910                    // if there are no more slashes.
911                    if !match_slash && text[ti..].contains(&b'/') {
912                        return WM_ABORT_TO_STARSTAR;
913                    }
914                    return WM_MATCH;
915                } else if !match_slash && p[pi] == b'/' {
916                    // _one_ asterisk followed by a slash with WM_PATHNAME matches
917                    // the next directory.
918                    match text[ti..].iter().position(|&c| c == b'/') {
919                        None => return WM_ABORT_ALL,
920                        Some(off) => {
921                            ti += off; // point at the slash; consumed by loop end
922                        }
923                    }
924                    // emulate `break` then the for-loop's `text++; p++` increment:
925                    pi += 1;
926                    ti += 1;
927                    continue;
928                }
929
930                // The matching loop.
931                let mut cur_t = ti;
932                loop {
933                    let mut tc = if cur_t < text.len() { text[cur_t] } else { 0 };
934                    if tc == 0 {
935                        break;
936                    }
937                    if !wm_is_glob_special(p[pi]) {
938                        let mut pc = p[pi];
939                        if (flags & WM_CASEFOLD) != 0 && wm_isupper(pc) {
940                            pc = wm_tolower(pc);
941                        }
942                        loop {
943                            tc = if cur_t < text.len() { text[cur_t] } else { 0 };
944                            if tc == 0 {
945                                break;
946                            }
947                            if !(match_slash || tc != b'/') {
948                                break;
949                            }
950                            let mut tcf = tc;
951                            if (flags & WM_CASEFOLD) != 0 && wm_isupper(tcf) {
952                                tcf = wm_tolower(tcf);
953                            }
954                            if tcf == pc {
955                                break;
956                            }
957                            cur_t += 1;
958                        }
959                        // Recompute the casefolded tc for the comparison below.
960                        let tc_cmp = {
961                            let raw = if cur_t < text.len() { text[cur_t] } else { 0 };
962                            if (flags & WM_CASEFOLD) != 0 && wm_isupper(raw) {
963                                wm_tolower(raw)
964                            } else {
965                                raw
966                            }
967                        };
968                        if tc_cmp != pc {
969                            if match_slash {
970                                return WM_ABORT_ALL;
971                            } else {
972                                return WM_ABORT_TO_STARSTAR;
973                            }
974                        }
975                    }
976                    let matched = dowild(&p[pi..], &text[cur_t..], flags);
977                    if matched != WM_NOMATCH {
978                        if !match_slash || matched != WM_ABORT_TO_STARSTAR {
979                            return matched;
980                        }
981                    } else {
982                        let cur_raw = if cur_t < text.len() { text[cur_t] } else { 0 };
983                        if !match_slash && cur_raw == b'/' {
984                            return WM_ABORT_TO_STARSTAR;
985                        }
986                    }
987                    cur_t += 1;
988                }
989                return WM_ABORT_ALL;
990            }
991            b'[' => {
992                pi += 1;
993                let mut p_ch2 = if pi < p.len() { p[pi] } else { 0 };
994                if p_ch2 == b'^' {
995                    p_ch2 = b'!';
996                }
997                let negated = p_ch2 == b'!';
998                if negated {
999                    pi += 1;
1000                    p_ch2 = if pi < p.len() { p[pi] } else { 0 };
1001                }
1002                let mut prev_ch: u8 = 0;
1003                let mut matched = false;
1004                loop {
1005                    if p_ch2 == 0 {
1006                        return WM_ABORT_ALL;
1007                    }
1008                    let mut next_prev: u8 = p_ch2;
1009                    let mut skip_class = false;
1010                    if p_ch2 == b'\\' {
1011                        pi += 1;
1012                        p_ch2 = if pi < p.len() { p[pi] } else { 0 };
1013                        if p_ch2 == 0 {
1014                            return WM_ABORT_ALL;
1015                        }
1016                        if t_ch == p_ch2 {
1017                            matched = true;
1018                        }
1019                        next_prev = p_ch2;
1020                    } else if p_ch2 == b'-' && prev_ch != 0 && pi + 1 < p.len() && p[pi + 1] != b']'
1021                    {
1022                        pi += 1;
1023                        p_ch2 = p[pi];
1024                        if p_ch2 == b'\\' {
1025                            pi += 1;
1026                            p_ch2 = if pi < p.len() { p[pi] } else { 0 };
1027                            if p_ch2 == 0 {
1028                                return WM_ABORT_ALL;
1029                            }
1030                        }
1031                        if t_ch <= p_ch2 && t_ch >= prev_ch {
1032                            matched = true;
1033                        } else if (flags & WM_CASEFOLD) != 0 && wm_islower(t_ch) {
1034                            let t_up = wm_toupper(t_ch);
1035                            if t_up <= p_ch2 && t_up >= prev_ch {
1036                                matched = true;
1037                            }
1038                        }
1039                        next_prev = 0;
1040                    } else if p_ch2 == b'[' && pi + 1 < p.len() && p[pi + 1] == b':' {
1041                        // [:class:]
1042                        let s = pi + 2;
1043                        let mut scan = s;
1044                        loop {
1045                            if scan >= p.len() {
1046                                break;
1047                            }
1048                            if p[scan] == b']' {
1049                                break;
1050                            }
1051                            scan += 1;
1052                        }
1053                        pi = scan;
1054                        p_ch2 = if pi < p.len() { p[pi] } else { 0 };
1055                        if p_ch2 == 0 {
1056                            return WM_ABORT_ALL;
1057                        }
1058                        // i = p - s - 1 (length of class name); require trailing ':'
1059                        let class_end = pi; // index of ']'
1060                        if class_end < s + 1 || p[class_end - 1] != b':' {
1061                            // Not a real [:class:]; treat '[' as a literal set member.
1062                            pi = s.wrapping_sub(2);
1063                            p_ch2 = b'[';
1064                            if t_ch == p_ch2 {
1065                                matched = true;
1066                            }
1067                            skip_class = true;
1068                            next_prev = p_ch2;
1069                        } else {
1070                            let class = &p[s..class_end - 1];
1071                            match wm_class_matches(class, t_ch, flags) {
1072                                Some(true) => matched = true,
1073                                Some(false) => {}
1074                                None => return WM_ABORT_ALL,
1075                            }
1076                            next_prev = 0;
1077                        }
1078                    } else if t_ch == p_ch2 {
1079                        matched = true;
1080                    }
1081
1082                    let _ = skip_class;
1083                    // next: advance to the next class char
1084                    prev_ch = next_prev;
1085                    pi += 1;
1086                    p_ch2 = if pi < p.len() { p[pi] } else { 0 };
1087                    if p_ch2 == b']' {
1088                        break;
1089                    }
1090                }
1091                if matched == negated || ((flags & WM_PATHNAME) != 0 && t_ch == b'/') {
1092                    return WM_NOMATCH;
1093                }
1094                pi += 1;
1095                ti += 1;
1096                continue;
1097            }
1098            b'\\' => {
1099                // Literal match with the following character. p[pi+1]=='\0'
1100                // failure is handled by the default arm below.
1101                pi += 1;
1102                let lit = if pi < p.len() { p[pi] } else { 0 };
1103                let lit = if (flags & WM_CASEFOLD) != 0 && wm_isupper(lit) {
1104                    wm_tolower(lit)
1105                } else {
1106                    lit
1107                };
1108                if t_ch != lit {
1109                    return WM_NOMATCH;
1110                }
1111                pi += 1;
1112                ti += 1;
1113                continue;
1114            }
1115            _ => {
1116                if t_ch != p_ch {
1117                    return WM_NOMATCH;
1118                }
1119                pi += 1;
1120                ti += 1;
1121                continue;
1122            }
1123        }
1124    }
1125
1126    if ti < text.len() && text[ti] != 0 {
1127        WM_NOMATCH
1128    } else {
1129        WM_MATCH
1130    }
1131}
1132
1133/// Match `pattern` against `text` with git's `wildmatch` semantics.
1134/// `flags` is a bitwise-OR of [`WM_CASEFOLD`] and [`WM_PATHNAME`].
1135pub fn wildmatch(pattern: &[u8], text: &[u8], flags: u32) -> bool {
1136    dowild(pattern, text, flags) == WM_MATCH
1137}
1138
1139#[cfg(test)]
1140mod tests {
1141    use super::*;
1142
1143    fn ps(args: &[&str]) -> Pathspec {
1144        Pathspec::parse(
1145            args.iter().map(|s| s.as_bytes()),
1146            PathspecMatchMagic::default(),
1147        )
1148        .expect("valid pathspec")
1149    }
1150
1151    #[test]
1152    fn empty_pathspec_matches_everything() {
1153        let p = Pathspec::default();
1154        assert!(p.is_empty());
1155        assert!(p.matches(b"any/path"));
1156    }
1157
1158    #[test]
1159    fn literal_prefix_matches_directory_recursively() {
1160        let p = ps(&["src"]);
1161        assert!(p.matches(b"src"));
1162        assert!(p.matches(b"src/lib.rs"));
1163        assert!(!p.matches(b"srcs/lib.rs"));
1164        assert!(!p.matches(b"other"));
1165    }
1166
1167    #[test]
1168    fn exclude_subtracts_from_includes() {
1169        let p = ps(&["src", ":(exclude)src/gen"]);
1170        assert!(p.matches(b"src/lib.rs"));
1171        assert!(!p.matches(b"src/gen/x.rs"));
1172    }
1173
1174    #[test]
1175    fn exclude_shorthand_sigils() {
1176        for spec in [":!foo", ":^foo"] {
1177            let p = ps(&[spec]);
1178            assert!(p.elements()[0].is_exclude());
1179            // exclude-only pathspec keeps everything but the excluded path.
1180            assert!(p.matches(b"bar"));
1181            assert!(!p.matches(b"foo"));
1182        }
1183    }
1184
1185    #[test]
1186    fn icase_magic_folds_case() {
1187        let p = ps(&[":(icase)readme"]);
1188        assert!(p.matches(b"README"));
1189        assert!(p.matches(b"readme"));
1190        let plain = ps(&["readme"]);
1191        assert!(!plain.matches(b"README"));
1192    }
1193
1194    #[test]
1195    fn glob_magic_is_pathname_aware() {
1196        // :(glob)*.rs uses WM_PATHNAME so `*` does not cross `/`.
1197        let p = ps(&[":(glob)*.rs"]);
1198        assert!(p.matches(b"lib.rs"));
1199        assert!(!p.matches(b"src/lib.rs"));
1200        // ** spans directories under glob magic.
1201        let pp = ps(&[":(glob)**/*.rs"]);
1202        assert!(pp.matches(b"src/lib.rs"));
1203    }
1204
1205    #[test]
1206    fn default_wildcard_can_cross_directory_separator() {
1207        let p = ps(&["*file3"]);
1208        assert!(p.matches(b"file3"));
1209        assert!(p.matches(b"subdir/file3"));
1210
1211        let glob = ps(&[":(glob)*file3"]);
1212        assert!(glob.matches(b"file3"));
1213        assert!(!glob.matches(b"subdir/file3"));
1214    }
1215
1216    #[test]
1217    fn literal_magic_disables_wildcards() {
1218        let p = ps(&[":(literal)a*b"]);
1219        assert!(p.matches(b"a*b"));
1220        assert!(!p.matches(b"axxb"));
1221    }
1222
1223    #[test]
1224    fn backslash_marks_pathspec_as_glob_special() {
1225        assert!(pathspec_is_glob(br"a\*b"));
1226        assert!(pathspec_is_glob(br"a\?b"));
1227        assert!(pathspec_is_glob(br"a\[b"));
1228        assert!(!pathspec_is_glob(b"plain/path"));
1229    }
1230
1231    #[test]
1232    fn escaped_wildcards_match_literal_bytes() {
1233        let p = ps(&[r"a\*b", r"a\?b", r"a\[b"]);
1234        assert!(p.matches(b"a*b"));
1235        assert!(p.matches(b"a?b"));
1236        assert!(p.matches(b"a[b"));
1237        assert!(!p.matches(b"axxb"));
1238        assert!(!p.matches(b"acb"));
1239    }
1240
1241    #[test]
1242    fn explicit_glob_literal_magic_overrides_global_defaults() {
1243        let noglob_default = PathspecMatchMagic {
1244            literal: true,
1245            glob: false,
1246            icase: false,
1247            literal_pathspecs: false,
1248        };
1249        let glob = PathspecElement::parse(b":(glob)*.rs", noglob_default).expect("glob override");
1250        assert!(glob.is_glob());
1251        assert!(!glob.magic().literal);
1252        assert!(glob.matches_path(b"lib.rs"));
1253        assert!(!glob.matches_path(b"src/lib.rs"));
1254
1255        let glob_default = PathspecMatchMagic {
1256            literal: false,
1257            glob: true,
1258            icase: false,
1259            literal_pathspecs: false,
1260        };
1261        let literal =
1262            PathspecElement::parse(b":(literal)*.rs", glob_default).expect("literal override");
1263        assert!(!literal.is_glob());
1264        assert!(literal.magic().literal);
1265        assert!(literal.matches_path(b"*.rs"));
1266        assert!(!literal.matches_path(b"lib.rs"));
1267    }
1268
1269    #[test]
1270    fn top_magic_is_parsed() {
1271        let p = ps(&[":(top)src", ":/other"]);
1272        assert!(p.elements()[0].is_top());
1273        assert!(p.elements()[1].is_top());
1274    }
1275
1276    #[test]
1277    fn attr_magic_is_retained() {
1278        let p = ps(&[":(attr:binary)data"]);
1279        assert_eq!(p.elements()[0].attrs(), &[b"binary".to_vec()]);
1280        assert_eq!(p.elements()[0].pattern(), b"data");
1281    }
1282
1283    #[test]
1284    fn combined_magic_words() {
1285        let p = ps(&[":(exclude,icase)Cargo.lock"]);
1286        let el = &p.elements()[0];
1287        assert!(el.is_exclude());
1288        // exclude is case-insensitive: CARGO.LOCK is subtracted too.
1289        assert!(!p.matches(b"CARGO.LOCK"));
1290    }
1291
1292    fn parse_err(arg: &[u8]) -> PathspecParseError {
1293        match Pathspec::parse([arg], PathspecMatchMagic::default()) {
1294            Ok(_) => panic!(
1295                "expected parse error for {:?}",
1296                String::from_utf8_lossy(arg)
1297            ),
1298            Err(e) => e,
1299        }
1300    }
1301
1302    #[test]
1303    fn glob_literal_conflict_is_error() {
1304        assert_eq!(
1305            parse_err(b":(glob,literal)x"),
1306            PathspecParseError::GlobLiteralConflict
1307        );
1308    }
1309
1310    #[test]
1311    fn unknown_magic_is_error() {
1312        assert!(matches!(
1313            parse_err(b":(bogus)x"),
1314            PathspecParseError::UnknownMagic(_)
1315        ));
1316    }
1317
1318    #[test]
1319    fn unterminated_magic_is_error() {
1320        assert_eq!(
1321            parse_err(b":(exclude"),
1322            PathspecParseError::UnterminatedMagic
1323        );
1324    }
1325
1326    #[test]
1327    fn exclude_only_keeps_unmatched() {
1328        let p = ps(&[":(exclude)target"]);
1329        assert!(p.matches(b"src/lib.rs"));
1330        assert!(!p.matches(b"target/debug"));
1331    }
1332}