-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmatcher.ts
More file actions
178 lines (167 loc) · 6.18 KB
/
matcher.ts
File metadata and controls
178 lines (167 loc) · 6.18 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
/**
* @fileoverview `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 { MATCHER_CACHE_MAX_SIZE, getPicomatch, matcherCache } from './_internal'
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.
// `_matchesGlob` caches the resolved native function; `_matchesGlobProbed`
// distinguishes "not yet probed" from "probed but absent".
let _matchesGlob: ((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'])
* ```
*/
/*@__NO_SIDE_EFFECTS__*/
export function getGlobMatcher(
glob: Pattern | Pattern[],
options?: { dot?: boolean; nocase?: boolean; ignore?: string[] },
): (path: string) => boolean {
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].sort()
const sortedOptions = options
? ObjectKeys(options)
.sort()
.map(k => {
const value = options[k as keyof typeof options]
const normalized = ArrayIsArray(value) ? [...value].sort() : 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.
const matchOptions = {
dot: true,
nocase: true,
...options,
...(negativePatterns.length > 0 ? { ignore: negativePatterns } : {}),
}
// External picomatch call
/* c8 ignore start */
const picomatch = getPicomatch()
matcher = picomatch(
positivePatterns.length > 0 ? positivePatterns : patterns,
matchOptions,
) 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
*/
/*@__NO_SIDE_EFFECTS__*/
export function getMatchesGlob():
| ((p: string, pattern: string) => boolean)
| undefined {
if (!_matchesGlobProbed) {
const fn = /*@__PURE__*/ (
require('node:path') as typeof import('node:path') & {
matchesGlob?: unknown
}
).matchesGlob
// path.matchesGlob is present on Node 22+; missing-fn arm fires
// only on older runtimes.
/* c8 ignore start */
if (typeof fn === 'function') {
_matchesGlob = fn as (p: string, pattern: string) => boolean
}
/* c8 ignore stop */
_matchesGlobProbed = true
}
return _matchesGlob
}