Skip to content

Commit 143dfeb

Browse files
committed
feat(tests): add comprehensive tests for globSync behavior to align with Node.js fs.globSync, including directory matching and file type checks
1 parent dc1940c commit 143dfeb

File tree

2 files changed

+159
-66
lines changed

2 files changed

+159
-66
lines changed

__test__/glob.spec.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,23 @@
11
import test from 'ava'
22
import { globSync, glob } from '../index.js'
3+
import * as nodeFs from 'node:fs'
34
import { join } from 'path'
5+
import { tmpdir } from 'node:os'
46

57
const CWD = process.cwd()
68

9+
// 构造包含文件和子目录的临时目录,用于验证目录匹配行为
10+
function makeDirFixture(): string {
11+
const base = join(tmpdir(), `hyper-glob-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
12+
nodeFs.mkdirSync(join(base, 'src/sub'), { recursive: true })
13+
nodeFs.writeFileSync(join(base, 'src/a.ts'), '')
14+
nodeFs.writeFileSync(join(base, 'src/b.ts'), '')
15+
nodeFs.writeFileSync(join(base, 'src/sub/c.ts'), '')
16+
nodeFs.mkdirSync(join(base, 'dist'), { recursive: true })
17+
nodeFs.writeFileSync(join(base, 'dist/out.js'), '')
18+
return base
19+
}
20+
721
test('globSync: should find files in current directory', (t) => {
822
const files = globSync('*.json', { cwd: CWD })
923
t.true(files.length > 0)
@@ -90,3 +104,82 @@ test('async: recursive match', async (t) => {
90104
t.true(files.length > 0)
91105
t.true(files.some((f) => f.includes('src/lib.rs')))
92106
})
107+
108+
// ===== 目录匹配行为(对齐 Node.js fs.globSync)=====
109+
110+
test('globSync: "src/*" should include subdirectories matching the pattern', (t) => {
111+
const base = makeDirFixture()
112+
// Node.js: fs.globSync('src/*') 返回 src/ 下的文件 AND 子目录
113+
const results = globSync('src/*', { cwd: base })
114+
const names = results.map((r) => r.replace(/\\/g, '/'))
115+
t.true(names.includes('src/a.ts'), 'should include files')
116+
t.true(names.includes('src/b.ts'), 'should include files')
117+
t.true(names.includes('src/sub'), 'should include directories matching the pattern')
118+
// 不应包含 dist/ 下的内容(不匹配 src/*)
119+
t.false(names.some((n) => n.startsWith('dist')))
120+
})
121+
122+
test('globSync: "**/*.ts" should NOT include directories (dirs lack .ts extension)', (t) => {
123+
const base = makeDirFixture()
124+
const results = globSync('**/*.ts', { cwd: base })
125+
const names = results.map((r) => r.replace(/\\/g, '/'))
126+
// 所有结果应以 .ts 结尾(目录不应被包含)
127+
t.true(names.length > 0)
128+
t.true(
129+
names.every((n) => n.endsWith('.ts')),
130+
`non-.ts entry found: ${names.join(', ')}`,
131+
)
132+
})
133+
134+
test('globSync: "**" should include both files and directories recursively', (t) => {
135+
const base = makeDirFixture()
136+
const results = globSync('**', { cwd: base })
137+
const names = results.map((r) => r.replace(/\\/g, '/'))
138+
// 应包含目录 src、src/sub、dist
139+
t.true(names.includes('src'), 'should include top-level directories')
140+
t.true(names.includes('src/sub'), 'should include nested directories')
141+
// 也应包含文件
142+
t.true(names.some((n) => n.endsWith('.ts')))
143+
})
144+
145+
test('globSync: dir-matching result should have isDirectory()=true with withFileTypes', (t) => {
146+
const base = makeDirFixture()
147+
const results = globSync('src/*', { cwd: base, withFileTypes: true })
148+
t.true(results.length > 0)
149+
const subDir = results.find((r) => typeof r === 'object' && r.name === 'sub')
150+
t.truthy(subDir, 'should include sub directory as Dirent')
151+
if (subDir && typeof subDir === 'object') {
152+
t.true(subDir.isDirectory())
153+
t.false(subDir.isFile())
154+
}
155+
})
156+
157+
test('dual-run: globSync "src/*" should match node:fs.globSync behavior for directories', (t) => {
158+
const base = makeDirFixture()
159+
// node:fs.globSync 自 v22.0.0 起稳定,对齐其目录匹配行为
160+
const nodeResults: string[] = []
161+
try {
162+
// @ts-ignore - globSync 在旧版 Node 可能不存在
163+
const nodeGlob = nodeFs.globSync as ((p: string, o: object) => string[]) | undefined
164+
if (typeof nodeGlob === 'function') {
165+
nodeResults.push(...nodeGlob('src/*', { cwd: base }))
166+
}
167+
} catch {
168+
// 旧版 Node.js 不支持 fs.globSync,跳过对比
169+
t.pass('node:fs.globSync not available, skipping dual-run comparison')
170+
return
171+
}
172+
const hyperResults = globSync('src/*', { cwd: base }).map((r) => r.replace(/\\/g, '/'))
173+
const nodeNorm = nodeResults.map((r) => r.replace(/\\/g, '/'))
174+
// 检查 node.js 返回的目录条目我们也有
175+
const nodeDirs = nodeNorm.filter((n) => {
176+
try {
177+
return nodeFs.statSync(join(base, n)).isDirectory()
178+
} catch {
179+
return false
180+
}
181+
})
182+
for (const d of nodeDirs) {
183+
t.true(hyperResults.includes(d), `should include directory '${d}' as node:fs does`)
184+
}
185+
})

src/glob.rs

Lines changed: 66 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ use napi_derive::napi;
66
use std::path::Path;
77
use std::sync::{Arc, Mutex};
88

9+
// ignore crate 会对文件按 override 白名单过滤,但目录无论是否匹配都会被遍历(以便
10+
// 递归进去找匹配的子条目)。因此目录需要单独用 dir_matcher 测试其路径是否符合模式,
11+
// 只有匹配的目录才加入结果——这与 Node.js fs.globSync 的行为一致:
12+
// - "src/*" → 返回 src/ 下的文件 AND 子目录
13+
// - "**/*.rs" → 只返回 .rs 文件(目录不含 .rs 扩展名,不会匹配)
14+
// - "**" → 返回所有文件和目录(但不含 cwd 根节点自身)
15+
916
#[napi(object)]
1017
#[derive(Clone)]
1118
pub struct GlobOptions {
@@ -33,17 +40,15 @@ pub fn glob_sync(
3340
let with_file_types = opts.with_file_types.unwrap_or(false);
3441
let concurrency = opts.concurrency.unwrap_or(4) as usize;
3542

36-
// Build match rules (Matcher)
37-
// ignore crate handles glob patterns via override
43+
// 构建 override(白名单模式):ignore crate 利用它来过滤文件;
44+
// 同时保留一份 dir_matcher 副本,用于判断目录自身是否匹配模式。
3845
let mut override_builder = OverrideBuilder::new(&cwd);
3946
override_builder
4047
.add(&pattern)
4148
.map_err(|e| Error::from_reason(e.to_string()))?;
4249

43-
if let Some(excludes) = opts.exclude {
50+
if let Some(ref excludes) = opts.exclude {
4451
for ex in excludes {
45-
// ignore crate exclusions usually start with !, or use builder.add_ignore
46-
// For simplicity here, we assume exclude is also a glob pattern, prepend !
4752
override_builder
4853
.add(&format!("!{}", ex))
4954
.map_err(|e| Error::from_reason(e.to_string()))?;
@@ -54,11 +59,14 @@ pub fn glob_sync(
5459
.build()
5560
.map_err(|e| Error::from_reason(e.to_string()))?;
5661

62+
// 复制一份给目录匹配用(walker 会消耗 overrides 所有权)
63+
let dir_matcher = Arc::new(overrides.clone());
64+
5765
let mut builder = WalkBuilder::new(&cwd);
5866
builder
59-
.overrides(overrides) // Apply glob patterns
60-
.standard_filters(opts.git_ignore.unwrap_or(true)) // Automatically handle .gitignore, .ignore etc
61-
.threads(concurrency); // Core: Enable multithreading with one line!
67+
.overrides(overrides)
68+
.standard_filters(opts.git_ignore.unwrap_or(true))
69+
.threads(concurrency);
6270

6371
// We use two vectors to avoid enum overhead in the lock if possible, but Mutex<Vec<T>> is easier
6472
let result_strings = Arc::new(Mutex::new(Vec::new()));
@@ -73,68 +81,60 @@ pub fn glob_sync(
7381
let result_strings = result_strings_clone.clone();
7482
let result_dirents = result_dirents_clone.clone();
7583
let root = root_path.clone();
84+
let dir_matcher = dir_matcher.clone();
7685

7786
Box::new(move |entry| {
78-
match entry {
79-
Ok(entry) => {
80-
// WalkBuilder's overrides already help us include or exclude
81-
// However, ignore crate returns directories too if they match.
82-
// Usually globs like "**/*.js" only match files.
83-
// But "src/*" matches both.
84-
// Let's keep logic: if it matches, we keep it.
85-
// But typically glob returns files.
86-
// If the user wants directories, pattern usually ends with /.
87-
// Standard glob behavior varies.
88-
// For now, let's include everything that matches the pattern overrides.
89-
90-
if entry.depth() == 0 {
91-
return ignore::WalkState::Continue;
92-
}
93-
94-
if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
95-
return ignore::WalkState::Continue;
96-
}
97-
98-
let path = entry.path();
99-
// Make path relative to cwd if possible, similar to node-glob
100-
let relative_path = path.strip_prefix(&root).unwrap_or(path);
101-
let relative_path_str = relative_path.to_string_lossy().to_string();
102-
103-
if with_file_types {
104-
let mut lock = result_dirents.lock().unwrap();
105-
106-
// Convert to Dirent
107-
let parent_path = relative_path
108-
.parent()
109-
.unwrap_or(Path::new(""))
110-
.to_string_lossy()
111-
.to_string();
112-
let name = relative_path
113-
.file_name()
114-
.unwrap_or_default()
115-
.to_string_lossy()
116-
.to_string();
117-
118-
let file_type = if let Some(ft) = entry.file_type() {
119-
get_file_type_id(&ft)
120-
} else {
121-
0 // Unknown
122-
};
123-
124-
lock.push(Dirent {
125-
name,
126-
parent_path,
127-
file_type,
128-
});
129-
} else {
130-
let mut lock = result_strings.lock().unwrap();
131-
lock.push(relative_path_str);
132-
}
133-
}
134-
Err(_) => {
135-
// Handle errors or ignore permission errors
87+
let entry = match entry {
88+
Ok(e) => e,
89+
Err(_) => return ignore::WalkState::Continue,
90+
};
91+
92+
// 跳过 cwd 根节点自身(depth 0)
93+
if entry.depth() == 0 {
94+
return ignore::WalkState::Continue;
95+
}
96+
97+
let path = entry.path();
98+
let relative_path = path.strip_prefix(&root).unwrap_or(path);
99+
let relative_path_str = relative_path.to_string_lossy().to_string();
100+
101+
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
102+
103+
if is_dir {
104+
// 目录:ignore crate 只为遍历而产出,需单独测试路径是否符合模式。
105+
// 与 Node.js 行为一致:模式 "src/*" 会同时返回 src/ 下的文件和子目录。
106+
let matched = dir_matcher.matched(relative_path, true);
107+
if !matched.is_whitelist() {
108+
// 目录本身不匹配模式,但仍继续遍历以便找到匹配的子条目
109+
return ignore::WalkState::Continue;
136110
}
111+
// 目录匹配模式,加入结果后继续遍历
137112
}
113+
// 非目录条目:ignore crate 的 override 白名单已确保它们匹配模式
114+
115+
if with_file_types {
116+
let mut lock = result_dirents.lock().unwrap();
117+
let parent_path = relative_path
118+
.parent()
119+
.unwrap_or(Path::new(""))
120+
.to_string_lossy()
121+
.to_string();
122+
let name = relative_path
123+
.file_name()
124+
.unwrap_or_default()
125+
.to_string_lossy()
126+
.to_string();
127+
let file_type = if let Some(ft) = entry.file_type() {
128+
get_file_type_id(&ft)
129+
} else {
130+
0
131+
};
132+
lock.push(Dirent { name, parent_path, file_type });
133+
} else {
134+
let mut lock = result_strings.lock().unwrap();
135+
lock.push(relative_path_str);
136+
}
137+
138138
ignore::WalkState::Continue
139139
})
140140
});

0 commit comments

Comments
 (0)