-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmatcher.ts
More file actions
190 lines (179 loc) · 6.93 KB
/
matcher.ts
File metadata and controls
190 lines (179 loc) · 6.93 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
/**
* @file `getGlobMatcher` — picomatch-backed sync predicate with an LRU-memoized
* matcher cache. `getMatchesGlob` exposes Node 22+'s native
* `path.matchesGlob` for the rare case where the caller wants strict
* (`nocase: false`, `dot: false`) matching.
*/
import { ArrayIsArray } from '../primordials/array'
import { JSONStringify } from '../primordials/json'
import { ObjectKeys } from '../primordials/object'
import { StringPrototypeStartsWith } from '../primordials/string'
import { getPicomatch, MATCHER_CACHE_MAX_SIZE, matcherCache } from './_internal'
import type { PicomatchOptions } from '../external/picomatch'
import type NodePath from 'node:path'
import type { Pattern } from './types'
// `path.matchesGlob` was added in Node v22.5.0 / v20.17.0 (Stable).
// Engines is >=22, so it's missing only on 22.0.x – 22.4.x.
// `matchesGlobCache` caches the resolved native function; `matchesGlobProbed`
// distinguishes "not yet probed" from "probed but absent".
let matchesGlobCache: ((p: string, pattern: string) => boolean) | undefined
let matchesGlobProbed = false
/**
* Return a glob-matcher function, memoized by pattern + options.
*
* The returned function is a fast synchronous predicate built on picomatch.
* Results are memoized — calling `getGlobMatcher(['*.ts'])` a thousand times in
* a loop returns the same compiled matcher each time, so callers do not need to
* hoist it themselves.
*
* The cache is LRU with a cap of 100 entries. Cache keys fold together the
* (sorted) pattern list and (sorted) option set, so arguments that differ only
* in ordering share a matcher.
*
* Default options: `dot: true`, `nocase: true`. Patterns starting with `!`
* become ignore patterns.
*
* @example
* ;```typescript
* const isMatch = getGlobMatcher('*.ts')
* isMatch('index.ts') // true
* isMatch('index.js') // false
*
* const isSource = getGlobMatcher(['src/**', '!**\/*.test.ts'])
* ```
*/
export function getGlobMatcher(
glob: Pattern | Pattern[],
options?: {
dot?: boolean | undefined
nocase?: boolean | undefined
ignore?: string[] | undefined
},
): (path: string) => boolean {
options = { __proto__: null, ...options } as typeof options
const patterns = ArrayIsArray(glob) ? glob : [glob]
// Create stable cache key by sorting patterns and option keys.
// Option values that are arrays (e.g. `ignore: ['a', 'b']`) get sorted
// element-wise so `['a', 'b']` and `['b', 'a']` hit the same entry —
// otherwise equivalent matchers re-compile and evict each other under
// the 100-entry cap.
const sortedPatterns = [...patterns].toSorted()
const sortedOptions = options
? ObjectKeys(options)
.toSorted()
.map(k => {
const value = options[k as keyof typeof options]
const normalized = ArrayIsArray(value) ? [...value].toSorted() : value
return `${k}:${JSONStringify(normalized)}`
})
.join(',')
: ''
const key = `${sortedPatterns.join('|')}:${sortedOptions}`
const existing = matcherCache.get(key)
if (existing) {
// Re-insert to mark as most-recently-used.
matcherCache.delete(key)
matcherCache.set(key, existing)
return existing
}
// LRU eviction triggers at 100 entries; not reachable from typical
// test runs.
/* c8 ignore start */
if (matcherCache.size >= MATCHER_CACHE_MAX_SIZE) {
const oldest = matcherCache.keys().next().value
if (oldest !== undefined) {
matcherCache.delete(oldest)
}
}
/* c8 ignore stop */
// Narrow `path.matchesGlob` fast-path. picomatch's defaults
// (`dot: true`, `nocase: true`) silently differ from
// `path.matchesGlob`'s behavior (case-sensitive, no dot match), so
// taking the fast-path under those defaults silently changes
// observable behavior — that's how the previous draft of this
// file regressed the case-insensitive default and the dot-match
// contract. Activate ONLY when the caller has explicitly opted
// out of both defaults (`nocase: false` AND `dot: false`),
// signaling "I want strict, case-sensitive, no-dotfile-match" —
// which is exactly what `path.matchesGlob` provides. No caller in
// the fleet does this today, but the path is correct + auditable.
let matcher: ((path: string) => boolean) | undefined
/* c8 ignore start */
if (
patterns.length === 1 &&
!StringPrototypeStartsWith(patterns[0]!, '!') &&
options !== undefined &&
options.nocase === false &&
options.dot === false &&
(options.ignore === undefined || options.ignore.length === 0)
) {
const matchesGlob = getMatchesGlob()
if (matchesGlob !== undefined) {
const pattern = patterns[0]!
matcher = (p: string) => matchesGlob(p, pattern)
}
}
/* c8 ignore stop */
if (matcher === undefined) {
// Separate positive and negative patterns.
const positivePatterns = patterns.filter(
p => !StringPrototypeStartsWith(p, '!'),
)
const negativePatterns = patterns
.filter(p => StringPrototypeStartsWith(p, '!'))
.map(p => p.slice(1))
// Use ignore option for negation patterns. Cast at the picomatch
// boundary: the caller's option props carry `| undefined`, which
// PicomatchOptions rejects under exactOptionalPropertyTypes even
// though the runtime values are valid.
const matchOptions = {
dot: true,
nocase: true,
...options,
...(negativePatterns.length > 0 ? { ignore: negativePatterns } : {}),
}
// External picomatch call
/* c8 ignore start - external picomatch lib, exercised via integration */
const picomatch = getPicomatch()
matcher = picomatch(
positivePatterns.length > 0 ? positivePatterns : patterns,
matchOptions as PicomatchOptions,
) as (path: string) => boolean
/* c8 ignore stop */
}
matcherCache.set(key, matcher)
return matcher
}
/**
* Resolve `path.matchesGlob` (or `undefined` if the runtime predates it).
* Probes once and caches the result for every subsequent call.
*
* Used by `getGlobMatcher`'s narrow fast-path — see the conditions spelled out
* at the call site. Exported for unit tests.
*
* @internal
*/
export function getMatchesGlob():
| ((p: string, pattern: string) => boolean)
| undefined {
if (!matchesGlobProbed) {
// The /*@__PURE__*/ stays adjacent to the require() call — oxfmt
// reformats `(/*@__PURE__*/ require(…) as T).x` back into the
// outside-paren form that rolldown doesn't honor; using an
// intermediate const sidesteps the reformat. See task #23.
const pathMod =
/*@__PURE__*/ require('node:path') as typeof NodePath & {
matchesGlob?: unknown | undefined
}
const fn = pathMod.matchesGlob
// path.matchesGlob is present on Node 22+; missing-fn arm fires
// only on older runtimes.
/* c8 ignore start */
if (typeof fn === 'function') {
matchesGlobCache = fn as (p: string, pattern: string) => boolean
}
/* c8 ignore stop */
matchesGlobProbed = true
}
return matchesGlobCache
}