blob: ff22dffa92c67d98ed469820da48779e8102f803 [file] [log] [blame]
// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
/// Implements an [Ignore] filter compatible with `.gitignore`.
///
/// An [Ignore] instance holds a set of [`.gitignore` rules][1], and allows
/// testing if a given path is ignored.
///
/// **Example**:
/// ```dart
/// import 'package:ignore/ignore.dart';
///
/// void main() {
/// final ignore = Ignore([
/// '*.o',
/// ]);
///
/// print(ignore.ignores('main.o')); // true
/// print(ignore.ignores('main.c')); // false
/// }
/// ```
///
/// For a generic walk of a file-hierarchy with ignore files at all levels see
/// [Ignore.listFiles].
///
/// [1]: https://git-scm.com/docs/gitignore
import 'package:meta/meta.dart';
/// A set of ignore rules representing a single ignore file.
///
/// An [Ignore] instance holds [`.gitignore` rules][1] relative to a given path.
///
/// **Example**:
/// ```dart
/// import 'package:ignore/ignore.dart';
///
/// void main() {
/// final ignore = Ignore([
/// '*.o',
/// ]);
///
/// print(ignore.ignores('main.o')); // true
/// print(ignore.ignores('main.c')); // false
/// }
/// ```
///
/// [1]: https://git-scm.com/docs/gitignore
@sealed
class Ignore {
final List<_IgnoreRule> _rules;
/// Create an [Ignore] instance with a set of [`.gitignore` compatible][1]
/// patterns.
///
/// Each value in [patterns] will be interpreted as one or more lines from
/// a `.gitignore` file, in compliance with the [`.gitignore` manual page][1].
///
/// The keys of 'pattern' are the directories to intpret the rules relative
/// to. The root should be the empty string, and sub-directories are separated
/// by '/' (but no final '/').
///
/// If [ignoreCase] is `true`, patterns will be case-insensitive. By default
/// `git` is case-sensitive. But case insensitivity can be enabled when a
/// repository is created, or by configuration option, see
/// [`core.ignoreCase` documentation][2] for details.
///
/// If [onInvalidPattern] is passed, it will be called with a
/// [FormatException] describing the problem. The exception will have [source]
/// as source.
///
/// **Example**:
/// ```dart
/// import 'package:ignore/ignore.dart';
/// void main() {
/// final ignore = Ignore({'': [
/// // You can pass an entire .gitignore file as a single string.
/// // You can also pass it as a list of lines, or both.
/// '''
/// # Comment in a .gitignore file
/// obj/
/// *.o
/// !main.o
/// '''
/// }]);
///
/// print(ignore.ignores('obj/README.md')); // true
/// print(ignore.ignores('lib.o')); // false
/// print(ignore.ignores('main.o')); // false
/// }
/// ```
///
/// [1]: https://git-scm.com/docs/gitignore
/// [2]: https://git-scm.com/docs/git-config#Documentation/git-config.txt-coreignoreCase
Ignore(
List<String> patterns, {
bool ignoreCase = false,
void Function(String pattern, FormatException exception)? onInvalidPattern,
}) : _rules = _parseIgnorePatterns(
patterns,
ignoreCase,
onInvalidPattern: onInvalidPattern,
).toList(growable: false);
/// Returns `true` if [path] is ignored by the patterns used to create this
/// [Ignore] instance, assuming those patterns are placed at `.`.
///
/// The [path] must be a relative path, not starting with `./`, `../`, and
/// must end in slash (`/`) if it is directory.
///
/// **Example**:
/// ```dart
/// import 'package:ignore/ignore.dart';
///
/// void main() {
/// final ignore = Ignore([
/// '*.o',
/// ]);
///
/// print(ignore.ignores('main.o')); // true
/// print(ignore.ignores('main.c')); // false
/// print(ignore.ignores('lib/')); // false
/// print(ignore.ignores('lib/helper.o')); // true
/// print(ignore.ignores('lib/helper.c')); // false
/// }
/// ```
bool ignores(String path) {
ArgumentError.checkNotNull(path, 'path');
if (path.isEmpty) {
throw ArgumentError.value(path, 'path', 'must be not empty');
}
if (path.startsWith('/') ||
path.startsWith('./') ||
path.startsWith('../') ||
path == '.' ||
path == '..') {
throw ArgumentError.value(
path,
'path',
'must be relative, and not start with "./", "../"',
);
}
if (_rules.isEmpty) {
return false;
}
final pathWithoutSlash =
path.endsWith('/') ? path.substring(0, path.length - 1) : path;
return listFiles(
beneath: pathWithoutSlash,
includeDirs: true, // because we are listing below pathWithoutSlash
listDir: (dir) {
// List the next part of path:
if (dir == pathWithoutSlash) return [];
final startOfNext = dir.isEmpty ? 0 : dir.length + 1;
final nextSlash = path.indexOf('/', startOfNext);
return [path.substring(startOfNext, nextSlash)];
},
ignoreForDir: (dir) => dir == '.' || dir.isEmpty ? this : null,
isDir: (candidate) =>
candidate == '.' ||
candidate.isEmpty ||
path.length > candidate.length && path[candidate.length] == '/',
).isEmpty;
}
/// Returns all the files in the tree under (and including) [beneath] not
/// ignored by ignore-files from [root] and down.
///
/// Represents paths normalized using '/' as directory separator. The empty
/// relative path is '.', no '..' are allowed.
///
/// [beneath] must start with [root] and even if it is a directory it should not
/// end with '/', if [beneath] is not provided, everything under root is
/// included.
///
/// [listDir] should enumerate the immediate contents of a given directory,
/// returning paths including [root].
///
/// [isDir] should return true if the argument is a directory. It will only be
/// queried with file-names under (and including) [beneath]
///
/// [ignoreForDir] should retrieve the ignore rules for a single directory
/// or return `null` if there is no ignore rules.
///
/// If [includeDirs] is true non-ignored directories will be included in the
/// result (including beneath).
///
/// This example program lists all files under second argument that are
/// not ignored by .gitignore files from first argument and below:
///
/// ```dart
/// import 'dart:io';
/// import 'package:path/path.dart' as p;
/// import 'package:pub/src/ignore.dart';
///
/// void main(List<String> args) {
/// var root = p.normalize(args[0]);
/// if (root == '.') root = '';
/// var beneath = args.length > 1 ? p.normalize(args[1]) : root;
/// if (beneath == '.') beneath = '';
/// String resolve(String path) {
/// return p.joinAll([root, ...p.posix.split(path)]);
/// }
///
/// Ignore.listFiles(
/// beneath: beneath,
/// listDir: (dir) => Directory(resolve(dir)).listSync().map((x) {
/// final relative = p.relative(x.path, from: root);
/// return p.posix.joinAll(p.split(relative));
/// }),
/// ignoreForDir: (dir) {
/// final f = File(resolve('dir/.gitignore'));
/// return f.existsSync() ? Ignore([f.readAsStringSync()]) : null;
/// },
/// isDir: (dir) => Directory(resolve(dir)).existsSync(),
/// ).forEach(print);
/// }
/// ```
static List<String> listFiles({
String beneath = '',
required Iterable<String> Function(String) listDir,
required Ignore? Function(String) ignoreForDir,
required bool Function(String) isDir,
bool includeDirs = false,
}) {
if (beneath.startsWith('/') ||
beneath.startsWith('./') ||
beneath.startsWith('../')) {
throw ArgumentError.value(
'must be relative and normalized', 'beneath', beneath);
}
if (beneath.endsWith('/')) {
throw ArgumentError.value('must not end with /', beneath);
}
// To streamline the algorithm we represent all paths as starting with '/'
// and the empty path as just '/'.
if (beneath == '.') beneath = '';
beneath = '/$beneath';
// Will contain all the files that are not ignored.
final result = <String>[];
// At any given point in the search, this will contain the Ignores from
// directories leading up to the current entity.
// The single `null` aligns popping and pushing in this stack with [toVisit]
// below.
final ignoreStack = <_IgnorePrefixPair?>[null];
// Find all ignores between './' and [beneath] (not inclusive).
// [index] points at the next '/' in the path.
var index = -1;
while ((index = beneath.indexOf('/', index + 1)) != -1) {
final partial = beneath.substring(0, index + 1);
if (_matchesStack(ignoreStack, partial)) {
// A directory on the way towards [beneath] was ignored. Empty result.
return <String>[];
}
final ignore = ignoreForDir(
partial == '/' ? '.' : partial.substring(1, partial.length - 1));
ignoreStack
.add(ignore == null ? null : _IgnorePrefixPair(ignore, partial));
}
// Do a depth first tree-search starting at [beneath].
// toVisit is a stack containing all items that are waiting to be processed.
final toVisit = [
[beneath]
];
while (toVisit.isNotEmpty) {
final topOfStack = toVisit.last;
if (topOfStack.isEmpty) {
toVisit.removeLast();
ignoreStack.removeLast();
continue;
}
final current = topOfStack.removeLast();
// This is the version of current we present to the callbacks and in
// [result].
//
// The empty path is represented as '.' and there is no leading '/'.
final normalizedCurrent = current == '/' ? '.' : current.substring(1);
final currentIsDir = isDir(normalizedCurrent);
if (_matchesStack(ignoreStack, currentIsDir ? '$current/' : current)) {
// current was ignored. Continue with the next item.
continue;
}
if (currentIsDir) {
final ignore = ignoreForDir(normalizedCurrent);
ignoreStack
.add(ignore == null ? null : _IgnorePrefixPair(ignore, current));
// Put all entities in current on the stack to be processed.
toVisit.add(listDir(normalizedCurrent).map((x) => '/$x').toList());
if (includeDirs) {
result.add(normalizedCurrent);
}
} else {
result.add(normalizedCurrent);
}
}
return result;
}
}
class _IgnoreParseResult {
// The parsed pattern.
final String pattern;
// The resulting matching rule. `null` if the pattern was empty or invalid.
final _IgnoreRule? rule;
// An invalid pattern is also considered empty.
bool get empty => rule == null;
bool get valid => exception == null;
// For invalid patterns this contains a description of the problem.
final FormatException? exception;
_IgnoreParseResult(this.pattern, this.rule) : exception = null;
_IgnoreParseResult.invalid(this.pattern, this.exception) : rule = null;
_IgnoreParseResult.empty(this.pattern)
: rule = null,
exception = null;
}
class _IgnoreRule {
/// A regular expression that represents this rule.
final RegExp pattern;
final bool negative;
/// The String this pattern was generated from.
final String original;
_IgnoreRule(this.pattern, this.negative, this.original);
@override
String toString() {
// TODO: implement toString
return '$original -> $pattern';
}
}
/// Pattern for a line-break which accepts CR LF and LF.
final _lineBreakPattern = RegExp('\r?\n');
/// [onInvalidPattern] can be used to handle parse failures. If
/// [onInvalidPattern] is `null` invalid patterns are ignored.
Iterable<_IgnoreRule> _parseIgnorePatterns(
Iterable<String> patterns,
bool ignoreCase, {
void Function(String pattern, FormatException exception)? onInvalidPattern,
}) sync* {
ArgumentError.checkNotNull(patterns, 'patterns');
ArgumentError.checkNotNull(ignoreCase, 'ignoreCase');
onInvalidPattern ??= (_, __) {};
final parsedPatterns = patterns
.expand((s) => s.split(_lineBreakPattern))
.map((pattern) => _parseIgnorePattern(pattern, ignoreCase));
for (final r in parsedPatterns) {
if (!r.valid) {
onInvalidPattern(r.pattern, r.exception!);
}
if (!r.empty) {
yield r.rule!;
}
}
}
_IgnoreParseResult _parseIgnorePattern(String pattern, bool ignoreCase) {
// Check if patterns is a comment
if (pattern.startsWith('#')) {
return _IgnoreParseResult.empty(pattern);
}
var first = 0;
var end = pattern.length;
// Detect negative patterns
final negative = pattern.startsWith('!');
if (negative) {
first++;
}
// Remove trailing whitespace unless escaped
while (end != 0 &&
pattern[end - 1] == ' ' &&
(end == 1 || pattern[end - 2] != '\\')) {
end--;
}
// Empty patterns match nothing.
if (first == end) return _IgnoreParseResult.empty(pattern);
var current = first;
String? peekChar() => current >= end ? null : pattern[current];
var expr = '';
// Parses the inside of a [] range. Returns the value as a RegExp character
// range, or null if the pattern was broken.
String? parseCharacterRange() {
var characterRange = '';
var first = true;
for (;;) {
final nextChar = peekChar();
if (nextChar == null) {
return null;
}
current++;
if (nextChar == '\\') {
final escaped = peekChar();
if (escaped == null) {
return null;
}
current++;
characterRange += escaped == '-' ? r'\-' : RegExp.escape(escaped);
} else if (nextChar == '!' && first) {
characterRange += '^';
} else if (nextChar == ']' && first) {
characterRange += RegExp.escape(nextChar);
} else if (nextChar == ']') {
assert(!first);
return characterRange;
} else {
characterRange += nextChar;
}
first = false;
}
}
var relativeToPath = false;
var matchesDirectoriesOnly = false;
// slashes have different significance depending on where they are in
// the String. Handle that here.
void handleSlash() {
if (current == end) {
// A slash at the end makes us only match directories.
matchesDirectoriesOnly = true;
} else {
// A slash anywhere else makes the pattern relative anchored at the
// current path.
relativeToPath = true;
}
}
for (;;) {
final nextChar = peekChar();
if (nextChar == null) break;
current++;
if (nextChar == '*') {
if (peekChar() == '*') {
// Handle '**'
current++;
if (peekChar() == '/') {
current++;
if (current == end) {
expr += '.*';
} else {
// Match nothing or a path followed by '/'
expr += '(?:(?:)|(?:.*/))';
}
// Handle the side effects of seeing a slash.
handleSlash();
} else {
expr += '.*';
}
} else if (peekChar() == '/' || peekChar() == null) {
// /a/* should not match '/a/'
expr += '[^/]+';
} else {
// Handle a single '*'
expr += '[^/]*';
}
} else if (nextChar == '?') {
// Handle '?'
expr += '[^/]';
} else if (nextChar == '[') {
// Character ranges
final characterRange = parseCharacterRange();
if (characterRange == null) {
return _IgnoreParseResult.invalid(
pattern,
FormatException(
'Pattern "$pattern" had an invalid `[a-b]` style character range',
pattern,
current),
);
}
expr += '[$characterRange]';
} else if (nextChar == '\\') {
// Escapes
final escaped = peekChar();
if (escaped == null) {
return _IgnoreParseResult.invalid(
pattern,
FormatException(
'Pattern "$pattern" end of pattern inside character escape.',
pattern,
current),
);
}
expr += RegExp.escape(escaped);
current++;
} else {
if (nextChar == '/') {
if (current - 1 != first && current != end) {
// If slash appears in the beginning we don't want it manifest in the
// regexp.
expr += '/';
}
handleSlash();
} else {
expr += RegExp.escape(nextChar);
}
}
}
if (relativeToPath) {
expr = '^$expr';
} else {
expr = '(?:^|/)$expr';
}
if (matchesDirectoriesOnly) {
expr = '$expr/\$';
} else {
expr = '$expr/?\$';
}
try {
return _IgnoreParseResult(
pattern,
_IgnoreRule(
RegExp(expr, caseSensitive: !ignoreCase), negative, pattern));
} on FormatException catch (e) {
throw AssertionError(
'Created broken expression "$expr" from ignore pattern "$pattern" -> $e');
}
}
/// A [Ignore] object, paired with the prefix where it is found in the directory
/// hierarchy.
class _IgnorePrefixPair {
final Ignore ignore;
final String prefix;
_IgnorePrefixPair(this.ignore, this.prefix);
@override
String toString() {
return '{${ignore._rules.map((r) => r.original)} $prefix}';
}
}
/// Returns true if any of [ignores] has a match of [path] that is not negated
/// by a later one.
///
/// expects [path] to start with '/'
///
/// If [path] should be matched as a directory, it should end with '/'.
bool _matchesStack(List<_IgnorePrefixPair?> ignores, String path) {
// This is optimized by trying the rules in reverse order.
// If a rule matches, the result is true if the rule is not negative.
for (final ignorePair in ignores.reversed) {
if (ignorePair == null) continue;
final prefixLength = ignorePair.prefix.length;
final s =
prefixLength == 0 ? path : path.substring(ignorePair.prefix.length);
for (final rule in ignorePair.ignore._rules.reversed) {
if (rule.pattern.hasMatch(s)) {
return !rule.negative;
}
}
}
return false;
}