c
\n", + "src/d.svelte": "d
\n", +}; + +describe.concurrent("bun fmt --doctor", () => { + test("with a .prettierrc", async () => { + const { stdout, stderr, exitCode, isUntouched } = await doctor(prettier); + expect(stderr).toBe(""); + expect(stdout).toBe(`Using .prettierrc (Prettier config) +Also .editorconfig + + 4 files are formatted natively + 2 files go to your installed Prettier: 2 *.svelte + +Built-in plugins: @ianvs/prettier-plugin-sort-imports +Plugins that use your installed Prettier: prettier-plugin-svelte + +You can remove: prettier-plugin-astro +Still needed: @ianvs/prettier-plugin-sort-imports, prettier, prettier-plugin-svelte (some files go to them) +Not checked: other tools' configs, CI files, Git hooks +`); + expect(isUntouched).toBe(true); + expect(exitCode).toBe(0); + }); + + test("-f json prints the same report as JSON", async () => { + const { stdout, exitCode } = await doctor(prettier, ["-f", "json"]); + expect(JSON.parse(stdout)).toEqual({ + config: { path: ".prettierrc", kind: "prettier", nested: [], editorconfig: true }, + files: { formatted: 4, by_prettier: { "*.svelte": 2 }, not_formatted: {}, left_for_plugins: 0 }, + plugins: { + built_in: ["@ianvs/prettier-plugin-sort-imports"], + by_prettier: ["prettier-plugin-svelte"], + not_supported: [], + not_installed: [], + }, + options_not_supported: [], + packages: { + removable: ["prettier-plugin-astro"], + needed: [ + { name: "@ianvs/prettier-plugin-sort-imports", reason: "files", by: null }, + { name: "prettier", reason: "files", by: null }, + { name: "prettier-plugin-svelte", reason: "files", by: null }, + ], + not_checked: ["other tools' configs", "CI files", "Git hooks"], + }, + notes: [], + }); + expect(exitCode).toBe(0); + }); + + test("Prettier and a built-in plugin are removable when no file needs them", async () => { + const { stdout, exitCode } = await doctor({ + "package.json": dependencies("prettier", "@trivago/prettier-plugin-sort-imports"), + ".prettierrc.json": JSON.stringify({ plugins: ["@trivago/prettier-plugin-sort-imports"] }), + "a.js": ugly, + }); + expect(stdout).toBe(`Using .prettierrc.json (Prettier config) + + 3 files are formatted natively + +Built-in plugins: @trivago/prettier-plugin-sort-imports + +You can remove: @trivago/prettier-plugin-sort-imports, prettier +Not checked: other tools' configs, CI files, Git hooks +`); + expect(exitCode).toBe(0); + }); + + test("the plugin that sorts classes is built in", async () => { + const { stdout, exitCode } = await doctor({ + "package.json": dependencies("prettier", "prettier-plugin-tailwindcss", "tailwindcss"), + ".prettierrc.json": JSON.stringify({ plugins: ["prettier-plugin-tailwindcss"], tailwindFunctions: ["cn"] }), + "a.js": ugly, + }); + expect(stdout).toBe(`Using .prettierrc.json (Prettier config) + + 3 files are formatted natively + +Built-in plugins: prettier-plugin-tailwindcss + +You can remove: prettier, prettier-plugin-tailwindcss +Not checked: other tools' configs, CI files, Git hooks +`); + expect(exitCode).toBe(0); + }); + + test("lists unsupported plugins and plugins that aren't installed", async () => { + const project = { + ...installed("prettier"), + "package.json": dependencies("prettier", "prettier-plugin-css-order", "prettier-plugin-astro"), + ".prettierrc.json": JSON.stringify({ plugins: ["prettier-plugin-astro"] }), + "styled/.prettierrc.json": JSON.stringify({ plugins: ["prettier-plugin-css-order"] }), + "styled/a.js": ugly, + "b.astro": "b
\n", + }; + const [text, { stdout, exitCode }] = await Promise.all([doctor(project), doctor(project, ["-f", "json"])]); + expect(text.stdout).toStartWith(`Using .prettierrc.json (Prettier config) +Also styled/.prettierrc.json + + 2 files are formatted natively + 1 file cannot be formatted: 1 *.astro + 2 files are skipped because a plugin is not supported + Use --allow-unsupported to format them without it + +Plugins that use your installed Prettier: prettier-plugin-astro +Plugins that are not supported: prettier-plugin-css-order +Not installed: prettier-plugin-astro + +Still needed: prettier, prettier-plugin-astro (some files go to them) + prettier-plugin-css-order (the skipped files need it) +`); + const { config, files, plugins, packages } = JSON.parse(stdout); + expect(config.nested).toEqual(["styled/.prettierrc.json"]); + expect(files).toEqual({ formatted: 2, by_prettier: {}, not_formatted: { "*.astro": 1 }, left_for_plugins: 2 }); + expect(plugins).toEqual({ + built_in: [], + by_prettier: ["prettier-plugin-astro"], + not_supported: ["prettier-plugin-css-order"], + not_installed: ["prettier-plugin-astro"], + }); + expect(packages).toEqual({ + removable: [], + needed: [ + { name: "prettier", reason: "files", by: null }, + { name: "prettier-plugin-astro", reason: "files", by: null }, + { name: "prettier-plugin-css-order", reason: "skipped_files", by: null }, + ], + not_checked: ["other tools' configs", "CI files", "Git hooks"], + }); + expect(exitCode).toBe(0); + }); + + test("Prettier is not removable while files are skipped because of a plugin", async () => { + const { stdout, exitCode } = await doctor({ + "package.json": dependencies("prettier", "prettier-plugin-css-order", "prettier-plugin-other"), + ".prettierrc": JSON.stringify({ plugins: ["prettier-plugin-css-order"] }), + "a.js": ugly, + }); + expect(stdout).toStartWith(`Using .prettierrc (Prettier config) + + 0 files are formatted natively + 3 files are skipped because a plugin is not supported + Use --allow-unsupported to format them without it + +Plugins that are not supported: prettier-plugin-css-order + +You can remove: prettier-plugin-other +Still needed: prettier, prettier-plugin-css-order (the skipped files need them) +Not checked: other tools' configs, CI files, Git hooks +`); + expect(exitCode).toBe(0); + }); + + test("packages that a JavaScript config file imports are still needed", async () => { + const { stdout, exitCode } = await doctor({ + "node_modules/prettier-config-shared/package.json": JSON.stringify({ + name: "prettier-config-shared", + main: "index.js", + }), + "node_modules/prettier-config-shared/index.js": "module.exports = { semi: false };", + "package.json": dependencies("prettier", "prettier-config-shared"), + "prettier.config.cjs": `module.exports = { ...require("prettier-config-shared"), singleQuote: true };`, + "a.js": ugly, + }); + expect(stdout).toBe(`Using prettier.config.cjs (Prettier config) + + 3 files are formatted natively + +You can remove: prettier +Still needed: prettier-config-shared (prettier.config.cjs loads it) +Not checked: other tools' configs, CI files, Git hooks +`); + expect(exitCode).toBe(0); + }); + + test("only packages named like formatter tooling are removable", async () => { + const names = [ + "prettier", + "oxfmt", + "prettier-plugin-a", + "@scope/prettier-plugin-a", + "@prettier/plugin-php", + "prettier-config-a", + "@scope/prettier-config", + "@scope/prettier-config-a", + ]; + const others = [ + "eslint-plugin-prettier", + "eslint-config-prettier", + "prettier-eslint", + "pretty-quick", + "prettierx", + "vite-plugin-prettier", + "@scope/prettier", + ]; + const { stdout, exitCode } = await doctor( + { "package.json": dependencies(...names, ...others), ".prettierrc": "{}" }, + ["-f", "json"], + ); + expect(JSON.parse(stdout).packages).toEqual({ + removable: names.toSorted(), + needed: [], + not_checked: ["other tools' configs", "CI files", "Git hooks"], + }); + expect(exitCode).toBe(0); + }); + + test("a package used by a script, lint-staged or another dependency is still needed", async () => { + const project = (fields: object) => ({ + "package.json": + JSON.stringify({ devDependencies: { prettier: "*", "eslint-plugin-prettier": "*" }, ...fields }, null, 2) + + "\n", + ".prettierrc": "{}\n", + }); + const peer = { + "node_modules/eslint-plugin-prettier/package.json": JSON.stringify({ + name: "eslint-plugin-prettier", + peerDependencies: { prettier: ">=3" }, + }), + }; + const [script, staged, dependent, free] = await Promise.all([ + doctor(project({ scripts: { check: "prettier --check ." } })), + doctor(project({ "lint-staged": { "*": "prettier --write" } })), + doctor({ ...project({}), ...peer }), + doctor(project({ scripts: { check: "bun fmt --check", pretty: "prettierx ." } })), + ]); + expect(script.stdout).toEndWith(`\nStill needed: prettier (the "check" script runs it)\n`); + expect(staged.stdout).toEndWith(`\nStill needed: prettier (lint-staged runs it)\n`); + expect(dependent.stdout).toEndWith(`\nStill needed: prettier (eslint-plugin-prettier needs it)\n`); + expect(free.stdout).toEndWith( + `\nYou can remove: prettier\nNot checked: other tools' configs, CI files, Git hooks\n`, + ); + }); + + test("with an .oxfmtrc.json", async () => { + const { stdout, exitCode, isUntouched } = await doctor({ + "package.json": dependencies("oxfmt"), + ".oxfmtrc.json": JSON.stringify({ semi: false }), + "a.js": ugly, + }); + expect(stdout).toBe(`Using .oxfmtrc.json (oxfmt config) + + 3 files are formatted natively + +You can remove: oxfmt +Not checked: other tools' configs, CI files, Git hooks +`); + expect(isUntouched).toBe(true); + expect(exitCode).toBe(0); + }); + + test("without a config file", async () => { + const { stdout, stderr, exitCode, isUntouched } = await doctor({ "a.js": ugly }); + expect(stderr).toBe(""); + expect(stdout).toBe(`No Prettier or oxfmt config found. Bun formats like Prettier by default. + + 1 file is formatted natively + +Run bun fmt --init to add a [fmt] section to bunfig.toml +`); + expect(isUntouched).toBe(true); + expect(exitCode).toBe(0); + }); + + test("an invalid config file is an error", async () => { + const { stdout, stderr, exitCode } = await doctor({ ".prettierrc.json": "{", "a.js": ugly }); + expect(stdout).toBe(""); + expect(stderr).toContain(".prettierrc.json"); + expect(exitCode).toBe(2); + }); + + test("-f only accepts json", async () => { + const { stderr, exitCode } = await doctor({ "a.js": ugly }, ["-f", "yaml"]); + expect(stderr).toContain(`--format takes json, not "yaml"`); + expect(exitCode).not.toBe(0); + }); + + test("runs even if package.json has a `fmt` script", async () => { + const { stdout, exitCode } = await doctor({ + "package.json": JSON.stringify({ scripts: { fmt: "echo the script" } }), + "a.js": ugly, + }); + expect(stdout).toStartWith("No Prettier or oxfmt config found."); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/cli/format/format.test.ts b/test/cli/format/format.test.ts index ac7f7f7e6d4d..4a52201b6582 100644 --- a/test/cli/format/format.test.ts +++ b/test/cli/format/format.test.ts @@ -34,7 +34,9 @@ import { inputs as lineEndingInputs } from "./line-endings.cases.ts"; afterAll(endChildren); -const command = [bunExe(), "format"]; +const command = [bunExe(), "fmt"]; +/** Matches the start of an error line: Prettier's `[error]` or Bun's `error:`. */ +const anError = /^(?:\[error\]|error:) /m; const env = { ...bunEnv, NO_COLOR: undefined, FORCE_COLOR: undefined }; @@ -77,6 +79,13 @@ async function format(files: Record\n
\n
\\{{a}}\n",
- "escaped-mustache-in-style": '\n',
- "white-space-next-to-a-mustache-in-style": "\n",
- "no-break-space-that-is-all-of-a-block": "{{#a }}\u00a0{{/a}}\n",
- "no-break-space-that-is-all-of-a-component": "\u00a0\n",
- "no-break-space-in-a-class": '\n',
- "escaped-mustache-that-is-ignored": "{{! prettier-ignore }}a\\{{b}}\n",
- "backslash-before-a-mustache-in-a-value": '\n',
- "backslash-before-a-comment": "a\\\\{{! b }}\n",
- "backslash-before-the-end-of-a-block": "{{#a }}b\\\\{{/a}}\n",
- "both-quotes-in-a-value": `\n`,
- "lines-of-a-script": "\n",
- "lines-of-a-textarea": "\n",
- "white-space-further-down-in-pre": "a\n b\n", - "children-of-what-counts-as-void": "
\\{{a}}\n", "\\{{a}}", "{{a}}"],
+ "escaped-mustache-in-style": [
+ '\n',
+ '',
+ '',
+ ],
+ "white-space-next-to-a-mustache-in-style": [
+ "\n",
+ "",
+ "",
+ ],
+ "no-break-space-that-is-all-of-a-block": ["{{#a }}\u00a0{{/a}}\n", "{{#a}}\u00a0{{/a}}", "{{#a}}{{/a}}"],
+ "no-break-space-that-is-all-of-a-component": ["\u00a0\n", "\u00a0", ""],
+ "no-break-space-in-a-class": ['\n', '', ''],
+ "escaped-mustache-that-is-ignored": [
+ "{{! prettier-ignore }}a\\{{b}}\n",
+ "{{! prettier-ignore }}a\\{{b}}",
+ "{{! prettier-ignore }}a{{b}}",
+ ],
+ "backslash-before-a-mustache-in-a-value": [
+ '\n',
+ '',
+ '',
+ ],
+ "backslash-before-a-comment": ["a\\\\{{! b }}\n", "a\\\\{{! b }}", "a\\{{! b }}"],
+ "backslash-before-the-end-of-a-block": ["{{#a }}b\\\\{{/a}}\n", "{{#a}}b\\\\{{/a}}", "{{#a}}b\\{{/a}}"],
+ "both-quotes-in-a-value": ["\n", '', ''],
+ "lines-of-a-script": [
+ "\n",
+ "",
+ "",
+ ],
+ "lines-of-a-textarea": [
+ "\n",
+ "",
+ "",
+ ],
+ "white-space-further-down-in-pre": [
+ "a\n b\n", + "
a\n b", + "
a b", + ], + "children-of-what-counts-as-void": ["
{{#a}} {{/a}}\n",
+ "{{#a}} {{/a}}",
+ "{{#a}}{{/a}}",
+ ],
+ "tilde-of-an-else-if-in-an-if-that-is-all-that-follows-else": [
+ "{{#z }}y{{else}}{{#if a}}b{{~else if c}}d {{/if}}{{/z}}\n",
+ "{{#z}}y{{else}}{{#if a}}b{{~else if c}}d {{/if}}{{/z}}",
+ "{{#z}}y{{else if a}}b{{~else if c}}d {{/z}}",
+ ],
+ "name-in-brackets-behind-else": [
+ "{{#[a b] }}{{else [a b] c}}{{/[a b]}}\n",
+ "{{#[a b]}}{{else [a b] c}}{{/[a b]}}",
+ "{{#[a b]}}{{else a b c}}{{/[a b]}}",
+ ],
+ "empty-brackets-and-a-slash": ["{{[]/a }}\n", "{{[]/a}}", "{{a}}"],
+ "empty-brackets-behind-an-at": ["{{@[].a }}\n", "{{@[].a}}", "{{@a}}"],
+ "empty-brackets-at-the-end": ["{{a.[] }}\n", "{{a.[]}}", "{{a.}}"],
+ "empty-brackets-in-arguments": ["{{a [].b c=[].d}}\n", "{{a [].b c=[].d}}", "{{a b c=d}}"],
+ "doctype-with-identifiers": [
+ '\n\n',
+ '\n',
+ "",
+ ],
+ "this-twice": ["{{this.this }}\n", "{{this.this}}", "{{this}}"],
+ "dots-before-a-name": ["{{a ...b}}\n", "{{a ...b}}", "{{a b}}"],
+ "slash-in-brackets": ["{{[a/b] }}\n", "{{[a/b]}}", "{{a/b}}"],
+ "hash-that-is-called-in-parentheses": ["{{a ((b=c) d)}}\n", "{{a ((b=c) d)}}", "{{a ( d)}}"],
+ "number-with-a-negative-exponent": ["{{a 0.0000001}}\n", "{{a 0.0000001}}", "{{a 1e-7}}"],
+ "mustache-where-a-comment-may-end": [
+ "\n",
+ "",
+ "{{b}}",
+ ],
+ "blank-behind-less-than": ["< a>b\n", "< a>b\n", "b"],
+ "columns-behind-a-line-separator": [
+ 'a\n\u2028{{~! b ~}} \n\u2029{{! prettier-ignore }} {{ i }} {{!-- j --~}}\n{{k\n\u2028 l}} {{~! m }}\n',
+ 'a \u2028{{~! b ~}}\n\n\n\u2029{{! prettier-ignore }}\n{{ i }}\n{{! j ~}}\n{{k l}}\n{{~! m }}',
+ 'a \u2028{{! b }}\n\n\n\u2029\u2029{{! prettier-ignore }\n {{ i }\n{{! j }}\n{{k l}}\n{{! m }}',
+ ],
};
- const files = Object.fromEntries(Object.entries(damaged).map(([name, text]) => [`${name}.hbs`, text]));
- const errors = Object.keys(files)
- .map(
- name => `[error] ${name}: formatting it the way Prettier does would change what it means. It is left as it is.`,
- )
- .sort();
- const errorsIn = (stderr: string) =>
- stderr
- .split("\n")
- .filter(line => line.startsWith("[error] "))
- .sort();
+ // These look like it, but Handlebars reads what Prettier prints as it reads the template.
+ const harmless: Recordc
\n", "\nc
\n"], + ])("%s", async (name, before, after) => { + const result = await format({ [name]: frontMatter + before }, [], { reads: [name] }); + expect(result.files).toEqual({ [name]: frontMatter + after }); + expect(result.exitCode).toBe(0); + expect(await different({ [name]: frontMatter + after }, [])).toEqual([]); + }); + }); + test("HTML, Vue, Angular templates, MJML", async () => { const files = { "a.html": 'c
a
\n" }); - expect(result.stderr).toContain("[error] a.html: SyntaxError:"); + expect(result.stderr).toContain("error: a.html: SyntaxError:"); expect(result.exitCode).toBe(2); }); - describe("HTML of which something would be lost is left as it is", () => { - // Prettier 3.9.9 writes a media query in lower case, which makes a `k` of the Kelvin sign. - const lossy = "\n"; - const error = (name: string) => - `[error] ${name}: formatting it the way Prettier does would change what is in it. It is left as it is.`; + describe("a with an element in it and no end tag", () => {
+ // The text, what Bun makes of it, and what Prettier 3.9.9 makes of it: all that follows the start tag is lost.
+ test.each([
+ ["a\n", "a\n", ">\n"],
+ ["ac\n", "ac\n", ">\n"],
+ [
+ 'x
a\n b\n',
+ 'x
\na\n b\n',
+ 'x
\n>\n',
+ ],
+ ["a\n", "a\n", ">\n"],
+ ])("%j", async (before, after, ofPrettier) => {
+ expect(after).not.toBe(ofPrettier);
+ const result = await format({ "a.html": before, "b.html": after }, [], { reads: ["a.html", "b.html"] });
+ expect(result.files).toEqual({ "a.html": after, "b.html": after });
+ expect(result.exitCode).toBe(0);
+ const checked = await format({ "a.html": before }, ["--debug-check"]);
+ expect(checked.stderr).toBe("");
+ expect(checked.exitCode).toBe(0);
+ });
+ });
+
+ describe("a style sheet of which Prettier changes a letter", () => {
+ // Prettier 3.9.9 writes a media query in lower case, which makes a `k` of the Kelvin sign: `@media (ka)`. To CSS only
+ // the letters of ASCII are the same in both cases.
+ const lossy = "\n";
+ const written = "\n";
- test.each([[[] as string[]], [["--check"]], [["-l"]]])("%j", async args => {
- const result = await format({ "a.html": lossy, "b.html": "a
\n" }, args, {
- reads: ["a.html", "b.html"],
- });
- expect(result.files).toEqual({ "a.html": lossy, "b.html": args.length > 0 ? "a
\n" : "a
\n" });
- expect(result.stderr.split("\n").filter(line => line.startsWith("[error] "))).toEqual([error("a.html")]);
- expect(result.exitCode).toBe(2);
+ test("keeps it", async () => {
+ const files = { "a.html": lossy, "b.css": "@media (\u212AA){a{b:c}}\n" };
+ const result = await format(files, [], { reads: ["a.html", "b.css"] });
+ expect(result.files).toEqual({ "a.html": written, "b.css": "@media (\u212Aa) {\n a {\n b: c;\n }\n}\n" });
+ expect(result.exitCode).toBe(0);
+ });
+
+ test("--debug-check has nothing to say about it", async () => {
+ const result = await format({ "a.html": lossy }, ["--debug-check"], { reads: ["a.html"] });
+ expect(result.files).toEqual({ "a.html": lossy });
+ expect(result.stderr).toBe("");
+ expect(result.stdout).toBe("a.html");
+ expect(result.exitCode).toBe(0);
+ });
+
+ test.each([["--check"], ["-l"]])("%s says that it needs formatting", async flag => {
+ const result = await format({ "a.html": lossy }, [flag], { reads: ["a.html"] });
+ expect(result.files).toEqual({ "a.html": lossy });
+ expect(result.stdout).toBe("a.html");
+ expect(result.exitCode).toBe(1);
});
test("standard input", async () => {
const result = await format({}, ["--stdin-filepath", "a.html"], { stdin: lossy });
- expect(result.raw).toBe("");
- expect(result.stderr.split("\n").filter(line => line.startsWith("[error] "))).toEqual([error("a.html")]);
- expect(result.exitCode).toBe(2);
+ expect(result.raw).toBe(written);
+ expect(result.exitCode).toBe(0);
});
- test("in a template and in a block of code, only that is", async () => {
+ test("in a template and in a block of code", async () => {
const files = {
"a.js": "const a = html`" + lossy.trim() + "`;\nconst b = html`c
`;\n",
"b.md": "# a\n\n```html\n" + lossy + "```\n\n```html\nc
\n```\n",
};
const result = await format(files, [], { reads: ["a.js", "b.md"] });
expect(result.files).toEqual({
- "a.js": "const a = html`" + lossy.trim() + "`;\nconst b = html`c
`;\n",
- "b.md": "# a\n\n```html\n" + lossy + "```\n\n```html\nc
\n```\n",
+ "a.js": "const a = html`" + written.trim() + "`;\nconst b = html`c
`;\n",
+ "b.md": "# a\n\n```html\n" + written + "```\n\n```html\nc
\n```\n",
});
expect(result.exitCode).toBe(0);
});
+ });
- test("--no-verify", async () => {
- const result = await format({ "a.html": lossy }, ["--no-verify"], { reads: ["a.html"] });
- expect(result.files).toEqual({
- "a.html": "\n",
- });
+ // What Prettier 3.9.9 does with the flag. oxfmt 0.72 does not have it. The comparison is Bun's own.
+ describe.each([
+ ["no config", {}, "error: "],
+ [".prettierrc", { ".prettierrc": "{}\n" }, "[error] "],
+ [".oxfmtrc.json", { ".oxfmtrc.json": "{}\n" }, "[error] "],
+ ])("--debug-check with %s", (_, config: Record, prefix) => {
+ const files = { "a.js": formatted, "b.js": ugly };
+ const names = Object.keys(files);
+ // Without the parentheses, which neither Prettier nor Bun writes, it means the same, but the tree is another. Prettier's
+ // check reports it too.
+ const lossy = "(a)();\n";
+
+ test("every file that passes is named, formatted or not, and nothing is written", async () => {
+ const result = await format({ ...config, ...files }, ["--debug-check", ...names], { reads: names });
+ expect(result.files).toEqual(files);
+ expect(result.stderr).toBe("");
+ expect(result.stdout.split("\n")).toEqual(["a.js", "b.js"]);
expect(result.exitCode).toBe(0);
});
+
+ test("a file that would say something else is an error", async () => {
+ const all = { "a.ts": lossy, "b.js": ugly };
+ const result = await format({ ...config, ...all }, ["--debug-check", "a.ts", "b.js"], {
+ reads: ["a.ts", "b.js"],
+ });
+ expect(result.files).toEqual(all);
+ expect(result.stderr.trim()).toBe(`${prefix}a.ts: formatting would change what the code does.`);
+ expect(result.stdout).toBe("b.js");
+ expect(result.exitCode).toBe(2);
+ });
+
+ test("-l names what needs formatting as well", async () => {
+ const result = await format({ ...config, ...files }, ["--debug-check", "-l", ...names], { reads: names });
+ expect(result.files).toEqual(files);
+ expect(result.stdout.split("\n")).toEqual(["a.js", "b.js", "b.js"]);
+ expect(result.exitCode).toBe(1);
+ });
+
+ test("--check exits with 1 if a file needs formatting", async () => {
+ const result = await format({ ...config, ...files }, ["--debug-check", "--check", ...names], { reads: names });
+ expect(result.files).toEqual(files);
+ expect(result.exitCode).toBe(1);
+ });
+
+ test("--write is refused", async () => {
+ const result = await format({ ...config, ...files }, ["--debug-check", "--write", ...names], { reads: names });
+ expect(result.files).toEqual(files);
+ expect(result.stderr.trim()).toBe(`${prefix}Cannot use --write and --debug-check together.`);
+ expect(result.exitCode).toBe(1);
+ });
+
+ test("standard input", async () => {
+ const [named, unnamed, looks, lost] = await Promise.all([
+ format(config, ["--debug-check", "--stdin-filepath", "x.js"], { stdin: ugly }),
+ format(config, ["--debug-check", "--parser", "babel"], { stdin: ugly }),
+ format(config, ["--debug-check", "--check", "--stdin-filepath", "x.js"], { stdin: ugly }),
+ format(config, ["--debug-check", "--stdin-filepath", "x.ts"], { stdin: lossy }),
+ ]);
+ // The path, and no line break.
+ expect(named.raw).toEndWith(`${sep}x.js`);
+ expect(named.exitCode).toBe(0);
+ expect(unnamed.raw).toBe("(stdin)\n");
+ expect(unnamed.exitCode).toBe(0);
+ // oxfmt refuses --check with --stdin-filepath.
+ expect(looks.raw).toBe(".oxfmtrc.json" in config ? "" : "(stdin)\n");
+ expect(looks.exitCode).toBe(1);
+ expect(lost.raw).toBe("");
+ expect(lost.stderr.trim()).toBe(`${prefix}x.ts: formatting would change what the code does.`);
+ expect(lost.exitCode).toBe(2);
+ });
});
test("HTML in templates", async () => {
@@ -1045,13 +1446,34 @@ describe.concurrent("bun format", () => {
expect(result.exitCode).toBe(0);
});
- test("a file is left as it is if a template would get an expression twice", async () => {
- // Like Prettier 3.9.9, the formatter closes the element: html`<${b}>c${b}>`.
- const text = "const a = html`<${b}>c`;\n";
- const result = await format({ "a.js": text }, [], { reads: ["a.js"] });
- expect(result.files).toEqual({ "a.js": text });
- expect(result.stderr).toContain("[error] a.js: formatting would change what the code means.");
- expect(result.exitCode).toBe(2);
+ describe("HTML in a template of which Prettier changes the substitutions", () => {
+ // The code, what Bun makes of it, and what Prettier 3.9.9 makes of it: it closes the element, so `b` is evaluated twice,
+ // and it does not find what stands for `${b}` again once that is in lower case. oxfmt 0.72 leaves both as they are.
+ test.each([
+ ["const a = html`<${b}>c`;\n", "const a = html`<${b}>c`;\n", "const a = html`<${b}>c${b}>`;\n"],
+ [
+ "const a = html`e
d`;\n",
+ "const a = html`e
d`;\n",
+ "const a = html`e
\n d `;\n",
+ ],
+ [
+ "const a = html``;\n",
+ "const a = html``;\n",
+ "const a = html``;\n",
+ ],
+ ])("%j", async (before, after, ofPrettier) => {
+ expect(after).not.toBe(ofPrettier);
+ const [written, again, checked] = await Promise.all([
+ format({ "a.js": before }, [], { reads: ["a.js"] }),
+ different({ "a.js": after }, []),
+ format({ "a.js": before }, ["--debug-check"], { reads: ["a.js"] }),
+ ]);
+ expect(written.files).toEqual({ "a.js": after });
+ expect(written.exitCode).toBe(0);
+ expect(again).toEqual([]);
+ expect(checked.stderr).toBe("");
+ expect(checked.exitCode).toBe(0);
+ });
});
test("HTML and Vue in blocks of code in Markdown", async () => {
@@ -1085,12 +1507,13 @@ describe.concurrent("bun format", () => {
const left = { "a.astro": "a
\n", "b.astro": "b
\n" };
const files = { ".prettierrc": '{ "plugins": ["prettier-plugin-astro"] }\n', ...left, "c.js": ugly };
const reads = [...Object.keys(left), "c.js"];
- const text = "2 files are in a language that bun format does not support yet, and left as they are: 2 .astro";
+ const text = "2 files were not formatted because bun fmt does not support their language yet: 2 .astro";
const result = await format(files, [], { reads });
expect(result.files).toEqual({ ...left, "c.js": formatted });
- expect(result.stderr.trimEnd().split("\n").at(-1)).toBe(
- `[error] ${text}. With --allow-unsupported this is a warning.`,
- );
+ expect(result.stderr.trimEnd().split("\n").slice(-2)).toEqual([
+ `[error] ${text}`,
+ "[error] Use --allow-unsupported to make this a warning.",
+ ]);
expect(result.exitCode).toBe(2);
const checked = await format({ ...files, "c.js": formatted }, ["--check"], { reads });
expect(checked.stderr).toContain(`[error] ${text}`);
@@ -1143,8 +1566,57 @@ exports.format = async (text, options) => {
const paths = [JSON.parse(calls[0]).filepath, ...result.files[reads[3]]!.trim().split("\n")];
expect(paths.map(it => basename(it))).toEqual(["a.svelte", "a.svelte", ".prettierrc"]);
expect(paths).toEqual(paths.map(it => resolve(it)));
- expect(result.stderr).toContain("1 file was handed to the Prettier of the project");
+ expect(result.stderr).toContain("Used your installed Prettier for 1 file in a language that needs a plugin");
+ expect(result.exitCode).toBe(0);
+ },
+ timeout,
+ );
+
+ // The order of prettier-plugin-packagejson is that of the package sort-package-json, not that of `sortPackageJson`.
+ test(
+ "so does a package.json with prettier-plugin-packagejson, and no other file",
+ async () => {
+ const plugin = "prettier-plugin-packagejson";
+ const project = {
+ ...packages,
+ [`node_modules/${plugin}/package.json`]: `{ "name": "${plugin}", "version": "3.0.0" }`,
+ ".prettierrc": `{ "plugins": ["${plugin}"], "packageSortOrder": ["version"] }\n`,
+ "package.json": '{ "a": 1 }\n',
+ "packages/b/package.json": '{ "b": 1 }\n',
+ "my-package.json": '{ "c": 1 }\n',
+ "d.js": "d ;\n",
+ };
+ const names = ["package.json", "packages/b/package.json", "my-package.json", "d.js"];
+ const result = await format(project, [], { reads: names });
+ expect(result.files).toEqual({
+ "package.json": '{ "a": 1 }\n',
+ "packages/b/package.json": '{ "b": 1 }\n',
+ "my-package.json": '{ "c": 1 }\n',
+ "d.js": "d;\n",
+ });
+ expect(result.stderr).toContain("Used your installed Prettier for 2 files");
+ expect(result.stderr).not.toContain("[warn]");
expect(result.exitCode).toBe(0);
+ // The flag is for what cannot be done, and this can.
+ const withFlag = await format(project, ["--allow-unsupported"], { reads: names });
+ expect(withFlag.files).toEqual(result.files);
+ expect(withFlag.stderr).toContain("Used your installed Prettier for 2 files");
+
+ // Without Prettier only those files are left, and with the flag they are formatted without the plugin.
+ const { ".prettierrc": config, "package.json": text } = project;
+ const alone = { ".prettierrc": config, "package.json": text, "d.js": "d ;\n" };
+ const left = await format(alone, [], { reads: ["package.json", "d.js"] });
+ expect(left.files).toEqual({ "package.json": text, "d.js": "d;\n" });
+ expect(left.stderr).toContain(`Not installed: prettier, ${plugin}`);
+ expect(left.stderr).toContain(
+ `[error] 1 file was not formatted because the Prettier config uses plugins that bun fmt does not include: ${plugin}\n[error] Use --allow-unsupported to format it without the plugins.\n`,
+ );
+ expect(left.stderr).not.toContain("language");
+ expect(left.exitCode).toBe(2);
+ const allowed = await format(alone, ["--allow-unsupported"], { reads: ["package.json"] });
+ expect(allowed.files).toEqual({ "package.json": '{\n "a": 1\n}\n' });
+ expect(allowed.stderr).toContain(`[warn] Not installed: prettier, ${plugin}`);
+ expect(allowed.exitCode).toBe(0);
},
timeout,
);
@@ -1155,7 +1627,7 @@ exports.format = async (text, options) => {
// What the file takes from the rest of the worker, with the paths of another system.
const run = (system: string, path: string, config: string | null) => `(() => {
const nodePath = require("node:path").${system};
- const [MESSAGE, DONE, FAILED, NOT_INSTALLED, cwd, seen] = [0, "0", "1", "5", "/", []];
+ const [DONE, FAILED, NOT_INSTALLED, cwd, seen] = ["0", "1", "5", "/", []];
let loadingTime = 0;
const prettier = {
resolveConfig: async (file, { config }) => void seen.push(file, config ?? null),
@@ -1164,8 +1636,8 @@ exports.format = async (text, options) => {
const createRequire = () => Object.assign(() => prettier, { resolve() {} });
const json = Buffer.from(${JSON.stringify(JSON.stringify({ ...head, path, config }))});
const message = new Uint8Array([...new Uint8Array(new Uint32Array([json.length]).buffer), ...json, 120]);
- const [buffers, ask] = [[message.buffer], () => message.length];
- const decode = (buffer, from, to) => Buffer.from(buffer, from, to - from).toString();
+ const messageWords = count => new Uint32Array(message.buffer, 0, count);
+ const messageText = (from, to = message.length) => Buffer.from(message.buffer, from, to - from).toString();
${readFileSync(join(worker, "paths.js"), "utf8")}
${readFileSync(join(worker, "prettier.js"), "utf8")}
return formatWithPrettier().then(result => [result, ...seen]);
@@ -1216,6 +1688,66 @@ exports.format = async (text, options) => {
timeout,
);
+ test(
+ "--parser with the plugin's parser: piped code goes to Prettier without a file name",
+ async () => {
+ const stdin = "c
\n";
+ const result = await format(files, ["--parser", "svelte", "--tab-width", "8"], { stdin, reads });
+ expect(result).toMatchObject({ raw: "c
\n", stderr: "", exitCode: 0 });
+ expect(result.files["a.svelte"]).toBe(files["a.svelte"]);
+ // `JSON.stringify` drops `filepath: undefined`.
+ expect(JSON.parse(result.files[reads[2]]!)).toEqual({ ...config, parser: "svelte", tabWidth: 8 });
+ expect(result.files[reads[3]]!.split("\n")[0]).toBe("undefined");
+ const checked = await format(files, ["--parser", "svelte", "--check"], { stdin });
+ expect(checked).toMatchObject({ raw: "(stdin)\n", exitCode: 1 });
+ },
+ timeout,
+ );
+
+ test.each<[string[], string, number]>([
+ [[], "[error] stdin: SyntaxError: Unexpected token (1:2)", 2],
+ [["--check"], "[error] Unexpected token (1:2)", 0],
+ ])(
+ "--parser with the plugin's parser, and the code doesn't parse: %j",
+ async (flags, stderr, exitCode) => {
+ const result = await format(files, ["--parser", "svelte", ...flags], { stdin: "broken
\n" });
+ expect(result).toMatchObject({ raw: "", stderr, exitCode });
+ },
+ timeout,
+ );
+
+ test(
+ "--parser with a parser that none of the plugins has",
+ async () => {
+ const prettier = `exports.resolveConfig = async () => null;
+class ConfigError extends Error {
+ name = "ConfigError";
+}
+exports.format = async (text, { parser }) => {
+ throw new ConfigError(\`Couldn't resolve parser "\${parser}".\`);
+};
+`;
+ const project = { ...files, "node_modules/prettier/index.cjs": prettier };
+ const result = await format(project, ["--parser", "nonsense"], { stdin: "a\n" });
+ expect(result).toMatchObject({ raw: "", stderr: `[error] Couldn't resolve parser "nonsense".`, exitCode: 1 });
+ },
+ timeout,
+ );
+
+ test("--parser with the plugin's parser, and nothing is installed", async () => {
+ const result = await format({ ".prettierrc": files[".prettierrc"] }, ["--parser", "svelte"], {
+ stdin: "c
\n",
+ });
+ expect(result.raw).toBe("");
+ expect(result.stderr.split("\n")).toEqual([
+ "[warn] Not installed: prettier, prettier-plugin-svelte",
+ "[warn] Install prettier and every plugin in your config, and bun fmt will use them for these files.",
+ "[error] 1 file was not formatted because bun fmt does not support its language yet: 1 svelte",
+ "[error] Use --allow-unsupported to make this a warning.",
+ ]);
+ expect(result.exitCode).toBe(2);
+ });
+
// As Prettier 3.9.9 with a plugin that throws this.
test.each<[string[], string, number]>([
[[], "[error] c.svelte: SyntaxError: Unexpected token (1:2)", 2],
@@ -1242,13 +1774,15 @@ exports.format = async (text, options) => {
...Object.fromEntries(installed.map(name => [name, "{}"])),
};
const stdin = "c
\n";
- const unsupported = "1 file is in a language that bun format does not support yet, and left as it is: 1 .svelte";
+ const unsupported = "1 file was not formatted because bun fmt does not support its language yet: 1 .svelte";
test.each([[[]], [["--check"]], [["--list-different"]]])("%j", async flags => {
const result = await format(project, [...flags, "--stdin-filepath", "c.svelte"], { stdin });
expect(result.raw).toBe("");
expect(result.stderr.split("\n")).toEqual([
- `[warn] Not installed: ${missing}. With all plugins of the configuration and prettier installed, bun format hands the files of their languages to them.`,
- `[error] ${unsupported}. With --allow-unsupported this is a warning.`,
+ `[warn] Not installed: ${missing}`,
+ "[warn] Install prettier and every plugin in your config, and bun fmt will use them for these files.",
+ `[error] ${unsupported}`,
+ "[error] Use --allow-unsupported to make this a warning.",
]);
expect(result.exitCode).toBe(2);
});
@@ -1354,7 +1888,7 @@ exports.format = async (text, options) => {
{ reads: ["a.svelte", "c.svelte"] },
);
expect(result.files).toEqual({ "a.svelte": "a
\n", "c.svelte": "waits
\n" });
- expect(result.stderr).toContain("[error] c.svelte: A promise is not settled, and nothing is left to wait for.");
+ expect(result.stderr).toContain("[error] c.svelte: A promise never settled, and nothing is left to wait for.");
expect(result.exitCode).toBe(2);
},
timeout,
@@ -1391,9 +1925,9 @@ ${packages["node_modules/prettier/index.cjs"]}`;
const result = await format(others, [], { reads: ["a.svelte"] });
expect(result.files["a.svelte"]).toBe(files["a.svelte"]);
expect(result.stderr).toContain(
- "[warn] Not installed: prettier-plugin-svelte. With all plugins of the configuration and prettier installed, bun format hands the files of their languages to them.",
+ "[warn] Not installed: prettier-plugin-svelte\n[warn] Install prettier and every plugin in your config, and bun fmt will use them for these files.",
);
- expect(result.stderr).toContain("and left as it is: 1 .svelte.");
+ expect(result.stderr).toContain("does not support its language yet: 1 .svelte\n");
expect(result.exitCode).toBe(2);
},
timeout,
@@ -1412,23 +1946,24 @@ ${packages["node_modules/prettier/index.cjs"]}`;
const reads = Object.keys(files);
const result = await format({ ...files, ".oxfmtrc.json": '{ "svelte": {} }\n' }, [], { reads });
expect(result.files).toEqual(after);
- expect(result.stderr).toContain("does not support yet, and left as it is: 1 .svelte.");
+ expect(result.stderr).toContain("does not support its language yet: 1 .svelte\n");
expect(result.exitCode).toBe(2);
const without = await format({ ...files, ".oxfmtrc.json": "{}\n" }, [], { reads });
expect(without.files).toEqual(after);
- expect(without.stderr).not.toContain("does not support yet");
+ expect(without.stderr).not.toContain("does not support");
expect(without.exitCode).toBe(0);
// oxfmt 0.72 formats the one, and gives the other back.
const piped = (config: string) =>
format({ ".oxfmtrc.json": config }, ["--stdin-filepath", "a.svelte"], { stdin: files["a.svelte"] });
expect(await piped('{ "svelte": {} }\n')).toMatchObject({
raw: "",
- stderr: expect.stringContaining("does not support yet, and left as it is: 1 .svelte."),
+ stderr: expect.stringContaining("does not support its language yet: 1 .svelte\n"),
exitCode: 2,
});
expect(await piped("{}\n")).toMatchObject({ raw: files["a.svelte"], stderr: "", exitCode: 0 });
- // Prettier has no TOML.
- expect(await different(files, [])).toEqual(["c.js"]);
+ // Prettier doesn't format TOML. Without a Prettier or oxfmt config, Bun formats it like any other language.
+ expect(await different({ ...files, ".prettierrc": "{}\n" }, [])).toEqual(["c.js"]);
+ expect(await different(files, [])).toEqual(["b.toml", "c.js", "Pipfile"]);
const broken = await format({ "b.toml": "a = = 1\n", ".oxfmtrc.json": "{}\n" }, [], { reads: ["b.toml"] });
expect(broken.files["b.toml"]).toBe("a = = 1\n");
expect(broken.stderr).toMatch(/b\.toml: SyntaxError: .+ \(1:\d+\)/);
@@ -1498,10 +2033,10 @@ ${packages["node_modules/prettier/index.cjs"]}`;
['{ "semi": null }', "Invalid semi value: null."],
['{ "semi": "true" }', 'Invalid semi value: "true".'],
['{ "endOfLine": null }', "Invalid endOfLine value: null."],
- ['{ "plugins": "x" }', '"plugins" is not an array.'],
- ['{ "overrides": 1 }', '"overrides" is not an array of objects with "files"'],
- ['{ "overrides": [{ "options": {} }] }', '"overrides" is not an array of objects with "files"'],
- ['{ "overrides": [{ "files": 1 }] }', '"overrides" is not an array of objects with "files"'],
+ ['{ "plugins": "x" }', '"plugins" must be an array'],
+ ['{ "overrides": 1 }', '"overrides" must be an array of objects, each with "files"'],
+ ['{ "overrides": [{ "options": {} }] }', '"overrides" must be an array of objects, each with "files"'],
+ ['{ "overrides": [{ "files": 1 }] }', '"overrides" must be an array of objects, each with "files"'],
])("a .prettierrc that Prettier cannot use is an error: %s", async (config, message) => {
const result = await format({ ".prettierrc": config, "a.js": ugly }, ["a.js"], { reads: ["a.js"] });
expect(result.files["a.js"]).toBe(ugly);
@@ -1514,8 +2049,8 @@ ${packages["node_modules/prettier/index.cjs"]}`;
const result = await format({ "a.json": "", "b.js": ugly }, ["--check", "."], {
before: dir => truncateSync(join(dir, "a.json"), 2 ** 29),
});
- expect(result.stderr).toContain(`[error] Unable to read file "a.json":\n[error] EFBIG: `);
- expect(result.stderr).toContain("[warn] b.js");
+ expect(result.stderr).toContain(`error: Unable to read file "a.json":\nEFBIG: `);
+ expect(result.stdout).toBe("b.js");
expect(result.exitCode).toBe(2);
});
@@ -1539,7 +2074,7 @@ ${packages["node_modules/prettier/index.cjs"]}`;
[],
],
[{ ".prettierrc.mjs": `export default { printWidth: Infinity, rangeStart: 0, rangeEnd: Infinity };` }, []],
- [{ "bunfig.toml": `[format]\nprintWidth = 100000\n` }, []],
+ [{ "bunfig.toml": `[fmt]\nprintWidth = 100000\n` }, []],
[{}, ["--print-width", "65536"]],
[{}, ["--print-width=100000"]],
[{}, ["--print-width=9007199254740991"]],
@@ -1605,23 +2140,27 @@ ${packages["node_modules/prettier/index.cjs"]}`;
const result = await format(files, [], { reads });
expect(result.files).toEqual({ "a.js": ugly, "b.astro": files["b.astro"], "other/c.js": formatted });
expect(result.stderr).toContain(
- "[error] 2 files are left as they are: the configuration names plugins that bun format does not have, and that may print them in another way: prettier-plugin-brace-style, ./own.js. With --allow-unsupported they are formatted without.",
+ "[error] 2 files were not formatted because the Prettier config uses plugins that bun fmt does not include: prettier-plugin-brace-style, ./own.js\n[error] Use --allow-unsupported to format them without the plugins.",
+ );
+ expect(result.stderr).toContain(
+ "[error] 1 file was not formatted because bun fmt does not support its language yet",
);
- expect(result.stderr).toContain("[error] 1 file is in a language that bun format does not support yet");
expect(result.exitCode).toBe(2);
const allowed = await format(files, ["--allow-unsupported"], { reads });
expect(allowed.files).toEqual({ "a.js": formatted, "b.astro": files["b.astro"], "other/c.js": formatted });
- expect(allowed.stderr).toContain("[warn] Plugins are not supported");
+ expect(allowed.stderr).toMatch(
+ /\[warn\] prettier-plugin-brace-style, \.\/own\.js in \S+\.prettierrc have no effect because bun fmt does not include those plugins\n/,
+ );
expect(allowed.exitCode).toBe(0);
const flag = await format({ "a.js": ugly }, ["--plugin", "prettier-plugin-brace-style"], { reads: ["a.js"] });
expect(flag.files["a.js"]).toBe(ugly);
- expect(flag.stderr).toContain("bun format does not have the plugin prettier-plugin-brace-style");
+ expect(flag.stderr).toContain("bun fmt does not include the plugin prettier-plugin-brace-style\n");
expect(flag.exitCode).toBe(2);
for (const flags of [[], ["--check"], ["--list-different"]]) {
const piped = await format(files, [...flags, "--stdin-filepath", "d.js"], { stdin: ugly });
expect(piped.raw).toBe("");
expect(piped.stderr).toBe(
- "[error] 1 file is left as it is: the configuration names plugins that bun format does not have, and that may print it in another way: prettier-plugin-brace-style, ./own.js. With --allow-unsupported it is formatted without.",
+ "[error] 1 file was not formatted because the Prettier config uses plugins that bun fmt does not include: prettier-plugin-brace-style, ./own.js\n[error] Use --allow-unsupported to format it without the plugins.",
);
expect(piped.exitCode).toBe(2);
}
@@ -1635,7 +2174,7 @@ ${packages["node_modules/prettier/index.cjs"]}`;
const result = await format(files, [], { reads: ["a.js", "legacy/b.js"] });
expect(result.files).toEqual({ "a.js": formatted, "legacy/b.js": ugly });
expect(result.stderr).toContain(
- "[error] 1 file is left as it is: the configuration names plugins that bun format does not have, and that may print it in another way: prettier-plugin-brace-style.",
+ "[error] 1 file was not formatted because the Prettier config uses plugins that bun fmt does not include: prettier-plugin-brace-style\n",
);
expect(result.exitCode).toBe(2);
// An override that empties the list takes its files out.
@@ -1650,6 +2189,40 @@ ${packages["node_modules/prettier/index.cjs"]}`;
expect(other.exitCode).toBe(2);
});
+ // Prettier 3.9.9 prints 3,939 files of JavaScript and TypeScript the same with and without @prettier/plugin-oxc 0.2.3.
+ test.each<[string, Record, string[]?]>([
+ ["its name", { ".prettierrc": '{ "plugins": ["@prettier/plugin-oxc"] }' }],
+ [
+ "the module",
+ {
+ "prettier.config.mjs": 'import * as oxc from "@prettier/plugin-oxc";\nexport default { plugins: [oxc] };',
+ "node_modules/@prettier/plugin-oxc/package.json":
+ '{ "name": "@prettier/plugin-oxc", "exports": "./index.mjs" }',
+ "node_modules/@prettier/plugin-oxc/index.mjs":
+ 'export const parsers = { oxc: { astFormat: "estree" }, "oxc-ts": { astFormat: "estree" } };\n',
+ },
+ ],
+ [
+ "overrides with its parsers",
+ {
+ ".prettierrc": JSON.stringify({
+ overrides: [
+ { files: "*.js", options: { parser: "oxc", plugins: ["@prettier/plugin-oxc"] } },
+ { files: "*.ts", options: { parser: "oxc-ts", plugins: ["@prettier/plugin-oxc"] } },
+ ],
+ }),
+ },
+ ],
+ ["--plugin", {}, ["--plugin", "@prettier/plugin-oxc"]],
+ ])("the plugin that only reads with another parser, named by %s, changes nothing", async (_, files, flags = []) => {
+ const result = await format({ ...files, "a.js": ugly, "a.ts": "let a: 1\n" }, ["--log-level=warn", ...flags], {
+ reads: ["a.js", "a.ts"],
+ });
+ expect(result.stderr).toBe("");
+ expect(result.files).toEqual({ "a.js": formatted, "a.ts": "let a: 1;\n" });
+ expect(result.exitCode).toBe(0);
+ });
+
test("a plugin that only adds a language: the rest is formatted, and the files of that language are counted", async () => {
const files = {
".prettierrc": '{ "plugins": ["prettier-plugin-astro"] }\n',
@@ -1659,7 +2232,7 @@ ${packages["node_modules/prettier/index.cjs"]}`;
};
const result = await format(files, [], { reads: ["a.js", "b.astro", "c.svelte"] });
expect(result.files).toEqual({ "a.js": formatted, "b.astro": files["b.astro"], "c.svelte": files["c.svelte"] });
- expect(result.stderr).toContain("and left as it is: 1 .astro.");
+ expect(result.stderr).toContain("does not support its language yet: 1 .astro\n");
expect(result.exitCode).toBe(2);
});
@@ -1720,7 +2293,7 @@ ${packages["node_modules/prettier/index.cjs"]}`;
const after = async (files: Record, args: string[] = []) =>
(await format({ "a.ts": asOxfmt, ...files }, args, { reads: ["a.ts"] })).files["a.ts"];
- test("a project that depends on oxfmt, or Vite+, and not on Prettier is formatted like oxfmt does", async () => {
+ test("a project that depends on oxfmt or Vite+ gets oxfmt's style", async () => {
expect(await after({})).toBe(asPrettier);
expect(await after({ "package.json": '{ "devDependencies": { "oxfmt": "0.72.0" } }\n' })).toBe(asOxfmt);
expect(await after({ "package.json": '{ "dependencies": { "vite-plus": "1.0.0" } }\n' })).toBe(asOxfmt);
@@ -1729,9 +2302,10 @@ ${packages["node_modules/prettier/index.cjs"]}`;
["a.ts"],
{ reads: ["a.ts"] },
);
- expect(both.files["a.ts"]).toBe(asPrettier);
+ // Depending on both suggests a migration to oxfmt, so oxfmt wins.
+ expect(both.files["a.ts"]).toBe(asOxfmt);
expect(both.stderr).toContain(
- "[warn] There is no configuration file, and the project depends on both oxfmt and prettier",
+ "note: No config file found, and package.json depends on both oxfmt and prettier\n Formatting like oxfmt. Add a config file to change that.",
);
// A configuration file says more than that.
expect(
@@ -1807,17 +2381,6 @@ ${packages["node_modules/prettier/index.cjs"]}`;
expect(result.files).toEqual({ "a.ts": asOxfmt, "pnpm-lock.yaml": "a: 1\n" });
expect(result.exitCode).toBe(0);
});
-
- test("--init writes an .oxfmtrc.json, as oxfmt does, and not over one that is there", async () => {
- const result = await format({ "a.ts": asOxfmt }, ["--init"], { reads: [".oxfmtrc.json", "a.ts"] });
- expect(result.files).toEqual({ ".oxfmtrc.json": '{\n "ignorePatterns": []\n}\n', "a.ts": asOxfmt });
- expect(result.stdout).toBe("Created `.oxfmtrc.json`.");
- expect(result.exitCode).toBe(0);
- const again = await format({ ".oxfmtrc.jsonc": "{}\n" }, ["--init"], { reads: [".oxfmtrc.jsonc"] });
- expect(again.files[".oxfmtrc.jsonc"]).toBe("{}\n");
- expect(again.stderr).toContain("A configuration file of oxfmt already exists.");
- expect(again.exitCode).toBe(1);
- });
});
// As Prettier 3.9.9 and oxfmt 0.72.
@@ -1873,7 +2436,7 @@ ${packages["node_modules/prettier/index.cjs"]}`;
const files = {
[`sub/${name}`]: `
const seen = JSON.stringify([process.argv[0] === process.execPath, ...process.argv.slice(1)]);
- export default { semi: seen !== ${JSON.stringify(JSON.stringify([true, "format", ...args]))} };\n`,
+ export default { semi: seen !== ${JSON.stringify(JSON.stringify([true, "fmt", ...args]))} };\n`,
"sub/a.js": "a;\n",
};
const result = await format(files, args, { reads: ["sub/a.js"] });
@@ -1920,21 +2483,23 @@ ${packages["node_modules/prettier/index.cjs"]}`;
const result = await format({ ...files, ".prettierrc": config }, [], { reads });
expect(result.files).toEqual({ ...files, "d.js": "d;\n" });
expect(result.stderr).toContain(
- "3 files are in a language that bun format does not support yet, and left as they are: 1 .astro, 1 .foo, 1 .js",
+ "3 files were not formatted because bun fmt does not support their language yet: 1 .astro, 1 .foo, 1 .js",
);
expect(result.exitCode).toBe(2);
const checked = await format({ ...files, ".prettierrc": config, "d.js": "d;\n" }, ["--check", ...reads], {
reads,
});
expect(checked.files).toEqual({ ...files, "d.js": "d;\n" });
- expect(checked.stderr).toContain("3 files are in a language that bun format does not support yet");
+ expect(checked.stderr).toContain(
+ "3 files were not formatted because bun fmt does not support their language yet",
+ );
expect(checked.exitCode).toBe(2);
});
test("at the top of a .prettierrc: every file is left as it is", async () => {
const result = await format({ ...files, ".prettierrc": '{ "parser": "nonsense" }\n' }, [], { reads });
expect(result.files).toEqual(files);
- expect(result.stderr).toContain("files are in a language that bun format does not support yet");
+ expect(result.stderr).toContain("files were not formatted because bun fmt does not support their language yet");
expect(result.exitCode).toBe(2);
});
@@ -1945,7 +2510,7 @@ ${packages["node_modules/prettier/index.cjs"]}`;
});
expect(result.raw).toBe("");
expect(result.stderr).toBe(
- "[error] 1 file is in a language that bun format does not support yet, and left as it is: 1 .astro. With --allow-unsupported this is a warning.",
+ "[error] 1 file was not formatted because bun fmt does not support its language yet: 1 .astro\n[error] Use --allow-unsupported to make this a warning.",
);
expect(result.exitCode).toBe(2);
});
@@ -1961,11 +2526,11 @@ ${packages["node_modules/prettier/index.cjs"]}`;
test("after --parser: an error, and nothing is written", async () => {
const result = await format(files, ["--parser", "nonsense"], { reads });
expect(result.files).toEqual(files);
- expect(result.stderr).toContain(`[error] Couldn't resolve parser "nonsense".`);
+ expect(result.stderr).toContain(`error: Couldn't resolve parser "nonsense".`);
expect(result.exitCode).toBe(2);
const piped = await format({}, ["--parser", "nonsense", "--stdin-filepath", "c.js"], { stdin: files["c.js"] });
expect(piped.raw).toBe("");
- expect(piped.stderr).toContain(`[error] Couldn't resolve parser "nonsense".`);
+ expect(piped.stderr).toContain(`error: Couldn't resolve parser "nonsense".`);
expect(piped.exitCode).toBe(2);
});
});
@@ -1979,8 +2544,8 @@ ${packages["node_modules/prettier/index.cjs"]}`;
])("JSON is not formatted into gigabytes: %s", async (_, make) => {
const files = { "ok.json": make(512), "huge.json": make(200_000) };
const [ok, huge] = await Promise.all(Object.keys(files).map(name => format(files, ["--check", name])));
- expect(ok.stderr).not.toContain("[error]");
- expect(huge.stderr).toContain("[error] huge.json:");
+ expect(ok.stderr).not.toContain("error:");
+ expect(huge.stderr).toContain("error: huge.json:");
expect(huge.exitCode).toBe(2);
});
@@ -2259,7 +2824,7 @@ ${packages["node_modules/prettier/index.cjs"]}`;
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
- expect(stderr).toBeOneOf(["", "[error] a.js: RangeError: The code is nested too deeply.\n"]);
+ expect(stderr).toBeOneOf(["", "error: a.js: RangeError: The code is nested too deeply.\n"]);
expect({ exitCode, signalCode: proc.signalCode }).toEqual({
exitCode: stderr === "" ? 0 : 2,
signalCode: null,
@@ -2272,7 +2837,7 @@ ${packages["node_modules/prettier/index.cjs"]}`;
const text = `
\n`;
const result = await format({ "a.html": text }, ["a.html"], { reads: ["a.html"] });
expect(result.files["a.html"]).toBe(text);
- expect(result.stderr).toContain("[error] a.html: RangeError: Invalid string length");
+ expect(result.stderr).toContain("error: a.html: RangeError: Invalid string length");
expect(result.exitCode).toBe(2);
});
@@ -2280,9 +2845,9 @@ ${packages["node_modules/prettier/index.cjs"]}`;
const result = await format({ "a.css": `a{${"b:c;".repeat(250)}}}` }, ["a.css"]);
// As Prettier shows them.
expect(result.stderr.split("\n").slice(0, 3)).toEqual([
- `[error] a.css: SyntaxError: Unexpected "}" (1:1004)`,
- `[error] > 1 | a{${"b:c;".repeat(250)}}}`,
- `[error] | ${" ".repeat(1_003)}^`,
+ `error: a.css: SyntaxError: Unexpected "}" (1:1004)`,
+ `> 1 | a{${"b:c;".repeat(250)}}}`,
+ ` | ${" ".repeat(1_003)}^`,
]);
expect(result.exitCode).toBe(2);
});
@@ -2308,8 +2873,8 @@ ${packages["node_modules/prettier/index.cjs"]}`;
async word => {
const text = word === "@" ? word.repeat(100_000) : `// @flow\ntype A = ${word.repeat(100_000)}x;`;
const result = await format({ "deep.js": text + "\n" }, ["--check", "deep.js"]);
- expect(result.stderr).toMatch(/^\[(?:error\] deep\.js:|warn\] deep\.js$)/m);
- expect(result.exitCode).toBe(result.stderr.includes("[error]") ? 2 : 1);
+ expect(result.stdout + "\n" + result.stderr).toMatch(/^(?:error: deep\.js:|deep\.js$)/m);
+ expect(result.exitCode).toBe(result.stderr.includes("error:") ? 2 : 1);
},
);
@@ -2333,7 +2898,7 @@ ${packages["node_modules/prettier/index.cjs"]}`;
const files = { "a.js": code + "\n", "b.mjs": code + "\n", "c.jsx": code + "\n" };
const result = await format(files, [], { reads: Object.keys(files) });
expect(result.files).toEqual(files);
- for (const name of Object.keys(files)) expect(result.stderr).toContain(`[error] ${name}: SyntaxError: `);
+ for (const name of Object.keys(files)) expect(result.stderr).toContain(`error: ${name}: SyntaxError: `);
expect(result.exitCode).toBe(2);
});
@@ -2364,7 +2929,7 @@ ${packages["node_modules/prettier/index.cjs"]}`;
])("what only TypeScript's parser reads is a syntax error in JavaScript: %j", async code => {
const result = await format({ "a.js": code + "\n" }, ["a.js"], { reads: ["a.js"] });
expect(result.files).toEqual({ "a.js": code + "\n" });
- expect(result.stderr).toContain("[error] a.js: SyntaxError: ");
+ expect(result.stderr).toContain("error: a.js: SyntaxError: ");
expect(result.exitCode).toBe(2);
});
@@ -2378,7 +2943,7 @@ ${packages["node_modules/prettier/index.cjs"]}`;
const files = { "a.js": `// @flow\n${code}\n` };
const result = await format(files, ["a.js"], { reads: ["a.js"] });
expect(result.files).toEqual(files);
- expect(result.stderr).toContain("[error] a.js: SyntaxError: ");
+ expect(result.stderr).toContain("error: a.js: SyntaxError: ");
expect(result.exitCode).toBe(2);
});
@@ -2405,6 +2970,29 @@ declare export function h(): void;
["a.ts", "using a;"],
["a.js", "const a;"],
["a.js", "const { a };"],
+ ["a.ts", "class A { set x(a = await g()) {} }"],
+ ["a.ts", "yield g;"],
+ ["a.ts", "function* g() { function yield() {} }"],
+ ["a.js", "class A { static { var await; } }"],
+ ["a.ts", "label: enum E {}"],
+ ["a.cjs", "void import.meta;"],
+ ["a.ts", "module M { await g(); }"],
+ ["a.ts", "namespace N { for await (const x of y) ; }"],
+ ["a.ts", "let x: { a; b; c?; readonly d; [e: string] };"],
+ ["a.d.ts", "await g;"],
+ ["a.ts", "declare class A { x = await g; }"],
+ ["a.ts", "class A { set x(v?: number) {} }"],
+ ["a.ts", "declare module A { export = B }"],
+ ["a.d.ts", "function f() { yield; }"],
+ ["a.ts", "declare namespace N { export default class {} }"],
+ ["a.d.ts", "await using x = g();"],
+ ["a.ts", "class A { constructor(this: never) {} }"],
+ ["a.js", 'export * from "m" with { type: 1 };'],
+ ["a.ts", 'export * from "m" with { type: 1 };'],
+ ["a.js", 'import a from "m" with { type: 1 };'],
+ ["a.ts", "type T = A;"],
+ ["a.ts", "interface I {}"],
+ ["a.ts", "function f() {}"],
])("with an .oxfmtrc.json what OXC's parser refuses is a syntax error: %s: %j", async (name, code) => {
const files = { [name]: code + "\n" };
const result = await format({ ...files, ".oxfmtrc.json": "{}\n" }, [name], { reads: [name] });
@@ -2420,9 +3008,18 @@ declare export function h(): void;
["a.ts", "export {};\nlet = 1;"],
["a.js", "export {};\nstatic = 1;"],
["a.js", "for (const a of b);"],
+ // typescript-estree and Babel refuse these.
+ ["a.js", "function f(a = await g()) {}"],
+ ["a.js", "function* a(x = yield 1) {}"],
+ ["a.mts", "for (var i = 0 in {});"],
+ ["a.ts", "for (const x: any of y);"],
+ ["a.ts", "with (a) b;"],
+ ["a.js", "if (a) label: function f() {}"],
+ ["a.js", 'import("m", { with: { type: 1 } });'],
+ ["a.ts", "class C {}\nfunction g() {}\ninterface I {}"],
])("with an .oxfmtrc.json what OXC's parser takes is formatted: %s: %j", async (name, code) => {
const result = await format({ [name]: code + "\n", ".oxfmtrc.json": "{}\n" }, ["--check", name]);
- expect(result.stderr).not.toContain("[error]");
+ expect(result.stderr).not.toMatch(anError);
expect(result.exitCode).toBe(0);
});
@@ -2542,7 +3139,7 @@ try {
{ ".editorconfig": "root = true\n[*]\ninsert_final_newline = true\nindent_size = 4\n", "a.js": "if (a) b;\n" },
["--check", "a.js"],
);
- expect(result.stderr).toBe("");
+ expect(result.stderr).toBe("1 file is formatted.");
expect(result.exitCode).toBe(0);
});
@@ -2564,7 +3161,7 @@ try {
// In a directory the others are passed over.
const directory = await format(files, ["bin"], { reads });
expect(directory.files).toEqual(after);
- expect(directory.stderr).not.toContain("[error]");
+ expect(directory.stderr).not.toMatch(anError);
expect(directory.exitCode).toBe(0);
const named = await format(files, reads, { reads });
expect(named.files).toEqual(after);
@@ -2583,7 +3180,7 @@ try {
};
const result = await format(files, ["bin/a", "bin/b"], { reads: ["bin/a", "bin/b"] });
expect(result.files).toEqual({ ...files, "bin/a": `#!/usr/bin/env ${interpreter}\na;\n` });
- expect(result.stderr).toContain("[error] bin/b: SyntaxError:");
+ expect(result.stderr).toContain("error: bin/b: SyntaxError:");
expect(result.exitCode).toBe(2);
},
);
@@ -2592,9 +3189,12 @@ try {
test("what a tool has written and Prettier does not read", async () => {
const names = ["composer.lock", "Pipfile.lock", "flake.lock", "deno.lock", "mcmod.info", "MODULE.bazel.lock"];
const files = Object.fromEntries(names.map(name => [name, '{"a": 1}\n']));
- const directory = await format({ ...files, "glide.lock": "a: 1\n" }, [], { reads: [...names, "glide.lock"] });
+ // Prettier reads glide.lock as YAML. Without a Prettier config, Bun skips it like every other lock file.
+ const all = { ...files, "glide.lock": "a: 1\n" };
+ const directory = await format({ ...all, ".prettierrc": "{}\n" }, [], { reads: [...names, "glide.lock"] });
expect(directory.files).toEqual({ ...files, "glide.lock": "a: 1\n" });
expect(directory.exitCode).toBe(0);
+ expect((await format(all, [], { reads: [...names, "glide.lock"] })).files).toEqual(all);
const named = await format(files, names, { reads: names });
expect(named.files).toEqual(files);
expect(named.stderr.split("\n").filter(line => line.includes("No parser could be inferred")).length).toBe(
@@ -2616,7 +3216,7 @@ try {
const files = { "FOO": "x y\n", "a.xyz": "x y\n" };
for (const named of ["FOO", "*.xyz"]) {
const result = await format(files, [...flags, named], { reads: Object.keys(files) });
- expect(result.stderr.includes("[error] No parser could be inferred for file")).toBe(exitCode === 2);
+ expect(result.stderr.includes("error: No parser could be inferred for file")).toBe(exitCode === 2);
expect(result.files).toEqual(files);
expect(result.exitCode).toBe(exitCode);
}
@@ -2634,7 +3234,7 @@ try {
])("standard input that there is no parser for: %j", async (flags, exitCode, isSaid) => {
const result = await format({}, [...flags, "--stdin-filepath", "FOO"], { stdin: "x y\n" });
expect(result.raw).toBe("");
- expect(result.stderr.includes("[error] No parser could be inferred for file")).toBe(isSaid);
+ expect(result.stderr.includes("error: No parser could be inferred for file")).toBe(isSaid);
expect(result.exitCode).toBe(exitCode);
});
@@ -2651,11 +3251,14 @@ try {
])("standard input that does not parse: %j %j", async (configuration, flags, exitCode, before) => {
const result = await format(configuration, [...flags, "--stdin-filepath", "a.ts"], { stdin: "let a: = 1;\n" });
expect(result.raw).toBe("");
+ const [first, other] = Object.keys(configuration).length === 0 ? ["error: ", ""] : ["[error] ", "[error] "];
+ // The words of the parser of each tool.
+ const why = ".oxfmtrc.json" in configuration ? "Unexpected token" : "Type expected.";
expect(result.stderr.split("\n")).toEqual([
- `[error] ${before}Type expected. (1:8)`,
- "[error] > 1 | let a: = 1;",
- "[error] | ^",
- "[error] 2 |",
+ `${first}${before}${why} (1:8)`,
+ `${other}> 1 | let a: = 1;`,
+ `${other} | ^`,
+ `${other} 2 |`,
]);
expect(result.exitCode).toBe(exitCode);
});
@@ -2671,14 +3274,14 @@ try {
])("standard input that does not parse, with --check: %s", async (name, stdin) => {
const result = await format({}, ["--check", "--stdin-filepath", name], { stdin });
expect(result.raw).toBe("");
- expect(result.stderr).toStartWith("[error] ");
+ expect(result.stderr).toStartWith("error: ");
expect(result.stderr).not.toContain(name);
expect(result.exitCode).toBe(0);
});
test.each(["--check", "--list-different"])("a file that does not parse, with %s", async flag => {
const result = await format({ "a.ts": "let a: = 1;\n" }, [flag, "a.ts"]);
- expect(result.stderr).toStartWith("[error] a.ts: SyntaxError: Type expected. (1:8)\n");
+ expect(result.stderr).toStartWith("error: a.ts: SyntaxError: Type expected. (1:8)\n");
expect(result.exitCode).toBe(2);
});
@@ -2853,7 +3456,7 @@ try {
);
const shapes = await run(index => texts[index]);
const plain = await run(() => repeated(ordinary, length / ordinary.length));
- expect(shapes.cpu / plain.cpu).toBeLessThan(4);
+ expect(slowdown(shapes, plain)).toBeLessThan(4);
}
},
120_000,
@@ -2946,7 +3549,7 @@ try {
format(Object.fromEntries(shapes.map(([ending], index) => [`${index}.${ending}`, text(index)])), ["--check"]);
const [odd, plain] = [await run(index => texts[index]), await run(() => ordinary.repeat(length / ordinary.length))];
// 3 when all is well.
- expect(odd.cpu / plain.cpu).toBeLessThan(6);
+ expect(slowdown(odd, plain)).toBeLessThan(6);
}, 120_000);
// The same for what is repeated in something. Prettier takes the square of the number of parameters.
@@ -2966,8 +3569,8 @@ try {
const run = (text: (index: number) => string) =>
format(Object.fromEntries(shapes.map((_, index) => [`${index}.scss`, text(index)])), ["--check"]);
const [odd, plain] = [await run(index => texts[index]), await run(() => ordinary.repeat(length / ordinary.length))];
- // 3 when all is well, 1,189 without the fix. Windows counts the time of the processor in steps of 15.6 ms.
- expect(odd.cpu / plain.cpu).toBeLessThan(60);
+ // 3 when all is well, 1,189 without the fix.
+ expect(slowdown(odd, plain)).toBeLessThan(60);
}, 120_000);
// What some part of reading or printing Markdown once walked again for each repetition, or took a thousand times too
@@ -3012,8 +3615,8 @@ try {
["--check"],
);
const [shapes, plain] = [await run(index => texts[index]), await run(() => prose.repeat(length / prose.length))];
- expect(shapes.stderr).not.toContain("[error]");
- expect(shapes.cpu / plain.cpu).toBeLessThan(6);
+ expect(shapes.stderr).not.toMatch(anError);
+ expect(slowdown(shapes, plain)).toBeLessThan(6);
},
120_000,
);
@@ -3085,9 +3688,9 @@ try {
];
const files = Object.fromEntries(cases.map(([ending, text], index) => [`${index}${ending}`, text]));
const result = await format(files, ["--check"]);
- const errors = result.stderr.split("\n").filter(line => /^\[error\] \d+\./.test(line));
+ const errors = result.stderr.split("\n").filter(line => /^error: \d+\./.test(line));
expect(errors.sort()).toEqual(
- cases.map(([ending, , message], index) => `[error] ${index}${ending}: SyntaxError: ${message}`).sort(),
+ cases.map(([ending, , message], index) => `error: ${index}${ending}: SyntaxError: ${message}`).sort(),
);
expect(result.exitCode).toBe(2);
});
@@ -3101,7 +3704,7 @@ try {
[],
{ reads: ["a.html", "b.vue"] },
);
- expect(result.stderr).not.toContain("[error]");
+ expect(result.stderr).not.toMatch(anError);
expect(result.files).toEqual({
"a.html": "\n",
"b.vue": "\n",
@@ -3149,6 +3752,41 @@ try {
]);
});
+ test("skips minified code and lock files unless they're named", async () => {
+ const generated = [
+ "vendor/jquery.min.js",
+ "vendor/a.min.mjs",
+ "vendor/a.min.cjs",
+ "vendor/a.min.css",
+ "package-lock.json",
+ "npm-shrinkwrap.json",
+ "sub/pnpm-lock.yaml",
+ "bun.lock",
+ "composer.lock",
+ "deno.lock",
+ "flake.lock",
+ ];
+ const unformatted = (name: string) =>
+ name.endsWith("js")
+ ? ugly
+ : name.endsWith(".css")
+ ? "a{b:c}\n"
+ : name.endsWith(".yaml")
+ ? "a: 1\n"
+ : '{"a":1}\n';
+ const all = { ...Object.fromEntries(generated.map(name => [name, unformatted(name)])), "admin.js": ugly };
+ expect(await different(all, [])).toEqual(["admin.js"]);
+ expect(await different(all, ["**/*"])).toEqual(["admin.js"]);
+ expect(await different(all, ["vendor/jquery.min.js", "package-lock.json"])).toEqual([
+ "vendor/jquery.min.js",
+ "package-lock.json",
+ ]);
+ // Prettier formats all of these that it has a parser for.
+ const withPrettier = await different({ ...all, ".prettierrc": "{}\n" }, []);
+ expect(withPrettier).toContain("vendor/jquery.min.js");
+ expect(withPrettier).toContain("package-lock.json");
+ });
+
test("files, directories, patterns, and patterns that exclude", async () => {
expect(await different(files, ["src/deep", "a.js"])).toEqual(["src/deep/e.jsx", "a.js"]);
expect(await different(files, ["src/*.{ts,mjs}"])).toEqual(["src/b.ts", "src/c.mjs"]);
@@ -3193,7 +3831,7 @@ try {
test("an argument that matches nothing fails with 2", async () => {
const result = await format(files, ["nothing/*.js", "a.js"], { reads: ["a.js"] });
expect(result.files).toEqual({ "a.js": formatted });
- expect(result.stderr).toContain('[error] No files matching the pattern were found: "nothing/*.js".');
+ expect(result.stderr).toContain('error: No files matching the pattern were found: "nothing/*.js".');
expect(result.exitCode).toBe(2);
});
@@ -3314,6 +3952,20 @@ try {
expect(result.exitCode).toBe(0);
});
+ // Both ways to a directory: it is walked into, and it is above a file that is named.
+ test.each([
+ ["package.json", ["."]],
+ ["package.json", ["src/a.ts"]],
+ [".prettierrc", ["."]],
+ ["prettier.config.js", ["src/a.ts"]],
+ ])("a directory called %s is no configuration file: %j", async (name, args) => {
+ const files = { ".prettierrc.json": `{ "semi": false }`, [`src/${name}/b.txt`]: "", "src/a.ts": ugly };
+ const result = await format(files, args, { reads: ["src/a.ts"] });
+ expect(result.stderr).toStartWith("Formatted ");
+ expect(result.files).toEqual({ "src/a.ts": noSemi });
+ expect(result.exitCode).toBe(0);
+ });
+
test.each([
[".prettierrc", `semi: [\n`],
[".prettierrc", `1\n`],
@@ -3327,6 +3979,60 @@ try {
expect(result.exitCode).toBe(2);
});
+ // Where Prettier 3.9.9 says "YAML Error in ..: Map keys must be unique at line .., column ..". A `.prettierrc` is YAML.
+ test.each<[string, string, number, number]>([
+ [".prettierrc", `{"semi": false, "semi": true}\n`, 1, 17],
+ [".prettierrc", `semi: false\nsemi: true\n`, 2, 1],
+ [".prettierrc", `"semi": false\nsemi: true\n`, 2, 1],
+ [".prettierrc", `semi: false\n"semi": true\n`, 2, 1],
+ [".prettierrc", `'semi': false\n'semi': true\n`, 2, 1],
+ [".prettierrc", `{ "semi" : false,\n "semi" : true }\n`, 2, 3],
+ [".prettierrc", `a:\n "b": 1\n "b": 2\n`, 3, 3],
+ [".prettierrc", `{"a": {"b": 1, "b": 2}, "semi": false}\n`, 1, 16],
+ [".prettierrc", `overrides:\n - files: "*.ts"\n options:\n semi: false\n semi: false\n`, 5, 7],
+ [".prettierrc", `? semi\n: false\n? semi\n: true\n`, 3, 3],
+ [".prettierrc.yaml", `semi: false\nsemi: true\n`, 2, 1],
+ [".prettierrc.yml", `semi: false\nsemi: false\n`, 2, 1],
+ ])("a key twice in %s: %j", async (name, text, line, column) => {
+ const result = await format({ [name]: text, "a.ts": ugly }, ["a.ts"], { reads: ["a.ts"] });
+ expect(result.files).toEqual({ "a.ts": ugly });
+ expect(result.stderr).toContain("Cannot load the configuration file");
+ expect(result.stderr).toContain(`Map keys must be unique at line ${line}, column ${column}:`);
+ expect(result.exitCode).toBe(2);
+ });
+
+ test("a key twice in YAML is shown as Prettier shows it", async () => {
+ const files = { ".prettierrc": `tabWidth: 2\noverrides:\n - files: a\n files: b\n`, "a.ts": ugly };
+ const result = await format(files, ["a.ts"]);
+ const lines = result.stderr.split("\n").map(line => line.replace(/^\[error\] ?/, ""));
+ expect(lines.slice(1, 6)).toEqual([
+ "Map keys must be unique at line 4, column 5:",
+ "",
+ " - files: a",
+ " files: b",
+ " ^",
+ ]);
+ });
+
+ // Prettier 3.9.9 takes the last.
+ test.each([
+ [".prettierrc.json", `{"semi": true, "semi": false}\n`],
+ [".prettierrc.json5", `{semi: true, semi: false}\n`],
+ ])("a key twice in %s", async (name, text) => {
+ const result = await format({ [name]: text, "a.ts": ugly }, ["a.ts"], { reads: ["a.ts"] });
+ expect(result.files).toEqual({ "a.ts": noSemi });
+ expect(result.exitCode).toBe(0);
+ });
+
+ test.each([`prettier:\n semi: false\n semi: false\n`, `name: a\nname: b\nprettier:\n semi: false\n`])(
+ "a package.yaml with a key twice has no configuration: %j",
+ async text => {
+ const result = await format({ "package.yaml": text, "a.ts": ugly }, ["a.ts"], { reads: ["a.ts"] });
+ expect(result.files).toEqual({ "a.ts": formatted });
+ expect(result.exitCode).toBe(0);
+ },
+ );
+
test("a package.yaml that cannot be read has no configuration", async () => {
const files = { "package.yaml": `name: [\n`, ".prettierrc": `semi: false\n`, "a.ts": ugly };
const result = await format(files, ["a.ts"], { reads: ["a.ts"] });
@@ -3425,7 +4131,7 @@ try {
test("requirePragma and checkIgnorePragma", async () => {
const files = {
"none.js": ugly,
- // Not `format.js`: that is what `bun format` would run.
+ // Not `format.js`: that is what `bun fmt` would run.
"at-format.js": `/** @format */\n${ugly}`,
"prettier.js": `#!/usr/bin/env bun\n/**\n * Text.\n * @prettier\n */\n${ugly}`,
"in-text.js": `/** see @format */\n${ugly}`,
@@ -3605,6 +4311,31 @@ try {
});
});
+ // What Prettier 3.9.9 does with each. In a worktree `.git` is a file.
+ describe.each<[string, Record, boolean]>([
+ ["nothing", {}, false],
+ ["a folder .git", { "repo/.git/HEAD": "" }, true],
+ ["a file .git", { "repo/.git": "gitdir: ../elsewhere\n" }, true],
+ ["a folder .hg", { "repo/.hg/store": "" }, true],
+ ["a file .hg", { "repo/.hg": "" }, false],
+ ["a folder .editorconfig", { "repo/.editorconfig/a": "root = true\n" }, false],
+ ])("an .editorconfig above a directory with %s", (_, marker, isRoot) => {
+ const files = { ".editorconfig": "[*]\nindent_style = tab\n", "repo/src/a.js": ugly, ...marker };
+ const expected = isRoot ? formatted : formatted.replace(" return", "\treturn");
+
+ test.each([[["."]], [["src/a.js"]]])("%j", async args => {
+ const result = await format(files, args, { cwd: "repo", reads: ["repo/src/a.js"] });
+ expect(result.files).toEqual({ "repo/src/a.js": expected });
+ expect(result.exitCode).toBe(0);
+ });
+
+ test("standard input", async () => {
+ const result = await format(files, ["--stdin-filepath", "src/a.js"], { cwd: "repo", stdin: ugly });
+ expect(result.raw).toBe(expected);
+ expect(result.exitCode).toBe(0);
+ });
+ });
+
describe("how an .editorconfig is read", () => {
// The one above says whether the one that is read is a root: with it, the quotes are single.
const project = {
@@ -3803,7 +4534,7 @@ try {
print({}, "--stdin-filepath", "my_node_modules/a.js"),
print({}, "--stdin-filepath", ".git/a.js"),
print({}, "--with-node-modules", "--stdin-filepath", "node_modules/x/a.js"),
- print({ "bunfig.toml": "[format]\nwithNodeModules = true\n" }, "--stdin-filepath", "node_modules/x/a.js"),
+ print({ "bunfig.toml": "[fmt]\nwithNodeModules = true\n" }, "--stdin-filepath", "node_modules/x/a.js"),
print({ ".oxfmtrc.json": "{}" }, "--stdin-filepath", "node_modules/x/a.js"),
print({}, "--flavor=oxfmt", "--stdin-filepath", "node_modules/x/a.js"),
]);
@@ -3817,6 +4548,415 @@ try {
});
});
+ // Expected values come from Prettier 3.9.9, which reads standard input whenever it gets no file arguments.
+ describe("--parser without any paths formats standard input", () => {
+ const files = {
+ "ci.yml": "jobs:\n test:\n steps:\n - run: |\n bun test\n",
+ "src/a.ts": "const a:number=1\n",
+ "package.json": `{"name":"a"}\n`,
+ ".editorconfig": "[*]\nindent_style = tab\n",
+ };
+ const reads = Object.keys(files);
+ const prettier = { ".prettierrc": "{}\n" };
+ const noSemi = { ".prettierrc": `{ "semi": false }\n` };
+ const js = `function f(){return "a"}\n`;
+ const printed = `function f() {\n return "a";\n}\n`;
+ const withoutSemi = `function f() {\n return "a"\n}\n`;
+ /** Runs `bun fmt --parser babel` with `js` on standard input. */
+ const pipe = (project: Record, flags: string[] = [], options: Options = {}) =>
+ format(project, ["--parser", "babel", ...flags], { stdin: js, ...options });
+
+ test.each([
+ ["no config", {}],
+ ["a Prettier config", { ".prettierrc": "{}\n" }],
+ ["an oxfmt config", { ".oxfmtrc.json": "{}\n" }],
+ ])("never writes to files in the working directory: %s", async (_, config) => {
+ for (const flags of [[], ["--write"], ["--check"], ["-l"]]) {
+ const result = await format({ ...files, ...config }, ["--parser", "markdown", ...flags], {
+ reads,
+ stdin: "# hi there\n",
+ });
+ expect(result.files).toEqual(files);
+ }
+ });
+
+ test.each([
+ ["babel", "const a = {b:1, c:'two'}\n", 'const a = { b: 1, c: "two" };\n'],
+ ["babel", "a = x\n", 'a = x;\n'],
+ ["babel", "// @flow\ntype A = {|a:1|}\n", "// @flow\ntype A = {| a: 1 |};\n"],
+ ["babel-flow", "type A = {|a:1|}\n", "type A = {| a: 1 |};\n"],
+ ["flow", "type A = {|a:1|}\n", "type A = {| a: 1 |};\n"],
+ ["acorn", "const a = {b:1, c:'two'}\n", 'const a = { b: 1, c: "two" };\n'],
+ ["espree", "const a = {b:1, c:'two'}\n", 'const a = { b: 1, c: "two" };\n'],
+ ["meriyah", "const a = {b:1, c:'two'}\n", 'const a = { b: 1, c: "two" };\n'],
+ ["typescript", "const a:number=1\n", "const a: number = 1;\n"],
+ ["babel-ts", "const a:number=1\n", "const a: number = 1;\n"],
+ ["css", "a{color:red}\n", "a {\n color: red;\n}\n"],
+ ["scss", "$a:1;a{b{color:$a}}\n", "$a: 1;\na {\n b {\n color: $a;\n }\n}\n"],
+ ["less", "@a:1;a{.b();color:@a}\n", "@a: 1;\na {\n .b();\n color: @a;\n}\n"],
+ ["json", '{"a":1, "b":[1,2]}\n', '{ "a": 1, "b": [1, 2] }\n'],
+ ["json5", '{"a":1, "b":[1,2]}\n', "{ a: 1, b: [1, 2] }\n"],
+ ["jsonc", '{"a":1, "b":[1,2],}\n', '{ "a": 1, "b": [1, 2] }\n'],
+ ["json-stringify", "{'a':1, b:[1,2]}\n", '{\n "a": 1,\n "b": [\n 1,\n 2\n ]\n}\n'],
+ ["yaml", "a: 1\nb:\n - 1\n", "a: 1\nb:\n - 1\n"],
+ ["markdown", "# hi there\n", "# hi there\n"],
+ ["markdown", "```js\nconst a = {b:1}\n```\n", "```js\nconst a = { b: 1 };\n```\n"],
+ ["mdx", "# hi there\n\n\n", '# hi there\n\n\n'],
+ ["html", "\na\n", "\n
a
\n"],
+ [
+ "vue",
+ "\n",
+ '\n',
+ ],
+ [
+ "angular",
+ "{{a|b}}\n",
+ '{{ a | b }}\n',
+ ],
+ ["lwc", "x\n", "x\n"],
+ ["graphql", "query { a b }\n", "query {\n a\n b\n}\n"],
+ ["glimmer", "{{b}}\n", '{{b}}'],
+ ])("%s: %j", async (parser, stdin, expected) => {
+ for (const config of [{}, prettier]) {
+ const result = await format({ ...files, ...config }, ["--parser", parser], { stdin });
+ expect(result).toMatchObject({ raw: expected, stderr: "", exitCode: 0 });
+ }
+ });
+
+ // With no extension to go by, the TypeScript parsers try with JSX and without.
+ test.each([
+ ["typescript", "a = x\n", "a = x;\n"],
+ ["typescript", "a = x\n", 'a = x;\n'],
+ ["typescript", "const f = (x:T)=>x\n", "const f = (x: T) => x;\n"],
+ ["typescript", "const a = (b: T) => {b}\n", "const a = (b: T) => {b};\n"],
+ ["typescript", "//
x
\n", "p"], ])("a NUL in %s", async (_, text, name) => { expect(await bytesAfter({ "a.html": text })).toEqual({ - errors: [`[error] a.html: SyntaxError: Opening tag "${name}" not terminated. (1:1)`], + errors: [`error: a.html: SyntaxError: Opening tag "${name}" not terminated. (1:1)`], exitCode: 2, files: { "a.html": text }, }); @@ -4207,8 +5347,7 @@ describe.concurrent("line breaks, byte order marks and encodings", () => { }); const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); return { - // Not the lines of a code frame: `[error] > 2 | ..`, `[error] | ^`. - errors: stderr.split(/\r?\n/).filter(line => /^\[error\] [^ >|]/.test(line)), + errors: stderr.split(/\r?\n/).filter(line => line.startsWith("error: ")), exitCode, files: Object.fromEntries( Object.keys(files).map(name => [name, readFileSync(join(String(dir), name)).toString("latin1")]), @@ -4541,7 +5680,7 @@ describe.concurrent("upper and lower case in the name of a file", () => { stderr: "pipe", }); const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); - expect(stderr).not.toContain("[error]"); + expect(stderr).not.toMatch(anError); expect(readFileSync(join(String(dir), "a.ts"), "utf8")).toBe("let a: number = 1;\n"); // The file that takes its place has its name, not the one that was typed. expect(readdirSync(String(dir))).toEqual(["a.ts"]); @@ -4549,10 +5688,10 @@ describe.concurrent("upper and lower case in the name of a file", () => { }); }); -describe.concurrent("a format script in package.json", () => { +describe.concurrent("a fmt script in package.json", () => { test("wins over the formatter", async () => { const result = await format( - { "package.json": JSON.stringify({ scripts: { format: "echo the script" } }), "a.js": ugly }, + { "package.json": JSON.stringify({ scripts: { fmt: "echo the script" } }), "a.js": ugly }, [], { reads: ["a.js"] }, ); @@ -4560,9 +5699,19 @@ describe.concurrent("a format script in package.json", () => { expect(result.files).toEqual({ "a.js": ugly }); }); - test("in that script, bun format is the formatter", async () => { - const script = `"${bunExe().replaceAll("\\", "/")}" format a.js`; - const result = await format({ "package.json": JSON.stringify({ scripts: { format: script } }), "a.js": ugly }, [], { + // Most projects call theirs `format`. + test("a format script does not", async () => { + const scripts = { format: "echo the script", preformat: "echo before", postformat: "echo after" }; + const files = { "package.json": JSON.stringify({ scripts }, null, 2) + "\n", "a.js": ugly }; + const result = await format(files, [], { reads: ["a.js"] }); + expect(result.stdout).toBe("a.js"); + expect(result.files).toEqual({ "a.js": formatted }); + expect(result.exitCode).toBe(0); + }); + + test("in that script, bun fmt is the formatter", async () => { + const script = `"${bunExe().replaceAll("\\", "/")}" fmt a.js`; + const result = await format({ "package.json": JSON.stringify({ scripts: { fmt: script } }), "a.js": ugly }, [], { reads: ["a.js"], }); expect(result.files).toEqual({ "a.js": formatted }); @@ -4570,11 +5719,11 @@ describe.concurrent("a format script in package.json", () => { }); }); -// What `bun format` meant before there was a formatter. -describe.concurrent("what else is called format in the project", () => { +// What `bun fmt` meant before there was a formatter. +describe.concurrent("what else is called fmt in the project", () => { test.each([ - ["a file", { "format.ts": 'console.log("the file");' }, "the file"], - ["the index of a directory", { "format/index.js": 'console.log("the index");' }, "the index"], + ["a file", { "fmt.ts": 'console.log("the file");' }, "the file"], + ["the index of a directory", { "fmt/index.js": 'console.log("the index");' }, "the index"], ])("%s wins over the formatter, and nothing is rewritten", async (_, files, printed) => { const result = await format({ ...files, "a.js": ugly }, [], { reads: ["a.js"] }); expect(result.stdout).toBe(printed); @@ -4583,10 +5732,10 @@ describe.concurrent("what else is called format in the project", () => { }); test.skipIf(isWindows)("an executable of a package wins over the formatter, and nothing is rewritten", async () => { - const files = { "node_modules/.bin/format": "#!/bin/sh\necho the executable\n", "a.js": ugly }; + const files = { "node_modules/.bin/fmt": "#!/bin/sh\necho the executable\n", "a.js": ugly }; const result = await format(files, [], { reads: ["a.js"], - before: dir => chmodSync(join(dir, "node_modules/.bin/format"), 0o755), + before: dir => chmodSync(join(dir, "node_modules/.bin/fmt"), 0o755), }); expect(result.stdout).toBe("the executable"); expect(result.files).toEqual({ "a.js": ugly }); @@ -4594,14 +5743,14 @@ describe.concurrent("what else is called format in the project", () => { }); test("a directory with nothing to run in it does not", async () => { - const result = await format({ "format/notes.txt": "x", "a.js": ugly }, [], { reads: ["a.js"] }); + const result = await format({ "fmt/notes.txt": "x", "a.js": ugly }, [], { reads: ["a.js"] }); expect(result.files).toEqual({ "a.js": formatted }); expect(result.exitCode).toBe(0); }); // On macOS a directory that can be searched is executable for `which`. test("nor beside package.json", async () => { - const files = { "package.json": "{}\n", "format/notes.txt": "x", "a.js": ugly }; + const files = { "package.json": "{}\n", "fmt/notes.txt": "x", "a.js": ugly }; const result = await format(files, [], { reads: ["a.js"] }); expect(result.files).toEqual({ "a.js": formatted }); expect(result.exitCode).toBe(0); @@ -4609,8 +5758,8 @@ describe.concurrent("what else is called format in the project", () => { }); // What the formatter takes from the process that it runs in: the terminal, the arguments, the streams, the stack, a way to run a -// configuration file that is a program. Where `bun format` is developed something else is in Bun's place for each of these. -describe.concurrent("what bun format takes from Bun", () => { +// configuration file that is a program. Where `bun fmt` is developed something else is in Bun's place for each of these. +describe.concurrent("what bun fmt takes from Bun", () => { /** `bun ...args` in a directory with `files`. */ async function bun(files: RecordPrix\u00a0: 10\u00a0€
; +export const name = \`名前\u3000太郎\`; +export const pattern = /10\u00a0kg/; +export const total\u00a0= 1; +`, + }; + const { native, eslint } = await inBothModes(files, ["no-irregular-whitespace"]); + expect(native).toEqual(["a.jsx:5:19 error no-irregular-whitespace"]); + expect(eslint).toEqual([ + "a.jsx:1:14 error no-irregular-whitespace", + "a.jsx:1:23 error no-irregular-whitespace", + "a.jsx:2:29 error no-irregular-whitespace", + "a.jsx:2:34 error no-irregular-whitespace", + "a.jsx:3:24 error no-irregular-whitespace", + "a.jsx:4:27 error no-irregular-whitespace", + "a.jsx:5:19 error no-irregular-whitespace", + ]); + }); + + test("a short circuit or a ternary that calls something is a fine statement", async () => { + const code = `export function f(done, a, b) { + done && done(); + done ? a() : b(); + done && a; + done ? a : b; +} +`; + const rule = "no-unused-expressions"; + const bunfig = { "bunfig.toml": `[lint.rules]\n"${rule}" = "warn"\n` }; + const files = { "a.js": code, "b.ts": code }; + const [plain, turnedOn] = await Promise.all([lint(files, []), lint({ ...files, ...bunfig }, [])]); + const own = (reports: string[]) => reports.filter(it => it.endsWith(rule)); + // The core rule is off by default. It gets the same options when it's turned on. + expect(own(plain.reports)).toEqual([ + `b.ts:4:3 warning @typescript-eslint/${rule}`, + `b.ts:5:3 warning @typescript-eslint/${rule}`, + ]); + expect(own(turnedOn.reports)).toEqual([ + `a.js:4:3 warning ${rule}`, + `a.js:5:3 warning ${rule}`, + `b.ts:4:3 warning @typescript-eslint/${rule}`, + `b.ts:5:3 warning @typescript-eslint/${rule}`, + ]); + const { eslint } = await inBothModes({ "a.js": code }, [rule]); + expect(eslint).toEqual([2, 3, 4, 5].map(line => `a.js:${line}:3 error ${rule}`)); + }); + + test("an async function as a JSX event handler isn't a misused promise", async () => { + const files = { + "tsconfig.json": `{ "compilerOptions": { "strict": true, "jsx": "preserve", "types": [] } }`, + "a.tsx": `declare global { + namespace JSX { + interface IntrinsicElements { + form: { onSubmit?: () => void }; + } + } +} +declare function save(): Promisea
;\n`; + const preact = `/** @jsx h */\nimport { h, Fragment } from "preact";\nexport const A = () => (\n <>\na
\n >\n);\n`; + + test.each(["a.js", "a.jsx", "a.tsx"])("React isn't unused in %s", async name => { + const { reports } = await lint({ [name]: react }, []); + expect(reports).toEqual([]); + }); + + test.each(["a.jsx", "a.tsx"])("the function that a @jsx comment names isn't unused in %s", async name => { + const { reports } = await lint({ [name]: preact }, []); + expect(reports).toEqual([]); + }); + + test("React is unused in a file without JSX", async () => { + const code = `import React from "react";\nexport const a = 1;\n`; + const { reports } = await lint({ "a.js": code, "b.ts": code }, []); + expect(reports).toEqual(["a.js:1:8 warning no-unused-vars", "b.ts:1:8 warning @typescript-eslint/no-unused-vars"]); + }); + + test("with the automatic runtime, turn react/jsx-uses-react off to find leftover imports", async () => { + const bunfig = `[lint.rules]\n"react/jsx-uses-react" = "off"\n`; + const { reports } = await lint({ "a.jsx": react, "bunfig.toml": bunfig }, []); + expect(reports).toEqual(["a.jsx:1:8 warning no-unused-vars"]); + }); + + test("--quiet doesn't bring the report back", async () => { + const bunfig = `[lint.rules]\n"no-unused-vars" = "error"\n`; + const { reports } = await lint({ "a.jsx": react, "bunfig.toml": bunfig }, ["--quiet"]); + expect(reports).toEqual([]); + }); + + test("with an ESLint config, only the rule from eslint-plugin-react does that", async () => { + const config = `export default [{ + files: ["**/*.jsx"], + languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } }, + rules: { "no-unused-vars": "error" }, +}];\n`; + const { reports } = await lint({ "a.jsx": react, "eslint.config.mjs": config }, ["a.jsx"]); + expect(reports).toEqual(["a.jsx:1:8 error no-unused-vars"]); + }); +}); + +describe.concurrent("--fix-suggestions never deletes code that does something", () => { + test("an unused variable stays if its initializer can have side effects", async () => { + const code = `const sent = sendEmail(user); +const last = queue.pop(); +const saved = await db.save(user); +const plain = 1; +import unused from "x"; +export {}; +`; + for (const flag of ["--fix-suggestions", "--fix-dangerously"]) { + const { texts, reports } = await lint({ "a.mjs": code }, [flag]); + expect(texts["a.mjs"]).toBe(`const sent = sendEmail(user); +const last = queue.pop(); +const saved = await db.save(user); +import "x"; +export {}; +`); + expect(reports.filter(it => it.endsWith("no-unused-vars"))).toEqual([ + "a.mjs:1:7 warning no-unused-vars", + "a.mjs:2:7 warning no-unused-vars", + "a.mjs:3:7 warning no-unused-vars", + ]); + } + }); + + const removed = [ + "const a = 1;", + 'const b = "text";', + "const c = -1 + 2 * 3;", + "const d = [1, [2], { e: 3, f() {}, get g() {} }];", + "const h = () => call();", + "const i = async function* () {};", + "const j = class extends Base { static k = 1; l = call(); };", + "const m = `text`;", + 'const n = typeof value === "string" ? null : !value;', + "const o = /a/u;", + "let p;", + "function q() { call(); }", + "class r { s = call(); }", + ]; + const kept = [ + "const a = call();", + "const b = new Thing();", + "const c = object.property;", + "const d = object[0];", + "const e = object?.property;", + "const f = [...list];", + "const g = { ...object };", + "const h = { [key]: 1 };", + "const i = tag`text`;", + "const j = `${value}`;", + "const k = (count = 1);", + "const l = count++;", + "const m = delete object.property;", + 'const n = import("x");', + "const o = class { static { call(); } };", + "const p = class { static q = call(); };", + "const r = class extends mixin() {};", + "const s = class { [key]() {} };", + 'const t = "property" in object;', + "const u = value instanceof Thing;", + "const v = [1, { w: [call()] }];", + "const x = true && (false || call());", + "const { y } = object;", + "const [z] = list;", + "class A { static B = call(); }", + "class C extends mixin() {}", + ]; + + test("declarations that only define something are removed, with their lines", async () => { + const { texts } = await lint({ "a.mjs": [...removed, "export {};", ""].join("\n") }, ["--fix-suggestions"]); + expect(texts["a.mjs"]).toBe("export {};\n"); + }); + + test.each([ + [ + "a line that has something else on it stays", + "const a = 1; // why\nconst b = 2; export {};\n", + "// why\nexport {};\n", + ], + ["no space stays at the end of a line", "g(); const a = 1;\nexport {};\n", "g();\nexport {};\n"], + [ + "no doubled space stays", + 'import { a, used } from "./b.js";\nused();\n', + 'import { used } from "./b.js";\nused();\n', + ], + [ + "no empty first line in a block", + "export function f() {\n const a = 1;\n\n return 1;\n}\n", + "export function f() {\n return 1;\n}\n", + ], + [ + "no empty last line in a block", + "export function f() {\n g();\n\n const a = 1;\n}\n", + "export function f() {\n g();\n}\n", + ], + [ + "no two empty lines in a row", + 'import x from "./x.js";\n\nconst a = 1;\n\nexport const y = x;\n', + 'import x from "./x.js";\n\nexport const y = x;\n', + ], + ["no empty line at the start of the file", "const a = 1;\n\nexport {};\n", "export {};\n"], + ["no empty line at the end of the file", "export {};\n\nconst a = 1;\n", "export {};\n"], + [ + "an empty line between two statements stays", + "g();\nconst a = 1;\n\nh();\nexport {};\n", + "g();\n\nh();\nexport {};\n", + ], + [ + "the same with \\r\\n", + "export function f() {\r\n const a = 1;\r\n\r\n return 1;\r\n}\r\n", + "export function f() {\r\n return 1;\r\n}\r\n", + ], + ])("%s", async (_, code, expected) => { + const { texts } = await lint({ "a.mjs": code }, ["--fix-suggestions"]); + expect(texts["a.mjs"]).toBe(expected); + }); + + test("declarations that run other code stay", async () => { + const code = [...kept, "export {};", ""].join("\n"); + const { texts, reports } = await lint({ "a.mjs": code }, ["--fix-suggestions"]); + expect(texts["a.mjs"]).toBe(code); + expect(reports.filter(it => it.endsWith("no-unused-vars")).length).toBe(kept.length); + }); + + test("a parameter stays, because the number of parameters can matter", async () => { + const code = "app.use((error, request, response, next) => {\n response.end(String(error));\n});\nexport {};\n"; + const bunfig = { "bunfig.toml": `[lint.rules]\n"no-unused-vars" = ["warn", { args = "all" }]\n` }; + const { texts, reports } = await lint({ "a.mjs": code, ...bunfig }, ["--fix-suggestions"]); + expect(texts["a.mjs"]).toBe(code); + expect(reports.filter(it => it.endsWith("no-unused-vars"))).toEqual([ + "a.mjs:1:17 warning no-unused-vars", + "a.mjs:1:36 warning no-unused-vars", + ]); + }); + + test("the top level of a classic script stays, because the page can use it", async () => { + const code = "function onClick() {}\nconst settings = { a: 1 };\n(function () {\n const inner = 1;\n})();\n"; + const { texts } = await lint({ "public/app.js": code, "lib.cjs": `${code}module.exports = {};\n` }, [ + "--fix-suggestions", + ]); + expect(texts["public/app.js"]).toBe("function onClick() {}\nconst settings = { a: 1 };\n(function () {\n})();\n"); + expect(texts["lib.cjs"]).toBe("(function () {\n})();\nmodule.exports = {};\n"); + }); + + test("only --fix-dangerously turns any into unknown or rewrites a dependency array", async () => { + const files = { + "package.json": JSON.stringify({ dependencies: { react: "^19.0.0" } }), + "a.ts": "export const then = (value: any) => value.then;\n", + "b.jsx": `import { useEffect } from "react"; +export function Timer({ ms, cb }) { + useEffect(() => { + setTimeout(cb, ms); + }, [ms]); + return null; +} +`, + }; + const [careful, dangerous] = await Promise.all([ + lint(files, ["--fix-suggestions"]), + lint(files, ["--fix-dangerously"]), + ]); + expect(careful.texts).toEqual(files); + expect(careful.reports).toEqual([ + "a.ts:1:29 warning @typescript-eslint/no-explicit-any", + "b.jsx:5:6 warning react-hooks/exhaustive-deps", + ]); + expect(dangerous.texts["a.ts"]).toBe(files["a.ts"].replace("any", "unknown")); + expect(dangerous.texts["b.jsx"]).toBe(files["b.jsx"].replace("[ms]", "[cb, ms]")); + }); + + test("it leaves alone what would change working code or break the type check", async () => { + const files = { + "tsconfig.json": `{ "compilerOptions": { "strict": true, "lib": ["esnext", "dom"], "types": [] } }`, + "a.ts": `// @ts-ignore +export const a: number = 1; +export interface Empty {} +export type Nothing = {}; +export const value = (map: Map