mirror of https://github.com/actions/toolkit
341 lines
10 KiB
TypeScript
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, '\\$&')
|
|
}
|
|
}
|