@hrg/gitignore 0.3.2 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/js/gitignore.mjs +145 -8
- package/js/glob.mjs +17 -0
- package/package.json +1 -1
package/js/gitignore.mjs
CHANGED
|
@@ -120,6 +120,9 @@ function translateCharacterClass(glob, open) {
|
|
|
120
120
|
return { regex: `${translated}]`, close };
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/** A pattern segment with none of these bytes is a plain name, so a directory-only rule can be indexed by it. */
|
|
124
|
+
const hasWildcardByte = /[*?[\\]/;
|
|
125
|
+
|
|
123
126
|
/** One parsed pattern. */
|
|
124
127
|
function parseRule(rawLine) {
|
|
125
128
|
let line = stripTrailingUnescapedSpaces(rawLine);
|
|
@@ -148,6 +151,11 @@ function parseRule(rawLine) {
|
|
|
148
151
|
return {
|
|
149
152
|
negated,
|
|
150
153
|
directoryOnly,
|
|
154
|
+
anchored,
|
|
155
|
+
// A plain name (no wildcard, no escape) is indexed rather than scanned: the directory-only ones are what a
|
|
156
|
+
// per-path prefix walk asks about most, and a name is the whole question for the unanchored form.
|
|
157
|
+
literal: hasWildcardByte.test(line) ? null : line,
|
|
158
|
+
indexed: false,
|
|
151
159
|
// Both anchors are required: Java's `Matcher.matches()` anchors the whole pattern, while JavaScript's
|
|
152
160
|
// `RegExp.test` searches — and a missing `^` lets an anchored pattern match a path *suffix*. That one
|
|
153
161
|
// difference is what the ignore vector `anchored-pattern` caught between the two ports.
|
|
@@ -197,6 +205,42 @@ export function parseGitignore(relativeDirectory, text) {
|
|
|
197
205
|
rules.push(rule);
|
|
198
206
|
}
|
|
199
207
|
}
|
|
208
|
+
// The directory-only literal rules are indexed rather than scanned, because they are the class a per-path
|
|
209
|
+
// decision asks about most and the one whose scan costs a prefix walk per rule. Two forms, because the
|
|
210
|
+
// question differs: an anchored literal is a whole path prefix (`module0/src/main/java/`), so it is keyed
|
|
211
|
+
// by that path and looked up once per depth the path has; an unanchored one is a single name (`build/`),
|
|
212
|
+
// so it is keyed by the name and looked up once per segment. A literal can only match a prefix of exactly
|
|
213
|
+
// its own depth, which is what the per-depth flag preserves: `java/fory-0/...` is rejected against a
|
|
214
|
+
// `java/fory-1/...` rule by the lookup itself, the case a first-segment prune cannot see. The flags are a
|
|
215
|
+
// plain array here rather than the reference's `u256`, and a pattern deeper than the array stays on the
|
|
216
|
+
// scanned path, where the answer is still the correct one.
|
|
217
|
+
const anchoredLiterals = new Map();
|
|
218
|
+
const unanchoredLiterals = new Map();
|
|
219
|
+
const anchoredDepths = [];
|
|
220
|
+
let deepestAnchoredDepth = 0;
|
|
221
|
+
for (let index = 0; index < rules.length; index++) {
|
|
222
|
+
const rule = rules[index];
|
|
223
|
+
if (!rule.directoryOnly || rule.literal === null) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (rule.anchored) {
|
|
227
|
+
let depth = 1;
|
|
228
|
+
for (let at = rule.literal.indexOf('/'); at >= 0; at = rule.literal.indexOf('/', at + 1)) {
|
|
229
|
+
depth += 1;
|
|
230
|
+
}
|
|
231
|
+
if (depth > 256) {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
anchoredLiterals.set(rule.literal, index);
|
|
235
|
+
anchoredDepths[depth] = true;
|
|
236
|
+
if (depth > deepestAnchoredDepth) {
|
|
237
|
+
deepestAnchoredDepth = depth;
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
unanchoredLiterals.set(rule.literal, index);
|
|
241
|
+
}
|
|
242
|
+
rule.indexed = true;
|
|
243
|
+
}
|
|
200
244
|
return {
|
|
201
245
|
relativeDirectory: directory,
|
|
202
246
|
ruleCount: rules.length,
|
|
@@ -216,11 +260,58 @@ export function parseGitignore(relativeDirectory, text) {
|
|
|
216
260
|
}
|
|
217
261
|
scoped = relativePath.slice(prefix.length);
|
|
218
262
|
}
|
|
263
|
+
|
|
264
|
+
// Ask the index first. `best` is the highest rule position any indexed rule can reach for this path, so
|
|
265
|
+
// only rules after it can override the answer — usually none. That is also why a hit needs no split:
|
|
266
|
+
// a path the index decides is answered before the matcher is reached.
|
|
267
|
+
let best = -1;
|
|
268
|
+
if (unanchoredLiterals.size > 0) {
|
|
269
|
+
// An unanchored directory rule names one segment, and for a file the last segment is the file itself.
|
|
270
|
+
let start = 0;
|
|
271
|
+
for (;;) {
|
|
272
|
+
const slash = scoped.indexOf('/', start);
|
|
273
|
+
const last = slash < 0;
|
|
274
|
+
if (!last || isDirectory) {
|
|
275
|
+
const name = last ? scoped.slice(start) : scoped.slice(start, slash);
|
|
276
|
+
const at = unanchoredLiterals.get(name);
|
|
277
|
+
if (at !== undefined && at > best) {
|
|
278
|
+
best = at;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (last) break;
|
|
282
|
+
start = slash + 1;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (deepestAnchoredDepth > 0) {
|
|
286
|
+
// One lookup per depth that actually holds a rule, not one per rule: a path shorter than a rule's depth
|
|
287
|
+
// costs nothing at all.
|
|
288
|
+
let depth = 0;
|
|
289
|
+
let start = 0;
|
|
290
|
+
for (;;) {
|
|
291
|
+
const slash = scoped.indexOf('/', start);
|
|
292
|
+
depth += 1;
|
|
293
|
+
const last = slash < 0;
|
|
294
|
+
if (last && !isDirectory) break;
|
|
295
|
+
if (anchoredDepths[depth] === true) {
|
|
296
|
+
const prefix = last ? scoped : scoped.slice(0, slash);
|
|
297
|
+
const at = anchoredLiterals.get(prefix);
|
|
298
|
+
if (at !== undefined && at > best) {
|
|
299
|
+
best = at;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (last) break;
|
|
303
|
+
start = slash + 1;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
219
307
|
// Split once for every `**` rule of this file, and only if one of them is reached: a file whose patterns all
|
|
220
308
|
// compile to a regex pays no split at all (the `*.log`-shaped rule sets of the small workloads).
|
|
221
309
|
let segments = null;
|
|
222
|
-
for (let index = rules.length - 1; index
|
|
310
|
+
for (let index = rules.length - 1; index > best; index--) {
|
|
223
311
|
const rule = rules[index];
|
|
312
|
+
if (rule.indexed) {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
224
315
|
if (rule.segments !== null && segments === null) {
|
|
225
316
|
segments = scoped.split('/');
|
|
226
317
|
}
|
|
@@ -228,6 +319,9 @@ export function parseGitignore(relativeDirectory, text) {
|
|
|
228
319
|
return !rule.negated;
|
|
229
320
|
}
|
|
230
321
|
}
|
|
322
|
+
if (best >= 0) {
|
|
323
|
+
return !rules[best].negated;
|
|
324
|
+
}
|
|
231
325
|
return undefined;
|
|
232
326
|
},
|
|
233
327
|
};
|
|
@@ -293,19 +387,29 @@ export function readGitignore(directoryPath, relativeDirectory) {
|
|
|
293
387
|
*/
|
|
294
388
|
export class GitIgnoreRules {
|
|
295
389
|
#stack = [];
|
|
296
|
-
// A directory's
|
|
390
|
+
// A directory's state, keyed by its path relative to the walk root. A path list visits the same directories over
|
|
297
391
|
// and over (`backtrack` asks about 84,000 ancestor directories that are 42 distinct ones), and the rules are a
|
|
298
|
-
// snapshot, so the
|
|
299
|
-
// things that change which files govern a directory, and a cached
|
|
392
|
+
// snapshot, so the state cannot change while the stack is unchanged. `push`/`pop` clear it: they are the only
|
|
393
|
+
// things that change which files govern a directory, and a cached answer must never outlive them.
|
|
394
|
+
//
|
|
395
|
+
// The state is the two facts a decision needs about the path's directory: whether it (or an ancestor) is excluded,
|
|
396
|
+
// and — implicit here — the chain of files that governs it. The chain half is already free in this port because the
|
|
397
|
+
// walk hands the stack to the decider instead of making it walk up the file system, so the cache pays for the
|
|
398
|
+
// ancestor half: one map lookup and one `dirname` per decision instead of a lookup and a substring per level.
|
|
399
|
+
#states = new Map();
|
|
400
|
+
// The historical walk, kept for a path outside the contract (a leading or trailing separator), where the index key
|
|
401
|
+
// would be a different one than the walk would ask about. Same shape as the memo it replaced.
|
|
300
402
|
#excluded = new Map();
|
|
301
403
|
|
|
302
404
|
push(file) {
|
|
303
405
|
this.#stack.push(file);
|
|
406
|
+
this.#states.clear();
|
|
304
407
|
this.#excluded.clear();
|
|
305
408
|
}
|
|
306
409
|
|
|
307
410
|
pop() {
|
|
308
411
|
this.#stack.pop();
|
|
412
|
+
this.#states.clear();
|
|
309
413
|
this.#excluded.clear();
|
|
310
414
|
}
|
|
311
415
|
|
|
@@ -316,12 +420,45 @@ export class GitIgnoreRules {
|
|
|
316
420
|
decide(relativePath, isDirectory) {
|
|
317
421
|
const direct = this.decideDirect(relativePath, isDirectory);
|
|
318
422
|
if (direct === true) {
|
|
423
|
+
// A directly excluded path needs no ancestor fact at all — the common case on a rule set with a literal hit.
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
const slash = relativePath.lastIndexOf('/');
|
|
427
|
+
// The index keys on the directory, and `dirOf` is the substring before the last separator for every path in the
|
|
428
|
+
// contract (`/`-separated, no leading or trailing slash). A path outside it keeps the historical answer rather
|
|
429
|
+
// than being answered from an index built on a different key.
|
|
430
|
+
if (slash === 0 || slash === relativePath.length - 1) {
|
|
431
|
+
return this.#walkAncestors(relativePath, direct);
|
|
432
|
+
}
|
|
433
|
+
if (this.#stateFor(slash < 0 ? '' : relativePath.slice(0, slash)).ancestorExcluded) {
|
|
319
434
|
return true;
|
|
320
435
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
436
|
+
return direct;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The ancestor-excluded fact for a directory, inherited: a directory is excluded when its parent is, or when its
|
|
441
|
+
* own decision through the stack excludes it — exactly the prefix set the per-path walk asked, computed once per
|
|
442
|
+
* directory instead of once per path per level.
|
|
443
|
+
*/
|
|
444
|
+
#stateFor(directory) {
|
|
445
|
+
let state = this.#states.get(directory);
|
|
446
|
+
if (state !== undefined) {
|
|
447
|
+
return state;
|
|
448
|
+
}
|
|
449
|
+
let ancestorExcluded = false;
|
|
450
|
+
if (directory !== '') {
|
|
451
|
+
const slash = directory.lastIndexOf('/');
|
|
452
|
+
const parent = this.#stateFor(slash < 0 ? '' : directory.slice(0, slash));
|
|
453
|
+
ancestorExcluded = parent.ancestorExcluded || this.decideDirect(directory, true) === true;
|
|
454
|
+
}
|
|
455
|
+
state = { ancestorExcluded };
|
|
456
|
+
this.#states.set(directory, state);
|
|
457
|
+
return state;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** The pre-index ancestor walk, used only for a path the index does not key on. */
|
|
461
|
+
#walkAncestors(relativePath, direct) {
|
|
325
462
|
for (let slash = relativePath.lastIndexOf('/'); slash >= 0; slash = relativePath.lastIndexOf('/', slash - 1)) {
|
|
326
463
|
const ancestor = relativePath.slice(0, slash);
|
|
327
464
|
let excluded = this.#excluded.get(ancestor);
|
package/js/glob.mjs
CHANGED
|
@@ -50,6 +50,23 @@ function rows(size) {
|
|
|
50
50
|
* @param {number} [count] how many segments to consider (default: all of them)
|
|
51
51
|
*/
|
|
52
52
|
export function segmentsMatch(segments, text, count = text.length) {
|
|
53
|
+
// The pattern's last segment, when it is not `**`, has to match the last segment the call considers — otherwise the
|
|
54
|
+
// whole DP is spent on a match that cannot exist. `count` is the first `count` segments, so the last considered text
|
|
55
|
+
// segment is `text[count - 1]` (the path's own last segment only when the whole path is being matched); a
|
|
56
|
+
// directory-only ancestor question passes its own last considered segment this way instead of the path's.
|
|
57
|
+
if (count > 0) {
|
|
58
|
+
const tail = segments[segments.length - 1];
|
|
59
|
+
if (tail.doubleStar !== true) {
|
|
60
|
+
const textTail = text[count - 1];
|
|
61
|
+
if (tail.literal !== undefined) {
|
|
62
|
+
if (textTail !== tail.literal) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
} else if (!tail.regex.test(textTail)) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
53
70
|
const [first, second] = rows(count + 1);
|
|
54
71
|
let reach = first;
|
|
55
72
|
let next = second;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hrg/gitignore",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "git's .gitignore (wildmatch) semantics: one matcher, shared by the Zig, JavaScript and Java ports.",
|
|
5
5
|
"license": "LicenseRef-GPL-3.0-with-Commons-Clause",
|
|
6
6
|
"author": "Davor Hrg",
|