Skip to content

Commit 7d7983d

Browse files
authored
feat(fmt): add file path discovery (#117)
1 parent e117493 commit 7d7983d

7 files changed

Lines changed: 464 additions & 0 deletions

File tree

packages/rstack/THIRD_PARTY_NOTICES.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,35 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
5858
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
5959
SOFTWARE.
6060

61+
## is-binary-path
62+
63+
This package includes bundled code from [is-binary-path](https://github.com/sindresorhus/is-binary-path).
64+
65+
License: MIT
66+
67+
MIT License
68+
69+
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
70+
Copyright (c) Paul Miller (https://paulmillr.com)
71+
72+
Permission is hereby granted, free of charge, to any person obtaining a copy of
73+
this software and associated documentation files (the "Software"), to deal in
74+
the Software without restriction, including without limitation the rights to
75+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
76+
the Software, and to permit persons to whom the Software is furnished to do so,
77+
subject to the following conditions:
78+
79+
The above copyright notice and this permission notice shall be included in all
80+
copies or substantial portions of the Software.
81+
82+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
83+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
84+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
85+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
86+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
87+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
88+
SOFTWARE.
89+
6190
## micromatch
6291

6392
This package includes bundled code from [micromatch](https://github.com/micromatch/micromatch).
@@ -197,3 +226,31 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
197226
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
198227
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
199228
SOFTWARE.
229+
230+
## tiny-readdir
231+
232+
This package includes bundled code from [tiny-readdir](https://github.com/fabiospampinato/tiny-readdir).
233+
234+
License: MIT
235+
236+
The MIT License (MIT)
237+
238+
Copyright (c) 2020-present Fabio Spampinato
239+
240+
Permission is hereby granted, free of charge, to any person obtaining a
241+
copy of this software and associated documentation files (the "Software"),
242+
to deal in the Software without restriction, including without limitation
243+
the rights to use, copy, modify, merge, publish, distribute, sublicense,
244+
and/or sell copies of the Software, and to permit persons to whom the
245+
Software is furnished to do so, subject to the following conditions:
246+
247+
The above copyright notice and this permission notice shall be included in
248+
all copies or substantial portions of the Software.
249+
250+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
251+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
252+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
253+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
254+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
255+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
256+
DEALINGS IN THE SOFTWARE.

packages/rstack/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,11 @@
6868
"@types/micromatch": "catalog:",
6969
"@types/node": "catalog:",
7070
"fast-ignore": "catalog:",
71+
"is-binary-path": "catalog:",
7172
"lint-staged": "catalog:",
7273
"micromatch": "catalog:",
7374
"rslog": "catalog:",
75+
"tiny-readdir": "catalog:",
7476
"typescript": "catalog:"
7577
},
7678
"peerDependencies": {
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import { lstat } from 'node:fs/promises';
2+
import path from 'node:path';
3+
import isBinaryPath from 'is-binary-path';
4+
import micromatch from 'micromatch';
5+
import readdir, { type Dirent } from 'tiny-readdir';
6+
7+
const alwaysIgnoredNames = new Set(['.git', '.sl', '.svn', '.hg', '.jj', 'node_modules']);
8+
9+
interface DiscoverFmtPathsOptions {
10+
/** Absolute directory used to resolve input paths. */
11+
cwd: string;
12+
patterns?: string[];
13+
}
14+
15+
const isErrnoException = (error: unknown): error is NodeJS.ErrnoException =>
16+
error instanceof Error && 'code' in error;
17+
18+
const lstatSafe = async (filePath: string) => {
19+
try {
20+
return await lstat(filePath);
21+
} catch (error) {
22+
if (!isErrnoException(error) || error.code !== 'ENOENT') {
23+
throw error;
24+
}
25+
}
26+
};
27+
28+
const isRelativePathInside = (relativePath: string): boolean =>
29+
relativePath !== '..' &&
30+
!relativePath.startsWith(`..${path.sep}`) &&
31+
!path.isAbsolute(relativePath);
32+
33+
const isPathInside = (rootPath: string, filePath: string): boolean =>
34+
isRelativePathInside(path.relative(rootPath, filePath));
35+
36+
const toPosixPath = (filePath: string): string =>
37+
path.sep === '\\' ? filePath.replaceAll('\\', '/') : filePath;
38+
39+
/** Supports both the legacy tiny-readdir type and Node.js 24 Dirent. */
40+
const getDirentParentPath = (dirent: Dirent): string =>
41+
(dirent as Dirent & { parentPath?: string }).parentPath ?? dirent.path;
42+
43+
/** Mirrors the path passed to tiny-readdir's ignore callback. */
44+
const getDirentPath = (dirent: Dirent, parentPath: string): string =>
45+
`${parentPath}${parentPath === path.sep ? '' : path.sep}${dirent.name}`;
46+
47+
const hasAlwaysIgnoredSegment = (cwd: string, filePath: string): boolean =>
48+
path
49+
.relative(cwd, filePath)
50+
.split(path.sep)
51+
.some((segment) => alwaysIgnoredNames.has(segment));
52+
53+
const createTraversalOptions = (isIncluded?: (filePath: string) => boolean) => {
54+
// tiny-readdir passes only a path to `ignore`, so retain the dirent type briefly.
55+
const directories = new Set<string>();
56+
57+
return {
58+
followSymlinks: false,
59+
ignore: (targetPath: string) => {
60+
const isDirectory = directories.delete(targetPath);
61+
if (alwaysIgnoredNames.has(path.basename(targetPath))) {
62+
return true;
63+
}
64+
65+
return (
66+
!isDirectory &&
67+
(isBinaryPath(targetPath) || (isIncluded !== undefined && !isIncluded(targetPath)))
68+
);
69+
},
70+
onDirents: (dirents: Dirent[]) => {
71+
const parentPath = getDirentParentPath(dirents[0]);
72+
73+
for (const dirent of dirents) {
74+
if (dirent.isDirectory()) {
75+
directories.add(getDirentPath(dirent, parentPath));
76+
}
77+
}
78+
79+
return undefined;
80+
},
81+
};
82+
};
83+
84+
const normalizeGlob = (cwd: string, pattern: string): string => {
85+
const relativePattern = path.isAbsolute(pattern) ? path.relative(cwd, pattern) : pattern;
86+
return toPosixPath(relativePattern);
87+
};
88+
89+
type PatternEntry = {
90+
kind: 'file' | 'directory' | 'glob' | 'negative-glob';
91+
value: string;
92+
};
93+
94+
type ClassifiedPatterns = {
95+
files: string[];
96+
directories: string[];
97+
globs: string[];
98+
negativeGlobs: string[];
99+
};
100+
101+
const classifyPatterns = async (cwd: string, patterns: string[]): Promise<ClassifiedPatterns> => {
102+
const entries = await Promise.all(
103+
patterns.map(async (pattern): Promise<PatternEntry | undefined> => {
104+
if (pattern.startsWith('!')) {
105+
return { kind: 'negative-glob', value: normalizeGlob(cwd, pattern.slice(1)) };
106+
}
107+
108+
const filePath = path.resolve(cwd, pattern);
109+
if (hasAlwaysIgnoredSegment(cwd, filePath)) {
110+
return;
111+
}
112+
113+
const stats = await lstatSafe(filePath);
114+
if (stats?.isFile()) {
115+
return { kind: 'file', value: filePath };
116+
}
117+
if (stats?.isDirectory()) {
118+
return { kind: 'directory', value: filePath };
119+
}
120+
if (stats) {
121+
return;
122+
}
123+
124+
return { kind: 'glob', value: normalizeGlob(cwd, pattern) };
125+
}),
126+
);
127+
128+
const result: ClassifiedPatterns = {
129+
files: [],
130+
directories: [],
131+
globs: [],
132+
negativeGlobs: [],
133+
};
134+
135+
for (const entry of entries) {
136+
if (!entry) {
137+
continue;
138+
}
139+
140+
switch (entry.kind) {
141+
case 'file':
142+
result.files.push(entry.value);
143+
break;
144+
case 'directory':
145+
result.directories.push(entry.value);
146+
break;
147+
case 'glob':
148+
result.globs.push(entry.value);
149+
break;
150+
case 'negative-glob':
151+
result.negativeGlobs.push(entry.value);
152+
break;
153+
}
154+
}
155+
156+
return result;
157+
};
158+
159+
const getOutermostPaths = (paths: string[]): string[] => {
160+
const sortedPaths = [...new Set(paths)].sort((left, right) => left.length - right.length);
161+
const outermostPaths: string[] = [];
162+
163+
for (const filePath of sortedPaths) {
164+
if (!outermostPaths.some((parentPath) => isPathInside(parentPath, filePath))) {
165+
outermostPaths.push(filePath);
166+
}
167+
}
168+
169+
return outermostPaths;
170+
};
171+
172+
/** Merges overlapping roots; micromatch remains responsible for glob syntax. */
173+
const getTraversalRoots = (cwd: string, directories: string[], globs: string[]): string[] => {
174+
const globRoots = globs.map((pattern) => path.resolve(cwd, micromatch.scan(pattern).base || '.'));
175+
176+
return getOutermostPaths([...directories, ...globRoots]);
177+
};
178+
179+
const discoverFmtPaths = async ({
180+
cwd,
181+
patterns: inputPatterns,
182+
}: DiscoverFmtPathsOptions): Promise<string[]> => {
183+
const patterns = inputPatterns?.length ? inputPatterns : ['.'];
184+
const {
185+
files: explicitFiles,
186+
directories,
187+
globs,
188+
negativeGlobs,
189+
} = await classifyPatterns(cwd, patterns);
190+
const directoryRoots = getOutermostPaths(directories);
191+
const globMatchers = globs.map((pattern) => micromatch.matcher(pattern, { dot: true }));
192+
const candidates = new Set(explicitFiles);
193+
const traversalRoots = getTraversalRoots(cwd, directoryRoots, globs);
194+
195+
const results = await Promise.all(
196+
traversalRoots.map(async (rootPath) => {
197+
const stats = await lstatSafe(rootPath);
198+
if (!stats?.isDirectory()) {
199+
return [];
200+
}
201+
202+
const includesAll = directoryRoots.some((directoryPath) =>
203+
isPathInside(directoryPath, rootPath),
204+
);
205+
const isIncluded = includesAll
206+
? undefined
207+
: (filePath: string): boolean => {
208+
if (directoryRoots.some((directoryPath) => isPathInside(directoryPath, filePath))) {
209+
return true;
210+
}
211+
212+
const relativePath = toPosixPath(path.relative(cwd, filePath));
213+
return globMatchers.some((matches) => matches(relativePath));
214+
};
215+
216+
return (await readdir(rootPath, createTraversalOptions(isIncluded))).files;
217+
}),
218+
);
219+
220+
for (const files of results) {
221+
for (const filePath of files) {
222+
candidates.add(filePath);
223+
}
224+
}
225+
226+
const negativeGlobMatchers = negativeGlobs.map((pattern) =>
227+
micromatch.matcher(pattern, { dot: true }),
228+
);
229+
const filePaths: string[] = [];
230+
231+
for (const filePath of candidates) {
232+
if (isBinaryPath(filePath)) {
233+
continue;
234+
}
235+
236+
if (negativeGlobMatchers.length) {
237+
const relativePath = toPosixPath(path.relative(cwd, filePath));
238+
if (negativeGlobMatchers.some((matches) => matches(relativePath))) {
239+
continue;
240+
}
241+
}
242+
243+
filePaths.push(filePath);
244+
}
245+
246+
return filePaths.sort();
247+
};
248+
249+
export { discoverFmtPaths };

0 commit comments

Comments
 (0)