Skip to content

Commit 327cb60

Browse files
committed
Add escape(), unescape(), and Minimatch.hasMagic()
Also, treat single-character brace classes as their literal character, without magic. So for example, `[f]` would be parsed as just `'f'`, and not treated as a magic pattern.
1 parent 75a8b84 commit 327cb60

13 files changed

+2328
-67
lines changed

README.md

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,14 +122,29 @@ var mm = new Minimatch(pattern, options)
122122

123123
### Methods
124124

125-
- `makeRe` Generate the `regexp` member if necessary, and return it.
125+
- `makeRe()` Generate the `regexp` member if necessary, and return it.
126126
Will return `false` if the pattern is invalid.
127127
- `match(fname)` Return true if the filename matches the pattern, or
128128
false otherwise.
129129
- `matchOne(fileArray, patternArray, partial)` Take a `/`-split
130130
filename, and match it against a single row in the `regExpSet`. This
131131
method is mainly for internal use, but is exposed so that it can be
132132
used by a glob-walker that needs to avoid excessive filesystem calls.
133+
- `hasMagic()` Returns true if the parsed pattern contains any
134+
magic characters. Returns false if all comparator parts are
135+
string literals. If the `magicalBraces` option is set on the
136+
constructor, then it will consider brace expansions which are
137+
not otherwise magical to be magic. If not set, then a pattern
138+
like `a{b,c}d` will return `false`, because neither `abd` nor
139+
`acd` contain any special glob characters.
140+
141+
This does **not** mean that the pattern string can be used as a
142+
literal filename, as it may contain magic glob characters that
143+
are escaped. For example, the pattern `\\*` or `[*]` would not
144+
be considered to have magic, as the matching portion parses to
145+
the literal string `'*'` and would match a path named `'*'`,
146+
not `'\\*'` or `'[*]'`. The `minimatch.unescape()` method may
147+
be used to remove escape characters.
133148

134149
All other methods are internal, and will be called as necessary.
135150

