Skip to content

Commit 4a18be5

Browse files
committed
feat: add Search::can_match_relative_path().
This way it's possible to match partial input against a pathspec to see if this root would have a chance to actually match.
1 parent 6b25653 commit 4a18be5

File tree

3 files changed

+145
-3
lines changed

3 files changed

+145
-3
lines changed

gix-pathspec/src/pattern.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,12 @@ impl Pattern {
142142
self.signature.contains(MagicSignature::EXCLUDE)
143143
}
144144

145+
/// Returns `true` is this pattern is supposed to always match, as it's either empty or designated `nil`.
146+
/// Note that technically the pattern might still be excluded.
147+
pub fn always_matches(&self) -> bool {
148+
self.is_nil() || self.path.is_empty()
149+
}
150+
145151
/// Translate ourselves to a long display format, that when parsed back will yield the same pattern.
146152
///
147153
/// Note that the

gix-pathspec/src/search/matching.rs

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use crate::{
88

99
impl Search {
1010
/// Return the first [`Match`] of `relative_path`, or `None`.
11-
/// `is_dir` is `true` if `relative_path` is a directory.
11+
/// `is_dir` is `true` if `relative_path` is a directory, or assumed `false` if `None`.
1212
/// `attributes` is called as `attributes(relative_path, case, is_dir, outcome) -> has_match` to obtain for attributes for `relative_path`, if
1313
/// the underlying pathspec defined an attribute filter, to be stored in `outcome`, returning true if there was a match.
1414
/// All attributes of the pathspec have to be present in the defined value for the pathspec to match.
@@ -52,7 +52,7 @@ impl Search {
5252
}
5353

5454
let case = if ignore_case { Case::Fold } else { Case::Sensitive };
55-
let mut is_match = mapping.value.pattern.is_nil() || mapping.value.pattern.path.is_empty();
55+
let mut is_match = mapping.value.pattern.always_matches();
5656
if !is_match {
5757
is_match = if mapping.pattern.first_wildcard_pos.is_none() {
5858
match_verbatim(mapping, relative_path, is_dir, case)
@@ -117,6 +117,54 @@ impl Search {
117117
res
118118
}
119119
}
120+
121+
/// As opposed to [`Self::pattern_matching_relative_path()`], this method will return `true` for a possibly partial `relative_path`
122+
/// if this pathspec *could* match by looking at the shortest shared prefix only.
123+
///
124+
/// This is useful if `relative_path` is a directory leading up to the item that is going to be matched in full later.
125+
/// Note that it should not end with `/` to indicate it's a directory, rather, use `is_dir` to indicate this.
126+
/// `is_dir` is `true` if `relative_path` is a directory, or assumed `false` if `None`.
127+
/// Returns `false` if this pathspec has no chance of ever matching `relative_path`.
128+
pub fn can_match_relative_path(&self, relative_path: &BStr, is_dir: Option<bool>) -> bool {
129+
if self.patterns.is_empty() {
130+
return true;
131+
}
132+
let common_prefix_len = self.common_prefix_len.min(relative_path.len());
133+
if relative_path.get(..common_prefix_len).map_or(true, |rela_path_prefix| {
134+
rela_path_prefix != self.common_prefix()[..common_prefix_len]
135+
}) {
136+
return false;
137+
}
138+
let is_dir = is_dir.unwrap_or_default();
139+
for mapping in &self.patterns {
140+
if mapping.value.pattern.signature.contains(MagicSignature::MUST_BE_DIR) && !is_dir {
141+
continue;
142+
}
143+
let pattern = &mapping.value.pattern;
144+
let common_len = mapping
145+
.pattern
146+
.first_wildcard_pos
147+
.unwrap_or_else(|| pattern.path.len())
148+
.min(relative_path.len());
149+
150+
let pattern_path = pattern.path[..common_len].as_bstr();
151+
let longest_possible_relative_path = &relative_path[..common_len];
152+
let ignore_case = pattern.signature.contains(MagicSignature::ICASE);
153+
let mut is_match = pattern.always_matches();
154+
if !is_match && common_len != 0 {
155+
is_match = if ignore_case {
156+
pattern_path.eq_ignore_ascii_case(longest_possible_relative_path)
157+
} else {
158+
pattern_path == longest_possible_relative_path
159+
};
160+
}
161+
if is_match {
162+
return !pattern.is_excluded();
163+
}
164+
}
165+
166+
self.all_patterns_are_excluded
167+
}
120168
}
121169

122170
fn match_verbatim(

gix-pathspec/tests/search/mod.rs

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,68 @@ fn no_pathspecs_match_everything() -> crate::Result {
1515
})
1616
.expect("matches");
1717
assert_eq!(m.pattern.prefix_directory(), "", "there is no prefix as none was given");
18+
assert_eq!(
19+
m.sequence_number, 0,
20+
"this is actually a fake pattern, as we have to match even though there isn't anything"
21+
);
22+
23+
assert!(search.can_match_relative_path("anything".into(), None));
24+
25+
Ok(())
26+
}
27+
28+
#[test]
29+
fn simplified_search_respects_must_be_dir() -> crate::Result {
30+
let search = gix_pathspec::Search::from_specs(pathspecs(&["a/b/"]), None, Path::new(""))?;
31+
assert!(!search.can_match_relative_path("a".into(), None));
32+
assert!(!search.can_match_relative_path("a".into(), Some(false)));
33+
assert!(search.can_match_relative_path("a".into(), Some(true)));
34+
assert!(search.can_match_relative_path("a/b".into(), Some(true)));
35+
36+
Ok(())
37+
}
38+
39+
#[test]
40+
fn simplified_search_respects_ignore_case() -> crate::Result {
41+
let search = gix_pathspec::Search::from_specs(pathspecs(&[":(icase)foo/**/bar"]), None, Path::new(""))?;
42+
assert!(search.can_match_relative_path("Foo".into(), None));
43+
assert!(search.can_match_relative_path("foo".into(), Some(true)));
44+
assert!(search.can_match_relative_path("FOO/".into(), Some(true)));
45+
46+
Ok(())
47+
}
48+
49+
#[test]
50+
fn simplified_search_respects_all_excluded() -> crate::Result {
51+
let search = gix_pathspec::Search::from_specs(
52+
pathspecs(&[":(exclude)a/file", ":(exclude)b/file"]),
53+
None,
54+
Path::new(""),
55+
)?;
56+
assert!(!search.can_match_relative_path("b".into(), None));
57+
assert!(!search.can_match_relative_path("a".into(), None));
58+
assert!(search.can_match_relative_path("c".into(), None));
59+
assert!(search.can_match_relative_path("c/".into(), None));
60+
61+
Ok(())
62+
}
63+
64+
#[test]
65+
fn simplified_search_handles_nil() -> crate::Result {
66+
let search = gix_pathspec::Search::from_specs(pathspecs(&[":"]), None, Path::new(""))?;
67+
assert!(search.can_match_relative_path("a".into(), None), "everything matches");
68+
assert!(search.can_match_relative_path("a".into(), Some(false)));
69+
assert!(search.can_match_relative_path("a".into(), Some(true)));
70+
assert!(search.can_match_relative_path("a/b".into(), Some(true)));
71+
72+
let search = gix_pathspec::Search::from_specs(pathspecs(&[":(exclude)"]), None, Path::new(""))?;
73+
assert!(
74+
!search.can_match_relative_path("a".into(), None),
75+
"everything does not match"
76+
);
77+
assert!(!search.can_match_relative_path("a".into(), Some(false)));
78+
assert!(!search.can_match_relative_path("a".into(), Some(true)));
79+
assert!(!search.can_match_relative_path("a/b".into(), Some(true)));
1880

1981
Ok(())
2082
}
@@ -28,6 +90,15 @@ fn init_with_exclude() -> crate::Result {
2890
"re-orded so that excluded are first"
2991
);
3092
assert_eq!(search.common_prefix(), "tests");
93+
assert!(
94+
search.can_match_relative_path("tests".into(), Some(true)),
95+
"prefix matches"
96+
);
97+
assert!(
98+
search.can_match_relative_path("test".into(), Some(true)),
99+
"prefix can also be shorter"
100+
);
101+
assert!(!search.can_match_relative_path("outside-of-tests".into(), None));
31102
Ok(())
32103
}
33104

