@hrg/gitignore 0.3.1 → 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/README.md +19 -8
- package/js/gitignore.mjs +145 -8
- package/js/glob.mjs +17 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -260,23 +260,23 @@ The three JS columns are **the same vectors, the same port, three matching strat
|
|
|
260
260
|
which is a pattern with `**` where the regex happened to be fast. That row is the open tuning question, not a settled
|
|
261
261
|
result.
|
|
262
262
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
263
|
+
Both ports now run the same segment DP as the reference, with the ancestor memoisation that took them from 17x and 11x behind
|
|
264
|
+
Zig to about 1.6x and 2.2x at the time it was measured - and the reference has since become roughly 10x faster than it started,
|
|
265
|
+
so the relative gap moved again. The current numbers, the method, and every improvement (including what was measured and
|
|
266
|
+
refuted) are in [doc/performance/](doc/performance/README.md) and [doc/measurement.md](doc/measurement.md).
|
|
267
267
|
### How these numbers are produced
|
|
268
268
|
|
|
269
269
|
Cold-process runs, with start-up measured separately and subtracted. That is the honest number for a CLI and it is
|
|
270
|
-
also noisy: the same Java build has reported `backtrack` at 3205 ms and 5129 ms. [
|
|
270
|
+
also noisy: the same Java build has reported `backtrack` at 3205 ms and 5129 ms. [doc/measurement.md](doc/measurement.md) records the two measurement modes, the steady-state table (12 rounds inside one process) and every
|
|
271
271
|
strategy tried on the Java port with what it cost. The Java column above predates the literal-segment fast path.
|
|
272
272
|
|
|
273
273
|
### Two caveats
|
|
274
274
|
|
|
275
275
|
- The runner measures a process; the start-up column is a subtraction, and on a loaded machine it is a noisy one. The
|
|
276
276
|
large rows are far outside that noise; the small ones are not.
|
|
277
|
-
- The ports are
|
|
278
|
-
|
|
279
|
-
|
|
277
|
+
- The ports are no longer "young" in the sense that mattered: the Java port adopted the segment DP, then literal segments, then
|
|
278
|
+
the ancestor memoisation, and the JS port did the same. What remains is the reference's own head start - it has three indexes
|
|
279
|
+
the ports do not have, and [doc/performance/shared.md](doc/performance/shared.md) is the list a new port should work through.
|
|
280
280
|
## Running the three
|
|
281
281
|
|
|
282
282
|
```text
|
|
@@ -379,3 +379,14 @@ Requirements, learned from a project of this author's that publishes this way (`
|
|
|
379
379
|
|
|
380
380
|
Afterwards, `fs-meta` resolves `hr.hrg:gitignore` from the repository like any other artifact; it no longer needs the local
|
|
381
381
|
`mvn install`.
|
|
382
|
+
## Checks
|
|
383
|
+
|
|
384
|
+
```text
|
|
385
|
+
bun scripts/check-verdicts.mjs # the three implementations agree, byte for byte
|
|
386
|
+
bun scripts/check-verdicts.mjs --json # one NDJSON object per workload
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
The 34 conformance vectors say what the matcher must decide; this says that the three implementations decide the *same thing*
|
|
390
|
+
on the six bench workloads — 10,000 verdicts, compared byte for byte, with the first differing line named on failure. A missing
|
|
391
|
+
binary is exit 2 with the build command, never a skip. It exists because twice in this project's history "the vectors pass" was
|
|
392
|
+
mistaken for "the ports agree", and the comparison had been run by hand in scratch files that were never committed.
|
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",
|