Skip to content

Fix plugin API in globally installed Prettier and introduce optional --plugin-search-dir#4192

Merged
ikatyang merged 9 commits intoprettier:masterfrom
kachkaev:fix-global-plugin-api
May 9, 2018
Merged

Fix plugin API in globally installed Prettier and introduce optional --plugin-search-dir#4192
ikatyang merged 9 commits intoprettier:masterfrom
kachkaev:fix-global-plugin-api

Conversation

@kachkaev
Copy link
Copy Markdown
Member

@kachkaev kachkaev commented Mar 22, 2018

Closes #4000 🎉

Instead of searching for dependencies and devDependnencies, in the nearest package.json, Prettier now searches for ["prettier-plugin-*/package.json", "@prettier/plugin-*/package.json"] in the nearest parent node_modules directory. This makes it possible for a globally installed instance to find plugins despite the lack of package.json up the tree. What is also good about this approach is that plugins are discoverable when they are a part of some boilerplate packages like create-react-app. Their philosophy is to stuff a project with all bells and whistles at a cost of just a single dependency in package.json.

Rather than matching directories like prettier-plugin-* and @prettier/plugin-* inside node_modules, loadPlugins() is looking for prettier-plugin-*/package.json and @prettier/plugin-*/package.json. I decided to do this for two reasons::

  • directories alone cannot be searched by globby, which has already been in Prettier dependencies (so no need to reinvent the wheel)
  • checking the presence of package.json file is more robust – there'll be no false-positives on empty dirs

This change in Prettier logic has been approved by @azz, the author of #3536 and #3624.


UPD: After a few iterations on this PR, I also added an optional --plugin-search-dir CLI parameter, justification of which you will find below.

@suchipi
Copy link
Copy Markdown
Member

suchipi commented Mar 22, 2018

Does this only look in the first node_modules dir upwards? Why not go up forever? Yarn workspaces and differences between npm and yarn are some things to consider here.

@kachkaev
Copy link
Copy Markdown
Member Author

kachkaev commented Mar 22, 2018

Do you think we should search for all node_modules up the tree? To be honest, I doubt that this will be both useful and glitch-free. No matter if we use npm or yarn, the main project's instance of prettier (out of several duplicates, if any) will be in path/to/project/node_modules/prettier. There'll be also path/to/project/node_modules/.bin/prettier, linking to ../node_modules/prettier/bin-prettier.js. Given these locations, plugins should be discoverable as good as they were in previous implementation.

There is one extra bit that I've added a bit later – see 61d87af. A directory with node modules is searched in two steps now. The first one succeeds for standard prettier instances (that is both local and global dependencies), while the second step is useful for the npm link-ed instances of Prettier and in tests.

@kachkaev
Copy link
Copy Markdown
Member Author

kachkaev commented Mar 22, 2018

I fixed one typo and the tests started to fail. Looks like we need a better algorithm to decide how to find parent node_modules in which we'll search for prettier plugins 🤔

findDirUp(__dirname, "node_modules")

works for

  • local prettier instances, any cwd
  • global prettier instances, any cwd

does not work for

  • local prettier instances installed using npm link
  • tests

findDirUp(process.cwd(), "node_modules")

works for

  • local prettier instances, assuming cwd is within the project
  • tests

does not work for

  • local prettier instances if cwd is outside project dir
  • global prettier instances

@kachkaev
Copy link
Copy Markdown
Member Author

kachkaev commented Mar 22, 2018

Perhaps, if we add some special env variable for tests, that'd be it, however, this option smells. In any case, we should not trust cwd as it used to be because of #4000 (comment):

This also breaks on non global installs when you invoke prettier from a parent directory.

@kachkaev
Copy link
Copy Markdown
Member Author

kachkaev commented Mar 22, 2018

  const nodeModulesDir =
    findDirUp(process.env.PRETTIER_PLUGINS_ROOT, "node_modules") ||
    findDirUp(__dirname, "node_modules") ||
    findDirUp(process.cwd(), "node_modules");

🤔

or even

  const nodeModulesDir =
    findNodeModulesDir(process.env.PRETTIER_PLUGINS_ROOT) ||
    findNodeModulesDir(__dirname) ||
    findNodeModulesDir(process.cwd());

Assuming tests set PRETTIER_PLUGINS_ROOT equal to each test project dir

@kachkaev
Copy link
Copy Markdown
Member Author

kachkaev commented Mar 22, 2018

Or how about --plugin-search-dir that would point to a directory with node_modules? This option will be only necessary in these two edge cases:

  • in tests
  • when prettier is installed using npm link

