UNPKG

13.7 kBJavaScriptView Raw
1// A simple implementation of make-array
2function makeArray (subject) {
3 return Array.isArray(subject)
4 ? subject
5 : [subject]
6}
7
8const REGEX_TEST_BLANK_LINE = /^\s+$/
9const REGEX_REPLACE_LEADING_EXCAPED_EXCLAMATION = /^\\!/
10const REGEX_REPLACE_LEADING_EXCAPED_HASH = /^\\#/
11const REGEX_SPLITALL_CRLF = /\r?\n/g
12// /foo,
13// ./foo,
14// ../foo,
15// .
16// ..
17const REGEX_TEST_INVALID_PATH = /^\.*\/|^\.+$/
18
19const SLASH = '/'
20const KEY_IGNORE = typeof Symbol !== 'undefined'
21 ? Symbol.for('node-ignore')
22 /* istanbul ignore next */
23 : 'node-ignore'
24
25const define = (object, key, value) =>
26 Object.defineProperty(object, key, {value})
27
28const 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
32const 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 '`..`'
52const 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
138const 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
226const 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
256const 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
276const regexCache = Object.create(null)
277
278// @param {pattern}
279const 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
299const isString = subject => typeof subject === 'string'
300
301// > A blank line matches no files, so it can serve as a separator for readability.
302const 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
309const splitPattern = pattern => pattern.split(REGEX_SPLITALL_CRLF)
310
311class 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
325const 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
353const throwError = (message, Ctor) => {
354 throw new Ctor(message)
355}
356
357const returnFalse = () => false
358
359const 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
384class 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 */
538if (
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
560const factory = options => new Ignore(options)
561
562factory.isPathValid = path => checkPath(path, returnFalse)
563
564module.exports = factory