Repository: prettier/prettier (JavaScript)
Date: 2022-06-18 14:26:08+00:00
Task type: Feature

Issue:
Better cache handling of multiple (but different) prettier calls
**Environments:**
- Prettier Version: 2.7.0
- Running Prettier via: CLI
- Operating System: Windows

After discussion https://github.com/prettier/prettier/pull/12800#discussion_r867309015 you moved from default cache location `.prettiercache` (eslint style) to `node_modules/.cache/prettier` (ava style).

In https://github.com/prettier/prettier/pull/12800#discussion_r878056060 you needed a use case for a `--cache-location` CLI param.

I have a script which formats and compiles many Visual Studio projects which shares one `node_modules` dir.
Compiling one project boosts the prettier call from 15s to 2s. 🎉

But every project has different files (surprise :-) so every prettier call finds new files and rebuilds (!) `node_modules/.cache/prettier` from scratch (which renders the cache useless in many situations).

It would be better if prettier would
* expand the cached cache file with new found files (but when do we cleanup?) or
* default to `.prettiercache` to be able to have a cache file per vs project or
* readd  `--cache-location` CLI param so I can configure the cache in my build script


cc @sosukesuzuki @fisker @7rulnik


Issue discussion:
I think `--cache-location` is good

Solution patch:
diff --git a/changelog_unreleased/cli/13019.md b/changelog_unreleased/cli/13019.md
new file mode 100644
index 000000000000..bb4f81b2bab8
--- /dev/null
+++ b/changelog_unreleased/cli/13019.md
@@ -0,0 +1,9 @@
+#### Add `--cache-location` option (#13019 by @sosukesuzuki)
+
+Path to the cache file location used by `--cache` flag. If you don't explicit `--cache-location`, Prettier saves cache file at `./node_modules/.cache/prettier/.prettier-cache`.
+
+If a file path is passed, that file is used as the cache file.
+
+```bash
+prettier --write --cache --cache-location=my_cache_file src
+```
diff --git a/docs/cli.md b/docs/cli.md
index af3afef5f9c1..b4d3981e9943 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -225,6 +225,16 @@ Also, since the cache file is stored in `./node_modules/.cache/prettier/.prettie
 
 > Plugins version and implementation are not used as cache keys. We recommend that you delete the cache when updating plugins.
 
+## `--cache-location`
+
+Path to the cache file location used by `--cache` flag. If you don't explicit `--cache-location`, Prettier saves cache file at `./node_modules/.cache/prettier/.prettier-cache`.
+
+If a file path is passed, that file is used as the cache file.
+
+```bash
+prettier --write --cache --cache-location=my_cache_file src
+```
+
 ## `--cache-strategy`
 
 Strategy for the cache to use for detecting changed files. Can be either `metadata` or `content`.
diff --git a/src/cli/constant.js b/src/cli/constant.js
index c7cc8958dcd4..33a64f3d180f 100644
--- a/src/cli/constant.js
+++ b/src/cli/constant.js
@@ -76,6 +76,10 @@ const options = {
     description: "Only format changed files. Cannot use with --stdin-filepath.",
     type: "boolean",
   },
+  "cache-location": {
+    description: "Path to the cache file.",
+    type: "path",
+  },
   "cache-strategy": {
     choices: [
       {
diff --git a/src/cli/find-cache-file.js b/src/cli/find-cache-file.js
index c8960ead4788..cc6ebee79821 100644
--- a/src/cli/find-cache-file.js
+++ b/src/cli/find-cache-file.js
@@ -1,19 +1,51 @@
 "use strict";
 
+const fs = require("fs").promises;
 const os = require("os");
 const path = require("path");
 const findCacheDir = require("find-cache-dir");
+const { statSafe, isJson } = require("./utils.js");
 
 /**
  * Find default cache file (`./node_modules/.cache/prettier/.prettier-cache`) using https://github.com/avajs/find-cache-dir
- *
- * @returns {string}
  */
-function findCacheFile() {
+function findDefaultCacheFile() {
   const cacheDir =
     findCacheDir({ name: "prettier", create: true }) || os.tmpdir();
   const cacheFilePath = path.join(cacheDir, ".prettier-cache");
   return cacheFilePath;
 }
 
+async function findCacheFileFromOption(cacheLocation) {
+  const cacheFile = path.resolve(cacheLocation);
+
+  const stat = await statSafe(cacheFile);
+  if (stat) {
+    if (stat.isDirectory()) {
+      throw new Error(
+        `Resolved --cache-location '${cacheFile}' is a directory`
+      );
+    }
+
+    const data = await fs.readFile(cacheFile, "utf8");
+    if (!isJson(data)) {
+      throw new Error(`'${cacheFile}' isn't a valid JSON file`);
+    }
+  }
+
+  return cacheFile;
+}
+
+/**
+ * @param {string | undefined} cacheLocation
+ * @returns {Promise<string>}
+ */
+async function findCacheFile(cacheLocation) {
+  if (!cacheLocation) {
+    return findDefaultCacheFile();
+  }
+  const cacheFile = await findCacheFileFromOption(cacheLocation);
+  return cacheFile;
+}
+
 module.exports = findCacheFile;