If --plugin-search-dir is not defined, findDirUp(__dirname, "node_modules") is used and thus all cases are covered. No more confusing cwd.

@kachkaev
Copy link
Copy Markdown
Member Author

kachkaev commented Mar 23, 2018

This PR is updated with a new --plugin-search-dir option. When it is not defined, prettier searches for plugins in the nearest node_nodules above it. Otherwise, the plugins are searched in node_modules subdirectory of the given path. This makes plugins potentially auto-discoverable in all these cases:

  • local prettier
  • global prettier
  • tests (with custom --plugin-search-dir provided)
  • npm link-ed instance of prettier (with --plugin-search-dir=. provided)

~~~@azz could you please help me with passing one remaining test?~~~

__UPD:__ Looks like I nailed it 😅 ! Usual thing: you spend over a day breaking into a shut door, then step away for one minute and it suddenly opens in a flick 😂 

@kachkaev kachkaev changed the title Make plugin API working in globally installed Prettier [RFC] Make plugin API working in globally installed Prettier Mar 23, 2018
@kachkaev kachkaev changed the title [RFC] Make plugin API working in globally installed Prettier [RFC] Fix plugin API in globally installed Prettier and introduce --plugin-search-dir Mar 23, 2018
@suchipi
Copy link
Copy Markdown
Member

suchipi commented Mar 23, 2018

Can you really call plugins "auto"-discoverable if you have to specify an option pointing to where they are? 😅

As an aside, require("module").globalPaths returns the global module paths where npm installs stuff, but not the yarn global path (which you can get via yarn global dir).

Could we combine pkg-search-up with require("module").globalPaths and yarn global dir to get what we want?

@kachkaev
Copy link
Copy Markdown
Member Author

kachkaev commented Mar 23, 2018

Yeah, I also see "auto"-discoverable a slightly funny term when we define --plugin-search-dir, but even in this case there still a portion of auto-discoverability: the plugins do get searched amongst node_modules 😄

Could we combine pkg-search-up with require("module").globalPaths and yarn global dir to get what we want?

I'm afraid that's not the way to go, because we don't want ambiguity caused by multiple plugin installs on one machine. Should a local instance of prettier fall back to global yarn or npm plugins if none are found locally? And what about if some are found? Guess the answer is no for both cases, unless there is guidance from a user (i.e. a custom-defined --plugin-search-dir). Also how do we make npm link-ed prettier search for plugins in the current project's directory without using process.cwd and without --plugin-search-dir=.? There seems to be now way to achieve that. Turns out, --plugin-search-dir is the least of evils that covers all possible use cases for plugin search. Most people won't have to know about this option anyway, because it's not needed in 99% of plugin use cases.

In future, we can support multiple --plugin-search-dirs just as we support multiple --plugin options now.

@suchipi
Copy link
Copy Markdown
Member

suchipi commented Mar 23, 2018

I think I misunderstood the goals and constraints of the PR. Disregard my comments until I have more time to look into this properly

@kachkaev
Copy link
Copy Markdown
Member Author

You probably thought that --plugin-search-dir is compulsory – and it's my fault because I forgot to mention that it's optional in the first comment. Fixed that now.

@kachkaev
Copy link
Copy Markdown
Member Author

kachkaev commented Mar 23, 2018

Spotted a bug in --plugin-search-dir autodetection when prettier is installed with yarn.

When there exists /path/to/project/node_modules/prettier/node_modules folder, prettier incorrectly starts searching for plugins in it rather than in /path/to/project/node_modules. Guess we fix that by replacing

findDirUp(__dirname, "node_modules")

with

findDirUp(path.resolve(findDirUp(__dirname, "prettier"), ".."), "node_modules")

I'm not sure how to cover that with tests though.

@azz
Copy link
Copy Markdown
Member

azz commented Mar 25, 2018

Not a huge fan of the --plugin-search-dir option, it seems to add a lot of complexity. The original implementation was intentionally minimal.

I'd be interested to see if the cwd approach fixes the issue raised (passing explicit --plugin=x with a globally installed x), I think I would prefer that solution instead.

@kachkaev kachkaev changed the title [RFC] Fix plugin API in globally installed Prettier and introduce --plugin-search-dir [RFC] Fix plugin API in globally installed Prettier and introduce optinonal --plugin-search-dir Mar 25, 2018
@kachkaev
Copy link
Copy Markdown
Member Author

kachkaev commented Mar 25, 2018

Hi @azz 👋