@@ -47,6 +118,7 @@ fn no_pathspecs_respect_prefix() -> crate::Result {
47118
.is_none(),
48119
"not the right prefix"
49120
);
121+
assert!(!search.can_match_relative_path("hello".into(), None));
50122
let m = search
51123
.pattern_matching_relative_path("a/b".into(), None, &mut |_, _, _, _| unreachable!("must not be called"))
52124
.expect("match");
@@ -55,12 +127,16 @@ fn no_pathspecs_respect_prefix() -> crate::Result {
55127
"a",
56128
"the prefix directory matched verbatim"
57129
);
130+
assert!(search.can_match_relative_path("a/".into(), Some(true)));
131+
assert!(search.can_match_relative_path("a".into(), Some(true)));
132+
assert!(!search.can_match_relative_path("a".into(), Some(false)));
133+
assert!(!search.can_match_relative_path("a".into(), None));
58134

59135
Ok(())
60136
}
61137

62138
#[test]
63-
fn prefixes_are_always_case_insensitive() -> crate::Result {
139+
fn prefixes_are_always_case_sensitive() -> crate::Result {
64140
let path = gix_testtools::scripted_fixture_read_only("match_baseline_files.sh")?.join("paths");
65141
let items = baseline::parse_paths(path)?;
66142

@@ -108,6 +184,18 @@ fn prefixes_are_always_case_insensitive() -> crate::Result {
108184
.collect();
109185
assert_eq!(actual, expected, "{spec} {prefix}");
110186
}
187+
188+
let search = gix_pathspec::Search::from_specs(
189+
gix_pathspec::parse(":(icase)bar".as_bytes(), Default::default()),
190+
Some(Path::new("FOO")),
191+
Path::new(""),
192+
)?;
193+
assert!(
194+
!search.can_match_relative_path("foo".into(), Some(true)),
195+
"icase does not apply to the prefix"
196+
);
197+
assert!(search.can_match_relative_path("FOO".into(), Some(true)));
198+
assert!(search.can_match_relative_path("FOO/ba".into(), Some(true)));
111199
Ok(())
112200
}
113201

0 commit comments

Comments
 (0)