diff --git a/src/cli/format.js b/src/cli/format.js
index 8f5e4867f545..6d91998ac91b 100644
--- a/src/cli/format.js
+++ b/src/cli/format.js
@@ -299,7 +299,7 @@ async function formatFiles(context) {
   }
 
   let formatResultsCache;
-  const cacheFilePath = findCacheFile();
+  const cacheFilePath = await findCacheFile(context.argv.cacheLocation);
   if (context.argv.cache) {
     formatResultsCache = new FormatResultsCache(
       cacheFilePath,
@@ -312,9 +312,11 @@ async function formatFiles(context) {
       );
       process.exit(2);
     }
-    const stat = await statSafe(cacheFilePath);
-    if (stat) {
-      await fs.unlink(cacheFilePath);
+    if (!context.argv.cacheLocation) {
+      const stat = await statSafe(cacheFilePath);
+      if (stat) {
+        await fs.unlink(cacheFilePath);
+      }
     }
   }
 
diff --git a/src/cli/utils.js b/src/cli/utils.js
index a846c185cfde..d13b5efcf15c 100644
--- a/src/cli/utils.js
+++ b/src/cli/utils.js
@@ -67,4 +67,17 @@ async function statSafe(filePath) {
   }
 }
 
-module.exports = { printToScreen, groupBy, pick, createHash, statSafe };
+/**
+ * @param {string} value
+ * @returns {boolean}
+ */
+function isJson(value) {
+  try {
+    JSON.parse(value);
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+module.exports = { printToScreen, groupBy, pick, createHash, statSafe, isJson };


Test patch:
diff --git a/tests/integration/__tests__/__snapshots__/early-exit.js.snap b/tests/integration/__tests__/__snapshots__/early-exit.js.snap
index 63f00b025ee6..d7886195e236 100644
--- a/tests/integration/__tests__/__snapshots__/early-exit.js.snap
+++ b/tests/integration/__tests__/__snapshots__/early-exit.js.snap
@@ -141,6 +141,7 @@ Other options:
 
   --cache                  Only format changed files. Cannot use with --stdin-filepath.
                            Defaults to false.
+  --cache-location <path>  Path to the cache file.
   --cache-strategy <metadata|content>
                            Strategy for the cache to use for detecting changed files.
   --no-color               Do not colorize error messages.
@@ -315,6 +316,7 @@ Other options:
 
   --cache                  Only format changed files. Cannot use with --stdin-filepath.
                            Defaults to false.
+  --cache-location <path>  Path to the cache file.
   --cache-strategy <metadata|content>
                            Strategy for the cache to use for detecting changed files.
   --no-color               Do not colorize error messages.
diff --git a/tests/integration/__tests__/__snapshots__/help-options.js.snap b/tests/integration/__tests__/__snapshots__/help-options.js.snap
index 009f516d3495..5574c326f2cc 100644
--- a/tests/integration/__tests__/__snapshots__/help-options.js.snap
+++ b/tests/integration/__tests__/__snapshots__/help-options.js.snap
@@ -57,6 +57,17 @@ Default: false
 
 exports[`show detailed usage with --help cache (write) 1`] = `[]`;
 
+exports[`show detailed usage with --help cache-location (stderr) 1`] = `""`;
+
+exports[`show detailed usage with --help cache-location (stdout) 1`] = `
+"--cache-location <path>
+
+  Path to the cache file.
+"
+`;
+
+exports[`show detailed usage with --help cache-location (write) 1`] = `[]`;
+
 exports[`show detailed usage with --help cache-strategy (stderr) 1`] = `""`;
 
 exports[`show detailed usage with --help cache-strategy (stdout) 1`] = `
diff --git a/tests/integration/__tests__/cache.js b/tests/integration/__tests__/cache.js
index 863cd29017d9..a15618395436 100644
--- a/tests/integration/__tests__/cache.js
+++ b/tests/integration/__tests__/cache.js
@@ -18,6 +18,9 @@ describe("--cache option", () => {
     "node_modules/.cache/prettier/.prettier-cache"
   );
 
+  const nonDefaultCacheFileName = ".non-default-cache-file";
+  const nonDefaultCacheFilePath = path.join(dir, nonDefaultCacheFileName);
+
   let contentA;
   let contentB;
 
@@ -28,6 +31,7 @@ describe("--cache option", () => {
 
   afterEach(async () => {
     rimraf.sync(path.join(dir, "node_modules"));
+    rimraf.sync(nonDefaultCacheFilePath);
     await fs.writeFile(path.join(dir, "a.js"), contentA);
     await fs.writeFile(path.join(dir, "b.js"), contentB);
   });
@@ -68,6 +72,20 @@ describe("--cache option", () => {
     );
   });
 
+  it("throws error when `--cache-location` is a directory.", async () => {
+    const { stderr } = await runPrettier(dir, [
+      "foo.js",
+      "--cache",
+      "--cache-location",
+      "dir",
+    ]);
+    expect(stripAnsi(stderr.trim())).toEqual(
+      expect.stringMatching(
+        /\[error] Resolved --cache-location '.+' is a directory/
+      )
+    );
+  });
+
   describe("--cache-strategy metadata", () => {
     it("creates default cache file named `node_modules/.cache/prettier/.prettier-cache`", async () => {
       await expect(fs.stat(defaultCacheFile)).rejects.toHaveProperty(
@@ -370,4 +388,83 @@ describe("--cache option", () => {
       await expect(fs.stat(defaultCacheFile)).rejects.toThrowError();
     });
   });
+
+  describe("--cache-location", () => {
+    it("doesn't create default cache file when `--cache-location` exists", async () => {
+      await expect(fs.stat(defaultCacheFile)).rejects.toHaveProperty(
+        "code",
+        "ENOENT"
+      );
+      await runPrettier(dir, [
+        "--cache",
+        "--cache-location",
+        nonDefaultCacheFileName,
+        ".",
+      ]);
+      await expect(fs.stat(defaultCacheFile)).rejects.toHaveProperty(
+        "code",
+        "ENOENT"
+      );
+    });
+
+    it("throws error for invalid JSON file", async () => {
+      const { stderr } = await runPrettier(dir, [
+        "--cache",
+        "--cache-location",
+        "a.js",
+        ".",
+      ]);
+      expect(stripAnsi(stderr).trim()).toEqual(
+        expect.stringMatching(/\[error] '.+' isn't a valid JSON file/)
+      );
+    });
+
+    describe("file", () => {
+      it("creates the cache file at location specified by `--cache-location`", async () => {
+        await expect(fs.stat(nonDefaultCacheFilePath)).rejects.toHaveProperty(
+          "code",
+          "ENOENT"
+        );
+        await runPrettier(dir, [
+          "--cache",
+          "--cache-location",
+          nonDefaultCacheFileName,
+          ".",
+        ]);
+        await expect(
+          fs.stat(nonDefaultCacheFilePath)
+        ).resolves.not.toThrowError();
+      });
+
+      it("does'nt format when cache is available", async () => {
+        const { stdout: firstStdout } = await runPrettier(dir, [
+          "--cache",
+          "--write",
+          "--cache-location",
+          nonDefaultCacheFileName,
+          ".",
+        ]);
+        expect(stripAnsi(firstStdout).split("\n").filter(Boolean)).toEqual(
+          expect.arrayContaining([
+            expect.stringMatching(/^a\.js .+ms$/),
+            expect.stringMatching(/^b\.js .+ms$/),
+          ])
+        );
+
+        const { stdout: secondStdout } = await runPrettier(dir, [
+          "--cache",
+          "--write",
+          "--cache-location",
+          nonDefaultCacheFileName,
+          ".",
+        ]);
+        expect(stripAnsi(secondStdout).split("\n").filter(Boolean)).toEqual(
+          expect.arrayContaining([
+            expect.stringMatching(/^a\.js .+ms \(cached\)$/),
+            expect.stringMatching(/^b\.js .+ms \(cached\)$/),
+          ])
+        );
+      });
+    });
+  });
 });
diff --git a/tests/integration/cli/cache/dir/.gitkeep b/tests/integration/cli/cache/dir/.gitkeep
new file mode 100644
index 000000000000..e69de29bb2d1