1 | // A simple implementation of make-array
|
2 | function makeArray (subject) {
|
3 | return Array.isArray(subject)
|
4 | ? subject
|
5 | : [subject]
|
6 | }
|
7 |
|
8 | const REGEX_TEST_BLANK_LINE = /^\s+$/
|
9 | const REGEX_REPLACE_LEADING_EXCAPED_EXCLAMATION = /^\\!/
|
10 | const REGEX_REPLACE_LEADING_EXCAPED_HASH = /^\\#/
|
11 | const REGEX_SPLITALL_CRLF = /\r?\n/g
|
12 | // /foo,
|
13 | // ./foo,
|
14 | // ../foo,
|
15 | // .
|
16 | // ..
|
17 | const REGEX_TEST_INVALID_PATH = /^\.*\/|^\.+$/
|
18 |
|
19 | const SLASH = '/'
|
20 | const KEY_IGNORE = typeof Symbol !== 'undefined'
|
21 | ? Symbol.for('node-ignore')
|
22 | /* istanbul ignore next */
|
23 | : 'node-ignore'
|
24 |
|
25 | const define = (object, key, value) =>
|
26 | Object.defineProperty(object, key, {value})
|
27 |
|
28 | const REGEX_REGEXP_RANGE = /([0-z])-([0-z])/g
|
29 |
|
30 | // Sanitize the range of a regular expression
|
31 | // The cases are complicated, see test cases for details
|
32 | const sanitizeRange = range => range.replace(
|
33 | REGEX_REGEXP_RANGE,
|
34 | (match, from, to) => from.charCodeAt(0) <= to.charCodeAt(0)
|
35 | ? match
|
36 | // Invalid range (out of order) which is ok for gitignore rules but
|
37 | // fatal for JavaScript regular expression, so eliminate it.
|
38 | : ''
|
39 | )
|
40 |
|
41 | // > If the pattern ends with a slash,
|
42 | // > it is removed for the purpose of the following description,
|
43 | // > but it would only find a match with a directory.
|
44 | // > In other words, foo/ will match a directory foo and paths underneath it,
|
45 | // > but will not match a regular file or a symbolic link foo
|
46 | // > (this is consistent with the way how pathspec works in general in Git).
|
47 | // '`foo/`' will not match regular file '`foo`' or symbolic link '`foo`'
|
48 | // -> ignore-rules will not deal with it, because it costs extra `fs.stat` call
|
49 | // you could use option `mark: true` with `glob`
|
50 |
|
51 | // '`foo/`' should not continue with the '`..`'
|
52 | const DEFAULT_REPLACER_PREFIX = [
|
53 |
|
54 | // > Trailing spaces are ignored unless they are quoted with backslash ("\")
|
55 | [
|
56 | // (a\ ) -> (a )
|
57 | // (a ) -> (a)
|
58 | // (a \ ) -> (a )
|
59 | /\\?\s+$/,
|
60 | match => match.indexOf('\\') === 0
|
61 | ? ' '
|
62 | : ''
|
63 | ],
|
64 |
|
65 | // replace (\ ) with ' '
|
66 | [
|
67 | /\\\s/g,
|
68 | () => ' '
|
69 | ],
|
70 |
|
71 | // Escape metacharacters
|
72 | // which is written down by users but means special for regular expressions.
|
73 |
|
74 | // > There are 12 characters with special meanings:
|
75 | // > - the backslash \,
|
76 | // > - the caret ^,
|
77 | // > - the dollar sign $,
|
78 | // > - the period or dot .,
|
79 | // > - the vertical bar or pipe symbol |,
|
80 | // > - the question mark ?,
|
81 | // > - the asterisk or star *,
|
82 | // > - the plus sign +,
|
83 | // > - the opening parenthesis (,
|
84 | // > - the closing parenthesis ),
|
85 | // > - and the opening square bracket [,
|
86 | // > - the opening curly brace {,
|
87 | // > These special characters are often called "metacharacters".
|
88 | [
|
89 | /[\\^$.|*+(){]/g,
|
90 | match => `\\${match}`
|
91 | ],
|
92 |
|
93 | [
|
94 | // > [abc] matches any character inside the brackets
|
95 | // > (in this case a, b, or c);
|
96 | /\[([^\]/]*)($|\])/g,
|
97 | (match, p1, p2) => p2 === ']'
|
98 | ? `[${sanitizeRange(p1)}]`
|
99 | : `\\${match}`
|
100 | ],
|
101 |
|
102 | [
|
103 | // > a question mark (?) matches a single character
|
104 | /(?!\\)\?/g,
|
105 | () => '[^/]'
|
106 | ],
|
107 |
|
108 | // leading slash
|
109 | [
|
110 |
|
111 | // > A leading slash matches the beginning of the pathname.
|
112 | // > For example, "/*.c" matches "cat-file.c" but not "mozilla-sha1/sha1.c".
|
113 | // A leading slash matches the beginning of the pathname
|
114 | /^\//,
|
115 | () => '^'
|
116 | ],
|
117 |
|
118 | // replace special metacharacter slash after the leading slash
|
119 | [
|
120 | /\//g,
|
121 | () => '\\/'
|
122 | ],
|
123 |
|
124 | [
|
125 | // > A leading "**" followed by a slash means match in all directories.
|
126 | // > For example, "**/foo" matches file or directory "foo" anywhere,
|
127 | // > the same as pattern "foo".
|
128 | // > "**/foo/bar" matches file or directory "bar" anywhere that is directly
|
129 | // > under directory "foo".
|
130 | // Notice that the '*'s have been replaced as '\\*'
|
131 | /^\^*\\\*\\\*\\\//,
|
132 |
|
133 | // '**/foo' <-> 'foo'
|
134 | () => '^(?:.*\\/)?'
|
135 | ]
|
136 | ]
|
137 |
|
138 | const DEFAULT_REPLACER_SUFFIX = [
|
139 | // starting
|
140 | [
|
141 | // there will be no leading '/'
|
142 | // (which has been replaced by section "leading slash")
|
143 | // If starts with '**', adding a '^' to the regular expression also works
|
144 | /^(?=[^^])/,
|
145 | function startingReplacer () {
|
146 | return !/\/(?!$)/.test(this)
|
147 | // > If the pattern does not contain a slash /,
|
148 | // > Git treats it as a shell glob pattern
|
149 | // Actually, if there is only a trailing slash,
|
150 | // git also treats it as a shell glob pattern
|
151 | ? '(?:^|\\/)'
|
152 |
|
153 | // > Otherwise, Git treats the pattern as a shell glob suitable for
|
154 | // > consumption by fnmatch(3)
|
155 | : '^'
|
156 | }
|
157 | ],
|
158 |
|
159 | // two globstars
|
160 | [
|
161 | // Use lookahead assertions so that we could match more than one `'/**'`
|
162 | /\\\/\\\*\\\*(?=\\\/|$)/g,
|
163 |
|
164 | // Zero, one or several directories
|
165 | // should not use '*', or it will be replaced by the next replacer
|
166 |
|
167 | // Check if it is not the last `'/**'`
|
168 | (_, index, str) => index + 6 < str.length
|
169 |
|
170 | // case: /**/
|
171 | // > A slash followed by two consecutive asterisks then a slash matches
|
172 | // > zero or more directories.
|
173 | // > For example, "a/**/b" matches "a/b", "a/x/b", "a/x/y/b" and so on.
|
174 | // '/**/'
|
175 | ? '(?:\\/[^\\/]+)*'
|
176 |
|
177 | // case: /**
|
178 | // > A trailing `"/**"` matches everything inside.
|
179 |
|
180 | // #21: everything inside but it should not include the current folder
|
181 | : '\\/.+'
|
182 | ],
|
183 |
|
184 | // intermediate wildcards
|
185 | [
|
186 | // Never replace escaped '*'
|
187 | // ignore rule '\*' will match the path '*'
|
188 |
|
189 | // 'abc.*/' -> go
|
190 | // 'abc.*' -> skip this rule
|
191 | /(^|[^\\]+)\\\*(?=.+)/g,
|
192 |
|
193 | // '*.js' matches '.js'
|
194 | // '*.js' doesn't match 'abc'
|
195 | (_, p1) => `${p1}[^\\/]*`
|
196 | ],
|
197 |
|
198 | // trailing wildcard
|
199 | [
|
200 | /(\^|\\\/)?\\\*$/,
|
201 | (_, p1) => {
|
202 | const prefix = p1
|
203 | // '\^':
|
204 | // '/*' does not match ''
|
205 | // '/*' does not match everything
|
206 |
|
207 | // '\\\/':
|
208 | // 'abc/*' does not match 'abc/'
|
209 | ? `${p1}[^/]+`
|
210 |
|
211 | // 'a*' matches 'a'
|
212 | // 'a*' matches 'aa'
|
213 | : '[^/]*'
|
214 |
|
215 | return `${prefix}(?=$|\\/$)`
|
216 | }
|
217 | ],
|
218 |
|
219 | [
|
220 | // unescape
|
221 | /\\\\\\/g,
|
222 | () => '\\'
|
223 | ]
|
224 | ]
|
225 |
|
226 | const POSITIVE_REPLACERS = [
|
227 | ...DEFAULT_REPLACER_PREFIX,
|
228 |
|
229 | // 'f'
|
230 | // matches
|
231 | // - /f(end)
|
232 | // - /f/
|
233 | // - (start)f(end)
|
234 | // - (start)f/
|
235 | // doesn't match
|
236 | // - oof
|
237 | // - foo
|
238 | // pseudo:
|
239 | // -> (^|/)f(/|$)
|
240 |
|
241 | // ending
|
242 | [
|
243 | // 'js' will not match 'js.'
|
244 | // 'ab' will not match 'abc'
|
245 | /(?:[^*/])$/,
|
246 |
|
247 | // 'js*' will not match 'a.js'
|
248 | // 'js/' will not match 'a.js'
|
249 | // 'js' will match 'a.js' and 'a.js/'
|
250 | match => `${match}(?=$|\\/)`
|
251 | ],
|
252 |
|
253 | ...DEFAULT_REPLACER_SUFFIX
|
254 | ]
|
255 |
|
256 | const NEGATIVE_REPLACERS = [
|
257 | ...DEFAULT_REPLACER_PREFIX,
|
258 |
|
259 | // #24, #38
|
260 | // The MISSING rule of [gitignore docs](https://git-scm.com/docs/gitignore)
|
261 | // A negative pattern without a trailing wildcard should not
|
262 | // re-include the things inside that directory.
|
263 |
|
264 | // eg:
|
265 | // ['node_modules/*', '!node_modules']
|
266 | // should ignore `node_modules/a.js`
|
267 | [
|
268 | /(?:[^*])$/,
|
269 | match => `${match}(?=$|\\/$)`
|
270 | ],
|
271 |
|
272 | ...DEFAULT_REPLACER_SUFFIX
|
273 | ]
|
274 |
|
275 | // A simple cache, because an ignore rule only has only one certain meaning
|
276 | const regexCache = Object.create(null)
|
277 |
|
278 | // @param {pattern}
|
279 | const makeRegex = (pattern, negative, ignorecase) => {
|
280 | const r = regexCache[pattern]
|
281 | if (r) {
|
282 | return r
|
283 | }
|
284 |
|
285 | const replacers = negative
|
286 | ? NEGATIVE_REPLACERS
|
287 | : POSITIVE_REPLACERS
|
288 |
|
289 | const source = replacers.reduce(
|
290 | (prev, current) => prev.replace(current[0], current[1].bind(pattern)),
|
291 | pattern
|
292 | )
|
293 |
|
294 | return regexCache[pattern] = ignorecase
|
295 | ? new RegExp(source, 'i')
|
296 | : new RegExp(source)
|
297 | }
|
298 |
|
299 | const isString = subject => typeof subject === 'string'
|
300 |
|
301 | // > A blank line matches no files, so it can serve as a separator for readability.
|
302 | const checkPattern = pattern => pattern
|
303 | && isString(pattern)
|
304 | && !REGEX_TEST_BLANK_LINE.test(pattern)
|
305 |
|
306 | // > A line starting with # serves as a comment.
|
307 | && pattern.indexOf('#') !== 0
|
308 |
|
309 | const splitPattern = pattern => pattern.split(REGEX_SPLITALL_CRLF)
|
310 |
|
311 | class IgnoreRule {
|
312 | constructor (
|
313 | origin,
|
314 | pattern,
|
315 | negative,
|
316 | regex
|
317 | ) {
|
318 | this.origin = origin
|
319 | this.pattern = pattern
|
320 | this.negative = negative
|
321 | this.regex = regex
|
322 | }
|
323 | }
|
324 |
|
325 | const createRule = (pattern, ignorecase) => {
|
326 | const origin = pattern
|
327 | let negative = false
|
328 |
|
329 | // > An optional prefix "!" which negates the pattern;
|
330 | if (pattern.indexOf('!') === 0) {
|
331 | negative = true
|
332 | pattern = pattern.substr(1)
|
333 | }
|
334 |
|
335 | pattern = pattern
|
336 | // > Put a backslash ("\") in front of the first "!" for patterns that
|
337 | // > begin with a literal "!", for example, `"\!important!.txt"`.
|
338 | .replace(REGEX_REPLACE_LEADING_EXCAPED_EXCLAMATION, '!')
|
339 | // > Put a backslash ("\") in front of the first hash for patterns that
|
340 | // > begin with a hash.
|
341 | .replace(REGEX_REPLACE_LEADING_EXCAPED_HASH, '#')
|
342 |
|
343 | const regex = makeRegex(pattern, negative, ignorecase)
|
344 |
|
345 | return new IgnoreRule(
|
346 | origin,
|
347 | pattern,
|
348 | negative,
|
349 | regex
|
350 | )
|
351 | }
|
352 |
|
353 | const throwError = (message, Ctor) => {
|
354 | throw new Ctor(message)
|
355 | }
|
356 |
|
357 | const returnFalse = () => false
|
358 |
|
359 | const checkPath = (path, doThrow) => {
|
360 | if (!isString(path)) {
|
361 | return doThrow(
|
362 | `path must be a string, but got \`${path}\``,
|
363 | TypeError
|
364 | )
|
365 | }
|
366 |
|
367 | // We don't know if we should ignore '', so throw
|
368 | if (!path) {
|
369 | return doThrow(`path must not be empty`, TypeError)
|
370 | }
|
371 |
|
372 | //
|
373 | if (REGEX_TEST_INVALID_PATH.test(path)) {
|
374 | const r = '`path.relative()`d'
|
375 | return doThrow(
|
376 | `path should be a ${r} string, but got "${path}"`,
|
377 | RangeError
|
378 | )
|
379 | }
|
380 |
|
381 | return true
|
382 | }
|
383 |
|
384 | class Ignore {
|
385 | constructor ({
|
386 | ignorecase = true
|
387 | } = {}) {
|
388 | this._rules = []
|
389 | this._ignorecase = ignorecase
|
390 | define(this, KEY_IGNORE, true)
|
391 | this._initCache()
|
392 | }
|
393 |
|
394 | _initCache () {
|
395 | this._ignoreCache = Object.create(null)
|
396 | this._testCache = Object.create(null)
|
397 | }
|
398 |
|
399 | _addPattern (pattern) {
|
400 | // #32
|
401 | if (pattern && pattern[KEY_IGNORE]) {
|
402 | this._rules = this._rules.concat(pattern._rules)
|
403 | this._added = true
|
404 | return
|
405 | }
|
406 |
|
407 | if (checkPattern(pattern)) {
|
408 | const rule = createRule(pattern, this._ignorecase)
|
409 | this._added = true
|
410 | this._rules.push(rule)
|
411 | }
|
412 | }
|
413 |
|
414 | // @param {Array<string> | string | Ignore} pattern
|
415 | add (pattern) {
|
416 | this._added = false
|
417 |
|
418 | makeArray(
|
419 | isString(pattern)
|
420 | ? splitPattern(pattern)
|
421 | : pattern
|
422 | ).forEach(this._addPattern, this)
|
423 |
|
424 | // Some rules have just added to the ignore,
|
425 | // making the behavior changed.
|
426 | if (this._added) {
|
427 | this._initCache()
|
428 | }
|
429 |
|
430 | return this
|
431 | }
|
432 |
|
433 | // legacy
|
434 | addPattern (pattern) {
|
435 | return this.add(pattern)
|
436 | }
|
437 |
|
438 | // | ignored : unignored
|
439 | // negative | 0:0 | 0:1 | 1:0 | 1:1
|
440 | // -------- | ------- | ------- | ------- | --------
|
441 | // 0 | TEST | TEST | SKIP | X
|
442 | // 1 | TESTIF | SKIP | TEST | X
|
443 |
|
444 | // - SKIP: always skip
|
445 | // - TEST: always test
|
446 | // - TESTIF: only test if checkUnignored
|
447 | // - X: that never happen
|
448 |
|
449 | // @param {boolean} whether should check if the path is unignored,
|
450 | // setting `checkUnignored` to `false` could reduce additional
|
451 | // path matching.
|
452 |
|
453 | // @returns {TestResult} true if a file is ignored
|
454 | _testOne (path, checkUnignored) {
|
455 | let ignored = false
|
456 | let unignored = false
|
457 |
|
458 | this._rules.forEach(rule => {
|
459 | const {negative} = rule
|
460 | if (
|
461 | unignored === negative && ignored !== unignored
|
462 | || negative && !ignored && !unignored && !checkUnignored
|
463 | ) {
|
464 | return
|
465 | }
|
466 |
|
467 | const matched = rule.regex.test(path)
|
468 |
|
469 | if (matched) {
|
470 | ignored = !negative
|
471 | unignored = negative
|
472 | }
|
473 | })
|
474 |
|
475 | return {
|
476 | ignored,
|
477 | unignored
|
478 | }
|
479 | }
|
480 |
|
481 | // @returns {TestResult}
|
482 | _test (path, cache, checkUnignored, slices) {
|
483 | checkPath(path, throwError)
|
484 |
|
485 | if (path in cache) {
|
486 | return cache[path]
|
487 | }
|
488 |
|
489 | if (!slices) {
|
490 | // path/to/a.js
|
491 | // ['path', 'to', 'a.js']
|
492 | slices = path.split(SLASH)
|
493 | }
|
494 |
|
495 | slices.pop()
|
496 |
|
497 | // If the path has no parent directory, just test it
|
498 | if (!slices.length) {
|
499 | return cache[path] = this._testOne(path, checkUnignored)
|
500 | }
|
501 |
|
502 | const parent = this._test(
|
503 | slices.join(SLASH) + SLASH,
|
504 | cache,
|
505 | checkUnignored,
|
506 | slices
|
507 | )
|
508 |
|
509 | // If the path contains a parent directory, check the parent first
|
510 | return cache[path] = parent.ignored
|
511 | // > It is not possible to re-include a file if a parent directory of
|
512 | // > that file is excluded.
|
513 | ? parent
|
514 | : this._testOne(path, checkUnignored)
|
515 | }
|
516 |
|
517 | ignores (path) {
|
518 | return this._test(path, this._ignoreCache, false).ignored
|
519 | }
|
520 |
|
521 | createFilter () {
|
522 | return path => !this.ignores(path)
|
523 | }
|
524 |
|
525 | filter (paths) {
|
526 | return makeArray(paths).filter(this.createFilter())
|
527 | }
|
528 |
|
529 | // @returns {TestResult}
|
530 | test (path) {
|
531 | return this._test(path, this._testCache, true)
|
532 | }
|
533 | }
|
534 |
|
535 | // Windows
|
536 | // --------------------------------------------------------------
|
537 | /* istanbul ignore if */
|
538 | if (
|
539 | // Detect `process` so that it can run in browsers.
|
540 | typeof process !== 'undefined'
|
541 | && (
|
542 | process.env && process.env.IGNORE_TEST_WIN32
|
543 | || process.platform === 'win32'
|
544 | )
|
545 | ) {
|
546 | const test = Ignore.prototype._test
|
547 |
|
548 | /* eslint no-control-regex: "off" */
|
549 | const make_posix = str => /^\\\\\?\\/.test(str)
|
550 | || /[^\x00-\x80]+/.test(str)
|
551 | ? str
|
552 | : str.replace(/\\/g, '/')
|
553 |
|
554 | Ignore.prototype._test = function testWin32 (path, ...args) {
|
555 | path = make_posix(path)
|
556 | return test.call(this, path, ...args)
|
557 | }
|
558 | }
|
559 |
|
560 | const factory = options => new Ignore(options)
|
561 |
|
562 | factory.isPathValid = path => checkPath(path, returnFalse)
|
563 |
|
564 | module.exports = factory
|