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

244 lines
6.5 KiB
TypeScript

import * as core from '@actions/core'
import * as fs from 'fs'
import * as globOptionsHelper from './internal-glob-options-helper'
import * as path from 'path'
import * as patternHelper from './internal-pattern-helper'
import {GlobOptions} from './internal-glob-options'
import {MatchKind} from './internal-match-kind'
import {Pattern} from './internal-pattern'
import {SearchState} from './internal-search-state'
const IS_WINDOWS = process.platform === 'win32'
export {GlobOptions}
/**
* Used to match files and directories
*/
export interface Globber {
/**
* Returns the search path preceding the first glob segment, from each pattern.
* Duplicates and descendants of other paths are filtered out.
*
* Example 1: The patterns `/foo/*` and `/bar/*` returns `/foo` and `/bar`.
*
* Example 2: The patterns `/foo/*` and `/foo/bar/*` returns `/foo`.
*/
getSearchPaths(): string[]
/**
* Returns files and directories matching the glob patterns.
*
* Order of the results is not guaranteed.
*/
glob(): Promise<string[]>
/**
* Returns files and directories matching the glob patterns.
*
* Order of the results is not guaranteed.
*/
globGenerator(): AsyncGenerator<string, void>
}
export class DefaultGlobber implements Globber {
private readonly options: GlobOptions
private readonly patterns: Pattern[] = []
private readonly searchPaths: string[] = []
private constructor(options?: GlobOptions) {
this.options = globOptionsHelper.getOptions(options)
}
getSearchPaths(): string[] {
// Return a copy
return this.searchPaths.slice()
}
async glob(): Promise<string[]> {
const result: string[] = []
for await (const itemPath of this.globGenerator()) {
result.push(itemPath)
}
return result
}
async *globGenerator(): AsyncGenerator<string, void> {
// Fill in defaults options
const options = globOptionsHelper.getOptions(this.options)
// Implicit descendants?
const patterns: Pattern[] = []
for (const pattern of this.patterns) {
patterns.push(pattern)
if (
options.implicitDescendants &&
(pattern.trailingSeparator ||
pattern.segments[pattern.segments.length - 1] !== '**')
) {
patterns.push(
new Pattern(pattern.negate, true, pattern.segments.concat('**'))
)
}
}
// 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 DefaultGlobber.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 && options.matchDirectories) {
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
}
}
}
/**
* Constructs a DefaultGlobber
*/
static async create(
patterns: string,
options?: GlobOptions
): Promise<DefaultGlobber> {
const result = new DefaultGlobber(options)
if (IS_WINDOWS) {
patterns = patterns.replace(/\r\n/g, '\n')
patterns = patterns.replace(/\r/g, '\n')
}
const lines = patterns.split('\n').map(x => x.trim())
for (const line of lines) {
// Empty or comment
if (!line || line.startsWith('#')) {
continue
}
// Pattern
else {
result.patterns.push(new Pattern(line))
}
}
result.searchPaths.push(...patternHelper.getSearchPaths(result.patterns))
return result
}
private static async stat(
item: SearchState,
options: GlobOptions,
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
}
}