@@ -150,6 +165,34 @@ supplied argument, suitable for use with `Array.filter`. Example:
150165
var javascripts = fileList.filter(minimatch.filter('*.js', { matchBase: true }))
151166
```
152167

168+
### minimatch.escape(pattern, options = {})
169+
170+
Escape all magic characters in a glob pattern, so that it will
171+
only ever match literal strings
172+
173+
If the `windowsPathsNoEscape` option is used, then characters are
174+
escaped by wrapping in `[]`, because a magic character wrapped in
175+
a character class can only be satisfied by that exact character.
176+
177+
Slashes (and backslashes in `windowsPathsNoEscape` mode) cannot
178+
be escaped or unescaped.
179+
180+
### minimatch.unescape(pattern, options = {})
181+
182+
Un-escape a glob string that may contain some escaped characters.
183+
184+
If the `windowsPathsNoEscape` option is used, then square-brace
185+
escapes are removed, but not backslash escapes. For example, it
186+
will turn the string `'[*]'` into `*`, but it will not turn
187+
`'\\*'` into `'*'`, becuase `\` is a path separator in
188+
`windowsPathsNoEscape` mode.
189+
190+
When `windowsPathsNoEscape` is not set, then both brace escapes
191+
and backslash escapes are removed.
192+
193+
Slashes (and backslashes in `windowsPathsNoEscape` mode) cannot
194+
be escaped or unescaped.
195+
153196
### minimatch.match(list, pattern, options)
154197

155198
Match against the list of
@@ -212,6 +255,16 @@ When a match is not found by `minimatch.match`, return a list containing
212255
the pattern itself if this option is set. When not set, an empty list
213256
is returned if there are no matches.
214257

258+
### magicalBraces
259+
260+
This only affects the results of the `Minimatch.hasMagic` method.
261+
262+
If the pattern contains brace expansions, such as `a{b,c}d`, but
263+
no other magic characters, then the `Minipass.hasMagic()` method
264+
will return `false` by default. When this option set, it will
265+
return `true` for brace expansion as well as other magic glob
266+
characters.
267+
215268
### matchBase
216269

217270
If set, then patterns without slashes will be matched

changelog.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# change log
22

3+
## 7.4
4+
5+
- Add `escape()` method
6+
- Add `unescape()` method
7+
- Add `Minimatch.hasMagic()` method
8+
39
## 7.3
410

511
- Add support for posix character classes in a unicode-aware way.

src/brace-expressions.ts

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,21 @@ const posixClasses: { [k: string]: [e: string, u: boolean, n?: boolean] } = {
2020
}
2121

2222
// only need to escape a few things inside of brace expressions
23-
const regExpEscape = (s: string) => s.replace(/[[\]\\-]/g, '\\$&')
24-
25-
const rangesToString = (ranges: string[]): string => {
26-
return (
27-
ranges
28-
// .map(r => r.replace(/[[\]]/g, '\\$&').replace(/^-/, '\\-'))
29-
.join('')
30-
)
31-
}
23+
// escapes: [ \ ] -
24+
const braceEscape = (s: string) => s.replace(/[[\]\\-]/g, '\\$&')
25+
// escape all regexp magic characters
26+
const regexpEscape = (s: string) =>
27+
s.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')
28+
29+
// everything has already been escaped, we just have to join
30+
const rangesToString = (ranges: string[]): string => ranges.join('')
31+
32+
export type ParseClassResult = [
33+
src: string,
34+
uFlag: boolean,
35+
consumed: number,
36+
hasMagic: boolean
37+
]
3238

3339
// takes a glob string at a posix brace expression, and returns
3440
// an equivalent regular expression source, and boolean indicating
@@ -39,7 +45,7 @@ const rangesToString = (ranges: string[]): string => {
3945
export const parseClass = (
4046
glob: string,
4147
position: number
42-
): [string, boolean, number] => {
48+
): ParseClassResult => {
4349
const pos = position
4450
/* c8 ignore start */
4551
if (glob.charAt(pos) !== '[') {
@@ -84,7 +90,7 @@ export const parseClass = (
8490
if (glob.startsWith(cls, i)) {
8591
// invalid, [a-[] is fine, but not [a-[:alpha]]
8692
if (rangeStart) {
87-
return ['$.', false, glob.length - pos]
93+
return ['$.', false, glob.length - pos, true]
8894
}
8995
i += cls.length
9096
if (neg) negs.push(unip)
@@ -101,9 +107,9 @@ export const parseClass = (
101107
// throw this range away if it's not valid, but others
102108
// can still match.
103109
if (c > rangeStart) {
104-
ranges.push(regExpEscape(rangeStart) + '-' + regExpEscape(c))
110+
ranges.push(braceEscape(rangeStart) + '-' + braceEscape(c))
105111
} else if (c === rangeStart) {
106-
ranges.push(regExpEscape(c))
112+
ranges.push(braceEscape(c))
107113
}
108114
rangeStart = ''
109115
i++
@@ -113,7 +119,7 @@ export const parseClass = (
113119
// now might be the start of a range.
114120
// can be either c-d or c-] or c<more...>] or c] at this point
115121
if (glob.startsWith('-]', i + 1)) {
116-
ranges.push(regExpEscape(c + '-'))
122+
ranges.push(braceEscape(c + '-'))
117123
i += 2
118124
continue
119125
}
@@ -124,20 +130,34 @@ export const parseClass = (
124130
}
125131

126132
// not the start of a range, just a single character
127-
ranges.push(regExpEscape(c))
133+
ranges.push(braceEscape(c))
128134
i++
129135
}
130136

131137
if (endPos < i) {
132138
// didn't see the end of the class, not a valid class,
133139
// but might still be valid as a literal match.
134-
return ['', false, 0]
140+
return ['', false, 0, false]
135141
}
136142

137143
// if we got no ranges and no negates, then we have a range that
138144
// cannot possibly match anything, and that poisons the whole glob
139145
if (!ranges.length && !negs.length) {
140-
return ['$.', false, glob.length - pos]
146+
return ['$.', false, glob.length - pos, true]
147+
}
148+
149+
// if we got one positive range, and it's a single character, then that's
150+
// not actually a magic pattern, it's just that one literal character.
151+
// we should not treat that as "magic", we should just return the literal
152+
// character. [_] is a perfectly valid way to escape glob magic chars.
153+
if (
154+
negs.length === 0 &&
155+
ranges.length === 1 &&
156+
/^\\?.$/.test(ranges[0]) &&
157+
!negate
158+
) {
159+
const r = ranges[0].length === 2 ? ranges[0].slice(-1) : ranges[0]
160+
return [regexpEscape(r), false, endPos - pos, false]
141161
}
142162

143163
const sranges = '[' + (negate ? '^' : '') + rangesToString(ranges) + ']'
@@ -149,5 +169,5 @@ export const parseClass = (
149169
? sranges
150170
: snegs
151171

152-
return [comb, uflag, endPos - pos]
172+
return [comb, uflag, endPos - pos, true]
153173
}

src/escape.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { MinimatchOptions } from './index.js'
2+
/**
3+
* Escape all magic characters in a glob pattern.
4+
*
5+
* If the {@link windowsPathsNoEscape | GlobOptions.windowsPathsNoEscape}
6+
* option is used, then characters are escaped by wrapping in `[]`, because
7+
* a magic character wrapped in a character class can only be satisfied by
8+
* that exact character. In this mode, `\` is _not_ escaped, because it is
9+
* not interpreted as a magic character, but instead as a path separator.
10+
*/
11+
export const escape = (
12+
s: string,
13+
{
14+
windowsPathsNoEscape = false,
15+
}: Pick<MinimatchOptions, 'windowsPathsNoEscape'> = {}
16+
) => {
17+
// don't need to escape +@! because we escape the parens
18+
// that make those magic, and escaping ! as [!] isn't valid,
19+
// because [!]] is a valid glob class meaning not ']'.
20+
return windowsPathsNoEscape
21+
? s.replace(/[?*()[\]]/g, '[$&]')
22+
: s.replace(/[?*()[\]\\]/g, '\\$&')
23+
}

src/index.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import expand from 'brace-expansion'
22
import { parseClass } from './brace-expressions.js'
3+
import { escape } from './escape.js'
4+
import { unescape } from './unescape.js'
35

46
export interface MinimatchOptions {
57
nobrace?: boolean
@@ -15,6 +17,7 @@ export interface MinimatchOptions {
1517
dot?: boolean
1618
nocase?: boolean
1719
nocaseMagicOnly?: boolean
20+
magicalBraces?: boolean
1821
matchBase?: boolean
1922
flipNegate?: boolean
2023
preserveMultipleSlashes?: boolean
@@ -182,6 +185,16 @@ export const defaults = (def: MinimatchOptions): typeof minimatch => {
182185
}
183186
},
184187

188+
unescape: (
189+
s: string,
190+
options: Pick<MinimatchOptions, 'windowsPathsNoEscape'> = {}
191+
) => orig.unescape(s, ext(def, options)),
192+
193+
escape: (
194+
s: string,
195+
options: Pick<MinimatchOptions, 'windowsPathsNoEscape'> = {}
196+
) => orig.escape(s, ext(def, options)),
197+
185198
filter: (pattern: string, options: MinimatchOptions = {}) =>
186199
orig.filter(pattern, ext(def, options)),
187200

@@ -353,6 +366,18 @@ export class Minimatch {
353366
this.make()
354367
}
355368

369+
hasMagic():boolean {
370+
if (this.options.magicalBraces && this.set.length > 1) {
371+
return true
372+
}
373+
for (const pattern of this.set) {
374+
for (const part of pattern) {
375+
if (typeof part !== 'string') return true
376+
}
377+
}
378+
return false
379+
}
380+
356381
debug(..._: any[]) {}
357382

358383
make() {
@@ -1182,12 +1207,12 @@ export class Minimatch {
11821207
case '[':
11831208
// swallow any state-tracking char before the [
11841209
clearStateChar()
1185-
const [src, needUflag, consumed] = parseClass(pattern, i)
1210+
const [src, needUflag, consumed, magic] = parseClass(pattern, i)
11861211
if (consumed) {
11871212
re += src
11881213
uflag = uflag || needUflag
11891214
i += consumed - 1
1190-
hasMagic = true
1215+
hasMagic = hasMagic || magic
11911216
} else {
11921217
re += '\\['
11931218
}
@@ -1303,7 +1328,7 @@ export class Minimatch {
13031328
// unescape anything in it, though, so that it'll be
13041329
// an exact match against a file etc.
13051330
if (!hasMagic) {
1306-
return globUnescape(pattern)
1331+
return globUnescape(re)
13071332
}
13081333

13091334
const flags = (options.nocase ? 'i' : '') + (uflag ? 'u' : '')
@@ -1496,5 +1521,10 @@ export class Minimatch {
14961521
return minimatch.defaults(def).Minimatch
14971522
}
14981523
}
1499-
1524+
/* c8 ignore start */
1525+
export { escape } from './escape.js'
1526+
export { unescape } from './unescape.js'
1527+
/* c8 ignore stop */
15001528
minimatch.Minimatch = Minimatch
1529+
minimatch.escape = escape
1530+
minimatch.unescape = unescape

src/unescape.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { MinimatchOptions } from './index.js'
2+
/**
3+
* Un-escape a string that has been escaped with {@link escape}.
4+
*
5+
* If the {@link windowsPathsNoEscape} option is used, then square-brace
6+
* escapes are removed, but not backslash escapes. For example, it will turn
7+
* the string `'[*]'` into `*`, but it will not turn `'\\*'` into `'*'`,
8+
* becuase `\` is a path separator in `windowsPathsNoEscape` mode.
9+
*
10+
* When `windowsPathsNoEscape` is not set, then both brace escapes and
11+
* backslash escapes are removed.
12+
*
13+
* Slashes (and backslashes in `windowsPathsNoEscape` mode) cannot be escaped
14+
* or unescaped.
15+
*/
16+
export const unescape = (
17+
s: string,
18+
{
19+
windowsPathsNoEscape = false,
20+
}: Pick<MinimatchOptions, 'windowsPathsNoEscape'> = {}
21+
) => {
22+
return windowsPathsNoEscape
23+
? s.replace(/\[([^\/\\])\]/g, '$1')
24+
: s.replace(/((?!\\).|^)\[([^\/])\]/g, '$1$2').replace(/\\([^\/])/g, '$1')
25+
}

0 commit comments

Comments
 (0)