Implement our own config explorer (#15663)

This commit is contained in:
fisker Cheung authored and GitHub committed 2023-12-05 15:00:43 +08:00
1 parent 117c04fcc8
commit 5f0956bc30
21 files changed
+393 -208

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
#### Improve config file search (#15663 by @fisker)
The Prettier config file search performance has been improved by more effective cache strategy.
+1 -1
View File
@@ -66,7 +66,7 @@ If `options.editorconfig` is `true` and an [`.editorconfig` file](https://editor
The promise will be rejected if there was an error parsing the configuration file.
The search starts at `process.cwd()`, or at `fileUrlOrPath` if provided. Please see the [lilconfig docs](https://github.com/antonk52/lilconfig) for details on how the resolving works.
The search starts at `process.cwd()`, or at the directory of `fileUrlOrPath` if provided.
```js
const configFile = await prettier.resolveConfigFile(filePath);
+1 -1
View File
@@ -3,7 +3,7 @@ id: configuration
title: Configuration File
---
Prettier uses [lilconfig](https://github.com/antonk52/lilconfig) for configuration file support. This means you can configure Prettier via (in order of precedence):
You can configure Prettier via (in order of precedence):
- A `"prettier"` key in your `package.json` file.
- A `.prettierrc` file written in JSON or YAML.
-1
View File
@@ -75,7 +75,6 @@
"js-yaml": "4.1.0",
"json5": "2.2.3",
"leven": "4.0.0",
"lilconfig": "2.1.0",
"lines-and-columns": "2.0.4",
"linguist-languages": "7.27.0",
"mem": "9.0.2",
-5
View File
@@ -610,11 +610,6 @@ const nodejsFiles = [
}),
path: require.resolve("@babel/code-frame"),
},
{
module: require.resolve("lilconfig"),
find: "exports.lilconfigSync = lilconfigSync;",
replacement: "",
},
],
addDefaultExport: true,
},
+2 -2
View File
@@ -1,5 +1,4 @@
import fs from "node:fs/promises";
import { lilconfig } from "lilconfig";
import getStdin from "get-stdin";
import { isCI } from "ci-info";
@@ -8,7 +7,8 @@ function writeFormattedFile(file, data) {
}
const mockable = {
lilconfig,
// eslint-disable-next-line unicorn/no-useless-undefined
getPrettierConfigSearchStopDirectory: () => undefined,
getStdin,
isCI: () => isCI,
writeFormattedFile,
-110
View File
@@ -1,110 +0,0 @@
import { pathToFileURL } from "node:url";
import parseToml from "@iarna/toml/parse-async.js";
import parseJson5 from "json5/lib/parse.js";
import yaml from "js-yaml";
import parseJson from "parse-json";
import mockable from "../common/mockable.js";
import loadExternalConfig from "./load-external-config.js";
const searchPlaces = [
"package.json",
".prettierrc",
".prettierrc.json",
".prettierrc.yaml",
".prettierrc.yml",
".prettierrc.json5",
".prettierrc.js",
".prettierrc.mjs",
".prettierrc.cjs",
"prettier.config.js",
"prettier.config.mjs",
"prettier.config.cjs",
".prettierrc.toml",
];
async function loadJs(filepath /*, content*/) {
const module = await import(pathToFileURL(filepath).href);
return module.default;
}
function loadYaml(filepath, content) {
try {
return yaml.load(content);
} catch (/** @type {any} */ error) {
error.message = `YAML Error in ${filepath}:\n${error.message}`;
throw error;
}
}
const loaders = {
async ".toml"(filePath, content) {
try {
return await parseToml(content);
} catch (/** @type {any} */ error) {
error.message = `TOML Error in ${filePath}:\n${error.message}`;
throw error;
}
},
".json5"(filePath, content) {
try {
return parseJson5(content);
} catch (/** @type {any} */ error) {
error.message = `JSON5 Error in ${filePath}:\n${error.message}`;
throw error;
}
},
".json"(filePath, content) {
try {
return parseJson(content);
} catch (/** @type {any} */ error) {
error.message = `JSON Error in ${filePath}:\n${error.message}`;
throw error;
}
},
".js": loadJs,
".mjs": loadJs,
".yaml": loadYaml,
".yml": loadYaml,
noExt: loadYaml,
};
async function transform(result) {
if (!result?.config) {
return result;
}
let { config, filepath } = result;
/*
We support external config
```json
{
"prettier": "my-prettier-config-package-or-file"
}
```
*/
if (typeof config === "string") {
config = await loadExternalConfig(config, filepath);
result.config = config;
}
if (typeof config !== "object") {
throw new TypeError(
"Config is only allowed to be an object, " +
`but received ${typeof config} in "${filepath}"`,
);
}
delete config.$schema;
return result;
}
/**
* @return {ReturnType<import("lilconfig").lilconfig>}
*/
function getExplorer() {
return mockable.lilconfig("prettier", { searchPlaces, loaders, transform });
}
export default getExplorer;
@@ -0,0 +1,119 @@
import path from "node:path";
import iterateDirectoryUp from "iterate-directory-up";
import isFile from "../../utils/is-file.js";
import { loadConfigFromPackageJson } from "./loaders.js";
const CONFIG_FILE_NAMES = [
"package.json",
".prettierrc",
".prettierrc.json",
".prettierrc.yaml",
".prettierrc.yml",
".prettierrc.json5",
".prettierrc.js",
".prettierrc.mjs",
".prettierrc.cjs",
"prettier.config.js",
"prettier.config.mjs",
"prettier.config.cjs",
".prettierrc.toml",
];
async function isPackageJsonFileWithPrettierConfig(file) {
try {
return Boolean(await loadConfigFromPackageJson(file));
} catch {
return false;
}
}
async function searchConfigInDirectory(directory) {
for (const fileName of CONFIG_FILE_NAMES) {
const file = path.join(directory, fileName);
if (!(await isFile(file))) {
continue;
}
if (
fileName !== "package.json" ||
(await isPackageJsonFileWithPrettierConfig(file))
) {
return file;
}
}
}
function createCachedSearchConfigInDirectory() {
const cache = new Map();
return async function (fileOrDirectory) {
if (cache.has(fileOrDirectory)) {
return cache.get(fileOrDirectory);
}
const promise = searchConfigInDirectory(fileOrDirectory);
cache.set(fileOrDirectory, promise);
const result = await promise;
cache.set(fileOrDirectory, result);
return result;
};
}
/** @type {Map} */ // @ts-expect-error -- intentionally not add the `get` method
const noopMap = { has: () => false, set() {} };
class ConfigSearcher {
#shouldCache;
#stopDirectory;
#searchedFilesStore;
#searchConfigInDirectory;
constructor({ stopDirectory, shouldCache }) {
this.#shouldCache = shouldCache;
this.#stopDirectory = stopDirectory;
this.#searchConfigInDirectory = shouldCache
? createCachedSearchConfigInDirectory()
: searchConfigInDirectory;
this.#searchedFilesStore = shouldCache ? new Map() : noopMap;
}
async #searchConfigInDirectories(startDirectory) {
const store = this.#searchedFilesStore;
for (const directory of iterateDirectoryUp(
startDirectory,
this.#stopDirectory,
)) {
// The parent directory may already searched
if (store.has(directory)) {
return store.get(directory);
}
const file = await this.#searchConfigInDirectory(directory);
if (file) {
return file;
}
}
}
async search(startDirectory) {
const store = this.#searchedFilesStore;
if (store.has(startDirectory)) {
return store.get(startDirectory);
}
const configFile = await this.#searchConfigInDirectories(startDirectory);
// Directories between startDirectory and configFile directory should has the same result
if (this.#shouldCache) {
for (const directory of iterateDirectoryUp(
startDirectory,
configFile ? path.dirname(configFile) : startDirectory,
)) {
store.set(directory, configFile);
}
}
return configFile;
}
}
export default ConfigSearcher;
@@ -0,0 +1,64 @@
import path from "node:path";
import mockable from "../../common/mockable.js";
import loadConfigWithoutCache from "./load-config.js";
import ConfigSearcher from "./config-searcher.js";
const loadCache = new Map();
const searchCache = new Map();
function clearCache() {
loadCache.clear();
searchCache.clear();
}
/**
* @param {string} configFile
* @param {{shouldCache?: boolean}} param1
* @returns {Promise<ReturnType<loadConfigWithoutCache>>}
*/
function loadConfig(configFile, { shouldCache }) {
configFile = path.resolve(configFile);
if (!shouldCache || !loadCache.has(configFile)) {
const promise = loadConfigWithoutCache(configFile);
// Even if `shouldCache` is false, we still cache it, so we can use it later
loadCache.set(configFile, promise);
return promise;
}
return loadCache.get(configFile);
}
/**
* @param {{shouldCache?: boolean, stopDirectory?: string}} param0
*/
function getSearchFunction({ shouldCache, stopDirectory }) {
stopDirectory = stopDirectory ? path.resolve(stopDirectory) : undefined;
const searchCacheKey = JSON.stringify({ shouldCache, stopDirectory });
if (!searchCache.has(searchCacheKey)) {
const searcher = new ConfigSearcher({ shouldCache, stopDirectory });
const searchFunction = searcher.search.bind(searcher);
searchCache.set(searchCacheKey, searchFunction);
}
return searchCache.get(searchCacheKey);
}
/**
* @param {string} startDirectory
* @param {{shouldCache?: boolean, stopDirectory?: string}} options
* @returns {Promise<string>}
*/
function searchConfig(startDirectory, options = {}) {
startDirectory = startDirectory
? path.resolve(startDirectory)
: process.cwd();
options.stopDirectory = mockable.getPrettierConfigSearchStopDirectory();
const search = getSearchFunction(options);
return search(startDirectory);
}
export { searchConfig, loadConfig, clearCache };
@@ -0,0 +1,48 @@
import path from "node:path";
import loadExternalConfig from "./load-external-config.js";
import loaders, { loadConfigFromPackageJson } from "./loaders.js";
async function loadConfig(configFile) {
const { base: fileName, ext: extension } = path.parse(configFile);
const load =
fileName === "package.json"
? loadConfigFromPackageJson
: loaders[extension];
if (!load) {
throw new Error(
`No loader specified for extension "${extension || "noExt"}"`,
);
}
let config = await load(configFile);
if (!config) {
return;
}
/*
We support external config
```json
{
"prettier": "my-prettier-config-package-or-file"
}
```
*/
if (typeof config === "string") {
config = await loadExternalConfig(config, configFile);
}
if (typeof config !== "object") {
throw new TypeError(
"Config is only allowed to be an object, " +
`but received ${typeof config} in "${configFile}"`,
);
}
delete config.$schema;
return config;
}
export default loadConfig;
@@ -1,12 +1,12 @@
import requireFromFile from "../utils/require-from-file.js";
import importFromFile from "../utils/import-from-file.js";
import requireFromFile from "../../utils/require-from-file.js";
import importFromFile from "../../utils/import-from-file.js";
const requireErrorCodesShouldBeIgnored = new Set([
"MODULE_NOT_FOUND",
"ERR_REQUIRE_ESM",
"ERR_PACKAGE_PATH_NOT_EXPORTED",
]);
async function loadExternalConfig(config, filepath) {
async function loadExternalConfig(externalConfig, configFile) {
/*
Try `require()` first, this is how it works in Prettier v2.
Kept this because the external config path or package may can't load with `import()`:
@@ -15,14 +15,14 @@ async function loadExternalConfig(config, filepath) {
3. is a dirname with index.js inside
*/
try {
return requireFromFile(config, filepath);
return requireFromFile(externalConfig, configFile);
} catch (/** @type {any} */ error) {
if (!requireErrorCodesShouldBeIgnored.has(error?.code)) {
throw error;
}
}
const module = await importFromFile(config, filepath);
const module = await importFromFile(externalConfig, configFile);
return module.default;
}
@@ -0,0 +1,68 @@
import { pathToFileURL } from "node:url";
import parseToml from "@iarna/toml/parse-async.js";
import parseJson5 from "json5/lib/parse.js";
import jsYaml from "js-yaml";
import parseJson from "parse-json";
import readFile from "../../utils/read-file.js";
async function readJson(file) {
const content = await readFile(file);
try {
return parseJson(content);
} catch (/** @type {any} */ error) {
error.message = `JSON Error in ${file}:\n${error.message}`;
throw error;
}
}
async function loadJs(file) {
const module = await import(pathToFileURL(file).href);
return module.default;
}
async function loadConfigFromPackageJson(file) {
const { prettier } = await readJson(file);
return prettier;
}
async function loadYaml(file) {
const content = await readFile(file);
try {
return jsYaml.load(content);
} catch (/** @type {any} */ error) {
error.message = `YAML Error in ${file}:\n${error.message}`;
throw error;
}
}
const loaders = {
async ".toml"(file) {
const content = await readFile(file);
try {
return await parseToml(content);
} catch (/** @type {any} */ error) {
error.message = `TOML Error in ${file}:\n${error.message}`;
throw error;
}
},
async ".json5"(file) {
const content = await readFile(file);
try {
return parseJson5(content);
} catch (/** @type {any} */ error) {
error.message = `JSON5 Error in ${file}:\n${error.message}`;
throw error;
}
},
".json": readJson,
".js": loadJs,
".mjs": loadJs,
".cjs": loadJs,
".yaml": loadYaml,
".yml": loadYaml,
// No extension
"": loadYaml,
};
export default loaders;
export { readJson, loadConfigFromPackageJson };
+25 -23
View File
@@ -3,15 +3,16 @@ import micromatch from "micromatch";
import mem, { memClear } from "mem";
import { toPath } from "url-or-path";
import partition from "../utils/partition.js";
import {
searchConfig as searchPrettierConfig,
loadConfig as loadPrettierConfigFile,
clearCache as clearPrettierConfigCache,
} from "./prettier-config-explorer/index.js";
import loadEditorConfigWithoutCache from "./resolve-editorconfig.js";
import getPrettierConfigExplorerWithoutCache from "./get-prettier-config-explorer.js";
const getPrettierConfigExplorer = mem(getPrettierConfigExplorerWithoutCache, {
cacheKey: ([options]) => options.cache,
});
const memoizedLoadEditorConfig = mem(loadEditorConfigWithoutCache);
function clearCache() {
memClear(getPrettierConfigExplorer);
clearPrettierConfigCache();
memClear(memoizedLoadEditorConfig);
}
@@ -25,22 +26,24 @@ function loadEditorConfig(filePath, options) {
)(filePath);
}
function loadPrettierConfig(filePath, options) {
const { useCache, config: configPath } = options;
const { load, search } = getPrettierConfigExplorer({
cache: Boolean(useCache),
});
async function loadPrettierConfig(filePath, options) {
const shouldCache = options.useCache;
let configFile = options.config;
if (configPath) {
return load(configPath);
if (!configFile) {
const directory = filePath
? path.dirname(path.resolve(filePath))
: undefined;
configFile = await searchPrettierConfig(directory, { shouldCache });
}
if (!filePath) {
return search();
if (!configFile) {
return;
}
const dirname = path.dirname(path.resolve(filePath));
return search(dirname);
const config = await loadPrettierConfigFile(configFile, { shouldCache });
return { config, configFile };
}
async function resolveConfig(fileUrlOrPath, options) {
@@ -64,7 +67,7 @@ async function resolveConfig(fileUrlOrPath, options) {
if (Array.isArray(merged.plugins)) {
merged.plugins = merged.plugins.map((value) =>
typeof value === "string" && value.startsWith(".") // relative path
? path.resolve(path.dirname(result.filepath), value)
? path.resolve(path.dirname(result.configFile), value)
: value,
);
}
@@ -73,19 +76,18 @@ async function resolveConfig(fileUrlOrPath, options) {
}
async function resolveConfigFile(fileUrlOrPath) {
const { search } = getPrettierConfigExplorerWithoutCache();
const dirname = fileUrlOrPath
const directory = fileUrlOrPath
? path.dirname(path.resolve(toPath(fileUrlOrPath)))
: undefined;
const result = await search(dirname);
return result?.filepath ?? null;
const result = await searchPrettierConfig(directory, { shouldCache: false });
return result ?? null;
}
function mergeOverrides(configResult, filePath) {
const { config, filepath: configPath } = configResult || {};
const { config, configFile } = configResult || {};
const { overrides, ...options } = config || {};
if (filePath && overrides) {
const relativeFilePath = path.relative(path.dirname(configPath), filePath);
const relativeFilePath = path.relative(path.dirname(configFile), filePath);
for (const override of overrides) {
if (
pathMatchesGlobs(
+22
View File
@@ -0,0 +1,22 @@
import fs from "node:fs/promises";
import { toPath } from "url-or-path";
/**
* @param {string | URL} file
* @param {{allowSymlinks: boolean}} [options]
* @returns {Promise<boolean>}
*/
async function isFile(file, options) {
const allowSymlinks = options?.allowSymlinks ?? true;
let stats;
try {
stats = await (allowSymlinks ? fs.stat : fs.lstat)(toPath(file));
} catch {
return false;
}
return stats.isFile();
}
export default isFile;
@@ -63,7 +63,7 @@ exports[`throw error with invalid config precedence option (configPrecedence) (w
exports[`throw error with invalid config target (directory) (stderr) 1`] = `
"[error] Invalid configuration:
[error] EISDIR: illegal operation on a directory, read"
[error] Unable to read '<cli>/config/invalid/folder/.prettierrc': EISDIR: illegal operation on a directory, read"
`;
exports[`throw error with invalid config target (directory) (stdout) 1`] = `""`;
@@ -1,3 +1,4 @@
import fs from "node:fs/promises";
import outdent from "outdent";
import jestPathSerializer from "../path-serializer.js";
@@ -178,3 +179,31 @@ describe("Invalid config value", () => {
stderr: "",
});
});
// Can't put a invalid `package.json` file in the test dir
test("Invalid package.json", async () => {
const packageJsonFile = new URL(
"../cli/config/invalid/broken-package-json/package.json",
import.meta.url,
);
try {
await fs.writeFile(packageJsonFile, '{"prettier":{}}');
const { stdout: configFileForValidPackageJson } = await runCli(
"cli/config/invalid/broken-package-json",
["--find-config-path", "foo.js"],
);
expect(configFileForValidPackageJson).toBe("package.json");
await fs.writeFile(packageJsonFile, '{"prettier":{');
const { stdout: configFileForInvalidPackageJson } = await runCli(
"cli/config/invalid/broken-package-json",
["--find-config-path", "foo.js"],
);
expect(configFileForInvalidPackageJson).toBe(".prettierrc");
} finally {
await fs.rm(packageJsonFile, { force: true });
}
});
-45
View File
@@ -1,52 +1,7 @@
import path from "node:path";
import createEsmUtils from "esm-utils";
import prettier from "../../config/prettier-entry.js";
const { __dirname } = createEsmUtils(import.meta);
const { mockable } = prettier.__debug;
// This don't has to be the same result as `prettier.resolveConfig`,
// Because we are testing with default `lilconfigOptions`
describe("lilconfig", () => {
const { lilconfig } = mockable;
const configs = [
{
title: "prettier.config.cjs",
dirname: path.join(__dirname, "../cli/config/js/"),
file: path.join(__dirname, "../cli/config/js/prettier.config.cjs"),
value: {
endOfLine: "auto",
tabWidth: 8,
},
},
{
title: "package.json",
dirname: path.join(__dirname, "../cli/config/package/"),
file: path.join(__dirname, "../cli/config/package/package.json"),
value: {
tabWidth: 3,
overrides: [
{
files: "*.ts",
options: {
tabWidth: 5,
},
},
],
},
},
];
for (const { title, dirname, file, value } of configs) {
test(`async version ${title}`, async () => {
const { config, filepath } = await lilconfig("prettier").search(dirname);
expect(config).toEqual(value);
expect(filepath).toBe(file);
});
}
});
test("isCI", () => {
expect(typeof mockable.isCI()).toBe("boolean");
});
+2 -6
View File
@@ -3,7 +3,6 @@ import fs from "node:fs";
import path from "node:path";
import url from "node:url";
import readline from "node:readline";
import { lilconfig } from "lilconfig";
import { prettierCli, prettierMainEntry } from "./env.js";
const normalizeToPosix =
@@ -65,11 +64,8 @@ async function run() {
// eslint-disable-next-line require-await
mockable.getStdin = async () => options.input || "";
mockable.isCI = () => Boolean(options.ci);
mockable.lilconfig = (moduleName, options) =>
lilconfig(moduleName, {
...options,
stopDir: url.fileURLToPath(new URL("./cli", import.meta.url)),
});
mockable.getPrettierConfigSearchStopDirectory = () =>
url.fileURLToPath(new URL("./cli", import.meta.url));
// eslint-disable-next-line require-await
mockable.writeFormattedFile = async (filename, content) => {
filename = normalizeToPosix(path.relative(process.cwd(), filename));
@@ -0,0 +1,2 @@
# will be created by test
package.json
-8
View File
@@ -6214,13 +6214,6 @@ __metadata:
languageName: node
linkType: hard
"lilconfig@npm:2.1.0":
version: 2.1.0
resolution: "lilconfig@npm:2.1.0"
checksum: 8549bb352b8192375fed4a74694cd61ad293904eee33f9d4866c2192865c44c4eb35d10782966242634e0cbc1e91fe62b1247f148dc5514918e3a966da7ea117
languageName: node
linkType: hard
"lines-and-columns@npm:2.0.4, lines-and-columns@npm:^2.0.3":
version: 2.0.4
resolution: "lines-and-columns@npm:2.0.4"
@@ -7371,7 +7364,6 @@ __metadata:
js-yaml: 4.1.0
json5: 2.2.3
leven: 4.0.0
lilconfig: 2.1.0
lines-and-columns: 2.0.4
linguist-languages: 7.27.0
magic-string: 0.30.5