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