diff --git a/changelog_unreleased/cli/12800.md b/changelog_unreleased/cli/12800.md new file mode 100644 index 000000000000..a154ef1177ef --- /dev/null +++ b/changelog_unreleased/cli/12800.md @@ -0,0 +1,25 @@ +#### [HIGHLIGHT]Add `--cache` and `--cache-strategy` CLI option (#12800 by @sosukesuzuki) + +Two new CLI options have been added for a caching system similar to [ESLint's one](https://eslint.org/docs/user-guide/command-line-interface#caching). + +##### `--cache` + +If this option is enabled, the following values are used as cache keys and the file is formatted only if one of them is changed. + +- Prettier version +- Options +- Node.js version +- (if `--cache-strategy` is `content`) content of the file +- (if `--cache-strategy` is `metadata`) file metadata, such as timestamps + +```bash +prettier --write --cache src +``` + +##### `--cache-strategy` + +Strategy for the cache to use for detecting changed files. Can be either `metadata` or `content`. If no strategy is specified, `content` will be used. + +```bash +prettier --write --cache --cache-strategy metadata src +``` diff --git a/cspell.json b/cspell.json index 6e12e2740416..37f04c11cdd1 100644 --- a/cspell.json +++ b/cspell.json @@ -289,6 +289,7 @@ "sandhose", "Sapegin", "sbdchd", + "sdbm", "scandir", "Serializers", "setlocal", diff --git a/docs/cli.md b/docs/cli.md index f72895ebd8a9..af3afef5f9c1 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -204,3 +204,35 @@ Prevent errors when pattern is unmatched. ## `--no-plugin-search` Disable plugin autoloading. + +## `--cache` + +If this option is enabled, the following values are used as cache keys and the file is formatted only if one of them is changed. + +- Prettier version +- Options +- Node.js version +- (if `--cache-strategy` is `metadata`) file metadata, such as timestamps +- (if `--cache-strategy` is `content`) content of the file + +```bash +prettier --write --cache src +``` + +Running Prettier without `--cache` will delete the cache. + +Also, since the cache file is stored in `./node_modules/.cache/prettier/.prettier-cache`, so you can use `rm ./node_modules/.cache/prettier/.prettier-cache` to remove it manually. + +> Plugins version and implementation are not used as cache keys. We recommend that you delete the cache when updating plugins. + +## `--cache-strategy` + +Strategy for the cache to use for detecting changed files. Can be either `metadata` or `content`. + +In general, `metadata` is faster. However, `content` is useful for updating the timestamp without changing the file content. This can happen, for example, during git operations such as `git clone`, because it does not track file modification times. + +If no strategy is specified, `content` will be used. + +```bash +prettier --write --cache --cache-strategy metadata src +``` diff --git a/package.json b/package.json index 22e3f492a434..f3149c4c8a76 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,8 @@ "esutils": "2.0.3", "fast-glob": "3.2.11", "fast-json-stable-stringify": "2.1.0", + "file-entry-cache": "6.0.1", + "find-cache-dir": "3.3.2", "find-parent-dir": "0.3.1", "flow-parser": "0.180.0", "get-stdin": "8.0.0", @@ -79,6 +81,7 @@ "remark-math": "3.0.1", "remark-parse": "8.0.3", "resolve": "1.22.0", + "sdbm": "2.0.0", "semver": "7.3.7", "string-width": "5.0.1", "strip-ansi": "7.0.1", @@ -96,6 +99,8 @@ "@esbuild-plugins/node-modules-polyfill": "0.1.4", "@glimmer/reference": "0.84.2", "@types/estree": "0.0.51", + "@types/file-entry-cache": "5.0.2", + "@types/find-cache-dir": "3.2.1", "@types/jest": "27.4.1", "@typescript-eslint/eslint-plugin": "5.20.0", "babel-jest": "27.5.1", diff --git a/scripts/vendors/vendor-meta.json b/scripts/vendors/vendor-meta.json index 0a5c00b08b0b..e4505dcf7016 100644 --- a/scripts/vendors/vendor-meta.json +++ b/scripts/vendors/vendor-meta.json @@ -9,6 +9,7 @@ "html-void-elements": "html-void-elements.json", "leven": "leven.js", "mem": "mem.js", + "sdbm": "sdbm.js", "string-width": "string-width.js", "strip-ansi": "strip-ansi.js" }, @@ -503,6 +504,23 @@ }, "contributors": [] }, + { + "name": "sdbm", + "maintainers": [], + "version": "2.0.0", + "description": "SDBM non-cryptographic hash function", + "repository": "sindresorhus/sdbm", + "homepage": null, + "private": false, + "license": "MIT", + "licenseText": "MIT License\n\nCopyright (c) Sindre Sorhus (https://sindresorhus.com)\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n", + "author": { + "name": "Sindre Sorhus", + "email": "sindresorhus@gmail.com", + "url": "https://sindresorhus.com" + }, + "contributors": [] + }, { "name": "ansi-regex", "maintainers": [], diff --git a/scripts/vendors/vendors.mjs b/scripts/vendors/vendors.mjs index c477dd756263..0473e0daa5e3 100644 --- a/scripts/vendors/vendors.mjs +++ b/scripts/vendors/vendors.mjs @@ -8,6 +8,7 @@ const vendors = [ "html-void-elements", "leven", "mem", + "sdbm", "string-width", "strip-ansi", "tempy", diff --git a/src/cli/constant.js b/src/cli/constant.js index 5755a06e0e6e..c7cc8958dcd4 100644 --- a/src/cli/constant.js +++ b/src/cli/constant.js @@ -71,6 +71,25 @@ const categoryOrder = [ */ /* eslint sort-keys: "error" */ const options = { + cache: { + default: false, + description: "Only format changed files. Cannot use with --stdin-filepath.", + type: "boolean", + }, + "cache-strategy": { + choices: [ + { + description: "Use the file metadata such as timestamps as cache keys", + value: "metadata", + }, + { + description: "Use the file content as cache keys", + value: "content", + }, + ], + description: "Strategy for the cache to use for detecting changed files.", + type: "choice", + }, check: { alias: "c", category: coreOptions.CATEGORY_OUTPUT, diff --git a/src/cli/expand-patterns.js b/src/cli/expand-patterns.js index d62108f7ec95..c097b10d3870 100644 --- a/src/cli/expand-patterns.js +++ b/src/cli/expand-patterns.js @@ -1,9 +1,10 @@ "use strict"; const path = require("path"); -const { promises: fs } = require("fs"); const fastGlob = require("fast-glob"); +const { statSafe } = require("./utils.js"); + /** @typedef {import('./context').Context} Context */ /** @@ -173,22 +174,6 @@ function sortPaths(paths) { return paths.sort((a, b) => a.localeCompare(b)); } -/** - * Get stats of a given path. - * @param {string} filePath The path to target file. - * @returns {Promise} The stats. - */ -async function statSafe(filePath) { - try { - return await fs.stat(filePath); - } catch (error) { - /* istanbul ignore next */ - if (error.code !== "ENOENT") { - throw error; - } - } -} - /** * This function should be replaced with `fastGlob.escapePath` when these issues are fixed: * - https://github.com/mrmlnc/fast-glob/issues/261 diff --git a/src/cli/find-cache-file.js b/src/cli/find-cache-file.js new file mode 100644 index 000000000000..c8960ead4788 --- /dev/null +++ b/src/cli/find-cache-file.js @@ -0,0 +1,19 @@ +"use strict"; + +const os = require("os"); +const path = require("path"); +const findCacheDir = require("find-cache-dir"); + +/** + * Find default cache file (`./node_modules/.cache/prettier/.prettier-cache`) using https://github.com/avajs/find-cache-dir + * + * @returns {string} + */ +function findCacheFile() { + const cacheDir = + findCacheDir({ name: "prettier", create: true }) || os.tmpdir(); + const cacheFilePath = path.join(cacheDir, ".prettier-cache"); + return cacheFilePath; +} + +module.exports = findCacheFile; diff --git a/src/cli/format-results-cache.js b/src/cli/format-results-cache.js new file mode 100644 index 000000000000..3dfb79d16535 --- /dev/null +++ b/src/cli/format-results-cache.js @@ -0,0 +1,96 @@ +"use strict"; + +// Inspired by LintResultsCache from ESLint +// https://github.com/eslint/eslint/blob/c2d0a830754b6099a3325e6d3348c3ba983a677a/lib/cli-engine/lint-result-cache.js + +const fileEntryCache = require("file-entry-cache"); +const stringify = require("fast-json-stable-stringify"); +// eslint-disable-next-line no-restricted-modules +const { version: prettierVersion } = require("../index.js"); +const { createHash } = require("./utils.js"); + +const optionsHashCache = new WeakMap(); +const nodeVersion = process && process.version; + +/** + * @param {*} options + * @returns {string} + */ +function getHashOfOptions(options) { + if (optionsHashCache.has(options)) { + return optionsHashCache.get(options); + } + const hash = createHash( + `${prettierVersion}_${nodeVersion}_${stringify(options)}` + ); + optionsHashCache.set(options, hash); + return hash; +} + +/** + * @typedef {{ hashOfOptions?: string }} OurMeta + * @typedef {import("file-entry-cache").FileDescriptor} FileDescriptor + * + * @param {import("file-entry-cache").FileDescriptor} fileDescriptor + * @returns {FileDescriptor["meta"] & OurMeta} + */ +function getMetadataFromFileDescriptor(fileDescriptor) { + return fileDescriptor.meta; +} + +class FormatResultsCache { + /** + * @param {string} cacheFileLocation The path of cache file location. (default: `node_modules/.cache/prettier/prettier-cache`) + * @param {string} cacheStrategy + */ + constructor(cacheFileLocation, cacheStrategy) { + const useChecksum = cacheStrategy === "content"; + + this.cacheFileLocation = cacheFileLocation; + this.fileEntryCache = fileEntryCache.create( + /* cacheId */ cacheFileLocation, + /* directory */ undefined, + useChecksum + ); + } + + /** + * @param {string} filePath + * @param {any} options + */ + existsAvailableFormatResultsCache(filePath, options) { + const fileDescriptor = this.fileEntryCache.getFileDescriptor(filePath); + const hashOfOptions = getHashOfOptions(options); + const meta = getMetadataFromFileDescriptor(fileDescriptor); + const changed = + fileDescriptor.changed || meta.hashOfOptions !== hashOfOptions; + + if (fileDescriptor.notFound) { + return false; + } + + if (changed) { + return false; + } + + return true; + } + + /** + * @param {string} filePath + * @param {any} options + */ + setFormatResultsCache(filePath, options) { + const fileDescriptor = this.fileEntryCache.getFileDescriptor(filePath); + const meta = getMetadataFromFileDescriptor(fileDescriptor); + if (fileDescriptor && !fileDescriptor.notFound) { + meta.hashOfOptions = getHashOfOptions(options); + } + } + + reconcile() { + this.fileEntryCache.reconcile(); + } +} + +module.exports = FormatResultsCache; diff --git a/src/cli/format.js b/src/cli/format.js index 0f89481eb2f1..8f5e4867f545 100644 --- a/src/cli/format.js +++ b/src/cli/format.js @@ -15,6 +15,9 @@ const { createIgnorer, errors } = require("./prettier-internal.js"); const { expandPatterns, fixWindowsSlashes } = require("./expand-patterns.js"); const getOptionsForFile = require("./options/get-options-for-file.js"); const isTTY = require("./is-tty.js"); +const findCacheFile = require("./find-cache-file.js"); +const FormatResultsCache = require("./format-results-cache.js"); +const { statSafe } = require("./utils.js"); function diff(a, b) { return require("diff").createTwoFilesPatch("", "", a, b, "", "", { @@ -295,6 +298,26 @@ async function formatFiles(context) { context.logger.log("Checking formatting..."); } + let formatResultsCache; + const cacheFilePath = findCacheFile(); + if (context.argv.cache) { + formatResultsCache = new FormatResultsCache( + cacheFilePath, + context.argv.cacheStrategy || "content" + ); + } else { + if (context.argv.cacheStrategy) { + context.logger.error( + "`--cache-strategy` cannot be used without `--cache`." + ); + process.exit(2); + } + const stat = await statSafe(cacheFilePath); + if (stat) { + await fs.unlink(cacheFilePath); + } + } + for await (const pathOrError of expandPatterns(context)) { if (typeof pathOrError === "object") { context.logger.error(pathOrError.error); @@ -362,17 +385,28 @@ async function formatFiles(context) { const start = Date.now(); + const isCacheExists = formatResultsCache?.existsAvailableFormatResultsCache( + filename, + options + ); + let result; let output; try { - result = format(context, input, options); + if (isCacheExists) { + result = { formatted: input }; + } else { + result = format(context, input, options); + } output = result.formatted; } catch (error) { handleError(context, filename, error, printedFilename); continue; } + formatResultsCache?.setFormatResultsCache(filename, options); + const isDifferent = output !== input; if (printedFilename) { @@ -408,7 +442,12 @@ async function formatFiles(context) { process.exitCode = 2; } } else if (!context.argv.check && !context.argv.listDifferent) { - context.logger.log(`${chalk.grey(filename)} ${Date.now() - start}ms`); + const message = `${chalk.grey(filename)} ${Date.now() - start}ms`; + if (isCacheExists) { + context.logger.log(`${message} (cached)`); + } else { + context.logger.log(message); + } } } else if (context.argv.debugCheck) { /* istanbul ignore else */ @@ -431,6 +470,8 @@ async function formatFiles(context) { } } + formatResultsCache?.reconcile(); + // Print check summary based on expected exit code if (context.argv.check) { if (numberOfUnformattedFilesFound === 0) { diff --git a/src/cli/index.js b/src/cli/index.js index 1c3ea9810ecf..6cb0a64d5aaa 100644 --- a/src/cli/index.js +++ b/src/cli/index.js @@ -103,6 +103,10 @@ async function main(context) { } else if (context.argv.fileInfo) { await logFileInfoOrDie(context); } else if (useStdin) { + if (context.argv.cache) { + context.logger.error("`--cache` cannot be used with stdin."); + process.exit(2); + } await formatStdin(context); } else if (hasFilePatterns) { await formatFiles(context); diff --git a/src/cli/utils.js b/src/cli/utils.js index 3fc2637dde39..a846c185cfde 100644 --- a/src/cli/utils.js +++ b/src/cli/utils.js @@ -1,5 +1,10 @@ "use strict"; +const { promises: fs } = require("fs"); + +// eslint-disable-next-line no-restricted-modules +const { default: sdbm } = require("../../vendors/sdbm.js"); + // eslint-disable-next-line no-console const printToScreen = console.log.bind(console); @@ -38,4 +43,28 @@ function pick(object, keys) { return Object.fromEntries(entries); } -module.exports = { printToScreen, groupBy, pick }; +/** + * @param {string} source + * @returns {string} + */ +function createHash(source) { + return String(sdbm(source)); +} + +/** + * Get stats of a given path. + * @param {string} filePath The path to target file. + * @returns {Promise} The stats. + */ +async function statSafe(filePath) { + try { + return await fs.stat(filePath); + } catch (error) { + /* istanbul ignore next */ + if (error.code !== "ENOENT") { + throw error; + } + } +} + +module.exports = { printToScreen, groupBy, pick, createHash, statSafe }; diff --git a/tests/integration/__tests__/__snapshots__/early-exit.js.snap b/tests/integration/__tests__/__snapshots__/early-exit.js.snap index 0daa6dcd4f9c..63f00b025ee6 100644 --- a/tests/integration/__tests__/__snapshots__/early-exit.js.snap +++ b/tests/integration/__tests__/__snapshots__/early-exit.js.snap @@ -139,6 +139,10 @@ Editor options: Other options: + --cache Only format changed files. Cannot use with --stdin-filepath. + Defaults to false. + --cache-strategy + Strategy for the cache to use for detecting changed files. --no-color Do not colorize error messages. --no-error-on-unmatched-pattern Prevent errors when pattern is unmatched. @@ -309,6 +313,10 @@ Editor options: Other options: + --cache Only format changed files. Cannot use with --stdin-filepath. + Defaults to false. + --cache-strategy + Strategy for the cache to use for detecting changed files. --no-color Do not colorize error messages. --no-error-on-unmatched-pattern Prevent errors when pattern is unmatched. diff --git a/tests/integration/__tests__/__snapshots__/help-options.js.snap b/tests/integration/__tests__/__snapshots__/help-options.js.snap index a90130ce8a21..009f516d3495 100644 --- a/tests/integration/__tests__/__snapshots__/help-options.js.snap +++ b/tests/integration/__tests__/__snapshots__/help-options.js.snap @@ -44,6 +44,35 @@ Default: true exports[`show detailed usage with --help bracket-spacing (write) 1`] = `[]`; +exports[`show detailed usage with --help cache (stderr) 1`] = `""`; + +exports[`show detailed usage with --help cache (stdout) 1`] = ` +"--cache + + Only format changed files. Cannot use with --stdin-filepath. + +Default: false +" +`; + +exports[`show detailed usage with --help cache (write) 1`] = `[]`; + +exports[`show detailed usage with --help cache-strategy (stderr) 1`] = `""`; + +exports[`show detailed usage with --help cache-strategy (stdout) 1`] = ` +"--cache-strategy + + Strategy for the cache to use for detecting changed files. + +Valid options: + + metadata Use the file metadata such as timestamps as cache keys + content Use the file content as cache keys +" +`; + +exports[`show detailed usage with --help cache-strategy (write) 1`] = `[]`; + exports[`show detailed usage with --help check (stderr) 1`] = `""`; exports[`show detailed usage with --help check (stdout) 1`] = ` diff --git a/tests/integration/__tests__/cache.js b/tests/integration/__tests__/cache.js new file mode 100644 index 000000000000..863cd29017d9 --- /dev/null +++ b/tests/integration/__tests__/cache.js @@ -0,0 +1,373 @@ +"use strict"; + +const path = require("path"); +const { promises: fs } = require("fs"); +const rimraf = require("rimraf"); +const { default: stripAnsi } = require("../../../vendors/strip-ansi.js"); + +const runPrettier = require("../run-prettier.js"); + +function resolveDir(dir) { + return path.join(__dirname, "..", dir); +} + +describe("--cache option", () => { + const dir = resolveDir("cli/cache"); + const defaultCacheFile = path.join( + dir, + "node_modules/.cache/prettier/.prettier-cache" + ); + + let contentA; + let contentB; + + beforeAll(async () => { + contentA = await fs.readFile(path.join(dir, "a.js"), "utf8"); + contentB = await fs.readFile(path.join(dir, "b.js"), "utf8"); + }); + + afterEach(async () => { + rimraf.sync(path.join(dir, "node_modules")); + await fs.writeFile(path.join(dir, "a.js"), contentA); + await fs.writeFile(path.join(dir, "b.js"), contentB); + }); + + it("throw error when cache-strategy is invalid", async () => { + const { stderr } = await runPrettier(dir, [ + "--cache", + "--cache-strategy", + "invalid", + ".", + ]); + expect(stripAnsi(stderr.trim())).toBe( + '[error] Invalid --cache-strategy value. Expected "content" or "metadata", but received "invalid".' + ); + }); + + it("throws error when use with --stdin-filepath", async () => { + const { stderr } = await runPrettier( + dir, + ["--cache", "--stdin-filepath", "foo.js"], + { input: "const a = a;" } + ); + expect(stripAnsi(stderr.trim())).toBe( + "[error] `--cache` cannot be used with stdin." + ); + }); + + it("throws error when use `--cache-strategy` without `--cache`.", async () => { + const { stderr } = await runPrettier( + dir, + ["foo.js", "--cache-strategy", "content"], + { + input: "const a = a;", + } + ); + expect(stripAnsi(stderr.trim())).toBe( + "[error] `--cache-strategy` cannot be used without `--cache`." + ); + }); + + describe("--cache-strategy metadata", () => { + it("creates default cache file named `node_modules/.cache/prettier/.prettier-cache`", async () => { + await expect(fs.stat(defaultCacheFile)).rejects.toHaveProperty( + "code", + "ENOENT" + ); + await runPrettier(dir, ["--cache", "--cache-strategy", "metadata", "."]); + await expect(fs.stat(defaultCacheFile)).resolves.not.toThrowError(); + }); + + it("does'nt format when cache is available", async () => { + const { stdout: firstStdout } = await runPrettier(dir, [ + "--cache", + "--write", + "--cache-strategy", + "metadata", + ".", + ]); + 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-strategy", + "metadata", + ".", + ]); + expect(stripAnsi(secondStdout).split("\n").filter(Boolean)).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^a\.js .+ms \(cached\)$/), + expect.stringMatching(/^b\.js .+ms \(cached\)$/), + ]) + ); + }); + + it("re-formats when a file has been updated.", async () => { + const { stdout: firstStdout } = await runPrettier(dir, [ + "--cache", + "--write", + "--cache-strategy", + "metadata", + ".", + ]); + expect(stripAnsi(firstStdout).split("\n").filter(Boolean)).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^a\.js .+ms$/), + expect.stringMatching(/^b\.js .+ms$/), + ]) + ); + + // Update `a.js` + await fs.writeFile(path.join(dir, "a.js"), "const a = `a`;"); + + const { stdout: secondStdout } = await runPrettier(dir, [ + "--cache", + "--write", + "--cache-strategy", + "metadata", + ".", + ]); + expect(stripAnsi(secondStdout).split("\n").filter(Boolean)).toEqual( + // the cache of `b.js` is only available. + expect.arrayContaining([ + expect.stringMatching(/^a\.js .+ms$/), + expect.stringMatching(/^b\.js .+ms \(cached\)$/), + ]) + ); + }); + + it("re-formats when timestamp has been updated", async () => { + const { stdout: firstStdout } = await runPrettier(dir, [ + "--cache", + "--write", + "--cache-strategy", + "metadata", + ".", + ]); + expect(stripAnsi(firstStdout).split("\n").filter(Boolean)).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^a\.js .+ms$/), + expect.stringMatching(/^b\.js .+ms$/), + ]) + ); + + // update timestamp + const time = new Date(); + await fs.utimes(path.join(dir, "a.js"), time, time); + + const { stdout: secondStdout } = await runPrettier(dir, [ + "--cache", + "--write", + "--cache-strategy", + "metadata", + ".", + ]); + expect(stripAnsi(secondStdout).split("\n").filter(Boolean)).toEqual( + // the cache of `b.js` is only available. + expect.arrayContaining([ + expect.stringMatching(/^a\.js .+ms$/), + expect.stringMatching(/^b\.js .+ms \(cached\)$/), + ]) + ); + }); + + it("re-formats when options has been updated.", async () => { + const { stdout: firstStdout } = await runPrettier(dir, [ + "--cache", + "--write", + "--cache-strategy", + "metadata", + ".", + ]); + 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", + "--cache-strategy", + "metadata", + "--write", + "--trailing-comma", + "all", + ".", + ]); + expect(stripAnsi(secondStdout).split("\n").filter(Boolean)).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^a\.js .+ms$/), + expect.stringMatching(/^b\.js .+ms$/), + ]) + ); + }); + + it("removes cache file when run Prettier without `--cache` option", async () => { + await runPrettier(dir, [ + "--cache", + "--write", + "--cache-strategy", + "metadata", + ".", + ]); + await expect(fs.stat(defaultCacheFile)).resolves.not.toThrowError(); + await runPrettier(dir, ["--write", "."]); + await expect(fs.stat(defaultCacheFile)).rejects.toThrowError(); + }); + }); + + describe("--cache-strategy content", () => { + it("creates default cache file named `node_modules/.cache/prettier/.prettier-cache`", async () => { + await expect(fs.stat(defaultCacheFile)).rejects.toHaveProperty( + "code", + "ENOENT" + ); + await runPrettier(dir, ["--cache", "--cache-strategy", "content", "."]); + await expect(fs.stat(defaultCacheFile)).resolves.not.toThrowError(); + }); + + it("does'nt format when cache is available", async () => { + const { stdout: firstStdout } = await runPrettier(dir, [ + "--cache", + "--cache-strategy", + "content", + "--write", + ".", + ]); + 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", + "--cache-strategy", + "content", + "--write", + ".", + ]); + expect(stripAnsi(secondStdout).split("\n").filter(Boolean)).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^a\.js .+ms \(cached\)$/), + expect.stringMatching(/^b\.js .+ms \(cached\)$/), + ]) + ); + }); + + it("re-formats when a file has been updated.", async () => { + const { stdout: firstStdout } = await runPrettier(dir, [ + "--cache", + "--cache-strategy", + "content", + "--write", + ".", + ]); + expect(stripAnsi(firstStdout).split("\n").filter(Boolean)).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^a\.js .+ms$/), + expect.stringMatching(/^b\.js .+ms$/), + ]) + ); + + // Update `a.js` + await fs.writeFile(path.join(dir, "a.js"), "const a = `a`;"); + + const { stdout: secondStdout } = await runPrettier(dir, [ + "--cache", + "--cache-strategy", + "content", + "--write", + ".", + ]); + expect(stripAnsi(secondStdout).split("\n").filter(Boolean)).toEqual( + // the cache of `b.js` is only available. + expect.arrayContaining([ + expect.stringMatching(/^a\.js .+ms$/), + expect.stringMatching(/^b\.js .+ms \(cached\)$/), + ]) + ); + }); + + it("does'nt re-format when timestamp has been updated", async () => { + const { stdout: firstStdout } = await runPrettier(dir, [ + "--cache", + "--cache-strategy", + "content", + "--write", + ".", + ]); + expect(stripAnsi(firstStdout).split("\n").filter(Boolean)).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^a\.js .+ms$/), + expect.stringMatching(/^b\.js .+ms$/), + ]) + ); + + // update timestamp + const time = new Date(); + await fs.utimes(path.join(dir, "a.js"), time, time); + + const { stdout: secondStdout } = await runPrettier(dir, [ + "--cache", + "--cache-strategy", + "content", + "--write", + ".", + ]); + expect(stripAnsi(secondStdout).split("\n").filter(Boolean)).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^a\.js .+ms \(cached\)$/), + expect.stringMatching(/^b\.js .+ms \(cached\)$/), + ]) + ); + }); + + it("re-formats when options has been updated.", async () => { + const { stdout: firstStdout } = await runPrettier(dir, [ + "--cache", + "--cache-strategy", + "content", + "--write", + ".", + ]); + 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-strategy", + "content", + "--trailing-comma", + "all", + ".", + ]); + expect(stripAnsi(secondStdout).split("\n").filter(Boolean)).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^a\.js .+ms$/), + expect.stringMatching(/^b\.js .+ms$/), + ]) + ); + }); + + it("removes cache file when run Prettier without `--cache` option", async () => { + await runPrettier(dir, ["--cache", "--write", "."]); + await expect(fs.stat(defaultCacheFile)).resolves.not.toThrowError(); + await runPrettier(dir, ["--write", "."]); + await expect(fs.stat(defaultCacheFile)).rejects.toThrowError(); + }); + }); +}); diff --git a/tests/integration/cli/cache/a.js b/tests/integration/cli/cache/a.js new file mode 100644 index 000000000000..b824ea0f9278 --- /dev/null +++ b/tests/integration/cli/cache/a.js @@ -0,0 +1,3 @@ +function a() { + console.log("this is a.js") +} diff --git a/tests/integration/cli/cache/b.js b/tests/integration/cli/cache/b.js new file mode 100644 index 000000000000..4bc80d22e7a9 --- /dev/null +++ b/tests/integration/cli/cache/b.js @@ -0,0 +1,3 @@ +function b() { + console.log("this is b.js"); +} diff --git a/tests/integration/cli/cache/package.json b/tests/integration/cli/cache/package.json new file mode 100644 index 000000000000..91dc022ee930 --- /dev/null +++ b/tests/integration/cli/cache/package.json @@ -0,0 +1,3 @@ +{ + "description": "this file is needed for testing to create default cache file" +} diff --git a/vendors/sdbm.d.ts b/vendors/sdbm.d.ts new file mode 100644 index 000000000000..812356628a7f --- /dev/null +++ b/vendors/sdbm.d.ts @@ -0,0 +1,3 @@ +// This file is generated automatically. +export {default} from "sdbm"; +export * from "sdbm"; diff --git a/vendors/sdbm.js b/vendors/sdbm.js new file mode 100644 index 000000000000..d867062988b5 --- /dev/null +++ b/vendors/sdbm.js @@ -0,0 +1,35 @@ +// @ts-nocheck +// This file is generated automatically +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// node_modules/sdbm/index.js +var sdbm_exports = {}; +__export(sdbm_exports, { + default: () => sdbm +}); +module.exports = __toCommonJS(sdbm_exports); +function sdbm(string) { + let hash = 0; + for (let i = 0; i < string.length; i++) { + hash = string.charCodeAt(i) + (hash << 6) + (hash << 16) - hash; + } + return hash >>> 0; +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = {}); diff --git a/website/playground/sidebar/SidebarOptions.js b/website/playground/sidebar/SidebarOptions.js index d47347c7148a..51d3704f1eac 100644 --- a/website/playground/sidebar/SidebarOptions.js +++ b/website/playground/sidebar/SidebarOptions.js @@ -1,5 +1,5 @@ import * as React from "react"; -import { groupBy } from "../../../src/cli/utils.js"; +import { groupBy } from "../util.js"; import { SidebarCategory } from "./components.js"; import Option from "./options.js"; diff --git a/website/playground/util.js b/website/playground/util.js index 2752aebb68cc..587a54910cb8 100644 --- a/website/playground/util.js +++ b/website/playground/util.js @@ -94,3 +94,27 @@ export function getAstAutoFold(parser) { return astAutoFold.glimmer; } } + +/** + * Copied from https://github.com/prettier/prettier/blob/6fe21780115cf5f74f83876d64b03a727fbab220/src/cli/utils.js#L6-L27 + * @template Obj + * @template Key + * @param {Array} array + * @param {(value: Obj) => Key} iteratee + * @returns {{[p in Key]: T}} + */ +export function groupBy(array, iteratee) { + const result = Object.create(null); + + for (const value of array) { + const key = iteratee(value); + + if (Array.isArray(result[key])) { + result[key].push(value); + } else { + result[key] = [value]; + } + } + + return result; +} diff --git a/yarn.lock b/yarn.lock index 7e9a6ff89975..72e836b3e8d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1638,6 +1638,18 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40" integrity sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ== +"@types/file-entry-cache@5.0.2": + version "5.0.2" + resolved "https://registry.yarnpkg.com/@types/file-entry-cache/-/file-entry-cache-5.0.2.tgz#3d31097d34fb5ff6bd9951f80d4082bda52feece" + integrity sha512-6uLb9gNrW+e4JivzglLQ2eJSyd7xvu5gSkwKIlOZOmuFgz8U7O9ddTwWjmWgUaIeukdQhoWefI5fQ5/MRTw8XA== + dependencies: + "@types/node" "*" + +"@types/find-cache-dir@3.2.1": + version "3.2.1" + resolved "https://registry.yarnpkg.com/@types/find-cache-dir/-/find-cache-dir-3.2.1.tgz#7b959a4b9643a1e6a1a5fe49032693cc36773501" + integrity sha512-frsJrz2t/CeGifcu/6uRo4b+SzAwT4NYCVPu1GN8IB9XTzrpPkGuV0tmh9mN+/L0PklAlsC3u5Fxt0ju00LXIw== + "@types/graceful-fs@^4.1.2": version "4.1.5" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.5.tgz#21ffba0d98da4350db64891f92a9e5db3cdb4e15" @@ -2394,6 +2406,11 @@ commenting@1.1.0: resolved "https://registry.yarnpkg.com/commenting/-/commenting-1.1.0.tgz#fae14345c6437b8554f30bc6aa6c1e1633033590" integrity sha512-YeNK4tavZwtH7jEgK1ZINXzLKm6DZdEMfsaaieOsCAN0S8vsY7UeuO3Q7d/M018EFgE+IeUAuBOKkFccBZsUZA== +commondir@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" + integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs= + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -3367,7 +3384,7 @@ fetch-blob@^3.1.2, fetch-blob@^3.1.4: node-domexception "^1.0.0" web-streams-polyfill "^3.0.3" -file-entry-cache@^6.0.1: +file-entry-cache@6.0.1, file-entry-cache@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== @@ -3381,6 +3398,15 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" +find-cache-dir@3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.2.tgz#b30c5b6eff0730731aea9bbd9dbecbd80256d64b" + integrity sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig== + dependencies: + commondir "^1.0.1" + make-dir "^3.0.2" + pkg-dir "^4.1.0" + find-parent-dir@0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/find-parent-dir/-/find-parent-dir-0.3.1.tgz#c5c385b96858c3351f95d446cab866cbf9f11125" @@ -4849,7 +4875,7 @@ magic-string@^0.25.3: dependencies: sourcemap-codec "^1.4.4" -make-dir@^3.0.0: +make-dir@^3.0.0, make-dir@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== @@ -5399,7 +5425,7 @@ pirates@^4.0.4: resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.5.tgz#feec352ea5c3268fb23a37c702ab1699f35a5f3b" integrity sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ== -pkg-dir@^4.2.0: +pkg-dir@^4.1.0, pkg-dir@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== @@ -5846,6 +5872,11 @@ scslre@^0.1.6: regexp-ast-analysis "^0.2.3" regexpp "^3.2.0" +sdbm@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/sdbm/-/sdbm-2.0.0.tgz#23828c1195e341d0f5810c59dfa60d86278f8718" + integrity sha512-dspMGxvHiwSTgyrmm90jHQV2sDqK46ssbDK+bQAlJ5aRuPo3C7So108V6rCuCDbm1CrNWuPeMpmTNQKPl7vO+A== + semver-compare@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc"