mirror of
https://git.mirrors.martin98.com/https://github.com/actions/toolkit
synced 2026-04-06 04:23:16 +08:00
glob (#268)
This commit is contained in:
183
packages/glob/src/glob.ts
Normal file
183
packages/glob/src/glob.ts
Normal file
@@ -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<string[]> {
|
||||
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<string, void> {
|
||||
// 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<fs.Stats | undefined> {
|
||||
// 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
|
||||
}
|
||||
28
packages/glob/src/internal-glob-options.ts
Normal file
28
packages/glob/src/internal-glob-options.ts
Normal file
@@ -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
|
||||
}
|
||||
16
packages/glob/src/internal-match-kind.ts
Normal file
16
packages/glob/src/internal-match-kind.ts
Normal file
@@ -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
|
||||
}
|
||||
206
packages/glob/src/internal-path-helper.ts
Normal file
206
packages/glob/src/internal-path-helper.ts
Normal file
@@ -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)
|
||||
}
|
||||
113
packages/glob/src/internal-path.ts
Normal file
113
packages/glob/src/internal-path.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
143
packages/glob/src/internal-pattern-helper.ts
Normal file
143
packages/glob/src/internal-pattern-helper.ts
Normal file
@@ -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))
|
||||
}
|
||||
321
packages/glob/src/internal-pattern.ts
Normal file
321
packages/glob/src/internal-pattern.ts
Normal file
@@ -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, '\\$&')
|
||||
}
|
||||
}
|
||||
9
packages/glob/src/internal-search-state.ts
Normal file
9
packages/glob/src/internal-search-state.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export class SearchState {
|
||||
readonly path: string
|
||||
readonly level: number
|
||||
|
||||
constructor(path: string, level: number) {
|
||||
this.path = path
|
||||
this.level = level
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user