diff --git a/__tests__/filter.test.ts b/__tests__/filter.test.ts index 7d7da94..c9a0bd8 100644 --- a/__tests__/filter.test.ts +++ b/__tests__/filter.test.ts @@ -98,6 +98,31 @@ describe('matching tests', () => { expect(match.dot).toEqual(files) }) + test('does not match when only negated pattern matches', () => { + const yaml = ` + backend: + - src/backend/** + - '!src/frontend/**' + ` + const filter = new Filter(yaml) + const files = modified(['vitest.setup.ts']) + const match = filter.match(files) + expect(match.backend).toEqual([]) + }) + + test('negated pattern excludes matching files', () => { + const yaml = ` + backend: + - '**/*' + - '!src/frontend/**' + ` + const filter = new Filter(yaml) + const backendFile = modified(['src/backend/main.ts']) + const frontendFile = modified(['src/frontend/main.ts']) + expect(filter.match(backendFile).backend).toEqual(backendFile) + expect(filter.match(frontendFile).backend).toEqual([]) + }) + test('matches all except tsx and less files (negate a group with or-ed parts)', () => { const yaml = ` backend: diff --git a/dist/index.js b/dist/index.js index 93d1e2e..b136b8c 100644 --- a/dist/index.js +++ b/dist/index.js @@ -130,33 +130,44 @@ class Filter { const aPredicate = (rule) => { return (rule.status === undefined || rule.status.includes(file.status)) && rule.isMatch(file.filename); }; - if (((_a = this.filterConfig) === null || _a === void 0 ? void 0 : _a.predicateQuantifier) === 'every') { - return patterns.every(aPredicate); - } - else { - return patterns.some(aPredicate); - } + const positives = patterns.filter(p => !p.negate); + const negatives = patterns.filter(p => p.negate); + const positiveMatch = positives.length === 0 + ? true + : ((_a = this.filterConfig) === null || _a === void 0 ? void 0 : _a.predicateQuantifier) === PredicateQuantifier.EVERY + ? positives.every(aPredicate) + : positives.some(aPredicate); + const negativeMatch = negatives.some(aPredicate); + return positiveMatch && !negativeMatch; } parseFilterItemYaml(item) { if (Array.isArray(item)) { return flat(item.map(i => this.parseFilterItemYaml(i))); } if (typeof item === 'string') { - return [{ status: undefined, isMatch: (0, picomatch_1.default)(item, MatchOptions) }]; + const negated = item.startsWith('!'); + const pattern = negated ? item.slice(1) : item; + return [{ status: undefined, isMatch: (0, picomatch_1.default)(pattern, MatchOptions), negate: negated }]; } if (typeof item === 'object') { - return Object.entries(item).map(([key, pattern]) => { + return Object.entries(item).flatMap(([key, pattern]) => { if (typeof key !== 'string' || (typeof pattern !== 'string' && !Array.isArray(pattern))) { this.throwInvalidFormatError(`Expected [key:string]= pattern:string | string[], but [${key}:${typeof key}]= ${pattern}:${typeof pattern} found`); } - return { - status: key - .split('|') - .map(x => x.trim()) - .filter(x => x.length > 0) - .map(x => x.toLowerCase()), - isMatch: (0, picomatch_1.default)(pattern, MatchOptions) - }; + const patterns = Array.isArray(pattern) ? pattern : [pattern]; + return patterns.map(p => { + const negated = p.startsWith('!'); + const pat = negated ? p.slice(1) : p; + return { + status: key + .split('|') + .map(x => x.trim()) + .filter(x => x.length > 0) + .map(x => x.toLowerCase()), + isMatch: (0, picomatch_1.default)(pat, MatchOptions), + negate: negated + }; + }); }); } this.throwInvalidFormatError(`Unexpected element type '${typeof item}'`); diff --git a/src/filter.ts b/src/filter.ts index 1947ef8..54a1424 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -21,6 +21,7 @@ const MatchOptions = { interface FilterRuleItem { status?: ChangeStatus[] // Required change status of the matched files isMatch: (str: string) => boolean // Matches the filename + negate?: boolean // When true, this rule excludes matching files } /** @@ -107,11 +108,20 @@ export class Filter { const aPredicate = (rule: Readonly): boolean => { return (rule.status === undefined || rule.status.includes(file.status)) && rule.isMatch(file.filename) } - if (this.filterConfig?.predicateQuantifier === 'every') { - return patterns.every(aPredicate) - } else { - return patterns.some(aPredicate) - } + + const positives = patterns.filter(p => !p.negate) + const negatives = patterns.filter(p => p.negate) + + const positiveMatch = + positives.length === 0 + ? true + : this.filterConfig?.predicateQuantifier === PredicateQuantifier.EVERY + ? positives.every(aPredicate) + : positives.some(aPredicate) + + const negativeMatch = negatives.some(aPredicate) + + return positiveMatch && !negativeMatch } private parseFilterItemYaml(item: FilterItemYaml): FilterRuleItem[] { @@ -120,24 +130,33 @@ export class Filter { } if (typeof item === 'string') { - return [{status: undefined, isMatch: picomatch(item, MatchOptions)}] + const negated = item.startsWith('!') + const pattern = negated ? item.slice(1) : item + return [{status: undefined, isMatch: picomatch(pattern, MatchOptions), negate: negated}] } if (typeof item === 'object') { - return Object.entries(item).map(([key, pattern]) => { + return Object.entries(item).flatMap(([key, pattern]) => { if (typeof key !== 'string' || (typeof pattern !== 'string' && !Array.isArray(pattern))) { this.throwInvalidFormatError( `Expected [key:string]= pattern:string | string[], but [${key}:${typeof key}]= ${pattern}:${typeof pattern} found` ) } - return { - status: key - .split('|') - .map(x => x.trim()) - .filter(x => x.length > 0) - .map(x => x.toLowerCase()) as ChangeStatus[], - isMatch: picomatch(pattern, MatchOptions) - } + + const patterns = Array.isArray(pattern) ? pattern : [pattern] + return patterns.map(p => { + const negated = p.startsWith('!') + const pat = negated ? p.slice(1) : p + return { + status: key + .split('|') + .map(x => x.trim()) + .filter(x => x.length > 0) + .map(x => x.toLowerCase()) as ChangeStatus[], + isMatch: picomatch(pat, MatchOptions), + negate: negated + } + }) }) }