From 2f43744470ba3a626b4a73fcef6eb5d6f7b8d03b Mon Sep 17 00:00:00 2001 From: Andy Edwards Date: Fri, 9 Apr 2021 21:16:36 -0500 Subject: [PATCH] feat: add init command, find-only cli output --- package.json | 1 + src/cli/index.ts | 139 +---------------------- src/cli/init.ts | 72 ++++++++++++ src/cli/transform.ts | 225 ++++++++++++++++++++++++++++++++++++++ src/runTransformOnFile.ts | 28 +++-- yarn.lock | 5 + 6 files changed, 322 insertions(+), 148 deletions(-) create mode 100644 src/cli/init.ts create mode 100644 src/cli/transform.ts diff --git a/package.json b/package.json index e816d95..b54b236 100644 --- a/package.json +++ b/package.json @@ -152,6 +152,7 @@ "babel-parse-wild-code": "^1.1.1", "chalk": "^4.1.0", "debug": "^4.3.1", + "dedent-js": "^1.0.1", "diff": "^5.0.0", "fs-extra": "^9.0.1", "glob-gitignore": "^1.0.14", diff --git a/src/cli/index.ts b/src/cli/index.ts index c2721b0..09bc295 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -2,141 +2,8 @@ /* eslint-disable no-console */ -import chalk from 'chalk' -import path from 'path' -import fs from 'fs-extra' import yargs from 'yargs' -import inquirer from 'inquirer' -import isEmpty from 'lodash/isEmpty' -import runTransform from '../runTransform' -import { Transform } from '../runTransformOnFile' -import formatDiff from '../util/formatDiff' +import transform from './transform' +import init from './init' -const argv = yargs - .option('transform', { - alias: 't', - describe: 'path to the transform file. Can be either a local path or url', - }) - .options('parser', { - describe: 'parser to use', - type: 'string', - }) - .option('find', { - alias: 'f', - describe: 'search pattern', - type: 'string', - }) - .option('replace', { - alias: 'r', - describe: 'replace pattern', - type: 'string', - }).usage(`Usage: - -$0 -f -r [] [] - - Quick search and replace in the given files and directories - (make sure to quote code) - - Example: - - astx -f 'rmdir($path, $force)' -r 'rmdir($path, { force: $force })' src - -$0 -t [] [] - - Applies a transform file to the given files and directories - -$0 [] [] - - Applies the default transform file (astx.js in working directory) - to the given files and directories -`).argv - -const paths = argv._.filter((x) => typeof x === 'string') as string[] -if (!paths.length) { - yargs.showHelp() - process.exit(1) -} - -function getTransform(): Transform { - const { transform, find, replace, parser }: any = argv - // eslint-disable-next-line @typescript-eslint/no-var-requires - if (transform) return require(path.resolve(transform)) - if (find && replace) { - const getOpt = (regex: RegExp): string | undefined => { - const index = process.argv.findIndex((a) => regex.test(a)) - return index >= 0 ? process.argv[index + 1] : undefined - } - // yargs Eats quotes, not cool... - const find = getOpt(/^(-f|--find)$/) - const replace = getOpt(/^(-r|--replace)$/) - return { find, replace, parser } - } - // eslint-disable-next-line @typescript-eslint/no-var-requires - return require(path.resolve('./astx.js')) -} - -const transform: Transform = getTransform() - -async function go() { - const results: Record = {} - for await (const { - file, - source, - transformed, - reports, - error, - } of runTransform(transform, { - paths, - })) { - if (error) { - console.error( - chalk.blue(` -========================================== -${file} -========================================== -`) - ) - console.error(chalk.red(error.stack)) - } else if (source && transformed && source !== transformed) { - results[file] = transformed - console.error( - chalk.blue(` -========================================== -${file} -========================================== -`) - ) - console.log(formatDiff(source, transformed)) - } else { - console.error(chalk.yellow(`no changes: ${file}`)) - } - - if (reports?.length) { - console.error(chalk.blue` -Reports -------- -`) - reports?.forEach((r) => console.error(...r)) - } - } - - if (!isEmpty(results)) { - const { apply } = await inquirer.prompt([ - { - type: 'confirm', - name: 'apply', - message: 'Apply changes', - default: false, - }, - ]) - if (apply) { - for (const file in results) { - await fs.writeFile(file, results[file], 'utf8') - console.error(`Wrote ${file}`) - } - } - if (process.send) process.send({ exit: 0 }) - } -} - -go() +yargs.command(transform).command(init).argv diff --git a/src/cli/init.ts b/src/cli/init.ts new file mode 100644 index 0000000..cd57cf2 --- /dev/null +++ b/src/cli/init.ts @@ -0,0 +1,72 @@ +import { Arguments, Argv, CommandModule } from 'yargs' +import fs from 'fs-extra' +import dedent from 'dedent-js' +import inquirer from 'inquirer' + +/* eslint-disable no-console */ + +type Options = { + file?: string + style?: string +} + +const init: CommandModule = { + command: 'init [file]', + describe: 'create a transform file', + builder: (yargs: Argv) => + yargs + .positional('file', { + describe: `name of the transform file to create`, + type: 'string', + default: 'astx.js', + }) + .option('style', { + alias: 's', + type: 'string', + choices: ['find-replace', 'function'], + }), + handler: async ({ + file = 'astx.js', + style, + }: Arguments): Promise => { + if (await fs.pathExists(file)) { + console.error( + `Path already exists: ${/^[./]/.test(file) ? file : './' + file}` + ) + process.exit(1) + } + if (!style) { + ;({ style } = await inquirer.prompt([ + { + name: 'style', + type: 'list', + choices: ['find-replace', 'function'], + }, + ])) + } + const content = + style === 'function' + ? dedent` + exports.astx = ({ astx, j, root, expression, statement, statements }) => { + // example: astx.find\`$foo\`.replace\`$foo\` + } + ` + : dedent` + exports.find = \` + + \` + + exports.where = { + + } + + exports.replace = \` + + \` + ` + await fs.writeFile(file, content, 'utf8') + console.error(`Wrote ${/^[./]/.test(file) ? file : './' + file}`) + }, +} + +export default init diff --git a/src/cli/transform.ts b/src/cli/transform.ts new file mode 100644 index 0000000..e611bde --- /dev/null +++ b/src/cli/transform.ts @@ -0,0 +1,225 @@ +import yargs, { Arguments, Argv, CommandModule } from 'yargs' +import { Transform } from '../runTransformOnFile' +import path from 'path' +import runTransform from '../runTransform' +import chalk from 'chalk' +import formatDiff from '../util/formatDiff' +import isEmpty from 'lodash/isEmpty' +import inquirer from 'inquirer' +import fs from 'fs-extra' + +import dedent from 'dedent-js' + +import { Match } from '../find' + +/* eslint-disable no-console */ + +type Options = { + transform?: string + parser?: string + find?: string + replace?: string + filesAndDirectories?: string[] +} + +const transform: CommandModule = { + command: '$0 [filesAndDirectories..]', + describe: 'apply a transform to the given files and directories', + builder: (yargs: Argv) => + yargs + .positional('filesAndDirectories', { + type: 'string', + array: true, + }) + .option('transform', { + alias: 't', + describe: `path to the transform file. Can be either a local path or url. Defaults to ./astx.js if --find isn't given`, + }) + .options('parser', { + describe: 'parser to use', + type: 'string', + }) + .option('find', { + alias: 'f', + describe: 'search pattern', + type: 'string', + }) + .option('replace', { + alias: 'r', + describe: 'replace pattern', + type: 'string', + }), + + handler: async (argv: Arguments) => { + const paths = (argv.filesAndDirectories || []).filter( + (x) => typeof x === 'string' + ) as string[] + if (!paths.length) { + yargs.showHelp() + process.exit(1) + } + + function getTransform(): Transform { + const { transform, find, parser }: any = argv + // eslint-disable-next-line @typescript-eslint/no-var-requires + if (transform) return require(path.resolve(transform)) + if (find) { + const getOpt = (regex: RegExp): string | undefined => { + const index = process.argv.findIndex((a) => regex.test(a)) + return index >= 0 ? process.argv[index + 1] : undefined + } + // yargs Eats quotes, not cool... + const find = getOpt(/^(-f|--find)$/) + const replace = getOpt(/^(-r|--replace)$/) + return { find, replace, parser } + } + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require(path.resolve('./astx.js')) + } + + const transform: Transform = getTransform() + + const results: Record = {} + let errorCount = 0 + let changedCount = 0 + let unchangedCount = 0 + for await (const { + file, + source, + transformed, + reports, + error, + matches, + } of runTransform(transform, { + paths, + })) { + const logHeader = (logFn: (value: string) => any) => + logFn( + chalk.blue(dedent` + ========================================== + ${file} + ========================================== + `) + ) + + if (error) { + errorCount++ + logHeader(console.error) + console.error(chalk.red(error.stack)) + } else if (source && transformed && source !== transformed) { + changedCount++ + results[file] = transformed + logHeader(console.error) + console.log(formatDiff(source, transformed)) + } else if (matches?.length && source) { + logHeader(console.log) + const lineCount = countLines(source) + for (let i = 0; i < matches.length; i++) { + const match = matches[i] + switch (match.type) { + case 'node': { + if (i > 0) + console.log(' '.repeat(String(lineCount).length + 1) + '|') + console.log(formatNodeMatch(source, lineCount, match)) + break + } + case 'statements': { + break + } + } + } + } else { + unchangedCount++ + } + + if (reports?.length) { + console.error( + chalk.blue(dedent` + Reports + ------- + `) + ) + reports?.forEach((r) => console.error(...r)) + } + } + + if (transform.replace || transform.astx) { + console.error( + chalk.yellow( + `${changedCount} file${changedCount === 1 ? '' : 's'} changed` + ) + ) + console.error( + chalk.green( + `${unchangedCount} file${unchangedCount === 1 ? '' : 's'} unchanged` + ) + ) + if (errorCount > 0) { + console.error( + chalk.red(`${errorCount} file${errorCount === 1 ? '' : 's'} errored`) + ) + } + } + + if (!isEmpty(results)) { + const { apply } = await inquirer.prompt([ + { + type: 'confirm', + name: 'apply', + message: 'Apply changes', + default: false, + }, + ]) + if (apply) { + for (const file in results) { + await fs.writeFile(file, results[file], 'utf8') + console.error(`Wrote ${file}`) + } + } + if (process.send) process.send({ exit: 0 }) + } + }, +} + +export default transform + +function countLines(source: string): number { + if (!source) return 0 + let lines = 1 + const eolRegex = /\r\n?|\n/gm + while (eolRegex.exec(source)) lines++ + return lines +} + +function formatNodeMatch( + source: string, + lineCount: number, + match: Match +): string { + const { + start: nodeStart, + end: nodeEnd, + loc: { + start: { line: startLine, column: startCol }, + }, + } = match.node + const start = nodeStart - startCol + const eolRegex = /\r\n?|\n/gm + eolRegex.lastIndex = nodeEnd + const eolMatch = eolRegex.exec(source) + const end = eolMatch ? eolMatch.index : nodeEnd + + const bolded = + source.substring(start, nodeStart) + + chalk.bold(source.substring(nodeStart, nodeEnd)) + + source.substring(nodeEnd, end) + + const lines = bolded.split(/\r\n?|\n/gm) + + const lineNumberLength = String(lineCount).length + + let line = startLine + return lines + .map((l) => `${String(line++).padStart(lineNumberLength, ' ')} | ${l}`) + .join('\n') +} diff --git a/src/runTransformOnFile.ts b/src/runTransformOnFile.ts index 48a687c..db14bcc 100644 --- a/src/runTransformOnFile.ts +++ b/src/runTransformOnFile.ts @@ -8,7 +8,7 @@ import jscodeshift, { } from 'jscodeshift' import { getParserAsync } from 'babel-parse-wild-code' import { ReplaceOptions } from './replace' -import Astx, { GetReplacement } from './Astx' +import Astx, { GetReplacement, StatementsMatchArray, MatchArray } from './Astx' import fs from 'fs-extra' import Path from 'path' import memoize from 'lodash/memoize' @@ -48,6 +48,7 @@ export type TransformResult = { transformed?: string reports?: any[] error?: Error + matches?: MatchArray | StatementsMatchArray } const getPrettier = memoize( @@ -82,17 +83,19 @@ export const runTransformOnFile = (transform: Transform) => async ( let transformed const reports: any[] = [] - if ( - typeof transform.astx !== 'function' && - transform.find && - transform.replace - ) { - transform.astx = ({ astx }) => - astx - .findAuto(transform.find as any, { where: transform.where }) - .replace(transform.replace as any) + let matches: MatchArray | StatementsMatchArray | undefined + + let transformFn = transform.astx + + if (typeof transformFn !== 'function' && transform.find) { + transformFn = ({ astx }) => { + matches = astx.findAuto(transform.find as any, { + where: transform.where, + }) + if (transform.replace) matches.replace(transform.replace as any) + } } - if (typeof transform.astx === 'function') { + if (typeof transformFn === 'function') { const root = j(source) const options = { source, @@ -107,7 +110,7 @@ export const runTransformOnFile = (transform: Transform) => async ( astx: new Astx(j, root), } const [_result, prettier] = await Promise.all([ - transform.astx(options), + transformFn(options), getPrettier(Path.dirname(file)), ]) transformed = _result @@ -135,6 +138,7 @@ export const runTransformOnFile = (transform: Transform) => async ( source, transformed, reports, + matches, } } catch (error) { return { diff --git a/yarn.lock b/yarn.lock index 3650394..eeba479 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3115,6 +3115,11 @@ decode-uri-component@^0.2.0: resolved "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= +dedent-js@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/dedent-js/-/dedent-js-1.0.1.tgz#bee5fb7c9e727d85dffa24590d10ec1ab1255305" + integrity sha1-vuX7fJ5yfYXf+iRZDRDsGrElUwU= + dedent@^0.7.0: version "0.7.0" resolved "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c"