Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,8 @@ A pattern is considered dynamic if it contains the following characters (`…`

Always use forward-slashes in glob expressions (patterns and [`ignore`](#ignore) option). Use backslashes for escaping characters. With the [`cwd`](#cwd) option use a convenient format.

Rooted absolute negative patterns without a drive letter (for example, `!/project/*.js`) use the drive or UNC share of the resolved [`cwd`](#cwd). This also applies to [`ignore`](#ignore); patterns with an explicit drive or UNC root keep their own root.

**Bad**

```ts
Expand Down
109 changes: 109 additions & 0 deletions src/providers/filters/entry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,115 @@ describe('Providers → Filters → Entry', () => {
});
});

describe('Rooted negative patterns', () => {
const { root } = path.parse(process.cwd());

it('should use the cwd root rather than its directory', () => {
const entry = tests.entry.builder().path('/root/file.txt').file().build();

reject(entry, {
positive: ['/root/file.txt'],
negative: ['/root/file.txt'],
options: { cwd: path.join(root, 'workspace'), absolute: true },
});
});

it('should use the cwd drive on Windows', () => {
const entry = tests.entry.builder().path('X:/root/file.txt').file().build();
const isActual = isAccepted(entry, {
positive: ['**/*'],
negative: ['/root/file.txt'],
options: { cwd: 'X:/workspace', absolute: true },
});

assert.strictEqual(isActual, !tests.platform.isWindows());
});

it('should not apply a rooted negative pattern to a different drive', () => {
const entry = tests.entry.builder().path('Y:/root/file.txt').file().build();

accept(entry, {
positive: ['**/*'],
negative: ['/root/file.txt'],
options: { cwd: 'X:/workspace', absolute: true },
});
});

it('should preserve explicit drives independently of cwd', () => {
const entry = tests.entry.builder().path('Y:/root/file.txt').file().build();
const options = { cwd: 'X:/workspace', absolute: true };

reject(entry, { positive: ['**/*'], negative: ['Y:/root/file.txt'], options });
accept(entry, { positive: ['**/*'], negative: ['X:/root/file.txt'], options });
});

for (const share of ['share', 'share[1]', 'share{one,two}']) {
it(`should use the literal UNC share "${share}" of cwd on Windows`, () => {
const isActual = isAccepted(FILE_ENTRY, {
positive: ['**/*'],
negative: ['/workspace/root/file.txt'],
options: { cwd: `//server/${share}/workspace`, absolute: true },
});

assert.strictEqual(isActual, !tests.platform.isWindows());
});
}

it('should not interpret glob characters in the cwd share as a pattern', () => {
const entry = tests.entry.builder().path('//server/share1/workspace/root/file.txt').file().build();

accept(entry, {
positive: ['**/*'],
negative: ['/workspace/root/file.txt'],
options: { cwd: '//server/share[1]/workspace', absolute: true },
});
});

it('should preserve explicit UNC roots on Windows', () => {
const options = { cwd: '//server/share/workspace', absolute: true };
const isActual = isAccepted(FILE_ENTRY, {
positive: ['**/*'],
negative: ['//server/share/workspace/root/file.txt'],
options,
});

assert.strictEqual(isActual, !tests.platform.isWindows());
accept(FILE_ENTRY, {
positive: ['**/*'],
negative: ['//server/other/workspace/root/file.txt'],
options,
});
});

it('should preserve a device drive root on Windows', () => {
const isActual = isAccepted(FILE_ENTRY, {
positive: ['**/*'],
negative: ['/root/file.txt'],
options: { cwd: '//?/X:/', absolute: true },
});

assert.strictEqual(isActual, !tests.platform.isWindows());
});

it('should preserve escaped characters in the pattern', () => {
const entry = tests.entry.builder().path('root/[file].txt').file().build();

reject(entry, {
positive: ['**/*'],
negative: [String.raw`/root/\[file\].txt`],
options: { cwd: root, absolute: true },
});
});

it('should not normalize parent segments in the pattern', () => {
accept(FILE_ENTRY, {
positive: ['**/*'],
negative: ['/root/missing/../file.txt'],
options: { cwd: root, absolute: true },
});
});
});

describe('Pattern', () => {
it('should reject when an entry match to the negative pattern', () => {
reject(FILE_ENTRY, {
Expand Down
22 changes: 21 additions & 1 deletion src/providers/filters/entry.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as path from 'node:path';
import * as utils from '../../utils/index.js';
import type Settings from '../../settings.js';
import type {
Expand Down Expand Up @@ -109,15 +110,34 @@ export default class EntryFilter {
return isMatched;
}

#getAbsoluteNegativePatterns(patterns: Pattern[]): Pattern[] {
if (path.sep !== '\\' || patterns.length === 0) {
return patterns;
}

// Resolve only the root to preserve glob syntax and escaping in the rest of the pattern.
const { root } = path.parse(utils.path.makeAbsolute(this.#settings.cwd, '.'));
const rootPattern = utils.path.convertPathToPattern(utils.path.appendTrailingSeparatorToDeviceRoot(root));

return patterns.map((pattern) => {
if (pattern.startsWith('/') && !pattern.startsWith('//')) {
return rootPattern + pattern.slice(1);
}

return pattern;
});
}

public getFilter(positive: Pattern[], negative: Pattern[]): EntryFilterFunction {
const [absoluteNegative, relativeNegative] = utils.pattern.partitionAbsoluteAndRelative(negative);
const absoluteNegativePatterns = this.#getAbsoluteNegativePatterns(absoluteNegative);

const patterns: PatternsRegexSet = {
positive: {
all: utils.pattern.convertPatternsToRe(positive, this.#micromatchOptions),
},
negative: {
absolute: utils.pattern.convertPatternsToRe(absoluteNegative, { ...this.#micromatchOptions, dot: true }),
absolute: utils.pattern.convertPatternsToRe(absoluteNegativePatterns, { ...this.#micromatchOptions, dot: true }),
relative: utils.pattern.convertPatternsToRe(relativeNegative, { ...this.#micromatchOptions, dot: true }),
},
};
Expand Down
77 changes: 77 additions & 0 deletions src/tests/e2e/options/absolute.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import * as path from 'node:path';
import * as process from 'node:process';
import { pathToFileURL } from 'node:url';
import { convertPathToPattern } from '../../../index.js';
import * as runner from '../runner.js';

const CWD = process.cwd();
const CWD_POSIX = CWD.replaceAll('\\', '/');
const FIXTURES_PATH = path.join(CWD, 'fixtures');
const FIXTURES_PATTERN = convertPathToPattern(FIXTURES_PATH);
const ROOTED_FIXTURES_PATTERN = `/${convertPathToPattern(FIXTURES_PATH.slice(path.parse(CWD).root.length))}`;

runner.suite('Options Absolute', {
resultTransform: runner.absoluteResultTransform,
Expand Down Expand Up @@ -153,3 +158,75 @@ runner.suite('Options Absolute (cwd & ignore)', {
},
],
});

runner.suite('Options Absolute (rooted negative patterns)', {
resultTransform: runner.absoluteResultTransform,
tests: [
...['file.md', '*.md', '[f]ile.md', '{file,missing}.md', '@(file|missing).md', String.raw`file\.md`].map((pattern) => ({
pattern: [`${ROOTED_FIXTURES_PATTERN}/file.md`, `!${ROOTED_FIXTURES_PATTERN}/${pattern}`],
options: { absolute: true },
issue: 486,
expected: () => [],
})),
{
pattern: [`${ROOTED_FIXTURES_PATTERN}/*.md`, `!${ROOTED_FIXTURES_PATTERN}/file.md`],
options: { absolute: true },
expected: () => [],
},
{
pattern: [`${FIXTURES_PATTERN}/file.md`, `!${ROOTED_FIXTURES_PATTERN}/file.md`],
options: { absolute: true },
expected: () => [],
},
{
pattern: [`${ROOTED_FIXTURES_PATTERN}/file.md`, `!${FIXTURES_PATTERN}/file.md`],
options: { absolute: true },
expected: () => [],
},
{
pattern: ['fixtures/file.md', '!fixtures/file.md'],
options: { absolute: true },
expected: () => [],
},
...[false, true].map((absolute) => ({
pattern: 'fixtures/*.md',
options: { absolute, ignore: [`${ROOTED_FIXTURES_PATTERN}/file.md`] },
expected: () => [],
})),
...['fixtures', FIXTURES_PATH, pathToFileURL(FIXTURES_PATH)].map((cwd) => ({
pattern: 'file.md',
options: { cwd, absolute: true, ignore: [`${ROOTED_FIXTURES_PATTERN}/file.md`] },
expected: () => [],
})),
{
pattern: [`${ROOTED_FIXTURES_PATTERN}/file.md`, `!${ROOTED_FIXTURES_PATTERN}/file.md`],
options: { cwd: path.join(FIXTURES_PATH, 'first'), absolute: true },
expected: () => [],
},
...['missing.md', 'missing/../file.md'].map((pattern) => ({
pattern: [`${ROOTED_FIXTURES_PATTERN}/file.md`, `!${ROOTED_FIXTURES_PATTERN}/${pattern}`],
options: { absolute: true },
expected: () => ['<root>/fixtures/file.md'],
})),
...[false, true].map((caseSensitiveMatch) => ({
pattern: 'fixtures/*.md',
options: { absolute: true, caseSensitiveMatch, ignore: [`${ROOTED_FIXTURES_PATTERN}/FILE.md`] },
expected: () => caseSensitiveMatch ? ['<root>/fixtures/file.md'] : [],
})),
{
pattern: [`${ROOTED_FIXTURES_PATTERN}/.file`, `!${ROOTED_FIXTURES_PATTERN}/**`],
options: { absolute: true },
expected: () => [],
},
{
pattern: [`${ROOTED_FIXTURES_PATTERN}/first/`, `!${ROOTED_FIXTURES_PATTERN}/first/`],
options: { absolute: true, onlyDirectories: true },
expected: () => [],
},
{
pattern: ['fixtures/{first,second}/file.md', `!${ROOTED_FIXTURES_PATTERN}/first/**`],
options: { absolute: true },
expected: () => ['<root>/fixtures/second/file.md'],
},
],
});