I thought about setting readPkgUp's cwd to Prettier's own directory, but had to decline this simple option. It would not allow for auto-search in a globally installed Prettier, which I think is crucial; #4000 is exactly about it. One should be able to do something like:

npm install --global prettier @prettier/plugin-a prettier-plugin-b prettier-plugin-c

And then enjoy

prettier "**/*.{js,md,a,b,c}"

in any folder on their machine. I know that having local dependencies is better, but despite that a global instance of Prettier powered by several plugins can be very useful, say, in editor extensions such as prettier-atom. Given this design constraint, we can't use process.cwd() and we can't simply readPkgUp, because global npm packages don't have package.json.

Thus, we are left with searching for actual packages above the tree from where Prettier is and, to be honest, this is not way more complex:

const nodeModulesDir = findDirUp(
  path.resolve(findDirUp(__dirname, "prettier"), ".."),
  "node_modules"
);
const autoPluginNames = findPluginsInNodeModules(nodeModulesDir);

(we first find parent prettier dir and then node_modules above it)

Some complexity comes into play indeed, but only if we want to override this behaviour for two very rare edge cases:

  • in tests (unless we copy prettier into plugins/automatic/node_modules/prettier)
  • when prettier is installed with npm link and is being debugged
const nodeModulesDir = pluginSearchDir
  ? path.resolve(process.cwd(), pluginSearchDir, "node_modules") // <--------
  : findDirUp(
      path.resolve(findDirUp(__dirname, "prettier"), ".."),
      "node_modules"
    );

This is only needed in advanced uses, mostly while debugging Prettier. Everyone else simply enjoys autoloaded plugins no matter if the instance is global and what the currentcwd is. IMO, option --plugin-search-dir is from the same boat as --loglevel and --support-info.

I'm open to alternative approaches that would fix #4000 and I don't insist that my implementation needs to be merged into prettier. However, after several days of thinking on this problem I can't come up with any other solution that would satisfy all the constraints that the problem of plugin autodiscovery has.

@kachkaev
Copy link
Copy Markdown
Member Author

kachkaev commented Mar 25, 2018

Technically we can remove --plugin-search-dir and keep the tests passing. That will involve copying prettier into plugins/automatic/node_modules/prettier before running automatically loads '@prettier/plugin-*' from --plugin-search-dir" and a couple of other tests. We would have to do this too if we simply changed cwd in findPkgUp.

However, without --plugin-search-dir npm link case will be no longer covered. It won't be ages till someone opens an issue titled Prettier can't autoload plugins when installed using npm link 😃

@kachkaev
Copy link
Copy Markdown
Member Author

kachkaev commented Apr 3, 2018

@suchipi when do you think you could look at this? Curious to hear your opinion!

Also ping @evilebottnawi @ikatyang @j-f1 🙌

@suchipi
Copy link
Copy Markdown
Member

suchipi commented Apr 5, 2018

@kachkaev I haven't had a chance to look at this yet but I haven't forgotten about it. I found somewhere that you linked to prettier/prettier-atom#395 which helps me understand the context of and motivation for this change better.

My main concerns are:

  • Plugin detection should work for the use-cases you need in prettier-atom
  • Plugin detection should work in monorepos (traverse upwards through multiple levels of local node_modules recursively)

Some "nice-to-have"s are that these would both work:

yarn global add prettier
yarn global add @prettier/plugin-php
prettier --write './**/*.php'
npm i -g prettier
npm i -g @prettier/plugin-php
prettier --write './**/*.php'

My kneejerk reaction to --plugin-search-dir is that I don't want to have to have it, but I don't fully understand why it's needed yet and I need to dig into this in order to understand.

I'll try to give this a thorough review sometime soon, but feel free to merge this without a review from me if it's ready.

@kachkaev
Copy link
Copy Markdown
Member Author

I squashed all commits in this PR and rebased them on top of the latest master. Tests seem to pass. Original unsquashed commits are now in fix-global-plugin-api-before-rebase branch of my fork just in case.

@suchipi when I declare that pluginSearchDir is since: "1.13.0", this option is not mentioned in some jests snapshots. What's the correct way of defining since?

It'd be great if someone could look at this PR at some point – we need to fix plugins in a global prettier setup 💯

@kachkaev
Copy link
Copy Markdown
Member Author

kachkaev commented May 2, 2018

Is there still interest in this PR? I'm really keen to see plugins working in a global instance of Prettier. Please help me to help! 🙂

It'd be great if I could have some design feedback and work on conflict resolution if all is OK.

@suchipi
Copy link
Copy Markdown
Member

suchipi commented May 2, 2018

