Skip to content

Commit 2f1e51e

Browse files
feat(windows): support both "\" and "/" for path separator on windows (#442)
* fix(windows): treat backslash as path separator in constraint matching Indexed paths keep the OS-native separator (\\ on Windows) so that pathdiff, strip_prefix and watcher event lookups all stay byte-identical. Queries and constraints always use /, so path_contains_segment and path_ends_with_suffix now fold / and \\ together when walking boundaries and comparing slices. Glob matching gets a localized / rewrite at the call site because globset operates on strings. Adds windows-latest to the Rust test matrix with a focused regression test covering PathSegment, FilePath and Glob constraints across grep, multi_grep and fuzzy_search. Also gates the unix-only write_relative_cstr / MMAP_THRESHOLD so -D warnings compiles on Windows. Fixes #381 * test(windows): canonicalize tmp base in tests that rely on path equality Three pre-existing tests compared picker-internal (canonical) paths against tmp.path()-derived (potentially 8.3 short-name) paths and silently passed on Linux/macOS. Match the picker's dunce canonicalization in each test so watch_dir, overflow and libgit2-workdir assertions hold on Windows too. score.rs also now detects a forward slash in the query (on top of MAIN_SEPARATOR) so a path-like query enables the path-alignment bonus on Windows. * fix(windows): canonicalize non-matching event paths in file picker lookups FilePicker canonicalizes its base via dunce but watcher events, consumer calls and tests may still pass short-name / different-casing paths. to_relative_path, find_file_index and add_new_file now fall back to canonicalize (or parent canonicalize for deleted files) on Windows, keeping native separators in storage while still resolving non-matching prefixes. * fix(windows): translate '/' to '\\' in fuzzy query parts on Windows Stored paths keep the native separator, so a user query like 'src/core/file.rs' fails the byte-wise frizbee match against 'src\\core\\file.rs'. Translate forward slashes to backslashes only when the query uses them, inside both score_files and score_dirs, so full-path queries still rank the closest path match first. * test(windows): compare fuzzy results by filename in fuzzy_and_grep_combined edit_name/del_name come from repo_files with forward-slash relative paths, but stored paths on Windows use backslashes, so p.contains(edit_name) never matched. Fall back to file_name() which is separator-agnostic. * chore: Update docs for - test(windows): compare fuzzy results by filename in fuzzy_and_grep_combined * test(windows): accept backslash separators in path-shape assertions grep_with_path_constraint, grep_with_negated_path_constraint and the watcher burst test asserted on relative paths with forward-slash prefixes. Stored paths on Windows use '\\', so accept either form. * refactor(constraints): gate backslash separator handling to Windows is_path_sep only folds '\\' on Windows and the two backslash-specific tests are #[cfg(windows)]. Removed verbose examples from the rustdoc on path_contains_segment / path_ends_with_suffix now that the behaviour is covered by the unit tests.
1 parent 52b1e86 commit 2f1e51e

12 files changed

Lines changed: 506 additions & 61 deletions

.github/workflows/rust.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@ jobs:
1717
name: Test
1818
runs-on: ${{ matrix.os }}
1919
strategy:
20+
fail-fast: false
2021
matrix:
21-
os: [ubuntu-latest, macos-latest]
22+
os: [ubuntu-latest, macos-latest, windows-latest]
2223
# Guard against deadlocks in the shared-picker / watcher teardown
2324
# path: a stuck test would otherwise consume a full 6h CI slot.
24-
timeout-minutes: 10
25+
timeout-minutes: 15
2526
steps:
2627
- uses: actions/checkout@v5
2728

crates/fff-core/src/constraints.rs

Lines changed: 90 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,34 @@ pub(crate) trait Constrainable {
4040
fn write_relative_path(&self, arena: ArenaPtr, out: &mut String);
4141
}
4242

