From a11539e1db495a98e3e8fd45249cc1e0ffddf1cd Mon Sep 17 00:00:00 2001 From: eric sciple Date: Tue, 31 Dec 2019 10:16:18 -0500 Subject: [PATCH] glob (#268) --- packages/glob/__tests__/glob.test.ts | 671 ++++++++++++++++++ .../__tests__/internal-path-helper.test.ts | 640 +++++++++++++++++ packages/glob/__tests__/internal-path.test.ts | 92 +++ .../__tests__/internal-pattern-helper.test.ts | 190 +++++ .../glob/__tests__/internal-pattern.test.ts | 366 ++++++++++ packages/glob/package-lock.json | 35 + packages/glob/package.json | 41 ++ packages/glob/src/glob.ts | 183 +++++ packages/glob/src/internal-glob-options.ts | 28 + packages/glob/src/internal-match-kind.ts | 16 + packages/glob/src/internal-path-helper.ts | 206 ++++++ packages/glob/src/internal-path.ts | 113 +++ packages/glob/src/internal-pattern-helper.ts | 143 ++++ packages/glob/src/internal-pattern.ts | 321 +++++++++ packages/glob/src/internal-search-state.ts | 9 + packages/glob/tsconfig.json | 11 + 16 files changed, 3065 insertions(+) create mode 100644 packages/glob/__tests__/glob.test.ts create mode 100644 packages/glob/__tests__/internal-path-helper.test.ts create mode 100644 packages/glob/__tests__/internal-path.test.ts create mode 100644 packages/glob/__tests__/internal-pattern-helper.test.ts create mode 100644 packages/glob/__tests__/internal-pattern.test.ts create mode 100644 packages/glob/package-lock.json create mode 100644 packages/glob/package.json create mode 100644 packages/glob/src/glob.ts create mode 100644 packages/glob/src/internal-glob-options.ts create mode 100644 packages/glob/src/internal-match-kind.ts create mode 100644 packages/glob/src/internal-path-helper.ts create mode 100644 packages/glob/src/internal-path.ts create mode 100644 packages/glob/src/internal-pattern-helper.ts create mode 100644 packages/glob/src/internal-pattern.ts create mode 100644 packages/glob/src/internal-search-state.ts create mode 100644 packages/glob/tsconfig.json diff --git a/packages/glob/__tests__/glob.test.ts b/packages/glob/__tests__/glob.test.ts new file mode 100644 index 00000000..810117ae --- /dev/null +++ b/packages/glob/__tests__/glob.test.ts @@ -0,0 +1,671 @@ +import * as child from 'child_process' +import * as glob from '../src/glob' +import * as io from '../../io/src/io' +import * as path from 'path' +import {promises as fs} from 'fs' + +const IS_WINDOWS = process.platform === 'win32' + +/** + * These test focus on the ability of glob to find files + * and not on the pattern matching aspect + */ +describe('glob', () => { + beforeAll(async () => { + await io.rmRF(getTestTemp()) + }) + + it('detects cycle', async () => { + // Create the following layout: + // + // /file + // /symDir -> + const root = path.join(getTestTemp(), 'detects-cycle') + await fs.mkdir(root, {recursive: true}) + await fs.writeFile(path.join(root, 'file'), 'test file content') + await createSymlinkDir(root, path.join(root, 'symDir')) + + const itemPaths = await glob.glob(root) + expect(itemPaths).toEqual([root, path.join(root, 'file')]) + // todo: ? expect(itemPaths[2]).toBe(path.join(root, 'symDir')) + }) + + it('detects deep cycle starting from middle', async () => { + // Create the following layout: + // + // /file-under-root + // /folder-a + // /folder-a/file-under-a + // /folder-a/folder-b + // /folder-a/folder-b/file-under-b + // /folder-a/folder-b/folder-c + // /folder-a/folder-b/folder-c/file-under-c + // /folder-a/folder-b/folder-c/sym-folder -> + const root = path.join( + getTestTemp(), + 'detects-deep-cycle-starting-from-middle' + ) + await fs.mkdir(path.join(root, 'folder-a', 'folder-b', 'folder-c'), { + recursive: true + }) + await fs.writeFile( + path.join(root, 'file-under-root'), + 'test file under root contents' + ) + await fs.writeFile( + path.join(root, 'folder-a', 'file-under-a'), + 'test file under a contents' + ) + await fs.writeFile( + path.join(root, 'folder-a', 'folder-b', 'file-under-b'), + 'test file under b contents' + ) + await fs.writeFile( + path.join(root, 'folder-a', 'folder-b', 'folder-c', 'file-under-c'), + 'test file under c contents' + ) + await createSymlinkDir( + root, + path.join(root, 'folder-a', 'folder-b', 'folder-c', 'sym-folder') + ) + await fs.stat( + path.join( + root, + 'folder-a', + 'folder-b', + 'folder-c', + 'sym-folder', + 'file-under-root' + ) + ) + + const itemPaths = await glob.glob(path.join(root, 'folder-a', 'folder-b')) + expect(itemPaths).toEqual([ + path.join(root, 'folder-a', 'folder-b'), + path.join(root, 'folder-a', 'folder-b', 'file-under-b'), + path.join(root, 'folder-a', 'folder-b', 'folder-c'), + path.join(root, 'folder-a', 'folder-b', 'folder-c', 'file-under-c'), + path.join(root, 'folder-a', 'folder-b', 'folder-c', 'sym-folder'), + path.join( + root, + 'folder-a', + 'folder-b', + 'folder-c', + 'sym-folder', + 'file-under-root' + ), + path.join( + root, + 'folder-a', + 'folder-b', + 'folder-c', + 'sym-folder', + 'folder-a' + ), + path.join( + root, + 'folder-a', + 'folder-b', + 'folder-c', + 'sym-folder', + 'folder-a', + 'file-under-a' + ) + ]) + }) + + it('detects cycle starting from symlink', async () => { + // Create the following layout: + // + // /file + // /symDir -> + const root: string = path.join( + getTestTemp(), + 'detects-cycle-starting-from-symlink' + ) + await fs.mkdir(root, {recursive: true}) + await fs.writeFile(path.join(root, 'file'), 'test file content') + await createSymlinkDir(root, path.join(root, 'symDir')) + await fs.stat(path.join(root, 'symDir')) + + const itemPaths = await glob.glob(path.join(root, 'symDir')) + expect(itemPaths).toEqual([ + path.join(root, 'symDir'), + path.join(root, 'symDir', 'file') + ]) + // todo: ? expect(itemPaths[2]).toBe(path.join(root, 'symDir', 'symDir')); + }) + + it('does not follow symlink when followSymbolicLink=false', async () => { + // Create the following layout: + // + // /realDir + // /realDir/file + // /symDir -> /realDir + const root = path.join( + getTestTemp(), + 'does-not-follow-symlink-when-follow-false' + ) + await fs.mkdir(path.join(root, 'realDir'), {recursive: true}) + await fs.writeFile(path.join(root, 'realDir', 'file'), 'test file content') + await createSymlinkDir( + path.join(root, 'realDir'), + path.join(root, 'symDir') + ) + + const itemPaths = await glob.glob(root, {followSymbolicLinks: false}) + expect(itemPaths).toEqual([ + root, + path.join(root, 'realDir'), + path.join(root, 'realDir', 'file'), + path.join(root, 'symDir') + ]) + }) + + it('does not follow symlink when search path is symlink and followSymbolicLink=false', async () => { + // Create the following layout: + // realDir + // realDir/file + // symDir -> realDir + const root = path.join( + getTestTemp(), + 'does-not-follow-symlink-when-search-path-is-symlink-and-follow-false' + ) + await fs.mkdir(path.join(root, 'realDir'), {recursive: true}) + await fs.writeFile(path.join(root, 'realDir', 'file'), 'test file content') + await createSymlinkDir( + path.join(root, 'realDir'), + path.join(root, 'symDir') + ) + + const itemPaths = await glob.glob(path.join(root, 'symDir'), { + followSymbolicLinks: false + }) + expect(itemPaths).toEqual([path.join(root, 'symDir')]) + }) + + it('does not return broken symlink', async () => { + // Create the following layout: + // + // /brokenSym -> /noSuch + // /realDir + // /realDir/file + // /symDir -> /realDir + const root = path.join(getTestTemp(), 'does-not-return-broken-symlink') + await fs.mkdir(root, {recursive: true}) + await createSymlinkDir( + path.join(root, 'noSuch'), + path.join(root, 'brokenSym') + ) + await fs.mkdir(path.join(root, 'realDir'), {recursive: true}) + await fs.writeFile(path.join(root, 'realDir', 'file'), 'test file content') + await createSymlinkDir( + path.join(root, 'realDir'), + path.join(root, 'symDir') + ) + + const itemPaths = await glob.glob(root) + expect(itemPaths).toEqual([ + root, + path.join(root, 'realDir'), + path.join(root, 'realDir', 'file'), + path.join(root, 'symDir'), + path.join(root, 'symDir', 'file') + ]) + }) + + it('does not return broken symlink when search path is broken symlink', async () => { + // Create the following layout: + // + // /brokenSym -> /noSuch + const root = path.join( + getTestTemp(), + 'does-not-return-broken-symlink-when-search-path-is-broken-symlink' + ) + await fs.mkdir(root, {recursive: true}) + const brokenSymPath = path.join(root, 'brokenSym') + await createSymlinkDir(path.join(root, 'noSuch'), brokenSymPath) + await fs.lstat(brokenSymPath) + + const itemPaths = await glob.glob(brokenSymPath) + expect(itemPaths).toEqual([]) + }) + + it('does not search paths that are not partial matches', async () => { + // Create the following layout: + // + // /realDir + // /realDir/nested + // /realDir/nested/file + // /realDir2 + // /realDir2/nested2 + // /realDir2/nested2/symDir -> /noSuch + const root = path.join( + getTestTemp(), + 'does-not-search-paths-that-are-not-partial-matches' + ) + await fs.mkdir(path.join(root, 'realDir', 'nested'), {recursive: true}) + await fs.writeFile( + path.join(root, 'realDir', 'nested', 'file'), + 'test file content' + ) + await fs.mkdir(path.join(root, 'realDir2', 'nested2'), {recursive: true}) + await createSymlinkDir( + path.join(root, 'noSuch'), + path.join(root, 'realDir2', 'nested2', 'symDir') + ) + + const options: glob.IGlobOptions = { + followSymbolicLinks: true, + omitBrokenSymbolicLinks: false + } + + // Should throw + try { + await glob.glob(`${root}/*Dir*/*nested*/*`, options) + throw new Error('should not reach here') + } catch (err) { + expect(err.message).toMatch(/broken symbolic link/i) + } + + // Not partial match + let itemPaths = await glob.glob(`${root}/*Dir/*nested*/*`, options) + expect(itemPaths).toEqual([path.join(root, 'realDir', 'nested', 'file')]) + + // Not partial match + itemPaths = await glob.glob(`${root}/*Dir*/*nested/*`, options) + expect(itemPaths).toEqual([path.join(root, 'realDir', 'nested', 'file')]) + }) + + it('does not throw for broken symlinks that are not matches or partial matches', async () => { + // Create the following layout: + // + // /realDir + // /realDir/file + // /symDir -> /noSuch + const root = path.join( + getTestTemp(), + 'does-not-throw-for-broken-symlinks-that-are-not-matches-or-partial-matches' + ) + await fs.mkdir(path.join(root, 'realDir'), {recursive: true}) + await fs.writeFile(path.join(root, 'realDir', 'file'), 'test file content') + await createSymlinkDir(path.join(root, 'noSuch'), path.join(root, 'symDir')) + + const options: glob.IGlobOptions = { + followSymbolicLinks: true, + omitBrokenSymbolicLinks: false + } + + // Match should throw + try { + await glob.glob(`${root}/*`, options) + throw new Error('should not reach here') + } catch (err) { + expect(err.message).toMatch(/broken symbolic link/i) + } + + // Partial match should throw + try { + await glob.glob(`${root}/*/*`, options) + throw new Error('should not reach here') + } catch (err) { + expect(err.message).toMatch(/broken symbolic link/i) + } + + // Not match or partial match + const itemPaths = await glob.glob(`${root}/*eal*/*`, options) + expect(itemPaths).toEqual([path.join(root, 'realDir', 'file')]) + }) + + it('follows symlink', async () => { + // Create the following layout: + // + // /realDir + // /realDir/file + // /symDir -> /realDir + const root = path.join(getTestTemp(), 'follows-symlink') + await fs.mkdir(path.join(root, 'realDir'), {recursive: true}) + await fs.writeFile(path.join(root, 'realDir', 'file'), 'test file content') + await createSymlinkDir( + path.join(root, 'realDir'), + path.join(root, 'symDir') + ) + + const itemPaths = await glob.glob(root) + expect(itemPaths).toEqual([ + root, + path.join(root, 'realDir'), + path.join(root, 'realDir', 'file'), + path.join(root, 'symDir'), + path.join(root, 'symDir', 'file') + ]) + }) + + it('follows symlink when search path is symlink', async () => { + // Create the following layout: + // realDir + // realDir/file + // symDir -> realDir + const root = path.join( + getTestTemp(), + 'follows-symlink-when-search-path-is-symlink' + ) + await fs.mkdir(path.join(root, 'realDir'), {recursive: true}) + await fs.writeFile(path.join(root, 'realDir', 'file'), 'test file content') + await createSymlinkDir( + path.join(root, 'realDir'), + path.join(root, 'symDir') + ) + + const itemPaths = await glob.glob(path.join(root, 'symDir')) + expect(itemPaths).toEqual([ + path.join(root, 'symDir'), + path.join(root, 'symDir', 'file') + ]) + }) + + it('returns broken symlink when followSymbolicLinks=false', async () => { + // Create the following layout: + // + // /brokenSym -> /noSuch + // /realDir + // /realDir/file + // /symDir -> /realDir + const root = path.join( + getTestTemp(), + 'returns-broken-symlink-when-follow-false' + ) + await fs.mkdir(root, {recursive: true}) + await createSymlinkDir( + path.join(root, 'noSuch'), + path.join(root, 'brokenSym') + ) + await fs.mkdir(path.join(root, 'realDir'), {recursive: true}) + await fs.writeFile(path.join(root, 'realDir', 'file'), 'test file content') + await createSymlinkDir( + path.join(root, 'realDir'), + path.join(root, 'symDir') + ) + + const itemPaths = await glob.glob(root, {followSymbolicLinks: false}) + expect(itemPaths).toEqual([ + root, + path.join(root, 'brokenSym'), + path.join(root, 'realDir'), + path.join(root, 'realDir', 'file'), + path.join(root, 'symDir') + ]) + }) + + it('returns broken symlink when search path is broken symlink and followSymbolicLinks=false', async () => { + // Create the following layout: + // + // /brokenSym -> /noSuch + const root = path.join( + getTestTemp(), + 'returns-broken-symlink-when-search-path-is-broken-symlink-and-follow-false' + ) + await fs.mkdir(root, {recursive: true}) + const brokenSymPath = path.join(root, 'brokenSym') + await createSymlinkDir(path.join(root, 'noSuch'), brokenSymPath) + + const itemPaths = await glob.glob(brokenSymPath, { + followSymbolicLinks: false + }) + expect(itemPaths).toEqual([brokenSymPath]) + }) + + it('returns depth first', async () => { + // Create the following layout: + // /a-file + // /b-folder + // /b-folder/a-file + // /b-folder/b-folder + // /b-folder/b-folder/file + // /b-folder/c-file + // /c-file + const root = path.join(getTestTemp(), 'returns-depth-first') + await fs.mkdir(path.join(root, 'b-folder', 'b-folder'), {recursive: true}) + await fs.writeFile(path.join(root, 'a-file'), 'test a-file content') + await fs.writeFile( + path.join(root, 'b-folder', 'a-file'), + 'test b-folder/a-file content' + ) + await fs.writeFile( + path.join(root, 'b-folder', 'b-folder', 'file'), + 'test b-folder/b-folder/file content' + ) + await fs.writeFile( + path.join(root, 'b-folder', 'c-file'), + 'test b-folder/c-file content' + ) + await fs.writeFile(path.join(root, 'c-file'), 'test c-file content') + + const itemPaths = await glob.glob(root) + expect(itemPaths).toEqual([ + root, + path.join(root, 'a-file'), + path.join(root, 'b-folder'), + path.join(root, 'b-folder', 'a-file'), + path.join(root, 'b-folder', 'b-folder'), + path.join(root, 'b-folder', 'b-folder', 'file'), + path.join(root, 'b-folder', 'c-file'), + path.join(root, 'c-file') + ]) + }) + + it('returns descendants', async () => { + // Create the following layout: + // /file-1 + // /dir-1 + // /dir-1/file-2 + // /dir-1/dir-2 + // /dir-1/dir-2/file-3 + const root = path.join(getTestTemp(), 'returns-descendants') + await fs.mkdir(path.join(root, 'dir-1', 'dir-2'), {recursive: true}) + await fs.writeFile(path.join(root, 'file-1'), '') + await fs.writeFile(path.join(root, 'dir-1', 'file-2'), '') + await fs.writeFile(path.join(root, 'dir-1', 'dir-2', 'file-3'), '') + + // When pattern ends with `/**/` + let pattern = `${root}${path.sep}**${path.sep}` + expect( + await glob.glob(pattern, { + implicitDescendants: false + }) + ).toHaveLength(3) // sanity check + expect(await glob.glob(pattern)).toEqual([ + root, + path.join(root, 'dir-1'), + path.join(root, 'dir-1', 'dir-2'), + path.join(root, 'dir-1', 'dir-2', 'file-3'), + path.join(root, 'dir-1', 'file-2'), + path.join(root, 'file-1') + ]) + + // When pattern ends with something other than `/**/` + pattern = `${root}${path.sep}**${path.sep}dir-?` + expect( + await glob.glob(pattern, { + implicitDescendants: false + }) + ).toHaveLength(2) // sanity check + expect(await glob.glob(pattern)).toEqual([ + path.join(root, 'dir-1'), + path.join(root, 'dir-1', 'dir-2'), + path.join(root, 'dir-1', 'dir-2', 'file-3'), + path.join(root, 'dir-1', 'file-2') + ]) + }) + + it('returns directories only when trailing slash and implicit descendants false', async () => { + // Create the following layout: + // /file-1 + // /dir-1 + // /dir-1/file-2 + // /dir-1/dir-2 + // /dir-1/dir-2/file-3 + const root = path.join( + getTestTemp(), + 'returns-directories-only-when-trailing-slash-and-implicit-descendants-false' + ) + await fs.mkdir(path.join(root, 'dir-1', 'dir-2'), {recursive: true}) + await fs.writeFile(path.join(root, 'file-1'), '') + await fs.writeFile(path.join(root, 'dir-1', 'file-2'), '') + await fs.writeFile(path.join(root, 'dir-1', 'dir-2', 'file-3'), '') + + const pattern = `${root}${path.sep}**${path.sep}` + expect(await glob.glob(pattern)).toHaveLength(6) // sanity check + expect( + await glob.glob(pattern, { + implicitDescendants: false + }) + ).toEqual([ + root, + path.join(root, 'dir-1'), + path.join(root, 'dir-1', 'dir-2') + ]) + }) + + it('returns empty when search path does not exist', async () => { + const itemPaths = await glob.glob(path.join(getTestTemp(), 'nosuch')) + expect(itemPaths).toEqual([]) + }) + + it('returns hidden files', async () => { + // Create the following layout: + // + // /.emptyFolder + // /.file + // /.folder + // /.folder/file + const root = path.join(getTestTemp(), 'returns-hidden-files') + await createHiddenDirectory(path.join(root, '.emptyFolder')) + await createHiddenDirectory(path.join(root, '.folder')) + await createHiddenFile(path.join(root, '.file'), 'test .file content') + await fs.writeFile( + path.join(root, '.folder', 'file'), + 'test .folder/file content' + ) + + const itemPaths = await glob.glob(root) + expect(itemPaths).toEqual([ + root, + path.join(root, '.emptyFolder'), + path.join(root, '.file'), + path.join(root, '.folder'), + path.join(root, '.folder', 'file') + ]) + }) + + it('returns normalized paths', async () => { + // Create the following layout: + // /hello/world.txt + const root: string = path.join(getTestTemp(), 'returns-normalized-paths') + await fs.mkdir(path.join(root, 'hello'), {recursive: true}) + await fs.writeFile(path.join(root, 'hello', 'world.txt'), '') + + const itemPaths = await glob.glob( + `${root}${path.sep}${path.sep}${path.sep}hello` + ) + expect(itemPaths).toEqual([ + path.join(root, 'hello'), + path.join(root, 'hello', 'world.txt') + ]) + }) + + it('throws when match broken symlink and omitBrokenSymbolicLinks=false', async () => { + // Create the following layout: + // + // /brokenSym -> /noSuch + const root = path.join( + getTestTemp(), + 'throws-when-match-broken-symlink-and-omit-false' + ) + await fs.mkdir(root, {recursive: true}) + await createSymlinkDir( + path.join(root, 'noSuch'), + path.join(root, 'brokenSym') + ) + + try { + await glob.glob(root, {omitBrokenSymbolicLinks: false}) + throw new Error('Expected tl.find to throw') + } catch (err) { + expect(err.message).toMatch(/broken symbolic link/) + } + }) + + it('throws when search path is broken symlink and omitBrokenSymbolicLinks=false', async () => { + // Create the following layout: + // + // /brokenSym -> /noSuch + const root = path.join( + getTestTemp(), + 'throws-when-search-path-is-broken-symlink-and-omit-false' + ) + await fs.mkdir(root, {recursive: true}) + const brokenSymPath = path.join(root, 'brokenSym') + await createSymlinkDir(path.join(root, 'noSuch'), brokenSymPath) + await fs.lstat(brokenSymPath) + + try { + await glob.glob(brokenSymPath, {omitBrokenSymbolicLinks: false}) + throw new Error('Expected tl.find to throw') + } catch (err) { + expect(err.message).toMatch(/broken symbolic link/) + } + }) +}) + +async function createHiddenDirectory(dir: string): Promise { + if (!path.basename(dir).match(/^\./)) { + throw new Error(`Expected dir '${dir}' to start with '.'.`) + } + + await fs.mkdir(dir, {recursive: true}) + if (IS_WINDOWS) { + const result = child.spawnSync('attrib.exe', ['+H', dir]) + if (result.status !== 0) { + const message: string = (result.output || []).join(' ').trim() + throw new Error( + `Failed to set hidden attribute for directory '${dir}'. ${message}` + ) + } + } +} + +async function createHiddenFile(file: string, content: string): Promise { + if (!path.basename(file).match(/^\./)) { + throw new Error(`Expected dir '${file}' to start with '.'.`) + } + + await fs.mkdir(path.dirname(file), {recursive: true}) + await fs.writeFile(file, content) + + if (IS_WINDOWS) { + const result = child.spawnSync('attrib.exe', ['+H', file]) + if (result.status !== 0) { + const message: string = (result.output || []).join(' ').trim() + throw new Error( + `Failed to set hidden attribute for file '${file}'. ${message}` + ) + } + } +} + +function getTestTemp(): string { + return path.join(__dirname, '_temp', 'glob') +} + +/** + * Creates a symlink directory on OSX/Linux, and a junction point directory on Windows. + * A symlink directory is not created on Windows since it requires an elevated context. + */ +async function createSymlinkDir(real: string, link: string): Promise { + if (IS_WINDOWS) { + await fs.symlink(real, link, 'junction') + } else { + await fs.symlink(real, link) + } +} diff --git a/packages/glob/__tests__/internal-path-helper.test.ts b/packages/glob/__tests__/internal-path-helper.test.ts new file mode 100644 index 00000000..48a10da3 --- /dev/null +++ b/packages/glob/__tests__/internal-path-helper.test.ts @@ -0,0 +1,640 @@ +import * as pathHelper from '../src/internal-path-helper' + +const IS_WINDOWS = process.platform === 'win32' + +describe('path-helper', () => { + it('dirname interprets directory name from paths', () => { + assertDirectoryName('', '.') + assertDirectoryName('.', '.') + assertDirectoryName('..', '.') + assertDirectoryName('hello', '.') + assertDirectoryName('hello/', '.') + assertDirectoryName('hello/world', 'hello') + + if (IS_WINDOWS) { + // Removes redundant slashes + assertDirectoryName('C:\\\\hello\\\\\\world\\\\', 'C:\\hello') + assertDirectoryName('C://hello///world//', 'C:\\hello') + // Relative root: + assertDirectoryName('\\hello\\\\world\\\\again\\\\', '\\hello\\world') + assertDirectoryName('/hello///world//again//', '\\hello\\world') + // UNC: + assertDirectoryName('\\\\hello\\world\\again\\', '\\\\hello\\world') + assertDirectoryName( + '\\\\hello\\\\\\world\\\\again\\\\', + '\\\\hello\\world' + ) + assertDirectoryName( + '\\\\\\hello\\\\\\world\\\\again\\\\', + '\\\\hello\\world' + ) + assertDirectoryName( + '\\\\\\\\hello\\\\\\world\\\\again\\\\', + '\\\\hello\\world' + ) + assertDirectoryName('//hello///world//again//', '\\\\hello\\world') + assertDirectoryName('///hello///world//again//', '\\\\hello\\world') + assertDirectoryName('/////hello///world//again//', '\\\\hello\\world') + // Relative: + assertDirectoryName('hello\\world', 'hello') + + // Directory trimming + assertDirectoryName('a:/hello', 'a:\\') + assertDirectoryName('z:/hello', 'z:\\') + assertDirectoryName('A:/hello', 'A:\\') + assertDirectoryName('Z:/hello', 'Z:\\') + assertDirectoryName('C:/', 'C:\\') + assertDirectoryName('C:/hello', 'C:\\') + assertDirectoryName('C:/hello/', 'C:\\') + assertDirectoryName('C:/hello/world', 'C:\\hello') + assertDirectoryName('C:/hello/world/', 'C:\\hello') + assertDirectoryName('C:', 'C:') + assertDirectoryName('C:hello', 'C:') + assertDirectoryName('C:hello/', 'C:') + assertDirectoryName('C:hello/world', 'C:hello') + assertDirectoryName('C:hello/world/', 'C:hello') + assertDirectoryName('/', '\\') + assertDirectoryName('/hello', '\\') + assertDirectoryName('/hello/', '\\') + assertDirectoryName('/hello/world', '\\hello') + assertDirectoryName('/hello/world/', '\\hello') + assertDirectoryName('\\', '\\') + assertDirectoryName('\\hello', '\\') + assertDirectoryName('\\hello\\', '\\') + assertDirectoryName('\\hello\\world', '\\hello') + assertDirectoryName('\\hello\\world\\', '\\hello') + assertDirectoryName('//hello', '\\\\hello') + assertDirectoryName('//hello/', '\\\\hello') + assertDirectoryName('//hello/world', '\\\\hello\\world') + assertDirectoryName('//hello/world/', '\\\\hello\\world') + assertDirectoryName('\\\\hello', '\\\\hello') + assertDirectoryName('\\\\hello\\', '\\\\hello') + assertDirectoryName('\\\\hello\\world', '\\\\hello\\world') + assertDirectoryName('\\\\hello\\world\\', '\\\\hello\\world') + assertDirectoryName('//hello/world/again', '\\\\hello\\world') + assertDirectoryName('//hello/world/again/', '\\\\hello\\world') + assertDirectoryName('hello/world/', 'hello') + assertDirectoryName('hello/world/again', 'hello\\world') + assertDirectoryName('../../hello', '..\\..') + } else { + // Should not converts slashes + assertDirectoryName('/hello\\world', '/') + assertDirectoryName('/hello\\world/', '/') + assertDirectoryName('\\\\hello\\world\\again', '.') + assertDirectoryName('\\\\hello\\world/', '.') + assertDirectoryName('\\\\hello\\world/again', '\\\\hello\\world') + assertDirectoryName('hello\\world', '.') + assertDirectoryName('hello\\world/', '.') + + // Should remove redundant slashes (rooted paths; UNC format not special) + assertDirectoryName('//hello', '/') + assertDirectoryName('//hello/world', '/hello') + assertDirectoryName('//hello/world/', '/hello') + assertDirectoryName('//hello//world//', '/hello') + assertDirectoryName('///hello////world///', '/hello') + + // Should remove redundant slashes (relative paths) + assertDirectoryName('hello//world//again//', 'hello/world') + assertDirectoryName('hello///world///again///', 'hello/world') + + // Directory trimming (Windows drive root format not special) + assertDirectoryName('C:/', '.') + assertDirectoryName('C:/hello', 'C:') + assertDirectoryName('C:/hello/', 'C:') + assertDirectoryName('C:/hello/world', 'C:/hello') + assertDirectoryName('C:/hello/world/', 'C:/hello') + assertDirectoryName('C:', '.') + assertDirectoryName('C:hello', '.') + assertDirectoryName('C:hello/', '.') + assertDirectoryName('C:hello/world', 'C:hello') + assertDirectoryName('C:hello/world/', 'C:hello') + + // Directory trimming (rooted paths) + assertDirectoryName('/', '/') + assertDirectoryName('/hello', '/') + assertDirectoryName('/hello/', '/') + assertDirectoryName('/hello/world', '/hello') + assertDirectoryName('/hello/world/', '/hello') + + // Directory trimming (relative paths) + assertDirectoryName('hello/world/', 'hello') + assertDirectoryName('hello/world/again', 'hello/world') + assertDirectoryName('../../hello', '../..') + } + }) + + it('ensureAbsoluteRoot roots paths', () => { + if (IS_WINDOWS) { + const currentDrive = process.cwd().substr(0, 2) + expect(currentDrive.match(/^[A-Z]:$/i)).toBeTruthy() + const otherDrive = currentDrive.toUpperCase().startsWith('C') + ? 'D:' + : 'C:' + + // Preserves relative pathing + assertEnsureAbsoluteRoot('C:/foo', '.', `C:/foo\\.`) + assertEnsureAbsoluteRoot('C:/foo/..', 'bar', `C:/foo/..\\bar`) + assertEnsureAbsoluteRoot('C:/foo', 'bar/../baz', `C:/foo\\bar/../baz`) + + // Already rooted - drive root + assertEnsureAbsoluteRoot('D:\\', 'C:/', 'C:/') + assertEnsureAbsoluteRoot('D:\\', 'a:/hello', 'a:/hello') + assertEnsureAbsoluteRoot('D:\\', 'C:\\', 'C:\\') + assertEnsureAbsoluteRoot('D:\\', 'C:\\hello', 'C:\\hello') + + // Already rooted - relative current drive root + expect(process.cwd().length).toBeGreaterThan(3) // sanity check not drive root + assertEnsureAbsoluteRoot(`${otherDrive}\\`, currentDrive, process.cwd()) + assertEnsureAbsoluteRoot( + `${otherDrive}\\`, + `${currentDrive}hello`, + `${process.cwd()}\\hello` + ) + assertEnsureAbsoluteRoot( + `${otherDrive}\\`, + `${currentDrive}hello/world`, + `${process.cwd()}\\hello/world` + ) + assertEnsureAbsoluteRoot( + `${otherDrive}\\`, + `${currentDrive}hello\\world`, + `${process.cwd()}\\hello\\world` + ) + + // Already rooted - relative other drive root + assertEnsureAbsoluteRoot( + `${currentDrive}\\`, + otherDrive, + `${otherDrive}\\` + ) + assertEnsureAbsoluteRoot( + `${currentDrive}\\`, + `${otherDrive}hello`, + `${otherDrive}\\hello` + ) + assertEnsureAbsoluteRoot( + `${currentDrive}\\`, + `${otherDrive}hello/world`, + `${otherDrive}\\hello/world` + ) + assertEnsureAbsoluteRoot( + `${currentDrive}\\`, + `${otherDrive}hello\\world`, + `${otherDrive}\\hello\\world` + ) + + // Already rooted - current drive root + assertEnsureAbsoluteRoot(`${otherDrive}\\`, '/', `${currentDrive}\\`) + assertEnsureAbsoluteRoot( + `${otherDrive}\\`, + '/hello', + `${currentDrive}\\hello` + ) + assertEnsureAbsoluteRoot(`${otherDrive}\\`, '\\', `${currentDrive}\\`) + assertEnsureAbsoluteRoot( + `${otherDrive}\\`, + '\\hello', + `${currentDrive}\\hello` + ) + + // Already rooted - UNC + assertEnsureAbsoluteRoot('D:\\', '//machine/share', '//machine/share') + assertEnsureAbsoluteRoot( + 'D:\\', + '\\\\machine\\share', + '\\\\machine\\share' + ) + + // Relative + assertEnsureAbsoluteRoot('D:/', 'hello', 'D:/hello') + assertEnsureAbsoluteRoot('D:/', 'hello/world', 'D:/hello/world') + assertEnsureAbsoluteRoot('D:\\', 'hello', 'D:\\hello') + assertEnsureAbsoluteRoot('D:\\', 'hello\\world', 'D:\\hello\\world') + assertEnsureAbsoluteRoot('D:/root', 'hello', 'D:/root\\hello') + assertEnsureAbsoluteRoot('D:/root', 'hello/world', 'D:/root\\hello/world') + assertEnsureAbsoluteRoot('D:\\root', 'hello', 'D:\\root\\hello') + assertEnsureAbsoluteRoot( + 'D:\\root', + 'hello\\world', + 'D:\\root\\hello\\world' + ) + assertEnsureAbsoluteRoot('D:/root/', 'hello', 'D:/root/hello') + assertEnsureAbsoluteRoot('D:/root/', 'hello/world', 'D:/root/hello/world') + assertEnsureAbsoluteRoot('D:\\root\\', 'hello', 'D:\\root\\hello') + assertEnsureAbsoluteRoot( + 'D:\\root\\', + 'hello\\world', + 'D:\\root\\hello\\world' + ) + } else { + // Preserves relative pathing + assertEnsureAbsoluteRoot('/foo', '.', `/foo/.`) + assertEnsureAbsoluteRoot('/foo/..', 'bar', `/foo/../bar`) + assertEnsureAbsoluteRoot('/foo', 'bar/../baz', `/foo/bar/../baz`) + + // Already rooted + assertEnsureAbsoluteRoot('/root', '/', '/') + assertEnsureAbsoluteRoot('/root', '/hello', '/hello') + assertEnsureAbsoluteRoot('/root', '/hello/world', '/hello/world') + + // Not already rooted - Windows style drive root + assertEnsureAbsoluteRoot('/root', 'C:/', '/root/C:/') + assertEnsureAbsoluteRoot('/root', 'C:/hello', '/root/C:/hello') + assertEnsureAbsoluteRoot('/root', 'C:\\', '/root/C:\\') + + // Not already rooted - Windows style relative drive root + assertEnsureAbsoluteRoot('/root', 'C:', '/root/C:') + assertEnsureAbsoluteRoot('/root', 'C:hello/world', '/root/C:hello/world') + + // Not already rooted - Windows style current drive root + assertEnsureAbsoluteRoot('/root', '\\', '/root/\\') + assertEnsureAbsoluteRoot( + '/root', + '\\hello\\world', + '/root/\\hello\\world' + ) + + // Not already rooted - Windows style UNC + assertEnsureAbsoluteRoot( + '/root', + '\\\\machine\\share', + '/root/\\\\machine\\share' + ) + + // Not already rooted - relative + assertEnsureAbsoluteRoot('/', 'hello', '/hello') + assertEnsureAbsoluteRoot('/', 'hello/world', '/hello/world') + assertEnsureAbsoluteRoot('/', 'hello\\world', '/hello\\world') + assertEnsureAbsoluteRoot('/root', 'hello', '/root/hello') + assertEnsureAbsoluteRoot('/root', 'hello/world', '/root/hello/world') + assertEnsureAbsoluteRoot('/root', 'hello\\world', '/root/hello\\world') + assertEnsureAbsoluteRoot('/root/', 'hello', '/root/hello') + assertEnsureAbsoluteRoot('/root/', 'hello/world', '/root/hello/world') + assertEnsureAbsoluteRoot('/root/', 'hello\\world', '/root/hello\\world') + assertEnsureAbsoluteRoot('/root\\', 'hello', '/root\\/hello') + assertEnsureAbsoluteRoot('/root\\', 'hello/world', '/root\\/hello/world') + assertEnsureAbsoluteRoot( + '/root\\', + 'hello\\world', + '/root\\/hello\\world' + ) + } + }) + + it('hasAbsoluteRoot detects absolute root', () => { + if (IS_WINDOWS) { + // Drive root + assertHasAbsoluteRoot('C:/', true) + assertHasAbsoluteRoot('a:/hello', true) + assertHasAbsoluteRoot('c:/hello', true) + assertHasAbsoluteRoot('z:/hello', true) + assertHasAbsoluteRoot('A:/hello', true) + assertHasAbsoluteRoot('C:/hello', true) + assertHasAbsoluteRoot('Z:/hello', true) + assertHasAbsoluteRoot('C:\\', true) + assertHasAbsoluteRoot('C:\\hello', true) + + // Relative drive root + assertHasAbsoluteRoot('C:', false) + assertHasAbsoluteRoot('C:hello', false) + assertHasAbsoluteRoot('C:hello/world', false) + assertHasAbsoluteRoot('C:hello\\world', false) + + // Current drive root + assertHasAbsoluteRoot('/', false) + assertHasAbsoluteRoot('/hello', false) + assertHasAbsoluteRoot('/hello/world', false) + assertHasAbsoluteRoot('\\', false) + assertHasAbsoluteRoot('\\hello', false) + assertHasAbsoluteRoot('\\hello\\world', false) + + // UNC + assertHasAbsoluteRoot('//machine/share', true) + assertHasAbsoluteRoot('//machine/share/', true) + assertHasAbsoluteRoot('//machine/share/hello', true) + assertHasAbsoluteRoot('\\\\machine\\share', true) + assertHasAbsoluteRoot('\\\\machine\\share\\', true) + assertHasAbsoluteRoot('\\\\machine\\share\\hello', true) + + // Relative + assertHasAbsoluteRoot('hello', false) + assertHasAbsoluteRoot('hello/world', false) + assertHasAbsoluteRoot('hello\\world', false) + } else { + // Root + assertHasAbsoluteRoot('/', true) + assertHasAbsoluteRoot('/hello', true) + assertHasAbsoluteRoot('/hello/world', true) + + // Windows style drive root - false on OSX/Linux + assertHasAbsoluteRoot('C:/', false) + assertHasAbsoluteRoot('a:/hello', false) + assertHasAbsoluteRoot('c:/hello', false) + assertHasAbsoluteRoot('z:/hello', false) + assertHasAbsoluteRoot('A:/hello', false) + assertHasAbsoluteRoot('C:/hello', false) + assertHasAbsoluteRoot('Z:/hello', false) + assertHasAbsoluteRoot('C:\\', false) + assertHasAbsoluteRoot('C:\\hello', false) + + // Windows style relative drive root - false on OSX/Linux + assertHasAbsoluteRoot('C:', false) + assertHasAbsoluteRoot('C:hello', false) + assertHasAbsoluteRoot('C:hello/world', false) + assertHasAbsoluteRoot('C:hello\\world', false) + + // Windows style current drive root - false on OSX/Linux + assertHasAbsoluteRoot('\\', false) + assertHasAbsoluteRoot('\\hello', false) + assertHasAbsoluteRoot('\\hello\\world', false) + + // Windows style UNC - false on OSX/Linux + assertHasAbsoluteRoot('\\\\machine\\share', false) + assertHasAbsoluteRoot('\\\\machine\\share\\', false) + assertHasAbsoluteRoot('\\\\machine\\share\\hello', false) + + // Relative + assertHasAbsoluteRoot('hello', false) + assertHasAbsoluteRoot('hello/world', false) + assertHasAbsoluteRoot('hello\\world', false) + } + }) + + it('hasRoot detects root', () => { + if (IS_WINDOWS) { + // Drive root + assertHasRoot('C:/', true) + assertHasRoot('a:/hello', true) + assertHasRoot('c:/hello', true) + assertHasRoot('z:/hello', true) + assertHasRoot('A:/hello', true) + assertHasRoot('C:/hello', true) + assertHasRoot('Z:/hello', true) + assertHasRoot('C:\\', true) + assertHasRoot('C:\\hello', true) + + // Relative drive root + assertHasRoot('C:', true) + assertHasRoot('C:hello', true) + assertHasRoot('C:hello/world', true) + assertHasRoot('C:hello\\world', true) + + // Current drive root + assertHasRoot('/', true) + assertHasRoot('/hello', true) + assertHasRoot('/hello/world', true) + assertHasRoot('\\', true) + assertHasRoot('\\hello', true) + assertHasRoot('\\hello\\world', true) + + // UNC + assertHasRoot('//machine/share', true) + assertHasRoot('//machine/share/', true) + assertHasRoot('//machine/share/hello', true) + assertHasRoot('\\\\machine\\share', true) + assertHasRoot('\\\\machine\\share\\', true) + assertHasRoot('\\\\machine\\share\\hello', true) + + // Relative + assertHasRoot('hello', false) + assertHasRoot('hello/world', false) + assertHasRoot('hello\\world', false) + } else { + // Root + assertHasRoot('/', true) + assertHasRoot('/hello', true) + assertHasRoot('/hello/world', true) + + // Windows style drive root - false on OSX/Linux + assertHasRoot('C:/', false) + assertHasRoot('a:/hello', false) + assertHasRoot('c:/hello', false) + assertHasRoot('z:/hello', false) + assertHasRoot('A:/hello', false) + assertHasRoot('C:/hello', false) + assertHasRoot('Z:/hello', false) + assertHasRoot('C:\\', false) + assertHasRoot('C:\\hello', false) + + // Windows style relative drive root - false on OSX/Linux + assertHasRoot('C:', false) + assertHasRoot('C:hello', false) + assertHasRoot('C:hello/world', false) + assertHasRoot('C:hello\\world', false) + + // Windows style current drive root - false on OSX/Linux + assertHasRoot('\\', false) + assertHasRoot('\\hello', false) + assertHasRoot('\\hello\\world', false) + + // Windows style UNC - false on OSX/Linux + assertHasRoot('\\\\machine\\share', false) + assertHasRoot('\\\\machine\\share\\', false) + assertHasRoot('\\\\machine\\share\\hello', false) + + // Relative + assertHasRoot('hello', false) + assertHasRoot('hello/world', false) + assertHasRoot('hello\\world', false) + } + }) + + it('normalizeSeparators normalizes slashes', () => { + if (IS_WINDOWS) { + // Drive-rooted + assertNormalizeSeparators('C:/', 'C:\\') + assertNormalizeSeparators('C:/hello', 'C:\\hello') + assertNormalizeSeparators('C:/hello/', 'C:\\hello\\') + assertNormalizeSeparators('C:\\', 'C:\\') + assertNormalizeSeparators('C:\\hello', 'C:\\hello') + assertNormalizeSeparators('C:', 'C:') + assertNormalizeSeparators('C:hello', 'C:hello') + assertNormalizeSeparators('C:hello/world', 'C:hello\\world') + assertNormalizeSeparators('C:hello\\world', 'C:hello\\world') + assertNormalizeSeparators('/', '\\') + assertNormalizeSeparators('/hello', '\\hello') + assertNormalizeSeparators('/hello/world', '\\hello\\world') + assertNormalizeSeparators('/hello//world', '\\hello\\world') + assertNormalizeSeparators('\\', '\\') + assertNormalizeSeparators('\\hello', '\\hello') + assertNormalizeSeparators('\\hello\\', '\\hello\\') + assertNormalizeSeparators('\\hello\\world', '\\hello\\world') + assertNormalizeSeparators('\\hello\\\\world', '\\hello\\world') + + // UNC + assertNormalizeSeparators('//machine/share', '\\\\machine\\share') + assertNormalizeSeparators('//machine/share/', '\\\\machine\\share\\') + assertNormalizeSeparators( + '//machine/share/hello', + '\\\\machine\\share\\hello' + ) + assertNormalizeSeparators('///machine/share', '\\\\machine\\share') + assertNormalizeSeparators('\\\\machine\\share', '\\\\machine\\share') + assertNormalizeSeparators('\\\\machine\\share\\', '\\\\machine\\share\\') + assertNormalizeSeparators( + '\\\\machine\\share\\hello', + '\\\\machine\\share\\hello' + ) + assertNormalizeSeparators('\\\\\\machine\\share', '\\\\machine\\share') + + // Relative + assertNormalizeSeparators('hello', 'hello') + assertNormalizeSeparators('hello/world', 'hello\\world') + assertNormalizeSeparators('hello//world', 'hello\\world') + assertNormalizeSeparators('hello\\world', 'hello\\world') + assertNormalizeSeparators('hello\\\\world', 'hello\\world') + } else { + // Rooted + assertNormalizeSeparators('/', '/') + assertNormalizeSeparators('/hello', '/hello') + assertNormalizeSeparators('/hello/world', '/hello/world') + assertNormalizeSeparators('//hello/world/', '/hello/world/') + + // Backslash not converted + assertNormalizeSeparators('C:\\', 'C:\\') + assertNormalizeSeparators('C:\\\\hello\\\\', 'C:\\\\hello\\\\') + assertNormalizeSeparators('\\', '\\') + assertNormalizeSeparators('\\hello', '\\hello') + assertNormalizeSeparators('\\hello\\world', '\\hello\\world') + assertNormalizeSeparators('hello\\world', 'hello\\world') + + // UNC not converted + assertNormalizeSeparators('\\\\machine\\share', '\\\\machine\\share') + + // UNC not preserved + assertNormalizeSeparators('//machine/share', '/machine/share') + + // Relative + assertNormalizeSeparators('hello', 'hello') + assertNormalizeSeparators('hello/////world', 'hello/world') + } + }) + + it('safeTrimTrailingSeparator safely trims trailing separator', () => { + assertSafeTrimTrailingSeparator('', '') + + if (IS_WINDOWS) { + // Removes redundant slashes + assertSafeTrimTrailingSeparator( + 'C:\\\\hello\\\\\\world\\\\', + 'C:\\hello\\world' + ) + assertSafeTrimTrailingSeparator('C://hello///world//', 'C:\\hello\\world') + // Relative root: + assertSafeTrimTrailingSeparator( + '\\hello\\\\world\\\\again\\\\', + '\\hello\\world\\again' + ) + assertSafeTrimTrailingSeparator( + '/hello///world//again//', + '\\hello\\world\\again' + ) + // UNC: + assertSafeTrimTrailingSeparator('\\\\hello\\world\\', '\\\\hello\\world') + assertSafeTrimTrailingSeparator( + '\\\\hello\\world\\\\', + '\\\\hello\\world' + ) + assertSafeTrimTrailingSeparator( + '\\\\hello\\\\\\world\\\\again\\', + '\\\\hello\\world\\again' + ) + assertSafeTrimTrailingSeparator('//hello/world/', '\\\\hello\\world') + assertSafeTrimTrailingSeparator('//hello/world//', '\\\\hello\\world') + assertSafeTrimTrailingSeparator( + '//hello//world//again/', + '\\\\hello\\world\\again' + ) + // Relative: + assertSafeTrimTrailingSeparator('hello\\world\\', 'hello\\world') + + // Slash trimming + assertSafeTrimTrailingSeparator('a:/hello/', 'a:\\hello') + assertSafeTrimTrailingSeparator('z:/hello', 'z:\\hello') + assertSafeTrimTrailingSeparator('C:/', 'C:\\') + assertSafeTrimTrailingSeparator('C:\\', 'C:\\') + assertSafeTrimTrailingSeparator('C:/hello/world', 'C:\\hello\\world') + assertSafeTrimTrailingSeparator('C:/hello/world/', 'C:\\hello\\world') + assertSafeTrimTrailingSeparator('C:', 'C:') + assertSafeTrimTrailingSeparator('C:hello/', 'C:hello') + assertSafeTrimTrailingSeparator('/', '\\') + assertSafeTrimTrailingSeparator('/hello/', '\\hello') + assertSafeTrimTrailingSeparator('\\', '\\') + assertSafeTrimTrailingSeparator('\\hello\\', '\\hello') + assertSafeTrimTrailingSeparator('//hello/', '\\\\hello') + assertSafeTrimTrailingSeparator('//hello/world', '\\\\hello\\world') + assertSafeTrimTrailingSeparator('//hello/world/', '\\\\hello\\world') + assertSafeTrimTrailingSeparator('\\\\hello', '\\\\hello') + assertSafeTrimTrailingSeparator('\\\\hello\\', '\\\\hello') + assertSafeTrimTrailingSeparator('\\\\hello\\world', '\\\\hello\\world') + assertSafeTrimTrailingSeparator('\\\\hello\\world\\', '\\\\hello\\world') + assertSafeTrimTrailingSeparator('hello/world/', 'hello\\world') + assertSafeTrimTrailingSeparator('hello/', 'hello') + assertSafeTrimTrailingSeparator('../../', '..\\..') + } else { + // Should not converts slashes + assertSafeTrimTrailingSeparator('/hello\\world', '/hello\\world') + assertSafeTrimTrailingSeparator('/hello\\world/', '/hello\\world') + assertSafeTrimTrailingSeparator('\\\\hello\\world/', '\\\\hello\\world') + assertSafeTrimTrailingSeparator('hello\\world/', 'hello\\world') + + // Should remove redundant slashes (rooted paths; UNC format not special) + assertSafeTrimTrailingSeparator('//hello', '/hello') + assertSafeTrimTrailingSeparator('//hello/world', '/hello/world') + assertSafeTrimTrailingSeparator('//hello/world/', '/hello/world') + assertSafeTrimTrailingSeparator('//hello//world//', '/hello/world') + assertSafeTrimTrailingSeparator('///hello////world///', '/hello/world') + + // Should remove redundant slashes (relative paths) + assertSafeTrimTrailingSeparator('hello//world//', 'hello/world') + assertSafeTrimTrailingSeparator('hello///world///', 'hello/world') + + // Slash trimming (Windows drive root format not special) + assertSafeTrimTrailingSeparator('C:/', 'C:') + assertSafeTrimTrailingSeparator('C:/hello', 'C:/hello') + assertSafeTrimTrailingSeparator('C:/hello/', 'C:/hello') + assertSafeTrimTrailingSeparator('C:hello/', 'C:hello') + + // Slash trimming (rooted paths) + assertSafeTrimTrailingSeparator('/', '/') + assertSafeTrimTrailingSeparator('/hello', '/hello') + assertSafeTrimTrailingSeparator('/hello/', '/hello') + assertSafeTrimTrailingSeparator('/hello/world/', '/hello/world') + + // Slash trimming (relative paths) + assertSafeTrimTrailingSeparator('hello/world/', 'hello/world') + assertSafeTrimTrailingSeparator('../../', '../..') + } + }) +}) + +function assertDirectoryName(itemPath: string, expected: string): void { + expect(pathHelper.dirname(itemPath)).toBe(expected) +} + +function assertEnsureAbsoluteRoot( + root: string, + itemPath: string, + expected: string +): void { + expect(pathHelper.ensureAbsoluteRoot(root, itemPath)).toBe(expected) +} + +function assertHasAbsoluteRoot(itemPath: string, expected: boolean): void { + expect(pathHelper.hasAbsoluteRoot(itemPath)).toBe(expected) +} + +function assertHasRoot(itemPath: string, expected: boolean): void { + expect(pathHelper.hasRoot(itemPath)).toBe(expected) +} + +function assertNormalizeSeparators(itemPath: string, expected: string): void { + expect(pathHelper.normalizeSeparators(itemPath)).toBe(expected) +} + +function assertSafeTrimTrailingSeparator( + itemPath: string, + expected: string +): void { + expect(pathHelper.safeTrimTrailingSeparator(itemPath)).toBe(expected) +} diff --git a/packages/glob/__tests__/internal-path.test.ts b/packages/glob/__tests__/internal-path.test.ts new file mode 100644 index 00000000..37090e49 --- /dev/null +++ b/packages/glob/__tests__/internal-path.test.ts @@ -0,0 +1,92 @@ +import * as path from 'path' +import {Path} from '../src/internal-path' + +const IS_WINDOWS = process.platform === 'win32' + +describe('path', () => { + it('constructs from rooted path', () => { + assertPath(`/`, `${path.sep}`, [path.sep]) + assertPath(`/foo`, `${path.sep}foo`, [path.sep, 'foo']) + if (IS_WINDOWS) { + assertPath(`C:\\foo`, `C:\\foo`, ['C:\\', 'foo']) + assertPath(`C:foo`, `C:foo`, ['C:', 'foo']) + assertPath(`\\\\foo\\bar\\baz`, `\\\\foo\\bar\\baz`, [ + '\\\\foo\\bar', + 'baz' + ]) + } + }) + + it('constructs from rooted segments', () => { + assertPath([`/`], `${path.sep}`, [path.sep]) + assertPath([`/`, `foo`], `${path.sep}foo`, [path.sep, 'foo']) + if (IS_WINDOWS) { + assertPath([`C:\\`, `foo`], `C:\\foo`, ['C:\\', 'foo']) + assertPath([`C:`, `foo`], `C:foo`, ['C:', 'foo']) + assertPath([`\\\\foo\\bar`, `baz`], `\\\\foo\\bar\\baz`, [ + '\\\\foo\\bar', + 'baz' + ]) + } + }) + + it('constructs from relative path', () => { + assertPath(`foo`, `foo`, ['foo']) + assertPath(`foo/bar`, `foo${path.sep}bar`, ['foo', 'bar']) + }) + + it('constructs from relative segments', () => { + assertPath([`foo`], `foo`, ['foo']) + assertPath([`foo`, `bar`], `foo${path.sep}bar`, ['foo', 'bar']) + }) + + it('normalizes slashes', () => { + assertPath( + `/foo///bar${path.sep}${path.sep}${path.sep}baz`, + `${path.sep}foo${path.sep}bar${path.sep}baz`, + [path.sep, 'foo', 'bar', 'baz'] + ) + }) + + it('preserves relative pathing', () => { + assertPath( + '/foo/../bar/./baz', + `${path.sep}foo${path.sep}..${path.sep}bar${path.sep}.${path.sep}baz`, + [path.sep, 'foo', '..', 'bar', '.', 'baz'] + ) + }) + + it('trims unnecessary trailing slash', () => { + assertPath('/', path.sep, [path.sep]) + assertPath('/foo/', `${path.sep}foo`, [path.sep, 'foo']) + assertPath('foo/', 'foo', ['foo']) + assertPath('foo/bar/', `foo${path.sep}bar`, ['foo', 'bar']) + if (IS_WINDOWS) { + assertPath('\\', '\\', ['\\']) + assertPath('C:\\', 'C:\\', ['C:\\']) + assertPath('C:\\foo\\', 'C:\\foo', ['C:\\', 'foo']) + assertPath('C:foo\\', 'C:foo', ['C:', 'foo']) + assertPath('\\\\computer\\share\\', '\\\\computer\\share', [ + '\\\\computer\\share' + ]) + assertPath('\\\\computer\\share\\foo', '\\\\computer\\share\\foo', [ + '\\\\computer\\share', + 'foo' + ]) + assertPath('\\\\computer\\share\\foo\\', '\\\\computer\\share\\foo', [ + '\\\\computer\\share', + 'foo' + ]) + } + }) +}) + +function assertPath( + itemPath: string | string[], + expectedPath: string, + expectedSegments: string[] +): void { + const actual = new Path(itemPath) + expect(actual.toString()).toBe(expectedPath) + expect(actual.segments).toEqual(expectedSegments) +} diff --git a/packages/glob/__tests__/internal-pattern-helper.test.ts b/packages/glob/__tests__/internal-pattern-helper.test.ts new file mode 100644 index 00000000..dc8b0de9 --- /dev/null +++ b/packages/glob/__tests__/internal-pattern-helper.test.ts @@ -0,0 +1,190 @@ +import * as path from 'path' +import * as patternHelper from '../src/internal-pattern-helper' +import {MatchKind} from '../src/internal-match-kind' +import {IS_WINDOWS} from '../../io/src/io-util' + +describe('pattern-helper', () => { + it('getSearchPaths omits negate search paths', () => { + const root = IS_WINDOWS ? 'C:\\' : '/' + const patterns = patternHelper.parse( + [ + `${root}search1/foo/**`, + `${root}search2/bar/**`, + `!${root}search3/baz/**` + ], + patternHelper.getOptions() + ) + const searchPaths = patternHelper.getSearchPaths(patterns) + expect(searchPaths).toEqual([ + `${root}search1${path.sep}foo`, + `${root}search2${path.sep}bar` + ]) + }) + + it('getSearchPaths omits search path when ancestor is also a search path', () => { + if (IS_WINDOWS) { + const patterns = patternHelper.parse( + [ + 'C:\\Search1\\Foo\\**', + 'C:\\sEARCH1\\fOO\\bar\\**', + 'C:\\sEARCH1\\foo\\bar', + 'C:\\Search2\\**', + 'C:\\Search3\\Foo\\Bar\\**', + 'C:\\sEARCH3\\fOO\\bAR\\**' + ], + patternHelper.getOptions() + ) + const searchPaths = patternHelper.getSearchPaths(patterns) + expect(searchPaths).toEqual([ + 'C:\\Search1\\Foo', + 'C:\\Search2', + 'C:\\Search3\\Foo\\Bar' + ]) + } else { + const patterns = patternHelper.parse( + [ + '/search1/foo/**', + '/search1/foo/bar/**', + '/search2/foo/bar', + '/search2/**', + '/search3/foo/bar/**', + '/search3/foo/bar/**' + ], + patternHelper.getOptions() + ) + const searchPaths = patternHelper.getSearchPaths(patterns) + expect(searchPaths).toEqual([ + '/search1/foo', + '/search2', + '/search3/foo/bar' + ]) + } + }) + + it('match supports interleaved exclude patterns', () => { + const root = IS_WINDOWS ? 'C:\\' : '/' + const itemPaths = [ + `${root}solution1/proj1/proj1.proj`, + `${root}solution1/proj1/README.txt`, + `${root}solution1/proj2/proj2.proj`, + `${root}solution1/proj2/README.txt`, + `${root}solution1/solution1.sln`, + `${root}solution2/proj1/proj1.proj`, + `${root}solution2/proj1/README.txt`, + `${root}solution2/proj2/proj2.proj`, + `${root}solution2/proj2/README.txt`, + `${root}solution2/solution2.sln` + ] + const patterns = patternHelper.parse( + [ + `${root}**/*.proj`, // include all proj files + `${root}**/README.txt`, // include all README files + `!${root}**/solution2/**`, // exclude the solution 2 folder entirely + `${root}**/*.sln`, // include all sln files + `!${root}**/proj2/README.txt` // exclude proj2 README files + ], + patternHelper.getOptions({implicitDescendants: false}) + ) + const matched = itemPaths.filter( + x => patternHelper.match(patterns, x) === MatchKind.All + ) + expect(matched).toEqual([ + `${root}solution1/proj1/proj1.proj`, + `${root}solution1/proj1/README.txt`, + `${root}solution1/proj2/proj2.proj`, + `${root}solution1/solution1.sln`, + `${root}solution2/solution2.sln` + ]) + }) + + it('match supports excluding directories', () => { + const root = IS_WINDOWS ? 'C:\\' : '/' + const itemPaths = [ + root, + `${root}foo`, + `${root}foo/bar`, + `${root}foo/bar/baz` + ] + const patterns = patternHelper.parse( + [ + `${root}foo/**`, // include all files and directories + `!${root}foo/**/` // exclude directories + ], + patternHelper.getOptions({implicitDescendants: false}) + ) + const matchKinds = itemPaths.map(x => patternHelper.match(patterns, x)) + expect(matchKinds).toEqual([ + MatchKind.None, + MatchKind.File, + MatchKind.File, + MatchKind.File + ]) + }) + + it('match supports including directories only', () => { + const root = IS_WINDOWS ? 'C:\\' : '/' + const itemPaths = [ + root, + `${root}foo/`, + `${root}foo/bar`, + `${root}foo/bar/baz` + ] + const patterns = patternHelper.parse( + [ + `${root}foo/**/` // include directories only + ], + patternHelper.getOptions({implicitDescendants: false}) + ) + const matchKinds = itemPaths.map(x => patternHelper.match(patterns, x)) + expect(matchKinds).toEqual([ + MatchKind.None, + MatchKind.Directory, + MatchKind.Directory, + MatchKind.Directory + ]) + }) + + it('parse skips comments', () => { + const patterns = patternHelper.parse( + ['# comment 1', ' # comment 2', '!#hello-world.txt'], + patternHelper.getOptions({implicitDescendants: false}) + ) + expect(patterns).toHaveLength(1) + expect(patterns[0].negate).toBeTruthy() + expect(patterns[0].segments.reverse()[0]).toEqual('#hello-world.txt') + }) + + it('parse skips empty patterns', () => { + const patterns = patternHelper.parse( + ['', ' ', 'hello-world.txt'], + patternHelper.getOptions({implicitDescendants: false}) + ) + expect(patterns).toHaveLength(1) + expect(patterns[0].segments.reverse()[0]).toEqual('hello-world.txt') + }) + + it('partialMatch skips negate patterns', () => { + const root = IS_WINDOWS ? 'C:\\' : '/' + const patterns = patternHelper.parse( + [ + `${root}search1/foo/**`, + `${root}search2/bar/**`, + `!${root}search2/bar/**`, + `!${root}search3/baz/**` + ], + patternHelper.getOptions({implicitDescendants: false}) + ) + expect(patternHelper.partialMatch(patterns, `${root}search1`)).toBeTruthy() + expect( + patternHelper.partialMatch(patterns, `${root}search1/foo`) + ).toBeTruthy() + expect(patternHelper.partialMatch(patterns, `${root}search2`)).toBeTruthy() + expect( + patternHelper.partialMatch(patterns, `${root}search2/bar`) + ).toBeTruthy() + expect(patternHelper.partialMatch(patterns, `${root}search3`)).toBeFalsy() + expect( + patternHelper.partialMatch(patterns, `${root}search3/bar`) + ).toBeFalsy() + }) +}) diff --git a/packages/glob/__tests__/internal-pattern.test.ts b/packages/glob/__tests__/internal-pattern.test.ts new file mode 100644 index 00000000..c6a1b2c8 --- /dev/null +++ b/packages/glob/__tests__/internal-pattern.test.ts @@ -0,0 +1,366 @@ +import * as io from '../../io/src/io' +import * as path from 'path' +import {MatchKind} from '../src/internal-match-kind' +import {promises as fs} from 'fs' + +// Mock 'os' before importing Pattern +/* eslint-disable import/first */ +/* eslint-disable @typescript-eslint/promise-function-async */ +// Note, @typescript-eslint/promise-function-async is a false positive due to the +// mock factory delegate which returns any. Fixed in a future version of jest. +jest.mock('os', () => jest.requireActual('os')) +const os = jest.requireMock('os') +import {Pattern} from '../src/internal-pattern' +jest.resetModuleRegistry() + +const IS_WINDOWS = process.platform === 'win32' + +describe('pattern', () => { + beforeAll(async () => { + await io.rmRF(getTestTemp()) + }) + + it('counts leading negate markers', () => { + const actual = [ + '/initial-includes/*.txt', + '!!/hello/two-negate-markers.txt', + '!!!!/hello/four-negate-markers.txt', + '!/initial-includes/one-negate-markers.txt', + '!!!/initial-includes/three-negate-markers.txt' + ].map(x => new Pattern(x).negate) + expect(actual).toEqual([false, false, false, true, true]) + }) + + it('escapes homedir', async () => { + const originalHomedir = os.homedir + const home = path.join(getTestTemp(), 'home-with-[and]') + await fs.mkdir(home, {recursive: true}) + try { + os.homedir = () => home + const pattern = new Pattern('~/m*') + + expect(pattern.searchPath).toBe(home) + expect(pattern.match(path.join(home, 'match'))).toBeTruthy() + expect(pattern.match(path.join(home, 'not-match'))).toBeFalsy() + } finally { + os.homedir = originalHomedir + } + }) + + it('escapes root', async () => { + const originalCwd = process.cwd() + const rootPath = path.join(getTestTemp(), 'cwd-with-[and]') + await fs.mkdir(rootPath, {recursive: true}) + try { + process.chdir(rootPath) + + // Relative + let pattern = new Pattern('m*') + expect(pattern.searchPath).toBe(rootPath) + expect(pattern.match(path.join(rootPath, 'match'))).toBeTruthy() + expect(pattern.match(path.join(rootPath, 'not-match'))).toBeFalsy() + + if (IS_WINDOWS) { + const currentDrive = process.cwd().substr(0, 2) + expect(currentDrive.match(/^[A-Z]:$/i)).toBeTruthy() + + // Relative current drive letter, e.g. C:m* + pattern = new Pattern(`${currentDrive}m*`) + expect(pattern.searchPath).toBe(rootPath) + expect(pattern.match(path.join(rootPath, 'match'))).toBeTruthy() + expect(pattern.match(path.join(rootPath, 'not-match'))).toBeFalsy() + + // Relative current drive, e.g. \path\to\cwd\m* + pattern = new Pattern( + `${Pattern.globEscape(process.cwd().substr(2))}\\m*` + ) + expect(pattern.searchPath).toBe(rootPath) + expect(pattern.match(path.join(rootPath, 'match'))).toBeTruthy() + expect(pattern.match(path.join(rootPath, 'not-match'))).toBeFalsy() + } + } finally { + process.chdir(originalCwd) + } + }) + + it('globstar matches immediately preceeding directory', () => { + const root = IS_WINDOWS ? 'C:\\' : '/' + const pattern = new Pattern(`${root}foo/bar/**`) + const actual = [ + root, + `${root}foo`, + `${root}foo/bar`, + `${root}foo/bar/baz` + ].map(x => pattern.match(x)) + expect(actual).toEqual([ + MatchKind.None, + MatchKind.None, + MatchKind.All, + MatchKind.All + ]) + }) + + it('is case insensitive match on Windows', () => { + const root = IS_WINDOWS ? 'C:\\' : '/' + const pattern = new Pattern(`${root}Foo/**/Baz`) + expect(pattern.match(`${root}Foo/Baz`)).toBe(MatchKind.All) + expect(pattern.match(`${root}Foo/bAZ`)).toBe( + IS_WINDOWS ? MatchKind.All : MatchKind.None + ) + expect(pattern.match(`${root}fOO/Baz`)).toBe( + IS_WINDOWS ? MatchKind.All : MatchKind.None + ) + expect(pattern.match(`${root}fOO/bar/bAZ`)).toBe( + IS_WINDOWS ? MatchKind.All : MatchKind.None + ) + }) + + it('is case insensitive partial match on Windows', () => { + const root = IS_WINDOWS ? 'C:\\' : '/' + const pattern = new Pattern(`${root}Foo/Bar/**/Baz`) + expect(pattern.partialMatch(`${root}Foo`)).toBeTruthy() + expect(pattern.partialMatch(`${root}fOO`)).toBe(IS_WINDOWS ? true : false) + }) + + it('matches root', () => { + const pattern = new Pattern(IS_WINDOWS ? 'C:\\**' : '/**') + expect(pattern.match(IS_WINDOWS ? 'C:\\' : '/')).toBe(MatchKind.All) + }) + + it('partial matches root', () => { + if (IS_WINDOWS) { + let pattern = new Pattern('C:\\foo\\**') + expect(pattern.partialMatch('c:\\')).toBeTruthy() + pattern = new Pattern('c:\\foo\\**') + expect(pattern.partialMatch('C:\\')).toBeTruthy() + } else { + const pattern = new Pattern('/foo/**') + expect(pattern.partialMatch('/')).toBeTruthy() + } + }) + + it('replaces leading . segment', () => { + // Pattern is '.' + let pattern = new Pattern('.') + expect(pattern.match(process.cwd())).toBe(MatchKind.All) + expect(pattern.match(path.join(process.cwd(), 'foo'))).toBe(MatchKind.None) + + // Pattern is './foo' + pattern = new Pattern('./foo') + expect(pattern.match(path.join(process.cwd(), 'foo'))).toBe(MatchKind.All) + expect(pattern.match(path.join(process.cwd(), 'bar'))).toBe(MatchKind.None) + + // Pattern is '.foo' + pattern = new Pattern('.foo') + expect(pattern.match(path.join(process.cwd(), '.foo'))).toBe(MatchKind.All) + expect(pattern.match(path.join(process.cwd(), 'foo'))).toBe(MatchKind.None) + expect(pattern.match(`${process.cwd()}foo`)).toBe(MatchKind.None) + }) + + it('replaces leading ~ segment', async () => { + const homedir = os.homedir() + expect(homedir).toBeTruthy() + await fs.stat(homedir) + + // Pattern is '~' + let pattern = new Pattern('~') + expect(pattern.match(homedir)).toBe(MatchKind.All) + expect(pattern.match(path.join(homedir, 'foo'))).toBe(MatchKind.None) + + // Pattern is '~/foo' + pattern = new Pattern('~/foo') + expect(pattern.match(path.join(homedir, 'foo'))).toBe(MatchKind.All) + expect(pattern.match(path.join(homedir, 'bar'))).toBe(MatchKind.None) + + // Pattern is '~foo' + pattern = new Pattern('~foo') + expect(pattern.match(path.join(process.cwd(), '~foo'))).toBe(MatchKind.All) + expect(pattern.match(path.join(homedir, 'foo'))).toBe(MatchKind.None) + expect(pattern.match(`${homedir}foo`)).toBe(MatchKind.None) + }) + + it('replaces leading relative root', () => { + if (IS_WINDOWS) { + const currentDrive = process.cwd().substr(0, 2) + expect(currentDrive.match(/^[A-Z]:$/i)).toBeTruthy() + const otherDrive = currentDrive.toUpperCase().startsWith('C') + ? 'D:' + : 'C:' + expect(process.cwd().length).toBeGreaterThan(3) // sanity check not drive root + + // Pattern is 'C:' + let pattern = new Pattern(currentDrive) + expect(pattern.match(process.cwd())).toBeTruthy() + expect(pattern.match(path.join(process.cwd(), 'foo'))).toBeFalsy() + + // Pattern is 'C:foo' + pattern = new Pattern(`${currentDrive}foo`) + expect(pattern.match(path.join(process.cwd(), 'foo'))).toBeTruthy() + expect(pattern.match(path.join(process.cwd(), 'bar'))).toBeFalsy() + expect(pattern.match(`${currentDrive}\\foo`)).toBeFalsy() + + // Pattern is 'X:' + pattern = new Pattern(otherDrive) + expect(pattern.match(`${otherDrive}\\`)).toBeTruthy() + expect(pattern.match(`${otherDrive}\\foo`)).toBeFalsy() + + // Pattern is 'X:foo' + pattern = new Pattern(`${otherDrive}foo`) + expect(pattern.match(`${otherDrive}\\foo`)).toBeTruthy() + expect(pattern.match(`${otherDrive}\\bar`)).toBeFalsy() + + // Pattern is '\\path\\to\\cwd' + pattern = new Pattern(`${process.cwd().substr(2)}\\foo`) + expect(pattern.match(path.join(process.cwd(), 'foo'))).toBeTruthy() + expect(pattern.match(path.join(process.cwd(), 'bar'))).toBeFalsy() + } + }) + + it('roots exclude pattern', () => { + const patternStrings = ['!hello.txt', '!**/world.txt'] + const actual = patternStrings.map(x => new Pattern(x)) + const expected = patternStrings + .map(x => x.substr(1)) + .map(x => path.join(Pattern.globEscape(process.cwd()), x)) + .map(x => `!${x}`) + .map(x => new Pattern(x)) + expect(actual.map(x => x.negate)).toEqual([true, true]) + expect(actual.map(x => x.segments)).toEqual(expected.map(x => x.segments)) + }) + + it('roots include pattern', () => { + const patternStrings = ['hello.txt', '**/world.txt'] + const actual = patternStrings.map(x => new Pattern(x)) + const expected = patternStrings.map( + x => new Pattern(path.join(Pattern.globEscape(process.cwd()), x)) + ) + expect(actual.map(x => x.segments)).toEqual(expected.map(x => x.segments)) + }) + + it('sets trailing separator', () => { + expect(new Pattern(' foo ').trailingSeparator).toBeFalsy() + expect(new Pattern(' /foo ').trailingSeparator).toBeFalsy() + expect(new Pattern('! /foo ').trailingSeparator).toBeFalsy() + expect(new Pattern(' /foo/* ').trailingSeparator).toBeFalsy() + expect(new Pattern(' /foo/** ').trailingSeparator).toBeFalsy() + expect(new Pattern(' \\foo ').trailingSeparator).toBeFalsy() + expect(new Pattern('! \\foo ').trailingSeparator).toBeFalsy() + expect(new Pattern(' \\foo\\* ').trailingSeparator).toBeFalsy() + expect(new Pattern(' \\foo\\** ').trailingSeparator).toBeFalsy() + expect(new Pattern(' foo/ ').trailingSeparator).toBeTruthy() + expect(new Pattern(' /foo/ ').trailingSeparator).toBeTruthy() + expect(new Pattern(' C:/foo/ ').trailingSeparator).toBeTruthy() + expect(new Pattern(' C:foo/ ').trailingSeparator).toBeTruthy() + expect(new Pattern(' D:foo/ ').trailingSeparator).toBeTruthy() + expect(new Pattern('! /foo/ ').trailingSeparator).toBeTruthy() + expect(new Pattern(' /foo/*/ ').trailingSeparator).toBeTruthy() + expect(new Pattern(' /foo/**/ ').trailingSeparator).toBeTruthy() + expect(new Pattern(' foo\\ ').trailingSeparator).toEqual( + IS_WINDOWS ? true : false + ) + expect(new Pattern(' \\foo\\ ').trailingSeparator).toEqual( + IS_WINDOWS ? true : false + ) + expect(new Pattern('! \\foo\\ ').trailingSeparator).toEqual( + IS_WINDOWS ? true : false + ) + expect(new Pattern(' \\foo\\*\\ ').trailingSeparator).toEqual( + IS_WINDOWS ? true : false + ) + expect(new Pattern(' \\foo\\**\\ ').trailingSeparator).toEqual( + IS_WINDOWS ? true : false + ) + }) + + it('supports including directories only', () => { + const root = IS_WINDOWS ? 'C:\\' : '/' + const pattern = new Pattern(`${root}foo/**/`) // trailing slash + const actual = [ + root, + `${root}foo/`, + `${root}foo/bar`, + `${root}foo/bar/baz` + ].map(x => pattern.match(x)) + expect(pattern.trailingSeparator).toBeTruthy() + expect(actual).toEqual([ + MatchKind.None, + MatchKind.Directory, + MatchKind.Directory, + MatchKind.Directory + ]) + }) + + it('trims pattern', () => { + const pattern = new Pattern(' hello.txt ') + expect(pattern.segments.reverse()[0]).toBe('hello.txt') + }) + + it('trims whitespace after trimming negate markers', () => { + const pattern = new Pattern(' ! ! ! hello.txt ') + expect(pattern.negate).toBeTruthy() + expect(pattern.segments.reverse()[0]).toBe('hello.txt') + }) + + it('unescapes segments to narrow search path', () => { + // Positive + const root = IS_WINDOWS ? 'C:\\' : '/' + let pattern = new Pattern(`${root}foo/b[a]r/b*`) + expect(pattern.searchPath).toBe(`${root}foo${path.sep}bar`) + expect(pattern.match(`${root}foo/bar/baz`)).toBeTruthy() + pattern = new Pattern(`${root}foo/b[*]r/b*`) + expect(pattern.searchPath).toBe(`${root}foo${path.sep}b*r`) + expect(pattern.match(`${root}foo/b*r/baz`)).toBeTruthy() + expect(pattern.match(`${root}foo/bar/baz`)).toBeFalsy() + pattern = new Pattern(`${root}foo/b[?]r/b*`) + expect(pattern.searchPath).toBe(`${root}foo${path.sep}b?r`) + expect(pattern.match(`${root}foo/b?r/baz`)).toBeTruthy() + expect(pattern.match(`${root}foo/bar/baz`)).toBeFalsy() + pattern = new Pattern(`${root}foo/b[!]r/b*`) + expect(pattern.searchPath).toBe(`${root}foo${path.sep}b!r`) + expect(pattern.match(`${root}foo/b!r/baz`)).toBeTruthy() + pattern = new Pattern(`${root}foo/b[[]ar/b*`) + expect(pattern.searchPath).toBe(`${root}foo${path.sep}b[ar`) + expect(pattern.match(`${root}foo/b[ar/baz`)).toBeTruthy() + pattern = new Pattern(`${root}foo/b[]r/b*`) + expect(pattern.searchPath).toBe(`${root}foo${path.sep}b[]r`) + expect(pattern.match(`${root}foo/b[]r/baz`)).toBeTruthy() + pattern = new Pattern(`${root}foo/b[r/b*`) + expect(pattern.searchPath).toBe(`${root}foo${path.sep}b[r`) + expect(pattern.match(`${root}foo/b[r/baz`)).toBeTruthy() + pattern = new Pattern(`${root}foo/b]r/b*`) + expect(pattern.searchPath).toBe(`${root}foo${path.sep}b]r`) + expect(pattern.match(`${root}foo/b]r/baz`)).toBeTruthy() + if (!IS_WINDOWS) { + pattern = new Pattern('/foo/b\\[a]r/b*') + expect(pattern.searchPath).toBe(`${path.sep}foo${path.sep}b[a]r`) + expect(pattern.match('/foo/b[a]r/baz')).toBeTruthy() + pattern = new Pattern('/foo/b[\\!]r/b*') + expect(pattern.searchPath).toBe(`${path.sep}foo${path.sep}b!r`) + expect(pattern.match('/foo/b!r/baz')).toBeTruthy() + pattern = new Pattern('/foo/b[\\]]r/b*') + expect(pattern.searchPath).toBe(`${path.sep}foo${path.sep}b]r`) + expect(pattern.match('/foo/b]r/baz')).toBeTruthy() + pattern = new Pattern('/foo/b[\\a]r/b*') + expect(pattern.searchPath).toBe(`${path.sep}foo${path.sep}bar`) + expect(pattern.match('/foo/bar/baz')).toBeTruthy() + } + + // Negative + pattern = new Pattern(`${root}foo/b[aA]r/b*`) + expect(pattern.searchPath).toBe(`${root}foo`) + pattern = new Pattern(`${root}foo/b[!a]r/b*`) + expect(pattern.searchPath).toBe(`${root}foo`) + if (IS_WINDOWS) { + pattern = new Pattern('C:/foo/b\\[a]r/b*') + expect(pattern.searchPath).toBe(`C:\\foo\\b\\ar`) + expect(pattern.match('C:/foo/b/ar/baz')).toBeTruthy() + pattern = new Pattern('C:/foo/b[\\!]r/b*') + expect(pattern.searchPath).toBe('C:\\foo\\b[\\!]r') + expect(pattern.match('C:/foo/b[undefined/!]r/baz')).toBeTruthy() // Note, "undefined" substr to accommodate a bug in Minimatch when nocase=true + } + }) +}) + +function getTestTemp(): string { + return path.join(__dirname, '_temp', 'internal-pattern') +} diff --git a/packages/glob/package-lock.json b/packages/glob/package-lock.json new file mode 100644 index 00000000..3eb24c1b --- /dev/null +++ b/packages/glob/package-lock.json @@ -0,0 +1,35 @@ +{ + "name": "@actions/glob", + "version": "0.1.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "requires": { + "brace-expansion": "^1.1.7" + } + } + } +} diff --git a/packages/glob/package.json b/packages/glob/package.json new file mode 100644 index 00000000..067d59c9 --- /dev/null +++ b/packages/glob/package.json @@ -0,0 +1,41 @@ +{ + "name": "@actions/glob", + "version": "0.1.0", + "preview": true, + "description": "Actions glob lib", + "keywords": [ + "github", + "actions", + "glob" + ], + "homepage": "https://github.com/actions/toolkit/tree/master/packages/glob", + "license": "MIT", + "main": "lib/glob.js", + "types": "lib/glob.d.ts", + "directories": { + "lib": "lib", + "test": "__tests__" + }, + "files": [ + "lib" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/actions/toolkit.git", + "directory": "packages/glob" + }, + "scripts": { + "test": "echo \"Error: run tests from root\" && exit 1", + "tsc": "tsc" + }, + "bugs": { + "url": "https://github.com/actions/toolkit/issues" + }, + "dependencies": { + "@actions/core": "^1.2.0", + "minimatch": "^3.0.4" + } +} diff --git a/packages/glob/src/glob.ts b/packages/glob/src/glob.ts new file mode 100644 index 00000000..861e1e8f --- /dev/null +++ b/packages/glob/src/glob.ts @@ -0,0 +1,183 @@ +import * as core from '@actions/core' +import * as fs from 'fs' +import * as path from 'path' +import * as patternHelper from './internal-pattern-helper' +import {IGlobOptions} from './internal-glob-options' +import {MatchKind} from './internal-match-kind' +import {Pattern} from './internal-pattern' +import {SearchState} from './internal-search-state' + +export {IGlobOptions} + +/** + * Returns files and directories matching the specified glob pattern. + * + * Order of the results is not guaranteed. + */ +export async function glob( + pattern: string, + options?: IGlobOptions +): Promise { + const result: string[] = [] + for await (const itemPath of globGenerator(pattern, options)) { + result.push(itemPath) + } + return result +} + +/** + * Returns files and directories matching the specified glob pattern. + * + * Order of the results is not guaranteed. + */ +export async function* globGenerator( + pattern: string, + options?: IGlobOptions +): AsyncGenerator { + // Set defaults options + options = patternHelper.getOptions(options) + + // Parse patterns + const patterns: Pattern[] = patternHelper.parse([pattern], options) + + // Push the search paths + const stack: SearchState[] = [] + for (const searchPath of patternHelper.getSearchPaths(patterns)) { + core.debug(`Search path '${searchPath}'`) + + // Exists? + try { + // Intentionally using lstat. Detection for broken symlink + // will be performed later (if following symlinks). + await fs.promises.lstat(searchPath) + } catch (err) { + if (err.code === 'ENOENT') { + continue + } + throw err + } + + stack.unshift(new SearchState(searchPath, 1)) + } + + // Search + const traversalChain: string[] = [] // used to detect cycles + while (stack.length) { + // Pop + const item = stack.pop() as SearchState + + // Match? + const match = patternHelper.match(patterns, item.path) + const partialMatch = + !!match || patternHelper.partialMatch(patterns, item.path) + if (!match && !partialMatch) { + continue + } + + // Stat + const stats: fs.Stats | undefined = await stat( + item, + options, + traversalChain + ) + + // Broken symlink, or symlink cycle detected, or no longer exists + if (!stats) { + continue + } + + // Directory + if (stats.isDirectory()) { + // Matched + if (match & MatchKind.Directory) { + yield item.path + } + // Descend? + else if (!partialMatch) { + continue + } + + // Push the child items in reverse + const childLevel = item.level + 1 + const childItems = (await fs.promises.readdir(item.path)).map( + x => new SearchState(path.join(item.path, x), childLevel) + ) + stack.push(...childItems.reverse()) + } + // File + else if (match & MatchKind.File) { + yield item.path + } + } +} + +/** + * Returns the search path preceeding the first segment that contains a pattern. + * + * For example, '/foo/bar*' returns '/foo'. + */ +export function getSearchPath(pattern: string): string { + const patterns: Pattern[] = patternHelper.parse( + [pattern], + patternHelper.getOptions() + ) + const searchPaths: string[] = patternHelper.getSearchPaths(patterns) + return searchPaths.length > 0 ? searchPaths[0] : '' +} + +async function stat( + item: SearchState, + options: IGlobOptions, + traversalChain: string[] +): Promise { + // Note: + // `stat` returns info about the target of a symlink (or symlink chain) + // `lstat` returns info about a symlink itself + let stats: fs.Stats + if (options.followSymbolicLinks) { + try { + // Use `stat` (following symlinks) + stats = await fs.promises.stat(item.path) + } catch (err) { + if (err.code === 'ENOENT') { + if (options.omitBrokenSymbolicLinks) { + core.debug(`Broken symlink '${item.path}'`) + return undefined + } + + throw new Error( + `No information found for the path '${item.path}'. This may indicate a broken symbolic link.` + ) + } + + throw err + } + } else { + // Use `lstat` (not following symlinks) + stats = await fs.promises.lstat(item.path) + } + + // Note, isDirectory() returns false for the lstat of a symlink + if (stats.isDirectory() && options.followSymbolicLinks) { + // Get the realpath + const realPath: string = await fs.promises.realpath(item.path) + + // Fixup the traversal chain to match the item level + while (traversalChain.length >= item.level) { + traversalChain.pop() + } + + // Test for a cycle + if (traversalChain.some((x: string) => x === realPath)) { + core.debug( + `Symlink cycle detected for path '${item.path}' and realpath '${realPath}'` + ) + return undefined + } + + // Update the traversal chain + traversalChain.push(realPath) + } + + return stats +} diff --git a/packages/glob/src/internal-glob-options.ts b/packages/glob/src/internal-glob-options.ts new file mode 100644 index 00000000..8e427454 --- /dev/null +++ b/packages/glob/src/internal-glob-options.ts @@ -0,0 +1,28 @@ +export interface IGlobOptions { + /** + * Indicates whether to follow symbolic links. Generally should be true + * unless deleting files. + * + * @default true + */ + followSymbolicLinks?: boolean + + /** + * Indicates whether directories that match a glob pattern, should implicitly + * cause all descendant paths to be matched. + * + * For example, given the directory `my-dir`, the following glob patterns + * would produce the same results: `my-dir/**`, `my-dir/`, `my-dir` + * + * @default true + */ + implicitDescendants?: boolean + + /** + * Indicates whether broken symbolic should be ignored and omitted from the + * result set. Otherwise an error will be thrown. + * + * @default true + */ + omitBrokenSymbolicLinks?: boolean +} diff --git a/packages/glob/src/internal-match-kind.ts b/packages/glob/src/internal-match-kind.ts new file mode 100644 index 00000000..75802da5 --- /dev/null +++ b/packages/glob/src/internal-match-kind.ts @@ -0,0 +1,16 @@ +/** + * Indicates whether a pattern matches a path + */ +export enum MatchKind { + /** Not matched */ + None = 0, + + /** Matched if the path is a directory */ + Directory = 1, + + /** Matched if the path is a regular file */ + File = 2, + + /** Matched */ + All = Directory | File +} diff --git a/packages/glob/src/internal-path-helper.ts b/packages/glob/src/internal-path-helper.ts new file mode 100644 index 00000000..7d9d0ef4 --- /dev/null +++ b/packages/glob/src/internal-path-helper.ts @@ -0,0 +1,206 @@ +import * as assert from 'assert' +import * as path from 'path' + +const IS_WINDOWS = process.platform === 'win32' + +/** + * Similar to path.dirname except normalizes the path separators and slightly better handling for Windows UNC paths. + * + * For example, on Linux/macOS: + * - `/ => /` + * - `/hello => /` + * + * For example, on Windows: + * - `C:\ => C:\` + * - `C:\hello => C:\` + * - `C: => C:` + * - `C:hello => C:` + * - `\ => \` + * - `\hello => \` + * - `\\hello => \\hello` + * - `\\hello\world => \\hello\world` + */ +export function dirname(p: string): string { + // Normalize slashes and trim unnecessary trailing slash + p = safeTrimTrailingSeparator(p) + + // Windows UNC root, e.g. \\hello or \\hello\world + if (IS_WINDOWS && /^\\\\[^\\]+(\\[^\\]+)?$/.test(p)) { + return p + } + + // Get dirname + let result = path.dirname(p) + + // Trim trailing slash for Windows UNC root, e.g. \\hello\world\ + if (IS_WINDOWS && /^\\\\[^\\]+\\[^\\]+\\$/.test(result)) { + result = safeTrimTrailingSeparator(result) + } + + return result +} + +/** + * Roots the path if not already rooted. On Windows, relative roots like `\` + * or `C:` are expanded based on the current working directory. + */ +export function ensureAbsoluteRoot(root: string, itemPath: string): string { + assert(root, `ensureAbsoluteRoot parameter 'root' must not be empty`) + assert(itemPath, `ensureAbsoluteRoot parameter 'itemPath' must not be empty`) + + // Already rooted + if (hasAbsoluteRoot(itemPath)) { + return itemPath + } + + // Windows + if (IS_WINDOWS) { + // Check for itemPath like C: or C:foo + if (itemPath.match(/^[A-Z]:[^\\/]|^[A-Z]:$/i)) { + let cwd = process.cwd() + assert( + cwd.match(/^[A-Z]:\\/i), + `Expected current directory to start with an absolute drive root. Actual '${cwd}'` + ) + + // Drive letter matches cwd? Expand to cwd + if (itemPath[0].toUpperCase() === cwd[0].toUpperCase()) { + // Drive only, e.g. C: + if (itemPath.length === 2) { + // Preserve specified drive letter case (upper or lower) + return `${itemPath[0]}:\\${cwd.substr(3)}` + } + // Drive + path, e.g. C:foo + else { + if (!cwd.endsWith('\\')) { + cwd += '\\' + } + // Preserve specified drive letter case (upper or lower) + return `${itemPath[0]}:\\${cwd.substr(3)}${itemPath.substr(2)}` + } + } + // Different drive + else { + return `${itemPath[0]}:\\${itemPath.substr(2)}` + } + } + // Check for itemPath like \ or \foo + else if (normalizeSeparators(itemPath).match(/^\\$|^\\[^\\]/)) { + const cwd = process.cwd() + assert( + cwd.match(/^[A-Z]:\\/i), + `Expected current directory to start with an absolute drive root. Actual '${cwd}'` + ) + + return `${cwd[0]}:\\${itemPath.substr(1)}` + } + } + + assert( + hasAbsoluteRoot(root), + `ensureAbsoluteRoot parameter 'root' must have an absolute root` + ) + + // Otherwise ensure root ends with a separator + if (root.endsWith('/') || (IS_WINDOWS && root.endsWith('\\'))) { + // Intentionally empty + } else { + // Append separator + root += path.sep + } + + return root + itemPath +} + +/** + * On Linux/macOS, true if path starts with `/`. On Windows, true for paths like: + * `\\hello\share` and `C:\hello` (and using alternate separator). + */ +export function hasAbsoluteRoot(itemPath: string): boolean { + assert(itemPath, `hasAbsoluteRoot parameter 'itemPath' must not be empty`) + + // Normalize separators + itemPath = normalizeSeparators(itemPath) + + // Windows + if (IS_WINDOWS) { + // E.g. \\hello\share or C:\hello + return itemPath.startsWith('\\\\') || /^[A-Z]:\\/i.test(itemPath) + } + + // E.g. /hello + return itemPath.startsWith('/') +} + +/** + * On Linux/macOS, true if path starts with `/`. On Windows, true for paths like: + * `\`, `\hello`, `\\hello\share`, `C:`, and `C:\hello` (and using alternate separator). + */ +export function hasRoot(itemPath: string): boolean { + assert(itemPath, `isRooted parameter 'itemPath' must not be empty`) + + // Normalize separators + itemPath = normalizeSeparators(itemPath) + + // Windows + if (IS_WINDOWS) { + // E.g. \ or \hello or \\hello + // E.g. C: or C:\hello + return itemPath.startsWith('\\') || /^[A-Z]:/i.test(itemPath) + } + + // E.g. /hello + return itemPath.startsWith('/') +} + +/** + * Removes redundant slashes and converts `/` to `\` on Windows + */ +export function normalizeSeparators(p: string): string { + p = p || '' + + // Windows + if (IS_WINDOWS) { + // Convert slashes on Windows + p = p.replace(/\//g, '\\') + + // Remove redundant slashes + const isUnc = /^\\\\+[^\\]/.test(p) // e.g. \\hello + return (isUnc ? '\\' : '') + p.replace(/\\\\+/g, '\\') // preserve leading \\ for UNC + } + + // Remove redundant slashes + return p.replace(/\/\/+/g, '/') +} + +/** + * Normalizes the path separators and trims the trailing separator (when safe). + * For example, `/foo/ => /foo` but `/ => /` + */ +export function safeTrimTrailingSeparator(p: string): string { + // Short-circuit if empty + if (!p) { + return '' + } + + // Normalize separators + p = normalizeSeparators(p) + + // No trailing slash + if (!p.endsWith(path.sep)) { + return p + } + + // Check '/' on Linux/macOS and '\' on Windows + if (p === path.sep) { + return p + } + + // On Windows check if drive root. E.g. C:\ + if (IS_WINDOWS && /^[A-Z]:\\$/i.test(p)) { + return p + } + + // Otherwise trim trailing slash + return p.substr(0, p.length - 1) +} diff --git a/packages/glob/src/internal-path.ts b/packages/glob/src/internal-path.ts new file mode 100644 index 00000000..789cb118 --- /dev/null +++ b/packages/glob/src/internal-path.ts @@ -0,0 +1,113 @@ +import * as assert from 'assert' +import * as path from 'path' +import * as pathHelper from './internal-path-helper' + +const IS_WINDOWS = process.platform === 'win32' + +/** + * Helper class for parsing paths into segments + */ +export class Path { + segments: string[] = [] + + /** + * Constructs a Path + * @param itemPath Path or array of segments + */ + constructor(itemPath: string | string[]) { + // String + if (typeof itemPath === 'string') { + assert(itemPath, `Parameter 'itemPath' must not be empty`) + + // Normalize slashes and trim unnecessary trailing slash + itemPath = pathHelper.safeTrimTrailingSeparator(itemPath) + + // Not rooted + if (!pathHelper.hasRoot(itemPath)) { + this.segments = itemPath.split(path.sep) + } + // Rooted + else { + // Add all segments, while not at the root + let remaining = itemPath + let dir = pathHelper.dirname(remaining) + while (dir !== remaining) { + // Add the segment + const basename = path.basename(remaining) + this.segments.unshift(basename) + + // Truncate the last segment + remaining = dir + dir = pathHelper.dirname(remaining) + } + + // Remainder is the root + this.segments.unshift(remaining) + } + } + // Array + else { + // Must not be empty + assert( + itemPath.length > 0, + `Parameter 'itemPath' must not be an empty array` + ) + + // Each segment + for (let i = 0; i < itemPath.length; i++) { + let segment = itemPath[i] + + // Must not be empty + assert( + segment, + `Parameter 'itemPath' must not contain any empty segments` + ) + + // Normalize slashes + segment = pathHelper.normalizeSeparators(itemPath[i]) + + // Root segment + if (i === 0 && pathHelper.hasRoot(segment)) { + segment = pathHelper.safeTrimTrailingSeparator(segment) + assert( + segment === pathHelper.dirname(segment), + `Parameter 'itemPath' root segment contains information for multiple segments` + ) + this.segments.push(segment) + } + // All other segments + else { + // Must not contain slash + assert( + !segment.includes(path.sep), + `Parameter 'itemPath' contains unexpected path separators` + ) + this.segments.push(segment) + } + } + } + } + + /** + * Converts the path to it's string representation + */ + toString(): string { + // First segment + let result = this.segments[0] + + // All others + let skipSlash = + result.endsWith(path.sep) || (IS_WINDOWS && /^[A-Z]:$/i.test(result)) + for (let i = 1; i < this.segments.length; i++) { + if (skipSlash) { + skipSlash = false + } else { + result += path.sep + } + + result += this.segments[i] + } + + return result + } +} diff --git a/packages/glob/src/internal-pattern-helper.ts b/packages/glob/src/internal-pattern-helper.ts new file mode 100644 index 00000000..9ca56b14 --- /dev/null +++ b/packages/glob/src/internal-pattern-helper.ts @@ -0,0 +1,143 @@ +import * as core from '@actions/core' +import * as pathHelper from './internal-path-helper' +import {IGlobOptions} from './internal-glob-options' +import {MatchKind} from './internal-match-kind' +import {Pattern} from './internal-pattern' + +const IS_WINDOWS = process.platform === 'win32' + +/** + * Returns a copy with defaults filled in + */ +export function getOptions(copy?: IGlobOptions): IGlobOptions { + const result: IGlobOptions = { + followSymbolicLinks: true, + implicitDescendants: true, + omitBrokenSymbolicLinks: true + } + + if (copy) { + if (typeof copy.followSymbolicLinks === 'boolean') { + result.followSymbolicLinks = copy.followSymbolicLinks + core.debug(`followSymbolicLinks '${result.followSymbolicLinks}'`) + } + + if (typeof copy.implicitDescendants === 'boolean') { + result.implicitDescendants = copy.implicitDescendants + core.debug(`implicitDescendants '${result.implicitDescendants}'`) + } + + if (typeof copy.omitBrokenSymbolicLinks === 'boolean') { + result.omitBrokenSymbolicLinks = copy.omitBrokenSymbolicLinks + core.debug(`omitBrokenSymbolicLinks '${result.omitBrokenSymbolicLinks}'`) + } + } + + return result +} + +/** + * Given an array of patterns, returns an array of paths to search. + * Duplicates and paths under other included paths are filtered out. + */ +export function getSearchPaths(patterns: Pattern[]): string[] { + // Ignore negate patterns + patterns = patterns.filter(x => !x.negate) + + // Create a map of all search paths + const searchPathMap: {[key: string]: string} = {} + for (const pattern of patterns) { + const key = IS_WINDOWS + ? pattern.searchPath.toUpperCase() + : pattern.searchPath + searchPathMap[key] = 'candidate' + } + + const result: string[] = [] + + for (const pattern of patterns) { + // Check if already included + const key = IS_WINDOWS + ? pattern.searchPath.toUpperCase() + : pattern.searchPath + if (searchPathMap[key] === 'included') { + continue + } + + // Check for an ancestor search path + let foundAncestor = false + let tempKey = key + let parent = pathHelper.dirname(tempKey) + while (parent !== tempKey) { + if (searchPathMap[parent]) { + foundAncestor = true + break + } + + tempKey = parent + parent = pathHelper.dirname(tempKey) + } + + // Include the search pattern in the result + if (!foundAncestor) { + result.push(pattern.searchPath) + searchPathMap[key] = 'included' + } + } + + return result +} + +/** + * Matches the patterns against the path + */ +export function match(patterns: Pattern[], itemPath: string): MatchKind { + let result: MatchKind = MatchKind.None + + for (const pattern of patterns) { + if (pattern.negate) { + result &= ~pattern.match(itemPath) + } else { + result |= pattern.match(itemPath) + } + } + + return result +} + +/** + * Parses the pattern strings into Pattern objects + */ +export function parse(patterns: string[], options: IGlobOptions): Pattern[] { + const result: Pattern[] = [] + + for (const patternString of patterns.map(x => x.trim())) { + // Skip empty or comment + if (!patternString || patternString.startsWith('#')) { + continue + } + + // Push + const pattern = new Pattern(patternString) + result.push(pattern) + + // Implicit descendants? + if ( + options.implicitDescendants && + (pattern.trailingSeparator || + pattern.segments[pattern.segments.length - 1] !== '**') + ) { + // Push + result.push(new Pattern(pattern.negate, pattern.segments.concat('**'))) + } + } + + return result +} + +/** + * Checks whether to descend further into the directory + */ +export function partialMatch(patterns: Pattern[], itemPath: string): boolean { + return patterns.some(x => !x.negate && x.partialMatch(itemPath)) +} diff --git a/packages/glob/src/internal-pattern.ts b/packages/glob/src/internal-pattern.ts new file mode 100644 index 00000000..2735ca73 --- /dev/null +++ b/packages/glob/src/internal-pattern.ts @@ -0,0 +1,321 @@ +import * as assert from 'assert' +import * as os from 'os' +import * as path from 'path' +import * as pathHelper from './internal-path-helper' +import {Minimatch, IMinimatch, IOptions as IMinimatchOptions} from 'minimatch' +import {MatchKind} from './internal-match-kind' +import {Path} from './internal-path' + +const IS_WINDOWS = process.platform === 'win32' + +export class Pattern { + /** + * Indicates whether matches should be excluded from the result set + */ + readonly negate: boolean = false + + /** + * The directory to search. The literal path prior to the first glob segment. + */ + readonly searchPath: string + + /** + * The path/pattern segments. Note, only the first segment (the root directory) + * may contain a directory separator charactor. Use the trailingSeparator field + * to determine whether the pattern ended with a trailing slash. + */ + readonly segments: string[] + + /** + * Indicates the pattern should only match directories, not regular files. + */ + readonly trailingSeparator: boolean + + /** + * The Minimatch object used for matching + */ + private readonly minimatch: IMinimatch + + /** + * Used to workaround a limitation with Minimatch when determining a partial + * match and the path is a root directory. For example, when the pattern is + * `/foo/**` or `C:\foo\**` and the path is `/` or `C:\`. + */ + private readonly rootRegExp: RegExp + + /* eslint-disable no-dupe-class-members */ + // Disable no-dupe-class-members due to false positive for method overload + // https://github.com/typescript-eslint/typescript-eslint/issues/291 + + constructor(pattern: string) + constructor(negate: boolean, segments: string[]) + constructor(patternOrNegate: string | boolean, segments?: string[]) { + // Pattern overload + let pattern: string + if (typeof patternOrNegate === 'string') { + pattern = patternOrNegate.trim() + } + // Segments overload + else { + // Convert to pattern + segments = segments || [] + assert(segments.length, `Parameter 'segments' must not empty`) + const root = Pattern.getLiteral(segments[0]) + assert( + root && pathHelper.hasAbsoluteRoot(root), + `Parameter 'segments' first element must be a root path` + ) + pattern = new Path(segments).toString().trim() + if (patternOrNegate) { + pattern = `!${pattern}` + } + } + + // Negate + while (pattern.startsWith('!')) { + this.negate = !this.negate + pattern = pattern.substr(1).trim() + } + + // Normalize slashes and ensures absolute root + pattern = Pattern.fixupPattern(pattern) + + // Segments + this.segments = new Path(pattern).segments + + // Trailing slash indicates the pattern should only match directories, not regular files + this.trailingSeparator = pathHelper + .normalizeSeparators(pattern) + .endsWith(path.sep) + pattern = pathHelper.safeTrimTrailingSeparator(pattern) + + // Search path (literal path prior to the first glob segment) + let foundGlob = false + const searchSegments = this.segments + .map(x => Pattern.getLiteral(x)) + .filter(x => !foundGlob && !(foundGlob = x === '')) + this.searchPath = new Path(searchSegments).toString() + + // Root RegExp (required when determining partial match) + this.rootRegExp = new RegExp( + Pattern.regExpEscape(searchSegments[0]), + IS_WINDOWS ? 'i' : '' + ) + + // Create minimatch + const minimatchOptions: IMinimatchOptions = { + dot: true, + nobrace: true, + nocase: IS_WINDOWS, + nocomment: true, + noext: true, + nonegate: true + } + pattern = IS_WINDOWS ? pattern.replace(/\\/g, '/') : pattern + this.minimatch = new Minimatch(pattern, minimatchOptions) + } + + /** + * Matches the pattern against the specified path + */ + match(itemPath: string): MatchKind { + // Last segment is globstar? + if (this.segments[this.segments.length - 1] === '**') { + // Normalize slashes + itemPath = pathHelper.normalizeSeparators(itemPath) + + // Append a trailing slash. Otherwise Minimatch will not match the directory immediately + // preceeding the globstar. For example, given the pattern `/foo/**`, Minimatch returns + // false for `/foo` but returns true for `/foo/`. Append a trailing slash to handle that quirk. + if (!itemPath.endsWith(path.sep)) { + // Note, this is safe because the constructor ensures the pattern has an absolute root. + // For example, formats like C: and C:foo on Windows are resolved to an aboslute root. + itemPath = `${itemPath}${path.sep}` + } + } else { + // Normalize slashes and trim unnecessary trailing slash + itemPath = pathHelper.safeTrimTrailingSeparator(itemPath) + } + + // Match + if (this.minimatch.match(itemPath)) { + return this.trailingSeparator ? MatchKind.Directory : MatchKind.All + } + + return MatchKind.None + } + + /** + * Indicates whether the pattern may match descendants of the specified path + */ + partialMatch(itemPath: string): boolean { + // Normalize slashes and trim unnecessary trailing slash + itemPath = pathHelper.safeTrimTrailingSeparator(itemPath) + + // matchOne does not handle root path correctly + if (pathHelper.dirname(itemPath) === itemPath) { + return this.rootRegExp.test(itemPath) + } + + return this.minimatch.matchOne( + itemPath.split(IS_WINDOWS ? /\\+/ : /\/+/), + this.minimatch.set[0], + true + ) + } + + /** + * Escapes glob patterns within a path + */ + static globEscape(s: string): string { + return (IS_WINDOWS ? s : s.replace(/\\/g, '\\\\')) // escape '\' on Linux/macOS + .replace(/(\[)(?=[^/]+\])/g, '[[]') // escape '[' when ']' follows within the path segment + .replace(/\?/g, '[?]') // escape '?' + .replace(/\*/g, '[*]') // escape '*' + } + + /** + * Normalizes slashes and ensures absolute root + */ + private static fixupPattern(pattern: string): string { + // Empty + assert(pattern, 'pattern cannot be empty') + + // Must not contain `.` segment, unless first segment + // Must not contain `..` segment + const literalSegments = new Path(pattern).segments.map(x => + Pattern.getLiteral(x) + ) + assert( + literalSegments.every((x, i) => (x !== '.' || i === 0) && x !== '..'), + `Invalid pattern '${pattern}'. Relative pathing '.' and '..' is not allowed.` + ) + + // Must not contain globs in root, e.g. Windows UNC path \\foo\b*r + assert( + !pathHelper.hasRoot(pattern) || literalSegments[0], + `Invalid pattern '${pattern}'. Root segment must not contain globs.` + ) + + // Normalize slashes + pattern = pathHelper.normalizeSeparators(pattern) + + // Replace leading `.` segment + if (pattern === '.' || pattern.startsWith(`.${path.sep}`)) { + pattern = Pattern.globEscape(process.cwd()) + pattern.substr(1) + } + // Replace leading `~` segment + else if (pattern === '~' || pattern.startsWith(`~${path.sep}`)) { + const homedir = os.homedir() + assert(homedir, 'Unable to determine HOME directory') + assert( + pathHelper.hasAbsoluteRoot(homedir), + `Expected HOME directory to be a rooted path. Actual '${homedir}'` + ) + pattern = Pattern.globEscape(homedir) + pattern.substr(1) + } + // Replace relative drive root, e.g. pattern is C: or C:foo + else if ( + IS_WINDOWS && + (pattern.match(/^[A-Z]:$/i) || pattern.match(/^[A-Z]:[^\\]/i)) + ) { + let root = pathHelper.ensureAbsoluteRoot( + 'C:\\dummy-root', + pattern.substr(0, 2) + ) + if (pattern.length > 2 && !root.endsWith('\\')) { + root += '\\' + } + pattern = Pattern.globEscape(root) + pattern.substr(2) + } + // Replace relative root, e.g. pattern is \ or \foo + else if (IS_WINDOWS && (pattern === '\\' || pattern.match(/^\\[^\\]/))) { + let root = pathHelper.ensureAbsoluteRoot('C:\\dummy-root', '\\') + if (!root.endsWith('\\')) { + root += '\\' + } + pattern = Pattern.globEscape(root) + pattern.substr(1) + } + // Otherwise ensure absolute root + else { + pattern = pathHelper.ensureAbsoluteRoot( + Pattern.globEscape(process.cwd()), + pattern + ) + } + + return pathHelper.normalizeSeparators(pattern) + } + + /** + * Attempts to unescape a pattern segment to create a literal path segment. + * Otherwise returns empty string. + */ + private static getLiteral(segment: string): string { + let literal = '' + for (let i = 0; i < segment.length; i++) { + const c = segment[i] + // Escape + if (c === '\\' && !IS_WINDOWS && i + 1 < segment.length) { + literal += segment[++i] + continue + } + // Wildcard + else if (c === '*' || c === '?') { + return '' + } + // Character set + else if (c === '[' && i + 1 < segment.length) { + let set = '' + let closed = -1 + for (let i2 = i + 1; i2 < segment.length; i2++) { + const c2 = segment[i2] + // Escape + if (c2 === '\\' && !IS_WINDOWS && i2 + 1 < segment.length) { + set += segment[++i2] + continue + } + // Closed + else if (c2 === ']') { + closed = i2 + break + } + // Otherwise + else { + set += c2 + } + } + + // Closed? + if (closed >= 0) { + // Cannot convert + if (set.length > 1) { + return '' + } + + // Convert to literal + if (set) { + literal += set + i = closed + continue + } + } + + // Otherwise fall thru + } + + // Append + literal += c + } + + return literal + } + + /** + * Escapes regexp special characters + * https://javascript.info/regexp-escaping + */ + private static regExpEscape(s: string): string { + return s.replace(/[[\\^$.|?*+()]/g, '\\$&') + } +} diff --git a/packages/glob/src/internal-search-state.ts b/packages/glob/src/internal-search-state.ts new file mode 100644 index 00000000..590db3d8 --- /dev/null +++ b/packages/glob/src/internal-search-state.ts @@ -0,0 +1,9 @@ +export class SearchState { + readonly path: string + readonly level: number + + constructor(path: string, level: number) { + this.path = path + this.level = level + } +} diff --git a/packages/glob/tsconfig.json b/packages/glob/tsconfig.json new file mode 100644 index 00000000..a8b812a6 --- /dev/null +++ b/packages/glob/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./lib", + "rootDir": "./src" + }, + "include": [ + "./src" + ] +} \ No newline at end of file