Skip to content

Commit 45ecc9b

Browse files
small performance optimizations (#83)
* perf: only calculate loop length only once * perf: speed up pattern processing * perf: determine platform only once * perf: speed up glob and path pattern checks further * perf: reduce bundle size by centralizing logging logic * perf: speed up path formatting * perf: reduce bundle size by checking noCase only once * revert: usage of at over indices * perf: reduce number of variables * perf: minify output * Revert "perf: minify output" This reverts commit ab23ee1. * dev: lint * dev: lint again * perf: only calculate loop length only once * perf: speed up pattern processing * perf: determine platform only once * perf: speed up glob and path pattern checks further * perf: reduce bundle size by centralizing logging logic * perf: speed up path formatting * perf: reduce bundle size by checking noCase only once * revert: usage of at over indices * perf: reduce number of variables * perf: minify output * Revert "perf: minify output" This reverts commit ab23ee1. * dev: lint * dev: lint again * perf: reduce output package size by inlining and renaming variables * perf: overhaul getPartialMatcher with cached splitted patterns * fix missing line from merge * make biome pass * tiny cleanup * optimize matcher initialization an old v8 post says creating arrays like this would be deoptimized forever, but in practice they do seem faster this way --------- Co-authored-by: Superchupu <53496941+SuperchupuDev@users.noreply.github.com>
1 parent 8872e68 commit 45ecc9b

File tree

2 files changed

+79
-56
lines changed

2 files changed

+79
-56
lines changed

src/index.ts

Lines changed: 47 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import path, { posix } from 'node:path';
22
import { type Options as FdirOptions, fdir } from 'fdir';
33
import picomatch from 'picomatch';
4-
import { escapePath, getPartialMatcher, isDynamicPattern, splitPattern } from './utils.ts';
4+
import { escapePath, getPartialMatcher, isDynamicPattern, log, splitPattern } from './utils.ts';
55

66
const PARENT_DIRECTORY = /^(\/?\.\.)+/;
77
const ESCAPING_BACKSLASHES = /\\(?=[()[\]{}!*+?@|])/g;
@@ -22,7 +22,7 @@ export interface GlobOptions {
2222
debug?: boolean;
2323
}
2424

25-
interface InternalProperties {
25+
interface InternalProps {
2626
root: string;
2727
commonPath: string[] | null;
2828
depthOffset: number;
@@ -32,7 +32,7 @@ function normalizePattern(
3232
pattern: string,
3333
expandDirectories: boolean,
3434
cwd: string,
35-
properties: InternalProperties,
35+
props: InternalProps,
3636
isIgnore: boolean
3737
) {
3838
let result: string = pattern;
@@ -53,35 +53,36 @@ function normalizePattern(
5353
const parentDirectoryMatch = PARENT_DIRECTORY.exec(result);
5454
if (parentDirectoryMatch?.[0]) {
5555
const potentialRoot = posix.join(cwd, parentDirectoryMatch[0]);
56-
if (properties.root.length > potentialRoot.length) {
57-
properties.root = potentialRoot;
58-
properties.depthOffset = -(parentDirectoryMatch[0].length + 1) / 3;
56+
if (props.root.length > potentialRoot.length) {
57+
props.root = potentialRoot;
58+
props.depthOffset = -(parentDirectoryMatch[0].length + 1) / 3;
5959
}
60-
} else if (!isIgnore && properties.depthOffset >= 0) {
60+
} else if (!isIgnore && props.depthOffset >= 0) {
6161
const parts = splitPattern(result);
62-
properties.commonPath ??= parts;
62+
props.commonPath ??= parts;
6363

64-
const newCommonPath = [];
64+
const newCommonPath: string[] = [];
65+
const length = Math.min(props.commonPath.length, parts.length);
6566

66-
for (let i = 0; i < Math.min(properties.commonPath.length, parts.length); i++) {
67+
for (let i = 0; i < length; i++) {
6768
const part = parts[i];
6869

6970
if (part === '**' && !parts[i + 1]) {
7071
newCommonPath.pop();
7172
break;
7273
}
7374

74-
if (part !== properties.commonPath[i] || isDynamicPattern(part) || i === parts.length - 1) {
75+
if (part !== props.commonPath[i] || isDynamicPattern(part) || i === parts.length - 1) {
7576
break;
7677
}
7778

7879
newCommonPath.push(part);
7980
}
8081

81-
properties.depthOffset = newCommonPath.length;
82-
properties.commonPath = newCommonPath;
82+
props.depthOffset = newCommonPath.length;
83+
props.commonPath = newCommonPath;
8384

84-
properties.root = newCommonPath.length > 0 ? `${cwd}/${newCommonPath.join('/')}` : cwd;
85+
props.root = newCommonPath.length > 0 ? `${cwd}/${newCommonPath.join('/')}` : cwd;
8586
}
8687

8788
return result;
@@ -90,7 +91,7 @@ function normalizePattern(
9091
function processPatterns(
9192
{ patterns, ignore = [], expandDirectories = true }: GlobOptions,
9293
cwd: string,
93-
properties: InternalProperties
94+
props: InternalProps
9495
) {
9596
if (typeof patterns === 'string') {
9697
patterns = [patterns];
@@ -111,22 +112,19 @@ function processPatterns(
111112
continue;
112113
}
113114
// don't handle negated patterns here for consistency with fast-glob
114-
if (!pattern.startsWith('!') || pattern[1] === '(') {
115-
const newPattern = normalizePattern(pattern, expandDirectories, cwd, properties, true);
116-
ignorePatterns.push(newPattern);
115+
if (pattern[0] !== '!' || pattern[1] === '(') {
116+
ignorePatterns.push(normalizePattern(pattern, expandDirectories, cwd, props, true));
117117
}
118118
}
119119

120120
for (const pattern of patterns) {
121121
if (!pattern) {
122122
continue;
123123
}
124-
if (!pattern.startsWith('!') || pattern[1] === '(') {
125-
const newPattern = normalizePattern(pattern, expandDirectories, cwd, properties, false);
126-
matchPatterns.push(newPattern);
124+
if (pattern[0] !== '!' || pattern[1] === '(') {
125+
matchPatterns.push(normalizePattern(pattern, expandDirectories, cwd, props, false));
127126
} else if (pattern[1] !== '!' || pattern[2] === '(') {
128-
const newPattern = normalizePattern(pattern.slice(1), expandDirectories, cwd, properties, true);
129-
ignorePatterns.push(newPattern);
127+
ignorePatterns.push(normalizePattern(pattern.slice(1), expandDirectories, cwd, props, true));
130128
}
131129
}
132130

@@ -148,35 +146,44 @@ function processPath(path: string, cwd: string, root: string, isDirectory: boole
148146
return getRelativePath(relativePath, cwd, root);
149147
}
150148

149+
function formatPaths(paths: string[], cwd: string, root: string) {
150+
for (let i = paths.length - 1; i >= 0; i--) {
151+
const path = paths[i];
152+
paths[i] = getRelativePath(path, cwd, root) + (!path || path.endsWith('/') ? '/' : '');
153+
}
154+
return paths;
155+
}
156+
151157
function crawl(options: GlobOptions, cwd: string, sync: false): Promise<string[]>;
152158
function crawl(options: GlobOptions, cwd: string, sync: true): string[];
153159
function crawl(options: GlobOptions, cwd: string, sync: boolean) {
154160
if (Array.isArray(options.patterns) && options.patterns.length === 0) {
155161
return sync ? [] : Promise.resolve([]);
156162
}
157163

158-
const properties = {
164+
const props = {
159165
root: cwd,
160166
commonPath: null,
161167
depthOffset: 0
162168
};
163169

164-
const processed = processPatterns(options, cwd, properties);
170+
const processed = processPatterns(options, cwd, props);
171+
const nocase = options.caseSensitiveMatch === false;
165172

166173
const matcher = picomatch(processed.match, {
167174
dot: options.dot,
168-
nocase: options.caseSensitiveMatch === false,
175+
nocase,
169176
ignore: processed.ignore
170177
});
171178

172179
const ignore = picomatch(processed.ignore, {
173180
dot: options.dot,
174-
nocase: options.caseSensitiveMatch === false
181+
nocase
175182
});
176183

177184
const partialMatcher = getPartialMatcher(processed.match, {
178185
dot: options.dot,
179-
nocase: options.caseSensitiveMatch === false
186+
nocase
180187
});
181188

182189
if (process.env.TINYGLOBBY_DEBUG) {
@@ -188,30 +195,30 @@ function crawl(options: GlobOptions, cwd: string, sync: boolean) {
188195
filters: [
189196
options.debug
190197
? (p, isDirectory) => {
191-
const path = processPath(p, cwd, properties.root, isDirectory, options.absolute);
198+
const path = processPath(p, cwd, props.root, isDirectory, options.absolute);
192199
const matches = matcher(path);
193200

194201
if (matches) {
195-
console.log(`[tinyglobby ${new Date().toLocaleTimeString('es')}] matched ${path}`);
202+
log(`matched ${path}`);
196203
}
197204

198205
return matches;
199206
}
200-
: (p, isDirectory) => matcher(processPath(p, cwd, properties.root, isDirectory, options.absolute))
207+
: (p, isDirectory) => matcher(processPath(p, cwd, props.root, isDirectory, options.absolute))
201208
],
202209
exclude: options.debug
203210
? (_, p) => {
204-
const relativePath = processPath(p, cwd, properties.root, true, true);
211+
const relativePath = processPath(p, cwd, props.root, true, true);
205212
const skipped = (relativePath !== '.' && !partialMatcher(relativePath)) || ignore(relativePath);
206213

207214
if (!skipped) {
208-
console.log(`[tinyglobby ${new Date().toLocaleTimeString('es')}] crawling ${p}`);
215+
log(`crawling ${p}`);
209216
}
210217

211218
return skipped;
212219
}
213220
: (_, p) => {
214-
const relativePath = processPath(p, cwd, properties.root, true, true);
221+
const relativePath = processPath(p, cwd, props.root, true, true);
215222
return (relativePath !== '.' && !partialMatcher(relativePath)) || ignore(relativePath);
216223
},
217224
pathSeparator: '/',
@@ -220,7 +227,7 @@ function crawl(options: GlobOptions, cwd: string, sync: boolean) {
220227
};
221228

222229
if (options.deep) {
223-
fdirOptions.maxDepth = Math.round(options.deep - properties.depthOffset);
230+
fdirOptions.maxDepth = Math.round(options.deep - props.depthOffset);
224231
}
225232

226233
if (options.absolute) {
@@ -241,19 +248,15 @@ function crawl(options: GlobOptions, cwd: string, sync: boolean) {
241248
fdirOptions.includeDirs = true;
242249
}
243250

244-
// backslashes are removed so that inferred roots like `C:/New folder \\(1\\)` work
245-
properties.root = properties.root.replace(BACKSLASHES, '');
246-
const api = new fdir(fdirOptions).crawl(properties.root);
251+
props.root = props.root.replace(BACKSLASHES, '');
252+
const root = props.root;
253+
const api = new fdir(fdirOptions).crawl(root);
247254

248-
if (cwd === properties.root || options.absolute) {
255+
if (cwd === root || options.absolute) {
249256
return sync ? api.sync() : api.withPromise();
250257
}
251258

252-
return sync
253-
? api.sync().map(p => getRelativePath(p, cwd, properties.root) + (!p || p.endsWith('/') ? '/' : ''))
254-
: api
255-
.withPromise()
256-
.then(paths => paths.map(p => getRelativePath(p, cwd, properties.root) + (!p || p.endsWith('/') ? '/' : '')));
259+
return sync ? formatPaths(api.sync(), cwd, root) : api.withPromise().then(paths => formatPaths(paths, cwd, root));
257260
}
258261

259262
export function glob(patterns: string | string[], options?: Omit<GlobOptions, 'patterns'>): Promise<string[]>;

src/utils.ts

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,28 @@ export interface PartialMatcherOptions {
88

99
// the result of over 4 months of figuring stuff out and a LOT of help
1010
export function getPartialMatcher(patterns: string[], options?: PartialMatcherOptions): Matcher {
11-
const regexes: RegExp[][] = [];
12-
const patternsParts: string[][] = [];
13-
for (const pattern of patterns) {
14-
const parts = splitPattern(pattern);
15-
patternsParts.push(parts);
16-
regexes.push(parts.map(part => picomatch.makeRe(part, options)));
11+
// you might find this code pattern odd, but apparently it's faster than using `.push()`
12+
const patternsCount = patterns.length;
13+
const patternsParts: string[][] = Array(patternsCount);
14+
const regexes: RegExp[][] = Array(patternsCount);
15+
for (let i = 0; i < patternsCount; i++) {
16+
const parts = splitPattern(patterns[i]);
17+
patternsParts[i] = parts;
18+
const partsCount = parts.length;
19+
const partRegexes = Array(partsCount);
20+
for (let j = 0; j < partsCount; j++) {
21+
partRegexes[j] = picomatch.makeRe(parts[j], options);
22+
}
23+
regexes[i] = partRegexes;
1724
}
1825
return (input: string) => {
1926
// no need to `splitPattern` as this is indeed not a pattern
2027
const inputParts = input.split('/');
2128
for (let i = 0; i < patterns.length; i++) {
2229
const patternParts = patternsParts[i];
2330
const regex = regexes[i];
24-
const minParts = Math.min(inputParts.length, patternParts.length);
31+
const inputPatternCount = inputParts.length;
32+
const minParts = Math.min(inputPatternCount, patternParts.length);
2533
let j = 0;
2634
while (j < minParts) {
2735
const part = patternParts[j];
@@ -48,7 +56,7 @@ export function getPartialMatcher(patterns: string[], options?: PartialMatcherOp
4856

4957
j++;
5058
}
51-
if (j === inputParts.length) {
59+
if (j === inputPatternCount) {
5260
return true;
5361
}
5462
}
@@ -59,13 +67,18 @@ export function getPartialMatcher(patterns: string[], options?: PartialMatcherOp
5967
// #endregion
6068

6169
// #region splitPattern
70+
// make options a global constant to reduce GC work
71+
const splitPatternOptions = { parts: true };
72+
6273
// if a pattern has no slashes outside glob symbols, results.parts is []
6374
export function splitPattern(path: string): string[] {
64-
const result = picomatch.scan(path, { parts: true });
75+
const result = picomatch.scan(path, splitPatternOptions);
6576
return result.parts?.length ? result.parts : [path];
6677
}
6778
// #endregion
6879

80+
const isWin = process.platform === 'win32';
81+
6982
// #region convertPathToPattern
7083
const ESCAPED_WIN32_BACKSLASHES = /\\(?![()[\]{}!+@])/g;
7184
export function convertPosixPathToPattern(path: string): string {
@@ -76,8 +89,9 @@ export function convertWin32PathToPattern(path: string): string {
7689
return escapeWin32Path(path).replace(ESCAPED_WIN32_BACKSLASHES, '/');
7790
}
7891

79-
export const convertPathToPattern: (path: string) => string =
80-
process.platform === 'win32' ? convertWin32PathToPattern : convertPosixPathToPattern;
92+
export const convertPathToPattern: (path: string) => string = isWin
93+
? convertWin32PathToPattern
94+
: convertPosixPathToPattern;
8195
// #endregion
8296

8397
// #region escapePath
@@ -94,7 +108,7 @@ const WIN32_UNESCAPED_GLOB_SYMBOLS = /(?<!\\)([()[\]{}]|^!|[!+@](?=\())/g;
94108
export const escapePosixPath = (path: string): string => path.replace(POSIX_UNESCAPED_GLOB_SYMBOLS, '\\$&');
95109
export const escapeWin32Path = (path: string): string => path.replace(WIN32_UNESCAPED_GLOB_SYMBOLS, '\\$&');
96110

97-
export const escapePath: (path: string) => string = process.platform === 'win32' ? escapeWin32Path : escapePosixPath;
111+
export const escapePath: (path: string) => string = isWin ? escapeWin32Path : escapePosixPath;
98112
// #endregion
99113

100114
// #region isDynamicPattern
@@ -119,3 +133,9 @@ export function isDynamicPattern(pattern: string, options?: { caseSensitiveMatch
119133
return scan.isGlob || scan.negated;
120134
}
121135
// #endregion
136+
137+
// #region log
138+
export function log(task: string): void {
139+
console.log(`[tinyglobby ${new Date().toLocaleTimeString('es')}] ${task}`);
140+
}
141+
// #endregion

0 commit comments

Comments
 (0)