@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 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.2",
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",