43-
/// Check if a relative path ends with the given suffix at a `/` boundary (case-insensitive).
44-
///
45-
/// Returns `true` when the path equals the suffix or the character before the suffix
46-
/// in the path is `/`. This ensures partial directory-name matches are rejected.
47-
///
48-
/// Examples:
49-
/// - `path_ends_with_suffix("libswscale/input.c", "libswscale/input.c")` → true (exact)
50-
/// - `path_ends_with_suffix("foo/libswscale/input.c", "libswscale/input.c")` → true (suffix)
51-
/// - `path_ends_with_suffix("xlibswscale/input.c", "libswscale/input.c")` → false (no boundary)
43+
/// Windows stores paths with `\\`; `/` comes from user queries.
44+
#[inline]
45+
fn is_path_sep(b: u8) -> bool {
46+
#[cfg(windows)]
47+
{
48+
b == b'/' || b == b'\\'
49+
}
50+
#[cfg(not(windows))]
51+
{
52+
b == b'/'
53+
}
54+
}
55+
56+
#[inline]
57+
fn path_slice_eq(a: &[u8], b: &[u8]) -> bool {
58+
if a.len() != b.len() {
59+
return false;
60+
}
61+
a.iter().zip(b).all(|(x, y)| {
62+
if is_path_sep(*x) && is_path_sep(*y) {
63+
true
64+
} else {
65+
x.eq_ignore_ascii_case(y)
66+
}
67+
})
68+
}
69+
70+
/// Path ends with suffix at a path-separator boundary (case-insensitive).
5271
#[inline]
5372
pub fn path_ends_with_suffix(path: &str, suffix: &str) -> bool {
5473
let path_bytes = path.as_bytes();
@@ -59,28 +78,25 @@ pub fn path_ends_with_suffix(path: &str, suffix: &str) -> bool {
5978

6079
let start = path.len() - suffix.len();
6180

62-
// Must land on a char boundary — multi-byte UTF-8 can make
63-
// `path.len() - suffix.len()` land inside a char.
81+
// Multi-byte UTF-8 may put `start` inside a char.
6482
if !path.is_char_boundary(start) {
6583
return false;
6684
}
6785

68-
if !path[start..].eq_ignore_ascii_case(suffix) {
86+
if !path_slice_eq(&path_bytes[start..], suffix_bytes) {
6987
return false;
7088
}
7189

72-
// Exact match, or the character before is '/'.
73-
// `start` is a char boundary but `start - 1` may be inside a multi-byte
74-
// char, so scan backward to find the preceding ASCII byte.
90+
// Exact or preceded by a separator. Scan backward past any multi-byte
91+
// continuation bytes to find the preceding ASCII byte.
7592
if start == 0 {
7693
return true;
7794
}
7895
let mut i = start;
7996
while i > 0 {
8097
i -= 1;
81-
// ASCII bytes (0..128) are single-byte UTF-8 code units
8298
if path_bytes[i] < 128 {
83-
return path_bytes[i] == b'/';
99+
return is_path_sep(path_bytes[i]);
84100
}
85101
}
86102
false
@@ -94,44 +110,40 @@ pub fn file_has_extension(file_name: &str, ext: &str) -> bool {
94110
return false;
95111
}
96112
let start = name_bytes.len() - ext_bytes.len() - 1;
97-
// `.` is ASCII (single byte), so `start` must be a char boundary.
98-
// If it lands inside a multi-byte char the extension can't match.
99113
if start > 0 && !file_name.is_char_boundary(start) {
100114
return false;
101115
}
102116
name_bytes.get(start) == Some(&b'.') && name_bytes[start + 1..].eq_ignore_ascii_case(ext_bytes)
103117
}
104118

105-
/// Supports multi-segment paths like "libswscale/aarch64" (consecutive components).
119+
/// Matches multi-segment queries like `libswscale/aarch64`.
106120
#[inline]
107121
pub fn path_contains_segment(path: &str, segment: &str) -> bool {
108122
let path_bytes = path.as_bytes();
109123
let segment_bytes = segment.as_bytes();
110124
let segment_len = segment_bytes.len();
111125

112-
// Check segment/ at start of path
113126
if path_bytes.len() > segment_len
114-
&& path_bytes.get(segment_len) == Some(&b'/')
127+
&& is_path_sep(path_bytes[segment_len])
115128
&& path.is_char_boundary(segment_len)
116-
&& path_bytes[..segment_len].eq_ignore_ascii_case(segment_bytes)
129+
&& path_slice_eq(&path_bytes[..segment_len], segment_bytes)
117130
{
118131
return true;
119132
}
120133

121-
// Check /segment/ anywhere using byte scanning
122134
if path_bytes.len() < segment_len + 2 {
123135
return false;
124136
}
125137

126138
for i in 0..path_bytes.len().saturating_sub(segment_len + 1) {
127-
if path_bytes[i] == b'/' {
139+
if is_path_sep(path_bytes[i]) {
128140
let start = i + 1;
129141
let end = start + segment_len;
130142
if end < path_bytes.len()
131-
&& path_bytes[end] == b'/'
143+
&& is_path_sep(path_bytes[end])
132144
&& path.is_char_boundary(start)
133145
&& path.is_char_boundary(end)
134-
&& path_bytes[start..end].eq_ignore_ascii_case(segment_bytes)
146+
&& path_slice_eq(&path_bytes[start..end], segment_bytes)
135147
{
136148
return true;
137149
}
@@ -249,13 +261,21 @@ pub(crate) fn apply_constraints<'a, T: Constrainable + Sync>(
249261
let glob_results = if has_globs {
250262
// Build a single contiguous buffer of all relative paths + offset table.
251263
// One allocation for the buffer, one for offsets — NOT one String per file.
264+
// On Windows we fold `\\` into `/` while copying so globset/zlob see a
265+
// canonical separator. The rewrite is in place on bytes we just wrote.
252266
let mut path_buf = Vec::<u8>::new();
253267
let mut offsets = Vec::<(usize, usize)>::with_capacity(items.len());
254268
let mut tmp = String::with_capacity(64);
255269
for item in items.iter() {
256270
let start = path_buf.len();
257271
item.write_relative_path(arena, &mut tmp);
258272
path_buf.extend_from_slice(tmp.as_bytes());
273+
#[cfg(windows)]
274+
for b in &mut path_buf[start..] {
275+
if *b == b'\\' {
276+
*b = b'/';
277+
}
278+
}
259279
offsets.push((start, path_buf.len() - start));
260280
}
261281
let path_refs: Vec<&str> = offsets
@@ -536,6 +556,32 @@ mod tests {
536556
assert!(!path_contains_segment("src", "src")); // no trailing slash
537557
}
538558

559+
#[cfg(windows)]
560+
#[test]
561+
fn test_path_contains_segment_accepts_backslash() {
562+
assert!(path_contains_segment("src\\lib.rs", "src"));
563+
assert!(path_contains_segment(
564+
"app\\modules\\src\\services\\x.lua",
565+
"src"
566+
));
567+
assert!(path_contains_segment("app\\SRC\\x.lua", "src"));
568+
569+
assert!(path_contains_segment(
570+
"foo\\libswscale\\aarch64\\input.S",
571+
"libswscale/aarch64"
572+
));
573+
assert!(path_contains_segment(
574+
"crates\\fff-core\\src\\grep.rs",
575+
"fff-core/src"
576+
));
577+
578+
assert!(!path_contains_segment("mysrc\\lib.rs", "src"));
579+
assert!(!path_contains_segment(
580+
"xlibswscale\\aarch64\\in.S",
581+
"libswscale/aarch64"
582+
));
583+
}
584+
539585
#[test]
540586
fn test_path_ends_with_suffix() {
541587
// Exact match
@@ -580,6 +626,23 @@ mod tests {
580626
assert!(path_ends_with_suffix("crates/src/main.rs", "src/main.rs"));
581627
}
582628

629+
#[cfg(windows)]
630+
#[test]
631+
fn test_path_ends_with_suffix_accepts_backslash() {
632+
assert!(path_ends_with_suffix(
633+
"app\\modules\\src\\services\\handler.lua",
634+
"services/handler.lua"
635+
));
636+
assert!(path_ends_with_suffix(
637+
"foo\\libswscale\\input.c",
638+
"libswscale/input.c"
639+
));
640+
assert!(!path_ends_with_suffix(
641+
"xlibswscale\\input.c",
642+
"libswscale/input.c"
643+
));
644+
}
645+
583646
#[test]
584647
fn test_path_ends_with_suffix_does_not_panic_on_unicode_suffix() {
585648
assert!(!path_ends_with_suffix("유니코드_파일_테스트.csv", "트.c"));

0 commit comments

Comments
 (0)