@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 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
- The Java port still runs the full DP (4397 ms total, `backtrack` 3205 ms, `anchored` and `doublestar` regressed against its
264
- regex version). The same hybrid applies to it and is the next step; the numbers above are why it should be measured per
265
- workload rather than by the total.
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. [+doc/measurement.md](doc/measurement.md)](doc/measurement.md) records the two measurement modes, the steady-state table (12 rounds inside one process) and every
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 young. Their remaining distance to Zig is a design difference, not tuning: Java has not adopted the DP, and
278
- neither port has the Zig implementation's per-chain indexing.
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 >= 0; 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 answer, keyed by its path relative to the walk root. A path list visits the same directories over
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 answer cannot change while the stack is unchanged. `push`/`pop` clear it: they are the only
299
- // things that change which files govern a directory, and a cached "excluded" must never outlive them.
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
- // An excluded directory is never entered, so a rule for a file inside it cannot re-include that file: ask the
322
- // ancestors, deepest first. A parent that a later rule re-includes answers false and is skipped. The ancestor is
323
- // found by walking the separators backwards, so no ancestor costs a split and a join — one substring, which is
324
- // also the memo's key.
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.1",
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",