@gerhobbelt/gitignore-parser
Version:
A simple .gitignore parser.
391 lines (337 loc) • 13.9 kB
JavaScript
/*! gitignore-parser 0.2.0-9 https://github.com//GerHobbelt/gitignore-parser @license Apache License, Version 2.0 */
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = global || self, factory(global.gitignoreParser = {}));
}(this, (function (exports) { 'use strict';
// force to false and smart code compressors can remove the resulting 'dead code':
/**
* Compile the given `.gitignore` content (not filename!)
* and return an object with `accepts`, `denies` and `inspects` methods.
* These methods each accepts a single filename or path and determines whether
* they are acceptable or unacceptable according to the `.gitignore` definition.
*
* @param {String} content The `.gitignore` content to compile.
* @return {Object} The helper object with methods that operate on the compiled content.
*/
function compile(content) {
let parsed = parse(content),
positives = parsed[0],
negatives = parsed[1];
return {
/// Helper (which can be overridden by userland code) invoked when
/// any `accepts()`, `denies()` or `inspects()` fail to help
/// the developer analyze what is going on inside: some gitignore spec
/// bits are non-intuitive / non-trivial, after all.
diagnose: function (query) {
{
console.log(`${query.query}:`, query);
}
},
/// Return TRUE when the given `input` path PASSES the gitignore filters,
/// i.e. when the given input path is DENIED.
///
/// Notes:
/// - you MUST postfix a input directory with '/' to ensure the gitignore
/// rules can be applied conform spec.
/// - you MAY prefix a input directory with '/' when that directory is
/// 'rooted' in the same directory as the compiled .gitignore spec file.
accepts: function (input, expected) {
if (input[0] === '/') input = input.slice(1);
input = '/' + input;
let acceptRe = negatives[0];
let acceptTest = acceptRe.test(input);
let denyRe = positives[0];
let denyTest = denyRe.test(input);
let returnVal = acceptTest || !denyTest; // See the test/fixtures/gitignore.manpage.txt near line 680 (grep for "uber-nasty"):
// to resolve chained rules which reject, then accept, we need to establish
// the precedence of both accept and reject parts of the compiled gitignore by
// comparing match lengths.
// Since the generated consolidated regexes are lazy, we must loop through all lines' regexes instead:
let acceptMatch, denyMatch;
if (acceptTest && denyTest) {
for (let re of negatives[1]) {
let m = re.exec(input);
if (m) {
if (!acceptMatch) {
acceptMatch = m;
} else if (acceptMatch[0].length < m[0].length) {
acceptMatch = m;
}
}
}
for (let re of positives[1]) {
let m = re.exec(input);
if (m) {
if (!denyMatch) {
denyMatch = m;
} else if (denyMatch[0].length < m[0].length) {
denyMatch = m;
}
}
} // acceptMatch = acceptRe.exec(input);
// denyMatch = denyRe.exec(input);
returnVal = acceptMatch[0].length >= denyMatch[0].length;
}
if (expected != null && expected !== returnVal) {
this.diagnose({
query: 'accepts',
input,
expected,
acceptRe,
acceptTest,
acceptMatch,
denyRe,
denyTest,
denyMatch,
combine: '(Accept || !Deny)',
returnVal
});
}
return returnVal;
},
/// Return TRUE when the given `input` path FAILS the gitignore filters,
/// i.e. when the given input path is ACCEPTED.
///
/// Notes:
/// - you MUST postfix a input directory with '/' to ensure the gitignore
/// rules can be applied conform spec.
/// - you MAY prefix a input directory with '/' when that directory is
/// 'rooted' in the same directory as the compiled .gitignore spec file.
denies: function (input, expected) {
if (input[0] === '/') input = input.slice(1);
input = '/' + input;
let acceptRe = negatives[0];
let acceptTest = acceptRe.test(input);
let denyRe = positives[0];
let denyTest = denyRe.test(input); // boolean logic:
//
// denies = !accepts =>
// = !(Accept || !Deny) =>
// = (!Accept && !!Deny) =>
// = (!Accept && Deny)
let returnVal = !acceptTest && denyTest; // See the test/fixtures/gitignore.manpage.txt near line 680 (grep for "uber-nasty"):
// to resolve chained rules which reject, then accept, we need to establish
// the precedence of both accept and reject parts of the compiled gitignore by
// comparing match lengths.
// Since the generated regexes are all set up to be GREEDY, we can use the
// consolidated regex for this, instead of having to loop through all lines' regexes:
let acceptMatch, denyMatch;
if (acceptTest && denyTest) {
for (let re of negatives[1]) {
let m = re.exec(input);
if (m) {
if (!acceptMatch) {
acceptMatch = m;
} else if (acceptMatch[0].length < m[0].length) {
acceptMatch = m;
}
}
}
for (let re of positives[1]) {
let m = re.exec(input);
if (m) {
if (!denyMatch) {
denyMatch = m;
} else if (denyMatch[0].length < m[0].length) {
denyMatch = m;
}
}
} // acceptMatch = acceptRe.exec(input);
// denyMatch = denyRe.exec(input);
// boolean logic: !(A>=B) ==> A<B
returnVal = acceptMatch[0].length < denyMatch[0].length;
}
if (expected != null && expected !== returnVal) {
this.diagnose({
query: 'denies',
input,
expected,
acceptRe,
acceptTest,
acceptMatch,
denyRe,
denyTest,
denyMatch,
combine: '(!Accept && Deny)',
returnVal
});
}
return returnVal;
},
/// Return TRUE when the given `input` path is inspected by any .gitignore
/// filter line.
///
/// You can use this method to help construct the decision path when you
/// process nested .gitignore files: .gitignore filters in subdirectories
/// MAY override parent .gitignore filters only when there's actually ANY
/// filter in the child .gitignore after all.
///
/// Notes:
/// - you MUST postfix a input directory with '/' to ensure the gitignore
/// rules can be applied conform spec.
/// - you MAY prefix a input directory with '/' when that directory is
/// 'rooted' in the same directory as the compiled .gitignore spec file.
inspects: function (input, expected) {
if (input[0] === '/') input = input.slice(1);
input = '/' + input;
let acceptRe = negatives[0];
let acceptTest = acceptRe.test(input);
let denyRe = positives[0];
let denyTest = denyRe.test(input); // when any filter 'touches' the input path, it must match,
// no matter whether it's a deny or accept filter line:
let returnVal = acceptTest || denyTest;
if (expected != null && expected !== returnVal) {
this.diagnose({
query: 'inspects',
input,
expected,
acceptRe,
acceptTest,
denyRe,
denyTest,
combine: '(Accept || Deny)',
returnVal
});
}
return returnVal;
}
};
}
/**
* Parse the given `.gitignore` content and return an array
* containing positives and negatives.
* Each of these in turn contains a regexp which will be
* applied to the 'rooted' paths to test for *deny* or *accept*
* respectively.
*
* @param {String} content The content to parse,
* @return {Array[]} The parsed positive and negatives definitions.
*/
function parse(content) {
return content.split('\n').map(function (line) {
line = line.trim();
return line;
}).filter(function (line) {
return line && line[0] !== '#';
}).reduce(function (lists, line) {
let isNegative = line[0] === '!';
if (isNegative) {
line = line.slice(1);
}
if (isNegative) {
lists[1].push(line);
} else {
lists[0].push(line);
}
return lists;
}, [[], []]).map(function (list) {
list = list.sort().map(prepareRegexPattern); // don't need submatches, hence we use should use non-capturing `(?:...)` grouping regexes:
// those are generally faster than their submatch-capturing brothers:
if (list.length > 0) {
return [new RegExp('(?:' + list.join(')|(?:') + ')'), list.map(re => new RegExp(re))];
} // this regex *won't match a thing*:
return [new RegExp('$^'), []];
});
}
function prepareRegexPattern(pattern) {
// https://git-scm.com/docs/gitignore#_pattern_format
//
// * ...
//
// * If there is a separator at the beginning or middle (or both) of the pattern,
// then the pattern is relative to the directory level of the particular
// .gitignore file itself.
// Otherwise the pattern may also match at any level below the .gitignore level.
//
// * ...
//
// * For example, a pattern `doc/frotz/` matches `doc/frotz` directory, but
// not `a/doc/frotz` directory; however `frotz/` matches `frotz` and `a/frotz`
// that is a directory (all paths are relative from the .gitignore file).
//
let input = pattern;
let re = '';
let rooted = false;
let directory = false;
if (pattern[0] === '/') {
rooted = true;
pattern = pattern.slice(1);
}
if (pattern[pattern.length - 1] === '/') {
directory = true;
pattern = pattern.slice(0, pattern.length - 1);
} // keep character ranges intact:
const rangeRe = /^((?:[^\[\\]|(?:\\.))*)\[((?:[^\]\\]|(?:\\.))*)\]/; // ^ could have used the 'y' sticky flag, but there's some trouble with infine loops inside
// the matcher below then...
let match;
while ((match = rangeRe.exec(pattern)) !== null) {
if (match[1].includes('/')) {
rooted = true; // ^ cf. man page:
//
// If there is a separator at the beginning or middle (or both)
// of the pattern, then the pattern is relative to the directory
// level of the particular .gitignore file itself. Otherwise
// the pattern may also match at any level below the .gitignore level.
}
re += transpileRegexPart(match[1]);
re += '[' + match[2] + ']';
pattern = pattern.slice(match[0].length);
}
if (pattern) {
if (pattern.includes('/')) {
rooted = true; // ^ cf. man page:
//
// If there is a separator at the beginning or middle (or both)
// of the pattern, then the pattern is relative to the directory
// level of the particular .gitignore file itself. Otherwise
// the pattern may also match at any level below the .gitignore level.
}
re += transpileRegexPart(pattern);
} // prep regexes assuming we'll always prefix the check string with a '/':
if (rooted) {
re = '^\\/' + re;
} else {
re = '\\/' + re;
} // cf spec:
//
// If there is a separator at the end of the pattern then the pattern
// will only match directories, otherwise the pattern can match
// **both files and directories**. (emphasis mine)
if (directory) {
// match the directory itself and anything within:
re += '\\/';
} else {
// match the file itself, or, when it is a directory, match the directory and anything within:
re += '(?:$|\\/)';
} // regex validation diagnostics: better to check if the part is valid
// then to discover it's gone haywire in the big conglomerate at the end.
{
try {
/* eslint no-new:1 */
new RegExp('(?:' + re + ')');
} catch (ex) {
console.log('failed regex:', {
input,
re,
ex
});
}
}
return re;
function transpileRegexPart(re) {
return re // unescape for these will be escaped again in the subsequent `.replace(...)`,
// whether they were escaped before or not:
.replace(/\\(.)/g, '$1') // escape special regex characters:
.replace(/[\-\[\]\{\}\(\)\+\.\\\^\$\|]/g, '\\$&').replace(/\?/g, '[^/]').replace(/\/\*\*\//g, '(?:/|(?:/.+/))').replace(/^\*\*\//g, '(?:|(?:.+/))').replace(/\/\*\*$/g, () => {
directory = true; // `a/**` should match `a/`, `a/b/` and `a/b`, the latter by implication of matching directory `a/`
return '(?:|(?:/.+))'; // `a/**` also accepts `a/` itself
}).replace(/\*\*/g, '.*') // `a/*` should match `a/b` and `a/b/` but NOT `a` or `a/`
// meanwhile, `a/*/` should match `a/b/` and `a/b/c` but NOT `a` or `a/` or `a/b`
.replace(/\/\*(\/|$)/g, '/[^/]+$1').replace(/\*/g, '[^/]*').replace(/\//g, '\\/');
}
}
exports.compile = compile;
exports.parse = parse;
})));
//# sourceMappingURL=gitignoreParser.umd.js.map