diff --git a/docs/plugins.md b/docs/plugins.md index 0b5c00a4..591f4376 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -7,31 +7,36 @@ title: Plugins (Beta) > The plugin API is in a **beta** state as of Prettier 1.10 and the API may change in the next release! -Plugins are ways of adding new languages to Prettier. Prettier's own implementations of all languages are expressed using the plugin API. The core `prettier` package contains JavaScript and other web-focussed languages built in. For additional languages you'll need to install a plugin. +Plugins are ways of adding new languages to Prettier. Prettier's own implementations of all languages are expressed using the plugin API. The core `prettier` package contains JavaScript and other web-focused languages built in. For additional languages you'll need to install a plugin. ## Using Plugins -Plugins are automatically loaded if you have them installed in your `package.json`. Prettier plugin package names must start with `@prettier/plugin-` or `prettier-plugin-` to be registered. +Plugins are automatically loaded if you have them installed in the same `node_modules` directory where `prettier` is located. Plugin package names must start with `@prettier/plugin-` or `prettier-plugin-` to be registered. -If the plugin is unable to be found automatically, you can load them with: +When plugins cannot be found automatically, you can load them with: -* The [CLI](./cli.md), via the `--plugin` flag: +* The [CLI](./cli.md), via the `--plugin` and `--plugin-search-dir`: ```bash - prettier --write main.foo --plugin=./foo-plugin + prettier --write main.foo --plugin-search-dir=./dir-with-plugins --plugin=./foo-plugin ``` - > Tip: You can pass multiple `--plugin` flags. + > Tip: You can set `--plugin` or `--plugin-search-dir` options multiple times. -* Or the [API](./api.md), via the `plugins` field: +* Or the [API](./api.md), via the `plugins` and `pluginSearchDirs` options: ```js prettier.format("code", { parser: "foo", + pluginSearchDirs: ["./dir-with-plugins"], plugins: ["./foo-plugin"] }); ``` +Prettier expects each of `pluginSearchDirs` to contain `node_modules` subdirectory, where `@prettier/plugin-*` and `prettier-plugin-*` will be searched. For instance, this can be your project directory or the location of global npm modules. + +Providing at least one path to `--plugin-search-dir`/`pluginSearchDirs` turns off plugin autoloading in the default directory (i.e. `node_modules` above `prettier` binary). + ## Official Plugins * [`@prettier/plugin-python`](https://github.com/prettier/plugin-python) diff --git a/index.js b/index.js index 79c6e133..736146e4 100644 --- a/index.js +++ b/index.js @@ -17,7 +17,7 @@ function withPlugins(fn) { const args = Array.from(arguments); const opts = args[1] || {}; args[1] = Object.assign({}, opts, { - plugins: loadPlugins(opts.plugins) + plugins: loadPlugins(opts.plugins, opts.pluginSearchDirs) }); return fn.apply(null, args); }; diff --git a/package.json b/package.json index 137de49e..6f90c9ce 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "emoji-regex": "6.5.1", "escape-string-regexp": "1.0.5", "esutils": "2.0.2", + "find-parent-dir": "0.3.0", "find-project-root": "1.1.1", "flow-parser": "0.70", "get-stream": "3.0.0", @@ -39,6 +40,7 @@ "jest-docblock": "22.2.2", "json-stable-stringify": "1.0.1", "leven": "2.1.0", + "lodash.uniqby": "4.7.0", "mem": "1.1.0", "minimatch": "3.0.4", "minimist": "1.2.0", @@ -48,7 +50,6 @@ "postcss-scss": "1.0.5", "postcss-selector-parser": "2.2.3", "postcss-values-parser": "1.5.0", - "read-pkg-up": "3.0.0", "remark-frontmatter": "1.1.0", "remark-parse": "5.0.0", "resolve": "1.5.0", diff --git a/src/cli/util.js b/src/cli/util.js index 9e99b378..a25f0621 100644 --- a/src/cli/util.js +++ b/src/cli/util.js @@ -746,7 +746,12 @@ function createMinimistOptions(detailedOptions) { .map(option => option.name), default: detailedOptions .filter(option => !option.deprecated) - .filter(option => !option.forwardToApi || option.name === "plugin") + .filter( + option => + !option.forwardToApi || + option.name === "plugin" || + option.name === "plugin-search-dir" + ) .filter(option => option.default !== undefined) .reduce( (current, option) => @@ -809,11 +814,15 @@ function createContext(args) { const context = { args }; updateContextArgv(context); - normalizeContextArgv(context, ["loglevel", "plugin"]); + normalizeContextArgv(context, ["loglevel", "plugin", "plugin-search-dir"]); context.logger = createLogger(context.argv["loglevel"]); - updateContextArgv(context, context.argv["plugin"]); + updateContextArgv( + context, + context.argv["plugin"], + context.argv["plugin-search-dir"] + ); return context; } @@ -823,12 +832,13 @@ function initContext(context) { normalizeContextArgv(context); } -function updateContextOptions(context, plugins) { +function updateContextOptions(context, plugins, pluginSearchDirs) { const supportOptions = prettier.getSupportInfo(null, { showDeprecated: true, showUnreleased: true, showInternal: true, - plugins + plugins, + pluginSearchDirs }).options; const detailedOptionMap = normalizeDetailedOptionMap( @@ -851,12 +861,12 @@ function updateContextOptions(context, plugins) { context.apiDefaultOptions = apiDefaultOptions; } -function pushContextPlugins(context, plugins) { +function pushContextPlugins(context, plugins, pluginSearchDirs) { context._supportOptions = context.supportOptions; context._detailedOptions = context.detailedOptions; context._detailedOptionMap = context.detailedOptionMap; context._apiDefaultOptions = context.apiDefaultOptions; - updateContextOptions(context, plugins); + updateContextOptions(context, plugins, pluginSearchDirs); } function popContextPlugins(context) { @@ -866,8 +876,8 @@ function popContextPlugins(context) { context.apiDefaultOptions = context._apiDefaultOptions; } -function updateContextArgv(context, plugins) { - pushContextPlugins(context, plugins); +function updateContextArgv(context, plugins, pluginSearchDirs) { + pushContextPlugins(context, plugins, pluginSearchDirs); const minimistOptions = createMinimistOptions(context.detailedOptions); const argv = minimist(context.args, minimistOptions); diff --git a/src/common/load-plugins.js b/src/common/load-plugins.js index b4899784..59676f71 100644 --- a/src/common/load-plugins.js +++ b/src/common/load-plugins.js @@ -1,10 +1,30 @@ "use strict"; +const uniqBy = require("lodash.uniqby"); +const fs = require("fs"); +const globby = require("globby"); +const path = require("path"); const resolve = require("resolve"); -const readPkgUp = require("read-pkg-up"); +const thirdParty = require("./third-party"); -function loadPlugins(plugins) { - plugins = plugins || []; +function loadPlugins(plugins, pluginSearchDirs) { + if (!plugins) { + plugins = []; + } + + if (!pluginSearchDirs) { + pluginSearchDirs = []; + } + // unless pluginSearchDirs are provided, auto-load plugins from node_modules that are parent to Prettier + if (!pluginSearchDirs.length) { + const autoLoadDir = thirdParty.findParentDir( + thirdParty.findParentDir(__dirname, "prettier"), + "node_modules" + ); + if (autoLoadDir) { + pluginSearchDirs = [autoLoadDir]; + } + } const internalPlugins = [ require("../language-js"), @@ -16,45 +36,64 @@ function loadPlugins(plugins) { require("../language-vue") ]; - const externalPlugins = plugins - .concat( - getPluginsFromPackage( - readPkgUp.sync({ - normalize: false - }).pkg - ) - ) - .map(plugin => { - if (typeof plugin !== "string") { - return plugin; + const externalManualLoadPluginInfos = plugins.map(pluginName => ({ + name: pluginName, + requirePath: resolve.sync(pluginName, { basedir: process.cwd() }) + })); + + const externalAutoLoadPluginInfos = pluginSearchDirs + .map(pluginSearchDir => { + const resolvedPluginSearchDir = path.resolve( + process.cwd(), + pluginSearchDir + ); + + if (!isDirectory(pluginSearchDir)) { + throw new Error( + `${pluginSearchDir} does not exist or is not a directory` + ); } - const pluginPath = resolve.sync(plugin, { basedir: process.cwd() }); - return Object.assign({ name: plugin }, eval("require")(pluginPath)); - }); + const nodeModulesDir = path.resolve( + resolvedPluginSearchDir, + "node_modules" + ); - return deduplicate(internalPlugins.concat(externalPlugins)); -} + return findPluginsInNodeModules(nodeModulesDir).map(pluginName => ({ + name: pluginName, + requirePath: resolve.sync(pluginName, { + basedir: resolvedPluginSearchDir + }) + })); + }) + .reduce((a, b) => a.concat(b), []); -function getPluginsFromPackage(pkg) { - if (!pkg) { - return []; - } - const deps = Object.assign({}, pkg.dependencies, pkg.devDependencies); - return Object.keys(deps).filter( - dep => - dep.startsWith("prettier-plugin-") || dep.startsWith("@prettier/plugin-") + const externalPlugins = uniqBy( + externalManualLoadPluginInfos.concat(externalAutoLoadPluginInfos), + "requirePath" + ).map(externalPluginInfo => + Object.assign( + { name: externalPluginInfo.name }, + eval("require")(externalPluginInfo.requirePath) + ) ); + + return internalPlugins.concat(externalPlugins); } -function deduplicate(items) { - const uniqItems = []; - for (const item of items) { - if (uniqItems.indexOf(item) < 0) { - uniqItems.push(item); - } +function findPluginsInNodeModules(nodeModulesDir) { + const pluginPackageJsonPaths = globby.sync( + ["prettier-plugin-*/package.json", "@prettier/plugin-*/package.json"], + { cwd: nodeModulesDir } + ); + return pluginPackageJsonPaths.map(path.dirname); +} + +function isDirectory(dir) { + try { + return fs.statSync(dir).isDirectory(); + } catch (e) { + return false; } - return uniqItems; } - module.exports = loadPlugins; diff --git a/src/common/third-party.js b/src/common/third-party.js index 8c08aef4..e578b1a3 100644 --- a/src/common/third-party.js +++ b/src/common/third-party.js @@ -2,8 +2,10 @@ const getStream = require("get-stream"); const cosmiconfig = require("cosmiconfig"); +const findParentDir = require("find-parent-dir").sync; module.exports = { getStream, - cosmiconfig + cosmiconfig, + findParentDir }; diff --git a/src/main/core-options.js b/src/main/core-options.js index 65b4199e..ba77619c 100644 --- a/src/main/core-options.js +++ b/src/main/core-options.js @@ -122,6 +122,21 @@ const options = { cliName: "plugin", cliCategory: CATEGORY_CONFIG }, + pluginSearchDirs: { + since: "1.13.0", + type: "path", + array: true, + default: [{ value: [] }], + category: CATEGORY_GLOBAL, + description: dedent` + Custom directory that contains prettier plugins in node_modules subdirectory. + Overrides default behavior when plugins are searched relatively to the location of Prettier. + Multiple values are accepted. + `, + exception: value => typeof value === "string" || typeof value === "object", + cliName: "plugin-search-dir", + cliCategory: CATEGORY_CONFIG + }, printWidth: { since: "0.0.0", category: CATEGORY_GLOBAL, diff --git a/tests_integration/__tests__/__snapshots__/early-exit.js.snap b/tests_integration/__tests__/__snapshots__/early-exit.js.snap index 037c0396..ac67f38f 100644 --- a/tests_integration/__tests__/__snapshots__/early-exit.js.snap +++ b/tests_integration/__tests__/__snapshots__/early-exit.js.snap @@ -302,6 +302,21 @@ Default: [] exports[`show detailed usage with --help plugin (write) 1`] = `Array []`; +exports[`show detailed usage with --help plugin-search-dir (stderr) 1`] = `""`; + +exports[`show detailed usage with --help plugin-search-dir (stdout) 1`] = ` +"--plugin-search-dir + + Custom directory that contains prettier plugins in node_modules subdirectory. + Overrides default behavior when plugins are searched relatively to the location of Prettier. + Multiple values are accepted. + +Default: [] +" +`; + +exports[`show detailed usage with --help plugin-search-dir (write) 1`] = `Array []`; + exports[`show detailed usage with --help print-width (stderr) 1`] = `""`; exports[`show detailed usage with --help print-width (stdout) 1`] = ` @@ -599,6 +614,11 @@ Config options: Defaults to .prettierignore. --plugin Add a plugin. Multiple plugins can be passed as separate \`--plugin\`s. Defaults to []. + --plugin-search-dir + Custom directory that contains prettier plugins in node_modules subdirectory. + Overrides default behavior when plugins are searched relatively to the location of Prettier. + Multiple values are accepted. + Defaults to []. --with-node-modules Process files inside 'node_modules' directory. Editor options: @@ -742,6 +762,11 @@ Config options: Defaults to .prettierignore. --plugin Add a plugin. Multiple plugins can be passed as separate \`--plugin\`s. Defaults to []. + --plugin-search-dir + Custom directory that contains prettier plugins in node_modules subdirectory. + Overrides default behavior when plugins are searched relatively to the location of Prettier. + Multiple values are accepted. + Defaults to []. --with-node-modules Process files inside 'node_modules' directory. Editor options: diff --git a/tests_integration/__tests__/early-exit.js b/tests_integration/__tests__/early-exit.js index 2f3d5117..f4b2f4dc 100644 --- a/tests_integration/__tests__/early-exit.js +++ b/tests_integration/__tests__/early-exit.js @@ -29,7 +29,8 @@ describe(`show detailed usage with plugin options (automatic resolution)`, () => runPrettier("plugins/automatic", [ "--help", "tab-width", - "--parser=bar" + "--parser=bar", + `--plugin-search-dir=.` ]).test({ status: 0 }); diff --git a/tests_integration/__tests__/plugin-resolution.js b/tests_integration/__tests__/plugin-resolution.js index 8ee67a23..ec4f4ed6 100644 --- a/tests_integration/__tests__/plugin-resolution.js +++ b/tests_integration/__tests__/plugin-resolution.js @@ -3,7 +3,7 @@ const runPrettier = require("../runPrettier"); const EOL = require("os").EOL; -describe("automatically loads 'prettier-plugin-*' from package.json devDependencies", () => { +describe("automatically loads 'prettier-plugin-*'", () => { runPrettier("plugins/automatic", ["file.txt", "--parser=foo"]).test({ stdout: "foo+contents" + EOL, stderr: "", @@ -12,7 +12,7 @@ describe("automatically loads 'prettier-plugin-*' from package.json devDependenc }); }); -describe("automatically loads '@prettier/plugin-*' from package.json dependencies", () => { +describe("automatically loads '@prettier/plugin-*'", () => { runPrettier("plugins/automatic", ["file.txt", "--parser=bar"]).test({ stdout: "bar+contents" + EOL, stderr: "", @@ -20,3 +20,82 @@ describe("automatically loads '@prettier/plugin-*' from package.json dependencie write: [] }); }); + +describe("automatically loads 'prettier-plugin-*' from --plugin-search-dir (same as autoload dir)", () => { + runPrettier("plugins/automatic", [ + "file.txt", + "--parser=foo", + `--plugin-search-dir=.` + ]).test({ + stdout: "foo+contents" + EOL, + stderr: "", + status: 0, + write: [] + }); +}); + +describe("automatically loads '@prettier/plugin-*' from --plugin-search-dir (same as autoload dir)", () => { + runPrettier("plugins/automatic", [ + "file.txt", + "--parser=bar", + `--plugin-search-dir=.` + ]).test({ + stdout: "bar+contents" + EOL, + stderr: "", + status: 0, + write: [] + }); +}); + +describe("automatically loads 'prettier-plugin-*' from --plugin-search-dir (different to autoload dir)", () => { + runPrettier("plugins", [ + "automatic/file.txt", + "--parser=foo", + `--plugin-search-dir=automatic` + ]).test({ + stdout: "foo+contents" + EOL, + stderr: "", + status: 0, + write: [] + }); +}); + +describe("automatically loads '@prettier/plugin-*' from --plugin-search-dir (different to autoload dir)", () => { + runPrettier("plugins", [ + "automatic/file.txt", + "--parser=bar", + `--plugin-search-dir=automatic` + ]).test({ + stdout: "bar+contents" + EOL, + stderr: "", + status: 0, + write: [] + }); +}); + +describe("does not crash when --plugin-search-dir does not contain node_modules", () => { + runPrettier("plugins/extensions", [ + "file.foo", + "--plugin=./plugin", + `--plugin-search-dir=.` + ]).test({ + stdout: "!contents" + EOL, + stderr: "", + status: 0, + write: [] + }); +}); + +describe("crashes when one of --plugin-search-dir does not exist", () => { + runPrettier("plugins/automatic", [ + "file.txt", + "--parser=foo", + `--plugin-search-dir=non-existing-dir`, + `--plugin-search-dir=.` + ]).test({ + stdout: "", + stderr: "non-existing-dir does not exist or is not a directory", + status: 1, + write: [] + }); +}); diff --git a/tests_integration/plugins/automatic/node_modules/@prettier/plugin-foo/package.json b/tests_integration/plugins/automatic/node_modules/@prettier/plugin-foo/package.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/tests_integration/plugins/automatic/node_modules/@prettier/plugin-foo/package.json @@ -0,0 +1 @@ +{} diff --git a/tests_integration/plugins/automatic/node_modules/prettier-plugin-bar/package.json b/tests_integration/plugins/automatic/node_modules/prettier-plugin-bar/package.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/tests_integration/plugins/automatic/node_modules/prettier-plugin-bar/package.json @@ -0,0 +1 @@ +{} diff --git a/tests_integration/plugins/automatic/package.json b/tests_integration/plugins/automatic/package.json index 52178d8b..0967ef42 100644 --- a/tests_integration/plugins/automatic/package.json +++ b/tests_integration/plugins/automatic/package.json @@ -1,8 +1 @@ -{ - "dependencies": { - "@prettier/plugin-foo": "*" - }, - "devDependencies": { - "prettier-plugin-bar": "*" - } -} +{} diff --git a/tests_integration/runPrettier.js b/tests_integration/runPrettier.js index a90a4d2a..0bb050ea 100644 --- a/tests_integration/runPrettier.js +++ b/tests_integration/runPrettier.js @@ -82,6 +82,9 @@ function runPrettier(dir, args, options) { Object.assign({}, options, { stopDir: __dirname }) ) ); + jest + .spyOn(require(thirdParty), "findParentDir") + .mockImplementation(() => process.cwd()); try { require(prettierCli); diff --git a/yarn.lock b/yarn.lock index 47f14950..a4e194db 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1935,6 +1935,10 @@ fill-range@^2.1.0: repeat-element "^1.1.2" repeat-string "^1.5.2" +find-parent-dir@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/find-parent-dir/-/find-parent-dir-0.3.0.tgz#33c44b429ab2b2f0646299c5f9f718f376ff8d54" + find-project-root@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/find-project-root/-/find-project-root-1.1.1.tgz#d242727a2d904725df5714f23dfdcdedda0b6ef8" @@ -3000,10 +3004,6 @@ json-loader@^0.5.4: version "0.5.4" resolved "https://registry.yarnpkg.com/json-loader/-/json-loader-0.5.4.tgz#8baa1365a632f58a3c46d20175fc6002c96e37de" -json-parse-better-errors@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.1.tgz#50183cd1b2d25275de069e9e71b467ac9eab973a" - json-schema-traverse@^0.3.0: version "0.3.1" resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz#349a6d44c53a51de89b40805c5d5e59b417d3340" @@ -3109,15 +3109,6 @@ load-json-file@^2.0.0: pify "^2.0.0" strip-bom "^3.0.0" -load-json-file@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-4.0.0.tgz#2f5f45ab91e33216234fd53adab668eb4ec0993b" - dependencies: - graceful-fs "^4.1.2" - parse-json "^4.0.0" - pify "^3.0.0" - strip-bom "^3.0.0" - loader-runner@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.3.0.tgz#f482aea82d543e07921700d5a46ef26fdac6b8a2" @@ -3142,6 +3133,10 @@ lodash.unescape@4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/lodash.unescape/-/lodash.unescape-4.0.1.tgz#bf2249886ce514cda112fae9218cdc065211fc9c" +lodash.uniqby@4.7.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz#d99c07a669e9e6d24e1362dfe266c67616af1302" + lodash@^4.14.0, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.3.0: version "4.17.4" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae" @@ -3587,13 +3582,6 @@ parse-json@^3.0.0: dependencies: error-ex "^1.3.1" -parse-json@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0" - dependencies: - error-ex "^1.3.1" - json-parse-better-errors "^1.0.1" - parse5@3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/parse5/-/parse5-3.0.3.tgz#042f792ffdd36851551cf4e9e066b3874ab45b5c" @@ -3648,12 +3636,6 @@ path-type@^2.0.0: dependencies: pify "^2.0.0" -path-type@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f" - dependencies: - pify "^3.0.0" - pbkdf2@^3.0.3: version "3.0.12" resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.0.12.tgz#be36785c5067ea48d806ff923288c5f750b6b8a2" @@ -3894,13 +3876,6 @@ rc@^1.1.7: minimist "^1.2.0" strip-json-comments "~2.0.1" -read-pkg-up@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-3.0.0.tgz#3ed496685dba0f8fe118d0691dc51f4a1ff96f07" - dependencies: - find-up "^2.0.0" - read-pkg "^3.0.0" - read-pkg-up@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02" @@ -3931,14 +3906,6 @@ read-pkg@^2.0.0: normalize-package-data "^2.3.2" path-type "^2.0.0" -read-pkg@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-3.0.0.tgz#9cbc686978fee65d16c00e2b19c237fcf6e38389" - dependencies: - load-json-file "^4.0.0" - normalize-package-data "^2.3.2" - path-type "^3.0.0" - readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.4, readable-stream@^2.2.2, readable-stream@^2.2.6: version "2.2.11" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.2.11.tgz#0796b31f8d7688007ff0b93a8088d34aa17c0f72"