diff --git a/bin/jscodeshift.sh b/bin/jscodeshift.sh index 6840f91b..c34460e6 100755 --- a/bin/jscodeshift.sh +++ b/bin/jscodeshift.sh @@ -15,83 +15,80 @@ const Runner = require('../src/Runner.js'); const path = require('path'); const pkg = require('../package.json'); -const opts = require('nomnom') - .script('jscodeshift') +const parser = require('../src/argsParser') .options({ - path: { - position: 0, - help: 'Files or directory to transform', - list: true, - metavar: 'FILE', - required: true - }, transform: { abbr: 't', default: './transform.js', - help: 'Path to the transform file. Can be either a local path or url', - metavar: 'FILE' + help: 'path to the transform file. Can be either a local path or url', + metavar: 'FILE', + required: true }, cpus: { abbr: 'c', - help: '(all by default) Determines the number of processes started.' + help: 'start at most N child processes to process source files', + defaultHelp: 'max(all - 1, 1)', + metavar: 'N', }, verbose: { abbr: 'v', choices: [0, 1, 2], default: 0, - help: 'Show more information about the transform process' + help: 'show more information about the transform process', + metavar: 'N', }, dry: { abbr: 'd', flag: true, - help: 'Dry run (no changes are made to files)' + default: false, + help: 'dry run (no changes are made to files)' }, print: { abbr: 'p', flag: true, - help: 'Print output, useful for development' + default: false, + help: 'print transformed files to stdout, useful for development' }, babel: { flag: true, default: true, - help: 'Apply Babel to transform files' + help: 'apply babeljs to the transform file' }, extensions: { default: 'js', - help: 'File extensions the transform file should be applied to' + help: 'transform files with these file extensions (comma separated list)', + metavar: 'EXT', }, ignorePattern: { full: 'ignore-pattern', list: true, - help: 'Ignore files that match a provided glob expression' + help: 'ignore files that match a provided glob expression', + metavar: 'GLOB', }, ignoreConfig: { full: 'ignore-config', list: true, - help: 'Ignore files if they match patterns sourced from a configuration file (e.g., a .gitignore)', + help: 'ignore files if they match patterns sourced from a configuration file (e.g. a .gitignore)', metavar: 'FILE' }, runInBand: { flag: true, default: false, full: 'run-in-band', - help: 'Run serially in the current process' + help: 'run serially in the current process' }, silent: { abbr: 's', flag: true, default: false, - full: 'silent', - help: 'No output' + help: 'do not write to stdout or stderr' }, parser: { choices: ['babel', 'babylon', 'flow', 'ts', 'tsx'], default: 'babel', - full: 'parser', - help: 'The parser to use for parsing your source files (babel | babylon | flow | ts | tsx)' + help: 'the parser to use for parsing the source files' }, version: { - flag: true, help: 'print version and exit', callback: function() { const requirePackage = require('../utils/requirePackage'); @@ -100,15 +97,31 @@ const opts = require('nomnom') ` - babel: ${require('babel-core').version}`, ` - babylon: ${requirePackage('@babel/parser').version}`, ` - flow: ${requirePackage('flow-parser').version}`, - ` - recast: ${requirePackage('recast').version}`, + ` - recast: ${requirePackage('recast').version}\n`, ].join('\n'); }, }, - }) - .parse(); + }); + +let options, positionalArguments; +try { + ({options, positionalArguments} = parser.parse()); + if (positionalArguments.length === 0) { + process.stderr.write( + 'Error: You have to provide at least one file/directory to transform.' + + '\n\n---\n\n' + + parser.getHelpText() + ); + process.exit(1); + } +} catch(e) { + const exitCode = e.exitCode === undefined ? 1 : e.exitCode; + (exitCode ? process.stderr : process.stdout).write(e.message); + process.exit(exitCode); +} Runner.run( - /^https?/.test(opts.transform) ? opts.transform : path.resolve(opts.transform), - opts.path, - opts + /^https?/.test(options.transform) ? options.transform : path.resolve(options.transform), + positionalArguments, + options ); diff --git a/package.json b/package.json index d991b268..12c5ad79 100644 --- a/package.json +++ b/package.json @@ -26,18 +26,17 @@ "author": "Felix Kling", "license": "BSD-3-Clause", "dependencies": { + "@babel/parser": "^7.0.0", "babel-plugin-transform-flow-strip-types": "^6.8.0", "babel-preset-es2015": "^6.9.0", "babel-preset-stage-1": "^6.5.0", "babel-register": "^6.9.0", - "@babel/parser": "^7.0.0", "colors": "^1.1.2", "flow-parser": "^0.*", "lodash": "^4.13.1", "micromatch": "^2.3.7", "neo-async": "^2.5.0", "node-dir": "0.1.8", - "nomnom": "^1.8.1", "recast": "^0.16.1", "temp": "^0.8.1", "write-file-atomic": "^1.2.0" diff --git a/src/__tests__/argsParser-test.js b/src/__tests__/argsParser-test.js new file mode 100644 index 00000000..6159d244 --- /dev/null +++ b/src/__tests__/argsParser-test.js @@ -0,0 +1,359 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +/*global jest, describe, it, expect, beforeEach*/ + +'use strict'; + +const argsParser = require('../argsParser'); + +describe('argsParser', function() { + it('prints the help text', function() { + const parser = argsParser.options({}); + let exception; + try { + parser.parse(['--help']); + } catch(e) { + exception = e; + } + expect(exception.exitCode).toEqual(0); + expect(exception.message).toEqual(parser.getHelpText()); + }); + + it('parsers positional arguments', function() { + const parser = argsParser.options({}); + const {positionalArguments} = parser.parse(['foo', 'bar']); + expect(positionalArguments).toEqual(['foo', 'bar']); + }); + + it('parsers mixed options, flags and positional arguments', function() { + const parser = argsParser.options({ + foo: {}, + bar: { + flag: true, + }, + bay: { + flag: true, + }, + baz: { + default: 'zab', + }, + }); + expect(parser.parse(['arg1', '--foo=1', 'arg2', '--bar', '--bay=1', 'arg3', 'arg4'])) + .toEqual({ + options: { + foo: 1, + bar: true, + bay: true, + baz: 'zab', + }, + positionalArguments: ['arg1', 'arg2', 'arg3', 'arg4'], + }); + }); + + describe('options', function() { + function test(testCases) { + for (const testName in testCases) { + const testCase = testCases[testName]; + + const parser = argsParser.options(testCase.options); + + it(testName + ' (space separated values)', function() { + const parse = () => parser.parse( + Array.prototype.concat.apply([], testCase.args) + ); + + if (typeof testCase.expected === 'string') { + expect(parse).toThrowError(testCase.expected); + } else { + expect(parse()).toEqual(testCase.expected); + } + }); + + it(testName + ' (= separated values)', function() { + const parse = () => parser.parse( + testCase.args.map(args => args.join('=')) + ); + if (typeof testCase.expected === 'string') { + expect(parse).toThrowError(testCase.expected); + } else { + expect(parse()).toEqual(testCase.expected); + } + }); + + } + } + + test({ + 'understands separate arg name and short option names': { + options: { + foo: { + full: 'another-foo', + }, + bar: { + abbr: 'b', + }, + }, + args: [['--another-foo', 'oof'], ['-b', 'rab']], + expected: { + options: { + foo: 'oof', + bar: 'rab', + }, + positionalArguments: [] + }, + }, + + 'understands default values': { + options: { + foo: {}, + bar: { + default: 'rab', + }, + baz: { + abbr: 'b', + default: 'zab', + }, + }, + args: [['--foo', 'oof']], + expected: { + options: { + foo: 'oof', + bar: 'rab', + baz: 'zab', + }, + positionalArguments: [] + }, + }, + + 'converts numeric option values to numbers': { + options: { + foo: {}, + bar: { + default: 456, + }, + baz: { + abbr: 'b', + }, + }, + args: [['--foo', '123'], ['-b', '789']], + expected: { + options: { + foo: 123, + bar: 456, + baz: 789, + }, + positionalArguments: [] + }, + }, + + 'understands lists': { + options: { + foo: { + list: true, + }, + bar: { + list: true, + }, + baz: {}, + }, + args: [ + ['--foo', 'oof1'], + ['--baz', 'zab1'], + ['--foo', 'oof2'], + ['--baz', 'zab2'], + ], + expected: { + options: { + foo: ['oof1', 'oof2'], + bar: [], + baz: 'zab2', + }, + positionalArguments: [] + }, + }, + + 'errors when an option does not have a value (1)': { + options: { + foo: {}, + bar: { + abbr: 'b', + }, + baz: {}, + }, + args: [ + ['--foo', 'oof'], + ['-b'], + ['--baz', 'zab'], + ], + expected: '--bar requires a value', + }, + + 'errors when an option does not have a value (2)': { + options: { + foo: {}, + bar: { + abbr: 'b', + }, + baz: {}, + }, + args: [ + ['--foo', 'oof'], + ['--bar'], + ['--baz', 'zab'], + ], + expected: '--bar requires a value', + }, + + 'errors when an option does not have a value (3)': { + options: { + foo: { + default: 'oof', + }, + }, + args: [['--foo']], + expected: '--foo requires a value', + }, + + 'understands choices': { + options: { + foo: { + choices: ['oof'], + }, + }, + args: [ + ['--foo', 'oof'], + ], + expected: { + options: { + foo: 'oof', + }, + positionalArguments: [] + }, + }, + + 'errors if choice does not match': { + options: { + foo: { + choices: ['oof'], + }, + bar: { + choices: ['rab1', 'rab2'], + }, + }, + args: [ + ['--foo', 'oof'], + ['--bar', 'rab'], + ], + expected: '--bar must be one of the values rab1,rab2', + }, + + 'accepts unkown options': { + options: {}, + args: [ + ['--foo'], + ['--bar'], + ['--bay', 'yab'], + ['foo'], + ['--b', 'zab1'], + ['--foo', 'oof'], + ['--b', 'zab2'], + ['bar'], + ], + expected: { + options: { + foo: 'oof', + bar: true, + bay: 'yab', + b: ['zab1', 'zab2'], + }, + positionalArguments: ['foo', 'bar'], + }, + }, + }); + }); + + describe('flags', function() { + const parser = argsParser.options({ + foo: { + abbr: 'f', + full: 'foo', + flag: true, + }, + bar: { + full: 'another-bar', + flag: true, + default: false, + }, + }); + + it('sets values to true of specified', function() { + expect(parser.parse(['--foo', '--another-bar', 'foo', 'bar'])) + .toEqual({ + options: { + foo: true, + bar: true, + }, + positionalArguments: ['foo', 'bar'], + }); + }); + + it('understands short options', function() { + expect(parser.parse(['-f', '--another-bar', 'f', 'bar'])) + .toEqual({ + options: { + foo: true, + bar: true, + }, + positionalArguments: ['f', 'bar'], + }); + }); + + it('sets default value if not specified', function() { + expect(parser.parse(['f', 'bar'])) + .toEqual({ + options: { + bar: false, + }, + positionalArguments: ['f', 'bar'], + }); + }); + + it('accepts flag=0 and flag=1 (undocumented)', function() { + expect(parser.parse(['--foo=0', '--another-bar=1'])) + .toEqual({ + options: { + foo: false, + bar: true, + }, + positionalArguments: [], + }); + expect(parser.parse(['-f=0'])) + .toEqual({ + options: { + foo: false, + bar: false, + }, + positionalArguments: [], + }); + }); + + it('understands --no-prefixes', function() { + expect(parser.parse(['--no-foo', '--no-another-bar'])) + .toEqual({ + options: { + foo: false, + bar: false, + }, + positionalArguments: [], + }); + }); + }); + +}); + diff --git a/src/argsParser.js b/src/argsParser.js new file mode 100644 index 00000000..72b4cd42 --- /dev/null +++ b/src/argsParser.js @@ -0,0 +1,250 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +function throwError(exitCode, message, helpText) { + const error = new Error( + helpText ? `${message}\n\n---\n\n${helpText}` : message + ); + error.exitCode = exitCode; + throw error; +} + +function formatOption(option) { + let text = ' '; + text += option.abbr ? `-${option.abbr}, ` : ' '; + text += `--${option.flag ? '(no-)' : ''}${option.full}`; + if (option.choices) { + text += `=${option.choices.join('|')}`; + } else if (option.metavar) { + text += `=${option.metavar}`; + } + if (option.list) { + text += ' ...'; + } + if (option.defaultHelp || option.default !== undefined || option.help) { + text += ' '; + if (text.length < 32) { + text += ' '.repeat(32 - text.length); + } + const textLength = text.length; + if (option.help) { + text += option.help; + } + if (option.defaultHelp || option.default !== undefined) { + if (option.help) { + text += '\n'; + } + text += `${' '.repeat(textLength)}(default: ${option.defaultHelp || option.default})`; + } + } + + return text; +} + +function getHelpText(options) { + const opts = Object.keys(options) + .map(k => options[k]) + .sort((a,b) => a.full.localeCompare(b.full)); + + const text = ` +Usage: jscodeshift [OPTION]... PATH... + or: jscodeshift [OPTION]... -t TRANSFORM_PATH PATH... + or: jscodeshift [OPTION]... -t URL PATH... + +Apply transform logic in TRANSFORM_PATH (recursively) to every PATH. + +Options: +"..." behind an option means that it can be supplied multiple times. +All options are also passed to the transformer, which means you can supply custom options that are not listed here. + +${opts.map(formatOption).join('\n')} +`; + return text.trimLeft(); +} + +function validateOptions(parsedOptions, options) { + const errors = []; + for (const optionName in options) { + const option = options[optionName]; + if (option.choices && !option.choices.includes(parsedOptions[optionName])) { + errors.push( + `Error: --${option.full} must be one of the values ${option.choices.join(',')}` + ); + } + } + if (errors.length > 0) { + throwError( + 1, + errors.join('\n'), + getHelpText(options) + ); + } +} + +function prepareOptions(options) { + options.help = { + abbr: 'h', + help: 'print this help and exit', + callback() { + return getHelpText(options); + }, + }; + + const preparedOptions = {}; + + for (const optionName of Object.keys(options)) { + const option = options[optionName]; + if (!option.full) { + option.full = optionName; + } + option.key = optionName; + + preparedOptions['--'+option.full] = option; + if (option.abbr) { + preparedOptions['-'+option.abbr] = option; + } + if (option.flag) { + preparedOptions['--no-'+option.full] = option; + } + } + + return preparedOptions; +} + +function isOption(value) { + return /^--?/.test(value); +} + +function parse(options, args=process.argv.slice(2)) { + const missingValue = Symbol(); + const preparedOptions = prepareOptions(options); + + const parsedOptions = {}; + const positionalArguments = []; + + for (const optionName in options) { + const option = options[optionName]; + if (option.default !== undefined) { + parsedOptions[optionName] = option.default; + } else if (option.list) { + parsedOptions[optionName] = []; + } + } + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (isOption(arg)) { + let optionName = arg; + let value = null; + let option = null; + if (optionName.includes('=')) { + const index = arg.indexOf('='); + optionName = arg.slice(0, index); + value = arg.slice(index+1); + } + if (preparedOptions.hasOwnProperty(optionName)) { + option = preparedOptions[optionName]; + } else { + // Unknown options are just "passed along". + // The logic is as follows: + // - If an option is encountered without a value, it's treated + // as a flag + // - If the option has a value, it's initialized with that value + // - If the option has been seen before, it's converted to a list + // If the previous value was true (i.e. a flag), that value is + // discarded. + const realOptionName = optionName.replace(/^--?(no-)?/, ''); + const isList = parsedOptions.hasOwnProperty(realOptionName) && + parsedOptions[realOptionName] !== true; + option = { + key: realOptionName, + full: realOptionName, + flag: !parsedOptions.hasOwnProperty(realOptionName) && + value === null && + isOption(args[i+1]), + list: isList, + }; + + if (isList) { + const currentValue = parsedOptions[realOptionName]; + if (!Array.isArray(currentValue)) { + parsedOptions[realOptionName] = currentValue === true ? + [] : + [currentValue]; + } + } + } + + if (option.callback) { + throwError(0, option.callback()); + } else if (option.flag) { + if (optionName.startsWith('--no-')) { + value = false; + } else if (value !== null) { + value = value === '1'; + } else { + value = true; + } + parsedOptions[option.key] = value; + } else { + if (value === null && i < args.length - 1 && !isOption(args[i+1])) { + // consume next value + value = args[i+1]; + i += 1; + } + if (value !== null) { + if (/^\d+$/.test(value)) { + value = Number(value); + } + if (option.list) { + parsedOptions[option.key].push(value); + } else { + parsedOptions[option.key] = value; + } + } else { + parsedOptions[option.key] = missingValue; + } + } + } else { + positionalArguments.push(/^\d+$/.test(arg) ? Number(arg) : arg); + } + } + + for (const optionName in parsedOptions) { + if (parsedOptions[optionName] === missingValue) { + throwError( + 1, + `Missing value: --${options[optionName].full} requires a value`, + getHelpText(options) + ); + } + } + + const result = { + positionalArguments, + options: parsedOptions, + }; + + validateOptions(parsedOptions, options); + + return result; +} + +module.exports = { + options(options) { + return { + parse(args) { + return parse(options, args); + }, + getHelpText() { + return getHelpText(options); + }, + }; + }, +}; diff --git a/yarn.lock b/yarn.lock index 420a2a2c..2cf4d2e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -70,10 +70,6 @@ ansi-styles@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" -ansi-styles@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-1.0.0.tgz#cb102df1c56f5123eab8b67cd7b98027a0279178" - ansicolors@~0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/ansicolors/-/ansicolors-0.2.1.tgz#be089599097b74a5c9c4a84a0cdbcdb62bd87aef" @@ -888,14 +884,6 @@ chalk@^1.0.0, chalk@^1.1.0, chalk@^1.1.1, chalk@^1.1.3: strip-ansi "^3.0.0" supports-color "^2.0.0" -chalk@~0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-0.4.0.tgz#5199a3ddcd0c1efe23bc08c1b027b06176e0c64f" - dependencies: - ansi-styles "~1.0.0" - has-color "~0.1.0" - strip-ansi "~0.1.0" - ci-info@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.0.0.tgz#dc5285f2b4e251821683681c381c3388f46ec534" @@ -1500,10 +1488,6 @@ has-ansi@^2.0.0: dependencies: ansi-regex "^2.0.0" -has-color@~0.1.0: - version "0.1.7" - resolved "https://registry.yarnpkg.com/has-color/-/has-color-0.1.7.tgz#67144a5260c34fc3cca677d041daf52fe7b78b2f" - has-flag@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa" @@ -2336,13 +2320,6 @@ node-notifier@^4.6.1: shellwords "^0.1.0" which "^1.0.5" -nomnom@^1.8.1: - version "1.8.1" - resolved "https://registry.yarnpkg.com/nomnom/-/nomnom-1.8.1.tgz#2151f722472ba79e50a76fc125bb8c8f2e4dc2a7" - dependencies: - chalk "~0.4.0" - underscore "~1.6.0" - normalize-package-data@^2.3.2: version "2.3.8" resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.3.8.tgz#d819eda2a9dedbd1ffa563ea4071d936782295bb" @@ -2895,10 +2872,6 @@ strip-ansi@^3.0.0, strip-ansi@^3.0.1: dependencies: ansi-regex "^2.0.0" -strip-ansi@~0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-0.1.1.tgz#39e8a98d044d150660abe4a6808acf70bb7bc991" - strip-bom@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e"