History

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.

Sebastian Thiel committed 2 years ago

Commit e409e8d1 parent d0c5a0e3 files · +233 −3Browse files

Unified · Split

3 files
  1. gix-pathspec/src/pattern.rs +6 −0
  2. gix-pathspec/src/search/matching.rs +63 −2
  3. gix-pathspec/tests/search/mod.rs +164 −1
gix-pathspec/src/pattern.rs+6−0
@@ -142,6 +142,12 @@ impl Pattern { · 141 unmodified lines
142 self.signature.contains(MagicSignature::EXCLUDE)142 self.signature.contains(MagicSignature::EXCLUDE)
143 }143 }
144 144
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
145 /// Translate ourselves to a long display format, that when parsed back will yield the same pattern.151 /// Translate ourselves to a long display format, that when parsed back will yield the same pattern.
146 ///152 ///
147 /// Note that the153 /// Note that the
gix-pathspec/src/search/matching.rs+63−2
@@ -8,7 +8,7 @@ use crate::{ · 7 unmodified lines
8 8
9impl Search {9impl Search {
10 /// Return the first [`Match`] of `relative_path`, or `None`.10 /// 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`.
12 /// `attributes` is called as `attributes(relative_path, case, is_dir, outcome) -> has_match` to obtain for attributes for `relative_path`, if12 /// `attributes` is called as `attributes(relative_path, case, is_dir, outcome) -> has_match` to obtain for attributes for `relative_path`, if
13 /// the underlying pathspec defined an attribute filter, to be stored in `outcome`, returning true if there was a match.13 /// the underlying pathspec defined an attribute filter, to be stored in `outcome`, returning true if there was a match.
14 /// All attributes of the pathspec have to be present in the defined value for the pathspec to match.14 /// All attributes of the pathspec have to be present in the defined value for the pathspec to match.
@@ -52,7 +52,7 @@ impl Search { · 37 unmodified lines
52 }52 }
53 53
54 let case = if ignore_case { Case::Fold } else { Case::Sensitive };54 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();
56 if !is_match {56 if !is_match {
57 is_match = if mapping.pattern.first_wildcard_pos.is_none() {57 is_match = if mapping.pattern.first_wildcard_pos.is_none() {
58 match_verbatim(mapping, relative_path, is_dir, case)58 match_verbatim(mapping, relative_path, is_dir, case)
@@ -117,6 +117,67 @@ impl Search { · 58 unmodified lines
117 res117 res
118 }118 }
119 }119 }
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 for mapping in &self.patterns {
139 let pattern = &mapping.value.pattern;
140 if mapping.pattern.first_wildcard_pos == Some(0) && !pattern.is_excluded() {
141 return true;
142 }
143 let max_usable_pattern_len = mapping.pattern.first_wildcard_pos.unwrap_or_else(|| pattern.path.len());
144 let common_len = max_usable_pattern_len.min(relative_path.len());
145
146 let pattern_path = pattern.path[..common_len].as_bstr();
147 let longest_possible_relative_path = &relative_path[..common_len];
148 let ignore_case = pattern.signature.contains(MagicSignature::ICASE);
149 let mut is_match = pattern.always_matches();
150 if !is_match && common_len != 0 {
151 is_match = if ignore_case {
152 pattern_path.eq_ignore_ascii_case(longest_possible_relative_path)
153 } else {
154 pattern_path == longest_possible_relative_path
155 };
156
157 if is_match {
158 is_match = if common_len < max_usable_pattern_len {
159 pattern.path.get(common_len) == Some(&b'/')
160 } else if relative_path.len() > max_usable_pattern_len {
161 relative_path.get(common_len) == Some(&b'/')
162 } else {
163 is_match
164 };
165 if let Some(is_dir) = is_dir.filter(|_| pattern.signature.contains(MagicSignature::MUST_BE_DIR)) {
166 is_match = if is_dir {
167 matches!(pattern.path.get(common_len), None | Some(&b'/'))
168 } else {
169 relative_path.get(common_len) == Some(&b'/')
170 }
171 }
172 }
173 }
174 if is_match {
175 return !pattern.is_excluded();
176 }
177 }
178
179 self.all_patterns_are_excluded
180 }
120}181}
121 182
122fn match_verbatim(183fn match_verbatim(
gix-pathspec/tests/search/mod.rs+164−1
@@ -15,6 +15,139 @@ fn no_pathspecs_match_everything() -> crate::Result { · 14 unmodified lines
15 })15 })
16 .expect("matches");16 .expect("matches");
17 assert_eq!(m.pattern.prefix_directory(), "", "there is no prefix as none was given");17 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]
29fn simplified_search_respects_must_be_dir() -> crate::Result {
30 let mut search = gix_pathspec::Search::from_specs(pathspecs(&["a/be/"]), None, Path::new(""))?;
31 search
32 .pattern_matching_relative_path("a/be/file".into(), Some(false), &mut |_, _, _, _| {
33 unreachable!("must not be called")
34 })
35 .expect("matches as this is a prefix match");
36 assert!(
37 !search.can_match_relative_path("any".into(), Some(false)),
38 "not our directory: a, and must be dir"
39 );
40 assert!(
41 !search.can_match_relative_path("any".into(), Some(true)),
42 "not our directory: a"
43 );
44 assert!(
45 !search.can_match_relative_path("any".into(), None),
46 "not our directory: a, and must be dir, still completely out of scope"
47 );
48 assert!(
49 !search.can_match_relative_path("a/bei".into(), None),
50 "not our directory: a/be"
51 );
52 assert!(!search.can_match_relative_path("a".into(), Some(false)), "must be dir");
53 assert!(search.can_match_relative_path("a".into(), Some(true)));
54 assert!(
55 search.can_match_relative_path("a".into(), None),
56 "now dir or not doesn't matter"
57 );
58 assert!(search.can_match_relative_path("a/be".into(), Some(true)));
59 assert!(
60 search.can_match_relative_path("a/be".into(), None),
61 "dir doesn't matter anymore"
62 );
63 assert!(
64 !search.can_match_relative_path("a/be".into(), Some(false)),
65 "files can't match as prefix"
66 );
67 assert!(
68 search.can_match_relative_path("a/be/file".into(), Some(false)),
69 "files can match if they are part of the suffix"
70 );
71
72 assert!(
73 !search.can_match_relative_path("a/b".into(), Some(false)),
74 "can't match a/be"
75 );
76 assert!(
77 !search.can_match_relative_path("a/b".into(), None),
78 "still can't match a/be"
79 );
80 assert!(
81 search
82 .pattern_matching_relative_path("a/b".into(), None, &mut |_, _, _, _| unreachable!("must not be called"))
83 .is_none(),
84 "no match if it's not the whole pattern that matches"
85 );
86 assert!(
87 !search.can_match_relative_path("a/b".into(), Some(true)),
88 "can't match a/be, which must be directory"
89 );
90
91 Ok(())
92}
93
94#[test]
95fn simplified_search_respects_ignore_case() -> crate::Result {
96 let search = gix_pathspec::Search::from_specs(pathspecs(&[":(icase)foo/**/bar"]), None, Path::new(""))?;
97 assert!(search.can_match_relative_path("Foo".into(), None));
98 assert!(search.can_match_relative_path("foo".into(), Some(true)));
99 assert!(search.can_match_relative_path("FOO/".into(), Some(true)));
100
101 Ok(())
102}
103
104#[test]
105fn simplified_search_respects_all_excluded() -> crate::Result {
106 let search = gix_pathspec::Search::from_specs(
107 pathspecs(&[":(exclude)a/file", ":(exclude)b/file"]),
108 None,
109 Path::new(""),
110 )?;
111 assert!(!search.can_match_relative_path("b".into(), None));
112 assert!(!search.can_match_relative_path("a".into(), None));
113 assert!(search.can_match_relative_path("c".into(), None));
114 assert!(search.can_match_relative_path("c/".into(), None));
115
116 Ok(())
117}
118
119#[test]
120fn simplified_search_wildcards() -> crate::Result {
121 let search = gix_pathspec::Search::from_specs(pathspecs(&["**/a*"]), None, Path::new(""))?;
122 assert!(
123 search.can_match_relative_path("a".into(), None),
124 "it can't determine it, so assume match"
125 );
126 assert!(search.can_match_relative_path("a/a".into(), Some(false)));
127 assert!(search.can_match_relative_path("a/a.o".into(), Some(false)));
128 assert!(
129 search.can_match_relative_path("b-unrelated".into(), None),
130 "this is also assumed to be a match, prefer false-positives over false-negatives"
131 );
132 Ok(())
133}
134
135#[test]
136fn simplified_search_handles_nil() -> crate::Result {
137 let search = gix_pathspec::Search::from_specs(pathspecs(&[":"]), None, Path::new(""))?;
138 assert!(search.can_match_relative_path("a".into(), None), "everything matches");
139 assert!(search.can_match_relative_path("a".into(), Some(false)));
140 assert!(search.can_match_relative_path("a".into(), Some(true)));
141 assert!(search.can_match_relative_path("a/b".into(), Some(true)));
142
143 let search = gix_pathspec::Search::from_specs(pathspecs(&[":(exclude)"]), None, Path::new(""))?;
144 assert!(
145 !search.can_match_relative_path("a".into(), None),
146 "everything does not match"
147 );
148 assert!(!search.can_match_relative_path("a".into(), Some(false)));
149 assert!(!search.can_match_relative_path("a".into(), Some(true)));
150 assert!(!search.can_match_relative_path("a/b".into(), Some(true)));
18 151
19 Ok(())152 Ok(())
20}153}
@@ -28,6 +161,15 @@ fn init_with_exclude() -> crate::Result { · 7 unmodified lines
28 "re-orded so that excluded are first"161 "re-orded so that excluded are first"
29 );162 );
30 assert_eq!(search.common_prefix(), "tests");163 assert_eq!(search.common_prefix(), "tests");
164 assert!(
165 search.can_match_relative_path("tests".into(), Some(true)),
166 "prefix matches"
167 );
168 assert!(
169 !search.can_match_relative_path("test".into(), Some(true)),
170 "prefix can not be shorter"
171 );
172 assert!(!search.can_match_relative_path("outside-of-tests".into(), None));
31 Ok(())173 Ok(())
32}174}
33 175
@@ -47,6 +189,7 @@ fn no_pathspecs_respect_prefix() -> crate::Result { · 13 unmodified lines
47 .is_none(),189 .is_none(),
48 "not the right prefix"190 "not the right prefix"
49 );191 );
192 assert!(!search.can_match_relative_path("hello".into(), None));
50 let m = search193 let m = search
51 .pattern_matching_relative_path("a/b".into(), None, &mut |_, _, _, _| unreachable!("must not be called"))194 .pattern_matching_relative_path("a/b".into(), None, &mut |_, _, _, _| unreachable!("must not be called"))
52 .expect("match");195 .expect("match");
@@ -55,12 +198,16 @@ fn no_pathspecs_respect_prefix() -> crate::Result { · 2 unmodified lines
55 "a",198 "a",
56 "the prefix directory matched verbatim"199 "the prefix directory matched verbatim"
57 );200 );
201 assert!(search.can_match_relative_path("a/".into(), Some(true)));
202 assert!(search.can_match_relative_path("a".into(), Some(true)));
203 assert!(!search.can_match_relative_path("a".into(), Some(false)));
204 assert!(search.can_match_relative_path("a".into(), None), "simple prefix search");
58 205
59 Ok(())206 Ok(())
60}207}
61 208
62#[test]209#[test]
63fn prefixes_are_always_case_insensitive() -> crate::Result {210fn prefixes_are_always_case_sensitive() -> crate::Result {
64 let path = gix_testtools::scripted_fixture_read_only("match_baseline_files.sh")?.join("paths");211 let path = gix_testtools::scripted_fixture_read_only("match_baseline_files.sh")?.join("paths");
65 let items = baseline::parse_paths(path)?;212 let items = baseline::parse_paths(path)?;
66 213
@@ -108,6 +255,22 @@ fn prefixes_are_always_case_insensitive() -> crate::Result { · 41 unmodified lines
108 .collect();255 .collect();
109 assert_eq!(actual, expected, "{spec} {prefix}");256 assert_eq!(actual, expected, "{spec} {prefix}");
110 }257 }
258
259 let search = gix_pathspec::Search::from_specs(
260 gix_pathspec::parse(":(icase)bar".as_bytes(), Default::default()),
261 Some(Path::new("FOO")),
262 Path::new(""),
263 )?;
264 assert!(
265 !search.can_match_relative_path("foo".into(), Some(true)),
266 "icase does not apply to the prefix"
267 );
268 assert!(search.can_match_relative_path("FOO".into(), Some(true)));
269 assert!(
270 !search.can_match_relative_path("FOO/ba".into(), Some(true)),
271 "a full match is needed"
272 );
273 assert!(search.can_match_relative_path("FOO/bar".into(), Some(true)));
111 Ok(())274 Ok(())
112}275}
113 276