| 1 | // Alchemy modifications are licensed under Apache-2.0. |
| 2 | // This file includes third-party code; see /THIRD_PARTY_LICENSES.md. |
| 3 | // |
| 4 | // TypeScript port of `ignore` (https://github.com/kaelzhang/node-ignore) v7.0.5. |
| 5 | // Copyright (c) 2013 Kael Zhang <i@kael.me>, contributors. MIT License. |
| 6 | |
| 7 | function makeArray<T>(subject: T | readonly T[]): T[] { |
| 8 | return Array.isArray(subject) ? [...subject] : [subject as T]; |
| 9 | } |
| 10 | |
| 11 | const UNDEFINED = undefined; |
| 12 | const EMPTY = ""; |
| 13 | const SPACE = " "; |
| 14 | const ESCAPE = "\\"; |
| 15 | const REGEX_TEST_BLANK_LINE = /^\s+$/; |
| 16 | const REGEX_INVALID_TRAILING_BACKSLASH = /(?:[^\\]|^)\\$/; |
| 17 | const REGEX_REPLACE_LEADING_EXCAPED_EXCLAMATION = /^\\!/; |
| 18 | const REGEX_REPLACE_LEADING_EXCAPED_HASH = /^\\#/; |
| 19 | const REGEX_SPLITALL_CRLF = /\r?\n/g; |
| 20 | |
| 21 | // Invalid: |
| 22 | // - /foo, |
| 23 | // - ./foo, |
| 24 | // - ../foo, |
| 25 | // - . |
| 26 | // - .. |
| 27 | // Valid: |
| 28 | // - .foo |
| 29 | const REGEX_TEST_INVALID_PATH = /^\.{0,2}\/|^\.{1,2}$/; |
| 30 | |
| 31 | const REGEX_TEST_TRAILING_SLASH = /\/$/; |
| 32 | |
| 33 | const SLASH = "/"; |
| 34 | |
| 35 | // Do not use ternary expression here, since "istanbul ignore next" is buggy |
| 36 | let TMP_KEY_IGNORE: string | symbol = "node-ignore"; |
| 37 | /* istanbul ignore else */ |
| 38 | if (typeof Symbol !== "undefined") { |
| 39 | TMP_KEY_IGNORE = Symbol.for("node-ignore"); |
| 40 | } |
| 41 | const KEY_IGNORE = TMP_KEY_IGNORE; |
| 42 | |
| 43 | const define = <T>(object: object, key: PropertyKey, value: T): T => { |
| 44 | Object.defineProperty(object, key, { value }); |
| 45 | return value; |
| 46 | }; |
| 47 | |
| 48 | const REGEX_REGEXP_RANGE = /([0-z])-([0-z])/g; |
| 49 | |
| 50 | const RETURN_FALSE = () => false; |
| 51 | |
| 52 | // Sanitize the range of a regular expression |
| 53 | // The cases are complicated, see test cases for details |
| 54 | const sanitizeRange = (range: string): string => |
| 55 | range.replace(REGEX_REGEXP_RANGE, (match, from, to) => |
| 56 | from.charCodeAt(0) <= to.charCodeAt(0) |
| 57 | ? match |
| 58 | : // Invalid range (out of order) which is ok for gitignore rules but |
| 59 | // fatal for JavaScript regular expression, so eliminate it. |
| 60 | EMPTY, |
| 61 | ); |
| 62 | |
| 63 | // > An optional `!` or `^` at the start of a class negates it, so that it |
| 64 | // > matches any character not in the set. (gitignore(5), fnmatch(3)) |
| 65 | // The leading `^` has already been escaped to `\^` by the metacharacter |
| 66 | // escaper, so we strip the literal `!` or escaped `^` and emit a single |
| 67 | // regex `^` which is the JavaScript negation token. |
| 68 | const negateRange = (range: string): string => |
| 69 | range.startsWith("!") || range.startsWith("\\^") |
| 70 | ? `^${range.slice(range[0] === "!" ? 1 : 2)}` |
| 71 | : range; |
| 72 | |
| 73 | // See fixtures #59 |
| 74 | const cleanRangeBackSlash = (slashes: string): string => { |
| 75 | const { length } = slashes; |
| 76 | return slashes.slice(0, length - (length % 2)); |
| 77 | }; |
| 78 | |
| 79 | // > If the pattern ends with a slash, |
| 80 | // > it is removed for the purpose of the following description, |
| 81 | // > but it would only find a match with a directory. |
| 82 | // > In other words, foo/ will match a directory foo and paths underneath it, |
| 83 | // > but will not match a regular file or a symbolic link foo |
| 84 | // > (this is consistent with the way how pathspec works in general in Git). |
| 85 | // '`foo/`' will not match regular file '`foo`' or symbolic link '`foo`' |
| 86 | // -> ignore-rules will not deal with it, because it costs extra `fs.stat` call |
| 87 | // you could use option `mark: true` with `glob` |
| 88 | |
| 89 | // '`foo/`' should not continue with the '`..`' |
| 90 | type Replacer = (this: string, ...args: any[]) => string; |
| 91 | |
| 92 | const REPLACERS: Array<readonly [RegExp, Replacer]> = [ |
| 93 | [ |
| 94 | // Remove BOM |
| 95 | // TODO: |
| 96 | // Other similar zero-width characters? |
| 97 | /^\uFEFF/, |
| 98 | () => EMPTY, |
| 99 | ], |
| 100 | |
| 101 | // > Trailing spaces are ignored unless they are quoted with backslash ("\") |
| 102 | [ |
| 103 | // (a\ ) -> (a ) |
| 104 | // (a ) -> (a) |
| 105 | // (a ) -> (a) |
| 106 | // (a \ ) -> (a ) |
| 107 | /((?:\\\\)*?)(\\?\s+)$/, |
| 108 | (_, m1, m2) => m1 + (m2.indexOf("\\") === 0 ? SPACE : EMPTY), |
| 109 | ], |
| 110 | |
| 111 | // Replace (\ ) with ' ' |
| 112 | // (\ ) -> ' ' |
| 113 | // (\\ ) -> '\\ ' |
| 114 | // (\\\ ) -> '\\ ' |
| 115 | [ |
| 116 | /(\\+?)\s/g, |
| 117 | (_, m1) => { |
| 118 | const { length } = m1; |
| 119 | return m1.slice(0, length - (length % 2)) + SPACE; |
| 120 | }, |
| 121 | ], |
| 122 | |
| 123 | // Escape metacharacters |
| 124 | // which is written down by users but means special for regular expressions. |
| 125 | |
| 126 | // > There are 12 characters with special meanings: |
| 127 | // > - the backslash \, |
| 128 | // > - the caret ^, |
| 129 | // > - the dollar sign $, |
| 130 | // > - the period or dot ., |
| 131 | // > - the vertical bar or pipe symbol |, |
| 132 | // > - the question mark ?, |
| 133 | // > - the asterisk or star *, |
| 134 | // > - the plus sign +, |
| 135 | // > - the opening parenthesis (, |
| 136 | // > - the closing parenthesis ), |
| 137 | // > - and the opening square bracket [, |
| 138 | // > - the opening curly brace {, |
| 139 | // > These special characters are often called "metacharacters". |
| 140 | [/[\\$.|*+(){^]/g, (match) => `\\${match}`], |
| 141 | |
| 142 | [ |
| 143 | // > a question mark (?) matches a single character |
| 144 | /(?!\\)\?/g, |
| 145 | () => "[^/]", |
| 146 | ], |
| 147 | |
| 148 | // leading slash |
| 149 | [ |
| 150 | // > A leading slash matches the beginning of the pathname. |
| 151 | // > For example, "/*.c" matches "cat-file.c" but not "mozilla-sha1/sha1.c". |
| 152 | // A leading slash matches the beginning of the pathname |
| 153 | /^\//, |
| 154 | () => "^", |
| 155 | ], |
| 156 | |
| 157 | // replace special metacharacter slash after the leading slash |
| 158 | [/\//g, () => "\\/"], |
| 159 | |
| 160 | [ |
| 161 | // > A leading "**" followed by a slash means match in all directories. |
| 162 | // > For example, "**/foo" matches file or directory "foo" anywhere, |
| 163 | // > the same as pattern "foo". |
| 164 | // > "**/foo/bar" matches file or directory "bar" anywhere that is directly |
| 165 | // > under directory "foo". |
| 166 | // Notice that the '*'s have been replaced as '\\*' |
| 167 | /^\^*(?:\\\*\\\*\\\/)+/, |
| 168 | |
| 169 | // '**/foo' <-> 'foo' |
| 170 | () => "^(?:.*\\/)?", |
| 171 | ], |
| 172 | |
| 173 | // starting |
| 174 | [ |
| 175 | // there will be no leading '/' |
| 176 | // (which has been replaced by section "leading slash") |
| 177 | // If starts with '**', adding a '^' to the regular expression also works |
| 178 | /^(?=[^^])/, |
| 179 | function startingReplacer(this: string) { |
| 180 | // If has a slash `/` at the beginning or middle |
| 181 | return !/\/(?!$)/.test(this) |
| 182 | ? // > Prior to 2.22.1 |
| 183 | // > If the pattern does not contain a slash /, |
| 184 | // > Git treats it as a shell glob pattern |
| 185 | // Actually, if there is only a trailing slash, |
| 186 | // git also treats it as a shell glob pattern |
| 187 | |
| 188 | // After 2.22.1 (compatible but clearer) |
| 189 | // > If there is a separator at the beginning or middle (or both) |
| 190 | // > of the pattern, then the pattern is relative to the directory |
| 191 | // > level of the particular .gitignore file itself. |
| 192 | // > Otherwise the pattern may also match at any level below |
| 193 | // > the .gitignore level. |
| 194 | "(?:^|\\/)" |
| 195 | : // > Otherwise, Git treats the pattern as a shell glob suitable for |
| 196 | // > consumption by fnmatch(3) |
| 197 | "^"; |
| 198 | }, |
| 199 | ], |
| 200 | |
| 201 | // two globstars |
| 202 | [ |
| 203 | // Use lookahead assertions so that we could match more than one `'/**'` |
| 204 | /\\\/\\\*\\\*(?=\\\/|$)/g, |
| 205 | |
| 206 | // Zero, one or several directories |
| 207 | // should not use '*', or it will be replaced by the next replacer |
| 208 | |
| 209 | // Check if it is not the last `'/**'` |
| 210 | (_, index, str) => |
| 211 | index + 6 < str.length |
| 212 | ? // case: /**/ |
| 213 | // > A slash followed by two consecutive asterisks then a slash matches |
| 214 | // > zero or more directories. |
| 215 | // > For example, "a/**/b" matches "a/b", "a/x/b", "a/x/y/b" and so on. |
| 216 | // '/**/' |
| 217 | "(?:\\/[^\\/]+)*" |
| 218 | : // case: /** |
| 219 | // > A trailing `"/**"` matches everything inside. |
| 220 | |
| 221 | // #21: everything inside but it should not include the current folder |
| 222 | "\\/.+", |
| 223 | ], |
| 224 | |
| 225 | // normal intermediate wildcards |
| 226 | [ |
| 227 | // Never replace escaped '*' |
| 228 | // ignore rule '\*' will match the path '*' |
| 229 | |
| 230 | // 'abc.*/' -> go |
| 231 | // 'abc.*' -> skip this rule, |
| 232 | // coz trailing single wildcard will be handed by [trailing wildcard] |
| 233 | /(^|[^\\]+)(\\\*)+(?=.+)/g, |
| 234 | |
| 235 | // '*.js' matches '.js' |
| 236 | // '*.js' doesn't match 'abc' |
| 237 | (_, p1, p2) => { |
| 238 | // 1. |
| 239 | // > An asterisk "*" matches anything except a slash. |
| 240 | // 2. |
| 241 | // > Other consecutive asterisks are considered regular asterisks |
| 242 | // > and will match according to the previous rules. |
| 243 | const unescaped = p2.replace(/\\\*/g, "[^\\/]*"); |
| 244 | return p1 + unescaped; |
| 245 | }, |
| 246 | ], |
| 247 | |
| 248 | [ |
| 249 | // unescape, revert step 3 except for back slash |
| 250 | // For example, if a user escape a '\\*', |
| 251 | // after step 3, the result will be '\\\\\\*' |
| 252 | /\\\\\\(?=[$.|*+(){^])/g, |
| 253 | () => ESCAPE, |
| 254 | ], |
| 255 | |
| 256 | [ |
| 257 | // '\\\\' -> '\\' |
| 258 | /\\\\/g, |
| 259 | () => ESCAPE, |
| 260 | ], |
| 261 | |
| 262 | [ |
| 263 | // > The range notation, e.g. [a-zA-Z], |
| 264 | // > can be used to match one of the characters in a range. |
| 265 | |
| 266 | // `\` is escaped by step 3 |
| 267 | /(\\)?\[([^\]/]*?)(\\*)($|\])/g, |
| 268 | (match, leadEscape, range, endEscape, close) => |
| 269 | leadEscape === ESCAPE |
| 270 | ? // '\\[bar]' -> '\\\\[bar\\]' |
| 271 | `\\[${range}${cleanRangeBackSlash(endEscape)}${close}` |
| 272 | : close === "]" |
| 273 | ? endEscape.length % 2 === 0 |
| 274 | ? // A normal case, and it is a range notation |
| 275 | // '[bar]' |
| 276 | // '[bar\\\\]' |
| 277 | `[${negateRange(sanitizeRange(range))}${endEscape}]` |
| 278 | : // Invalid range notaton |
| 279 | // '[bar\\]' -> '[bar\\\\]' |
| 280 | "[]" |
| 281 | : "[]", |
| 282 | ], |
| 283 | |
| 284 | // ending |
| 285 | [ |
| 286 | // 'js' will not match 'js.' |
| 287 | // 'ab' will not match 'abc' |
| 288 | /(?:[^*])$/, |
| 289 | |
| 290 | // WTF! |
| 291 | // https://git-scm.com/docs/gitignore |
| 292 | // changes in [2.22.1](https://git-scm.com/docs/gitignore/2.22.1) |
| 293 | // which re-fixes #24, #38 |
| 294 | |
| 295 | // > If there is a separator at the end of the pattern then the pattern |
| 296 | // > will only match directories, otherwise the pattern can match both |
| 297 | // > files and directories. |
| 298 | |
| 299 | // 'js*' will not match 'a.js' |
| 300 | // 'js/' will not match 'a.js' |
| 301 | // 'js' will match 'a.js' and 'a.js/' |
| 302 | (match) => |
| 303 | /\/$/.test(match) |
| 304 | ? // foo/ will not match 'foo' |
| 305 | `${match}$` |
| 306 | : // foo matches 'foo' and 'foo/' |
| 307 | `${match}(?=$|\\/$)`, |
| 308 | ], |
| 309 | ]; |
| 310 | |
| 311 | const REGEX_REPLACE_TRAILING_WILDCARD = /(^|\\\/)?\\\*$/; |
| 312 | const MODE_IGNORE = "regex"; |
| 313 | const MODE_CHECK_IGNORE = "checkRegex"; |
| 314 | |
| 315 | type MatcherMode = typeof MODE_IGNORE | typeof MODE_CHECK_IGNORE; |
| 316 | |
| 317 | const TRAILING_WILD_CARD_REPLACERS: Record<MatcherMode, Replacer> = { |
| 318 | [MODE_IGNORE](_, p1: string | undefined) { |
| 319 | const prefix = p1 |
| 320 | ? // '\^': |
| 321 | // '/*' does not match EMPTY |
| 322 | // '/*' does not match everything |
| 323 | |
| 324 | // '\\\/': |
| 325 | // 'abc/*' does not match 'abc/' |
| 326 | `${p1}[^/]+` |
| 327 | : // 'a*' matches 'a' |
| 328 | // 'a*' matches 'aa' |
| 329 | "[^/]*"; |
| 330 | |
| 331 | return `${prefix}(?=$|\\/$)`; |
| 332 | }, |
| 333 | |
| 334 | [MODE_CHECK_IGNORE](_, p1: string | undefined) { |
| 335 | // When doing `git check-ignore` |
| 336 | const prefix = p1 |
| 337 | ? // '\\\/': |
| 338 | // 'abc/*' DOES match 'abc/' ! |
| 339 | `${p1}[^/]*` |
| 340 | : // 'a*' matches 'a' |
| 341 | // 'a*' matches 'aa' |
| 342 | "[^/]*"; |
| 343 | |
| 344 | return `${prefix}(?=$|\\/$)`; |
| 345 | }, |
| 346 | }; |
| 347 | |
| 348 | // @param {pattern} |
| 349 | const makeRegexPrefix = (pattern: string): string => |
| 350 | REPLACERS.reduce( |
| 351 | (prev, [matcher, replacer]) => |
| 352 | prev.replace(matcher, replacer.bind(pattern)), |
| 353 | pattern, |
| 354 | ); |
| 355 | |
| 356 | const isString = (subject: unknown): subject is string => |
| 357 | typeof subject === "string"; |
| 358 | |
| 359 | // > A blank line matches no files, so it can serve as a separator for readability. |
| 360 | const checkPattern = (pattern: string): boolean => |
| 361 | Boolean( |
| 362 | pattern && |
| 363 | !REGEX_TEST_BLANK_LINE.test(pattern) && |
| 364 | !REGEX_INVALID_TRAILING_BACKSLASH.test(pattern) && |
| 365 | // > A line starting with # serves as a comment. |
| 366 | pattern.indexOf("#") !== 0, |
| 367 | ); |
| 368 | |
| 369 | const splitPattern = (pattern: string): string[] => |
| 370 | pattern.split(REGEX_SPLITALL_CRLF).filter(Boolean); |
| 371 | |
| 372 | class IgnoreRule { |
| 373 | readonly pattern: string; |
| 374 | readonly mark: string | undefined; |
| 375 | readonly negative: boolean; |
| 376 | readonly body!: string; |
| 377 | readonly ignoreCase!: boolean; |
| 378 | readonly regexPrefix!: string; |
| 379 | private _regex?: RegExp; |
| 380 | private _checkRegex?: RegExp; |
| 381 | |
| 382 | constructor( |
| 383 | pattern: string, |
| 384 | mark: string | undefined, |
| 385 | body: string, |
| 386 | ignoreCase: boolean, |
| 387 | negative: boolean, |
| 388 | prefix: string, |
| 389 | ) { |
| 390 | this.pattern = pattern; |
| 391 | this.mark = mark; |
| 392 | this.negative = negative; |
| 393 | |
| 394 | define(this, "body", body); |
| 395 | define(this, "ignoreCase", ignoreCase); |
| 396 | define(this, "regexPrefix", prefix); |
| 397 | } |
| 398 | |
| 399 | get regex() { |
| 400 | const key = `_${MODE_IGNORE}` as const; |
| 401 | |
| 402 | if (this[key]) { |
| 403 | return this[key]; |
| 404 | } |
| 405 | |
| 406 | return this._make(MODE_IGNORE, key); |
| 407 | } |
| 408 | |
| 409 | get checkRegex() { |
| 410 | const key = `_${MODE_CHECK_IGNORE}` as const; |
| 411 | |
| 412 | if (this[key]) { |
| 413 | return this[key]; |
| 414 | } |
| 415 | |
| 416 | return this._make(MODE_CHECK_IGNORE, key); |
| 417 | } |
| 418 | |
| 419 | _make(mode: MatcherMode, key: "_regex" | "_checkRegex"): RegExp { |
| 420 | const str = this.regexPrefix.replace( |
| 421 | REGEX_REPLACE_TRAILING_WILDCARD, |
| 422 | |
| 423 | // It does not need to bind pattern |
| 424 | TRAILING_WILD_CARD_REPLACERS[mode], |
| 425 | ); |
| 426 | |
| 427 | const regex = this.ignoreCase ? new RegExp(str, "i") : new RegExp(str); |
| 428 | |
| 429 | return define(this, key, regex); |
| 430 | } |
| 431 | } |
| 432 | |
| 433 | const createRule = ( |
| 434 | { pattern, mark }: PatternParams, |
| 435 | ignoreCase: boolean, |
| 436 | ): IgnoreRule => { |
| 437 | let negative = false; |
| 438 | let body = pattern; |
| 439 | |
| 440 | // > An optional prefix "!" which negates the pattern; |
| 441 | if (body.indexOf("!") === 0) { |
| 442 | negative = true; |
| 443 | body = body.substr(1); |
| 444 | } |
| 445 | |
| 446 | body = body |
| 447 | // > Put a backslash ("\") in front of the first "!" for patterns that |
| 448 | // > begin with a literal "!", for example, `"\!important!.txt"`. |
| 449 | .replace(REGEX_REPLACE_LEADING_EXCAPED_EXCLAMATION, "!") |
| 450 | // > Put a backslash ("\") in front of the first hash for patterns that |
| 451 | // > begin with a hash. |
| 452 | .replace(REGEX_REPLACE_LEADING_EXCAPED_HASH, "#"); |
| 453 | |
| 454 | const regexPrefix = makeRegexPrefix(body); |
| 455 | |
| 456 | return new IgnoreRule(pattern, mark, body, ignoreCase, negative, regexPrefix); |
| 457 | }; |
| 458 | |
| 459 | class RuleManager { |
| 460 | private readonly _ignoreCase: boolean; |
| 461 | _rules: IgnoreRule[] = []; |
| 462 | private _added = false; |
| 463 | |
| 464 | constructor(ignoreCase: boolean) { |
| 465 | this._ignoreCase = ignoreCase; |
| 466 | } |
| 467 | |
| 468 | _add(pattern: string | Ignore | PatternParams): void { |
| 469 | // #32 |
| 470 | const ignorePattern = pattern as Ignore & Record<PropertyKey, unknown>; |
| 471 | if (pattern && ignorePattern[KEY_IGNORE]) { |
| 472 | this._rules = this._rules.concat(ignorePattern._rules._rules); |
| 473 | this._added = true; |
| 474 | return; |
| 475 | } |
| 476 | |
| 477 | if (isString(pattern)) { |
| 478 | pattern = { |
| 479 | pattern, |
| 480 | }; |
| 481 | } |
| 482 | |
| 483 | const patternParams = pattern as PatternParams; |
| 484 | if (checkPattern(patternParams.pattern)) { |
| 485 | const rule = createRule(patternParams, this._ignoreCase); |
| 486 | this._added = true; |
| 487 | this._rules.push(rule); |
| 488 | } |
| 489 | } |
| 490 | |
| 491 | // @param {Array<string> | string | Ignore} pattern |
| 492 | add( |
| 493 | pattern: string | Ignore | PatternParams | readonly (string | Ignore)[], |
| 494 | ): boolean { |
| 495 | this._added = false; |
| 496 | |
| 497 | const patterns: readonly (string | Ignore | PatternParams)[] = isString( |
| 498 | pattern, |
| 499 | ) |
| 500 | ? splitPattern(pattern) |
| 501 | : Array.isArray(pattern) |
| 502 | ? pattern |
| 503 | : [pattern as Ignore | PatternParams]; |
| 504 | patterns.forEach(this._add, this); |
| 505 | |
| 506 | return this._added; |
| 507 | } |
| 508 | |
| 509 | // Test one single path without recursively checking parent directories |
| 510 | // |
| 511 | // - checkUnignored `boolean` whether should check if the path is unignored, |
| 512 | // setting `checkUnignored` to `false` could reduce additional |
| 513 | // path matching. |
| 514 | // - check `string` either `MODE_IGNORE` or `MODE_CHECK_IGNORE` |
| 515 | |
| 516 | // @returns {TestResult} true if a file is ignored |
| 517 | test( |
| 518 | path: string, |
| 519 | checkUnignored: boolean, |
| 520 | mode: MatcherMode, |
| 521 | ): IgnoreTestResult { |
| 522 | let ignored = false; |
| 523 | let unignored = false; |
| 524 | let matchedRule: IgnoreRule | undefined; |
| 525 | |
| 526 | this._rules.forEach((rule) => { |
| 527 | const { negative } = rule; |
| 528 | |
| 529 | // | ignored : unignored |
| 530 | // -------- | --------------------------------------- |
| 531 | // negative | 0:0 | 0:1 | 1:0 | 1:1 |
| 532 | // -------- | ------- | ------- | ------- | -------- |
| 533 | // 0 | TEST | TEST | SKIP | X |
| 534 | // 1 | TESTIF | SKIP | TEST | X |
| 535 | |
| 536 | // - SKIP: always skip |
| 537 | // - TEST: always test |
| 538 | // - TESTIF: only test if checkUnignored |
| 539 | // - X: that never happen |
| 540 | if ( |
| 541 | (unignored === negative && ignored !== unignored) || |
| 542 | (negative && !ignored && !unignored && !checkUnignored) |
| 543 | ) { |
| 544 | return; |
| 545 | } |
| 546 | |
| 547 | const matched = rule[mode].test(path); |
| 548 | |
| 549 | if (!matched) { |
| 550 | return; |
| 551 | } |
| 552 | |
| 553 | ignored = !negative; |
| 554 | unignored = negative; |
| 555 | |
| 556 | matchedRule = negative ? UNDEFINED : rule; |
| 557 | }); |
| 558 | |
| 559 | const ret: IgnoreTestResult = { |
| 560 | ignored, |
| 561 | unignored, |
| 562 | }; |
| 563 | |
| 564 | if (matchedRule) { |
| 565 | ret.rule = matchedRule; |
| 566 | } |
| 567 | |
| 568 | return ret; |
| 569 | } |
| 570 | } |
| 571 | |
| 572 | type ErrorConstructor = new (message: string) => Error; |
| 573 | type InvalidPathHandler = ( |
| 574 | message: string, |
| 575 | constructor: ErrorConstructor, |
| 576 | ) => boolean; |
| 577 | |
| 578 | const throwError: InvalidPathHandler = (message, Ctor) => { |
| 579 | throw new Ctor(message); |
| 580 | }; |
| 581 | |
| 582 | interface CheckPath { |
| 583 | (path: unknown, originalPath: unknown, doThrow: InvalidPathHandler): boolean; |
| 584 | isNotRelative: (path: string) => boolean; |
| 585 | convert: (path: string) => string; |
| 586 | } |
| 587 | |
| 588 | const checkPath: CheckPath = (path, originalPath, doThrow) => { |
| 589 | if (!isString(path)) { |
| 590 | return doThrow( |
| 591 | `path must be a string, but got \`${originalPath}\``, |
| 592 | TypeError, |
| 593 | ); |
| 594 | } |
| 595 | |
| 596 | // We don't know if we should ignore EMPTY, so throw |
| 597 | if (!path) { |
| 598 | return doThrow(`path must not be empty`, TypeError); |
| 599 | } |
| 600 | |
| 601 | // Check if it is a relative path |
| 602 | if (checkPath.isNotRelative(path)) { |
| 603 | const r = "`path.relative()`d"; |
| 604 | return doThrow( |
| 605 | `path should be a ${r} string, but got "${originalPath}"`, |
| 606 | RangeError, |
| 607 | ); |
| 608 | } |
| 609 | |
| 610 | return true; |
| 611 | }; |
| 612 | |
| 613 | const isNotRelative = (path: string): boolean => |
| 614 | REGEX_TEST_INVALID_PATH.test(path); |
| 615 | |
| 616 | checkPath.isNotRelative = isNotRelative; |
| 617 | |
| 618 | // On windows, the following function will be replaced |
| 619 | /* istanbul ignore next */ |
| 620 | checkPath.convert = (path) => path; |
| 621 | |
| 622 | class Ignore { |
| 623 | readonly [KEY_IGNORE]?: true; |
| 624 | readonly _rules: RuleManager; |
| 625 | private readonly _strictPathCheck: boolean; |
| 626 | private _ignoreCache!: Record<string, IgnoreTestResult>; |
| 627 | private _testCache!: Record<string, IgnoreTestResult>; |
| 628 | |
| 629 | constructor({ |
| 630 | ignorecase = true, |
| 631 | ignoreCase = ignorecase, |
| 632 | allowRelativePaths = false, |
| 633 | }: IgnoreOptions = {}) { |
| 634 | define(this, KEY_IGNORE, true); |
| 635 | |
| 636 | this._rules = new RuleManager(ignoreCase); |
| 637 | this._strictPathCheck = !allowRelativePaths; |
| 638 | this._initCache(); |
| 639 | } |
| 640 | |
| 641 | _initCache(): void { |
| 642 | // A cache for the result of `.ignores()` |
| 643 | this._ignoreCache = Object.create(null); |
| 644 | |
| 645 | // A cache for the result of `.test()` |
| 646 | this._testCache = Object.create(null); |
| 647 | } |
| 648 | |
| 649 | add( |
| 650 | pattern: string | Ignore | readonly (string | Ignore)[] | PatternParams, |
| 651 | ): this { |
| 652 | if (this._rules.add(pattern)) { |
| 653 | // Some rules have just added to the ignore, |
| 654 | // making the behavior changed, |
| 655 | // so we need to re-initialize the result cache |
| 656 | this._initCache(); |
| 657 | } |
| 658 | |
| 659 | return this; |
| 660 | } |
| 661 | |
| 662 | // legacy |
| 663 | addPattern(pattern: string | readonly string[]): this { |
| 664 | return this.add(pattern); |
| 665 | } |
| 666 | |
| 667 | // @returns {TestResult} |
| 668 | _test( |
| 669 | originalPath: string, |
| 670 | cache: Record<string, IgnoreTestResult>, |
| 671 | checkUnignored: boolean, |
| 672 | slices?: string[], |
| 673 | ): IgnoreTestResult { |
| 674 | const path = |
| 675 | originalPath && |
| 676 | // Supports nullable path |
| 677 | checkPath.convert(originalPath); |
| 678 | |
| 679 | checkPath( |
| 680 | path, |
| 681 | originalPath, |
| 682 | this._strictPathCheck ? throwError : RETURN_FALSE, |
| 683 | ); |
| 684 | |
| 685 | return this._t(path, cache, checkUnignored, slices); |
| 686 | } |
| 687 | |
| 688 | checkIgnore(path: string): IgnoreTestResult { |
| 689 | // If the path doest not end with a slash, `.ignores()` is much equivalent |
| 690 | // to `git check-ignore` |
| 691 | if (!REGEX_TEST_TRAILING_SLASH.test(path)) { |
| 692 | return this.test(path); |
| 693 | } |
| 694 | |
| 695 | const slices = path.split(SLASH).filter(Boolean); |
| 696 | slices.pop(); |
| 697 | |
| 698 | if (slices.length) { |
| 699 | const parent = this._t( |
| 700 | slices.join(SLASH) + SLASH, |
| 701 | this._testCache, |
| 702 | true, |
| 703 | slices, |
| 704 | ); |
| 705 | |
| 706 | if (parent.ignored) { |
| 707 | return parent; |
| 708 | } |
| 709 | } |
| 710 | |
| 711 | return this._rules.test(path, false, MODE_CHECK_IGNORE); |
| 712 | } |
| 713 | |
| 714 | _t( |
| 715 | // The path to be tested |
| 716 | path: string, |
| 717 | |
| 718 | // The cache for the result of a certain checking |
| 719 | cache: Record<string, IgnoreTestResult>, |
| 720 | |
| 721 | // Whether should check if the path is unignored |
| 722 | checkUnignored: boolean, |
| 723 | |
| 724 | // The path slices |
| 725 | slices?: string[], |
| 726 | ): IgnoreTestResult { |
| 727 | if (path in cache) { |
| 728 | return cache[path]; |
| 729 | } |
| 730 | |
| 731 | if (!slices) { |
| 732 | // path/to/a.js |
| 733 | // ['path', 'to', 'a.js'] |
| 734 | slices = path.split(SLASH).filter(Boolean); |
| 735 | } |
| 736 | |
| 737 | slices.pop(); |
| 738 | |
| 739 | // If the path has no parent directory, just test it |
| 740 | if (!slices.length) { |
| 741 | return (cache[path] = this._rules.test( |
| 742 | path, |
| 743 | checkUnignored, |
| 744 | MODE_IGNORE, |
| 745 | )); |
| 746 | } |
| 747 | |
| 748 | const parent = this._t( |
| 749 | slices.join(SLASH) + SLASH, |
| 750 | cache, |
| 751 | checkUnignored, |
| 752 | slices, |
| 753 | ); |
| 754 | |
| 755 | // If the path contains a parent directory, check the parent first |
| 756 | return (cache[path] = parent.ignored |
| 757 | ? // > It is not possible to re-include a file if a parent directory of |
| 758 | // > that file is excluded. |
| 759 | parent |
| 760 | : this._rules.test(path, checkUnignored, MODE_IGNORE)); |
| 761 | } |
| 762 | |
| 763 | ignores(path: string): boolean { |
| 764 | return this._test(path, this._ignoreCache, false).ignored; |
| 765 | } |
| 766 | |
| 767 | createFilter(): (path: string) => boolean { |
| 768 | return (path: string) => !this.ignores(path); |
| 769 | } |
| 770 | |
| 771 | filter(paths: readonly string[]): string[] { |
| 772 | return makeArray(paths).filter(this.createFilter()); |
| 773 | } |
| 774 | |
| 775 | // @returns {TestResult} |
| 776 | test(path: string): IgnoreTestResult { |
| 777 | return this._test(path, this._testCache, true); |
| 778 | } |
| 779 | } |
| 780 | |
| 781 | const factory = (options?: IgnoreOptions): Ignore => new Ignore(options); |
| 782 | |
| 783 | const isPathValid = (path: string): boolean => |
| 784 | checkPath(path && checkPath.convert(path), path, RETURN_FALSE); |
| 785 | |
| 786 | /* istanbul ignore next */ |
| 787 | const setupWindows = () => { |
| 788 | /* eslint no-control-regex: "off" */ |
| 789 | const makePosix = (str: string): string => |
| 790 | /^\\\\\?\\/.test(str) || /["<>|\u0000-\u001F]+/u.test(str) |
| 791 | ? str |
| 792 | : str.replace(/\\/g, "/"); |
| 793 | |
| 794 | checkPath.convert = makePosix; |
| 795 | |
| 796 | // 'C:\\foo' <- 'C:\\foo' has been converted to 'C:/' |
| 797 | // 'd:\\foo' |
| 798 | const REGEX_TEST_WINDOWS_PATH_ABSOLUTE = /^[a-z]:\//i; |
| 799 | checkPath.isNotRelative = (path: string) => |
| 800 | REGEX_TEST_WINDOWS_PATH_ABSOLUTE.test(path) || isNotRelative(path); |
| 801 | }; |
| 802 | |
| 803 | // Windows |
| 804 | // -------------------------------------------------------------- |
| 805 | /* istanbul ignore next */ |
| 806 | if ( |
| 807 | // Detect `process` so that it can run in browsers. |
| 808 | typeof process !== "undefined" && |
| 809 | process.platform === "win32" |
| 810 | ) { |
| 811 | setupWindows(); |
| 812 | } |
| 813 | |
| 814 | // ES module exports (upstream uses `module.exports`). Upstream also exposes |
| 815 | // `setupWindows` via `Symbol.for("setupWindows")` for its own test suite; we |
| 816 | // drop that hook since it's unused. |
| 817 | |
| 818 | export default factory as unknown as ( |
| 819 | options?: IgnoreOptions, |
| 820 | ) => IgnoreInstance; |
| 821 | export { Ignore, isPathValid }; |
| 822 | |
| 823 | // --- Type declarations ----------------------------------------------------- |
| 824 | // (not part of the verbatim upstream source; mirrors upstream `index.d.ts`) |
| 825 | |
| 826 | type Pathname = string; |
| 827 | |
| 828 | interface PatternParams { |
| 829 | pattern: string; |
| 830 | mark?: string; |
| 831 | } |
| 832 | |
| 833 | interface IgnoreTestResult { |
| 834 | ignored: boolean; |
| 835 | unignored: boolean; |
| 836 | rule?: IgnoreRule; |
| 837 | } |
| 838 | |
| 839 | export interface IgnoreOptions { |
| 840 | ignorecase?: boolean; |
| 841 | /** For compatibility. */ |
| 842 | ignoreCase?: boolean; |
| 843 | allowRelativePaths?: boolean; |
| 844 | } |
| 845 | |
| 846 | export interface IgnoreInstance { |
| 847 | add( |
| 848 | patterns: |
| 849 | | string |
| 850 | | IgnoreInstance |
| 851 | | readonly (string | IgnoreInstance)[] |
| 852 | | PatternParams, |
| 853 | ): this; |
| 854 | filter(pathnames: readonly Pathname[]): Pathname[]; |
| 855 | createFilter(): (pathname: Pathname) => boolean; |
| 856 | ignores(pathname: Pathname): boolean; |
| 857 | test(pathname: Pathname): { |
| 858 | ignored: boolean; |
| 859 | unignored: boolean; |
| 860 | rule?: { pattern: string; mark?: string; negative: boolean }; |
| 861 | }; |
| 862 | checkIgnore(pathname: Pathname): { |
| 863 | ignored: boolean; |
| 864 | unignored: boolean; |
| 865 | rule?: { pattern: string; mark?: string; negative: boolean }; |
| 866 | }; |
| 867 | } |