Hi @kachkaev, I have had this PR on my mind and I still have interest in it but haven't looked at it in a while because I've been pretty busy. I cannot promise when I will be able to look at this but I want prettier plugins to work with global installations, too.

@suchipi
Copy link
Copy Markdown
Member

suchipi commented May 2, 2018

I apologize that this PR has been open for so long 🙏

@suchipi
Copy link
Copy Markdown
Member

suchipi commented May 2, 2018

Also just saw your question about since, I don't know the answer but @ikatyang or @duailibe might

@kachkaev
Copy link
Copy Markdown
Member Author

kachkaev commented May 7, 2018

I guess --plugin-search-dir should be an array, just like --plugin. That is to allow autoloading from multiple folders, which may be useful in editor extensions. For instance, Prettier in Atom may want to autoload both local project's plugins as well as the global (editor-wide) ones.

I'll probably wait for #4341 to land to master before rebasing this PR. That's because prettier.getFileInfo() will have to accept pluginSearchDirs and I'd like to add that to tests.

@kachkaev kachkaev changed the title [RFC] Fix plugin API in globally installed Prettier and introduce optinonal --plugin-search-dir Fix plugin API in globally installed Prettier and introduce optional --plugin-search-dir May 8, 2018
@kachkaev
Copy link
Copy Markdown
Member Author

kachkaev commented May 8, 2018

--plugin-search-dir is an array now and #4364 is taken into account. I'm waiting for #4341 to be merged to add one more test.

@ikatyang I'm using find-parent-dir instead of find-up that you've suggested as its API slightly better fits what we need. This module is mocked in tests_integrarion/__tests__/plugin-resolution.js to test autoloading when --plugin-search-dir is not provided. I'm not sure how we can test autoloading without mocking given that Prettier's entry point within the repo has different parents than when it's installed in the wild. Apart from a need to partially mock autoloading, load-plugins.js should be covered pretty well:

screen shot 2018-05-08 at 10 13 33

(I also built prettier and manually installed it as a global npm dependency – plugin resolution worked)

Please let me know if you have any questions about autoloading, it's not the most straightforward thing to figure out.

What do you guys think in general? What else is left to do?

