diff --git a/README.md b/README.md index 904db3a3..986ea84c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/providers/filters/entry.spec.ts b/src/providers/filters/entry.spec.ts index 16fbfffe..1f14feaa 100644 --- a/src/providers/filters/entry.spec.ts +++ b/src/providers/filters/entry.spec.ts @@ -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, { diff --git a/src/providers/filters/entry.ts b/src/providers/filters/entry.ts index cf5a2557..0ca2bdd2 100644 --- a/src/providers/filters/entry.ts +++ b/src/providers/filters/entry.ts @@ -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 { @@ -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 }), }, }; diff --git a/src/tests/e2e/options/absolute.e2e.ts b/src/tests/e2e/options/absolute.e2e.ts index c062aa32..ae316247 100644 --- a/src/tests/e2e/options/absolute.e2e.ts +++ b/src/tests/e2e/options/absolute.e2e.ts @@ -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, @@ -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: () => ['/fixtures/file.md'], + })), + ...[false, true].map((caseSensitiveMatch) => ({ + pattern: 'fixtures/*.md', + options: { absolute: true, caseSensitiveMatch, ignore: [`${ROOTED_FIXTURES_PATTERN}/FILE.md`] }, + expected: () => caseSensitiveMatch ? ['/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: () => ['/fixtures/second/file.md'], + }, + ], +});