UNPKG

@atomist/automation-client

Version:

Atomist API for software low-level client

528 lines (488 loc) • 20.4 kB
import { defineDynamicProperties, evaluateExpression, FunctionRegistry, isSuccessResult, isUnionPathExpression, PathExpression, stringify, toPathExpression, TreeNode, } from "@atomist/tree-path"; import * as _ from "lodash"; import { logger } from "../../util/logger"; import { Predicate } from "@atomist/tree-path/lib/path/pathExpression"; import { AttributeEqualityPredicate, NestedPathExpressionPredicate, } from "@atomist/tree-path/lib/path/predicates"; import { File } from "../../project/File"; import { retrieveOrCompute } from "../../project/HasCache"; import { Project, ProjectAsync, } from "../../project/Project"; import { fileIterator, gatherFromFiles, GlobOptions, } from "../../project/util/projectUtils"; import { toSourceLocation } from "../../project/util/sourceLocationUtils"; import { LocatedTreeNode } from "../LocatedTreeNode"; import { FileHit, MatchResult, NodeReplacementOptions, } from "./FileHits"; import { FileParser, isFileParser, } from "./FileParser"; import { FileParserRegistry } from "./FileParserRegistry"; /** * Create a MatchTester to use against this file, caching * any parsed or otherwise computed resources. */ export type MatchTesterMaker = (f: File) => Promise<MatchTester>; /** * Options for performing a path expression query against a project */ export interface PathExpressionQueryOptions { /** * Parser or parsers to use to parse files */ parseWith: FileParser | FileParserRegistry; /** * Glob pattern or patterns describing files to match */ globPatterns: GlobOptions; /** * Path expression to execute */ pathExpression: string | PathExpression; /** functionRegistry registry to look for path expression functions in */ functionRegistry?: FunctionRegistry; /** * Optional filter to exclude files before parse phase. This can increase efficiency * by preventing unnecessary parsing. */ fileFilter?: (f: File) => Promise<boolean>; /** * Optionally return a tester for matches in this file, testing the node backing the match. * This can be useful for contextual testing: E.g. testing if one match is within another. * @param {File} f * @return {Promise<MatchTester>} */ testWith?: MatchTesterMaker; /** * Whether to cache the AST for the given parser for this file. * Caching will occur unless this property is explicitly set to false. * Disable caching if many files are likely to be parsed as one offs. */ cacheAst?: boolean; } /** * Tests matches within this file, using their tree node basis. */ export type MatchTester = (n: LocatedTreeNode) => boolean; /** * Integrate path expressions with project operations to find all matches * @param p project * @param parseWith parser or parsers to use to parse files * @param globPatterns file glob patterns * @param pathExpression path expression string or parsed * @param functionRegistry registry to look for path expression functions in * @return {Promise<MatchResult[]>} matches * @type T match type to mix in * @deprecated use matches */ export async function findMatches<T>(p: ProjectAsync, parseWith: FileParser | FileParserRegistry, globPatterns: GlobOptions, pathExpression: string | PathExpression, functionRegistry?: FunctionRegistry): Promise<Array<MatchResult & T>> { const fileHits = await fileMatches(p, { parseWith, globPatterns, pathExpression, functionRegistry }); return _.flatten(fileHits.map(f => f.matches as any)); } /** * Integrate path expressions with project operations to find all matches * @param p project * @param peqo query options * @return {Promise<MatchResult[]>} matches * @type T match type to mix in */ export async function matches<T>(p: ProjectAsync, peqo: PathExpressionQueryOptions): Promise<Array<MatchResult & T>> { const fileHits = await fileMatches(p, peqo); return _.flatten(fileHits.map(f => f.matches as any)); } /** * Generator style iteration over matches in a project. * Modifications made to matches will be made on the project. * @param p project * @param opts options for query * @type T match type to mix in */ export async function* matchIterator<T>(p: Project, opts: PathExpressionQueryOptions): AsyncIterable<MatchResult & T> { const fileHits = fileHitIterator(p, opts); for await (const fileHit of fileHits) { try { for (const match of fileHit.matches) { yield match as any; } } finally { // Even if the user jumps out of the generator, ensure that we make the changes to the present file await (p as any).flush(); } } } /** * Integrate path expressions with project operations to gather mapped values from all matches. * Choose the files with globPatterns; choose the portions of code to match with the pathExpression. * Choose what to return based on each match with the mapper function. * Returns all of the values returned by the mapper (except undefined). * @param p project * @param parserOrRegistry parser or parsers to use to parse files * @param globPatterns file glob patterns * @param mapper mapping function from match result to result * @param pathExpression path expression string or parsed * @param functionRegistry registry to look for path expression functions in * @return {Promise<MatchResult[]>} matches * @deprecated use gather */ export async function gatherFromMatches<T>(p: ProjectAsync, parserOrRegistry: FileParser | FileParserRegistry, globPatterns: GlobOptions, pathExpression: string | PathExpression, mapper: (m: MatchResult) => T, functionRegistry?: FunctionRegistry): Promise<T[]> { return gather(p, { parseWith: parserOrRegistry, globPatterns, pathExpression, mapper, functionRegistry }); } /** * Integrate path expressions with project operations to gather mapped values from all matches. * Choose the files with globPatterns; choose the portions of code to match with the pathExpression. * Choose what to return based on each match with the mapper function. * Returns all of the values returned by the mapper (except undefined). * @param p project * @param peqo options * @return {Promise<MatchResult[]>} matches */ export async function gather<T>(p: ProjectAsync, peqo: PathExpressionQueryOptions & { mapper: (m: MatchResult) => T }): Promise<T[]> { const fileHits = await fileMatches(p, peqo); return _.flatten( fileHits.map(f => f.matches.map(peqo.mapper).filter(x => !!x))); } /** * A match within a project, including location information */ export interface Gathered<T> { readonly value: T; readonly file: File; /** * Raw match result */ readonly matchResult: MatchResult; } /** * Like gather but keep location */ export async function gatherWithLocation<T>(p: ProjectAsync, peqo: PathExpressionQueryOptions & { mapper: (m: MatchResult) => T }): Promise<Array<Gathered<T>>> { const fileHits = await fileMatches(p, peqo); const result: Array<Gathered<T>> = []; for (const fileHit of fileHits) { const values = fileHit.matches.map(peqo.mapper).filter(x => !!x); values.forEach((value, index) => result.push({ value, file: fileHit.file, matchResult: fileHit.matches[index] }), ); } return result; } /** * Integrate path expressions with project operations to find all matches * and their files * @param p project * @param parseWith parser or parsers to use to parse files * @param globPatterns file glob patterns * @param pathExpression path expression string or parsed * @param functionRegistry registry to look for path expression functions in * @return hits for each file * @deprecated use fileMatches */ export async function findFileMatches(p: ProjectAsync, parseWith: FileParser | FileParserRegistry, globPatterns: GlobOptions, pathExpression: string | PathExpression, functionRegistry?: FunctionRegistry): Promise<FileHit[]> { return fileMatches(p, { parseWith, globPatterns, pathExpression, functionRegistry }); } /** * Integrate path expressions with project operations to find all matches * and their files * @param p project * @param peqo query options * @return hits for each file */ export async function fileMatches(p: ProjectAsync, peqo: PathExpressionQueryOptions): Promise<FileHit[]> { const parsed: PathExpression = toPathExpression(peqo.pathExpression); const parser = findParser(parsed, peqo.parseWith); if (!parser) { throw new Error(`Cannot find parser for path expression [${peqo.pathExpression}]: Using ${peqo.parseWith}`); } const valuesToCheckFor = literalValues(parsed); const files = await gatherFromFiles(p, peqo.globPatterns, file => parseFile(parser, parsed, peqo.functionRegistry, p, file, valuesToCheckFor, undefined, peqo.cacheAst !== false)); return files.filter(x => !!x); } export interface PathExpressionFileHits { fileHits: FileHit[]; pathExpression: string; } /** * Iterate over provided path expression query options and return * [[FileHit]]s for each path expression. * @param p project * @param peqos Array of query options * @return hits for each file for each query option */ export async function pathExpressionFileMatches(p: ProjectAsync, peqos: PathExpressionQueryOptions[]): Promise<PathExpressionFileHits[]> { const pefhs: PathExpressionFileHits[] = []; const fileCache: Record<string, File> = {}; for (const peqo of peqos) { const parsed: PathExpression = toPathExpression(peqo.pathExpression); const parser = findParser(parsed, peqo.parseWith); if (!parser) { throw new Error(`Cannot find parser for path expression [${peqo.pathExpression}]: Using ${peqo.parseWith}`); } const matchFiles = await p.getFiles(peqo.globPatterns); const checkFiles = matchFiles.map(f => { if (!fileCache[f.path]) { fileCache[f.path] = f; } return fileCache[f.path]; }); const valuesToCheckFor = literalValues(parsed); const files: FileHit[] = []; for (const file of checkFiles) { const hit = await parseFile(parser, parsed, peqo.functionRegistry, p, file, valuesToCheckFor, undefined, peqo.cacheAst !== false); if (hit) { files.push(hit); } } if (files.length > 0) { pefhs.push({ pathExpression: stringify(parsed), fileHits: files, }); } } return pefhs; } /** * Generator style iteration over file matches * @param p project * @param opts options for query */ export async function* fileHitIterator(p: Project, opts: PathExpressionQueryOptions): AsyncIterable<FileHit> { const parsed: PathExpression = toPathExpression(opts.pathExpression); const parser = findParser(parsed, opts.parseWith); if (!parser) { throw new Error(`Cannot find parser for path expression [${opts.pathExpression}]: Using ${opts.parseWith}`); } const valuesToCheckFor = literalValues(parsed); for await (const file of fileIterator(p, opts.globPatterns, opts.fileFilter)) { const fileHit = await parseFile(parser, parsed, opts.functionRegistry, p, file, valuesToCheckFor, opts.testWith, opts.cacheAst !== false); if (!!fileHit) { yield fileHit; } } } async function parseFile(parser: FileParser, pex: PathExpression, functionRegistry: FunctionRegistry, p: ProjectAsync, file: File, valuesToCheckFor: string[], matchTester: MatchTesterMaker, cacheAst: boolean): Promise<FileHit> { // First, apply optimizations if (valuesToCheckFor.length > 0) { const content = await file.getContent(); if (valuesToCheckFor.every(literal => !content.includes(literal))) { return undefined; } } if (!!parser.couldBeMatchesInThisFile && !await parser.couldBeMatchesInThisFile(pex, file)) { // Skip parsing as we know there can never be matches return undefined; } // If we get here, we need to parse the file try { // Use a cached AST if appropriate const topLevelProduction = await retrieveOrCompute(file, `ast_${parser.rootName}`, async f => { const prod = await parser.toAst(f); defineDynamicProperties(prod); return prod; }, cacheAst); logger.debug("Successfully parsed file '%s' to AST with root node named '%s'. Will execute '%s'", file.path, topLevelProduction.$name, stringify(pex)); const fileNode = { path: file.path, name: file.name, $name: file.name, $children: [topLevelProduction], }; const r = evaluateExpression(fileNode, pex, functionRegistry); if (isSuccessResult(r)) { logger.debug("%d matches in file '%s'", r.length, file.path); return fillInSourceLocations(file, r) .then(locatedNodes => { if (matchTester) { return matchTester(file) .then(test => new FileHit(p, file, fileNode, locatedNodes.filter(test))); } return new FileHit(p, file, fileNode, locatedNodes); }); } else { logger.debug("No matches in file '%s'", file.path); return undefined; } } catch (err) { logger.debug("Failed to parse file '%s': %s", file.path, err); return undefined; } } /** * Use file content to fill in LocatedTreeNode.sourceLocation * @param {File} f * @param {TreeNode[]} nodes * @return {Promise<LocatedTreeNode[]>} */ function fillInSourceLocations(f: File, nodes: TreeNode[]): Promise<LocatedTreeNode[]> { if (nodes.length === 0) { // Optimization. // In this case, let's not read the file content and leave source locations undefined return Promise.resolve(nodes as LocatedTreeNode[]); } return f.getContent() .then(content => { nodes.forEach(n => { (n as LocatedTreeNode).sourceLocation = toSourceLocation(f, content, n.$offset); }); return nodes as LocatedTreeNode[]; }); } /** * Convenient method to find all values of matching nodes-- * typically, terminals such as identifiers * @param p project * @param globPatterns file glob pattern * @param parserOrRegistry parser for files * @param pathExpression path expression string or parsed * @param functionRegistry registry to look for path expression functions in * @return {Promise<TreeNode[]>} hit record for each matching file */ export function findValues(p: ProjectAsync, parserOrRegistry: FileParser | FileParserRegistry, globPatterns: GlobOptions, pathExpression: string | PathExpression, functionRegistry?: FunctionRegistry): Promise<string[]> { return fileMatches(p, { parseWith: parserOrRegistry, globPatterns, pathExpression, functionRegistry }) .then(fileHits => _.flatten(fileHits.map(f => f.matches)) .map(m => m.$value)); } /** * Integrate path expressions with project operations to find all matches * of a path expression and zap them. Use with care! * @param p project * @param globPatterns file glob pattern * @param parserOrRegistry parser for files * @param pathExpression path expression string or parsed * @param opts options for handling whitespace * @return {Promise<TreeNode[]>} hit record for each matching file */ export function zapAllMatches<P extends ProjectAsync = ProjectAsync>(p: P, parserOrRegistry: FileParser | FileParserRegistry, globPatterns: GlobOptions, pathExpression: string | PathExpression, opts: NodeReplacementOptions = {}): Promise<P> { return doWithAllMatches(p, parserOrRegistry, globPatterns, pathExpression, m => m.zap(opts)); } /** * Integrate path expressions with project operations to find all matches * of a path expression and perform a mutation on them them. Use with care! * @param p project * @param globPatterns file glob pattern * @param parserOrRegistry parser for files * @param pathExpression path expression string or parsed * @param action what to do with matches * @return {Promise<TreeNode[]>} hit record for each matching file */ export async function doWithAllMatches<P extends ProjectAsync = ProjectAsync>(p: P, parserOrRegistry: FileParser | FileParserRegistry, globPatterns: GlobOptions, pathExpression: string | PathExpression, action: (m: MatchResult) => void): Promise<P> { const fileHits = await fileMatches(p, { parseWith: parserOrRegistry, globPatterns, pathExpression }); fileHits.forEach(fh => applyActionToMatches(fh, action)); return (p as any).flush(); } function applyActionToMatches(fh: FileHit, action: (m: MatchResult) => void): void { // Sort file hits in reverse order so that offsets aren't upset by applications const sorted = fh.matches.sort((m1, m2) => m1.$offset - m2.$offset); sorted.forEach(action); } export function findParser(pathExpression: PathExpression, fp: FileParser | FileParserRegistry): FileParser { if (isFileParser(fp)) { if (!!fp.validate) { fp.validate(pathExpression); } return fp; } else { return fp.parserFor(pathExpression); } } /** * Return literal values that must be present in a file for this path expression to * match. Return the empty array if there are no literal @values or if we cannot * determine whether there may be for this path expression. * @param {PathExpression} pex * @return {string[]} */ export function literalValues(pex: PathExpression): string[] { return _.uniq( allPredicates(pex) .filter(isAttributeEqualityPredicate) .map(p => p.value)); } function allPredicates(pe: PathExpression): Predicate[] { if (isUnionPathExpression(pe)) { return _.flatten(pe.unions.map(allPredicates)); } return _.flatten(pe.locationSteps.map(s => { return _.flatten(s.predicates.map(p => { if (isNestedPredicate(p)) { return allPredicates(p.pathExpression); } else { return [p]; } })); })); } function isAttributeEqualityPredicate(p: Predicate): p is AttributeEqualityPredicate { const maybe = p as AttributeEqualityPredicate; return !!maybe.value; } function isNestedPredicate(p: Predicate): p is NestedPathExpressionPredicate { const maybe = p as NestedPathExpressionPredicate; return !!maybe.pathExpression; }