Comment thread src/language-markdown/embed.js Outdated
const supportInfo = support.getSupportInfo(null, {
plugins: options.plugins
plugins: options.plugins,
pluginSearchDirs: options.pluginSearchDirs
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The options.plugins here should be already loaded, no need to specify pluginSearchDirs.

Comment thread src/main/options.js Outdated

const supportOptions = getSupportInfo(null, {
plugins: options.plugins,
pluginSearchDirs: options.pluginSearchDirs,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed – removing pluginSearchDirs: options.pluginSearchDirs in both cases did not break local tests. This must be an artefact of my earlier attempts prior to #4364.


describe("automatically loads 'prettier-plugin-*' from package.json devDependencies", () => {
runPrettier("plugins/automatic", ["file.txt", "--parser=foo"]).test({
if (process.env.NODE_ENV !== "production") {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have a third-party.js that is used for mocking this kind of things:

const getStream = require("get-stream");
const cosmiconfig = require("cosmiconfig");
module.exports = {
getStream,
cosmiconfig
};

for example:

// We cannot use `jest.setMock("get-stream", impl)` here, because in the
// production build everything is bundled into one file so there is no
// "get-stream" module to mock.
jest.spyOn(require(thirdParty), "getStream").mockImplementation(() => ({
then: handler => handler(options.input || "")
}));

Comment thread src/common/load-plugins.js Outdated

const pluginPath = resolve.sync(plugin, { basedir: process.cwd() });
return Object.assign({ name: plugin }, eval("require")(pluginPath));
const externalPluginInfos = [];
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be better to not mutate array if possible as it's clearer, I'd prefer something like this:

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 nodeModulesDir = path.resolve(
      resolvedPluginSearchDir,
      "node_modules"
    );

    return findPluginsInNodeModules(nodeModulesDir).map(pluginName => ({
      name: pluginName,
      requirePath: resolve.sync(pluginName, {
        basedir: resolvedPluginSearchDir
      })
    }));
  })
  .reduce((a, b) => a.concat(b), []);

const externalPlugins = uniqBy(
  externalManualLoadPluginInfos.concat(externalAutoLoadPluginInfos),
  "requirePath"
).map(externalPluginInfo =>
  Object.assign(
    { name: externalPluginInfo.name },
    eval("require")(externalPluginInfo.requirePath)
  )
);

@@ -1,22 +1,108 @@
"use strict";
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems the current implementation does not support pluginSearchDirs in config files, which we should do something like the following in resolve-config.js:

options.pluginSearchDirs = options.pluginSearchDirs.map(
  pluginSearchDir => path.resolve(
    path.dirname(configFilePath), 
    pluginSearchDir
  )
)

@kachkaev
Copy link
Copy Markdown
Member Author

kachkaev commented May 8, 2018

Many thanks for your review comments @ikatyang, especially for a trick with mocking! It took me ages to figure out what how to test findParentDir and I still did it wrong in the end 😅

One thing I did not address is supporting pluginSearchDirs in Prettier config. That's a great idea IMO, however, don't you think that plugins should be configurable this way too? If yes, will these two config file options supplement CLI/API options or will the latter replace the values in the config file?

How about starting a separate discussion in a new PR and too keep this one concise?

Comment thread src/common/load-plugins.js Outdated
const globby = require("globby");
const path = require("path");
const resolve = require("resolve");
const thirdParty = require("../common/third-party");
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const thirdParty = require("./third-party");

// We cannot use `jest.setMock("get-stream", impl)` here, because in the
// production build everything is bundled into one file so there is no
// "get-stream" module to mock.
jest.spyOn(require(thirdParty), "getStream").mockImplementation(() => ({
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason you want to move it? IMO, we shouldn't touch unrelated code.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason for this change was that I wanted to sort third-party scripts alphabetically. I reverted that in bfbc580, but I'd be keen to delete it and thus to keep sorting. This will help read and write other third-party scripts in future. WDYT?

Comment thread docs/plugins.md
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`:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need to pass --plugin and --plugin-search-dir simultaneously, these two options should be documented separately for clarity, or at least examples should be separated.

Copy link
Copy Markdown
Member Author

@kachkaev kachkaev May 9, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why someone can't use --plugin and --plugin-search-dir simultaneously – that's technically possible and can be useful when debugging how a bunch of plugins work together (e.g. we have python and php plugins installed globally and we're testing if a locally developed elm plugin with a locally installed prettier does not conflict with the other two). Seeing both options together looks logical to me after this phrase in the docs:

When plugins cannot be found automatically, you can load them with:

I don't feel there is much distinction between --plugin and --plugin-search-dir for a user, so I don't see much value in splitting the narrative into two separate sections. The only potential benefit that I see is consistency with how other CLI options are documented 🤔

Shall we keep the docs as is until the plugin ecosystem graduates from beta and thus deserves extra attention anyway?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I originally meant they could be used simultaneously but not required since they are completely different and won't affect each other.

After second thought, the examples are relative paths so people should understand that they're separated. This PR should be good to go now.

@ikatyang
Copy link
Copy Markdown
Member

ikatyang commented May 9, 2018

How about starting a separate discussion in a new PR and too keep this one concise?

Sure. And currently the options from CLI/config file will be overridden based on --config-precedence, if we'd like to support supplement, we just need to concat them I guess.

@suchipi suchipi added this to the 1.13 milestone May 9, 2018
Copy link
Copy Markdown
Collaborator

@duailibe duailibe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall LGTM, I'll defer the specifics to @ikatyang (go ahead and merge when you're comfortable)

@kachkaev
Copy link
Copy Markdown
Member Author

kachkaev commented May 9, 2018

@ikatyang shall we merge as @duailibe suggests? I'll then rebase #4341 and add --plugin-search-dir to its tests.

@ikatyang
Copy link
Copy Markdown
Member

ikatyang commented May 9, 2018

Sure.

@ikatyang
Copy link
Copy Markdown
Member

ikatyang commented May 9, 2018

Opened #4446 to track relative paths for plugins and pluginSearchDirs in config files.

@azz
Copy link
Copy Markdown
Member

azz commented May 9, 2018

🎉 Thanks for working through this problem so diligently, @kachkaev!

@suchipi
Copy link
Copy Markdown
Member

suchipi commented May 9, 2018

Yes, thank you so much @kachkaev!

I'm sorry I wasn't able to give this PR the attention I should have, but I'm really glad to see this get merged. Thank you @duailibe @ikatyang @azz for reviewing in my unavailability!

@kachkaev kachkaev mentioned this pull request May 9, 2018
@lock lock Bot added the locked-due-to-inactivity Please open a new issue and fill out the template instead of commenting. label Aug 7, 2018
@lock lock Bot locked as resolved and limited conversation to collaborators Aug 7, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

locked-due-to-inactivity Please open a new issue and fill out the template instead of commenting.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants