1
0
Fork 0
toolkit/packages/glob/src/internal-pattern.ts

341 lines
10 KiB
TypeScript

import * as os from 'os'
import * as path from 'path'
import * as pathHelper from './internal-path-helper'
import assert from 'assert'
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 character. 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
/**
* Indicates that the pattern is implicitly added as opposed to user specified.
*/
private readonly isImplicitPattern: boolean
constructor(pattern: string)
constructor(
pattern: string,
isImplicitPattern: boolean,
segments: undefined,
homedir: string
)
constructor(
negate: boolean,
isImplicitPattern: boolean,
segments: string[],
homedir?: string
)
constructor(
patternOrNegate: string | boolean,
isImplicitPattern = false,
segments?: string[],
homedir?: 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, homedir)
// 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' : ''
)
this.isImplicitPattern = isImplicitPattern
// 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
// preceding 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) && this.isImplicitPattern === false) {
// 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 absolute 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, homedir?: 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}`)) {
homedir = 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, '\\$&')
}
}