diff --git a/.gitattributes b/.gitattributes index 845d96d83957..810f71b3ea73 100644 --- a/.gitattributes +++ b/.gitattributes @@ -27,6 +27,12 @@ test/cli/format/own/cases/** -text # Compared line by line with what a runner prints, which ends a line with \n on every system. test/cli/format/*/expected.txt text eol=lf test/cli/lint/conformance/expected*.txt text eol=lf +# Embedded in the executable, which splits them at \n. +src/lint/linter/config/presets.txt text eol=lf +src/lint/linter/deprecated.txt text eol=lf +src/lint/linter/schema/schemas.txt text eol=lf +src/lint/oxlint_help.txt text eol=lf +src/sema/messages.txt text eol=lf *.lockb binary diff=lockb diff --git a/.prettierignore b/.prettierignore index 39946967ea7c..3441ed0017c2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -16,6 +16,8 @@ scripts/build/xmac.mjs # the output codeblocks need to stay minified docs/bundler/minifier.mdx +# Prettier would reformat the code examples, and test/cli/lint/rule-docs.test.ts runs them exactly as written +docs/lint/rules # `declare module "*" with { type: "text" }` is TypeScript 7.1 syntax that # prettier's parser does not accept yet diff --git a/.prettierrc b/.prettierrc index 14285ca70431..b389721f9995 100644 --- a/.prettierrc +++ b/.prettierrc @@ -25,6 +25,14 @@ "options": { "printWidth": 100 } + }, + { + "files": ["src/runtime/cli/init/**"], + "options": { + "arrowParens": "always", + "printWidth": 80, + "quoteProps": "as-needed" + } } ] } diff --git a/Cargo.lock b/Cargo.lock index 3bef19fdeab0..bdd1e35c55e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1019,6 +1019,7 @@ dependencies = [ "bun_threading", "bun_wyhash", "bun_yarr", + "bun_zstd", "rustc-hash", "smallvec", ] @@ -1054,6 +1055,7 @@ dependencies = [ "bun_lint_react", "bun_lint_typescript", "bun_lint_unicorn", + "bun_md", "bun_paths", "bun_sema", "bun_sema_driver", @@ -1061,6 +1063,7 @@ dependencies = [ "bun_semver", "bun_sys", "bun_threading", + "bun_zstd", ] [[package]] @@ -1584,6 +1587,7 @@ dependencies = [ "bun_sema_standalone", "bun_threading", "bun_wyhash", + "bun_zstd", "hashbrown 0.15.5", "rustc-hash", "smallvec", @@ -1624,6 +1628,7 @@ dependencies = [ "bun_alloc", "bun_ast", "bun_core", + "bun_highway", "bun_sema", "bun_threading", "smallvec", diff --git a/Cargo.toml b/Cargo.toml index 6005caf5be39..178709a02541 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -489,3 +489,14 @@ bun_transpiler = { path = "src/transpiler" } [profile.release.package.bun_react_compiler] opt-level = "s" + +# The runners behind `--run-typescript-tests`, `--run-prettier-tests` and `--run-eslint-tests`. Only canary and debug +# builds have them, and the time goes into what they test, which keeps its own level. +[profile.release.package.bun_sema_baselines] +opt-level = "z" + +[profile.release.package.bun_format_conformance] +opt-level = "z" + +[profile.release.package.bun_lint_conformance] +opt-level = "z" diff --git a/LICENSE.md b/LICENSE.md index 3ca935b29ca2..9b498678c9ee 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -47,28 +47,33 @@ Bun statically links these libraries: | A fork of [`uWebsockets`](https://github.com/jarred-sumner/uwebsockets) | Apache 2.0 licensed | | Parts of [Tigerbeetle's IO code](https://github.com/tigerbeetle/tigerbeetle/blob/532c8b70b9142c17e07737ab6d3da68d7500cbca/src/io/windows.zig#L1) | Apache 2.0 licensed | | `__cxa_thread_atexit` fallback from [LLVM libc++abi](https://github.com/llvm/llvm-project/blob/llvmorg-19.1.0/libcxxabi/src/cxa_thread_atexit.cpp) | Apache 2.0 with LLVM exception | -| The type checker behind `bun check` is a port of [`typescript-go`](https://github.com/microsoft/typescript-go), and uses TypeScript's diagnostic messages and `lib.*.d.ts` files | Apache 2.0 | -| The linter behind `bun lint` is a port of [ESLint](https://github.com/eslint/eslint): its rules, its code path analysis, its configuration and its messages | MIT | -| The TypeScript rules of `bun lint`, their scope analysis and their helpers for types are a port of [`typescript-eslint`](https://github.com/typescript-eslint/typescript-eslint) | MIT | -| The scope analysis of `bun lint` follows [`eslint-scope`](https://github.com/eslint/js/tree/main/packages/eslint-scope) | BSD 2-Clause | -| The selectors of `bun lint` are a port of [`esquery`](https://github.com/estools/esquery) | BSD 3-Clause | -| `bun lint` has ports of [`@eslint-community/regexpp`](https://github.com/eslint-community/regexpp), [`@eslint-community/eslint-utils`](https://github.com/eslint-community/eslint-utils), [`ts-api-utils`](https://github.com/JoshuaKGoldberg/ts-api-utils), [`levn`](https://github.com/gkz/levn) and [`natural-compare`](https://github.com/litejs/natural-compare-lite), reports invalid options the way [`ajv`](https://github.com/ajv-validator/ajv) does, and uses the lists of [`globals`](https://github.com/sindresorhus/globals) | MIT | -| The modes of `src/glob` for the patterns of other tools follow [`minimatch`](https://github.com/isaacs/minimatch) | Blue Oak 1.0.0 | -| and are ports of [`brace-expansion`](https://github.com/juliangruber/brace-expansion), [`picomatch`](https://github.com/micromatch/picomatch), [`ignore`](https://github.com/kaelzhang/node-ignore), [`is-glob`](https://github.com/micromatch/is-glob) and [`is-extglob`](https://github.com/jonschlinkert/is-extglob), and of the way [`globset` and `ignore`](https://github.com/BurntSushi/ripgrep) read a line of an ignore file | MIT | -| and of [`glob-parent`](https://github.com/gulpjs/glob-parent) | ISC | -| The rules of plugins that are built into `bun lint` are ports from [`eslint-plugin-react-hooks`](https://github.com/facebook/react/tree/main/packages/eslint-plugin-react-hooks), [`eslint-plugin-import`](https://github.com/import-js/eslint-plugin-import), [`eslint-plugin-n`](https://github.com/eslint-community/eslint-plugin-n), [`eslint-plugin-es-x`](https://github.com/eslint-community/eslint-plugin-es-x), [`eslint-plugin-react`](https://github.com/jsx-eslint/eslint-plugin-react) with [`jsx-ast-utils`](https://github.com/jsx-eslint/jsx-ast-utils), [`eslint-plugin-prettier`](https://github.com/prettier/eslint-plugin-prettier) with [`prettier-linter-helpers`](https://github.com/prettier/prettier-linter-helpers), and [`oxlint`](https://github.com/oxc-project/oxc) | MIT | -| How the rule `prettier/prettier` cuts the difference between two texts into reports follows [`fast-diff`](https://github.com/jhchen/fast-diff) | Apache 2.0 | -| What the rules of `eslint-plugin-react` ask of ESLint's older API (`isSpaceBetweenTokens`, `getJSDocComment`) follows [`@eslint/compat`](https://github.com/eslint/rewrite/tree/main/packages/compat) | Apache 2.0 | -| The rules of `unicorn`, `oxc`, `react`, `react-perf`, `jsx-a11y`, `nextjs`, `import`, `promise`, `node`, `jest`, `vitest`, `vue` and `jsdoc` that `bun lint` has with a configuration of oxlint are ports of the rules of [`oxlint`](https://github.com/oxc-project/oxc), which are ports from [`eslint-plugin-unicorn`](https://github.com/sindresorhus/eslint-plugin-unicorn), [`eslint-plugin-react`](https://github.com/jsx-eslint/eslint-plugin-react), [`eslint-plugin-react-perf`](https://github.com/cvazac/eslint-plugin-react-perf), [`eslint-plugin-jsx-a11y`](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y), [`@next/eslint-plugin-next`](https://github.com/vercel/next.js/tree/canary/packages/eslint-plugin-next), [`eslint-plugin-import`](https://github.com/import-js/eslint-plugin-import), [`eslint-plugin-n`](https://github.com/eslint-community/eslint-plugin-n), [`eslint-plugin-jest`](https://github.com/jest-community/eslint-plugin-jest), [`@vitest/eslint-plugin`](https://github.com/vitest-dev/eslint-plugin-vitest) and [`eslint-plugin-vue`](https://github.com/vuejs/eslint-plugin-vue) | MIT | -| and from [`eslint-plugin-promise`](https://github.com/eslint-community/eslint-plugin-promise) | ISC | -| and from [`eslint-plugin-jsdoc`](https://github.com/gajus/eslint-plugin-jsdoc) | BSD 3-Clause | -| How `bun lint` and `bun format` take a JSDoc comment apart follows [`oxc_jsdoc`](https://github.com/oxc-project/oxc/tree/main/crates/oxc_jsdoc) | MIT | -| The formatter behind `bun format` implements [Prettier](https://github.com/prettier/prettier). Its rules for JavaScript and TypeScript are a port of [`oxc_formatter`](https://github.com/oxc-project/oxc/tree/main/crates/oxc_formatter) | MIT | -| The intermediate representation and the printer of `bun format` come from [Biome](https://github.com/biomejs/biome) | MIT or Apache 2.0 | -| The CSS, Less and SCSS parsers of `bun format` follow [`postcss`](https://github.com/postcss/postcss), [`postcss-less`](https://github.com/shellscape/postcss-less), [`postcss-scss`](https://github.com/postcss/postcss-scss), [`postcss-selector-parser`](https://github.com/postcss/postcss-selector-parser), [`postcss-media-query-parser`](https://github.com/dryoma/postcss-media-query-parser) and [`postcss-value-parser`](https://github.com/TrySound/postcss-value-parser) | MIT | -| The import sorting of `bun format` is a port of [`@trivago/prettier-plugin-sort-imports`](https://github.com/trivago/prettier-plugin-sort-imports) and [`@ianvs/prettier-plugin-sort-imports`](https://github.com/ianvs/prettier-plugin-sort-imports) | Apache 2.0 | -| `bun format` also has ports of [`prettier-plugin-organize-imports`](https://github.com/simonhaenisch/prettier-plugin-organize-imports), parts of [`@babel/generator`](https://github.com/babel/babel), [`sort-package-json`](https://crates.io/crates/sort-package-json), [`jest-docblock`](https://github.com/jestjs/jest/tree/main/packages/jest-docblock), and tables that follow [`emoji-regex`](https://github.com/mathiasbynens/emoji-regex) and [`get-east-asian-width`](https://github.com/sindresorhus/get-east-asian-width) | MIT | -| The Svelte printer of `bun format` is a port of [`prettier-plugin-svelte`](https://github.com/sveltejs/prettier-plugin-svelte) | MIT | +| The type checker behind `bun check` and the parser it shares with `bun lint` and `bun fmt` are ports of [`typescript-go`](https://github.com/microsoft/typescript-go). They use TypeScript's diagnostic messages and `lib.*.d.ts` files | Apache 2.0 | +| `bun lint` ports large parts of [ESLint](https://github.com/eslint/eslint): code path analysis, rule helpers such as `ast-utils.js`, config and comment handling, and many rules. The ESLint rules in `bun lint` use ESLint's rule names, options, option schemas, messages and descriptions. The `eslint:recommended` and `eslint:all` presets are ESLint's. Parts of `main.js` in `src/lint/js_plugin/worker` are copied from ESLint, and parts of `LintReport.cpp`, `LintSourceCode.cpp` and `LintTokens.cpp` in `src/jsc/bindings` are ported from it | MIT | +| `bun lint` does the same with the rules of [`typescript-eslint`](https://github.com/typescript-eslint/typescript-eslint). It also ports `type-utils` and the plugin's `util` helpers, and uses its presets, the error messages of `typescript-estree` and the `lib` globals of `scope-manager` | MIT | +| Selectors in `bun lint` are a port of [`esquery`](https://github.com/estools/esquery) | BSD 3-Clause | +| `bun lint` has ports of [`@eslint-community/regexpp`](https://github.com/eslint-community/regexpp), [`@eslint-community/eslint-utils`](https://github.com/eslint-community/eslint-utils), [`levn`](https://github.com/gkz/levn) and [`natural-compare`](https://github.com/litejs/natural-compare-lite), and of parts of [`ts-api-utils`](https://github.com/JoshuaKGoldberg/ts-api-utils) and [`@eslint/eslintrc`](https://github.com/eslint/eslintrc) | MIT | +| `bun lint` uses the error messages of [`ajv`](https://github.com/ajv-validator/ajv), [`acorn`](https://github.com/acornjs/acorn) and [`optionator`](https://github.com/gkz/optionator), and the lists in [`globals`](https://github.com/sindresorhus/globals) and [`is-core-module`](https://github.com/inspect-js/is-core-module) | MIT | +| `bun lint` uses the error messages of [`espree`](https://github.com/eslint/js/tree/main/packages/espree) | BSD 2-Clause | +| `bun lint` uses the `JSON.parse` error messages of [V8](https://github.com/v8/v8) | BSD 3-Clause | +| `bun lint` has ports of parts of [`@eslint/plugin-kit`, `@eslint/config-array`, `@eslint/config-helpers` and `@eslint/compat`](https://github.com/eslint/rewrite) | Apache 2.0 | +| `bun lint` and `bun fmt` have a port of the pattern-to-regular-expression table of [`ignore`](https://github.com/kaelzhang/node-ignore) | MIT | +| `bun lint` and `bun fmt` have ports of [`is-glob`](https://github.com/micromatch/is-glob) and [`is-extglob`](https://github.com/jonschlinkert/is-extglob) | MIT | +| `bun lint` and `bun fmt` have a port of [`glob-parent`](https://github.com/gulpjs/glob-parent) | ISC | +| The `react-hooks`, `import`, `n`, `react` and `prettier` rules in `bun lint` are ports of the rules in [`eslint-plugin-react-hooks`](https://github.com/facebook/react/tree/main/packages/eslint-plugin-react-hooks), [`eslint-plugin-import`](https://github.com/import-js/eslint-plugin-import), [`eslint-plugin-n`](https://github.com/eslint-community/eslint-plugin-n), [`eslint-plugin-es-x`](https://github.com/eslint-community/eslint-plugin-es-x), [`eslint-plugin-react`](https://github.com/jsx-eslint/eslint-plugin-react) and [`eslint-plugin-prettier`](https://github.com/prettier/eslint-plugin-prettier), with the same rule names, options and messages. They include the Node.js version tables of `eslint-plugin-n` and a few functions from [`jsx-ast-utils`](https://github.com/jsx-eslint/jsx-ast-utils) and [`prettier-linter-helpers`](https://github.com/prettier/prettier-linter-helpers) | MIT | +| The text diff used by `bun test` and the `prettier/prettier` rule has parts of [`diff-match-patch`](https://github.com/google/diff-match-patch) and of [`fast-diff`](https://github.com/jhchen/fast-diff), which is derived from it | Apache 2.0 | +| The `unicorn`, `oxc`, `react-perf`, `jsx-a11y`, `nextjs`, `promise`, `node`, `jest` and `vitest` rules in `bun lint` are ports of the rules in [`oxlint`](https://github.com/oxc-project/oxc), with its messages and help text. With an oxlint config, the other rules use the messages of oxlint and [`tsgolint`](https://github.com/oxc-project/tsgolint) too. `bun lint` also ports parts of `oxc_jsdoc` and `oxc_react_compiler` | MIT | +| oxlint ported those rules from [`eslint-plugin-unicorn`](https://github.com/sindresorhus/eslint-plugin-unicorn), [`eslint-plugin-react-perf`](https://github.com/cvazac/eslint-plugin-react-perf), [`eslint-plugin-jsx-a11y`](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y), [`@next/eslint-plugin-next`](https://github.com/vercel/next.js/tree/canary/packages/eslint-plugin-next), [`eslint-plugin-n`](https://github.com/eslint-community/eslint-plugin-n), [`eslint-plugin-jest`](https://github.com/jest-community/eslint-plugin-jest) and [`@vitest/eslint-plugin`](https://github.com/vitest-dev/eslint-plugin-vitest). Their rule names, options and some of their messages are in `bun lint` | MIT | +| oxlint ported its `promise` rules from [`eslint-plugin-promise`](https://github.com/eslint-community/eslint-plugin-promise) | ISC | +| The ARIA tables of the `jsx-a11y` rules come from [`aria-query`](https://github.com/A11yance/aria-query) by way of oxlint | Apache 2.0 | +| `bun fmt` is a port of [Prettier](https://github.com/prettier/prettier): its printers for CSS, SCSS, Less, HTML, Vue, Angular, Markdown, YAML, GraphQL and Handlebars, its document printer, its handling of options, pragmas, ranges, cursor offsets and embedded code, and the HTML tables it is built with | MIT | +| The JavaScript and TypeScript printer of `bun fmt`, its JSDoc formatting and its oxfmt-style import sorting are a port of [`oxc_formatter`](https://github.com/oxc-project/oxc/tree/main/crates/oxc_formatter), which is a port of the [Biome](https://github.com/biomejs/biome) formatter. The intermediate representation and its printer come from Biome by way of `oxc_formatter_core` | MIT | +| `bun fmt` has ports of the parsers in [`postcss`](https://github.com/postcss/postcss), [`postcss-scss`](https://npmjs.com/package/postcss-scss), [`postcss-less`](https://npmjs.com/package/postcss-less), [`postcss-selector-parser`](https://github.com/postcss/postcss-selector-parser), [`postcss-media-query-parser`](https://github.com/dryoma/postcss-media-query-parser), [`postcss-values-parser`](https://npmjs.com/package/postcss-values-parser), [`angular-html-parser`](https://npmjs.com/package/angular-html-parser), [`graphql-js`](https://github.com/graphql/graphql-js), [`@glimmer/syntax`](https://github.com/glimmerjs/glimmer-vm), [`simple-html-tokenizer`](https://github.com/tildeio/simple-html-tokenizer) and [`@angular/compiler`](https://github.com/angular/angular), and of parts of [`yaml-unist-parser`](https://npmjs.com/package/yaml-unist-parser) and [`angular-estree-parser`](https://npmjs.com/package/angular-estree-parser) | MIT | +| `bun fmt` also has a port of [`@handlebars/parser`](https://github.com/handlebars-lang/handlebars-parser) and of one file of [`yaml`](https://npmjs.com/package/yaml) | ISC | +| Import sorting in `bun fmt` has ports of [`@trivago/prettier-plugin-sort-imports`](https://github.com/trivago/prettier-plugin-sort-imports) and [`@ianvs/prettier-plugin-sort-imports`](https://github.com/ianvs/prettier-plugin-sort-imports), and of `organizeImports.ts` in [TypeScript](https://github.com/microsoft/TypeScript) | Apache 2.0 | +| `bun fmt` has ports of [`jest-docblock`](https://github.com/jestjs/jest/tree/main/packages/jest-docblock) and the crates [`sort-package-json`](https://crates.io/crates/sort-package-json) and [`oxc-toml`](https://crates.io/crates/oxc-toml) (a fork of taplo), and of parts of [`@babel/generator`](https://github.com/babel/babel), [`micromark`](https://github.com/micromark/micromark) and [`editorconfig-without-wasm`](https://github.com/fisker/editorconfig-core-js-without-wasm). Its character width tables are generated from [`emoji-regex`](https://github.com/mathiasbynens/emoji-regex), [`get-east-asian-width`](https://github.com/sindresorhus/get-east-asian-width) and [`narrow-emojis`](https://npmjs.com/package/narrow-emojis) | MIT | +| Tailwind class sorting in `bun fmt` is a port of parts of [`prettier-plugin-tailwindcss`](https://github.com/tailwindlabs/prettier-plugin-tailwindcss) | MIT | + +The tests in `test/cli/lint/conformance` and `test/cli/format` include test cases from ESLint, typescript-eslint, the ESLint plugins above, oxc, tsgolint, Prettier and the two import sorting plugins. They are not part of the `bun` binary. Their licenses are next to them. ## Polyfills diff --git a/clippy.toml b/clippy.toml index fd5329f16597..bf4d170052de 100644 --- a/clippy.toml +++ b/clippy.toml @@ -18,7 +18,7 @@ disallowed-methods = [ { path = "std::os::unix::fs::symlink", reason = "use bun_sys::file" }, { path = "std::env::var", reason = "use bun_core::env_var" }, { path = "std::env::var_os", reason = "use bun_core::env_var" }, - { path = "std::thread::spawn", reason = "use bun_threading::spawn_named or ThreadPool" }, + { path = "std::thread::spawn", reason = "use std::thread::Builder with a stack size, and name the thread (Output::Source::configure_named_thread), or use bun_threading::ThreadPool" }, { path = "std::mem::zeroed", reason = "use MaybeUninit; zeroed is UB for most Bun types" }, { path = "bun_core::output::pretty_fmt_rt", reason = "const format strings must use pretty_fmt!/write_pretty! (compile-time rewrite); the runtime walk is only for templates built at runtime — allow with justification" }, { path = "bun_core::output::pretty_fmt_args", reason = "const format strings must use pretty_fmt!/write_pretty! (compile-time rewrite); the runtime walk is only for templates built at runtime — allow with justification" }, @@ -78,6 +78,15 @@ disallowed-methods = [ { path = "bstr::ByteSlice::split_once_str", reason = "use bun_core::strings::split_once (highway memmem)" }, { path = "bstr::ByteSlice::rsplit_once_str", reason = "use bun_core::strings::rsplit_once (highway memrmem)" }, { path = "bstr::ByteSlice::replace", reason = "use bun_core::strings::replace_owned (highway memmem)" }, + # `u8x16` and the other aliases hide `Simd` and `Mask` from disallowed-types, so their constructors are listed too. + { path = "core::simd::Simd::splat", reason = "use bun_highway / bun_core::strings (highway): one SIMD system, with runtime dispatch" }, + { path = "core::simd::Simd::from_array", reason = "use bun_highway / bun_core::strings (highway): one SIMD system, with runtime dispatch" }, + { path = "core::simd::Simd::from_slice", reason = "use bun_highway / bun_core::strings (highway): one SIMD system, with runtime dispatch" }, + { path = "core::simd::Simd::load_or_default", reason = "use bun_highway / bun_core::strings (highway): one SIMD system, with runtime dispatch" }, + { path = "core::simd::Simd::load_or", reason = "use bun_highway / bun_core::strings (highway): one SIMD system, with runtime dispatch" }, + { path = "core::simd::Mask::splat", reason = "use bun_highway / bun_core::strings (highway): one SIMD system, with runtime dispatch" }, + { path = "core::simd::Mask::from_array", reason = "use bun_highway / bun_core::strings (highway): one SIMD system, with runtime dispatch" }, + { path = "core::simd::Mask::from_bitmask", reason = "use bun_highway / bun_core::strings (highway): one SIMD system, with runtime dispatch" }, ] disallowed-types = [ @@ -89,6 +98,8 @@ disallowed-types = [ { path = "std::collections::HashMap", reason = "use bun_collections (wyhash)" }, { path = "std::collections::HashSet", reason = "use bun_collections (wyhash)" }, { path = "std::fs::File", reason = "use bun_sys::File" }, + { path = "core::simd::Simd", reason = "use bun_highway / bun_core::strings (highway): one SIMD system, with runtime dispatch" }, + { path = "core::simd::Mask", reason = "use bun_highway / bun_core::strings (highway): one SIMD system, with runtime dispatch" }, { path = "std::process::Command", reason = "use bun_core::util::spawn_sync_inherit (CLI helpers) or bun_spawn_sys (full control)" }, { path = "std::backtrace::Backtrace", reason = "release builds compile std without its `backtrace` feature (scripts/build/rust.ts), so this captures frames it cannot symbolize; use bun_core::dump_current_stack_trace (bun_crash_handler)" }, ] diff --git a/completions/bun.bash b/completions/bun.bash index 77b601ca1d91..c277a08b78b8 100644 --- a/completions/bun.bash +++ b/completions/bun.bash @@ -89,7 +89,7 @@ _bun_completions() { declare -A PACKAGE_OPTIONS; declare -A PM_OPTIONS; - local SUBCOMMANDS="dev bun create run install add remove upgrade completions discord help init pm x test repl update audit dedupe prune outdated link unlink build check lint format"; + local SUBCOMMANDS="dev bun create run install add remove upgrade completions discord help init pm x test repl update audit dedupe prune outdated link unlink build check lint fmt"; GLOBAL_OPTIONS[LONG_OPTIONS]="--use --cwd --bunfile --server-bunfile --config --disable-react-fast-refresh --disable-hmr --env-file --extension-order --jsx-factory --jsx-fragment --extension-order --jsx-factory --jsx-fragment --jsx-import-source --jsx-production --jsx-runtime --main-fields --no-summary --version --platform --public-dir --tsconfig-override --define --external --help --inject --loader --origin --port"; GLOBAL_OPTIONS[SHORT_OPTIONS]="-c -v -d -e -h -i -l -u -p"; @@ -195,10 +195,10 @@ _bun_completions() { COMPREPLY=( $(compgen -f -W "--project --pretty --no-pretty --all --threads --timing --cwd --help -p -h" -- "${cur_word}") ); return;; lint) - COMPREPLY=( $(compgen -f -W "--config --no-config-lookup --flavor --rule --global --parser-options --ext --fix --fix-dry-run --fix-type --ignore-pattern --no-ignore --no-warn-ignored --stdin --stdin-filename --quiet --max-warnings --format --all --output-file --color --no-color --no-inline-config --report-unused-disable-directives --report-unused-disable-directives-severity --report-unused-inline-configs --suppress-all --suppress-rule --suppressions-location --prune-suppressions --pass-on-unpruned-suppressions --no-error-on-unmatched-pattern --pass-on-no-patterns --exit-on-fatal-error --allow-unsupported --native-plugin-rules --no-native-plugin-rules --print-config --type-aware --no-type-aware --infer-globals --no-infer-globals --project --type-check --allow --warn --deny --deny-warnings --silent --ignore-path --disable-nested-config --fix-suggestions --fix-dangerously --init --rules --threads --timing --cwd --help -c -f -o -p -A -W -D -h" -- "${cur_word}") ); + COMPREPLY=( $(compgen -f -W "--config --no-config-lookup --rule --rules --explain --init --global --no-inline-config --report-unused-disable-directives --print-config --doctor --allow-unsupported --staged --changed --ignore-pattern --ignore-path --no-ignore --ext --stdin --stdin-filename --list-files --no-error-on-unmatched-pattern --cwd --format --all --output-file --quiet --max-warnings --color --no-color --timing --fix --fix-dry-run --fix-type --fix-suggestions --fix-dangerously --type-aware --no-type-aware --tsconfig --type-check --infer-globals --no-infer-globals --threads --version --help --help-all --flavor --project --parser-options --parser --plugin --native-plugin-rules --no-native-plugin-rules --report-unused-disable-directives-severity --report-unused-inline-configs --no-warn-ignored --suppress-all --suppress-rule --suppressions-location --prune-suppressions --pass-on-unpruned-suppressions --pass-on-no-patterns --exit-on-fatal-error --concurrency --env-info --no-eslintrc --env --rulesdir --resolve-plugins-relative-to --allow --warn --deny --deny-warnings --silent --disable-nested-config --import-plugin --react-plugin --jsdoc-plugin --jest-plugin --vitest-plugin --jsx-a11y-plugin --nextjs-plugin --react-perf-plugin --promise-plugin --node-plugin --vue-plugin --disable-unicorn-plugin --disable-oxc-plugin --disable-typescript-plugin -c -f -o -v -h -p -A -W -D" -- "${cur_word}") ); return;; - format) - COMPREPLY=( $(compgen -f -W "--check --list-different --stdin-filepath --config --no-config --disable-nested-config --no-editorconfig --config-precedence --ignore-path --ignore-pattern --with-node-modules --no-error-on-unmatched-pattern --ignore-unknown --find-config-path --log-level --print-width --tab-width --use-tabs --no-semi --single-quote --jsx-single-quote --quote-props --trailing-comma --no-bracket-spacing --bracket-same-line --arrow-parens --object-wrap --single-attribute-per-line --html-whitespace-sensitivity --vue-indent-script-and-style --prose-wrap --embedded-language-formatting --end-of-line --require-pragma --check-ignore-pragma --insert-pragma --range-start --range-end --threads --allow-unsupported --flavor --init --timing --cwd --help -c -l -u -h" -- "${cur_word}") ); + fmt) + COMPREPLY=( $(compgen -f -W "--config --no-config --no-editorconfig --init --doctor --print-width --tab-width --use-tabs --no-semi --single-quote --jsx-single-quote --quote-props --trailing-comma --no-bracket-spacing --bracket-same-line --arrow-parens --object-wrap --single-attribute-per-line --html-whitespace-sensitivity --vue-indent-script-and-style --prose-wrap --embedded-language-formatting --end-of-line --no-insert-final-newline --sort-imports --sort-tailwindcss --sort-package-json --jsdoc --staged --changed --stdin-filepath --ignore-path --ignore-pattern --with-node-modules --no-error-on-unmatched-pattern --ignore-unknown --allow-unsupported --list-files --cwd --check --list-different --log-level --format --color --no-color --timing --threads --debug-check --version --help --all --help-all --write --config-precedence --find-config-path --parser --plugin --require-pragma --check-ignore-pragma --insert-pragma --range-start --range-end --cursor-offset --experimental-ternaries --experimental-operator-position --jsx-bracket-same-line --flavor --disable-nested-config -u -c -l -f -v -h -w" -- "${cur_word}") ); return;; create|c) COMPREPLY=( $(compgen -W "--force --no-install --help --no-git --verbose --no-package-json --open next react" -- "${cur_word}") ); diff --git a/completions/bun.fish b/completions/bun.fish index 556e3670d8c7..bb24adb94428 100644 --- a/completions/bun.fish +++ b/completions/bun.fish @@ -35,7 +35,7 @@ end set -l bun_install_boolean_flags yarn production optional development no-save dry-run force no-cache silent verbose global set -l bun_install_boolean_flags_descriptions "Write a yarn.lock file (yarn v1)" "Don't install devDependencies" "Add dependency to optionalDependencies" "Add dependency to devDependencies" "Don't update package.json or save a lockfile" "Don't install anything" "Always request the latest versions from the registry & reinstall all dependencies" "Ignore manifest cache entirely" "Don't output anything" "Excessively verbose logging" "Use global folder" -set -l bun_builtin_cmds_without_run dev create help bun upgrade discord install remove add update audit dedupe prune init pm x repl check lint format +set -l bun_builtin_cmds_without_run dev create help bun upgrade discord install remove add update audit dedupe prune init pm x repl check lint fmt set -l bun_builtin_cmds_accepting_flags create help bun upgrade discord run init link unlink pm x update function __bun_complete_bins_scripts --inherit-variable bun_builtin_cmds_without_run -d "Emit bun completions for bins and scripts" @@ -220,109 +220,160 @@ complete -c bun -n "__fish_seen_subcommand_from check" -l "threads" -r -d "Numbe complete -c bun -n "__fish_seen_subcommand_from check" -l "timing" -d "Print load and check times" complete -c bun -n "__fish_seen_subcommand_from check" -l "cwd" -r -d "Set the working directory" complete -c bun -n "__fish_use_subcommand" -a "lint" -d "Lint JavaScript and TypeScript" -f -complete -c bun -n "__fish_seen_subcommand_from lint" -s "c" -l "config" -r -F -d "Use this configuration file instead of looking for one" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-config-lookup" -d "Do not look for a configuration file" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "flavor" -r -d "Whose configuration files count where there are both: eslint or oxlint" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "rule" -r -d "Configure a rule: --rule 'eqeqeq: [error, smart]'" +complete -c bun -n "__fish_seen_subcommand_from lint" -s "c" -l "config" -r -F -d "Use this config file instead of looking for one" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-config-lookup" -d "Do not look for a config file" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "rule" -r -d "Set a rule: --rule 'eqeqeq: [error, smart]'" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "rules" -d "List the built-in rules" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "explain" -r -d "Print the documentation of a rule" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "init" -d "Add a [lint] section to bunfig.toml" complete -c bun -n "__fish_seen_subcommand_from lint" -l "global" -r -d "Define global variables: --global a,b:true" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "parser-options" -r -d "Set parser options: --parser-options projectService:true" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "ext" -r -d "Lint files with these extensions too" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "fix" -d "Fix what can be fixed, and write the files" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "fix-dry-run" -d "Fix without writing. The json format has the fixed code" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "fix-type" -r -d "Only apply fixes of rules of these types: directive, problem, suggestion, layout" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "ignore-pattern" -r -d "Ignore the files that match" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-inline-config" -d "Ignore eslint-disable and other config comments" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "report-unused-disable-directives" -d "Report eslint-disable comments that disable nothing" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "print-config" -r -F -d "Print the config for a file and exit" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "doctor" -d "Show which rules run natively and which packages you can remove" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "allow-unsupported" -d "Warn instead of failing when the config has a rule or file that is not supported" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "staged" -d "Only lint files that are staged in git" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "changed" -d "Only lint files that differ from a branch or commit (default: the default branch)" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "ignore-pattern" -r -d "Skip files that match this pattern" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "ignore-path" -r -F -d "Read ignore patterns from this file instead of .eslintignore" complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-ignore" -d "Lint ignored files too" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-warn-ignored" -d "Do not warn about an ignored file that is named as an argument" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "stdin" -d "Lint the code on standard input" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "stdin-filename" -r -F -d "The file name that the code on standard input is linted as" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "quiet" -d "Report errors only" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "max-warnings" -r -d "Exit with 1 if there are more warnings than this" -complete -c bun -n "__fish_seen_subcommand_from lint" -s "f" -l "format" -r -d "stylish (default), pretty, json, json-with-metadata, unix, github, agent, checkstyle, junit, gitlab, sarif" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "ext" -r -d "Also lint files with these extensions" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "stdin" -d "Lint code from standard input" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "stdin-filename" -r -F -d "The file name to use for code from standard input" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "list-files" -d "Print the files that would be linted and exit" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-error-on-unmatched-pattern" -d "Do not fail when an argument matches no file" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "cwd" -r -d "Set the working directory" +complete -c bun -n "__fish_seen_subcommand_from lint" -s "f" -l "format" -r -d "pretty (default), stylish, json, json-with-metadata, unix, github, agent, checkstyle, junit, gitlab, sarif" complete -c bun -n "__fish_seen_subcommand_from lint" -l "all" -d "Show every problem (pretty and agent group identical problems above 50)" complete -c bun -n "__fish_seen_subcommand_from lint" -s "o" -l "output-file" -r -F -d "Write the report to a file" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "quiet" -d "Report errors only" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "max-warnings" -r -d "Exit with 1 if there are more warnings than this" complete -c bun -n "__fish_seen_subcommand_from lint" -l "color" -d "Always use colors" complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-color" -d "Never use colors" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-inline-config" -d "Ignore eslint-disable and other configuration comments" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "report-unused-disable-directives" -d "Report eslint-disable comments that disable nothing, as errors" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "report-unused-disable-directives-severity" -r -d "The same, as off, warn or error" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "report-unused-inline-configs" -r -d "Report configuration comments that change nothing" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "suppress-all" -d "Tolerate the errors that there are now: write them to eslint-suppressions.json" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "suppress-rule" -r -d "The same for the errors of a rule" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "suppressions-location" -r -F -d "Another file than eslint-suppressions.json" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "prune-suppressions" -d "Remove from that file what no longer occurs" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "pass-on-unpruned-suppressions" -d "Do not fail if that file has what no longer occurs" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-error-on-unmatched-pattern" -d "Do not fail if an argument matches no file" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "pass-on-no-patterns" -d "Exit with 0 if there are no arguments, instead of linting ." -complete -c bun -n "__fish_seen_subcommand_from lint" -l "exit-on-fatal-error" -d "Exit with 2 if a file cannot be parsed" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "allow-unsupported" -d "Only warn about rules and files of the configuration that cannot be linted yet" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "native-plugin-rules" -r -d "The plugins whose built-in rules run in place of the installed package: import,react-hooks (default: all)" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-native-plugin-rules" -d "Run the rules of the installed plugins, in JavaScript, not the built-in ones" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "print-config" -r -F -d "Print the configuration of a file, and lint nothing" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "type-aware" -d "Run the rules that need types, whatever the configuration says" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "timing" -d "Print how long each phase took" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "fix" -d "Fix problems and write the files" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "fix-dry-run" -d "Fix without writing. The json format has the fixed code" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "fix-type" -r -d "Only apply fixes from rules of these types: directive, problem, suggestion, layout" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "fix-suggestions" -d "Apply suggestions" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "fix-dangerously" -d "Apply every fix and suggestion, even those that can change behavior" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "type-aware" -d "Also run the rules that need types" complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-type-aware" -d "Skip the rules that need types" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "infer-globals" -d "The globals of a file are what the types of its project declare, too" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-infer-globals" -d "Without a configuration file: those of browsers and of Node.js" -complete -c bun -n "__fish_seen_subcommand_from lint" -s "p" -l "project" -r -F -d "The tsconfig.json for the rules that need types" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "type-check" -d "With --type-aware: report type errors too" -complete -c bun -n "__fish_seen_subcommand_from lint" -s "A" -l "allow" -r -d "Turn a rule off, or with an .oxlintrc.json a category" -complete -c bun -n "__fish_seen_subcommand_from lint" -s "W" -l "warn" -r -d "The same: make it a warning" -complete -c bun -n "__fish_seen_subcommand_from lint" -s "D" -l "deny" -r -d "The same: make it an error" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "tsconfig" -r -F -d "Use this tsconfig.json for the rules that need types" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "type-check" -d "With --type-aware, report type errors too" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "infer-globals" -d "Also treat what the project's types declare as globals" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-infer-globals" -d "Do not take globals from the project's types" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "threads" -r -d "Number of threads (default: one per CPU core)" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "help-all" -d "The same as --help --all" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "flavor" -r -d "Use the eslint or the oxlint config in a project that has both" +complete -c bun -n "__fish_seen_subcommand_from lint" -s "p" -l "project" -r -F -d "The same as --tsconfig" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "parser-options" -r -d "Set parser options: --parser-options projectService:true" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "parser" -r -d "Parse files with this package" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "plugin" -r -d "Load these plugins" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "native-plugin-rules" -r -d "Use the built-in rules for these plugins only: import,react-hooks (default: all)" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-native-plugin-rules" -d "Run plugin rules from the installed packages, in JavaScript" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "report-unused-disable-directives-severity" -r -d "Report unused eslint-disable comments as off, warn or error" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "report-unused-inline-configs" -r -d "Report config comments that change nothing" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-warn-ignored" -d "Do not warn when an argument names an ignored file" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "suppress-all" -d "Record the current errors in eslint-suppressions.json and stop reporting them" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "suppress-rule" -r -d "The same, for the errors of one rule" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "suppressions-location" -r -F -d "Use this file instead of eslint-suppressions.json" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "prune-suppressions" -d "Remove recorded errors that no longer occur from that file" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "pass-on-unpruned-suppressions" -d "Do not fail when that file has errors that no longer occur" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "pass-on-no-patterns" -d "Exit with 0 when there are no arguments, instead of linting ." +complete -c bun -n "__fish_seen_subcommand_from lint" -l "exit-on-fatal-error" -d "Exit with 2 if a file cannot be parsed" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "concurrency" -r -d "The same as --threads" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "env-info" -d "Print the versions of Bun, ESLint and TypeScript" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "no-eslintrc" -d "ESLint 8: do not look for .eslintrc files" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "env" -r -d "ESLint 8: turn on these environments" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "rulesdir" -r -F -d "ESLint 8: load rules from these directories" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "resolve-plugins-relative-to" -r -F -d "ESLint 8: look for plugins from this directory" +complete -c bun -n "__fish_seen_subcommand_from lint" -s "A" -l "allow" -r -d "Turn off a rule or a category" +complete -c bun -n "__fish_seen_subcommand_from lint" -s "W" -l "warn" -r -d "Make a rule or a category a warning" +complete -c bun -n "__fish_seen_subcommand_from lint" -s "D" -l "deny" -r -d "Make a rule or a category an error" complete -c bun -n "__fish_seen_subcommand_from lint" -l "deny-warnings" -d "Exit with 1 if there are warnings" complete -c bun -n "__fish_seen_subcommand_from lint" -l "silent" -d "Print no problems" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "ignore-path" -r -F -d "A file with patterns to ignore, in place of .eslintignore" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "disable-nested-config" -d "Use the configuration file of the working directory for every file" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "fix-suggestions" -d "Apply suggestions, as oxlint does" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "fix-dangerously" -d "Apply every fix and suggestion, as oxlint does" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "init" -d "Write an .oxlintrc.json with the defaults of oxlint" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "rules" -d "List the rules that are built in" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "threads" -r -d "Number of threads (default: one per CPU core)" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "timing" -d "Print how long each phase took" -complete -c bun -n "__fish_seen_subcommand_from lint" -l "cwd" -r -d "Set the working directory" -complete -c bun -n "__fish_use_subcommand" -a "format" -d "Format code the way Prettier does" -f -complete -c bun -n "__fish_seen_subcommand_from format" -s "c" -l "check" -d "Do not write. Exit with 1 if a file is not formatted" -complete -c bun -n "__fish_seen_subcommand_from format" -s "l" -l "list-different" -d "Do not write. Print the files that are not formatted, and exit with 1 if there are any" -complete -c bun -n "__fish_seen_subcommand_from format" -l "stdin-filepath" -r -F -d "Format standard input as that file, and print the result" -complete -c bun -n "__fish_seen_subcommand_from format" -l "config" -r -F -d "Use this configuration file instead of looking for one" -complete -c bun -n "__fish_seen_subcommand_from format" -l "no-config" -d "Do not look for a configuration file" -complete -c bun -n "__fish_seen_subcommand_from format" -l "disable-nested-config" -d "Use the configuration file of the working directory for every file" -complete -c bun -n "__fish_seen_subcommand_from format" -l "no-editorconfig" -d "Do not read .editorconfig" -complete -c bun -n "__fish_seen_subcommand_from format" -l "config-precedence" -r -d "cli-override (default), file-override, or prefer-file" -complete -c bun -n "__fish_seen_subcommand_from format" -l "ignore-path" -r -F -d "Files with patterns to ignore (default: .gitignore and .prettierignore)" -complete -c bun -n "__fish_seen_subcommand_from format" -l "ignore-pattern" -r -d "Ignore the files that match, as a line of .prettierignore does" -complete -c bun -n "__fish_seen_subcommand_from format" -l "with-node-modules" -d "Format files in node_modules too" -complete -c bun -n "__fish_seen_subcommand_from format" -l "no-error-on-unmatched-pattern" -d "Do not fail if an argument matches no file" -complete -c bun -n "__fish_seen_subcommand_from format" -s "u" -l "ignore-unknown" -d "Say nothing about a file that there is no parser for" -complete -c bun -n "__fish_seen_subcommand_from format" -l "find-config-path" -r -F -d "Print the configuration file of a file, and format nothing" -complete -c bun -n "__fish_seen_subcommand_from format" -l "log-level" -r -d "silent, error, warn, log (default), or debug" -complete -c bun -n "__fish_seen_subcommand_from format" -l "print-width" -r -d "The line length to wrap at (default: 80)" -complete -c bun -n "__fish_seen_subcommand_from format" -l "tab-width" -r -d "Spaces per indentation level (default: 2)" -complete -c bun -n "__fish_seen_subcommand_from format" -l "use-tabs" -d "Indent with tabs" -complete -c bun -n "__fish_seen_subcommand_from format" -l "no-semi" -d "Only print semicolons where they are needed" -complete -c bun -n "__fish_seen_subcommand_from format" -l "single-quote" -d "Use single quotes" -complete -c bun -n "__fish_seen_subcommand_from format" -l "jsx-single-quote" -d "Use single quotes in JSX" -complete -c bun -n "__fish_seen_subcommand_from format" -l "quote-props" -r -d "as-needed (default), consistent, or preserve" -complete -c bun -n "__fish_seen_subcommand_from format" -l "trailing-comma" -r -d "all (default), es5, or none" -complete -c bun -n "__fish_seen_subcommand_from format" -l "no-bracket-spacing" -d "No spaces between the braces of an object literal" -complete -c bun -n "__fish_seen_subcommand_from format" -l "bracket-same-line" -d "Put the closing bracket of a multi-line element at the end of the last line" -complete -c bun -n "__fish_seen_subcommand_from format" -l "arrow-parens" -r -d "always (default) or avoid" -complete -c bun -n "__fish_seen_subcommand_from format" -l "object-wrap" -r -d "preserve (default) or collapse" -complete -c bun -n "__fish_seen_subcommand_from format" -l "single-attribute-per-line" -d "One attribute per line in HTML, Vue, and JSX" -complete -c bun -n "__fish_seen_subcommand_from format" -l "html-whitespace-sensitivity" -r -d "css (default), strict, or ignore" -complete -c bun -n "__fish_seen_subcommand_from format" -l "vue-indent-script-and-style" -d "Indent what is in the script and style tags of Vue files" -complete -c bun -n "__fish_seen_subcommand_from format" -l "prose-wrap" -r -d "preserve (default), always, or never" -complete -c bun -n "__fish_seen_subcommand_from format" -l "embedded-language-formatting" -r -d "auto (default) or off" -complete -c bun -n "__fish_seen_subcommand_from format" -l "end-of-line" -r -d "lf (default), crlf, cr, or auto" -complete -c bun -n "__fish_seen_subcommand_from format" -l "require-pragma" -d "Only format files that start with a comment that has @format or @prettier" -complete -c bun -n "__fish_seen_subcommand_from format" -l "check-ignore-pragma" -d "Do not format files that start with a comment that has @noformat or @noprettier" -complete -c bun -n "__fish_seen_subcommand_from format" -l "insert-pragma" -d "Put a comment with @format at the top of the files that are formatted" -complete -c bun -n "__fish_seen_subcommand_from format" -l "range-start" -r -d "Only format the statements from this offset on" -complete -c bun -n "__fish_seen_subcommand_from format" -l "range-end" -r -d "Only format the statements up to this offset" -complete -c bun -n "__fish_seen_subcommand_from format" -l "threads" -r -d "Number of threads (default: one per CPU core)" -complete -c bun -n "__fish_seen_subcommand_from format" -l "allow-unsupported" -d "Only warn about files in a language that is not supported" -complete -c bun -n "__fish_seen_subcommand_from format" -l "flavor" -r -d "Without a configuration file, format like prettier or like oxfmt" -complete -c bun -n "__fish_seen_subcommand_from format" -l "init" -d "Write an .oxfmtrc.json, with which files are formatted like oxfmt does" -complete -c bun -n "__fish_seen_subcommand_from format" -l "timing" -d "Print how long each phase took" -complete -c bun -n "__fish_seen_subcommand_from format" -l "cwd" -r -d "Set the working directory" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "disable-nested-config" -d "Use the config file in the working directory for every file" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "import-plugin" -d "Turn on the import plugin" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "react-plugin" -d "Turn on the react plugin" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "jsdoc-plugin" -d "Turn on the jsdoc plugin" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "jest-plugin" -d "Turn on the jest plugin" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "vitest-plugin" -d "Turn on the vitest plugin" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "jsx-a11y-plugin" -d "Turn on the jsx-a11y plugin" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "nextjs-plugin" -d "Turn on the nextjs plugin" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "react-perf-plugin" -d "Turn on the react-perf plugin" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "promise-plugin" -d "Turn on the promise plugin" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "node-plugin" -d "Turn on the node plugin" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "vue-plugin" -d "Turn on the vue plugin" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "disable-unicorn-plugin" -d "Turn off the unicorn plugin" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "disable-oxc-plugin" -d "Turn off the oxc plugin" +complete -c bun -n "__fish_seen_subcommand_from lint" -l "disable-typescript-plugin" -d "Turn off the typescript plugin" +complete -c bun -n "__fish_use_subcommand" -a "fmt" -d "Format code the way Prettier does" -f +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "config" -r -F -d "Use this config file instead of looking for one" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "no-config" -d "Do not look for a config file" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "no-editorconfig" -d "Do not read .editorconfig" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "init" -d "Add a [fmt] section to bunfig.toml" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "doctor" -d "Show which files are formatted natively and which packages you can remove" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "print-width" -r -d "The line length to wrap at (default: 80)" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "tab-width" -r -d "Spaces per indentation level (default: 2)" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "use-tabs" -d "Indent with tabs" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "no-semi" -d "Only print semicolons where they are needed" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "single-quote" -d "Use single quotes" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "jsx-single-quote" -d "Use single quotes in JSX" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "quote-props" -r -d "as-needed (default), consistent, or preserve" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "trailing-comma" -r -d "all (default), es5, or none" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "no-bracket-spacing" -d "No spaces inside the braces of an object literal" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "bracket-same-line" -d "Put the closing bracket of a multi-line element at the end of the last line" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "arrow-parens" -r -d "always (default) or avoid" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "object-wrap" -r -d "preserve (default) or collapse" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "single-attribute-per-line" -d "One attribute per line in HTML, Vue, and JSX" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "html-whitespace-sensitivity" -r -d "css (default), strict, or ignore" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "vue-indent-script-and-style" -d "Indent the code in the script and style tags of Vue files" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "prose-wrap" -r -d "preserve (default), always, or never" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "embedded-language-formatting" -r -d "auto (default) or off" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "end-of-line" -r -d "lf (default), crlf, cr, or auto" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "no-insert-final-newline" -d "Do not end files with a newline" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "sort-imports" -d "Sort and group imports" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "sort-tailwindcss" -d "Sort Tailwind CSS classes" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "sort-package-json" -d "Sort the keys of package.json" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "jsdoc" -d "Format JSDoc comments" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "staged" -d "Only format files that are staged in git, and stage them again" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "changed" -d "Only format files that differ from a branch or commit (default: the default branch)" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "stdin-filepath" -r -F -d "Format standard input as this file and print the result" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "ignore-path" -r -F -d "Read ignore patterns from these files (default: .gitignore and .prettierignore)" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "ignore-pattern" -r -d "Skip files that match this pattern" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "with-node-modules" -d "Format files in node_modules too" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "no-error-on-unmatched-pattern" -d "Do not fail when an argument matches no file" +complete -c bun -n "__fish_seen_subcommand_from fmt" -s "u" -l "ignore-unknown" -d "Ignore files in an unknown language" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "allow-unsupported" -d "Warn instead of failing when the language of a file is not supported" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "list-files" -d "Print the files that would be formatted and exit" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "cwd" -r -d "Set the working directory" +complete -c bun -n "__fish_seen_subcommand_from fmt" -s "c" -l "check" -d "Do not write. Exit with 1 if a file needs formatting" +complete -c bun -n "__fish_seen_subcommand_from fmt" -s "l" -l "list-different" -d "Do not write. Print the files that need formatting and exit with 1 if there are any" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "log-level" -r -d "silent, error, warn, log (default), or debug" +complete -c bun -n "__fish_seen_subcommand_from fmt" -s "f" -l "format" -r -d "With --doctor, print the report as json" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "color" -d "Always use colors" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "no-color" -d "Never use colors" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "timing" -d "Print how long each phase took" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "threads" -r -d "Number of threads (default: one per CPU core)" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "debug-check" -d "Do not write. Fail if formatting a file would change what it means" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "all" -d "With --help, print every flag" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "help-all" -d "The same as --help --all" +complete -c bun -n "__fish_seen_subcommand_from fmt" -s "w" -l "write" -d "Write the files. This is the default" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "config-precedence" -r -d "cli-override (default), file-override, or prefer-file" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "find-config-path" -r -F -d "Print the path of the config for a file and exit" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "parser" -r -d "Read every file with this parser" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "plugin" -r -d "Load these plugins" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "require-pragma" -d "Only format files that start with a @format or @prettier comment" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "check-ignore-pragma" -d "Skip files that start with a @noformat or @noprettier comment" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "insert-pragma" -d "Put a @format comment at the top of formatted files" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "range-start" -r -d "Only format the statements from this offset on" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "range-end" -r -d "Only format the statements up to this offset" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "cursor-offset" -r -d "With --stdin-filepath, print on stderr where this offset ends up" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "experimental-ternaries" -d "Put the question mark of a ternary after the condition" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "experimental-operator-position" -r -d "end (default) or start" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "jsx-bracket-same-line" -d "The old name of --bracket-same-line" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "flavor" -r -d "Without a config file, format like prettier or like oxfmt" +complete -c bun -n "__fish_seen_subcommand_from fmt" -l "disable-nested-config" -d "Use the config file in the working directory for every file" complete -c bun -n "__fish_use_subcommand" -a "dedupe" -d "Remove duplicate versions from the lockfile" -f complete -c bun -n "__fish_use_subcommand" -a "prune" -d "Remove packages that are not in the lockfile from node_modules" -f complete -c bun -n "__fish_seen_subcommand_from audit; and not __fish_seen_subcommand_from fix" -a "fix" -d "Upgrade vulnerable packages to the lowest safe version" -f diff --git a/completions/bun.zsh b/completions/bun.zsh index 2a8add6c259b..dc19b0f5c49f 100644 --- a/completions/bun.zsh +++ b/completions/bun.zsh @@ -768,98 +768,116 @@ _bun_lint_completion() { _arguments -s -C \ '1: :->cmd1' \ '*: :_files' \ - '--config[Use this configuration file instead of looking for one]:config:_files' \ - '-c[Use this configuration file instead of looking for one]:config:_files' \ - '--no-config-lookup[Do not look for a configuration file]' \ - '--flavor[Whose configuration files count where there are both\: eslint or oxlint]:flavor' \ - '--rule[Configure a rule\: --rule '"'"'eqeqeq\: \[error, smart\]'"'"']:rule' \ + '--config[Use this config file instead of looking for one]:config:_files' \ + '-c[Use this config file instead of looking for one]:config:_files' \ + '--no-config-lookup[Do not look for a config file]' \ + '--rule[Set a rule\: --rule '"'"'eqeqeq\: \[error, smart\]'"'"']:rule' \ + '--rules[List the built-in rules]' \ + '--explain[Print the documentation of a rule]:explain' \ + '--init[Add a \[lint\] section to bunfig.toml]' \ '--global[Define global variables\: --global a,b\:true]:global' \ - '--parser-options[Set parser options\: --parser-options projectService\:true]:parser-options' \ - '--ext[Lint files with these extensions too]:ext' \ - '--fix[Fix what can be fixed, and write the files]' \ - '--fix-dry-run[Fix without writing. The json format has the fixed code]' \ - '--fix-type[Only apply fixes of rules of these types\: directive, problem, suggestion, layout]:fix-type' \ - '--ignore-pattern[Ignore the files that match]:ignore-pattern' \ + '--no-inline-config[Ignore eslint-disable and other config comments]' \ + '--report-unused-disable-directives[Report eslint-disable comments that disable nothing]' \ + '--print-config[Print the config for a file and exit]:print-config:_files' \ + '--doctor[Show which rules run natively and which packages you can remove]' \ + '--allow-unsupported[Warn instead of failing when the config has a rule or file that is not supported]' \ + '--staged[Only lint files that are staged in git]' \ + '--changed[Only lint files that differ from a branch or commit (default\: the default branch)]' \ + '--ignore-pattern[Skip files that match this pattern]:ignore-pattern' \ + '--ignore-path[Read ignore patterns from this file instead of .eslintignore]:ignore-path:_files' \ '--no-ignore[Lint ignored files too]' \ - '--no-warn-ignored[Do not warn about an ignored file that is named as an argument]' \ - '--stdin[Lint the code on standard input]' \ - '--stdin-filename[The file name that the code on standard input is linted as]:stdin-filename:_files' \ - '--quiet[Report errors only]' \ - '--max-warnings[Exit with 1 if there are more warnings than this]:max-warnings' \ - '--format[stylish (default), pretty, json, json-with-metadata, unix, github, agent, checkstyle, junit, gitlab, sarif]:format' \ - '-f[stylish (default), pretty, json, json-with-metadata, unix, github, agent, checkstyle, junit, gitlab, sarif]:format' \ + '--ext[Also lint files with these extensions]:ext' \ + '--stdin[Lint code from standard input]' \ + '--stdin-filename[The file name to use for code from standard input]:stdin-filename:_files' \ + '--list-files[Print the files that would be linted and exit]' \ + '--no-error-on-unmatched-pattern[Do not fail when an argument matches no file]' \ + '--cwd[Set the working directory]:cwd:_files -/' \ + '--format[pretty (default), stylish, json, json-with-metadata, unix, github, agent, checkstyle, junit, gitlab, sarif]:format' \ + '-f[pretty (default), stylish, json, json-with-metadata, unix, github, agent, checkstyle, junit, gitlab, sarif]:format' \ '--all[Show every problem (pretty and agent group identical problems above 50)]' \ '--output-file[Write the report to a file]:output-file:_files' \ '-o[Write the report to a file]:output-file:_files' \ + '--quiet[Report errors only]' \ + '--max-warnings[Exit with 1 if there are more warnings than this]:max-warnings' \ '--color[Always use colors]' \ '--no-color[Never use colors]' \ - '--no-inline-config[Ignore eslint-disable and other configuration comments]' \ - '--report-unused-disable-directives[Report eslint-disable comments that disable nothing, as errors]' \ - '--report-unused-disable-directives-severity[The same, as off, warn or error]:report-unused-disable-directives-severity' \ - '--report-unused-inline-configs[Report configuration comments that change nothing]:report-unused-inline-configs' \ - '--suppress-all[Tolerate the errors that there are now\: write them to eslint-suppressions.json]' \ - '--suppress-rule[The same for the errors of a rule]:suppress-rule' \ - '--suppressions-location[Another file than eslint-suppressions.json]:suppressions-location:_files' \ - '--prune-suppressions[Remove from that file what no longer occurs]' \ - '--pass-on-unpruned-suppressions[Do not fail if that file has what no longer occurs]' \ - '--no-error-on-unmatched-pattern[Do not fail if an argument matches no file]' \ - '--pass-on-no-patterns[Exit with 0 if there are no arguments, instead of linting .]' \ - '--exit-on-fatal-error[Exit with 2 if a file cannot be parsed]' \ - '--allow-unsupported[Only warn about rules and files of the configuration that cannot be linted yet]' \ - '--native-plugin-rules[The plugins whose built-in rules run in place of the installed package\: import,react-hooks (default\: all)]:native-plugin-rules' \ - '--no-native-plugin-rules[Run the rules of the installed plugins, in JavaScript, not the built-in ones]' \ - '--print-config[Print the configuration of a file, and lint nothing]:print-config:_files' \ - '--type-aware[Run the rules that need types, whatever the configuration says]' \ + '--timing[Print how long each phase took]' \ + '--fix[Fix problems and write the files]' \ + '--fix-dry-run[Fix without writing. The json format has the fixed code]' \ + '--fix-type[Only apply fixes from rules of these types\: directive, problem, suggestion, layout]:fix-type' \ + '--fix-suggestions[Apply suggestions]' \ + '--fix-dangerously[Apply every fix and suggestion, even those that can change behavior]' \ + '--type-aware[Also run the rules that need types]' \ '--no-type-aware[Skip the rules that need types]' \ - '--infer-globals[The globals of a file are what the types of its project declare, too]' \ - '--no-infer-globals[Without a configuration file\: those of browsers and of Node.js]' \ - '--project[The tsconfig.json for the rules that need types]:project:_files' \ - '-p[The tsconfig.json for the rules that need types]:project:_files' \ - '--type-check[With --type-aware\: report type errors too]' \ - '--allow[Turn a rule off, or with an .oxlintrc.json a category]:allow' \ - '-A[Turn a rule off, or with an .oxlintrc.json a category]:allow' \ - '--warn[The same\: make it a warning]:warn' \ - '-W[The same\: make it a warning]:warn' \ - '--deny[The same\: make it an error]:deny' \ - '-D[The same\: make it an error]:deny' \ + '--tsconfig[Use this tsconfig.json for the rules that need types]:tsconfig:_files' \ + '--type-check[With --type-aware, report type errors too]' \ + '--infer-globals[Also treat what the project'"'"'s types declare as globals]' \ + '--no-infer-globals[Do not take globals from the project'"'"'s types]' \ + '--threads[Number of threads (default\: one per CPU core)]:threads' \ + '--version[Print the version of Bun]' \ + '-v[Print the version of Bun]' \ + '--help[Print the common flags. With --all, every flag]' \ + '-h[Print the common flags. With --all, every flag]' \ + '--help-all[The same as --help --all]' \ + '--flavor[Use the eslint or the oxlint config in a project that has both]:flavor' \ + '--project[The same as --tsconfig]:project:_files' \ + '-p[The same as --tsconfig]:project:_files' \ + '--parser-options[Set parser options\: --parser-options projectService\:true]:parser-options' \ + '--parser[Parse files with this package]:parser' \ + '--plugin[Load these plugins]:plugin' \ + '--native-plugin-rules[Use the built-in rules for these plugins only\: import,react-hooks (default\: all)]:native-plugin-rules' \ + '--no-native-plugin-rules[Run plugin rules from the installed packages, in JavaScript]' \ + '--report-unused-disable-directives-severity[Report unused eslint-disable comments as off, warn or error]:report-unused-disable-directives-severity' \ + '--report-unused-inline-configs[Report config comments that change nothing]:report-unused-inline-configs' \ + '--no-warn-ignored[Do not warn when an argument names an ignored file]' \ + '--suppress-all[Record the current errors in eslint-suppressions.json and stop reporting them]' \ + '--suppress-rule[The same, for the errors of one rule]:suppress-rule' \ + '--suppressions-location[Use this file instead of eslint-suppressions.json]:suppressions-location:_files' \ + '--prune-suppressions[Remove recorded errors that no longer occur from that file]' \ + '--pass-on-unpruned-suppressions[Do not fail when that file has errors that no longer occur]' \ + '--pass-on-no-patterns[Exit with 0 when there are no arguments, instead of linting .]' \ + '--exit-on-fatal-error[Exit with 2 if a file cannot be parsed]' \ + '--concurrency[The same as --threads]:concurrency' \ + '--env-info[Print the versions of Bun, ESLint and TypeScript]' \ + '--no-eslintrc[ESLint 8\: do not look for .eslintrc files]' \ + '--env[ESLint 8\: turn on these environments]:env' \ + '--rulesdir[ESLint 8\: load rules from these directories]:rulesdir:_files' \ + '--resolve-plugins-relative-to[ESLint 8\: look for plugins from this directory]:resolve-plugins-relative-to:_files' \ + '--allow[Turn off a rule or a category]:allow' \ + '-A[Turn off a rule or a category]:allow' \ + '--warn[Make a rule or a category a warning]:warn' \ + '-W[Make a rule or a category a warning]:warn' \ + '--deny[Make a rule or a category an error]:deny' \ + '-D[Make a rule or a category an error]:deny' \ '--deny-warnings[Exit with 1 if there are warnings]' \ '--silent[Print no problems]' \ - '--ignore-path[A file with patterns to ignore, in place of .eslintignore]:ignore-path:_files' \ - '--disable-nested-config[Use the configuration file of the working directory for every file]' \ - '--fix-suggestions[Apply suggestions, as oxlint does]' \ - '--fix-dangerously[Apply every fix and suggestion, as oxlint does]' \ - '--init[Write an .oxlintrc.json with the defaults of oxlint]' \ - '--rules[List the rules that are built in]' \ - '--threads[Number of threads (default\: one per CPU core)]:threads' \ - '--timing[Print how long each phase took]' \ - '--cwd[Set the working directory]:cwd:_files -/' \ - '--help[Print this help menu]' \ - '-h[Print this help menu]' && + '--disable-nested-config[Use the config file in the working directory for every file]' \ + '--import-plugin[Turn on the import plugin]' \ + '--react-plugin[Turn on the react plugin]' \ + '--jsdoc-plugin[Turn on the jsdoc plugin]' \ + '--jest-plugin[Turn on the jest plugin]' \ + '--vitest-plugin[Turn on the vitest plugin]' \ + '--jsx-a11y-plugin[Turn on the jsx-a11y plugin]' \ + '--nextjs-plugin[Turn on the nextjs plugin]' \ + '--react-perf-plugin[Turn on the react-perf plugin]' \ + '--promise-plugin[Turn on the promise plugin]' \ + '--node-plugin[Turn on the node plugin]' \ + '--vue-plugin[Turn on the vue plugin]' \ + '--disable-unicorn-plugin[Turn off the unicorn plugin]' \ + '--disable-oxc-plugin[Turn off the oxc plugin]' \ + '--disable-typescript-plugin[Turn off the typescript plugin]' && ret=0 } -_bun_format_completion() { +_bun_fmt_completion() { _arguments -s -C \ '1: :->cmd1' \ '*: :_files' \ - '--check[Do not write. Exit with 1 if a file is not formatted]' \ - '-c[Do not write. Exit with 1 if a file is not formatted]' \ - '--list-different[Do not write. Print the files that are not formatted, and exit with 1 if there are any]' \ - '-l[Do not write. Print the files that are not formatted, and exit with 1 if there are any]' \ - '--stdin-filepath[Format standard input as that file, and print the result]:stdin-filepath:_files' \ - '--config[Use this configuration file instead of looking for one]:config:_files' \ - '--no-config[Do not look for a configuration file]' \ - '--disable-nested-config[Use the configuration file of the working directory for every file]' \ + '--config[Use this config file instead of looking for one]:config:_files' \ + '--no-config[Do not look for a config file]' \ '--no-editorconfig[Do not read .editorconfig]' \ - '--config-precedence[cli-override (default), file-override, or prefer-file]:config-precedence' \ - '--ignore-path[Files with patterns to ignore (default\: .gitignore and .prettierignore)]:ignore-path:_files' \ - '--ignore-pattern[Ignore the files that match, as a line of .prettierignore does]:ignore-pattern' \ - '--with-node-modules[Format files in node_modules too]' \ - '--no-error-on-unmatched-pattern[Do not fail if an argument matches no file]' \ - '--ignore-unknown[Say nothing about a file that there is no parser for]' \ - '-u[Say nothing about a file that there is no parser for]' \ - '--find-config-path[Print the configuration file of a file, and format nothing]:find-config-path:_files' \ - '--log-level[silent, error, warn, log (default), or debug]:log-level' \ + '--init[Add a \[fmt\] section to bunfig.toml]' \ + '--doctor[Show which files are formatted natively and which packages you can remove]' \ '--print-width[The line length to wrap at (default\: 80)]:print-width' \ '--tab-width[Spaces per indentation level (default\: 2)]:tab-width' \ '--use-tabs[Indent with tabs]' \ @@ -868,29 +886,68 @@ _bun_format_completion() { '--jsx-single-quote[Use single quotes in JSX]' \ '--quote-props[as-needed (default), consistent, or preserve]:quote-props' \ '--trailing-comma[all (default), es5, or none]:trailing-comma' \ - '--no-bracket-spacing[No spaces between the braces of an object literal]' \ + '--no-bracket-spacing[No spaces inside the braces of an object literal]' \ '--bracket-same-line[Put the closing bracket of a multi-line element at the end of the last line]' \ '--arrow-parens[always (default) or avoid]:arrow-parens' \ '--object-wrap[preserve (default) or collapse]:object-wrap' \ '--single-attribute-per-line[One attribute per line in HTML, Vue, and JSX]' \ '--html-whitespace-sensitivity[css (default), strict, or ignore]:html-whitespace-sensitivity' \ - '--vue-indent-script-and-style[Indent what is in the script and style tags of Vue files]' \ + '--vue-indent-script-and-style[Indent the code in the script and style tags of Vue files]' \ '--prose-wrap[preserve (default), always, or never]:prose-wrap' \ '--embedded-language-formatting[auto (default) or off]:embedded-language-formatting' \ '--end-of-line[lf (default), crlf, cr, or auto]:end-of-line' \ - '--require-pragma[Only format files that start with a comment that has @format or @prettier]' \ - '--check-ignore-pragma[Do not format files that start with a comment that has @noformat or @noprettier]' \ - '--insert-pragma[Put a comment with @format at the top of the files that are formatted]' \ + '--no-insert-final-newline[Do not end files with a newline]' \ + '--sort-imports[Sort and group imports]' \ + '--sort-tailwindcss[Sort Tailwind CSS classes]' \ + '--sort-package-json[Sort the keys of package.json]' \ + '--jsdoc[Format JSDoc comments]' \ + '--staged[Only format files that are staged in git, and stage them again]' \ + '--changed[Only format files that differ from a branch or commit (default\: the default branch)]' \ + '--stdin-filepath[Format standard input as this file and print the result]:stdin-filepath:_files' \ + '--ignore-path[Read ignore patterns from these files (default\: .gitignore and .prettierignore)]:ignore-path:_files' \ + '--ignore-pattern[Skip files that match this pattern]:ignore-pattern' \ + '--with-node-modules[Format files in node_modules too]' \ + '--no-error-on-unmatched-pattern[Do not fail when an argument matches no file]' \ + '--ignore-unknown[Ignore files in an unknown language]' \ + '-u[Ignore files in an unknown language]' \ + '--allow-unsupported[Warn instead of failing when the language of a file is not supported]' \ + '--list-files[Print the files that would be formatted and exit]' \ + '--cwd[Set the working directory]:cwd:_files -/' \ + '--check[Do not write. Exit with 1 if a file needs formatting]' \ + '-c[Do not write. Exit with 1 if a file needs formatting]' \ + '--list-different[Do not write. Print the files that need formatting and exit with 1 if there are any]' \ + '-l[Do not write. Print the files that need formatting and exit with 1 if there are any]' \ + '--log-level[silent, error, warn, log (default), or debug]:log-level' \ + '--format[With --doctor, print the report as json]:format' \ + '-f[With --doctor, print the report as json]:format' \ + '--color[Always use colors]' \ + '--no-color[Never use colors]' \ + '--timing[Print how long each phase took]' \ + '--threads[Number of threads (default\: one per CPU core)]:threads' \ + '--debug-check[Do not write. Fail if formatting a file would change what it means]' \ + '--version[Print the version of Bun]' \ + '-v[Print the version of Bun]' \ + '--help[Print the common flags. With --all, every flag]' \ + '-h[Print the common flags. With --all, every flag]' \ + '--all[With --help, print every flag]' \ + '--help-all[The same as --help --all]' \ + '--write[Write the files. This is the default]' \ + '-w[Write the files. This is the default]' \ + '--config-precedence[cli-override (default), file-override, or prefer-file]:config-precedence' \ + '--find-config-path[Print the path of the config for a file and exit]:find-config-path:_files' \ + '--parser[Read every file with this parser]:parser' \ + '--plugin[Load these plugins]:plugin' \ + '--require-pragma[Only format files that start with a @format or @prettier comment]' \ + '--check-ignore-pragma[Skip files that start with a @noformat or @noprettier comment]' \ + '--insert-pragma[Put a @format comment at the top of formatted files]' \ '--range-start[Only format the statements from this offset on]:range-start' \ '--range-end[Only format the statements up to this offset]:range-end' \ - '--threads[Number of threads (default\: one per CPU core)]:threads' \ - '--allow-unsupported[Only warn about files in a language that is not supported]' \ - '--flavor[Without a configuration file, format like prettier or like oxfmt]:flavor' \ - '--init[Write an .oxfmtrc.json, with which files are formatted like oxfmt does]' \ - '--timing[Print how long each phase took]' \ - '--cwd[Set the working directory]:cwd:_files -/' \ - '--help[Print this help menu]' \ - '-h[Print this help menu]' && + '--cursor-offset[With --stdin-filepath, print on stderr where this offset ends up]:cursor-offset' \ + '--experimental-ternaries[Put the question mark of a ternary after the condition]' \ + '--experimental-operator-position[end (default) or start]:experimental-operator-position' \ + '--jsx-bracket-same-line[The old name of --bracket-same-line]' \ + '--flavor[Without a config file, format like prettier or like oxfmt]:flavor' \ + '--disable-nested-config[Use the config file in the working directory for every file]' && ret=0 } @@ -1030,7 +1087,7 @@ _bun() { 'audit\:"Check installed packages for vulnerabilities" ' 'check\:"Type check a TypeScript project" ' 'lint\:"Lint JavaScript and TypeScript" ' - 'format\:"Format code the way Prettier does" ' + 'fmt\:"Format code the way Prettier does" ' 'dedupe\:"Remove duplicate versions from the lockfile" ' 'prune\:"Remove packages that are not in the lockfile from node_modules" ' 'outdated\:"Display the latest versions of outdated dependencies" ' @@ -1126,8 +1183,8 @@ _bun() { _bun_lint_completion ;; - format) - _bun_format_completion + fmt) + _bun_fmt_completion ;; dedupe) @@ -1237,8 +1294,8 @@ _bun() { _bun_lint_completion ;; - format) - _bun_format_completion + fmt) + _bun_fmt_completion ;; dedupe) diff --git a/docs/docs.json b/docs/docs.json index dccf9df3d8d9..2a1311b53aba 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -72,14 +72,17 @@ "/runtime/index", "/runtime/watch-mode", "/runtime/check", - "/runtime/lint", - "/runtime/format", "/runtime/debugger", "/runtime/repl", "/runtime/bunfig", "/runtime/code-generation-from-strings" ] }, + { + "group": "Linter & Formatter", + "icon": "sparkles", + "pages": ["/runtime/lint", "/lint/rules/index", "/lint/plugins", "/runtime/format"] + }, { "group": "File & Module System", "icon": "file", @@ -311,7 +314,6 @@ } ] }, - { "tab": "Guides", "icon": "map", @@ -502,6 +504,16 @@ "/guides/test/svelte-test" ] }, + { + "group": "Linter & Formatter", + "icon": "sparkles", + "pages": [ + "/guides/lint/migrate-from-eslint", + "/guides/lint/migrate-from-oxlint", + "/guides/format/migrate-from-prettier", + "/guides/format/migrate-from-oxfmt" + ] + }, { "group": "Runtime & Debugging", "icon": "bug", diff --git a/docs/guides/format/migrate-from-oxfmt.mdx b/docs/guides/format/migrate-from-oxfmt.mdx new file mode 100644 index 000000000000..18dfcd5949d4 --- /dev/null +++ b/docs/guides/format/migrate-from-oxfmt.mdx @@ -0,0 +1,118 @@ +--- +title: Migrate from oxfmt to bun fmt +sidebarTitle: Migrate from oxfmt +mode: center +--- + +`bun fmt` reads your `.oxfmtrc.json` and prints what oxfmt 0.72 prints. In most projects you only change the command. + +Start with `--doctor`. It shows what Bun will do with your config, and it doesn't format or write anything. + +```sh terminal icon="terminal" +bun fmt --doctor +``` + +```txt +Using .oxfmtrc.json (oxfmt config) + + 3 files are formatted natively + +Still needed: oxfmt (the "format" script runs it) +``` + +--- + +Change the scripts. + +```json package.json icon="file-json" +{ + "scripts": { + "format": "oxfmt", // [!code --] + "format:check": "oxfmt --check", // [!code --] + "format": "bun fmt", // [!code ++] + "format:check": "bun fmt --check" // [!code ++] + } +} +``` + +Flags and arguments stay the same. Change CI and Git hooks too. + +If your script is called `fmt`, don't skip this: `bun fmt` runs a `fmt` script when `package.json` has one, so you'd still get oxfmt. + +--- + +Check that nothing changes. With an oxfmt config, a run prints what oxfmt prints. + +```sh terminal icon="terminal" +bun fmt --check +``` + +```txt +$ bun fmt --check +Checking formatting... + +All matched files use the correct format. +Finished in 4ms on 3 files using 64 threads. +``` + +If you're on a version of oxfmt before 0.72, a few files change once, like when you upgrade oxfmt. + +--- + +Run `--doctor` again. No script runs oxfmt anymore, so you can uninstall it. + +```txt +You can remove: oxfmt +Not checked: other tools' configs, CI files, Git hooks +``` + +```sh terminal icon="terminal" +bun remove oxfmt +``` + +No `.oxfmtrc.json`? Add one with `{}` in it first. Without a config file, the `oxfmt` dependency is how Bun knows to format like oxfmt and not like Prettier. + +--- + +Bun reads everything oxfmt reads: + +- `.oxfmtrc.json`, `.oxfmtrc.jsonc`, and `oxfmt.config.ts`, with `overrides` and `ignorePatterns` +- The `fmt` key of `vite.config.ts`, if you use vite-plus +- Every option, including `sortImports`, `sortPackageJson`, `sortTailwindcss`, and `jsdoc` +- `// oxfmt-ignore` and `// prettier-ignore` comments +- Every flag of oxfmt 0.72 except `--lsp` and `--migrate` + +It also follows oxfmt's defaults: lines wrap at 100 columns, every `.gitignore` in the repository counts, TOML files are formatted, and `.prettierrc` is ignored. + +--- + +Still have a `.prettierrc` next to `.oxfmtrc.json`? Bun uses `.oxfmtrc.json` and tells you so. Delete the one you no longer use, or pick one with `--config`. + +```txt +note: Using .oxfmtrc.json. .prettierrc is ignored. Use --config to pick it. +``` + +--- + +Keep `oxfmt` if your editor's oxc extension uses it. `bun fmt` has no language server. + +--- + +## Differences from oxfmt + +If `bun fmt` and oxfmt 0.72 print different code, that's a bug. [Open an issue](https://github.com/oven-sh/bun/issues). These differences are intentional: + +- **The output is oxfmt 0.72's**, whatever version you had installed. +- **No `--lsp` or `--migrate`.** +- **No Svelte.** With `svelte` in the config, Bun skips `.svelte` files and exits with code `2`. `--allow-unsupported` makes that a warning. +- **Errors and warnings are worded differently.** +- **An invalid TOML file is left alone**, with a warning. oxfmt rewrites the parts it can read. +- **A file that isn't valid UTF-8 is still formatted**, and the invalid bytes are kept. oxfmt exits with code `2`. +- **Where oxfmt's output changes what a file means, Bun's differs.** That's rare. In TOML oxfmt joins the lines of an inline table with a comment, which comments out the rest of the table. Bun keeps one entry per line. + +--- + +See also: + +- [`bun fmt`](/runtime/format) +- [Migrate from oxlint to bun lint](/guides/lint/migrate-from-oxlint) diff --git a/docs/guides/format/migrate-from-prettier.mdx b/docs/guides/format/migrate-from-prettier.mdx new file mode 100644 index 000000000000..9f4f67170ed9 --- /dev/null +++ b/docs/guides/format/migrate-from-prettier.mdx @@ -0,0 +1,162 @@ +--- +title: Migrate from Prettier to bun fmt +sidebarTitle: Migrate from Prettier +mode: center +--- + +`bun fmt` reads your `.prettierrc` and prints what Prettier 3 prints. In most projects you only change the command. + +Start with `--doctor`. It shows what Bun will do with your config, and it doesn't format or write anything. + +```sh terminal icon="terminal" +bun fmt --doctor +``` + +```txt +Using .prettierrc (Prettier config) + + 4 files are formatted natively + +Built-in plugins: @ianvs/prettier-plugin-sort-imports + +You can remove: @ianvs/prettier-plugin-sort-imports +Still needed: prettier (the "format" script runs it) +Not checked: other tools' configs, CI files, Git hooks +``` + +--- + +Change the scripts. + +```json package.json icon="file-json" +{ + "scripts": { + "format": "prettier --write .", // [!code --] + "format:check": "prettier --check .", // [!code --] + "format": "bun fmt", // [!code ++] + "format:check": "bun fmt --check" // [!code ++] + } +} +``` + +`bun fmt` writes by default and formats the current directory, so `--write .` goes away. Other flags and arguments stay the same. Change CI and Git hooks too. + +If your script is called `fmt`, don't skip this: `bun fmt` runs a `fmt` script when `package.json` has one, so you'd still get Prettier. + +Watch out for two differences in hooks and shell scripts: + +- **No arguments means the current directory.** Prettier prints an error there. So if the file list can be empty, as in `git diff --cached --name-only | xargs bun fmt` or `bun fmt $(git ls-files '*.foo')`, Bun formats everything. Use `bun fmt --staged` in a pre-commit hook, or `xargs -r`. +- **`bun fmt file.ts` writes the file and prints its name.** `prettier file.ts` prints the formatted code. To print it, pipe the code in: `bun fmt --stdin-filepath file.ts < file.ts`. + +Piping into `--parser` works like in Prettier: `generate | bun fmt --parser typescript > out.ts`. + +--- + +Check that nothing changes. With a Prettier config, `--check` prints what Prettier prints. + +```sh terminal icon="terminal" +bun fmt --check +``` + +```txt +$ bun fmt --check +Checking formatting... +All matched files use Prettier code style! +``` + +--- + +Run `--doctor` again. No script runs Prettier anymore, so it can go too. + +```txt +You can remove: @ianvs/prettier-plugin-sort-imports, prettier +Not checked: other tools' configs, CI files, Git hooks +``` + +```sh terminal icon="terminal" +bun remove prettier @ianvs/prettier-plugin-sort-imports +``` + +Leave `.prettierrc` as it is, including `plugins`. + +Keep `prettier` if your editor's Prettier extension uses it. + +--- + +Bun reads everything Prettier reads: + +- `.prettierrc` in JSON, YAML, TOML, JavaScript, or TypeScript, `prettier.config.*`, and `"prettier"` in `package.json`, with `overrides` +- `.editorconfig`, `.prettierignore`, and `.gitignore` +- `// prettier-ignore` comments and the `@format` and `@noformat` pragmas +- Every flag of Prettier 3 except `--file-info` and `--support-info` + +--- + +Bun doesn't load Prettier plugins. These are built in, with all their options, so leave them in `plugins`: + +- `prettier-plugin-tailwindcss` +- `@trivago/prettier-plugin-sort-imports` +- `@ianvs/prettier-plugin-sort-imports` +- `prettier-plugin-organize-imports` +- `@prettier/plugin-oxc` + +--- + +Svelte, Astro, and other languages that come from a plugin go to your installed Prettier. So does `package.json` if you use `prettier-plugin-packagejson`. Keep `prettier` and every plugin in `plugins` installed. `--doctor` shows which files those are. + +```txt +Using .prettierrc (Prettier config) + + 3 files are formatted natively + 1 file goes to your installed Prettier: 1 *.svelte + +Built-in plugins: @ianvs/prettier-plugin-sort-imports +Plugins that use your installed Prettier: prettier-plugin-svelte + +Still needed: @ianvs/prettier-plugin-sort-imports, prettier, prettier-plugin-svelte (some files go to them) +``` + +--- + +With any other plugin, Bun skips the files that config covers and exits with code `2`. A plugin could print them differently, and Bun won't guess. Pass `--allow-unsupported` to format them without the plugin. + +```txt +[error] 3 files were not formatted because the Prettier config uses plugins that bun fmt does not include: prettier-plugin-brace-style +[error] Use --allow-unsupported to format them without the plugins. +``` + +--- + +You don't have to keep `.prettierrc`. Move the options to [`bunfig.toml`](/runtime/format#options), under the same names, and delete the file. `overrides` become [`[[fmt.overrides]]`](/runtime/bunfig#format-overrides). + +```toml bunfig.toml icon="settings" +[fmt] +semi = false +singleQuote = true +``` + +Without a Prettier config, `bun fmt` also formats TOML files, and `--check` prints a shorter report. + +--- + +## Differences from Prettier + +If `bun fmt` and Prettier 3 print different code, that's a bug. [Open an issue](https://github.com/oven-sh/bun/issues). These differences are intentional: + +- **It writes by default.** `--write` is accepted and does nothing. +- **No arguments means the current directory.** +- **Only changed files are listed**, followed by a summary on stderr. +- **An unknown flag is an error.** Prettier warns and continues. +- **`--cache` does nothing.** Every run formats every file. +- **Exit code `2` means something went wrong**, like a syntax error or a skipped file. Prettier uses `1` for some of these. +- **Bytes that aren't valid UTF-8 are kept.** Prettier replaces them. +- **Where Prettier's output changes what a file means, Bun's differs.** That's rare. In a Handlebars template Prettier drops ``, and it joins the lines of a `; +} +``` + +{/* bad */} +```jsx +import Script from "next/script"; + +export default function Page() { + return + ; +} +``` diff --git a/docs/lint/rules/nextjs/next-script-for-ga.mdx b/docs/lint/rules/nextjs/next-script-for-ga.mdx new file mode 100644 index 000000000000..728f407cc693 --- /dev/null +++ b/docs/lint/rules/nextjs/next-script-for-ga.mdx @@ -0,0 +1,72 @@ +--- +title: "@next/next/next-script-for-ga" +description: "Require `next/script` instead of `\n`; + const result = await format({ ...bunfig("sortImports = true"), "a.vue": vue }); + expect(result.files["a.vue"]).toBe(`\n`); + }); + + test("also applies on top of a .prettierrc", async () => { + const result = await format({ + ...bunfig("sortImports = true"), + ".prettierrc": `{ "semi": false }`, + "a.ts": imports, + }); + expect(result.stderr).toBe(""); + expect(result.files["a.ts"]).toBe(sorted.replaceAll(";", "")); + }); + + test.each([ + [`sortImports = "yes"`, "expected boolean or object but received string"], + ["sortImports = 1", "expected boolean or object but received number"], + [ + "sortImports = { newlineBetween = false }", + `Unknown key "newlineBetween" in [fmt.sortImports]\n Did you mean "newlinesBetween"?`, + ], + ["sortImports = { importOrder = [] }", `Unknown key "importOrder" in [fmt.sortImports]\n`], + [`sortImports = { newlinesBetween = "no" }`, "expected boolean but received string"], + [`sortImports = { groups = "builtin" }`, "expected array but received string"], + ["sortImports = { internalPattern = [1] }", "expected string but received number"], + [`sortImports = { order = "up" }`, 'Invalid `sortImports` configuration: `order` must be "asc" or "desc"'], + [ + `sortImports = { groups = ["packages"] }`, + "Invalid `sortImports` configuration: unknown group name `packages` in `groups`", + ], + [ + "sortImports = { partitionByNewline = true }", + "`partitionByNewline: true` and `newlinesBetween: true` cannot be used together", + ], + ])("rejects %s with: %s", async (line, message) => { + const result = await format({ ...bunfig(line), "a.ts": imports }); + expect(result.stderr).toContain(message); + expect(result.stderr).toContain("bunfig.toml:2:"); + expect(result.files["a.ts"]).toBe(imports); + expect(result.exitCode).toBe(2); + }); +}); + +describe.concurrent("sortTailwindcss", () => { + /** Minimal stand-in for Tailwind CSS 4. A stylesheet containing `reversed` flips the class order. */ + const tailwind = { + "node_modules/tailwindcss/package.json": JSON.stringify({ + name: "tailwindcss", + version: "4.0.0", + main: "index.js", + }), + "node_modules/tailwindcss/theme.css": "", + "node_modules/tailwindcss/index.js": `const order = ["m-2", "flex", "p-4"]; +exports.__unstable__loadDesignSystem = async css => { + const sign = css.includes("reversed") ? -1 : 1; + return { getClassOrder: classes => classes.map(name => [name, order.includes(name) ? BigInt(sign * order.indexOf(name)) : null]) }; +}; +`, + }; + const input = `;\n`; + const sorted = `;\n`; + + test("sorts classes in JSX, HTML, Vue and CSS", async () => { + const result = await format({ + ...tailwind, + ...bunfig("sortTailwindcss = true"), + "a.jsx": input + wide, + "a.html": `\n`, + "a.vue": `\n`, + "a.css": `a {\n @apply p-4 flex m-2;\n}\n`, + }); + expect(result.stderr).toBe(""); + expect(result.files["a.jsx"]).toBe(sorted + broken); + expect(result.files["a.html"]).toBe(`\n`); + expect(result.files["a.vue"]).toBe(`\n`); + expect(result.files["a.css"]).toBe(`a {\n @apply m-2 flex p-4;\n}\n`); + expect(result.exitCode).toBe(0); + }); + + test("is off by default and doesn't need tailwindcss installed", async () => { + const result = await format({ "a.jsx": input }); + expect(result.stderr).toBe(""); + expect(result.files["a.jsx"]).toBe(input); + expect(result.exitCode).toBe(0); + }); + + test("--sort-tailwindcss turns it on and --no-sort-tailwindcss turns it off", async () => { + const [on, off] = await Promise.all([ + format({ ...tailwind, "a.jsx": input }, ["--sort-tailwindcss", "."]), + format({ ...tailwind, ...bunfig("sortTailwindcss = true"), "a.jsx": input }, ["--no-sort-tailwindcss", "."]), + ]); + expect(on.files["a.jsx"]).toBe(sorted); + expect(off.files["a.jsx"]).toBe(input); + }); + + test("takes options from a table and resolves paths from the working directory", async () => { + const result = await format({ + ...tailwind, + "bunfig.toml": `[fmt.sortTailwindcss] +stylesheet = "./styles/app.css" +functions = ["cn"] +attributes = ["look"] +preserveDuplicates = true +`, + "styles/app.css": "/* reversed */\n", + "src/a.jsx": `;\ncn("m-2 p-4");\nother("m-2 p-4");\n`, + }); + expect(result.stderr).toBe(""); + expect(result.files["src/a.jsx"]).toBe( + `;\ncn("p-4 m-2");\nother("m-2 p-4");\n`, + ); + expect(result.exitCode).toBe(0); + }); + + test("sorts classes in clsx, cn, cva and similar calls by default, unless `functions` is set", async () => { + const calls = ["clsx", "cn", "cva", "cx", "classNames", "classnames", "twMerge", "twJoin", "tv", "other"]; + const code = (classes: (name: string) => string) => calls.map(name => `${name}("${classes(name)}");\n`).join(""); + const [byDefault, named, oxfmt] = await Promise.all([ + format({ ...tailwind, ...bunfig("sortTailwindcss = true"), "a.js": code(() => "p-4 m-2") }), + format({ ...tailwind, ...bunfig(`sortTailwindcss = { functions = ["other"] }`), "a.js": code(() => "p-4 m-2") }), + format({ ...tailwind, ".oxfmtrc.json": `{ "sortTailwindcss": true }`, "a.js": code(() => "p-4 m-2") }), + ]); + expect(byDefault.files["a.js"]).toBe(code(name => (name === "other" ? "p-4 m-2" : "m-2 p-4"))); + expect(named.files["a.js"]).toBe(code(name => (name === "other" ? "m-2 p-4" : "p-4 m-2"))); + // oxfmt's default list is empty. + expect(oxfmt.files["a.js"]).toBe(code(() => "p-4 m-2")); + }); + + test("also applies on top of a .prettierrc", async () => { + const files = { ...tailwind, ".prettierrc": `{ "jsxSingleQuote": true }`, "a.jsx": input }; + const result = await format({ ...files, ...bunfig("sortTailwindcss = true") }); + expect(result.stderr).toBe(""); + expect(result.files["a.jsx"]).toBe(sorted.replaceAll('"', "'")); + }); + + test("fails when tailwindcss isn't installed", async () => { + const result = await format({ ...bunfig("sortTailwindcss = true"), "a.jsx": input, "b.js": "b ;\n" }); + expect(result.stderr).toContain("the tailwindcss package was not found"); + expect(result.files["a.jsx"]).toBe(input); + // Files without classes don't need the package. + expect(result.files["b.js"]).toBe("b;\n"); + expect(result.exitCode).toBe(2); + }); + + test.each([ + [`sortTailwindcss = "yes"`, "expected boolean or object but received string"], + [ + "sortTailwindcss = { function = [] }", + `Unknown key "function" in [fmt.sortTailwindcss]\n Did you mean "functions"?`, + ], + [`sortTailwindcss = { functions = "cn" }`, "expected array but received string"], + ["sortTailwindcss = { attributes = [true] }", "expected string but received boolean"], + ["sortTailwindcss = { stylesheet = 1 }", "expected string but received number"], + ["sortTailwindcss = { preserveWhitespace = 1 }", "expected boolean but received number"], + ])("rejects %s with: %s", async (line, message) => { + const result = await format({ ...tailwind, ...bunfig(line), "a.jsx": input }); + expect(result.stderr).toContain(message); + expect(result.files["a.jsx"]).toBe(input); + expect(result.exitCode).toBe(2); + }); +}); + +describe.concurrent("sortPackageJson", () => { + const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`; + const input = json({ version: "1.0.0", scripts: { test: "a", build: "b" }, name: "x" }); + const sorted = json({ name: "x", version: "1.0.0", scripts: { test: "a", build: "b" } }); + const withScripts = json({ name: "x", version: "1.0.0", scripts: { build: "b", test: "a" } }); + + test("works as a key, a flag and a table", async () => { + const [off, key, flag, under, table] = await Promise.all([ + format({ "package.json": input }), + format({ ...bunfig("sortPackageJson = true"), "package.json": input, "other.json": input }), + format({ "package.json": input }, ["--sort-package-json", "."]), + format({ ...bunfig("sortPackageJson = { sortScripts = true }"), "package.json": input }, [ + "--no-sort-package-json", + ".", + ]), + format({ ...bunfig("sortPackageJson = { sortScripts = true }"), "package.json": input }), + ]); + expect(off.files["package.json"]).toBe(input); + expect(key.files["package.json"]).toBe(sorted); + expect(key.files["other.json"]).toBe(input); + expect(flag.files["package.json"]).toBe(sorted); + expect(under.files["package.json"]).toBe(input); + expect(table.files["package.json"]).toBe(withScripts); + }); + + test("is still an unknown option in a .prettierrc", async () => { + const result = await format({ ".prettierrc": `{ "sortPackageJson": true }`, "package.json": input }); + expect(result.stderr).toContain("Ignored unknown option { sortPackageJson: true }."); + expect(result.files["package.json"]).toBe(input); + }); + + test.each([ + ["sortPackageJson = []", "expected boolean or object but received array"], + [ + "sortPackageJson = { sortScript = true }", + `Unknown key "sortScript" in [fmt.sortPackageJson]\n Did you mean "sortScripts"?`, + ], + [`sortPackageJson = { sortScripts = "all" }`, "expected boolean but received string"], + ])("rejects %s with: %s", async (line, message) => { + const result = await format({ ...bunfig(line), "package.json": input }); + expect(result.stderr).toContain(message); + expect(result.exitCode).toBe(2); + }); +}); + +describe.concurrent("jsdoc", () => { + const input = `/** + * adds two numbers + * @returns {number} the sum + * @param {number} b second + * @param {number} a first + */ +export function add(a, b) {} +`; + const formatted = `/** + * Adds two numbers + * + * @param {number} a First + * @param {number} b Second + * @returns {number} The sum + */ +export function add(a, b) {} +`; + + test("works as a key, a flag and a table", async () => { + const options = "jsdoc = { capitalizeDescriptions = false, descriptionWithDot = true }"; + const [off, key, flag, under, table, over] = await Promise.all([ + format({ "a.js": input }), + format({ ...bunfig("jsdoc = true"), "a.js": input }), + format({ "a.js": input }, ["--jsdoc", "."]), + format({ ...bunfig(options), "a.js": input }, ["--no-jsdoc", "."]), + format({ ...bunfig(options), "a.js": input }), + format({ ...bunfig(options), "a.js": input }, ["--jsdoc", "."]), + ]); + expect(off.files["a.js"]).toBe(input); + expect(key.files["a.js"]).toBe(formatted); + expect(flag.files["a.js"]).toBe(formatted); + expect(under.files["a.js"]).toBe(input); + expect(table.files["a.js"]).toBe(`/** + * adds two numbers. + * + * @param {number} a first. + * @param {number} b second. + * @returns {number} the sum + */ +export function add(a, b) {} +`); + expect(over.files["a.js"]).toBe(table.files["a.js"]); + }); + + test.each([ + [`jsdoc = "all"`, "expected boolean or object but received string"], + ["jsdoc = { capitalize = true }", `Unknown key "capitalize" in [fmt.jsdoc]\n`], + ["jsdoc = { descriptionTag = 1 }", "expected boolean but received number"], + [`jsdoc = { commentLineStrategy = "one" }`, "Invalid jsdoc.commentLineStrategy value: one."], + ])("rejects %s with: %s", async (line, message) => { + const result = await format({ ...bunfig(line), "a.js": input }); + expect(result.stderr).toContain(message); + expect(result.files["a.js"]).toBe(input); + expect(result.exitCode).toBe(2); + }); +}); + +describe.concurrent("insertFinalNewline", () => { + test("works as a key and a flag", async () => { + const files = { "a.js": "a ;\n", "a.css": "a {\n}\n", "a.md": "a\n" }; + const [plain, key, flag, under] = await Promise.all([ + format(files), + format({ ...bunfig("insertFinalNewline = false"), ...files }), + format(files, ["--no-insert-final-newline", "."]), + format({ ...bunfig("insertFinalNewline = false"), ...files }, ["--insert-final-newline", "."]), + ]); + expect(plain.files).toEqual({ "a.js": "a;\n", "a.css": "a {\n}\n", "a.md": "a\n" }); + expect(key.files).toMatchObject({ "a.js": "a;", "a.css": "a {\n}", "a.md": "a" }); + expect(flag.files).toEqual({ "a.js": "a;", "a.css": "a {\n}", "a.md": "a" }); + expect(under.files).toMatchObject(plain.files); + }); + + test("is still an unknown option in a .prettierrc", async () => { + const result = await format({ ".prettierrc": `{ "insertFinalNewline": false }`, "a.js": "a ;\n" }); + expect(result.stderr).toContain("Ignored unknown option { insertFinalNewline: false }."); + expect(result.files["a.js"]).toBe("a;\n"); + }); + + test("rejects a non-boolean value", async () => { + const result = await format({ ...bunfig(`insertFinalNewline = "no"`), "a.js": "a ;\n" }); + expect(result.stderr).toContain("expected boolean but received string"); + expect(result.exitCode).toBe(2); + }); +}); + +describe.concurrent("TOML", () => { + const input = `a = 1\n[b]\nc = [ "d",'e' ]\n`; + const formatted = `a = 1\n[b]\nc = ["d", 'e']\n`; + + test("is formatted by default, including bunfig.toml", async () => { + const result = await format({ ...bunfig("semi = false"), "a.toml": input, "a.js": "a ;\n" }); + expect(result.stderr).toBe(""); + expect(result.files).toEqual({ "bunfig.toml": "[fmt]\nsemi = false\n", "a.toml": formatted, "a.js": "a\n" }); + expect(result.exitCode).toBe(0); + }); + + test("is formatted when the file is passed by name", async () => { + const result = await format({ "a.toml": input }, ["a.toml"]); + expect(result.files["a.toml"]).toBe(formatted); + expect(result.exitCode).toBe(0); + }); + + test("is skipped under a Prettier config, because Prettier doesn't format TOML", async () => { + const [all, named, below] = await Promise.all([ + format({ ".prettierrc": "{}", "a.toml": input, "a.js": "a ;\n" }), + format({ ".prettierrc": "{}", "a.toml": input }, ["a.toml"]), + format({ "sub/.prettierrc": "{}", "sub/a.toml": input, "a.toml": input }), + ]); + expect(all.files["a.toml"]).toBe(input); + expect(all.exitCode).toBe(0); + expect(named.stderr).toContain("No parser could be inferred for file"); + expect(named.exitCode).toBe(2); + expect(below.files).toMatchObject({ "sub/a.toml": input, "a.toml": formatted }); + }); + + test("wraps at printWidth", async () => { + const items = Array(9).fill(`"aaaaaaaa"`); + const result = await format({ "a.toml": `a = [${items.join(", ")}]\n` }); + expect(result.files["a.toml"]).toBe(`a = [\n${items.map(it => ` ${it},\n`).join("")}]\n`); + }); + + test("reports a syntax error for invalid TOML", async () => { + const result = await format({ "a.toml": "a = \n", "b.toml": input }); + expect(result.stderr).toContain("a.toml: SyntaxError:"); + expect(result.files).toEqual({ "a.toml": "a = \n", "b.toml": formatted }); + expect(result.exitCode).toBe(2); + }); + + test("has no `toml` key and no --toml flag", async () => { + const [key, flag] = await Promise.all([ + format({ ...bunfig("toml = true"), "a.toml": input }), + format({ "a.toml": input }, ["--toml", "."]), + ]); + expect(key.stderr).toContain(`nknown key "toml" in [fmt]`); + expect(flag.stderr).toContain("--toml"); + expect([key.exitCode, flag.exitCode]).toEqual([2, 2]); + expect([key.files["a.toml"], flag.files["a.toml"]]).toEqual([input, input]); + }); +}); + +describe.concurrent("overrides", () => { + const files = { + "a.js": `a("b");\n`, + "a.test.js": `a("b");\n`, + "src/b.test.js": `a("b");\n`, + "src/skip.test.js": `a("b");\n`, + }; + const table = `[fmt] +semi = false + +[[fmt.overrides]] +files = "*.test.js" +excludeFiles = ["src/skip.*"] +options = { singleQuote = true, semi = true } +`; + + test("apply to matching files and win over the top-level keys", async () => { + const result = await format({ "bunfig.toml": table, ...files }); + expect(result.stderr).toBe(""); + expect(result.files).toMatchObject({ + "a.js": `a("b")\n`, + "a.test.js": `a('b');\n`, + "src/b.test.js": `a('b');\n`, + "src/skip.test.js": `a("b")\n`, + }); + expect(result.exitCode).toBe(0); + }); + + test("patterns use Bun.Glob syntax, including extglobs and brace ranges", async () => { + const names = ["a/x.js", "ab/x.js", "c/x.js", "f1.js", "f3.js", "f4.js"]; + const sources = Object.fromEntries(names.map(name => [name, `a("b");\n`])); + const globs = `["+(a|b)/**", "f{1..3}.js"]`; + /** Returns the files the override applied to. Those lose their semicolon. */ + const matched = async (patterns: string) => { + const bunfig = `[[fmt.overrides]]\n${patterns}\noptions = { semi = false }\n`; + const { files } = await format({ "bunfig.toml": bunfig, ...sources }); + return names.filter(name => files[name] === `a("b")\n`); + }; + expect(await matched(`files = ${globs}`)).toEqual(["a/x.js", "ab/x.js", "f1.js", "f3.js"]); + expect(await matched(`files = "**/*.js"\nexcludeFiles = ${globs}`)).toEqual(["c/x.js", "f4.js"]); + }); + + test("a leading ! negates a pattern, with or without a slash", async () => { + const [a, aTest, b, bTest] = ["a.js", "a.test.js", "src/b.js", "src/b.test.js"]; + const sources = Object.fromEntries([a, aTest, b, bTest].map(name => [name, `a("b");\n`])); + // The patterns, and the files that the override applies to. + const rows: Record = { + 'files = ["*.test.js"]': [aTest, bTest], + 'files = ["!**/*.test.js"]': [a, b], + 'files = ["!*.test.js"]': [a, b], + 'files = ["!!*.test.js"]': [aTest, bTest], + 'files = ["!./a.js"]': [aTest, b, bTest], + 'files = ["!src/**"]': [a, aTest], + // `!(` starts an extglob group. It isn't a negation. + 'files = ["!(a|b).js"]': [aTest, bTest], + 'files = ["!!(a|b).js"]': [a, b], + 'files = ["**/*.js"]\nexcludeFiles = ["!*.test.js"]': [aTest, bTest], + 'files = ["**/*.js"]\nexcludeFiles = ["!**/*.test.js"]': [aTest, bTest], + 'files = ["**/*.js"]\nexcludeFiles = ["!(a|b).js"]': [a, b], + }; + /** The files that lose their semicolon. */ + const matched = async (patterns: string) => { + const bunfig = `[[fmt.overrides]]\n${patterns}\noptions = { semi = false }\n`; + const { files } = await format({ "bunfig.toml": bunfig, ...sources }); + return Object.keys(sources).filter(name => files[name] === `a("b")\n`); + }; + const results = await Promise.all(Object.keys(rows).map(matched)); + expect(Object.fromEntries(Object.keys(rows).map((patterns, at) => [patterns, results[at]]))).toEqual(rows); + }); + + test("later overrides win, and command-line flags win over all of them", async () => { + const more = `${table} +[[fmt.overrides]] +files = ["src/**"] +options = { singleQuote = false, useTabs = true } +`; + const block = { "src/c.test.js": `{\n a("b");\n}\n` }; + const [later, flag] = await Promise.all([ + format({ "bunfig.toml": more, ...block }), + format({ "bunfig.toml": more, ...block }, ["--no-semi", "--single-quote", "--no-use-tabs", "."]), + ]); + expect(later.files["src/c.test.js"]).toBe(`{\n\ta("b");\n}\n`); + expect(flag.files["src/c.test.js"]).toBe(`{\n a('b')\n}\n`); + }); + + test("also apply on top of a .prettierrc", async () => { + const result = await format({ + "bunfig.toml": table, + ".prettierrc": `{ "singleQuote": false, "semi": true }`, + ...files, + }); + expect(result.files).toMatchObject({ "a.js": `a("b")\n`, "a.test.js": `a('b');\n` }); + }); + + test("can set `parser`", async () => { + const result = await format({ + "bunfig.toml": `[[fmt.overrides]]\nfiles = ["*.rc", ".babelrc"]\noptions = { parser = "json5" }\n`, + "a.rc": `{"a":1}\n`, + "a.other": `{"a":1}\n`, + }); + expect(result.stderr).toBe(""); + expect(result.files).toMatchObject({ "a.rc": `{ a: 1 }\n`, "a.other": `{"a":1}\n` }); + }); + + test("can turn on sortImports and insertFinalNewline for some files", async () => { + const imports = `import b from "./b";\nimport a from "a";\n`; + const bunfig = `[[fmt.overrides]] +files = "src/**" +[fmt.overrides.options] +sortImports = { newlinesBetween = false } +insertFinalNewline = false +`; + const [result, off, on] = await Promise.all([ + format({ "bunfig.toml": bunfig, "a.js": imports, "src/a.js": imports }), + format({ "bunfig.toml": bunfig, "src/a.js": imports }, ["--no-sort-imports", "."]), + format({ "bunfig.toml": bunfig, "a.js": imports, "src/a.js": imports }, ["--sort-imports", "."]), + ]); + expect(result.stderr).toBe(""); + expect(result.files).toMatchObject({ "a.js": imports, "src/a.js": `import a from "a";\nimport b from "./b";` }); + expect(off.files["src/a.js"]).toBe(imports.trimEnd()); + // The flag turns sorting on everywhere but keeps the override's options. + expect(on.files).toMatchObject({ + "a.js": `import a from "a";\n\nimport b from "./b";\n`, + "src/a.js": `import a from "a";\nimport b from "./b";`, + }); + }); + + test.each([ + ["overrides = {}", `"overrides" is an array of tables\n Write [[fmt.overrides]], with two brackets`], + ["overrides = 1", "expected array but received number"], + ["overrides = [1]", "expected object but received number"], + ["overrides = [{ options = { semi = false } }]", `an override needs "files"`], + ["overrides = [{ files = 1 }]", "expected string or array but received number"], + ["overrides = [{ files = [1] }]", "expected string but received number"], + [`overrides = [{ files = "a[" }]`, "Invalid glob pattern `a[`"], + [`overrides = [{ file = "a" }]`, `Unknown key "file" in [fmt.overrides]\n Did you mean "files"?`], + [`overrides = [{ files = "a", options = { semi = 1 } }]`, "expected boolean but received number"], + [`overrides = [{ files = "a", options = { printWidth = 1.5 } }]`, "Invalid printWidth value: 1.5."], + [`overrides = [{ files = "a", options = { threads = 1 } }]`, `Unknown key "threads" in [fmt.overrides.options]\n`], + [`overrides = [{ files = "a", options = { toml = true } }]`, `Unknown key "toml" in [fmt.overrides.options]\n`], + [`overrides = [{ files = "a", options = { check = true } }]`, `Unknown key "check" in [fmt.overrides.options]\n`], + [`overides = []`, `Unknown key "overides" in [fmt]\n Did you mean "overrides"?`], + ])("rejects %s with: %s", async (line, message) => { + const result = await format({ ...bunfig(line), "a.js": "a ;\n" }); + expect(result.stderr).toContain(message); + expect(result.files["a.js"]).toBe("a ;\n"); + expect(result.exitCode).toBe(2); + }); +}); + +describe.concurrent(".oxfmtrc.json next to a Prettier config", () => { + const note = (ignored: string) => `note: Using .oxfmtrc.json. ${ignored} is ignored. Use --config to pick it.\n`; + const both = { ".oxfmtrc.json": "{}\n", ".prettierrc": "{}\n", "a.js": wide, "sub/b.js": wide }; + + test("uses .oxfmtrc.json and prints one note", async () => { + const result = await format(both); + expect(result.stderr).toBe(note(".prettierrc")); + expect(result.files).toMatchObject({ "a.js": wide, "sub/b.js": wide }); + expect(result.exitCode).toBe(0); + }); + + test("--config picks either file and silences the note", async () => { + const [prettier, oxfmt] = await Promise.all([ + format(both, ["--config", ".prettierrc", "a.js"]), + format(both, ["--config", ".oxfmtrc.json", "a.js"]), + ]); + expect(prettier.stderr).toBe(""); + expect(prettier.files["a.js"]).toBe(broken); + expect(oxfmt.stderr).toBe(""); + expect(oxfmt.files["a.js"]).toBe(wide); + }); + + test("counts package.json only if it has a `prettier` key", async () => { + const [has, lacks] = await Promise.all([ + format({ ".oxfmtrc.json": "{}\n", "package.json": `{\n "prettier": {}\n}\n`, "a.js": wide }), + format({ ".oxfmtrc.json": "{}\n", "package.json": `{\n "name": "a"\n}\n`, "a.js": wide }), + ]); + expect(has.stderr).toBe(note("prettier in package.json")); + expect(lacks.stderr).toBe(""); + }); + + test("doesn't run the ignored prettier.config.js", async () => { + const files = { ".oxfmtrc.json": "{}\n", "prettier.config.js": `throw new Error("It runs.");\n`, "a.ts": wide }; + const result = await format(files, ["a.ts"]); + expect(result.stderr).toBe(note("prettier.config.js")); + expect(result.exitCode).toBe(0); + }); + + test("prints no note with only one config file, or with --log-level=error", async () => { + const { ".prettierrc": _, ...oxfmt } = both; + const { ".oxfmtrc.json": __, ...prettier } = both; + const results = await Promise.all([format(oxfmt), format(prettier), format(both, ["--log-level=error", "."])]); + expect(results.map(it => it.stderr)).toEqual(["", "", ""]); + }); +}); diff --git a/test/cli/format/conformance.test.ts b/test/cli/format/conformance.test.ts index ed558b4a0428..7db2a5268fd6 100644 --- a/test/cli/format/conformance.test.ts +++ b/test/cli/format/conformance.test.ts @@ -6,7 +6,7 @@ import { collect, concatenate } from "./bundle.ts"; afterAll(endChildren); -// The tests of Prettier and of oxfmt, and inputs of our own, run on `bun format`. See the README.md of prettier, oxfmt and own. +// The tests of Prettier and of oxfmt, and inputs of our own, run on `bun fmt`. See the README.md of prettier, oxfmt and own. // `src/format/conformance` runs them: it says what is checked, and what is left out and why. // // `/expected.txt` is what the runner printed when it was last written: the checks that fail, one per line, the totals, and what is @@ -42,7 +42,9 @@ test.skipIf(!hasRunner).concurrent.each([ ); await using proc = spawn({ timeout: 4 * 60_000, - cmd: [bunExe(), "format", `--run-${runner}-tests`, bundle, `--every=${every}`], + cmd: [bunExe(), "fmt", `--run-${runner}-tests`, bundle, `--every=${every}`], + // Not in this repository, which has a `fmt` script. + cwd: String(dir), env: bunEnv, stdout: "pipe", stderr: "inherit", diff --git a/test/cli/format/format-doctor.test.ts b/test/cli/format/format-doctor.test.ts new file mode 100644 index 000000000000..0c4053341c20 --- /dev/null +++ b/test/cli/format/format-doctor.test.ts @@ -0,0 +1,346 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { endChildren, spawn } from "../children"; + +afterAll(endChildren); + +const env = { ...bunEnv, NO_COLOR: undefined, FORCE_COLOR: undefined }; + +async function doctor(files: Record, args: string[] = []) { + using dir = tempDir("bun-format-doctor", files); + const list = () => (readdirSync(String(dir), { recursive: true }) as string[]).sort(); + const before = list(); + await using proc = spawn({ + cmd: [bunExe(), "fmt", "--threads", "2", "--doctor", ...args], + env, + cwd: String(dir), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const isUntouched = + Bun.deepEquals(list(), before) && + Object.entries(files).every(([name, text]) => readFileSync(join(String(dir), name), "utf8") === text); + return { stdout, stderr, exitCode, isUntouched }; +} + +/** Files for an installed package. Nothing loads it. */ +const installed = (name: string) => ({ + [`node_modules/${name}/package.json`]: JSON.stringify({ name, version: "1.0.0", main: "index.js" }), + [`node_modules/${name}/index.js`]: "module.exports = {};", +}); + +const dependencies = (...names: string[]) => + JSON.stringify({ name: "app", devDependencies: Object.fromEntries(names.map(name => [name, "*"])) }, null, 2) + "\n"; + +const ugly = "const a = {b:1}\n"; + +const prettier = { + ...installed("prettier"), + ...installed("prettier-plugin-svelte"), + ...installed("@ianvs/prettier-plugin-sort-imports"), + "package.json": dependencies( + "prettier", + "prettier-plugin-svelte", + "prettier-plugin-astro", + "@ianvs/prettier-plugin-sort-imports", + "eslint-config-prettier", + "typescript", + ), + ".prettierrc": JSON.stringify({ + semi: false, + plugins: ["@ianvs/prettier-plugin-sort-imports", "prettier-plugin-svelte"], + }), + ".editorconfig": "root = true\n[*]\nindent_size = 2\n", + "src/a.js": ugly, + "src/b.ts": ugly, + "src/c.svelte": "

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, args: string[], options: Op }; } +/** + * How many times the processor time of `plain` the run `odd` took. Windows counts that time in ticks of 1/64 s, so a + * short run can report 0: anything below a tick counts as one. + */ +const slowdown = (odd: { cpu: number }, plain: { cpu: number }) => + odd.cpu / Math.max(plain.cpu, isWindows ? 1 / 64 : 0); + /** Whether the temporary directory is in a repository. */ const isInRepository = (function look(directory: string): boolean { const isRoot = [".git", ".jj"].some(name => existsSync(join(directory, name))); @@ -89,7 +98,7 @@ async function different(files: Record, args: string[], options? return stdout.split("\n").filter(Boolean); } -describe.concurrent("bun format", () => { +describe.concurrent("bun fmt", () => { test("writes the files that change, and prints their names", async () => { const result = await format({ "a.js": ugly, "b.ts": formatted, "src/c.tsx": ugly }, [], { reads: ["a.js", "b.ts", "src/c.tsx"], @@ -129,7 +138,7 @@ describe.concurrent("bun format", () => { ["-l", "--check", "--no-config", "--config", "x", "."], "[error] Cannot use --no-config and --config together.", ], - ["no configuration", {}, ["-l", "--check", "."], together], + ["no configuration", {}, ["-l", "--check", "."], together.replace("[error]", "error:")], ["oxfmt", oxfmt, ["--list-different", "--check", "."], second("--check", "--list-different")], ["oxfmt", oxfmt, ["--check", "--list-different", "."], second("--list-different", "--check")], ["oxfmt", oxfmt, ["--list-different", "--write", "."], second("--write", "--list-different")], @@ -144,7 +153,6 @@ describe.concurrent("bun format", () => { ["oxfmt", oxfmt, ["--stdin-filepath", "a.js", "--check"], second("--check", "--stdin-filepath")], ["oxfmt", oxfmt, ["--check", "--stdin-filepath=a.js"], second("--stdin-filepath", "--check")], ["oxfmt", oxfmt, ["--init", "--check"], second("--check", "--init")], - ["oxfmt", {}, ["--write", "--init"], second("--init", "--write")], ["oxfmt", { ".oxfmtrc.json": "{" }, ["--list-different", "--write", "."], second("--write", "--list-different")], ])("%s: %j %j", async (_, configuration, args, stderr) => { const files = { ...configuration, "good.js": formatted, "bad.js": ugly }; @@ -241,14 +249,14 @@ describe.concurrent("bun format", () => { }); test.each<[Record, string[], string, number]>([ - [{}, ["--tab-width=4294967295"], "[error] a.js: RangeError: Invalid string length", 2], + [{}, ["--tab-width=4294967295"], "error: a.js: RangeError: Invalid string length", 2], [{ ".prettierrc": '{"tabWidth": 1e20}' }, [], "[error] a.js: RangeError: Invalid string length", 2], - [{}, ["--print-width", "1.5"], "[error] Invalid --print-width value. Expected an integer, but received 1.5.", 1], - [{}, ["--print-width=abc"], "[error] Invalid --print-width value. Expected an integer, but received null.", 1], - [{}, ["--print-width", "1e-1"], "[error] Invalid --print-width value. Expected an integer, but received 0.1.", 1], - [{}, ["--print-width=1_000"], "[error] Invalid --print-width value. Expected an integer, but received null.", 1], - [{}, ["--tab-width=1.5"], "[error] Invalid --tab-width value. Expected an integer, but received 1.5.", 1], - [{}, ["--tab-width", "NaN"], "[error] Invalid --tab-width value. Expected an integer, but received null.", 1], + [{}, ["--print-width", "1.5"], "error: Invalid --print-width value. Expected an integer, but received 1.5.", 1], + [{}, ["--print-width=abc"], "error: Invalid --print-width value. Expected an integer, but received null.", 1], + [{}, ["--print-width", "1e-1"], "error: Invalid --print-width value. Expected an integer, but received 0.1.", 1], + [{}, ["--print-width=1_000"], "error: Invalid --print-width value. Expected an integer, but received null.", 1], + [{}, ["--tab-width=1.5"], "error: Invalid --tab-width value. Expected an integer, but received 1.5.", 1], + [{}, ["--tab-width", "NaN"], "error: Invalid --tab-width value. Expected an integer, but received null.", 1], [ { ".prettierrc": '{"printWidth": 1.5}' }, [], @@ -355,7 +363,7 @@ describe.concurrent("bun format", () => { // Prettier and oxfmt write into the file (`fs.writeFile`, `fs::write`): all but its text stays as it is. describe("the file that is written", () => { const isRoot = process.getuid?.() === 0; - /** `bun format` in `dir`, which is still there afterwards. `before`: what starts the command. */ + /** `bun fmt` in `dir`, which is still there afterwards. `before`: what starts the command. */ async function write(dir: string, args: string[], before: string[] = []) { await using proc = spawn({ cmd: [...before, ...command, ...args], @@ -403,7 +411,7 @@ describe.concurrent("bun format", () => { for (const result of [inRepository, inNone]) { expect(result.files).toEqual({ "outside/c/d.js": ugly }); expect(result.stderr).toContain( - '[error] Unable to write file "c/d.js":\n[error] A link leads out of the repository.', + 'error: Unable to write file "c/d.js":\nit is a symlink to a file outside the repository', ); expect(result.exitCode).toBe(2); } @@ -452,7 +460,7 @@ describe.concurrent("bun format", () => { chmodSync(join(String(dir), "src"), 0o555); try { const { stderr, exitCode } = await write(String(dir), []); - expect(stderr).not.toContain("[error]"); + expect(stderr).not.toMatch(anError); expect(read(String(dir), "src/a.js")).toBe(formatted); expect(exitCode).toBe(0); } finally { @@ -468,7 +476,7 @@ describe.concurrent("bun format", () => { try { const { stderr, exitCode } = await write(String(dir), ["a.js", "b.js"]); expect([read(String(dir), "a.js"), read(String(dir), "b.js")]).toEqual([ugly, formatted]); - expect(stderr).toMatch(/\[error\] Unable to write file "a\.js":\r?\n(\[error\] )?(EACCES|EPERM)/); + expect(stderr).toMatch(/^error: Unable to write file "a\.js":\r?\n(EACCES|EPERM)/); expect(readdirSync(String(dir)).sort()).toEqual(["a.js", "b.js"]); expect(exitCode).toBe(2); } finally { @@ -481,25 +489,26 @@ describe.concurrent("bun format", () => { // Linux takes 4,096 bytes, macOS 1,024, Windows 32,767 characters, which is also all that its command line has. const long = Buffer.alloc(isWindows ? 30_000 : 40_000, "a/").toString() + "a.js"; const result = await format({ "a.js": ugly }, [long, "a.js"], { reads: ["a.js"] }); - expect(result.stderr).toStartWith('[error] No files matching the pattern were found: "a/a/'); + expect(result.stderr).toStartWith('error: No files matching the pattern were found: "a/a/'); expect(result.files).toEqual({ "a.js": formatted }); expect(result.exitCode).toBe(2); }); test("--check writes nothing and exits with 1", async () => { - const result = await format({ "a.js": ugly, "b.js": formatted, "c.js": ugly }, ["--check"], { reads: ["a.js"] }); + const files = { ".prettierrc": "{}\n", "a.js": ugly, "b.js": formatted, "c.js": ugly }; + const result = await format(files, ["--check"], { reads: ["a.js"] }); expect(result.files).toEqual({ "a.js": ugly }); expect(result.stdout).toMatchInlineSnapshot(`"Checking formatting..."`); expect(result.stderr).toMatchInlineSnapshot(` "[warn] a.js [warn] c.js - [warn] Code style issues found in 2 files. Run bun format to fix." + [warn] Code style issues found in 2 files. Run bun fmt to fix." `); expect(result.exitCode).toBe(1); }); test("--check exits with 0 if everything is formatted", async () => { - const result = await format({ "a.js": formatted }, ["--check"]); + const result = await format({ ".prettierrc": "{}\n", "a.js": formatted }, ["--check"]); expect(result.stdout).toMatchInlineSnapshot(` "Checking formatting... All matched files use Prettier code style!" @@ -554,7 +563,7 @@ describe.concurrent("bun format", () => { test("a syntax error is reported, the other files are formatted, and the exit code is 2", async () => { const result = await format({ "a.js": "const = 1;\n", "b.js": ugly }, [], { reads: ["a.js", "b.js"] }); expect(result.files).toEqual({ "a.js": "const = 1;\n", "b.js": formatted }); - expect(result.stderr).toContain("[error] a.js: SyntaxError:"); + expect(result.stderr).toContain("error: a.js: SyntaxError:"); expect(result.stderr).toContain("(1:7)"); expect(result.exitCode).toBe(2); }); @@ -562,79 +571,83 @@ describe.concurrent("bun format", () => { test("a syntax error says what is wrong and where, in every language", async () => { // One for each language and kind of refusal: the file, and what is said about it. const cases: [string, string, string][] = [ - ["string.css", 'a {\n b: "x\n}\n', "This string is not closed (2:6)"], - ["block.css", "a {\n b: c;\n", "This block is not closed (1:1)"], + ["string.css", 'a {\n b: "x\n}\n', "Unclosed string (2:6)"], + ["block.css", "a {\n b: c;\n", "Unclosed block (1:1)"], ["brace.css", "a {\n}\n}\n", 'Unexpected "}" (3:1)'], - ["comment.css", "a {\n /* b\n}\n", "This comment is not closed (2:3)"], - ["bracket.css", "a {\n b: c(1;\n}\n", "This bracket is not closed (2:7)"], - ["url.css", "a {\n b: url(c;\n}\n", "This bracket is not closed (2:9)"], - ["word.css", "a {\n b c;\n}\n", "This is neither a declaration nor a rule (2:3)"], + ["comment.css", "a {\n /* b\n}\n", "Unclosed comment (2:3)"], + ["bracket.css", "a {\n b: c(1;\n}\n", "Unclosed bracket (2:7)"], + ["url.css", "a {\n b: url(c;\n}\n", "Unclosed bracket (2:9)"], + ["word.css", "a {\n b c;\n}\n", "Expected a declaration or a rule (2:3)"], ["at.css", "@ a;\n", 'Expected a name after "@" (1:1)'], ["colon.css", "a {\n b: c: d;\n}\n", 'Unexpected ":" (2:7)'], ["first-colon.css", "a {\n : b;\n c: d:\n}\n", 'Unexpected ":" (3:7)'], - ["no-name.css", "a {\n (): ;\n}\n", "Expected the name of a property (2:3)"], + ["no-name.css", "a {\n (): ;\n}\n", "Expected a property name (2:3)"], ["two-words.css", "a {\n b c: d;\n}\n", 'Expected ":" (2:5)'], ["selector.css", "{\n a: b;\n}\n", "Expected a selector (1:1)"], - ["custom-selector.css", "@custom-selector a;\n", "The parameters of this at-rule cannot be read (1:1)"], - ["value.css", "a {\n b: c);\n}\n", "This value cannot be read (2:6)"], - ["interpolation.scss", "a {\n b: #{$c;\n", "This interpolation is not closed (2:6)"], - ["string.scss", 'a {\n b: "x;\n}\n', "This string is not closed (2:6)"], + ["custom-selector.css", "@custom-selector a;\n", "Invalid at-rule parameters (1:1)"], + ["value.css", "a {\n b: c);\n}\n", "Invalid value (2:6)"], + ["interpolation.scss", "a {\n b: #{$c;\n", "Unclosed interpolation (2:6)"], + ["string.scss", 'a {\n b: "x;\n}\n', "Unclosed string (2:6)"], ["nested.scss", "a {\n b: c: {\n d: e;\n }\n}\n", 'Unexpected ":" (2:7)'], - ["mixin.less", "a {\n .b(;\n}\n", "This bracket is not closed (2:5)"], - ["each.less", "each(@a, {\n", "This bracket is not closed (1:5)"], - ["word.less", "a {\n b c;\n}\n", "This is neither a declaration nor a rule (2:3)"], + ["mixin.less", "a {\n .b(;\n}\n", "Unclosed bracket (2:5)"], + ["each.less", "each(@a, {\n", "Unclosed bracket (1:5)"], + ["word.less", "a {\n b c;\n}\n", "Expected a declaration or a rule (2:3)"], ["end.graphql", "query {\n a\n", "Unexpected end of file (3:1)"], ["name.graphql", "query {\n}\n", "Expected a name (2:1)"], ["value.graphql", "query { a(b: ) }\n", "Expected a value (1:14)"], - ["string.graphql", 'query { a(b: "x) }\n', "This string is not closed (1:14)"], - ["block-string.graphql", '"""\na\n', "This string is not closed (1:1)"], + ["string.graphql", 'query { a(b: "x) }\n', "Unclosed string (1:14)"], + ["block-string.graphql", '"""\na\n', "Unclosed string (1:1)"], ["escape.graphql", 'query { a(b: "\\x") }\n', "Invalid escape sequence (1:15)"], ["definition.graphql", "foo A { b }\n", "Expected a definition (1:1)"], ["colon.graphql", "query ($a) { b }\n", 'Expected ":" (1:10)'], ["character.graphql", "query { a ? }\n", "Unexpected character (1:11)"], ["number.graphql", "query { a(b: 01) }\n", "Invalid number (1:14)"], ["digit.graphql", "query { a(b: 1.) }\n", "Expected a digit (1:16)"], - ["extension.graphql", "extend type A\n", "This extension adds nothing (1:14)"], - ["location.graphql", "directive @a on B\n", "Expected a place where a directive can be (1:17)"], + ["extension.graphql", "extend type A\n", "This extension is empty (1:14)"], + ["location.graphql", "directive @a on B\n", "Expected a directive location (1:17)"], ["brace.graphql", "fragment A on B\n@c d\n", 'Expected "{" (2:4)'], ["parenthesis.graphql", "query { a(b: 1 }\n", "Expected a name (1:16)"], ["bracket.graphql", "type A { b: [C }\n", 'Expected "]" (1:16)'], ["closing-brace.graphql", "schema { query: A ]\n", "Expected a name (1:19)"], - ["block.hbs", "
\n {{#if a}}\n
\n", "This block is not closed (2:3)"], + ["block.hbs", "
\n {{#if a}}\n
\n", "Unclosed block (2:3)"], ["mustache.hbs", "
\n {{a\n
\n", "Unexpected character (3:1)"], ["end.hbs", "{{a", "Unexpected end of file (1:4)"], - ["end-tag.hbs", "
\n

\n

\n", "This end tag does not close the element that is open (3:1)"], - ["name.hbs", "{{#if a}}b{{/each}}\n", "This is not the name of the block that is open (1:11)"], + ["end-tag.hbs", "
\n

\n

\n", "This end tag does not match the open element (3:1)"], + ["name.hbs", "{{#if a}}b{{/each}}\n", "This name does not match the open block (1:11)"], ["no-start.hbs", "\n", "This end tag has no start tag (1:1)"], ["hash.hbs", "{{a b=}}\n", "Unexpected token (1:6)"], - ["in-tag.hbs", "
\n", "A block can only be in an element or in another block (1:6)"], + ["in-tag.hbs", "
\n", "A block can only be inside an element or another block (1:6)"], ["partial.hbs", "{{> a}}\n", "Partials, decorators and raw blocks are not supported (1:1)"], - ["void.hbs", "\n", "This element has no end tag (1:8)"], - ["element.hbs", "{{#if a}}
{{/if}}\n", "This element is not closed (1:10)"], - ["comment.hbs", "{{!-- a\n", "This comment is not closed (1:1)"], - ["short-comment.hbs", "{{! a\n", "This comment is not closed (1:1)"], - ["string.hbs", '{{a "b}}\n', "This string is not closed (1:5)"], - ["segment.hbs", "{{a [b}}\n", "This bracket is not closed (1:5)"], + ["void.hbs", "\n", "This element cannot have an end tag (1:8)"], + ["element.hbs", "{{#if a}}
{{/if}}\n", "Unclosed element (1:10)"], + ["comment.hbs", "{{!-- a\n", "Unclosed comment (1:1)"], + ["short-comment.hbs", "{{! a\n", "Unclosed comment (1:1)"], + ["string.hbs", '{{a "b}}\n', "Unclosed string (1:5)"], + ["segment.hbs", "{{a [b}}\n", "Unclosed bracket (1:5)"], ["path.hbs", "{{a/../b}}\n", '"..", "." and "this" can only be at the start of a path (1:8)'], ["params.hbs", "{{#each a as ||}}{{/each}}\n", "Expected a name (1:15)"], - ["tag-params.hbs", "\n\n", "These block parameters cannot be read (1:9)"], + ["tag-params.hbs", "\n\n", "Invalid block parameters (1:9)"], ["attribute.hbs", '
\n', "An end tag cannot have attributes (1:12)"], - ["self-closing.hbs", "
\n", "An end tag cannot close itself (1:6)"], - ["unquoted.hbs", "
\n", "A value with a mustache and text in it needs quotes (1:8)"], - ["tag-name.hbs", "<{{a}}>\n", "A mustache cannot be here (1:2)"], + ["self-closing.hbs", "
\n", "An end tag cannot be self-closing (1:6)"], + [ + "unquoted.hbs", + "
\n", + "An attribute value that mixes text and a mustache needs quotes (1:8)", + ], + ["tag-name.hbs", "<{{a}}>\n", "A mustache is not allowed here (1:2)"], ["equals.hbs", "
\n", "Unexpected character (1:6)"], ["sexpr.hbs", "{{a (b}}\n", 'Expected ")" (1:7)'], - ["doctype.hbs", "\n", "This doctype cannot be read (1:20)"], + ["doctype.hbs", "\n", "Invalid doctype (1:20)"], ["close.hbs", "{{a b=c d}}\n", 'Expected "}}" (1:9)'], - ["mustache-comment.hbs", "
\n", "A comment cannot be here (1:8)"], - ["raw.hbs", "{{{{a}}}} b\n", "This block is not closed (1:10)"], - ["colon.hbs", "<:>\n", "This is not the name of an element (1:1)"], + ["mustache-comment.hbs", "
\n", "A comment is not allowed here (1:8)"], + ["raw.hbs", "{{{{a}}}} b\n", "Unclosed block (1:10)"], + ["colon.hbs", "<:>\n", "Invalid element name (1:1)"], ["mapping.yaml", "a: b: c\n", "Unexpected token (1:5)"], ["flow.yaml", "a: [1, 2\nb: 3\n", "Unexpected token (2:1)"], ["string.yaml", 'a: "x\n', "Unexpected character (2:1)"], ["single.yaml", "a: 'x\n", "Unexpected character (2:1)"], - ["tab.yaml", "\ta: 1\n", "A tab cannot be indentation (1:3)"], - ["anchors.yaml", "a: &x &y 1\n", "A node has one anchor and one tag at most (1:7)"], + ["tab.yaml", "\ta: 1\n", "Tabs cannot be used for indentation (1:3)"], + ["anchors.yaml", "a: &x &y 1\n", "A node can have at most one anchor and one tag (1:7)"], ["token.yaml", "- a\nb: 1\n", "Unexpected token (2:1)"], ["directive.yaml", "%YAML 1.2\na: 1\n", "Unexpected token (2:1)"], ["directive-end.yaml", "%YAML 1.2\n", "Unexpected token (2:1)"], @@ -648,7 +661,7 @@ describe.concurrent("bun format", () => { ["comma.yaml", "a: [b c: d e]\n[a b]\n", "Unexpected token (3:1)"], ["missing-comma.yaml", "[a: b c: d]\n", "Unexpected token (1:8)"], ["comment.yaml", 'a: "b"#c\n', "Unexpected character (1:7)"], - ["key.yaml", "[a\nb: c]\n", 'A key without "?" has to be on one line (2:2)'], + ["key.yaml", "[a\nb: c]\n", 'A key without "?" must be on one line (2:2)'], ["colon.yaml", "a: 1\nb\n", "Unexpected token (3:1)"], ["indent.yaml", "a:\n - b\n - c\n", "Unexpected token (3:2)"], ["block-in-flow.yaml", "[a: |\n b\n]\n", "Unexpected token (1:5)"], @@ -661,17 +674,17 @@ describe.concurrent("bun format", () => { ["set.yaml", "{!!set}\n", "This cannot be formatted (1:2)"], ["token.json", '{"a": }\n', "Unexpected token (1:7)"], ["end.json", '{"a": 1\n', "Unexpected end of file (2:1)"], - ["string.json", '{"a": "x\n}\n', "This string is not closed (1:7)"], + ["string.json", '{"a": "x\n}\n', "Unclosed string (1:7)"], ["escape.json", '{"a": "\\x"}\n', "Invalid escape sequence (1:7)"], - ["comment.json", '{"a": 1} /* b\n', "This comment is not closed (1:10)"], - ["more.json", '{"a": 1} 2\n', "Expected the end of the file (1:10)"], + ["comment.json", '{"a": 1} /* b\n', "Unclosed comment (1:10)"], + ["more.json", '{"a": 1} 2\n', "Expected end of file (1:10)"], ["only-comments.json", "// a\n", "Unexpected end of file (2:1)"], ["word.json", "nul\n", "Unexpected token (1:1)"], ["sign-before-a-string.json", "+''\n", "Unexpected token (1:2)"], ["sign-before-a-word.json5", "-null\n", "Unexpected token (1:2)"], ]; const result = await format(Object.fromEntries(cases.map(([name, text]) => [name, text])), []); - const said = [...result.stderr.matchAll(/^\[error\] ([^:\n]+): SyntaxError: (.+)$/gm)].map(it => [it[1], it[2]]); + const said = [...result.stderr.matchAll(/^error: ([^:\n]+): SyntaxError: (.+)$/gm)].map(it => [it[1], it[2]]); expect(Object.fromEntries(said)).toEqual(Object.fromEntries(cases.map(([name, , message]) => [name, message]))); expect(result.exitCode).toBe(2); }); @@ -686,38 +699,38 @@ describe.concurrent("bun format", () => { }; const result = await format(files, []); expect(result.stderr).toMatchInlineSnapshot(` - "[error] a.css: SyntaxError: This string is not closed (2:16) - [error] 1 | a { - [error] > 2 | /* é😀 */ b: "x - [error] | ^ - [error] 3 | } - [error] 4 | - [error] b.css: SyntaxError: This string is not closed (7:6) - [error] 5 | - [error] 6 | d { - [error] > 7 | e: "f - [error] | ^ - [error] 8 | } - [error] 9 | - [error] 10 | g { - [error] c.js: SyntaxError: Expression expected. (1:11) - [error] > 1 | const a = ; - [error] | ^ - [error] 2 | - [error] d.yaml: SyntaxError: A tab cannot be indentation (2:2) - [error] 1 | a: - [error] > 2 | - b - [error] | ^ - [error] 3 | + "error: a.css: SyntaxError: Unclosed string (2:16) + 1 | a { + > 2 | /* é😀 */ b: "x + | ^ + 3 | } + 4 | + error: b.css: SyntaxError: Unclosed string (7:6) + 5 | + 6 | d { + > 7 | e: "f + | ^ + 8 | } + 9 | + 10 | g { + error: c.js: SyntaxError: Expression expected. (1:11) + > 1 | const a = ; + | ^ + 2 | + error: d.yaml: SyntaxError: Tabs cannot be used for indentation (2:2) + 1 | a: + > 2 | - b + | ^ + 3 | Formatted 0 files, 0 unchanged" `); expect(result.exitCode).toBe(2); const stdin = await format({}, ["--stdin-filepath", "a.css"], { stdin: "a {\n" }); expect(stdin.stderr).toMatchInlineSnapshot(` - "[error] a.css: SyntaxError: This block is not closed (1:1) - [error] > 1 | a { - [error] | ^ - [error] 2 |" + "error: a.css: SyntaxError: Unclosed block (1:1) + > 1 | a { + | ^ + 2 |" `); expect(stdin.exitCode).toBe(2); }); @@ -739,15 +752,47 @@ describe.concurrent("bun format", () => { } }); - test("a file is left as it is if what would be written is another program", async () => { - // Like Prettier 3.9.9, the formatter writes no `;` before the `(` here, which makes `c(..)` of the two statements. - const text = "a ? b : c;\n(d ? e : f) ? g : h;\n"; - const result = await format({ "a.js": text, "b.js": ugly }, ["--no-semi", "--experimental-ternaries"], { - reads: ["a.js"], + describe("without semicolons, a statement that would go on with the line before it starts with one", () => { + // Prettier 3.9.9 writes none before these, which makes `c(..)` of two statements, and `let c` of two others. + test.each([ + [ + "the parentheses of --experimental-ternaries around a condition", + ["--experimental-ternaries"], + "a ? b : c;\n(d ? e : f) ? g : h;\n", + "a ? b : c\n;(\n d ? e : f\n) ?\n g\n: h\n", + "a ? b : c\n(\n d ? e : f\n) ?\n g\n: h\n", + ], + [ + "the same around a condition that does not fit on the line", + ["--experimental-ternaries", "--print-width=20"], + "a;\nbbbbbbbbbb && cccccccccc ? d : e;\nf && g ? h : i;\n", + "a\n;(\n bbbbbbbbbb &&\n cccccccccc\n) ?\n d\n: e\nf && g ? h : i\n", + "a\n(\n bbbbbbbbbb &&\n cccccccccc\n) ?\n d\n: e\nf && g ? h : i\n", + ], + ["a variable that is called let", [], "a;\nlet;\nc;\n", "a\n;(let)\nc\n", "a\nlet\nc\n"], + ])("%s", async (_, flags, text, right, ofPrettier) => { + expect(right).not.toBe(ofPrettier); + const result = await format({ "a.js": text }, ["--no-semi", ...flags], { reads: ["a.js"] }); + expect(result.files).toEqual({ "a.js": right }); + expect(result.exitCode).toBe(0); + const checked = await format({ "a.js": text, "b.js": right }, ["--no-semi", ...flags, "--debug-check"]); + expect(checked.stderr).toBe(""); + expect(checked.stdout.split("\n")).toEqual(["a.js", "b.js"]); + expect(checked.exitCode).toBe(0); + expect((await format({ "a.js": right }, ["--no-semi", ...flags, "--check"])).exitCode).toBe(0); }); - 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); + }); + + test("in TypeScript no line ends with what looks like type arguments", async () => { + // Prettier 3.9.9 prints `a <\n b >\n c;`, which is `a; c;`. + const files = { "a.ts": "a < b > c;\n", "b.js": "a < b > c;\n" }; + const flags = ["--print-width=5"]; + const result = await format(files, flags, { reads: ["a.ts", "b.js"] }); + expect(result.files).toEqual({ "a.ts": "a <\n b > c;\n", "b.js": "a <\n b >\n c;\n" }); + expect(result.exitCode).toBe(0); + const checked = await format(files, [...flags, "--debug-check"]); + expect(checked.stderr).toBe(""); + expect(checked.exitCode).toBe(0); }); test("Handlebars", async () => { @@ -759,99 +804,357 @@ describe.concurrent("bun format", () => { expect(result.exitCode).toBe(0); }); - describe("a template that Prettier damages is left as it is", () => { - // What Prettier 3.9.9 prints for each of these says something else, or cannot be parsed any more. - const damaged = { - "doctype": "\n

\n", - "character-behind-less-than": "a <3 b >
\n", - "start-of-a-comment": "

\n", - "mustache-where-a-comment-starts": "

\n", - "open-tag-at-the-end": "

\n", - "escaped-mustache-in-a-name": "\n", - "escaped-mustache-in-pre": "
\\{{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", - "this-and-a-slash": "{{this/a }}\n", - "this-behind-an-at": "{{@this.a }}\n", - "this-in-brackets": "{{[this] }}\n", - "empty-brackets": "{{[].a }}\n", - "arguments-of-a-literal": '{{"a" b}}\n', - "hash-that-is-called": "{{(a=b) c}}\n", - "raw-block": "{{{{a}}}} {{b}} {{{{/a}}}}\n", - "inverted-block": "{{^a }}b{{else}}c{{/a}}\n", - "key-in-brackets": "{{a [b c]=d}}\n", - "literal-in-brackets": "{{a [true]}}\n", - "number-in-brackets": "{{a [-1]}}\n", - "else-in-brackets": "{{[else] }}\n", - "argument-in-brackets": "{{a [@b]}}\n", - "bracket-in-brackets": "{{a [b\\]]}}\n", - "line-break-in-brackets": "{{a [b\nc]}}\n", - "block-parameter-in-brackets": "{{#a as |[b] c|}}{{/a}}\n", - "private-name": "{{a.#b }}\n", - "number-with-an-exponent": "{{a 1000000000000000000000}}\n", - "backslash-at-the-end-of-a-string": '{{a "b\\"}}\n', - "tilde-in-three-braces": "{{{a }~}}\n", - "three-braces-around-a-modifier": "\n", - "tilde-at-the-end-of-a-comment": "{{!-- a ~--}}\n", - "dashes-at-the-start-of-a-comment": "{{!---- a --}}\n", - "rest-of-the-name-behind-else": "{{#a }}{{else if.b c}}{{/a}}\n", - "tilde-of-an-if-that-is-all-that-follows-else": "{{#a }}{{else}}{{~#if b}}c{{/if}}{{/a}}\n", - "line-separator-behind-a-line-break": "a\n\u2028{{b }}\n", - "indented-mark-of-a-document-in-front-matter": "---\n ---\na: b\n---\n

\n", + describe("a template of which Prettier changes the meaning", () => { + // The template, what Bun makes of it, and what Prettier 3.9.9 makes of it, which says something else or cannot be parsed + // any more. Where nothing else is clearly right, the smallest part around the place stays as it is written. + const templates: Record = { + "doctype": ["\n

\n", "\n

", "

"], + "character-behind-less-than": ["a <3 b >\n", "a <3 b >\n", "a "], + "start-of-a-comment": ["

\n", "

", "

"], + "mustache-where-a-comment-starts": ["

\n", "

", "{{a}}

"], + "open-tag-at-the-end": ["

"], + "escaped-mustache-in-a-comment": [ + "

\n", + "

", + "

", + ], + "escaped-mustache-in-a-name": ["\n", "", ""], + "escaped-mustache-in-pre": ["
\\{{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\n", "a", ""], + "this-and-a-slash": ["{{this/a }}\n", "{{this/a}}", "{{a}}"], + "this-behind-an-at": ["{{@this.a }}\n", "{{@this.a}}", "{{@a}}"], + "this-in-brackets": ["{{[this] }}\n", "{{[this]}}", "{{this.this}}"], + "empty-brackets": ["{{[].a }}\n", "{{[].a}}", "{{a}}"], + "arguments-of-a-literal": ['{{"a" b}}\n', '{{"a" b}}', '{{"a"}}'], + "hash-that-is-called": ["{{(a=b) c}}\n", "{{(a=b) c}}", "{{ c}}"], + "raw-block": ["{{{{a}}}} {{b}} {{{{/a}}}}\n", "{{{{a}}}} {{b}} {{{{/a}}}}", "{{#a}} \\{{b}} {{/a}}"], + "key-in-brackets": ["{{a [b c]=d}}\n", "{{a [b c]=d}}", "{{a b c=d}}"], + "literal-in-brackets": ["{{a [true]}}\n", "{{a [true]}}", "{{a true}}"], + "number-in-brackets": ["{{a [-1]}}\n", "{{a [-1]}}", "{{a -1}}"], + "else-in-brackets": ["{{[else] }}\n", "{{[else]}}", "{{else}}"], + "argument-in-brackets": ["{{a [@b]}}\n", "{{a [@b]}}", "{{a @b}}"], + "bracket-in-brackets": ["{{a [b\\]]}}\n", "{{a [b\\]]}}", "{{a [b]]}}"], + "line-break-in-brackets": ["{{a [b\nc]}}\n", "{{a [b\nc]}}", "{{a [[b\nc]]}}"], + "private-name": ["{{a.#b }}\n", "{{a.#b}}", "{{a.[#b]}}"], + "number-with-an-exponent": ["{{a 1000000000000000000000}}\n", "{{a 1000000000000000000000}}", "{{a 1e+21}}"], + "tilde-in-three-braces": ["{{{a }~}}\n", "{{{a}~}}", "{{{a~}}}"], + "three-braces-around-a-modifier": ["\n", "", ""], + "tilde-at-the-end-of-a-comment": ["{{!-- a ~--}}\n", "{{!-- a ~--}}", "{{! a ~}}"], + "dashes-at-the-start-of-a-comment": ["{{!---- a --}}\n", "{{!---- a --}}", "{{!-- a }}"], + "rest-of-the-name-behind-else": [ + "{{#a }}{{else if.b c}}{{/a}}\n", + "{{#a}}{{else if.b c}}{{/a}}", + "{{#a}}{{else if c}}{{/a}}", + ], + "tilde-of-an-if-that-is-all-that-follows-else": [ + "{{#a }}{{else}}{{~#if b}}c{{/if}}{{/a}}\n", + "{{#a}}{{else}}{{~#if b}}c{{/if}}{{/a}}", + "{{#a}}{{else if b}}c{{/a}}", + ], + "indented-mark-of-a-document-in-front-matter": [ + "---\n ---\na: b\n---\n

\n", + "---\n ---\na: b\n---\n\n

", + "---\n---\na: b\n---\n\n

", + ], + "no-break-space-that-is-all-of-the-template": ["\u00a0\n", "\u00a0", ""], + "white-space-that-is-all-of-a-block-in-a-textarea": [ + "\n", + "", + "", + ], + "white-space-that-is-all-of-a-block-in-pre": [ + "
{{#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: Record = { + "inverted-block": ["{{^a }}b{{else}}c{{/a}}\n", "{{#a}}c{{else}}b{{/a}}"], + "block-parameter-in-brackets": ["{{#a as |[b] c|}}{{/a}}\n", "{{#a as |[b] c|}}{{/a}}"], + "backslash-at-the-end-of-a-string": ['{{a "b\\"}}\n', '{{a "b\\"}}'], + "line-separator-behind-a-line-break": ["a\n\u2028{{b }}\n", "a \u2028{{b}}"], + }; + const all = Object.entries({ ...templates, ...harmless }); + const files = Object.fromEntries(all.map(([name, it]) => [`${name}.hbs`, it[0]])); + const written = Object.fromEntries(all.map(([name, it]) => [`${name}.hbs`, it[1]])); + + test("says the same afterwards", async () => { + for (const [, after, ofPrettier] of Object.values(templates)) expect(after).not.toBe(ofPrettier); + const result = await format(files, [], { reads: Object.keys(files) }); + expect(result.files).toEqual(written); + expect(result.exitCode).toBe(0); + }); - test.each([[[] as string[]], [["--check"]], [["-l"]]])("%j", async args => { - const result = await format({ ...files, "fine.hbs": "{{a }}" }, args, { - reads: [...Object.keys(files), "fine.hbs"], - }); - expect(result.files).toEqual({ ...files, "fine.hbs": args.length > 0 ? "{{a }}" : "{{a}}" }); - expect(errorsIn(result.stderr)).toEqual(errors); - expect(result.exitCode).toBe(2); + test.each([ + ["no config", {}], + [".prettierrc", { ".prettierrc": "{}\n" }], + [".oxfmtrc.json", { ".oxfmtrc.json": "{}\n" }], + ])("with %s", async (_, config: Record) => { + const result = await format({ ...config, ...files }, Object.keys(files), { reads: Object.keys(files) }); + expect(result.files).toEqual(written); + expect(result.exitCode).toBe(0); + }); + + test("--debug-check has nothing to say about it", async () => { + const result = await format(files, ["--debug-check"], { reads: Object.keys(files) }); + expect(result.files).toEqual(files); + expect(result.stderr).toBe(""); + expect(result.stdout.split("\n").sort()).toEqual(Object.keys(files).sort()); + expect(result.exitCode).toBe(0); + }); + + test("what is written is formatted", async () => { + expect(await different(written, [])).toEqual([]); + }); + + test("where white space does not count, a word and an escaped mustache stay two words", async () => { + // Prettier 3.9.9 prints `p\\{{a}} {{q}}`. + const flags = ["--html-whitespace-sensitivity=ignore"]; + const result = await format({ "a.hbs": "p \\{{a}} {{q }}\n" }, flags, { reads: ["a.hbs"] }); + expect(result.files).toEqual({ "a.hbs": "p \\{{a}} {{q}}" }); + expect(result.exitCode).toBe(0); }); test("standard input", async () => { - const result = await format({}, ["--stdin-filepath", "a.hbs"], { stdin: damaged.doctype }); - expect(result.raw).toBe(""); - expect(errorsIn(result.stderr)).toEqual([ - "[error] a.hbs: formatting it the way Prettier does would change what it means. It is left as it is.", - ]); - expect(result.exitCode).toBe(2); + const [before, after] = templates.doctype; + const result = await format({}, ["--stdin-filepath", "a.hbs"], { stdin: before }); + expect(result.raw).toBe(after); + expect(result.exitCode).toBe(0); }); - test("in Markdown, only the block of code is", async () => { + test("in a block of code in Markdown", async () => { const text = "# a\n\n```hbs\n\n

\n```\n\n```hbs\n

\n```\n"; const result = await format({ "a.md": text }, [], { reads: ["a.md"] }); expect(result.files).toEqual({ - "a.md": "# a\n\n```hbs\n\n

\n```\n\n```hbs\n

\n```\n", + "a.md": "# a\n\n```hbs\n\n

\n```\n\n```hbs\n

\n```\n", }); expect(result.exitCode).toBe(0); }); }); + describe("Markdown of which Prettier changes the meaning", () => { + // The text, what Bun makes of it, and what Prettier 3.9.9 makes of it: it gives two emphases that touch the same marker, + // which makes one of them, or none. + test.each([ + ["x *a*_b_\n", "x *a*_b_\n", "x _a__b_\n"], + ["x _a_*b*\n", "x _a_*b*\n", "x _a__b_\n"], + ["x **a**__b__\n", "x **a**__b__\n", "x **a****b**\n"], + ["x ***a******b***\n", "x ***a******b***\n", "x _**a**__**b**_\n"], + ["- *a*_b_*c*\n", "- *a*_b_*c*\n", "- _a__b__c_\n"], + ])("%j", async (before, after, ofPrettier) => { + expect(after).not.toBe(ofPrettier); + const result = await format({ "a.md": before, "b.md": after }, [], { reads: ["a.md", "b.md"] }); + expect(result.files).toEqual({ "a.md": after, "b.md": after }); + expect(result.exitCode).toBe(0); + }); + + // Known: Bun prints what Prettier 3.9.9 prints for these, and a CommonMark parser reads that as something else. All are + // emphasis three deep, or runs of `*` and `_` that are text in an emphasis. Telling them from `*foo_bar*`, of which + // `_foo_bar_` is right, takes the rules for delimiter runs. + test.each([ + ["**a****a**\n", "**a\\****a**\n"], + ["*b **a*b*** c*\n", "*b \\**a*b*** c*\n"], + ["*b _a_*b* c*\n", "*b *a**b* c*\n"], + ["*b _*a*_ c*\n", "*b _*a*\\_ c*\n"], + ["*b *_a_* c*\n", "_b **a** c_\n"], + ["**b __**a**__ c**\n", "**b __**a**\\__ c**\n"], + ["**__a__****__a__**\n", "****a********a****\n"], + ["*b a__b__c c*\n", "_b a__b_\\_c c_\n"], + ["*b __a__b c*\n", "_b \\__a_\\_b c_\n"], + ["**b __a__b c**\n", "**b \\__a_\\_b c**\n"], + ["*b **(a)**b c*\n", "*b **(a)*_b c_\n"], + ["*b _\u4e2d_a c*\n", "_b _\u4e2d_a c_\n"], + ["*b a_\u4e2d_ c*\n", "_b a_\u4e2d_ c_\n"], + ])("known: %j", async (before, ofPrettier) => { + const result = await format({ "a.md": before }, [], { reads: ["a.md"] }); + expect(result.files).toEqual({ "a.md": ofPrettier }); + }); + }); + + test("a folded scalar of YAML that keeps its line breaks has as many with --prose-wrap=never", async () => { + // Prettier 3.9.9 prints `k: >+\n a\n \n \n\nl: m\n`, which has one more. + const result = await format({ "a.yaml": "k: >+\n a\n\n\nl: m\n" }, ["--prose-wrap=never"], { + reads: ["a.yaml"], + }); + expect(result.files).toEqual({ "a.yaml": "k: >+\n a\n\n\nl: m\n" }); + expect(result.exitCode).toBe(0); + }); + + describe("an escaped blank at the end of the parameters of an at-rule", () => { + // Prettier 3.9.9 prints `@extend .a\\;`: the backslash escapes the `;`. + const names = ["extend", "include", "media", "supports", "container", "layer", "import", "custom"]; + test.each(["a.css", "a.scss", "a.less"])("%s", async file => { + const before = names.map(name => `x{@${name} .a\\ ;}\n@${name} .a\\ ;\n`).join("") + "@last .a\\ "; + const after = names.map(name => `x {\n @${name} .a\\ ;\n}\n@${name} .a\\ ;\n`).join("") + "@last .a\\ ;\n"; + const result = await format({ [file]: before }, [], { reads: [file] }); + expect(result.files).toEqual({ [file]: after }); + expect(result.exitCode).toBe(0); + expect(await different({ [file]: after }, [])).toEqual([]); + }); + + test("before a parenthesis, and not twice where Prettier has one", async () => { + // Prettier 3.9.9 prints `@media (a\\) {`. What it prints for the second is right. + const files = { "a.scss": "@media (a\\ ){b{c:d}}\n@each .a\\ ;\n" }; + const result = await format(files, [], { reads: ["a.scss"] }); + expect(result.files).toEqual({ "a.scss": "@media (a\\ ) {\n b {\n c: d;\n }\n}\n@each .a\\ ;\n" }); + expect(result.exitCode).toBe(0); + }); + }); + + describe("a selector without a namespace", () => { + // The selector, what Bun makes of it, and what Prettier 3.9.9 makes of it. + test.each([ + ["x |b", "x |b", "x|b"], + ["x,|b,y", "x,\n|b,\ny", "x,\n,|b,\ny"], + ])("%j", async (before, after, ofPrettier) => { + expect(after).not.toBe(ofPrettier); + const written = `${after} {\n c: d;\n}\n`; + const result = await format({ "a.css": `${before}{c:d}\n`, "b.css": written }, [], { reads: ["a.css", "b.css"] }); + expect(result.files).toEqual({ "a.css": written, "b.css": written }); + expect(result.exitCode).toBe(0); + }); + }); + + describe("a backslash in a value of a style sheet", () => { + // The value, what Bun makes of it, and what Prettier 3.9.9 makes of it: it writes nothing behind a word with a backslash. + test.each([ + ["Font\\ Awesome\\ 5", "Font\\ Awesome\\ 5", "Font\\Awesome\\5"], + ["a\\b c", "a\\b c", "a\\bc"], + ["d\\ e", "d\\ e", "d\\e"], + ["d\\ -5", "d\\ -5", "d\\-5"], + ["d\\ ", "d\\ ", "d\\"], + ["(a\\ )", "(a\\ )", "(a\\)"], + ["f(b a\\ )", "f(b a\\ )", "f(b a\\)"], + ["f(a\\ , b)", "f(a\\ , b)", "f(a\\, b)"], + ["a\\ , b", "a\\ , b", "a\\, b"], + // What the backslash escapes here is no blank, and Prettier is right. + ["\\@a 1\\/3", "\\@a 1\\/3", null], + ["d\\\\ e", "d\\\\ e", "d\\\\e"], + ["x d\\e f g", "x d\\e f g", "x d\\ef g"], + ])("%j", async (before, after, ofPrettier) => { + expect(after).not.toBe(ofPrettier); + const files = Object.fromEntries(["a.css", "b.scss", "c.less"].map(name => [name, `a{b:${before}}\n`])); + const result = await format(files, [], { reads: Object.keys(files) }); + const written = `a {\n b: ${after};\n}\n`; + expect(result.files).toEqual({ "a.css": written, "b.scss": written, "c.less": written }); + expect(result.exitCode).toBe(0); + expect(await different({ "a.css": written }, [])).toEqual([]); + }); + }); + + describe("YAML of which Prettier changes a value", () => { + // The text, what Bun makes of it, and what Prettier 3.9.9 makes of it: a key with one line break more, a scalar that has + // become a marker, a byte order mark that was the start of a key, and a key that cannot be read. + test.each([ + ["? |+\n a\n\n: m\n", "? |+\n a\n\n: m\n", "? |+\n a\n\n\n: m\n"], + ["k: l\n? >+\n a\n\n", "k: l\n? >+\n a\n\n", "k: l\n? >+\n a\n\n\n"], + ["--- ---\n", "--- ---\n", "---\n---\n"], + ["--- ... x\n", "--- ... x\n", "---\n... x\n"], + ["? \ufeffa\n: m\n", "? \ufeffa\n: m\n", "\ufeffa: m\n"], + ["? {a\n b: c}\n: m\n", "? { ? a\n b\n : c }\n: m\n", "{ ? a\n b\n : c }: m\n"], + ])("%j", async (before, after, ofPrettier) => { + expect(after).not.toBe(ofPrettier); + expect(Bun.YAML.parse(after)).toEqual(Bun.YAML.parse(before)); + const result = await format({ "a.yaml": before, "b.yaml": after }, [], { reads: ["a.yaml", "b.yaml"] }); + expect(result.files).toEqual({ "a.yaml": after, "b.yaml": after }); + expect(result.exitCode).toBe(0); + }); + }); + + describe("front matter that would end early", () => { + // Prettier 3.9.9 formats the YAML, which puts the second `---` at the start of its line: there the front matter ends. + const frontMatter = "---\n ---\na: b\n---\n"; + test.each([ + ["a.md", "\n# c\n", "\n# c\n"], + ["a.mdx", "\n# c\n", "\n# c\n"], + ["a.css", "a{b:c}\n", "\na {\n b: c;\n}\n"], + ["a.scss", "a{b:c}\n", "\na {\n b: c;\n}\n"], + ["a.html", "

c

\n", "\n

c

\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

\n', @@ -961,52 +1264,150 @@ describe.concurrent("bun format", () => { reads: ["a.html", "b.html"], }); expect(result.files).toEqual({ "a.html": "
\n", "b.html": "

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

\n
a\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\n

c

\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\n

c

\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\n

c

\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`. - 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`;\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", "\n

a\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", "//
\nconst a = b;\n", "//
\nconst a = b;\n"], + ["babel-ts", "a = x\n", "a = x;\n"], + ["babel-ts", "a =
x
\n", 'a =
x
;\n'], + ])("%s takes both JSX and type assertions: %j", async (parser, stdin, expected) => { + const result = await format(prettier, ["--parser", parser], { stdin }); + expect(result).toMatchObject({ raw: expected, stderr: "", exitCode: 0 }); + }); + + test.each([ + ["const a =
x
\nlet b: = 1\n", "(2:8)"], + ["const a = x\nlet b: = 1\n", "(2:8)"], + ])("typescript reports the error of the syntax the code looks like: %j", async (stdin, position) => { + const result = await format(prettier, ["--parser", "typescript"], { stdin }); + expect(result.stderr.split("\n")[0]).toBe(`[error] stdin: SyntaxError: Type expected. ${position}`); + expect(result.exitCode).toBe(2); + }); + + test("prints nothing for empty input", async () => { + // `stdin: "ignore"`, like `< /dev/null`. + const results = await Promise.all([ + format(files, ["--parser", "babel"], { reads }), + pipe(files, [], { stdin: "" }), + ]); + for (const result of results) expect(result).toMatchObject({ raw: "", stderr: "", exitCode: 0 }); + expect(results[0].files).toEqual(files); + }); + + test("uses the config of the working directory", async () => { + const nested = { ...noSemi, "sub/.prettierrc": `{ "tabWidth": 8 }\n`, "sub/dir/a.txt": "", "other/a.txt": "" }; + const [here, below, nearer, off, named] = await Promise.all([ + pipe(noSemi), + pipe(nested, [], { cwd: "other" }), + pipe(nested, [], { cwd: "sub/dir" }), + pipe(noSemi, ["--no-config"]), + pipe({ ...prettier, "x.json": `{ "semi": false }` }, ["--config", "x.json"]), + ]); + expect(here.raw).toBe(withoutSemi); + expect(below.raw).toBe(withoutSemi); + expect(nearer.raw).toBe(printed.replace(" return", " return")); + expect(off.raw).toBe(printed); + expect(named.raw).toBe(withoutSemi); + }); + + test.each([ + [ + "overrides in .prettierrc", + { ".prettierrc": `{ "overrides": [{ "files": $, "options": { "singleQuote": true } }] }` }, + ], + ["overrides in --config", { "x.json": `{ "overrides": [{ "files": $, "options": { "singleQuote": true } }] }` }], + [ + "overrides in .oxfmtrc.json", + { ".oxfmtrc.json": `{ "overrides": [{ "files": $, "options": { "singleQuote": true } }] }` }, + ], + ["[[fmt.overrides]]", { "bunfig.toml": `[[fmt.overrides]]\nfiles = $\noptions = { singleQuote = true }\n` }], + ])("there's no file name for %s to match", async (_, project) => { + const [[name, text]] = Object.entries(project); + const globs = `["*", "**", "**/*", "*.js", "stdin", "(stdin)"]`; + const flags = name === "x.json" ? ["--config", name] : []; + const result = await pipe({ [name]: text.replace("$", globs) }, flags); + expect(result).toMatchObject({ raw: printed, stderr: "", exitCode: 0 }); + // The same override does match a name. + const control = await format({ [name]: text.replace("$", globs) }, [...flags, "--stdin-filepath", "a.js"], { + stdin: js, + }); + expect(control.raw).toBe(printed.replaceAll('"', "'")); + }); + + test.each([ + ["no config", {}], + ["a Prettier config", prettier], + ])(".editorconfig doesn't apply: %s", async (_, config) => { + const project = { ...config, ".editorconfig": "root = true\n[*]\nindent_style = tab\n" }; + expect((await pipe(project)).raw).toBe(printed); + const control = await format(project, ["--stdin-filepath", "a.js"], { stdin: js }); + expect(control.raw).toBe(printed.replace(" return", "\treturn")); + }); + + test("ignore files don't apply", async () => { + const everything = "*\n**\nstdin\n"; + const results = await Promise.all([ + pipe({ ...prettier, ".prettierignore": everything }), + pipe({ ...prettier, ".gitignore": everything }), + pipe({ ...prettier, ig: everything }, ["--ignore-path", "ig"]), + pipe(prettier, ["--ignore-path", "missing"]), + pipe({ ...prettier, "node_modules/x/a.txt": "" }, [], { cwd: "node_modules/x" }), + pipe({}, ["--ignore-pattern", "*"]), + ]); + for (const result of results) expect(result).toMatchObject({ raw: printed, stderr: "", exitCode: 0 }); + }); + + test.each([ + [["--check"], js, "(stdin)\n", 1], + [["--list-different"], js, "(stdin)\n", 1], + [["--check"], printed, "", 0], + [["--list-different"], printed, "", 0], + [["--write"], js, printed, 0], + [["--check", "--write"], js, "", 0], + [["--list-different", "--write"], js, "", 0], + [["--log-level", "silent"], js, printed, 0], + [["--log-level", "warn", "--check"], js, "", 1], + ])("%j", async (flags, stdin, raw, exitCode) => { + for (const config of [{}, prettier]) { + const result = await pipe({ ...files, ...config }, flags, { stdin, reads }); + expect(result).toMatchObject({ raw, stderr: "", exitCode, files }); + } + }); + + test.each<[Record, string[], string, number]>([ + [prettier, [], "[error] stdin: SyntaxError: ", 2], + [prettier, ["--write"], "[error] stdin: SyntaxError: ", 2], + [prettier, ["--check"], "[error] ", 0], + [prettier, ["--list-different"], "[error] ", 0], + [{}, [], "error: stdin: SyntaxError: ", 2], + [{ ".oxfmtrc.json": "{}" }, [], "[error] stdin: SyntaxError: ", 2], + ])("a syntax error: %j %j", async (config, flags, before, exitCode) => { + const result = await format(config, ["--parser", "typescript", ...flags], { stdin: "let a: = 1;\n" }); + const margin = before.startsWith("[") ? "[error] " : ""; + const why = ".oxfmtrc.json" in config ? "Unexpected token" : "Type expected."; + expect(result.raw).toBe(""); + expect(result.stderr.split("\n")).toEqual([ + `${before}${why} (1:8)`, + `${margin}> 1 | let a: = 1;`, + `${margin} | ^`, + `${margin} 2 |`, + ]); + expect(result.exitCode).toBe(exitCode); + }); + + test.each<[Record, string[], string, number]>([ + [prettier, [], "[error] ", 1], + [prettier, ["--check"], "[error] ", 0], + [prettier, ["--list-different"], "[error] ", 0], + [prettier, ["--ignore-unknown"], "[error] ", 1], + [{}, [], "error: ", 2], + [{}, ["--check"], "error: ", 2], + ])("a parser nobody has: %j %j", async (config, flags, before, exitCode) => { + const result = await format({ ...files, ...config }, ["--parser", "nonsense", ...flags], { stdin: js, reads }); + expect(result).toMatchObject({ + raw: "", + stderr: `${before}Couldn't resolve parser "nonsense".`, + exitCode, + files, + }); + }); + + test("--config-precedence", async () => { + const none = "[error] No parser and no file path given, couldn't infer a parser."; + const prefer = ["--config-precedence", "prefer-file"]; + const override = ["--config-precedence", "file-override"]; + const json = { ".prettierrc": `{ "parser": "json" }` }; + expect(await pipe(noSemi, ["--semi", ...override])).toMatchObject({ raw: withoutSemi, exitCode: 0 }); + expect(await pipe(noSemi, ["--semi"])).toMatchObject({ raw: printed, exitCode: 0 }); + expect(await pipe(json, override, { stdin: `{"a":1}` })).toMatchObject({ raw: `{ "a": 1 }\n`, exitCode: 0 }); + expect(await pipe(json, prefer, { stdin: `{"a":1}` })).toMatchObject({ raw: `{ "a": 1 }\n`, exitCode: 0 }); + // The config file wins, and it has no parser. + expect(await pipe(noSemi, prefer)).toMatchObject({ raw: "", stderr: none, exitCode: 2 }); + expect(await pipe(noSemi, [...prefer, "--check"])).toMatchObject({ raw: "", stderr: none, exitCode: 0 }); + expect(await pipe(noSemi, [...prefer, "--ignore-unknown"])).toMatchObject({ raw: "", stderr: "", exitCode: 0 }); + }); + + test.each([ + [["--cursor-offset", "13"], js, printed, "14"], + [["--cursor-offset", "0"], js, printed, "0"], + [ + ["--range-start", "10", "--range-end", "20"], + "a = 1;\nb = 2;\nc = 3;\n", + "a = 1;\nb = 2;\nc = 3;\n", + "", + ], + [["--range-start", "10", "--cursor-offset", "3"], "a = 1;\nb = 2;\n", "a = 1;\nb = 2;\n", "3"], + [["--insert-pragma"], "a\n", "/** @format */\n\na;\n", ""], + [["--require-pragma"], js, js, ""], + [["--require-pragma"], "/** @format */\n" + js, "/** @format */\n" + printed, ""], + [["--check-ignore-pragma"], "/** @noformat */\n" + js, "/** @noformat */\n" + js, ""], + [["--single-quote", "--use-tabs"], js, "function f() {\n\treturn 'a';\n}\n", ""], + [["--end-of-line", "auto"], "a = 1;\r\nb;\r\n", "a = 1;\r\nb;\r\n", ""], + ])("%j", async (flags, stdin, raw, stderr) => { + expect(await pipe(prettier, flags, { stdin })).toMatchObject({ raw, stderr, exitCode: 0 }); + }); + + test("an empty --stdin-filepath is no file name either", async () => { + expect(await pipe(noSemi, ["--stdin-filepath", ""])).toMatchObject({ raw: withoutSemi, stderr: "", exitCode: 0 }); + }); + + test.each([[["--parser", "babel"]], [["--stdin-filepath", "a.js"]]])( + "warns about an unknown option in the config: %j", + async args => { + const result = await format({ ".prettierrc": `{ "nope": 1 }` }, args, { stdin: js }); + expect(result).toMatchObject({ + raw: printed, + stderr: "[warn] Ignored unknown option { nope: 1 }.", + exitCode: 0, + }); + }, + ); + + test("with an oxfmt config, uses its options", async () => { + const project = { + ".oxfmtrc.json": `{ "semi": false }`, + ".editorconfig": "[*]\nindent_style = tab\n[*.js]\nindent_size = 8\n", + }; + // oxfmt reads `[*]` as settings for the whole project, not as a pattern. + expect(await pipe(project)).toMatchObject({ + raw: withoutSemi.replace(" return", "\treturn"), + stderr: "", + exitCode: 0, + }); + expect(await pipe(project, ["--check"])).toMatchObject({ raw: "(stdin)\n", exitCode: 1 }); + }); + + test.each(["--staged", "--changed"])("%s has no file list to filter", async flag => { + const result = await pipe(files, [flag], { reads }); + expect(result).toMatchObject({ + raw: "", + stderr: `error: ${flag} and --parser without files cannot be used together`, + exitCode: 2, + files, + }); + }); + + test("without --parser, piped input still means the working directory", async () => { + const result = await format({ "a.js": ugly }, [], { stdin: js, reads: ["a.js"] }); + expect(result.files).toEqual({ "a.js": formatted }); + expect(result.stdout).toBe("a.js"); + expect(result.exitCode).toBe(0); + }); + + // Some sandboxes have no pseudo-terminals. + const hasTerminal = (() => { + if (isWindows) return false; + try { + new Bun.Terminal({}).close(); + return true; + } catch { + return false; + } + })(); + + test.skipIf(!hasTerminal)( + "in a terminal with nothing piped in, it's an error instead of waiting for input", + async () => { + using dir = tempDir("bun-format", files); + let output = ""; + await using proc = spawn({ + cmd: [...command, "--parser", "babel"], + env: { ...env, NO_COLOR: "1", TERM: "xterm-256color" }, + cwd: String(dir), + terminal: { + cols: 200, + rows: 40, + data(_terminal, chunk) { + output += Buffer.from(chunk).toString(); + }, + }, + }); + expect(await proc.exited).toBe(2); + expect(output.replaceAll("\r\n", "\n")).toBe( + "error: --parser without files formats standard input, and nothing is piped in\n Pipe the code in, or name the files to format.\n", + ); + }, + ); + }); + + describe("--parser with a directory", () => { + const files = { + "a.md": "# a\n", + "LICENSE": "a b\n", + "src/b.md": "# b\n", + "src/c.js": "c ;\n", + ".env": "A = 1\n", + }; + const reads = Object.keys(files); + + test.each([ + [["--parser", "markdown", "."], ".", "markdown", "**/*.md"], + [["--parser", "markdown", "./"], "./", "markdown", "**/*.md"], + [["--parser", "flow", "src"], "src", "flow", "src/**/*.js"], + [["--parser", "babel", "a.md", "src/"], "src/", "babel", "src/**/*.js"], + [["--parser", "typescript", "--check", "src"], "src", "typescript", "src/**/*.ts"], + [["--parser", "json5", "-l", "src"], "src", "json5", "src/**/*.json5"], + [["--parser", "glimmer", "--no-config", "src"], "src", "glimmer", "src/**/*.hbs"], + [["--parser", "markdown", ""], "", "markdown", "**/*.md"], + ])("is an error without a config: %j", async (args, directory, parser, glob) => { + const result = await format(files, args, { reads }); + expect(result.files).toEqual(files); + expect(result.raw).toBe(""); + expect(result.stderr.split("\n")).toEqual([ + `error: --parser ${parser} would read every file in "${directory}" as ${parser}`, + ` Name the files, or use a glob: bun fmt --parser ${parser} "${glob}"`, + ]); + expect(result.exitCode).toBe(2); + }); + + // oxfmt 0.72.0 has no --parser, so there's nothing to be compatible with. + test.each<[Record, string[]]>([ + [{ ".oxfmtrc.json": "{}\n" }, []], + [{}, ["--flavor=oxfmt"]], + ])("is an error when formatting like oxfmt: %j %j", async (config, flags) => { + const result = await format({ ...files, ...config }, [...flags, "--parser", "markdown", "."], { reads }); + expect(result.files).toEqual(files); + expect(result.raw).toBe(""); + expect(result.stderr.split("\n")).toEqual([ + `[error] --parser markdown would read every file in "." as markdown`, + `[error] Name the files, or use a glob: bun fmt --parser markdown "**/*.md"`, + ]); + expect(result.exitCode).toBe(2); + }); + + test("files and globs are explicit, so they're formatted", async () => { + const result = await format(files, ["--parser", "markdown", "LICENSE", "**/*.md"], { reads }); + expect(result.files).toEqual({ ...files, "LICENSE": "a b\n", "a.md": "# a\n", "src/b.md": "# b\n" }); + expect(result.exitCode).toBe(0); + }); + + // Prettier 3.9.9 rewrites the same files. + test("with a Prettier config, every file in it is read with that parser, like Prettier does", async () => { + const result = await format({ ...files, ".prettierrc": "{}\n" }, ["--parser", "markdown", "."], { reads }); + expect(result.files).toEqual({ + "a.md": "# a\n", + "LICENSE": "a b\n", + "src/b.md": "# b\n", + "src/c.js": "c ;\n", + ".env": "A = 1\n", + }); + expect(result.exitCode).toBe(0); + }); + }); + describe("the command line", () => { test("formatting options", async () => { const result = await format( @@ -3857,8 +4997,8 @@ try { test("--help", async () => { const result = await format({}, ["--help"]); - expect(result.stdout).toContain("bun format"); - expect(result.stdout).toContain("--list-different"); + expect(result.stdout).toContain("bun fmt"); + expect(result.stdout).toContain("--check"); expect(result.exitCode).toBe(0); }); }); @@ -3891,7 +5031,7 @@ describe.concurrent("what an ignore file has is not written, however it is come const kept = ["generated/a.js", "generated/deep/b.js", "vendor/c.js"]; const result = await format(files, ["generated", "generated/*.js", "vendor", "src"], { reads: kept }); expect(result.stdout).toBe("src/d.js"); - expect(result.stderr).not.toContain("[error]"); + expect(result.stderr).not.toMatch(anError); expect(result.files).toEqual(Object.fromEntries(kept.map(name => [name, ugly]))); expect(result.exitCode).toBe(0); }); @@ -3901,7 +5041,7 @@ describe.concurrent("what an ignore file has is not written, however it is come async (...args) => { const files = { ".prettierignore": "src/a.js\nsrc/b.js\n", "src/a.js": ugly, "src/b.js": ugly }; const result = await format(files, ["--check", ...args], { reads: ["src/a.js"] }); - expect(result.stderr).not.toContain("[error]"); + expect(result.stderr).not.toMatch(anError); expect(result.files).toEqual({ "src/a.js": ugly }); expect(result.exitCode).toBe(0); }, @@ -3910,7 +5050,7 @@ describe.concurrent("what an ignore file has is not written, however it is come test("a pattern that matches nothing is an error beside a directory of which every file is ignored", async () => { const files = { ".prettierignore": "src/a.js\n", "src/a.js": ugly }; const result = await format(files, ["--check", "src", "none/*.js"]); - expect(result.stderr).toContain(`[error] No files matching the pattern were found: "none/*.js".`); + expect(result.stderr).toContain(`error: No files matching the pattern were found: "none/*.js".`); expect(result.stderr).not.toContain(`"src"`); expect(result.exitCode).toBe(2); }); @@ -4018,7 +5158,8 @@ describe.concurrent("how a path is written", () => { commandLine: [], }; await using proc = spawn({ - cmd: [bunExe(), "-e", `const given = ${JSON.stringify(given)};\n${source}`], + // As there: nothing is fetched. + cmd: [bunExe(), "--no-install", "-e", `const given = ${JSON.stringify(given)};\n${source}`], env, cwd: String(dir), stdin: "ignore", @@ -4047,14 +5188,14 @@ describe.concurrent("how a path is written", () => { }); return (await proc.stderr.text()).split(/\r?\n/)[0]; }; - const noParser = `[error] No parser could be inferred for file "${join(String(dir), "notes.foo")}".`; + const noParser = `error: No parser could be inferred for file "${join(String(dir), "notes.foo")}".`; expect( await Promise.all([ firstLine(["-l", join("src", "*.foo")]), firstLine(["-l", join(String(dir), "notes.foo")]), firstLine(["--stdin-filepath", join("src", "..", "notes.foo")], "a\n"), ]), - ).toEqual([`[error] No files matching the pattern were found: "${join("src", "*.foo")}".`, noParser, noParser]); + ).toEqual([`error: No files matching the pattern were found: "${join("src", "*.foo")}".`, noParser, noParser]); }); // src/config/prettier-config/config-searcher.js and the package `editorconfig` ask the system for a file of that name. @@ -4087,8 +5228,7 @@ describe.concurrent("a file that is not UTF-8, or has a NUL", () => { }); const [stderr, exitCode] = await Promise.all([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")]), @@ -4122,9 +5262,9 @@ describe.concurrent("a file that is not UTF-8, or has a NUL", () => { expect(result.files).toEqual( Object.fromEntries(Object.entries(files).map(([name, bytes]) => [name, bytes.toString("latin1")])), ); - expect(result.errors.map(line => line.split(":")[0]).sort()).toEqual( + expect(result.errors.map(line => line.split(":").slice(0, 2).join(":")).sort()).toEqual( Object.keys(files) - .map(name => `[error] ${name}`) + .map(name => `error: ${name}`) .sort(), ); expect(result.exitCode).toBe(2); @@ -4140,8 +5280,8 @@ describe.concurrent("a file that is not UTF-8, or has a NUL", () => { const result = await bytesAfter(files); expect(result.files).toEqual(files); expect(result.errors.sort()).toEqual([ - "[error] a.html: SyntaxError: What follows this NUL would be lost (2:5)", - "[error] a.vue: SyntaxError: What follows this NUL would be lost (2:5)", + "error: a.html: SyntaxError: Unexpected NUL character (2:5)", + "error: a.vue: SyntaxError: Unexpected NUL character (2:5)", ]); expect(result.exitCode).toBe(2); }); @@ -4153,7 +5293,7 @@ describe.concurrent("a file that is not UTF-8, or has a NUL", () => { ["the value of an attribute without quotes", "

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: Record, args: string[], variables: Record = {}) { using dir = tempDir("bun-format-process", files); @@ -4627,10 +5776,11 @@ describe.concurrent("what bun format takes from Bun", () => { test("colors are Bun's to decide: FORCE_COLOR, NO_COLOR, and none in a pipe", async () => { const files = { "a.js": ugly }; + const ofPrettier = { ...files, ".prettierrc": "{}\n" }; const [forced, refused, piped, asOxfmt] = await Promise.all([ - format(files, ["--check"], { env: { FORCE_COLOR: "1" } }), - format(files, ["--check"], { env: { NO_COLOR: "1" } }), - format(files, ["--check"]), + format(ofPrettier, ["--check"], { env: { FORCE_COLOR: "1" } }), + format(ofPrettier, ["--check"], { env: { NO_COLOR: "1" } }), + format(ofPrettier, ["--check"]), format({ ...files, ".oxfmtrc.json": "{}\n" }, ["--check", "a.js"], { env: { FORCE_COLOR: "1" } }), ]); expect(forced.stderr.split("\n")[0]).toBe("[\x1b[33mwarn\x1b[0m] a.js"); @@ -4642,12 +5792,12 @@ describe.concurrent("what bun format takes from Bun", () => { test("--cwd behind format, before it, and in BUN_OPTIONS, whose other flags are not the formatter's", async () => { const files = { "a.js": ugly, "sub/b.js": ugly }; const results = await Promise.all([ - bun(files, ["format", "--cwd", "sub", "-l"]), - bun(files, ["format", "--cwd=sub", "-l"]), - bun(files, ["--cwd=sub", "format", "-l"]), - bun(files, ["format", "-l"], { BUN_OPTIONS: "--cwd=sub" }), - bun(files, ["--silent", "format", "-l", "sub"]), - bun(files, ["format", "-l", "sub"], { BUN_OPTIONS: "--silent --no-install" }), + bun(files, ["fmt", "--cwd", "sub", "-l"]), + bun(files, ["fmt", "--cwd=sub", "-l"]), + bun(files, ["--cwd=sub", "fmt", "-l"]), + bun(files, ["fmt", "-l"], { BUN_OPTIONS: "--cwd=sub" }), + bun(files, ["--silent", "fmt", "-l", "sub"]), + bun(files, ["fmt", "-l", "sub"], { BUN_OPTIONS: "--silent --no-install" }), ]); expect(results.map(it => [it.stdout.replaceAll("\\", "/"), it.stderr, it.exitCode])).toEqual([ ...Array(4).fill(["b.js\n", "", 1]), @@ -4658,7 +5808,7 @@ describe.concurrent("what bun format takes from Bun", () => { // The process that runs the configuration file starts in that directory. With BUN_OPTIONS it would look for `sub` in `sub`. test("--cwd in BUN_OPTIONS, with a configuration file that is a program", async () => { const files = { "sub/.prettierrc.mjs": "export default { semi: false };\n", "sub/b.js": "b;\n" }; - const result = await bun(files, ["format", "-l", "b.js"], { BUN_OPTIONS: "--cwd=sub" }); + const result = await bun(files, ["fmt", "-l", "b.js"], { BUN_OPTIONS: "--cwd=sub" }); expect(result).toEqual({ stdout: "b.js\n", stderr: "", exitCode: 1 }); }); @@ -4668,25 +5818,25 @@ describe.concurrent("what bun format takes from Bun", () => { "prettier.config.mjs": "setInterval(() => {}, 1000);\nexport default { semi: false };\n", "b.js": "b;\n", }; - expect(await bun(files, ["format", "-l", "b.js"])).toEqual({ stdout: "b.js\n", stderr: "", exitCode: 1 }); + expect(await bun(files, ["fmt", "-l", "b.js"])).toEqual({ stdout: "b.js\n", stderr: "", exitCode: 1 }); }); test("what the bunfig.toml of the project has for other commands is not read", async () => { - const result = await bun({ "bunfig.toml": "[install]\nglobalDir = 1\n", "a.js": ugly }, ["format", "-l"]); + const result = await bun({ "bunfig.toml": "[install]\nglobalDir = 1\n", "a.js": ugly }, ["fmt", "-l"]); expect(result).toEqual({ stdout: "a.js\n", stderr: "", exitCode: 1 }); }); test("--config= before format names another file than bunfig.toml", async () => { const files = { - "bunfig.toml": "[format]\nsemi = false\n", - "other.toml": "[format]\nsingleQuote = true\n", + "bunfig.toml": "[fmt]\nsemi = false\n", + "other.toml": "[fmt]\nsingleQuote = true\n", "a.js": "a('b');\n", }; const results = await Promise.all([ - bun(files, ["format", "-l"]), - bun(files, ["--config=other.toml", "format", "-l"]), - bun(files, ["-c=other.toml", "format", "-l"]), - bun(files, ["--config=none.toml", "format", "-l"]), + bun(files, ["fmt", "-l"]), + bun(files, ["--config=other.toml", "fmt", "-l"]), + bun(files, ["-c=other.toml", "fmt", "-l"]), + bun(files, ["--config=none.toml", "fmt", "-l"]), ]); expect(results.map(it => [it.stdout, it.exitCode])).toEqual([ ["a.js\n", 1], @@ -4697,9 +5847,9 @@ describe.concurrent("what bun format takes from Bun", () => { expect(results[3].stderr).toContain(`while reading config "`); }); - test("what is wrong with [format] is shown where it is, as for every other section", async () => { - const files = { "bunfig.toml": `[format]\nsemi = false\nprintWidth = "wide"\n`, "a.js": ugly }; - const result = await bun(files, ["format", "-l"]); + test("what is wrong with [fmt] is shown where it is, as for every other section", async () => { + const files = { "bunfig.toml": `[fmt]\nsemi = false\nprintWidth = "wide"\n`, "a.js": ugly }; + const result = await bun(files, ["fmt", "-l"]); expect(result.stderr).toContain(`3 | printWidth = "wide"\n`); expect(result.stderr).toMatch(/error: expected number but received string\n\s+at .*bunfig\.toml:3:14\n/); expect(result.stdout).toBe(""); @@ -4741,7 +5891,7 @@ describe.concurrent("what bun format takes from Bun", () => { writeFileSync(name, ugly); const run = async (argument: string) => { await using proc = spawn({ - cmd: ["sh", "-c", `exec "$0" format ${argument}`, bunExe()], + cmd: ["sh", "-c", `exec "$0" fmt ${argument}`, bunExe()], env, cwd: String(dir), stdout: "pipe", @@ -4780,18 +5930,18 @@ describe.concurrent("what bun format takes from Bun", () => { async open => { const result = await format({}, ["--stdin-filepath", "deep.jsx"], { stdin: open.repeat(100_000) + "\n" }); expect(result.raw).toBe(""); - expect(result.stderr).toStartWith("[error] deep.jsx:"); + expect(result.stderr).toStartWith("error: deep.jsx:"); expect(result.exitCode).toBe(2); }, ); }); -// What differs between Windows, macOS and Linux, for `bun format`: how a path is written, which names are the same file, how a line +// What differs between Windows, macOS and Linux, for `bun fmt`: how a path is written, which names are the same file, how a line // ends, what a link is, what can be written. Nothing here is passed through `normalizeBunSnapshot`, which makes `/` of every `\` // and `\n` of every `\r\n`: what is printed is compared as it is printed. // // A test that can only tell something on one system runs on all of them where it can: there it is a test that nothing else breaks. -describe("bun format on Windows, macOS and Linux", () => { +describe("bun fmt on Windows, macOS and Linux", () => { const env = { ...bunEnv, AGENT: "0", CLAUDECODE: undefined, NO_COLOR: "1", FORCE_COLOR: undefined }; // A drive letter that a test has taken would stay, for every process of the session, if the test ran out of time. @@ -4821,10 +5971,10 @@ describe("bun format on Windows, macOS and Linux", () => { type Directory = string | { toString(): string }; - /** Runs `bun format ` in `cwd`. What it prints is returned as it is. */ + /** Runs `bun fmt ` in `cwd`. What it prints is returned as it is. */ async function format(cwd: Directory, args: string[], stdin?: string, before: string[] = []) { const proc = spawn({ - cmd: [...before, bunExe(), "format", ...args], + cmd: [...before, bunExe(), "fmt", ...args], env, cwd: String(cwd), stdin: stdin === undefined ? "ignore" : Buffer.from(stdin), @@ -4900,15 +6050,11 @@ describe("bun format on Windows, macOS and Linux", () => { const results = await Promise.all([format(listed, ["-l"]), format(checked, ["--check"]), format(written, [])]); expect(results.map(it => it.stdout)).toEqual([ "a.js\nsrc/deep/b.ts\n", - "Checking formatting...\nError occurred when checking code style in the above file.\n", "a.js\nsrc/deep/b.ts\n", - ]); - expect(results[1].stderr.split("\n").filter(line => line.startsWith("[warn] "))).toEqual([ - "[warn] a.js", - "[warn] src/deep/b.ts", + "a.js\nsrc/deep/b.ts\n", ]); for (const { stderr } of results) { - expect(stderr).toContain("[error] src/broken.js: SyntaxError: "); + expect(stderr).toContain("error: src/broken.js: SyntaxError: "); expect(stderr).not.toContain("\r"); } }); @@ -4988,14 +6134,14 @@ describe("bun format on Windows, macOS and Linux", () => { test( "a project in a directory with blanks, `#`, `%41` and letters that are not ASCII", async () => { - using dir = tempDir("bun format #1 %41 é 日本", { + using dir = tempDir("bun fmt #1 %41 é 日本", { ".prettierrc.mjs": `import shared from "./my configs/shared#1.mjs";\nexport default { ...shared };\n`, "my configs/shared#1.mjs": "export default { semi: false };\n", "sources é/ü 100%.js": formatted, "sources é/中文.js": formatted, }); const { stdout, stderr, exitCode } = await format(dir, ["sources é"]); - expect(stderr).not.toContain("[error]"); + expect(stderr).not.toMatch(anError); expect(stdout).toBe("sources é/ü 100%.js\nsources é/中文.js\n"); expect(read(dir, "sources é", "ü 100%.js")).toBe("a\n"); expect(exitCode).toBe(0); @@ -5011,7 +6157,7 @@ describe("bun format on Windows, macOS and Linux", () => { writeFileSync(join(String(dir), ...names, "a.js"), formatted); writeFileSync(join(String(dir), ...names, "b.js"), formatted); const walked = await format(dir, []); - expect(walked.stderr).not.toContain("[error]"); + expect(walked.stderr).not.toMatch(anError); expect(walked.stdout).toBe(`${names.join("/")}/a.js\n${names.join("/")}/b.js\n`); expect(read(dir, ...names, "a.js")).toBe("a\n"); writeFileSync(join(String(dir), ...names, "b.js"), formatted); @@ -5072,7 +6218,7 @@ describe("bun format on Windows, macOS and Linux", () => { ]), ).toEqual([["src/a.js"], ["src/a.js"], [`${share}/src/a.js`.replaceAll("\\", "/")]]); const written = await format(share, []); - expect(written.stderr).not.toContain("[error]"); + expect(written.stderr).not.toMatch(anError); expect(read(dir, "src", "a.js")).toBe("a\n"); expect(read(dir, "src", "ignored.js")).toBe(formatted); expect(everythingIn(dir)).toEqual([".prettierignore", ".prettierrc", "src", "src/a.js", "src/ignored.js"]); @@ -5089,7 +6235,7 @@ describe("bun format on Windows, macOS and Linux", () => { }); const real = realpathSync(String(dir)); const { stderr, exitCode } = await format(`\\\\127.0.0.1\\${real[0]}$\\${real.slice(3)}`, ["a.js"]); - expect(stderr).not.toContain("[error]"); + expect(stderr).not.toMatch(anError); expect(read(dir, "a.js")).toBe("a\n"); expect(exitCode).toBe(0); }, @@ -5144,11 +6290,12 @@ describe("bun format on Windows, macOS and Linux", () => { test("a file that is named in the other case is formatted if the system finds it", async () => { using dir = tempDir("bun-format-platform", { "src/Button.js": ugly }); const foldsCase = existsSync(join(String(dir), "SRC", "bUTTON.JS")); - const { stdout, exitCode } = await format(dir, [join("src", "Button.js"), join("SRC", "bUTTON.JS")]); + const { stdout, stderr, exitCode } = await format(dir, [join("src", "Button.js"), join("SRC", "bUTTON.JS")]); expect(stdout.split("\n")[0]).toBe("src/Button.js"); expect(read(dir, "src", "Button.js")).toBe(formatted); expect(everythingIn(dir)).toEqual(["src", "src/Button.js"]); - expect(exitCode).toBe(foldsCase ? 0 : 2); + // With stderr, so that a failure says why. + expect({ stderr, exitCode }).toEqual({ stderr: expect.any(String), exitCode: foldsCase ? 0 : 2 }); }); // macOS finds `é` whether it is written as one character or as `e` and an accent. @@ -5238,7 +6385,7 @@ describe("bun format on Windows, macOS and Linux", () => { name === ".editorconfig" ? ["if (a) {\n b;\n}\n", "if (a) {\n\tb;\n}\n"] : [formatted, "a\n"]; using dir = tempDir("bun-format-platform", { [name]: crlf(text), "a.js": before }); const { stderr } = await format(dir, ["a.js"]); - expect(stderr).not.toContain("[error]"); + expect(stderr).not.toMatch(anError); expect(read(dir, "a.js")).toBe(after); }); }); @@ -5263,7 +6410,7 @@ describe("bun format on Windows, macOS and Linux", () => { using dir = tempDir("bun-format-platform", { "real/a.js": ugly }); link(join(String(dir), "real"), join(String(dir), "linked")); const { stderr, exitCode } = await format(dir, ["-l", "linked"]); - expect(stderr).toContain('[error] Explicitly specified pattern "linked" is a symbolic link.'); + expect(stderr).toContain('error: Explicitly specified pattern "linked" is a symbolic link.'); expect(exitCode).toBe(2); }); @@ -5330,7 +6477,7 @@ describe("bun format on Windows, macOS and Linux", () => { expect(handle).not.toBe(0xffffffffffffffffn); try { const { stderr, exitCode } = await format(dir, []); - expect(stderr).not.toContain("[error]"); + expect(stderr).not.toMatch(anError); expect(read(dir, "a.js")).toBe(formatted); expect(everythingIn(dir)).toEqual(["a.js"]); expect(exitCode).toBe(0); @@ -5396,8 +6543,8 @@ describe("bun format on Windows, macOS and Linux", () => { }); }); -// A key of `[format]` is the default of the flag of the same name. -describe.concurrent("[format] in bunfig.toml", () => { +// A key of `[fmt]` is the default of the flag of the same name. +describe.concurrent("[fmt] in bunfig.toml", () => { /** What a run leaves behind: what it prints but for the times, how it ends, and the files. */ async function run(files: Record, args: string[]) { const reads = Object.keys(files).filter(name => name !== "bunfig.toml"); @@ -5447,7 +6594,7 @@ describe.concurrent("[format] in bunfig.toml", () => { test.each(options)("%s", async (key, value, other, name, source) => { const files = { [name]: source }; - const bunfig = { "bunfig.toml": `[format]\n${key} = ${toml(value)}\n` }; + const bunfig = { "bunfig.toml": `[fmt]\n${key} = ${toml(value)}\n` }; const rc = { ".prettierrc": JSON.stringify({ [key]: other }) }; const [plain, byKey, byFlag, underFlag, byOtherFlag, overFile, flagOverFile] = await Promise.all([ run(files, [name]), @@ -5538,13 +6685,15 @@ describe.concurrent("[format] in bunfig.toml", () => { ]; test.each(others)("%s", async (line, flags, opposite, files, args) => { - const bunfig = { "bunfig.toml": `[format]\n${line}\n` }; + const bunfig = { "bunfig.toml": `[fmt]\n${line}\n` }; + // bunfig.toml gets formatted too, so every run has one to keep the outputs comparable. + const empty = { "bunfig.toml": "# Nothing.\n" }; const [plain, byKey, byFlag, underFlag, byOtherFlag] = await Promise.all([ - run(files, args), + run({ ...files, ...empty }, args), run({ ...files, ...bunfig }, args), - run(files, [...flags, ...args]), + run({ ...files, ...empty }, [...flags, ...args]), run({ ...files, ...bunfig }, [...opposite, ...args]), - run(files, [...(opposite.length ? opposite : flags), ...args]), + run({ ...files, ...empty }, [...(opposite.length ? opposite : flags), ...args]), ]); expect(byKey).toEqual(byFlag); expect(byKey).not.toEqual(plain); @@ -5554,19 +6703,19 @@ describe.concurrent("[format] in bunfig.toml", () => { test("threads", async () => { const files = { "a.js": ugly, "b.js": ugly }; const [byKey, byFlag, zero] = await Promise.all([ - run({ ...files, "bunfig.toml": "[format]\nthreads = 1\n" }, ["a.js", "b.js"]), + run({ ...files, "bunfig.toml": "[fmt]\nthreads = 1\n" }, ["a.js", "b.js"]), run(files, ["--threads=1", "a.js", "b.js"]), - run({ ...files, "bunfig.toml": "[format]\nthreads = 0\n" }, ["a.js", "b.js"]), + run({ ...files, "bunfig.toml": "[fmt]\nthreads = 0\n" }, ["a.js", "b.js"]), ]); expect(byKey).toEqual(byFlag); - expect(zero.stderr).toContain(`--threads takes a number above zero, not "0".`); + expect(zero.stderr).toContain(`--threads takes a number above zero, not "0"`); expect(zero.exitCode).toBe(2); }); test.each(["67108864", "9007199254740991"])("%s threads are as many as there can be", async count => { const files = { "a.js": ugly, "b.js": ugly }; const [byKey, byFlag, few] = await Promise.all([ - run({ ...files, "bunfig.toml": `[format]\nthreads = ${count}\n` }, ["a.js", "b.js"]), + run({ ...files, "bunfig.toml": `[fmt]\nthreads = ${count}\n` }, ["a.js", "b.js"]), run(files, [`--threads=${count}`, "a.js", "b.js"]), run(files, ["--threads=1", "a.js", "b.js"]), ]); @@ -5575,12 +6724,12 @@ describe.concurrent("[format] in bunfig.toml", () => { }); test("a list on the command line is added to the list of the file", async () => { - const files = { "a.js": ugly, "b.js": ugly, "c.js": ugly, "bunfig.toml": `[format]\nignorePatterns = ["b.js"]\n` }; + const files = { "a.js": ugly, "b.js": ugly, "c.js": ugly, "bunfig.toml": `[fmt]\nignorePatterns = ["b.js"]\n` }; expect(await different(files, ["--ignore-pattern=c.js", "."])).toEqual(["a.js"]); }); test("with configPrecedence = prefer-file the section counts only where there is no configuration file", async () => { - const bunfig = `[format]\nconfigPrecedence = "prefer-file"\nsemi = false\n`; + const bunfig = `[fmt]\nconfigPrecedence = "prefer-file"\nsemi = false\n`; const [alone, beside] = await Promise.all([ run({ "bunfig.toml": bunfig, "a.js": "a;\n" }, ["a.js"]), run({ "bunfig.toml": bunfig, ".prettierrc": "{}", "a.js": "a;\n" }, ["a.js"]), @@ -5589,7 +6738,7 @@ describe.concurrent("[format] in bunfig.toml", () => { }); test("the file is that of the working directory, after --cwd", async () => { - const files = { "bunfig.toml": "[format]\nsemi = false\n", "sub/bunfig.toml": "[format]\nsingleQuote = true\n" }; + const files = { "bunfig.toml": "[fmt]\nsemi = false\n", "sub/bunfig.toml": "[fmt]\nsingleQuote = true\n" }; const source = { "a.js": `a("b");\n`, "sub/a.js": `a("b");\n` }; const [above, below, inside] = await Promise.all([ run({ ...files, ...source }, ["sub/a.js"]), @@ -5617,9 +6766,9 @@ describe.concurrent("[format] in bunfig.toml", () => { [`cwd = "sub"`, "--cwd"], ])("%s is refused, and nothing is written", async (line, flag) => { const key = line.split(" ")[0]; - const result = await run({ "bunfig.toml": `[format]\n${line}\n`, "a.js": ugly }, ["a.js"]); + const result = await run({ "bunfig.toml": `[fmt]\n${line}\n`, "a.js": ugly }, ["a.js"]); expect(result.stderr).toContain( - `"${key}" cannot be set in bunfig.toml, it says what one run does. Pass ${flag} on the command line`, + `error: "${key}" cannot be set in bunfig.toml\n Use ${flag} on the command line\n`, ); expect(result.files["a.js"]).toBe(ugly); expect(result.exitCode).toBe(2); @@ -5640,44 +6789,53 @@ describe.concurrent("[format] in bunfig.toml", () => { [`flavor = "biome"`, `Invalid --flavor value. Expected "oxfmt" or "prettier", but received "biome".`], [`logLevel = "loud"`, `Invalid --log-level value.`], [`configPrecedence = "mine"`, `Invalid --config-precedence value.`], - ["semicolons = false", `unknown key "semicolons" in [format]. Did you mean "semi"?`], - ["printwidth = 80", `unknown key "printwidth" in [format]. Did you mean "printWidth"?`], - [`plugins = ["a"]`, `unknown key "plugins" in [format].`], - [`parser = "babel"`, `unknown key "parser" in [format].`], - ["overrides = []", `unknown key "overrides" in [format].`], + ["semicolons = false", `Unknown key "semicolons" in [fmt]\n`], + ["printwidth = 80", `Unknown key "printwidth" in [fmt]\n Did you mean "printWidth"?`], + [`plugins = ["a"]`, `Unknown key "plugins" in [fmt]\n`], + [`parser = "babel"`, `Unknown key "parser" in [fmt]\n`], ])("%s is refused: %s", async (line, message) => { - const result = await run({ "bunfig.toml": `[format]\n${line}\n`, "a.js": ugly }, ["a.js"]); + const result = await run({ "bunfig.toml": `[fmt]\n${line}\n`, "a.js": ugly }, ["a.js"]); expect(result.stderr).toContain(message); expect(result.files["a.js"]).toBe(ugly); expect(result.exitCode).toBe(2); }); test("a file that is not TOML", async () => { - const result = await run({ "bunfig.toml": "[format\nsemi = false\n", "a.js": ugly }, ["a.js"]); + const result = await run({ "bunfig.toml": "[fmt\nsemi = false\n", "a.js": ugly }, ["a.js"]); expect(result.files["a.js"]).toBe(ugly); expect(result.exitCode).toBe(2); }); - test("format that is no table", async () => { - const result = await run({ "bunfig.toml": `format = "prettier"\n`, "a.js": ugly }, ["a.js"]); + test("fmt that is no table", async () => { + const result = await run({ "bunfig.toml": `fmt = "prettier"\n`, "a.js": ugly }, ["a.js"]); expect(result.stderr).toContain("expected object but received string"); expect(result.exitCode).toBe(2); }); test("every flag in the help is a key, or is refused as a key with its name", async () => { - const help = await format({}, ["--help"]); + const help = await format({}, ["--help", "--all"]); const flags = [...help.stdout.matchAll(/^\s+(?:-\w, )?--([a-z-]+)/gm)].map(it => it[1]); - expect(flags.length).toBeGreaterThan(40); + expect(flags.length).toBeGreaterThan(60); const answers = await Promise.all( flags.map(async flag => { const positive = flag.replace(/^no-/, ""); const key = positive === "ignore-pattern" ? "ignorePatterns" : positive.replace(/-(\w)/g, (_, it) => it.toUpperCase()); // Of a type that no key takes: one that exists says what it expects. - const { stderr } = await run({ "bunfig.toml": `[format]\n${key} = {}\n` }, ["--list-files"]); - return [flag, /expected \w+ but received object|cannot be set in bunfig\.toml/.test(stderr)]; + const { stderr } = await run({ "bunfig.toml": `[fmt]\n${key} = [[]]\n` }, ["--list-files"]); + return [flag, /expected [\w ]+ but received array|cannot be set in bunfig\.toml/.test(stderr)]; }), ); - expect(answers.filter(it => !it[1])).toEqual([]); + // Prettier and oxfmt flags that aren't project settings, or that do nothing. + expect(answers.filter(it => !it[1]).map(it => it[0])).toEqual([ + "parser", + "plugin", + "jsx-bracket-same-line", + "cache", + "cache-location", + "cache-strategy", + "experimental-cli", + "lsp", + ]); }); }); diff --git a/test/cli/format/glob.test.ts b/test/cli/format/glob.test.ts new file mode 100644 index 000000000000..2123534ad327 --- /dev/null +++ b/test/cli/format/glob.test.ts @@ -0,0 +1,216 @@ +import { afterAll, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { endChildren, spawn } from "../children"; + +afterAll(endChildren); + +const env = { ...bunEnv, AGENT: "0", CLAUDECODE: undefined, GITHUB_ACTIONS: undefined }; +const ugly = "if (a) {\nb( 1 )\n}\n"; + +async function format(files: Record, args: string[], stdin?: string) { + using dir = tempDir("bun-format-glob", files); + await using proc = spawn({ + cmd: [bunExe(), "fmt", ...args], + env, + cwd: String(dir), + stdin: stdin === undefined ? "ignore" : Buffer.from(stdin), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +const names = [ + "a.js", + "a.cjs", + "a.mjs", + "a.ts", + "src/a.js", + "src/b.ts", + "src/x/d.js", + "src/x/e.tsx", + "lib/f.js", + "lib/deep/g.js", + "lib/deep/h.min.js", + "sub/i.js", +]; +const project = { ...Object.fromEntries(names.map(name => [name, ugly])), ".prettierrc.json": "{}" }; + +// The expected files come from Prettier 3.9. +test.concurrent.each([ + ["src/**/*.@(js|ts)", "src/a.js src/b.ts src/x/d.js"], + ["{a,src/x/*}.?(c)js", "a.cjs a.js src/x/d.js"], + ["lib/!(deep)*.js", "lib/f.js"], + ["**/+(a|d).js", "a.js src/a.js src/x/d.js"], + // Prettier also treats parentheses without a prefix as a group. + ["(src|lib)/*.js", "lib/f.js src/a.js"], + // `a/**` also matches `a` itself. + ["lib/*/**", "lib/deep/g.js lib/deep/h.min.js lib/f.js"], +])("the command line pattern %s lists the same files as Prettier", async (pattern, expected) => { + const { stdout, exitCode } = await format(project, ["-l", pattern]); + expect(stdout.trim().split("\n").sort().join(" ")).toBe(expected); + expect(exitCode).toBe(1); +}); + +test.concurrent("extglob patterns work in `files` and `excludeFiles` of Prettier overrides", async () => { + const overrides = [ + { files: "*.?(c|m)js", options: { semi: false } }, + { files: "src/**/*.@(ts|tsx)", options: { tabWidth: 8 } }, + { files: "lib/!(deep)/**", options: { tabWidth: 1 } }, + { files: ["lib/+(deep|x)/*.js"], excludeFiles: "**/*.min.js", options: { useTabs: true } }, + ]; + const files = { ".prettierrc.json": JSON.stringify({ overrides }) }; + const second = async (name: string) => (await format(files, ["--stdin-filepath", name], ugly)).stdout.split("\n")[1]; + expect(Object.fromEntries(await Promise.all(names.map(async name => [name, await second(name)])))).toEqual({ + "a.js": " b(1)", + "a.cjs": " b(1)", + "a.mjs": " b(1)", + "a.ts": " b(1);", + "src/a.js": " b(1)", + "src/b.ts": " b(1);", + "src/x/d.js": " b(1)", + "src/x/e.tsx": " b(1);", + "lib/f.js": " b(1)", + "lib/deep/g.js": "\tb(1)", + "lib/deep/h.min.js": " b(1)", + "sub/i.js": " b(1)", + }); +}); + +test.concurrent.each([ + // Braces without a comma are literal. + ["{a.js}", "a.js", " "], + ["{a.js,b.js}", "a.js", " "], + ["*.{js}", "a.js", " "], + // `**` crosses `/` anywhere in the section name. + ["**.js", "lib/deep/a.js", " "], + ["lib/**.js", "lib/deep/a.js", " "], + ["lib/**.js", "a.js", " "], + ["li**a.js", "lib/deep/a.js", " "], + ["li**/a.js", "lib/deep/a.js", " "], + ["li**", "lib/deep/a.js", " "], + ["lib/*.js", "lib/deep/a.js", " "], + // A section without `/` matches in any directory. + ["a.js", "lib/deep/a.js", " "], + ["/a.js", "lib/deep/a.js", " "], +])(".editorconfig section [%s] with %s behaves like Prettier", async (section, name, indent) => { + const files = { ".prettierrc.json": "{}", ".editorconfig": `root = true\n[${section}]\nindent_size = 4\n` }; + const { stdout, exitCode } = await format(files, ["--stdin-filepath", name], ugly); + expect(stdout).toBe(`if (a) {\n${indent}b(1);\n}\n`); + expect(exitCode).toBe(0); +}); + +const numbered = [ + "0", + "1", + "2", + "3", + "4", + "9", + "10", + "11", + "15", + "20", + "01", + "02", + "03", + "001", + "-1", + "a", + "b", + "c", + "d", +]; + +// The expected results come from Prettier 3.9. fast-glob expands `{1..3}` to 1, 2 and 3. micromatch turns it into the class `[1-3]`. +test.concurrent.each([ + ["x{1..3}.js", "1 2 3", "1 2 3"], + ["x{3..1}.js", "1 2 3", "1 2 3"], + ["x{01..03}.js", "01 02 03", ""], + ["x{1..10}.js", "1 10 2 3 4 9", "0 1"], + ["x{10..20}.js", "10 11 15 20", "0 1 2"], + ["x{1..9..2}.js", "1 3 9", "1 2 9"], + ["x{a..c}.js", "a b c", "a b c"], + ["x{c..a}.js", "a b c", "a b c"], + ["x{-1..1}.js", "-1 0 1", "1"], + ["x{a}.js", "", ""], +])("%s behaves like Prettier on the command line and in an override", async (pattern, listed, overridden) => { + const files = Object.fromEntries(numbered.map(it => [`x${it}.js`, "a( 1 )\n"])); + const names = (text: string) => + text + .split(" ") + .filter(Boolean) + .map(it => `x${it}.js`) + .sort(); + const { stdout } = await format({ ...files, ".prettierrc.json": "{}" }, ["-l", pattern]); + expect(stdout.trim().split("\n").filter(Boolean).sort()).toEqual(names(listed)); + + const config = { ".prettierrc.json": JSON.stringify({ overrides: [{ files: pattern, options: { semi: false } }] }) }; + const printed = await Promise.all( + numbered.map(async it => [ + `x${it}.js`, + (await format(config, ["--stdin-filepath", `x${it}.js`], "a( 1 )\n")).stdout, + ]), + ); + expect( + printed + .filter(it => it[1] === "a(1)\n") + .map(it => it[0]) + .sort(), + ).toEqual(names(overridden)); +}); + +test.concurrent.each([ + ["{1..3}.js", "2.js", " "], + ["{1..3}.js", "4.js", " "], + ["{1..3}.js", "02.js", " "], + ["{01..03}.js", "02.js", " "], + ["{-1..1}.js", "-1.js", " "], + ["{3..1}.js", "2.js", " "], + ["v{1..2}/*.js", "v2/a.js", " "], +])(".editorconfig number range [%s] with %s behaves like Prettier", async (section, name, indent) => { + const files = { ".prettierrc.json": "{}", ".editorconfig": `root = true\n[${section}]\nindent_size = 4\n` }; + const { stdout, exitCode } = await format(files, ["--stdin-filepath", name], ugly); + expect(stdout).toBe(`if (a) {\n${indent}b(1);\n}\n`); + expect(exitCode).toBe(0); +}); + +// The expected files come from Prettier 3.9. Its `ignore` package looks for a `/` before it trims the line, so `a/ ` is anchored. +test.concurrent.each([ + ["a/", "z.js"], + ["a/ ", "sub/a/y.js z.js"], + ["a ", "z.js"], +])("the .prettierignore line %j ignores the same files as Prettier", async (line, expected) => { + const files = { + "a/x.js": ugly, + "sub/a/y.js": ugly, + "z.js": ugly, + ".prettierrc.json": "{}\n", + ".prettierignore": `${line}\n`, + }; + const { stdout, exitCode } = await format(files, ["-l", "."]); + expect(stdout.trim().split("\n").sort().join(" ")).toBe(expected); + expect(exitCode).toBe(1); +}); + +// The expected files come from Prettier 3.9. To its `ignore` package the `**` in `build/**/` can stand for nothing, so the line is +// about `build` itself. git reads it differently: there `build/a.js` isn't ignored. +test.concurrent.each([ + ["build/**/", "a.js a/f.js ab.js src/build/d.js src/e.js"], + ["**/build/**/", "a.js a/f.js ab.js src/e.js"], + ["build/", "a.js a/f.js ab.js src/e.js"], + ["build/**", "a.js a/f.js ab.js src/build/d.js src/e.js"], + ["build/**/x/", "a.js a/f.js ab.js build/a.js build/sub/b.js src/build/d.js src/e.js"], + ["a**/**", "a.js ab.js build/a.js build/sub/b.js build/sub/x/c.js src/build/d.js src/e.js"], +])("the .prettierignore line %j with ** ignores the same files as Prettier", async (line, expected) => { + const names = "build/a.js build/sub/b.js build/sub/x/c.js src/build/d.js src/e.js a.js a/f.js ab.js".split(" "); + const files = { + ...Object.fromEntries(names.map(name => [name, ugly])), + ".prettierrc.json": "{}\n", + ".prettierignore": `${line}\n`, + }; + const { stdout, exitCode } = await format(files, ["-l", "."]); + expect(stdout.trim().split("\n").sort().join(" ")).toBe(expected); + expect(exitCode).toBe(1); +}); diff --git a/test/cli/format/line-endings.cases.ts b/test/cli/format/line-endings.cases.ts index 342c61d9c31f..b1e9d8a819c6 100644 --- a/test/cli/format/line-endings.cases.ts +++ b/test/cli/format/line-endings.cases.ts @@ -1,4 +1,4 @@ -// Small inputs in every language that `bun format` reads, with line breaks wherever they mean something: in comments, in +// Small inputs in every language that `bun fmt` reads, with line breaks wherever they mean something: in comments, in // `prettier-ignore`d code, in strings, templates and block scalars, in embedded code, between things that keep an empty line. // They are written with \n. `format.test.ts` gives them other line breaks and a byte order mark. export const inputs: Record = { diff --git a/test/cli/format/oracle/fuzz/README.md b/test/cli/format/oracle/fuzz/README.md index f17738b2a3c5..e9ce0b801ac6 100644 --- a/test/cli/format/oracle/fuzz/README.md +++ b/test/cli/format/oracle/fuzz/README.md @@ -1,8 +1,8 @@ # Fuzzers -They run `bun format` on texts that they make, and ask the npm package of Prettier about each. Neither runs in CI: they need Prettier. +They run `bun fmt` on texts that they make, and ask the npm package of Prettier about each. Neither runs in CI: they need Prettier. | file | what it makes | | --- | --- | -| `handlebars/damage.ts` | Handlebars: the templates whose syntax tree, as Prettier compares trees in its own tests, is another one after formatting, and of which `bun format` does not say that it would damage them | -| `near.ts` | every language: Prettier's fixtures, or real files (`--files`), each a little changed: a token deleted, doubled, swapped with the next, or the text cut off: a file while it is typed. It runs `bun format --write` itself, with its checks, and asks Prettier, or oxfmt (`--oxfmt`), with the defaults or a set of options (`--options`). Two tables: how many have the same bytes, and which check refused what the other tool prints, rightly or not. `bun near.ts --command="bun format" --prettier= --language=js` (`css`, `yaml`, `graphql`, `json`, `handlebars`, `markdown`, `html`) | +| `handlebars/damage.ts` | Handlebars: the templates whose syntax tree, as Prettier compares trees in its own tests, is another one after formatting, and of which `bun fmt` does not say that it would damage them | +| `near.ts` | every language: Prettier's fixtures, or real files (`--files`), each a little changed: a token deleted, doubled, swapped with the next, or the text cut off: a file while it is typed. It runs `bun fmt --write` itself, with its checks, and asks Prettier, or oxfmt (`--oxfmt`), with the defaults or a set of options (`--options`). Two tables: how many have the same bytes, and which check refused what the other tool prints, rightly or not. `bun near.ts --command="bun fmt" --prettier= --language=js` (`css`, `yaml`, `graphql`, `json`, `handlebars`, `markdown`, `html`) | diff --git a/test/cli/format/oracle/fuzz/handlebars/damage.ts b/test/cli/format/oracle/fuzz/handlebars/damage.ts index 43a0eb335cd6..5f98130eb2cb 100644 --- a/test/cli/format/oracle/fuzz/handlebars/damage.ts +++ b/test/cli/format/oracle/fuzz/handlebars/damage.ts @@ -1,8 +1,8 @@ -// Prettier changes what some templates mean. `bun format` prints the same, but says so and leaves the file alone. This looks for templates +// Prettier changes what some templates mean. `bun fmt` prints the same, but says so and leaves the file alone. This looks for templates // that it does not say it of: those whose syntax tree, as Prettier compares trees in its own tests, is another one after formatting, or that // cannot be parsed any more. // -// bun damage.ts "bun format" [-show=10] [-told] +// bun damage.ts "bun fmt" [-show=10] [-told] // // `-told`: also prints the templates that it says it of although the trees are the same: what the parsers drop is in neither tree. import { readFileSync, readdirSync } from "node:fs"; diff --git a/test/cli/format/oracle/fuzz/near.ts b/test/cli/format/oracle/fuzz/near.ts index d02023c81424..276f01a89a09 100644 --- a/test/cli/format/oracle/fuzz/near.ts +++ b/test/cli/format/oracle/fuzz/near.ts @@ -1,8 +1,8 @@ // Texts that are nearly normal: Prettier's fixtures, or real files, each a little changed: a token deleted, two neighbours deleted, a token -// doubled, swapped with the next, or the text cut off after it. That is what a file looks like while it is typed. `bun format --write` runs on +// doubled, swapped with the next, or the text cut off after it. That is what a file looks like while it is typed. `bun fmt --write` runs on // them, with the checks that it makes before it writes, and the npm package of Prettier, or oxfmt, is asked about each. // -// bun near.ts --command="bun format" --prettier= --language=css +// bun near.ts --command="bun fmt" --prettier= --language=css // [--oxfmt=] the flavor of oxfmt, against oxfmt // [--options] each text with one of SETS in the place of the defaults // [--files=] real files in the place of the fixtures, one change each. More than once: more directories @@ -14,7 +14,7 @@ // Languages: js (with jsx, ts, tsx), css (with less, scss), yaml, graphql, json, handlebars, markdown (with mdx), html (with vue, angular, mjml). // Nothing is stored: what is expected is asked when it is run. The same seed makes the same texts. // -// It prints two tables. The second is about refusals: `bun format` leaves a file alone if what it has printed does not say what the file says. +// It prints two tables. The second is about refusals: `bun fmt` leaves a file alone if what it has printed does not say what the file says. // Where the other tool prints something, that is a true alarm if the other tool has lost or added a letter or a digit (in HTML and Handlebars also // a `<` or a `{`), and a false one if not. import { spawnSync } from "node:child_process"; @@ -82,7 +82,7 @@ if (process.argv[2] == "--child") { const endings = ENDINGS[language]; if (!endings || !flags.has("command") || !(flags.has("prettier") || isOxfmt)) { throw new Error( - `usage: bun near.ts --command="bun format" --prettier= --language=<${Object.keys(ENDINGS).join("|")}> ..`, + `usage: bun near.ts --command="bun fmt" --prettier= --language=<${Object.keys(ENDINGS).join("|")}> ..`, ); } @@ -310,7 +310,7 @@ if (process.argv[2] == "--child") { if (flags.has("texts") && ours !== undefined && expected !== undefined) writeFileSync(join(flag("texts"), name), text); if (verdict == "only we refuse: a check") { - const key = `${ending} | ${refusal!.replace(/\. It is left as it is.*$/, "")}`; + const key = `${ending} | ${refusal!.replace(/\. It was not formatted.*$/, "")}`; const alarm = alarms.get(key) ?? { texts: 0, isTrue: 0 }; alarms.set(key, alarm); alarm.texts++; diff --git a/test/cli/format/oracle/sort-imports/make-fixtures.mjs b/test/cli/format/oracle/sort-imports/make-fixtures.mjs index 2d1251315ca1..ecbd193ab792 100644 --- a/test/cli/format/oracle/sort-imports/make-fixtures.mjs +++ b/test/cli/format/oracle/sort-imports/make-fixtures.mjs @@ -1,10 +1,10 @@ // Makes test/cli/format/sort-imports/*.json of the cases that fixtures.mjs, fuzz.mjs and oxfmt.mjs make. // -// node make-fixtures.mjs --out= --sample=150 [--bin="bun format"] +// node make-fixtures.mjs --out= --sample=150 [--bin="bun fmt"] // // All cases of the tools' own tests are taken, and of the others (`fuzz/..`) every n-th, so that `--sample` are left of -// each file. Left out: what the tool itself fails on, syntax that bun format does not read, and, of the cases for oxfmt, -// those that bun format and oxfmt format differently without sorting imports (`plain`): they are not about imports. +// each file. Left out: what the tool itself fails on, syntax that bun fmt does not read, and, of the cases for oxfmt, +// those that bun fmt and oxfmt format differently without sorting imports (`plain`): they are not about imports. import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; @@ -28,7 +28,7 @@ for (const file of positional) { { ...it, options, output: it.plain }, { ...it, options, input: it.output }, ]); - const [bin, ...args] = (named.bin ?? "bun format").split(" "); + const [bin, ...args] = (named.bin ?? "bun fmt").split(" "); const differs = new Set(); // One run for all the cases with the same options. for (const [options, group] of Map.groupBy(plain, it => JSON.stringify(it.options))) { @@ -48,7 +48,7 @@ for (const file of positional) { for (const [index, it] of cases.entries()) { if (index % step !== 0 || UNSUPPORTED.some(pattern => pattern.test(it.name)) || /\bassert\s*\{/.test(it.input) || REFUSED.test(it.input)) continue; const { parser, flavor, ...options } = it.options; - // `bun format` refuses the syntax of TypeScript in a JavaScript file, as Prettier does, unless such a parser is asked for. + // `bun fmt` refuses the syntax of TypeScript in a JavaScript file, as Prettier does, unless such a parser is asked for. if (/\.[cm]?jsx?$/.test(it.filename) && ["typescript", "babel-ts", "flow", "babel-flow"].includes(parser) && /^tests\/(Angular|Typescript)/.test(it.name)) { options.overrides = [{ files: "*.js", options: { parser } }]; } diff --git a/test/cli/format/oracle/sort-imports/oxfmt.mjs b/test/cli/format/oracle/sort-imports/oxfmt.mjs index daa8ca27254d..da4811f9600d 100644 --- a/test/cli/format/oracle/sort-imports/oxfmt.mjs +++ b/test/cli/format/oracle/sort-imports/oxfmt.mjs @@ -5,7 +5,7 @@ // // - `tests`: every `assert_format(input, config, expected)` of crates/oxc_formatter/tests/ir_transform/sort_imports/*.rs. // - `tokens`, `lines`: as in fuzz.mjs. -// A case also has `plain`: what oxfmt prints without `sortImports`. Where bun format and oxfmt differ on that, the +// A case also has `plain`: what oxfmt prints without `sortImports`. Where bun fmt and oxfmt differ on that, the // difference is not about imports. import fs from "node:fs"; import { createRequire } from "node:module"; diff --git a/test/cli/format/own/README.md b/test/cli/format/own/README.md index b9cedef67092..f46e2dfbb73e 100644 --- a/test/cli/format/own/README.md +++ b/test/cli/format/own/README.md @@ -1,9 +1,9 @@ # Inputs of our own -Small programs on which `bun format` once differed from Prettier, and which no test of Prettier or of oxfmt has: most were found in real code. They are in the form of oxfmt's tests (`../oxfmt/README.md`), as plain files: +Small programs on which `bun fmt` once differed from Prettier, and which no test of Prettier or of oxfmt has: most were found in real code. They are in the form of oxfmt's tests (`../oxfmt/README.md`), as plain files: - `cases/**/.input`: the input. `` says what language it is. `.input` keeps everything else from taking it for code. -- `.prettier.snap`: what Prettier prints, which is what `bun format` has to print. +- `.prettier.snap`: what Prettier prints, which is what `bun fmt` has to print. - `.snap`: what oxfmt prints, which is what it has to print for whoever has an `.oxfmtrc.json`. Where it does not yet, the file is called `.snap.todo` and is not compared. - `options.json`: the sets of options for the inputs of a directory. Each is run at `printWidth` 80 and 100 too. @@ -13,4 +13,6 @@ To add one, write the input and run `sync.ts`, which asks both: bun test/cli/format/own/sync.ts ``` -**Nothing here fails**, so there is no list of what is expected to: `../conformance.test.ts` wants no line that starts with `FAIL`. An input comes in the commit that makes `bun format` print what Prettier prints for it. If the runner says `FAIL oxfmt` for it, rename its `.snap` to `.snap.todo`. +**Nothing here fails**, so there is no list of what is expected to: `../conformance.test.ts` wants no line that starts with `FAIL`. An input comes in the commit that makes `bun fmt` print what Prettier prints for it. If the runner says `FAIL oxfmt` for it, rename its `.snap` to `.snap.todo`. + +**What `bun fmt` prints differently on purpose is not here.** Where what Prettier or oxfmt prints means something else than the input, `bun fmt` prints the right thing. Such an input is a row in `../format.test.ts`, with what `bun fmt` prints and what the tool prints. diff --git a/test/cli/format/own/cases/handlebars/an-empty-first-segment.hbs.input b/test/cli/format/own/cases/handlebars/an-empty-first-segment.hbs.input deleted file mode 100644 index 589cb3104b36..000000000000 --- a/test/cli/format/own/cases/handlebars/an-empty-first-segment.hbs.input +++ /dev/null @@ -1 +0,0 @@ -{{[].a}} {{[]/a}} {{@[].a}} {{a.[]}} {{a [].b c=[].d}} diff --git a/test/cli/format/own/cases/handlebars/an-empty-first-segment.hbs.prettier.snap b/test/cli/format/own/cases/handlebars/an-empty-first-segment.hbs.prettier.snap deleted file mode 100644 index e1d7b2bac77a..000000000000 --- a/test/cli/format/own/cases/handlebars/an-empty-first-segment.hbs.prettier.snap +++ /dev/null @@ -1,9 +0,0 @@ ------------------- -{ printWidth: 80 } ------------------- -{{a}} {{a}} {{@a}} {{a.}} {{a b c=d}} -------------------- -{ printWidth: 100 } -------------------- -{{a}} {{a}} {{@a}} {{a.}} {{a b c=d}} -===================== End ===================== diff --git a/test/cli/format/own/cases/handlebars/an-empty-first-segment.hbs.snap b/test/cli/format/own/cases/handlebars/an-empty-first-segment.hbs.snap deleted file mode 100644 index e1d7b2bac77a..000000000000 --- a/test/cli/format/own/cases/handlebars/an-empty-first-segment.hbs.snap +++ /dev/null @@ -1,9 +0,0 @@ ------------------- -{ printWidth: 80 } ------------------- -{{a}} {{a}} {{@a}} {{a.}} {{a b c=d}} -------------------- -{ printWidth: 100 } -------------------- -{{a}} {{a}} {{@a}} {{a.}} {{a b c=d}} -===================== End ===================== diff --git a/test/cli/format/own/cases/handlebars/columns-behind-a-line-separator.hbs.input b/test/cli/format/own/cases/handlebars/columns-behind-a-line-separator.hbs.input deleted file mode 100644 index 0a56949794ff..000000000000 --- a/test/cli/format/own/cases/handlebars/columns-behind-a-line-separator.hbs.input +++ /dev/null @@ -1,5 +0,0 @@ -a -
{{~! b ~}}

-
{{! prettier-ignore }} {{ i }} {{!-- j --~}} -{{k -
 l}} {{~! m }} diff --git a/test/cli/format/own/cases/handlebars/columns-behind-a-line-separator.hbs.prettier.snap b/test/cli/format/own/cases/handlebars/columns-behind-a-line-separator.hbs.prettier.snap deleted file mode 100644 index 794f461f0a26..000000000000 --- a/test/cli/format/own/cases/handlebars/columns-behind-a-line-separator.hbs.prettier.snap +++ /dev/null @@ -1,23 +0,0 @@ ------------------- -{ printWidth: 80 } ------------------- -a 
{{! b }} -

- -

{{! prettier-ignore } - {{ i } -{{! j }} -{{k l}} -{{! m }} -------------------- -{ printWidth: 100 } -------------------- -a 
{{! b }} -

- -

{{! prettier-ignore } - {{ i } -{{! j }} -{{k l}} -{{! m }} -===================== End ===================== diff --git a/test/cli/format/own/cases/handlebars/columns-behind-a-line-separator.hbs.snap b/test/cli/format/own/cases/handlebars/columns-behind-a-line-separator.hbs.snap deleted file mode 100644 index 794f461f0a26..000000000000 --- a/test/cli/format/own/cases/handlebars/columns-behind-a-line-separator.hbs.snap +++ /dev/null @@ -1,23 +0,0 @@ ------------------- -{ printWidth: 80 } ------------------- -a 
{{! b }} -

- -

{{! prettier-ignore } - {{ i } -{{! j }} -{{k l}} -{{! m }} -------------------- -{ printWidth: 100 } -------------------- -a 
{{! b }} -

- -

{{! prettier-ignore } - {{ i } -{{! j }} -{{k l}} -{{! m }} -===================== End ===================== diff --git a/test/cli/format/own/cases/handlebars/what-the-parsers-change.hbs.input b/test/cli/format/own/cases/handlebars/what-the-parsers-change.hbs.input new file mode 100644 index 000000000000..4ea2e146ce5b --- /dev/null +++ b/test/cli/format/own/cases/handlebars/what-the-parsers-change.hbs.input @@ -0,0 +1,11 @@ +{{@a/b}} {{a.[b].[c d]}} +{{&a}} +{{1.50}} {{-0}} {{007}} +{{^a}}b{{else}}c{{/a}} {{#a}}b{{^}}c{{/a}} + {{> b}} {{/if}} --> +
+a\{{b}} c\\{{d}} e\\\{{f}} +{{#a}}{{else}}{{#if b}}{{/if}}{{/a}} {{#a}}{{else b}}{{else a}}{{/a}} {{#a}}{{~else b~}}c{{~/a~}} +{{a "b\"c" 'd\'e' g="h'i\"j"}} +<b> +{{a "f\"}} diff --git a/test/cli/format/own/cases/handlebars/what-the-parsers-drop-and-change.hbs.prettier.snap b/test/cli/format/own/cases/handlebars/what-the-parsers-change.hbs.prettier.snap similarity index 69% rename from test/cli/format/own/cases/handlebars/what-the-parsers-drop-and-change.hbs.prettier.snap rename to test/cli/format/own/cases/handlebars/what-the-parsers-change.hbs.prettier.snap index 77425422ed59..004909a840fc 100644 --- a/test/cli/format/own/cases/handlebars/what-the-parsers-drop-and-change.hbs.prettier.snap +++ b/test/cli/format/own/cases/handlebars/what-the-parsers-change.hbs.prettier.snap @@ -1,38 +1,20 @@ ------------------ { printWidth: 80 } ------------------ - -{{a}} -{{this.this}} -{{this}} -{{a b}} -{{a/b}} -{{a.[#b]}} {{@a/b}} {{a.b.[c d]}} {{{a}}} -{{"a"}} -{{ c}} -{{a ( d)}} {{1.5}} {{0}} {{7}} -{{1.2345678901234568e+29}} -{{1e-7}} {{#a}}c{{else}}b{{/a}} {{#a}}b{{else}}c{{/a}} -{{#raw a}} \{{c}} {{/raw}} - -{{a}} -{{b}} {{> b}} {{/if}} --> -b a\{{b}} c\\{{d}} e\\\{{f}} - {{#a}}{{else if b}}{{/a}} {{#a}}{{else}}{{#b}}{{else}}{{#a}}{{/a}}{{/b}}{{/a}} {{#a}}{{~else~}}{{~#b~}}c{{~/b~}}{{~/a~}} @@ -48,38 +30,20 @@ e\\\{{f}} ------------------- { printWidth: 100 } ------------------- - -{{a}} -{{this.this}} -{{this}} -{{a b}} -{{a/b}} -{{a.[#b]}} {{@a/b}} {{a.b.[c d]}} {{{a}}} -{{"a"}} -{{ c}} -{{a ( d)}} {{1.5}} {{0}} {{7}} -{{1.2345678901234568e+29}} -{{1e-7}} {{#a}}c{{else}}b{{/a}} {{#a}}b{{else}}c{{/a}} -{{#raw a}} \{{c}} {{/raw}} - -{{a}} -{{b}} {{> b}} {{/if}} --> -b a\{{b}} c\\{{d}} e\\\{{f}} - {{#a}}{{else if b}}{{/a}} {{#a}}{{else}}{{#b}}{{else}}{{#a}}{{/a}}{{/b}}{{/a}} {{#a}}{{~else~}}{{~#b~}}c{{~/b~}}{{~/a~}} diff --git a/test/cli/format/own/cases/handlebars/what-the-parsers-drop-and-change.hbs.snap b/test/cli/format/own/cases/handlebars/what-the-parsers-change.hbs.snap similarity index 69% rename from test/cli/format/own/cases/handlebars/what-the-parsers-drop-and-change.hbs.snap rename to test/cli/format/own/cases/handlebars/what-the-parsers-change.hbs.snap index 77425422ed59..004909a840fc 100644 --- a/test/cli/format/own/cases/handlebars/what-the-parsers-drop-and-change.hbs.snap +++ b/test/cli/format/own/cases/handlebars/what-the-parsers-change.hbs.snap @@ -1,38 +1,20 @@ ------------------ { printWidth: 80 } ------------------ - -{{a}} -{{this.this}} -{{this}} -{{a b}} -{{a/b}} -{{a.[#b]}} {{@a/b}} {{a.b.[c d]}} {{{a}}} -{{"a"}} -{{ c}} -{{a ( d)}} {{1.5}} {{0}} {{7}} -{{1.2345678901234568e+29}} -{{1e-7}} {{#a}}c{{else}}b{{/a}} {{#a}}b{{else}}c{{/a}} -{{#raw a}} \{{c}} {{/raw}} - -{{a}} -{{b}} {{> b}} {{/if}} --> -b a\{{b}} c\\{{d}} e\\\{{f}} - {{#a}}{{else if b}}{{/a}} {{#a}}{{else}}{{#b}}{{else}}{{#a}}{{/a}}{{/b}}{{/a}} {{#a}}{{~else~}}{{~#b~}}c{{~/b~}}{{~/a~}} @@ -48,38 +30,20 @@ e\\\{{f}} ------------------- { printWidth: 100 } ------------------- - -{{a}} -{{this.this}} -{{this}} -{{a b}} -{{a/b}} -{{a.[#b]}} {{@a/b}} {{a.b.[c d]}} {{{a}}} -{{"a"}} -{{ c}} -{{a ( d)}} {{1.5}} {{0}} {{7}} -{{1.2345678901234568e+29}} -{{1e-7}} {{#a}}c{{else}}b{{/a}} {{#a}}b{{else}}c{{/a}} -{{#raw a}} \{{c}} {{/raw}} - -{{a}} -{{b}} {{> b}} {{/if}} --> -b a\{{b}} c\\{{d}} e\\\{{f}} - {{#a}}{{else if b}}{{/a}} {{#a}}{{else}}{{#b}}{{else}}{{#a}}{{/a}}{{/b}}{{/a}} {{#a}}{{~else~}}{{~#b~}}c{{~/b~}}{{~/a~}} diff --git a/test/cli/format/own/cases/handlebars/what-the-parsers-drop-and-change.hbs.input b/test/cli/format/own/cases/handlebars/what-the-parsers-drop-and-change.hbs.input deleted file mode 100644 index 8e82ba90e251..000000000000 --- a/test/cli/format/own/cases/handlebars/what-the-parsers-drop-and-change.hbs.input +++ /dev/null @@ -1,14 +0,0 @@ - - -{{this/a}} {{[this]}} {{this.this}} {{a ...b}} {{[a/b]}} {{a.#b}} {{@a/b}} {{a.[b].[c d]}} -{{&a}} {{"a" b c=d}} {{(a=b) c}} {{a ((b=c) d)}} -{{1.50}} {{-0}} {{007}} {{123456789012345678901234567890}} {{0.0000001}} -{{^a}}b{{else}}c{{/a}} {{#a}}b{{^}}c{{/a}} -{{{{raw a}}}} {{c}} {{{{/raw}}}} - {{> b}} {{/if}} --> -< a>b -a\{{b}} c\\{{d}} e\\\{{f}} -{{#a}}{{else}}{{#if b}}{{/if}}{{/a}} {{#a}}{{else b}}{{else a}}{{/a}} {{#a}}{{~else b~}}c{{~/a~}} -{{a "b\"c" 'd\'e' g="h'i\"j"}} -<b> -{{a "f\"}} diff --git a/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution-and-not-closed.js.input b/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution-and-not-closed.js.input deleted file mode 100644 index 9948bc97cb33..000000000000 --- a/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution-and-not-closed.js.input +++ /dev/null @@ -1,7 +0,0 @@ -a = html`<${t} >`; -b = html`<${t} ${a}>x`; -c = html``; -d = html`<${t} ${a}>x`; -e = open ? html`<${t} ${a}>` : html`<${t} ${a}>${k}`; -f = html`<${t} ${a} />`; -g = html`
`; diff --git a/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution-and-not-closed.js.prettier.snap b/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution-and-not-closed.js.prettier.snap deleted file mode 100644 index e85e865a148c..000000000000 --- a/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution-and-not-closed.js.prettier.snap +++ /dev/null @@ -1,23 +0,0 @@ ------------------- -{ printWidth: 80 } ------------------- -a = html`<${t}>`; -b = html`<${t} ${a}>x`; -c = html``; -d = html`<${t} ${a}>x`; -e = open ? html`<${t} ${a}>` : html`<${t} ${a}>${k}`; -f = html`<${t} ${a} />`; -g = html`
`; - -------------------- -{ printWidth: 100 } -------------------- -a = html`<${t}>`; -b = html`<${t} ${a}>x`; -c = html``; -d = html`<${t} ${a}>x`; -e = open ? html`<${t} ${a}>` : html`<${t} ${a}>${k}`; -f = html`<${t} ${a} />`; -g = html`
`; - -===================== End ===================== diff --git a/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution-and-not-closed.js.snap b/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution-and-not-closed.js.snap deleted file mode 100644 index 309abe1eee2b..000000000000 --- a/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution-and-not-closed.js.snap +++ /dev/null @@ -1,23 +0,0 @@ ------------------- -{ printWidth: 80 } ------------------- -a = html`<${t} >`; -b = html`<${t} ${a}>x`; -c = html``; -d = html`<${t} ${a}>x`; -e = open ? html`<${t} ${a}>` : html`<${t} ${a}>${k}`; -f = html`<${t} ${a} />`; -g = html`
`; - -------------------- -{ printWidth: 100 } -------------------- -a = html`<${t} >`; -b = html`<${t} ${a}>x`; -c = html``; -d = html`<${t} ${a}>x`; -e = open ? html`<${t} ${a}>` : html`<${t} ${a}>${k}`; -f = html`<${t} ${a} />`; -g = html`
`; - -===================== End ===================== diff --git a/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution.js.input b/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution.js.input new file mode 100644 index 000000000000..29d53ef6d148 --- /dev/null +++ b/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution.js.input @@ -0,0 +1,3 @@ +a = html`<${t} ${a}>${k}`; +b = html`<${t} ${a} />`; +c = html`
`; diff --git a/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution.js.prettier.snap b/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution.js.prettier.snap new file mode 100644 index 000000000000..98cff6a5a94b --- /dev/null +++ b/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution.js.prettier.snap @@ -0,0 +1,15 @@ +------------------ +{ printWidth: 80 } +------------------ +a = html`<${t} ${a}>${k}`; +b = html`<${t} ${a} />`; +c = html`
`; + +------------------- +{ printWidth: 100 } +------------------- +a = html`<${t} ${a}>${k}`; +b = html`<${t} ${a} />`; +c = html`
`; + +===================== End ===================== diff --git a/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution.js.snap b/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution.js.snap new file mode 100644 index 000000000000..98cff6a5a94b --- /dev/null +++ b/test/cli/format/own/cases/html/template-whose-tag-is-a-substitution.js.snap @@ -0,0 +1,15 @@ +------------------ +{ printWidth: 80 } +------------------ +a = html`<${t} ${a}>${k}`; +b = html`<${t} ${a} />`; +c = html`
`; + +------------------- +{ printWidth: 100 } +------------------- +a = html`<${t} ${a}>${k}`; +b = html`<${t} ${a} />`; +c = html`
`; + +===================== End ===================== diff --git a/test/cli/format/own/cases/types/options-of-an-import-type-in-the-options-of-another.ts.input b/test/cli/format/own/cases/types/options-of-an-import-type-in-the-options-of-another.ts.input new file mode 100644 index 000000000000..1e314e611e63 --- /dev/null +++ b/test/cli/format/own/cases/types/options-of-an-import-type-in-the-options-of-another.ts.input @@ -0,0 +1,9 @@ +type A = import("./a", { with: { k: 0 as import("./c", { with: { inner: 1 } }), outer: 2 } }); +type B = import("./a", { with: { outer: 2, k: 0 as import("./c", { with: { inner: 1 } }) } }); +import data from "./d.json" with { type: "json" }; +type C = import("./m", { with: { a: 0 as import("./n", { with: { x: () => q } }), b: 0 as import("./o", { with: { y: () => q } }), c: () => q } }); +type D = import("./m", { with: { a: 0 as import("./n", { with: { x: 0 as import("./o", { with: { y: () => q } }), x2: () => q } }), c: () => q } }); +type E = import("./m", { with: { a: () => { let u: import("./n", { with: { b: { x: 1 }, c: () => 2 } }); return u; } } }); +type F = import("./a", { with: { "k": 0 as import("./c", { with: { "inner": "v1" } }), "outer": "v2" } }); +type G = import("./a", { with: { outer: "1" } }); +type H = import("./a"); diff --git a/test/cli/format/own/cases/types/options-of-an-import-type-in-the-options-of-another.ts.prettier.snap b/test/cli/format/own/cases/types/options-of-an-import-type-in-the-options-of-another.ts.prettier.snap new file mode 100644 index 000000000000..89c356ebe2f2 --- /dev/null +++ b/test/cli/format/own/cases/types/options-of-an-import-type-in-the-options-of-another.ts.prettier.snap @@ -0,0 +1,75 @@ +------------------ +{ printWidth: 80 } +------------------ +type A = import("./a", { + with: { k: 0 as import("./c", { with: { inner: 1 } }), outer: 2 }, +}); +type B = import("./a", { + with: { outer: 2, k: 0 as import("./c", { with: { inner: 1 } }) }, +}); +import data from "./d.json" with { type: "json" }; +type C = import("./m", { + with: { + a: 0 as import("./n", { with: { x: () => q } }), + b: 0 as import("./o", { with: { y: () => q } }), + c: () => q, + }, +}); +type D = import("./m", { + with: { + a: 0 as import("./n", { + with: { x: 0 as import("./o", { with: { y: () => q } }), x2: () => q }, + }), + c: () => q, + }, +}); +type E = import("./m", { + with: { + a: () => { + let u: import("./n", { with: { b: { x: 1 }, c: () => 2 } }); + return u; + }, + }, +}); +type F = import("./a", { + with: { k: 0 as import("./c", { with: { inner: "v1" } }), outer: "v2" }, +}); +type G = import("./a", { with: { outer: "1" } })< + import("./c", { with: { inner: "2" } }) +>; +type H = import("./a"); + +------------------- +{ printWidth: 100 } +------------------- +type A = import("./a", { with: { k: 0 as import("./c", { with: { inner: 1 } }), outer: 2 } }); +type B = import("./a", { with: { outer: 2, k: 0 as import("./c", { with: { inner: 1 } }) } }); +import data from "./d.json" with { type: "json" }; +type C = import("./m", { + with: { + a: 0 as import("./n", { with: { x: () => q } }), + b: 0 as import("./o", { with: { y: () => q } }), + c: () => q, + }, +}); +type D = import("./m", { + with: { + a: 0 as import("./n", { + with: { x: 0 as import("./o", { with: { y: () => q } }), x2: () => q }, + }), + c: () => q, + }, +}); +type E = import("./m", { + with: { + a: () => { + let u: import("./n", { with: { b: { x: 1 }, c: () => 2 } }); + return u; + }, + }, +}); +type F = import("./a", { with: { k: 0 as import("./c", { with: { inner: "v1" } }), outer: "v2" } }); +type G = import("./a", { with: { outer: "1" } }); +type H = import("./a"); + +===================== End ===================== diff --git a/test/cli/format/own/cases/types/options-of-an-import-type-in-the-options-of-another.ts.snap b/test/cli/format/own/cases/types/options-of-an-import-type-in-the-options-of-another.ts.snap new file mode 100644 index 000000000000..98411dba3007 --- /dev/null +++ b/test/cli/format/own/cases/types/options-of-an-import-type-in-the-options-of-another.ts.snap @@ -0,0 +1,97 @@ +------------------ +{ printWidth: 80 } +------------------ +type A = import( + "./a", + { with: { k: 0 as import("./c", { with: { inner: 1 } }), outer: 2 } } +); +type B = import( + "./a", + { with: { outer: 2, k: 0 as import("./c", { with: { inner: 1 } }) } } +); +import data from "./d.json" with { type: "json" }; +type C = import( + "./m", + { + with: { + a: 0 as import("./n", { with: { x: () => q } }), + b: 0 as import("./o", { with: { y: () => q } }), + c: () => q, + }, + } +); +type D = import( + "./m", + { + with: { + a: 0 as import( + "./n", + { + with: { + x: 0 as import("./o", { with: { y: () => q } }), + x2: () => q, + }, + } + ), + c: () => q, + }, + } +); +type E = import("./m", { + with: { + a: () => { + let u: import("./n", { with: { b: { x: 1 }, c: () => 2 } }); + return u; + }, + }, +}); +type F = import( + "./a", + { with: { k: 0 as import("./c", { with: { inner: "v1" } }), outer: "v2" } } +); +type G = import("./a", { with: { outer: "1" } })< + import("./c", { with: { inner: "2" } }) +>; +type H = import("./a"); + +------------------- +{ printWidth: 100 } +------------------- +type A = import("./a", { with: { k: 0 as import("./c", { with: { inner: 1 } }), outer: 2 } }); +type B = import("./a", { with: { outer: 2, k: 0 as import("./c", { with: { inner: 1 } }) } }); +import data from "./d.json" with { type: "json" }; +type C = import( + "./m", + { + with: { + a: 0 as import("./n", { with: { x: () => q } }), + b: 0 as import("./o", { with: { y: () => q } }), + c: () => q, + }, + } +); +type D = import( + "./m", + { + with: { + a: 0 as import( + "./n", + { with: { x: 0 as import("./o", { with: { y: () => q } }), x2: () => q } } + ), + c: () => q, + }, + } +); +type E = import("./m", { + with: { + a: () => { + let u: import("./n", { with: { b: { x: 1 }, c: () => 2 } }); + return u; + }, + }, +}); +type F = import("./a", { with: { k: 0 as import("./c", { with: { inner: "v1" } }), outer: "v2" } }); +type G = import("./a", { with: { outer: "1" } }); +type H = import("./a"); + +===================== End ===================== diff --git a/test/cli/format/own/sync.ts b/test/cli/format/own/sync.ts index f82c099893cc..ffea350ce708 100644 --- a/test/cli/format/own/sync.ts +++ b/test/cli/format/own/sync.ts @@ -34,7 +34,7 @@ for (const [path, bytes] of files) { rmSync(directory, { recursive: true }); } writeFileSync(join(import.meta.dir, `${name}.prettier.snap`), render(asPrettier)); - // `.todo`: `bun format` does not print that yet, and it is not compared. + // `.todo`: `bun fmt` does not print that yet, and it is not compared. const snapshot = join(import.meta.dir, `${name}.snap`); if (asOxfmt.length > 0) writeFileSync(existsSync(`${snapshot}.todo`) ? `${snapshot}.todo` : snapshot, render(asOxfmt)); } diff --git a/test/cli/format/oxfmt-cli/cases.json b/test/cli/format/oxfmt-cli/cases.json index 0de69f2c8222..a9b50b307bb4 100644 --- a/test/cli/format/oxfmt-cli/cases.json +++ b/test/cli/format/oxfmt-cli/cases.json @@ -145,15 +145,6 @@ "root.js": "const introduce = \"I am root.js\"\n", "foo/file.js": "const message = 2\n" }, - "init": { - ".gitkeep": "" - }, - "init_already_exists": { - ".oxfmtrc.json": "{}" - }, - "init_with_schema": { - "node_modules/oxfmt/configuration_schema.json": "{}" - }, "insert_final_newline": { "editorconfig_only/.editorconfig": "root = true\n\n[*]\ninsert_final_newline = false\n", "editorconfig_only/test.css": ".foo { color: red }\n", @@ -1507,37 +1498,6 @@ "root.js" ] }, - { - "name": "init", - "index": 0, - "args": [ - "--init" - ], - "exitCode": 0, - "changed": { - ".oxfmtrc.json": "{\n \"ignorePatterns\": []\n}\n" - } - }, - { - "name": "init_already_exists", - "index": 0, - "args": [ - "--init" - ], - "exitCode": 1, - "changed": {} - }, - { - "name": "init_with_schema", - "index": 0, - "args": [ - "--init" - ], - "exitCode": 0, - "changed": { - ".oxfmtrc.json": "{\n \"$schema\": \"./node_modules/oxfmt/configuration_schema.json\",\n \"ignorePatterns\": []\n}\n" - } - }, { "name": "insert_final_newline", "index": 0, diff --git a/test/cli/format/oxfmt-cli/oxfmt-cli.test.ts b/test/cli/format/oxfmt-cli/oxfmt-cli.test.ts index 63756c6262f6..07b3ebc23619 100644 --- a/test/cli/format/oxfmt-cli/oxfmt-cli.test.ts +++ b/test/cli/format/oxfmt-cli/oxfmt-cli.test.ts @@ -3,7 +3,7 @@ // `cases.json` is written by `test/cli/lint/oracle/driver/oxfmt-fixtures.mjs --record`. import { afterAll, describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isWindows, tempDir } from "harness"; -import { existsSync, readFileSync, symlinkSync } from "node:fs"; +import { readFileSync, symlinkSync } from "node:fs"; import { join } from "node:path"; import { endChildren, spawn } from "../../children"; import { cases, fixtures } from "./cases.json"; @@ -11,17 +11,17 @@ import { cases, fixtures } from "./cases.json"; afterAll(endChildren); type Files = Record; -// A fixture has `experimentalTailwindcss`, which has no effect here. That `bun format` says so is tested elsewhere. -const command = [bunExe(), "format", "--allow-unsupported"]; +// A fixture has `experimentalTailwindcss`, which has no effect here. That `bun fmt` says so is tested elsewhere. +const command = [bunExe(), "fmt", "--allow-unsupported"]; const env = { NO_COLOR: "1", AGENT: "0", CLAUDECODE: undefined }; -describe.concurrent("bun format does what oxfmt does", () => { +describe.concurrent("bun fmt does what oxfmt does", () => { for (const it of cases) { const files = { ...(fixtures as Record)[it.name], ...it.gitignore } as Files; const links = Object.entries(files).filter(([, file]) => typeof file !== "string") as [string, { link: string }][]; test.skipIf(isWindows && links.length > 0)(`${it.name}/${it.index}: ${it.args.join(" ")}`, async () => { const texts = Object.entries(files).filter(([, file]) => typeof file === "string") as [string, string][]; - // Without a configuration file `bun format` is like Prettier. An empty one above the fixtures makes it like oxfmt. + // Without a configuration file `bun fmt` is like Prettier. An empty one above the fixtures makes it like oxfmt. using dir = tempDir("oxfmt-cli", { ".oxfmtrc.json": "{}\n", ...Object.fromEntries(texts.map(([name, text]) => [`fixtures/${name}`, text])), @@ -37,10 +37,8 @@ describe.concurrent("bun format does what oxfmt does", () => { stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - // Also what `--init` makes. - const made = Object.keys(it.changed).filter(name => !(name in files) && existsSync(join(root, name))); - const changed = [...texts, ...made.map(name => [name, undefined])].flatMap(([name, text]) => { - const after = readFileSync(join(root, name as string), "utf8"); + const changed = texts.flatMap(([name, text]) => { + const after = readFileSync(join(root, name), "utf8"); return after === text ? [] : [[name, after]]; }); const named = (stdout + stderr) diff --git a/test/cli/format/oxfmt/README.md b/test/cli/format/oxfmt/README.md index c31262112df8..67d1abcf052a 100644 --- a/test/cli/format/oxfmt/README.md +++ b/test/cli/format/oxfmt/README.md @@ -10,7 +10,7 @@ which Prettier. oxc is under the MIT license: `LICENSE`. Every input is formatted with each set of options of the nearest `options.json`, at `printWidth` 80 and 100, as oxc's test harness does. -- `.prettier.snap` is what `bun format` has to print. `oxfmt-ignore` says nothing to Prettier, and nothing to `bun format` in its flavor. +- `.prettier.snap` is what `bun fmt` has to print. `oxfmt-ignore` says nothing to Prettier, and nothing to `bun fmt` in its flavor. - `.snap` is what it has to print for whoever has an `.oxfmtrc.json` (`flavor: oxfmt`). ```sh diff --git a/test/cli/format/prettier-cli/prettier-cli.test.ts b/test/cli/format/prettier-cli/prettier-cli.test.ts index 8fd061347818..7247a9c80e7b 100644 --- a/test/cli/format/prettier-cli/prettier-cli.test.ts +++ b/test/cli/format/prettier-cli/prettier-cli.test.ts @@ -1,6 +1,6 @@ // The command lines of Prettier's own integration tests (`runCli("cli/..", [..])` in `tests/integration/__tests__/*.js` of // prettier/prettier, MIT: see LICENSE), with what Prettier 3.9 does in each: the exit code, the files afterwards, the files that -// `--check` and `--list-different` name, and what is printed for standard input. `bun format` writes unless told otherwise, so +// `--check` and `--list-different` name, and what is printed for standard input. `bun fmt` writes unless told otherwise, so // Prettier was given `--write` where the command line only prints. // `cases.json` is written by `test/cli/lint/oracle/driver/prettier-fixtures.mjs --record`. import { afterAll, describe, expect, test } from "bun:test"; @@ -13,12 +13,12 @@ import { cases, fixtures } from "./cases.json"; afterAll(endChildren); type Files = Record; -const command = [bunExe(), "format"]; -// The languages that `bun format` leaves alone. +const command = [bunExe(), "fmt"]; +// The languages that `bun fmt` leaves alone. const notCompared = /\.mdx$/; const env = { ...bunEnv, NO_COLOR: "1", AGENT: "0", CLAUDECODE: undefined }; -describe.concurrent("bun format does what Prettier does", () => { +describe.concurrent("bun fmt does what Prettier does", () => { for (const it of cases) { const top = it.directory.split("/").slice(0, 2).join("/"); const files = (fixtures as Record)[top]; diff --git a/test/cli/format/prettier/README.md b/test/cli/format/prettier/README.md index 519cbc3b793b..447fe4d68208 100644 --- a/test/cli/format/prettier/README.md +++ b/test/cli/format/prettier/README.md @@ -1,6 +1,6 @@ # Prettier's tests -`bun format` prints what [Prettier](https://github.com/prettier/prettier) prints. `bundle.zst` holds `tests/format/{js,jsx,typescript,flow,json,css,less,scss,graphql,yaml,markdown,mdx,handlebars,html,vue,angular,lwc,mjml,misc}` of Prettier: all 19 directories, unchanged: the inputs, and the snapshots +`bun fmt` prints what [Prettier](https://github.com/prettier/prettier) prints. `bundle.zst` holds `tests/format/{js,jsx,typescript,flow,json,css,less,scss,graphql,yaml,markdown,mdx,handlebars,html,vue,angular,lwc,mjml,misc}` of Prettier: all 19 directories, unchanged: the inputs, and the snapshots (`__snapshots__/format.test.js.snap`), which also say with which options each input is formatted. One file after the other, compressed with zstd: see `../bundle.ts`. `sync.ts` writes it, and `version.json` says from which commit. Prettier is under the MIT license: `LICENSE`. diff --git a/test/cli/format/sort-imports.test.ts b/test/cli/format/sort-imports.test.ts new file mode 100644 index 000000000000..2e57c1a7b64f --- /dev/null +++ b/test/cli/format/sort-imports.test.ts @@ -0,0 +1,799 @@ +// Tests for `sortImports` in bunfig.toml's `[fmt]` and for `--sort-imports`. On top of oxfmt's `sortImports`, they sort the +// names inside braces and merge imports from the same module. Neither has an option. +import { afterAll, describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { endChildren, spawn } from "../children"; + +afterAll(endChildren); + +async function run(cwd: string, args: string[]) { + await using proc = spawn({ + cmd: [bunExe(), "fmt", "--log-level=warn", ...args], + env: bunEnv, + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +/** Formats `files` twice. `--debug-check` comes first, which fails if sorting changes what a file imports. */ +async function format(files: Record, args: string[] = []) { + using dir = tempDir("bun-format-sort-imports", files); + const checked = await run(String(dir), [...args, "--debug-check", "."]); + const written = await run(String(dir), [...args, "."]); + const first = checked.exitCode === 0 ? written : checked; + const read = () => + Object.fromEntries(Object.keys(files).map(name => [name, readFileSync(join(String(dir), name), "utf8")])); + const after = read(); + const second = await run(String(dir), [...args, "."]); + return { ...first, files: after, second: { ...second, files: read() } }; +} + +const bunfig = { "bunfig.toml": "[fmt]\nsortImports = true\n" }; + +const cases: [name: string, input: string, output: string][] = [ + [ + "sorts names case-insensitively, with numbers in natural order, ignoring inline `type`", + `import { b, a, C, a10, a2, type T, type d, _e, $f } from "x"; +`, + `import { $f, _e, a, a2, a10, b, C, type d, type T } from "x"; +`, + ], + [ + "sorts `a as z` by the imported name", + `import { z as a, b as y, a as z, default as d, c } from "x"; +`, + `import { a as z, b as y, c, default as d, z as a } from "x"; +`, + ], + [ + "sorts string names by their contents", + `import { "b-c" as c, "a-b" as b, a } from "x"; +`, + `import { a, "a-b" as b, "b-c" as c } from "x"; +`, + ], + [ + "sorts names in `import type`", + `import type { B, a } from "x"; +`, + `import type { a, B } from "x"; +`, + ], + [ + "merges two imports from the same module", + `import { b } from "x"; +import { a } from "x"; +`, + `import { a, b } from "x"; +`, + ], + [ + "merges three imports with another module between them", + `import { c } from "x"; +import { y } from "y"; +import { a } from "x"; +import { b } from "x"; +`, + `import { a, b, c } from "x"; +import { y } from "y"; +`, + ], + [ + "treats 'x' and \"x\" as the same module", + `import { b } from 'x'; +import { a } from "x"; +`, + `import { a, b } from "x"; +`, + ], + [ + "merges a default import with named imports", + `import x from "x"; +import { a } from "x"; +`, + `import x, { a } from "x"; +`, + ], + [ + "merges named imports with a later default import", + `import { a } from "x"; +import x from "x"; +`, + `import x, { a } from "x"; +`, + ], + [ + "merges names into an import that has a default and names", + `import x, { b } from "x"; +import { a } from "x"; +`, + `import x, { a, b } from "x"; +`, + ], + [ + "keeps two different default imports separate", + `import x from "x"; +import y from "x"; +import { a } from "x"; +`, + `import x, { a } from "x"; +import y from "x"; +`, + ], + [ + "doesn't merge a default import with a namespace import", + `import x from "x"; +import * as y from "x"; +`, + `import x from "x"; +import * as y from "x"; +`, + ], + [ + "doesn't merge a namespace import with named imports", + `import * as y from "x"; +import { b, a } from "x"; +import { c } from "x"; +`, + `import * as y from "x"; +import { a, b, c } from "x"; +`, + ], + [ + "doesn't merge names into `import x, * as y`", + `import x, * as y from "x"; +import { a } from "x"; +`, + `import x, * as y from "x"; +import { a } from "x"; +`, + ], + [ + "keeps `import type` separate from value imports", + `import type { B } from "x"; +import { a } from "x"; +import type { A } from "x"; +import { b } from "x"; +`, + `import type { A, B } from "x"; +import { a, b } from "x"; +`, + ], + [ + "merges `{ type B }` with value imports", + `import { type B } from "x"; +import { a } from "x"; +`, + `import { a, type B } from "x"; +`, + ], + [ + "doesn't merge `import type X` with `import type { A }`", + `import type X from "x"; +import type { A } from "x"; +`, + `import type X from "x"; +import type { A } from "x"; +`, + ], + [ + "drops duplicate names", + `import { a, b, a } from "x"; +import { b } from "x"; +`, + `import { a, b } from "x"; +`, + ], + [ + "keeps the same name under different aliases", + `import { a as c } from "x"; +import { a as b, a } from "x"; +`, + `import { a, a as b, a as c } from "x"; +`, + ], + [ + "merges imports with equal import attributes", + `import { b } from "x" with { type: "json" }; +import { a } from "x" with { "type": 'json' }; +`, + `import { a, b } from "x" with { type: "json" }; +`, + ], + [ + "doesn't merge imports with different import attributes", + `import b from "x" with { type: "json" }; +import { a } from "x"; +import { c } from "x" with { type: "css" }; +`, + `import b from "x" with { type: "json" }; +import { a } from "x"; +import { c } from "x" with { type: "css" }; +`, + ], + [ + "never moves or merges anything across a side-effect import", + `import { z } from "z"; +import { b } from "x"; +import "./setup"; +import { a } from "x"; +import { c } from "a"; +import "./more"; +import "./again"; +import { d } from "a"; +`, + `import { b } from "x"; +import { z } from "z"; +import "./setup"; +import { c } from "a"; +import { a } from "x"; +import "./more"; +import "./again"; +import { d } from "a"; +`, + ], + [ + "never merges side-effect imports", + `import "x"; +import "x"; +import { a } from "x"; +`, + `import "x"; +import "x"; +import { a } from "x"; +`, + ], + [ + "merges other imports into the one that has a comment above it", + `import { c } from "x"; +// about b +import { b } from "x"; +import { a } from "x"; +`, + `// about b +import { a, b, c } from "x"; +`, + ], + [ + "doesn't merge two imports that both have a comment above them", + `// about b +import { b } from "x"; +// about a +import { a } from "x"; +`, + `// about b +import { b } from "x"; +// about a +import { a } from "x"; +`, + ], + [ + "merges other imports into the one that has a trailing comment", + `import { b } from "x"; // about b +import { a } from "x"; +`, + `import { a, b } from "x"; // about b +`, + ], + [ + "ignores a comment separated from the import by a blank line", + `import { b } from "x"; + +// about nothing + +import { a } from "x"; +`, + `import { a, b } from "x"; + +// about nothing +`, + ], + [ + "moves comments inside the braces with their names", + `import { + // before c + c, + b, // behind b + /* before a */ a, +} from "x"; +`, + `import { + /* before a */ a, + b, // behind b + // before c + c, +} from "x"; +`, + ], + [ + "moves comments inside the braces with their names across a merge", + `import { d, c /* behind c */ } from "x"; +import { + b, // behind b + // before a + a, +} from "x"; +`, + `import { + // before a + a, + b, // behind b + c /* behind c */, + d, +} from "x"; +`, + ], + [ + "keeps a line comment after the last name from swallowing the brace", + `import { b, // behind b + a // behind a +} from "x"; +`, + `import { + a, // behind a + b, // behind b +} from "x"; +`, + ], + [ + "keeps a comment before the closing brace at the end after a merge", + `import { + b, + a, + // the end +} from "x"; +import { c } from "x"; +`, + `import { + a, + b, + c, + // the end +} from "x"; +`, + ], + [ + "moves comments right outside the braces with the first and last name", + `import /* why */ { b, a } from "x"; +import { c } from "x"; +import { e, d } /* why */ from "y"; +import { f } from "y"; +`, + `import { a, /* why */ b, c } from "x"; +import { d /* why */, e, f } from "y"; +`, + ], + [ + "wraps a merged import that no longer fits on one line", + `import { someRatherLongName, anotherRatherLongName } from "some-module"; +import { yetAnotherRatherLongName, theLastName } from "some-module"; +`, + `import { + anotherRatherLongName, + someRatherLongName, + theLastName, + yetAnotherRatherLongName, +} from "some-module"; +`, + ], + [ + "leaves `export .. from` alone", + `export { b, a } from "x"; +export { c } from "x"; +import { e, d } from "x"; +`, + `export { b, a } from "x"; +export { c } from "x"; +import { d, e } from "x"; +`, + ], + [ + "leaves a prettier-ignore'd import alone and doesn't merge across it", + `import { b } from "x"; +// prettier-ignore +import { d, c } from "x"; +import { a } from "x"; +`, + `import { b } from "x"; +// prettier-ignore +import { d, c } from "x"; +import { a } from "x"; +`, + ], + [ + "doesn't merge across other statements", + `import { b } from "x"; +f(); +import { a } from "x"; +`, + `import { b } from "x"; +f(); +import { a } from "x"; +`, + ], + [ + "leaves `import {}` alone and treats `import x, {}` as `import x`", + `import {} from "x"; +import { a } from "x"; +import x, {} from "y"; +import { b } from "y"; +`, + `import {} from "x"; +import { a } from "x"; +import x, { b } from "y"; +`, + ], + [ + "merges imports that share a line", + `import { b } from "x"; import { a } from "x"; import y from "y"; +`, + `import { a, b } from "x"; +import y from "y"; +`, + ], + [ + "works after a directive and a top-of-file comment", + `// @ts-check +"use client"; +import { b } from "x"; +import { a } from "x"; +`, + `// @ts-check +"use client"; +import { a, b } from "x"; +`, + ], + [ + "handles CRLF line endings", + `import { b } from "x";\r +import {\r + d, // d\r + a,\r +} from "x";\r +`, + `import { + a, + b, + d, // d +} from "x"; +`, + ], + [ + "groups, sorts names and merges in one pass", + `import { z } from "./z"; +import fs, { readFileSync } from "node:fs"; +import { b } from "pkg"; +import { existsSync } from "node:fs"; +import type { T } from "pkg"; +import { a } from "pkg"; +import { y } from "./z"; +`, + `import fs, { existsSync, readFileSync } from "node:fs"; + +import { a, b } from "pkg"; +import type { T } from "pkg"; + +import { y, z } from "./z"; +`, + ], + [ + "moves comments right outside the braces across a merge", + `import { c } from "x"; +import /* first */ { b, a } /* last */ from "x"; +`, + `import { a /* last */, /* first */ b, c } from "x"; +`, + ], + [ + "merges two imports that both have a comment before the brace", + `import /* one */ { d, c } from "x"; +import /* two */ { b, a } from "x"; +`, + `import { a, /* two */ b, c, /* one */ d } from "x"; +`, + ], + [ + "keeps a comment next to a default import in place", + `import x /* why */ from "x"; +import { a } from "x"; +import /* why */ y from "y"; +import { b } from "y"; +`, + `import x /* why */, { a } from "x"; +import /* why */ y, { b } from "y"; +`, + ], + [ + "keeps a comment next to the module specifier in place", + `import { b } from /* why */ "x"; +import { a } from "x"; +`, + `import { a, b } from /* why */ "x"; +`, + ], + [ + "moves a line comment before the brace with the first name", + `import // why +{ b, a } from "x"; +import { c } from "x"; +`, + `import { + a, + // why + b, + c, +} from "x"; +`, + ], + [ + "keeps a comment before the closing brace", + `import { + b, + a, + // keep +} from "x"; +`, + `import { + a, + b, + // keep +} from "x"; +`, + ], + [ + "moves a trailing line comment with its name", + `import { + b, // about b + a, +} from "x"; +`, + `import { + a, + b, // about b +} from "x"; +`, + ], + [ + "moves a block comment after a name with that name", + `import { c /* about c */, b, a } from "x"; +`, + `import { a, b, c /* about c */ } from "x"; +`, + ], + [ + "moves a comment on its own line with the name below it", + `import { + c, + // about b + b, + a, +} from "x"; +`, + `import { + a, + // about b + b, + c, +} from "x"; +`, + ], + [ + "moves a comment before the first name with that name", + `import { + // about b + b, + a, +} from "x"; +`, + `import { + a, + // about b + b, +} from "x"; +`, + ], + [ + "keeps a comment before the closing brace when another import merges in", + `import { + b, + a, + // keep +} from "x"; +import { c } from "x"; +`, + `import { + a, + b, + c, + // keep +} from "x"; +`, + ], + [ + "keeps a comment before the closing brace of an import that gets merged away", + `import { c } from "x"; +import { + b, + a, + // keep +} from "x"; +`, + `import { + a, + b, + c, + // keep +} from "x"; +`, + ], + [ + "moves a trailing line comment with its name across a merge", + `import { c } from "x"; +import { + b, // about b + a, +} from "x"; +`, + `import { + a, + b, // about b + c, +} from "x"; +`, + ], + [ + "moves a block comment after a name with that name across a merge", + `import { d } from "x"; +import { c /* about c */, b, a } from "x"; +`, + `import { a, b, c /* about c */, d } from "x"; +`, + ], + [ + "moves a comment on its own line with the name below it across a merge", + `import { d } from "x"; +import { + c, + // about b + b, + a, +} from "x"; +`, + `import { + a, + // about b + b, + c, + d, +} from "x"; +`, + ], + [ + "moves a comment before the first name with that name across a merge", + `import { c } from "x"; +import { + // about b + b, + a, +} from "x"; +`, + `import { + a, + // about b + b, + c, +} from "x"; +`, + ], + [ + "keeps comments from both imports when they merge", + `import { + // about d + d, + c, // about c +} from "x"; +import { + b /* about b */, + // about a + a, +} from "x"; +`, + `import { + // about a + a, + b /* about b */, + c, // about c + // about d + d, +} from "x"; +`, + ], +]; + +describe("sortImports", () => { + // All cases share one run. + const name = (index: number) => `${index}.ts`; + const done = format({ ...bunfig, ...Object.fromEntries(cases.map((it, index) => [name(index), it[1]])) }); + + test("formats every case without errors", async () => { + const { stderr, exitCode, second } = await done; + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(second.stderr).toBe(""); + expect(second.exitCode).toBe(0); + }); + + test.each(cases.map((it, index) => [it[0], index] as const))("%s", async (_, index) => { + const { files, second } = await done; + expect(files[name(index)]).toBe(cases[index][2]); + // No comment gets lost or duplicated. + const comments = (code: string) => (code.match(/\/\/.*|\/\*.*?\*\//g) ?? []).sort(); + expect(comments(files[name(index)])).toEqual(comments(cases[index][1])); + // A second run changes nothing. + expect(second.files[name(index)]).toBe(cases[index][2]); + }); +}); + +describe.concurrent("sortImports sources", () => { + const input = `import { c } from "x";\nimport "./setup";\nimport { b, a } from "x";\nimport { d } from "a";\n`; + const tidy = `import { c } from "x";\nimport "./setup";\nimport { d } from "a";\nimport { a, b } from "x";\n`; + // oxfmt doesn't sort names or merge, and it keeps a side-effect import at its index while others move around it. + const asOxfmt = `import { d } from "a";\n\nimport "./setup";\nimport { c } from "x";\nimport { b, a } from "x";\n`; + + test("the key, the table and the flag all sort names and merge", async () => { + const [key, table, flag] = await Promise.all([ + format({ ...bunfig, "a.ts": input }), + format({ "bunfig.toml": "[fmt.sortImports]\nnewlinesBetween = false\n", "a.ts": input }), + format({ "a.ts": input }, ["--sort-imports"]), + ]); + expect([key.files["a.ts"], table.files["a.ts"], flag.files["a.ts"]]).toEqual([tidy, tidy, tidy]); + }); + + test("sortImports in .oxfmtrc.json keeps oxfmt's behavior unless --sort-imports is passed", async () => { + const files = { ".oxfmtrc.json": `{ "sortImports": true }`, "a.ts": input }; + const [oxfmt, flag] = await Promise.all([format(files), format(files, ["--sort-imports"])]); + expect(oxfmt.files["a.ts"]).toBe(asOxfmt); + expect(flag.files["a.ts"]).toBe(tidy); + }); + + test("sortSideEffects = true lets side-effect imports move", async () => { + const result = await format({ "bunfig.toml": "[fmt.sortImports]\nsortSideEffects = true\n", "a.ts": input }); + expect(result.files["a.ts"]).toBe( + `import { d } from "a";\nimport { c } from "x";\nimport { a, b } from "x";\n\nimport "./setup";\n`, + ); + }); + + test("works in JavaScript, JSX and Vue scripts", async () => { + const code = `import { b } from "x";\nimport { a } from "x";\n`; + const merged = `import { a, b } from "x";\n`; + const result = await format({ + ...bunfig, + "a.js": code, + "a.mjs": code, + "a.jsx": code, + "a.vue": `\n`, + "b.vue": `\n`, + }); + expect(result.stderr).toBe(""); + expect(result.files).toMatchObject({ + "a.js": merged, + "a.mjs": merged, + "a.jsx": merged, + "a.vue": `\n`, + "b.vue": `\n`, + }); + expect(result.second.files).toEqual(result.files); + }); + + test("skips a file with a syntax error", async () => { + const code = `import { b, a } from "x";\nimport { from "x";\n`; + const result = await format({ ...bunfig, "a.ts": code }); + expect(result.files["a.ts"]).toBe(code); + expect(result.exitCode).toBe(2); + }); +}); diff --git a/test/cli/format/sort-imports/LICENSE b/test/cli/format/sort-imports/LICENSE new file mode 100644 index 000000000000..261eeb9e9f8b --- /dev/null +++ b/test/cli/format/sort-imports/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/test/cli/format/sort-imports/sort-imports.test.ts b/test/cli/format/sort-imports/sort-imports.test.ts index 07110108afac..b4ccc0f12f84 100644 --- a/test/cli/format/sort-imports/sort-imports.test.ts +++ b/test/cli/format/sort-imports/sort-imports.test.ts @@ -26,7 +26,7 @@ const tools = { async function format(files: Record, names: string[], flags: string[] = []) { using dir = tempDir("bun-format-sort-imports", files); await using proc = spawn({ - cmd: [bunExe(), "format", "--log-level=warn", ...flags, "."], + cmd: [bunExe(), "fmt", "--log-level=warn", ...flags, "."], env: bunEnv, cwd: String(dir), stdout: "pipe", diff --git a/test/cli/format/tailwind/cases.json b/test/cli/format/tailwind/cases.json index d973b4ee4f30..5dad5f4f8371 100644 --- a/test/cli/format/tailwind/cases.json +++ b/test/cli/format/tailwind/cases.json @@ -1025,8 +1025,8 @@ "options": { "sortTailwindcss": {} }, - "input": "
\n
\n
\n
\n
\n
\n\n{{foo class=\"p-4 flex\"}}\n", - "output": "
\n
\n
\n
\n
\n
\n\n{{foo class=\"p-4 flex\"}}" + "input": "
\n
\n
\n
\n
\n\n{{foo class=\"p-4 flex\"}}\n", + "output": "
\n
\n
\n
\n
\n\n{{foo class=\"p-4 flex\"}}" }, { "name": "Angular", diff --git a/test/cli/format/tailwind/tailwind.test.ts b/test/cli/format/tailwind/tailwind.test.ts index 652e3744b1e2..475a83305ee7 100644 --- a/test/cli/format/tailwind/tailwind.test.ts +++ b/test/cli/format/tailwind/tailwind.test.ts @@ -41,7 +41,7 @@ exports.createContext = config => { async function formatIn(cwd: string, names: string[], flags: string[] = ["."], stdin?: string) { await using proc = spawn({ - cmd: [bunExe(), "format", "--log-level=warn", ...flags], + cmd: [bunExe(), "fmt", "--log-level=warn", ...flags], env: bunEnv, cwd, stdin: stdin === undefined ? "ignore" : Buffer.from(stdin), @@ -76,10 +76,63 @@ describe.concurrent("sortTailwindcss", () => { const input = ';\n'; const [sorted, reversed] = [';\n', ';\n']; - const unknown = "sortTailwindcss: The order of some classes cannot be found."; + const unknown = "sortTailwindcss could not find the order of some classes."; const listed = (root: string) => readdirSync(root, { recursive: true, encoding: "utf8" }).sort(); + describe("Handlebars: classes that oxfmt joins with what is next to them, or loses", () => { + // The template, what Bun makes of it, and what oxfmt 0.72 makes of it. It takes the blank at the end of a string away + // whatever follows, moves a class that touches what is before or behind the mustache, and loses a no-break space. + const rows = [ + [ + '\n', + '', + '', + ], + [ + '\n', + '', + '', + ], + [ + '\n', + ``, + ``, + ], + [ + '\n', + ``, + ``, + ], + [ + '\n', + ``, + ``, + ], + ['\n', '', ''], + // With white space in between nothing touches. + ['\n', ``, null], + ] as const; + const names = rows.map((_, index) => `${index}.hbs`); + const files = Object.fromEntries(rows.map((row, index) => [names[index], row[0]])); + const project = { ...version4, ".oxfmtrc.json": '{ "sortTailwindcss": {} }' }; + + test("stay apart", async () => { + for (const [, after, ofOxfmt] of rows) expect(after).not.toBe(ofOxfmt); + const result = await format({ ...project, ...files }, names); + expect(result.stderr).toBe(""); + expect(result.files).toEqual(rows.map(row => row[1])); + expect(result.exitCode).toBe(0); + }); + + test("--debug-check compares what would be written, and has nothing to say about it", async () => { + const result = await format({ ...project, ...files }, names, ["--debug-check"]); + expect(result.stderr).toBe(""); + expect(result.files).toEqual(rows.map(row => row[0])); + expect(result.exitCode).toBe(0); + }); + }); + test("nothing is written into the project, not even while Tailwind is asked", async () => { // The stand-in looks around when it is loaded, and refuses to answer if there is a file that the test has not made. const looksAround = `const { readdirSync } = require("node:fs"); @@ -95,7 +148,7 @@ if (now.join() !== before.join()) throw new Error("There is " + now.filter(it => }); const before = listed(String(dir)); await using proc = spawn({ - cmd: [bunExe(), "format", "--log-level=warn", "."], + cmd: [bunExe(), "fmt", "--log-level=warn", "."], env: { ...bunEnv, FILES_OF_THE_TEST: JSON.stringify(before) }, cwd: String(dir), stdout: "pipe", @@ -193,16 +246,17 @@ if (now.join() !== before.join()) throw new Error("There is " + now.filter(it => test("without the package it is an error, and the files with classes are left as they are", async () => { const files = { ".oxfmtrc.json": '{ "sortTailwindcss": true }', "a.jsx": `${input}a ;\n`, "b.js": "b ;\n" }; const result = await format(files, ["a.jsx", "b.js"]); - expect(result.stderr).toContain("[error] sortTailwindcss: It needs the package tailwindcss"); - expect(result.stderr).toContain(`[error] a.jsx: ${unknown} It is left as it is.\n`); + expect(result.stderr).toContain("[error] sortTailwindcss: the tailwindcss package was not found from "); + expect(result.stderr).toContain("[error] Run bun add -d tailwindcss\n"); + expect(result.stderr).toContain(`[error] a.jsx: ${unknown} It was not formatted.\n`); expect(result.files).toEqual([`${input}a ;\n`, "b;\n"]); expect(result.exitCode).toBe(2); // The first directory in the order of the names, not the one whose file has come first. const many = Object.fromEntries(["z", "c", "x", "b", "y"].map(name => [`${name}/a.jsx`, input])); const inMany = await format({ ".oxfmtrc.json": files[".oxfmtrc.json"], ...many }, []); - expect(inMany.stderr).toMatch(/which cannot be found from \S*[\\/]b\. Install it\./); + expect(inMany.stderr).toMatch(/was not found from \S*[\\/]b\n/); const allowed = await format(files, ["a.jsx", "b.js"], ["--allow-unsupported"]); - expect(allowed.stderr).toContain("[warn] sortTailwindcss: It needs the package tailwindcss"); + expect(allowed.stderr).toContain("[warn] sortTailwindcss: the tailwindcss package was not found from "); expect(allowed.files).toEqual([`${input}a;\n`, "b;\n"]); expect(allowed.exitCode).toBe(0); }); @@ -235,7 +289,7 @@ if (now.join() !== before.join()) throw new Error("There is " + now.filter(it => expect(Object.fromEntries(Object.keys(files).map(name => [name, read(name)]))).toEqual(expected); await using proc = spawn({ - cmd: [bunExe(), "format", "--stdin-filepath", "a.jsx"], + cmd: [bunExe(), "fmt", "--stdin-filepath", "a.jsx"], env: bunEnv, cwd: String(dir), stdin: Buffer.from(files["latin1.jsx"], "latin1"), @@ -262,8 +316,8 @@ if (now.join() !== before.join()) throw new Error("There is " + now.filter(it => }); const result = await formatIn(String(dir), ["a.jsx", "b.jsx", "c.jsx"]); expect(result.stderr.split("\n").filter(line => line.startsWith("[error]"))).toEqual([ - `[error] b.jsx: ${unknown} It is left as it is.`, - `[error] c.jsx: ${unknown} It is left as it is.`, + `[error] b.jsx: ${unknown} It was not formatted.`, + `[error] c.jsx: ${unknown} It was not formatted.`, ]); expect(result.files).toEqual([ sorted, @@ -291,9 +345,9 @@ if (now.join() !== before.join()) throw new Error("There is " + now.filter(it => writeFileSync(inside(`/${name}`), text); const result = await formatIn(String(dir), ["a.jsx"]); expect(result.stderr.split("\n").filter(line => line.startsWith("[error]"))).toEqual([ - `[error] caf\uFFFD/b.jsx: ${unknown} It is left as it is.`, - `[error] sortTailwindcss: ${String(dir)}/caf\uFFFD/node_modules/tailwindcss: It cannot be loaded from a path that is not UTF-8.`, - "[error] Left as it is: 1 file.", + `[error] caf\uFFFD/b.jsx: ${unknown} It was not formatted.`, + `[error] sortTailwindcss: ${String(dir)}/caf\uFFFD/node_modules/tailwindcss: cannot load Tailwind CSS from a path that is not UTF-8`, + "[error] 1 file with classes was not formatted.", ]); expect(result.files).toEqual([sorted]); expect(readFileSync(inside("/b.jsx"), "utf8")).toBe(`${input}b ;\n`); @@ -315,10 +369,10 @@ if (now.join() !== before.join()) throw new Error("There is " + now.filter(it => using dir = tempDir("bun-format-tailwind", { ".oxfmtrc.json": '{ "sortTailwindcss": true }' }); const from = ["--stdin-filepath", "a.jsx"]; const refused = await formatIn(String(dir), [], from, `${input}a ;\n`); - expect(refused.stderr).toContain("[error] sortTailwindcss: It needs the package tailwindcss"); + expect(refused.stderr).toContain("[error] sortTailwindcss: the tailwindcss package was not found from "); expect(refused).toMatchObject({ stdout: "", exitCode: 2 }); const allowed = await formatIn(String(dir), [], ["--allow-unsupported", ...from], `${input}a ;\n`); - expect(allowed.stderr).toContain("[warn] sortTailwindcss: It needs the package tailwindcss"); + expect(allowed.stderr).toContain("[warn] sortTailwindcss: the tailwindcss package was not found from "); expect(allowed).toMatchObject({ stdout: `${input}a;\n`, exitCode: 0 }); }); @@ -396,7 +450,7 @@ if (now.join() !== before.join()) throw new Error("There is " + now.filter(it => `[${allows ? "warn" : "error"}] sortTailwindcss: ${join(String(dir), "three/tailwind.config.js").replaceAll("\\", "/")}: It has no such plugin.`, ); expect(lists[0].stderr).toContain( - allows ? "The classes are not sorted in 7 files." : "Left as they are: 7 files.", + allows ? "Classes were not sorted in 7 files." : "7 files with classes were not formatted.", ); expect(Array.from(lists[0].stderr.matchAll(/(\w+)\/tailwind\.config\.js: /g), it => it[1])).toEqual([ "a", @@ -419,4 +473,479 @@ if (now.join() !== before.join()) throw new Error("There is " + now.filter(it => const files = { ".oxfmtrc.json": '{ "sortTailwindcss": true }', "a.jsx": ';\n' }; expect(await format(files, ["a.jsx"])).toMatchObject({ stderr: "", exitCode: 0 }); }); + + // What is expected is what Prettier 3.9.9 does with prettier-plugin-tailwindcss 0.8.1 and tailwindcss 4.3.3. + describe("prettier-plugin-tailwindcss in a Prettier config", () => { + const plugin = "prettier-plugin-tailwindcss"; + /** The plugin as far as a configuration can look at it. Nothing of it runs. */ + const installed = { + [`node_modules/${plugin}/package.json`]: JSON.stringify({ name: plugin, type: "module", exports: "./index.mjs" }), + [`node_modules/${plugin}/index.mjs`]: + 'export const options = { tailwindConfig: { type: "string" } };\nexport const parsers = {};\n', + }; + const project = { ...version4, ...installed }; + const config = (options: object) => ({ ".prettierrc": JSON.stringify({ plugins: [plugin], ...options }) }); + const shared = (main: string, text: string) => ({ + "node_modules/@acme/config/package.json": JSON.stringify({ name: "@acme/config", main }), + [`node_modules/@acme/config/${main}`]: text, + }); + + test.each<[string, Record, string[]?]>([ + ["its name", config({})], + ["a path", { ".prettierrc": `{ "plugins": ["./node_modules/${plugin}/index.mjs"] }` }], + ["require.resolve()", { "prettier.config.cjs": `module.exports = { plugins: [require.resolve("${plugin}")] };` }], + [ + "import.meta.resolve()", + { "prettier.config.mjs": `export default { plugins: [import.meta.resolve("${plugin}")] };` }, + ], + ["a URL", { "prettier.config.mjs": `export default { plugins: [new URL(import.meta.resolve("${plugin}"))] };` }], + ["the module", { "prettier.config.mjs": `import * as it from "${plugin}";\nexport default { plugins: [it] };` }], + ["import()", { "prettier.config.mjs": `export default { plugins: [await import("${plugin}")] };` }], + ["require()", { "prettier.config.cjs": `module.exports = { plugins: [require("${plugin}")] };` }], + [ + "an override", + { ".prettierrc": `{ "overrides": [{ "files": "*.jsx", "options": { "plugins": ["${plugin}"] } }] }` }, + ], + ["--plugin", {}, ["--plugin", plugin]], + ["--plugin beside a config", { ".prettierrc": "{}" }, [`--plugin=${plugin}`]], + ["package.json", { "package.json": `{ "prettier": { "plugins": ["${plugin}"] } }` }], + ["YAML", { ".prettierrc.yaml": `plugins:\n - ${plugin}\n` }], + ["TOML", { ".prettierrc.toml": `plugins = ["${plugin}"]\n` }], + [ + "a shared config", + { "package.json": '{ "prettier": "@acme/config" }', ...shared("a.json", `{ "plugins": ["${plugin}"] }`) }, + ], + [ + "a shared config in JavaScript", + { + ".prettierrc": '"@acme/config"', + ...shared("a.js", `module.exports = { plugins: [require.resolve("${plugin}")] };`), + }, + ], + ])("is named by %s", async (_, files, flags = []) => { + const result = await format({ ...project, ...files, "a.jsx": input }, ["a.jsx"], flags); + expect(result).toMatchObject({ stderr: "", files: [sorted], exitCode: 0 }); + }); + + test("counts for the files whose list of plugins has it", async () => { + const overrides = [ + { files: "off/**", options: { plugins: [] } }, + { files: "*.tsx", options: { tailwindPreserveDuplicates: true } }, + ]; + const twice = ';\n'; + const files = { ...config({ overrides }), "a.jsx": input, "off/a.jsx": input, "a.tsx": twice, "b.jsx": twice }; + const result = await format({ ...project, ...files }, ["a.jsx", "off/a.jsx", "a.tsx", "b.jsx"]); + expect(result).toMatchObject({ + stderr: "", + files: [sorted, input, ';\n', ';\n'], + exitCode: 0, + }); + }); + + test.each<[object, string, string]>([ + // No function takes classes unless the config says so. + [{}, 'cn("p-4 flex");\nclsx("p-4 flex");\ntwMerge("p-4 flex");\ntw`p-4 flex`;\n', ""], + [ + { tailwindFunctions: ["cn"] }, + 'cn("p-4 flex");\nclsx("p-4 flex");\ncn`p-4 flex`;\n', + 'cn("flex p-4");\nclsx("p-4 flex");\ncn`flex p-4`;\n', + ], + [ + { tailwindFunctions: ["cn"], overrides: [{ files: "*.jsx", options: { tailwindFunctions: ["tw"] } }] }, + 'cn("p-4 flex");\ntw("p-4 flex");\n', + 'cn("p-4 flex");\ntw("flex p-4");\n', + ], + [ + { tailwindAttributes: ["mine"] }, + ';\n', + ';\n', + ], + [{}, ';\n', ';\n'], + [ + { tailwindPreserveWhitespace: true }, + ';\n', + ';\n', + ], + [{ tailwindPreserveDuplicates: true }, ';\n', ';\n'], + ])("has its options and its defaults: %j", async (options, before, after) => { + const result = await format({ ...project, ...config(options), "a.jsx": before }, ["a.jsx"]); + expect(result).toMatchObject({ stderr: "", files: [after || before], exitCode: 0 }); + }); + + // The plugin says the same, behind the directory of the config and without the `[warn]`. + const deprecated = (name: string) => + `[warn] Deprecated: Use the \`tailwindStylesheet\` option for v4 projects instead of \`${name}\`.\n`; + const both = + "[warn] You have specified a Tailwind CSS stylesheet and a Tailwind CSS config at the same time. Use stylesheetPath unless you are using v3. Preferring the stylesheet.\n"; + test.each<[object, string]>([ + [{ tailwindStylesheet: "./css/app.css" }, ""], + [{ tailwindStylesheet: "css/app.css" }, ""], + [{ tailwindEntryPoint: "./css/app.css" }, deprecated("tailwindEntryPoint")], + [{ tailwindConfig: "./css/app.css" }, deprecated("tailwindConfig")], + [{ tailwindStylesheet: "./css/app.css", tailwindEntryPoint: "./none.css", tailwindConfig: "./none.js" }, both], + [{ overrides: [{ files: "src/**", options: { tailwindStylesheet: "./css/app.css" } }] }, ""], + ])("a path is relative to the config: %j", async (options, stderr) => { + const files = { ...config(options), "css/app.css": "/* reversed */\n", "src/a.jsx": sorted }; + using dir = tempDir("bun-format-tailwind", { ...project, ...files }); + const result = await formatIn(join(String(dir), "src"), ["a.jsx"], ["a.jsx"]); + expect(result).toMatchObject({ stderr, files: [reversed], exitCode: 0 }); + }); + + test.each(["tailwindStylesheet", "tailwindEntryPoint", "tailwindConfig"])("an empty %s is none", async name => { + const result = await format({ ...project, ...config({ [name]: "" }), "a.jsx": input }, ["a.jsx"]); + expect(result).toMatchObject({ stderr: "", files: [sorted], exitCode: 0 }); + }); + + test("a path is relative to the working directory if the config is the one that --config names", async () => { + const files = { + "config/.prettierrc": config({ tailwindStylesheet: "./app.css" })[".prettierrc"], + "a.jsx": sorted, + }; + const flags = ["--config", "config/.prettierrc"]; + const beside = await format({ ...project, ...files, "app.css": "/* reversed */\n" }, ["a.jsx"], flags); + expect(beside).toMatchObject({ stderr: "", files: [reversed], exitCode: 0 }); + const elsewhere = await format({ ...project, ...files, "config/app.css": "/* reversed */\n" }, ["a.jsx"], flags); + expect(elsewhere).toMatchObject({ files: [sorted], exitCode: 2 }); + expect(elsewhere.stderr).toContain("app.css"); + }); + + test("tailwindConfig is what Tailwind CSS 3 is loaded with", async () => { + const files = { ...version3, ...installed, ...config({ tailwindConfig: "./other.js" }), "src/a.jsx": sorted }; + const result = await format({ ...files, "other.js": "module.exports = { reversed: true };\n" }, ["src/a.jsx"]); + expect(result).toMatchObject({ stderr: "", files: [reversed], exitCode: 0 }); + }); + + test("tailwindPackageName is what Tailwind CSS is called", async () => { + const renamed = Object.fromEntries( + Object.entries(version4).map(([name, text]) => [name.replace("/tailwindcss/", "/@acme/tw/"), text]), + ); + const files = { ...renamed, ...installed, ...config({ tailwindPackageName: "@acme/tw" }), "a.jsx": input }; + expect(await format(files, ["a.jsx"])).toMatchObject({ stderr: "", files: [sorted], exitCode: 0 }); + }); + + test.each([ + [ + { tailwindFunctions: "cn" }, + 'Invalid tailwindFunctions value. Expected an array of a string, but received "cn".', + ], + [ + { tailwindAttributes: [1] }, + "Invalid tailwindAttributes value. Expected an array of a string, but received [1].", + ], + [ + { tailwindPreserveWhitespace: "yes" }, + 'Invalid tailwindPreserveWhitespace value. Expected true or false, but received "yes".', + ], + [{ tailwindStylesheet: 1 }, "Invalid tailwindStylesheet value. Expected a string, but received 1."], + [{ tailwindStylesheet: null }, "Invalid tailwindStylesheet value. Expected a string, but received null."], + [{ tailwindConfig: null }, "Invalid tailwindConfig value. Expected a string, but received null."], + [{ tailwindEntryPoint: null }, "Invalid tailwindEntryPoint value. Expected a string, but received null."], + [{ tailwindPackageName: null }, "Invalid tailwindPackageName value. Expected a string, but received null."], + [ + { tailwindFunctions: null }, + "Invalid tailwindFunctions value. Expected an array of a string, but received null.", + ], + [ + { tailwindAttributes: null }, + "Invalid tailwindAttributes value. Expected an array of a string, but received null.", + ], + [ + { tailwindPreserveWhitespace: null }, + "Invalid tailwindPreserveWhitespace value. Expected true or false, but received null.", + ], + [ + { tailwindPreserveDuplicates: null }, + "Invalid tailwindPreserveDuplicates value. Expected true or false, but received null.", + ], + ])("a value of the wrong type is refused: %j", async (options, refusal) => { + const result = await format({ ...project, ...config(options), "a.jsx": input }, ["a.jsx"]); + expect(result.stderr).toBe(`[error] ${refusal}\n`); + expect(result.files).toEqual([input]); + expect(result.exitCode).not.toBe(0); + }); + + test("without it its options are unknown, and with it an option that it does not have is", async () => { + const options = { tailwindFunctions: ["cn"], tailwindConfig: "./x.js", tailwindPreserveWhitespace: true }; + const without = await format({ ...project, ".prettierrc": JSON.stringify(options), "a.jsx": input }, ["a.jsx"]); + expect(without).toMatchObject({ + stderr: + '[warn] Ignored unknown option { tailwindFunctions: ["cn"] }.\n' + + '[warn] Ignored unknown option { tailwindConfig: "./x.js" }.\n' + + "[warn] Ignored unknown option { tailwindPreserveWhitespace: true }.\n", + files: [input], + exitCode: 0, + }); + const unknown = await format({ ...project, ...config({ tailwindOther: 1 }), "a.jsx": input }, ["a.jsx"]); + expect(unknown).toMatchObject({ + stderr: "[warn] Ignored unknown option { tailwindOther: 1 }.\n", + files: [sorted], + exitCode: 0, + }); + }); + + test("another plugin beside it is still refused, by its name", async () => { + const files = { ".prettierrc": JSON.stringify({ plugins: [plugin, "prettier-plugin-other"] }), "a.jsx": input }; + const result = await format({ ...project, ...files }, ["a.jsx"], ["--log-level=log", "--check"]); + expect(result.stderr).toContain("plugins that bun fmt does not include: prettier-plugin-other\n"); + // That it has no effect is said in a run that goes on without it. + expect(result.stderr).not.toContain("has no effect"); + // Nothing is said about files that have not been looked at. + expect(result.stdout).toBe("Checking formatting...\n"); + expect(result.files).toEqual([input]); + expect(result.exitCode).toBe(2); + }); + + const names = { tailwindFunctions: ["cn", "/^tw/"], tailwindAttributes: ["mine", "/^data-cls/"] }; + + test("where classes are is what it says, not oxfmt", async () => { + // The lines of a file, before and after. + const rows: Record = { + "a.jsx": [ + // However deep in an argument or in the value of an attribute. + ['cn(other("p-4 flex"));', 'cn(other("flex p-4"));'], + [';', ';'], + ['cn(a === "p-4 flex" && "flex m-2");', 'cn(a === "flex p-4" && "m-2 flex");'], + ['cn();', 'cn();'], + ["cn(other`p-4 flex`);", "cn(other`flex p-4`);"], + [ + 'cn({ "p-4 flex": a, [`p-1 m-2`]: b, ["p-2 m-3"]: c });', + 'cn({ "flex p-4": a, [`m-2 p-1`]: b, ["m-3 p-2"]: c });', + ], + // In chains. + ['cn("p-4 flex").b("p-4 flex");', 'cn("flex p-4").b("flex p-4");'], + ['cn.a.b.c("p-4 flex").d("p-4 flex").e("p-4 flex");', 'cn.a.b.c("flex p-4").d("flex p-4").e("flex p-4");'], + ['x.cn("p-4 flex");', ""], + ['new cn("p-4 flex");', ""], + // Patterns. + ['twx("p-4 flex");', 'twx("flex p-4");'], + [ + ';', + ';', + ], + // To Babel a call in an optional chain is another kind of node. + ['cn?.("p-4 flex");', ""], + ['cn?.x.y("p-4 flex");', ""], + // What is added to stays apart, up to the innermost function. + ['cn(a + (b ? " p-4 flex " : c));', 'cn(a + (b ? " flex p-4" : c));'], + ['cn("x " + cn(" p-4 flex "));', 'cn("x " + cn("flex p-4"));'], + ['"x " + cn` p-4 flex ` + " y";', '"x " + cn` flex p-4 ` + " y";'], + // Of the texts of a template it looks at the wrong ends, which leaves more blanks than are needed. + ['cn(`x ${" p-4 flex "} y`);', 'cn(`x ${" flex p-4 "} y`);'], + ['cn(`${" p-4 flex "}`);', 'cn(`${" flex p-4 "}`);'], + ['cn(` ${" p-4 flex "} `);', 'cn(` ${"flex p-4"} `);'], + ["cn(`p-4 flex${a}m-2 p-1`);", ""], + ["cn(` p-4 flex ${a} p-1 m-2 `);", "cn(`flex p-4 ${a} m-2 p-1`);"], + // A list that was on several lines does not keep the element open. + [';', ';'], + ], + "a.tsx": [ + ['cn?.("p-4 flex");', 'cn?.("flex p-4");'], + ['cn?.x.y("p-4 flex");', 'cn?.x.y("flex p-4");'], + ['(cn?.x)("p-4 flex");', ""], + ['cn!("p-4 flex");', ""], + ['cn("p-4 flex" as string);', 'cn("flex p-4" as string);'], + // In this tree it does not find the texts of a template. + ['cn(`x ${" p-4 flex "} y`);', 'cn(`x ${"flex p-4"} y`);'], + ['cn(`${" p-4 flex "}`);', 'cn(`${"flex p-4"}`);'], + ['cn(`x${`p-4 flex ${" p-1 m-2 "} p-2 m-3`}y`);', 'cn(`x${`flex p-4 ${"m-2 p-1"} m-3 p-2`}y`);'], + ], + "a.css": [["a {\n @apply p-4 /* c */ flex;\n}", "a {\n @apply /* c */ flex p-4;\n}"]], + "a.html": [['
x
', '
x
']], + "a.mdx": [ + ['
\n', '\n'], + ['export const a = cn("p-4 flex");', 'export const a = cn("flex p-4");'], + ], + "lwc/a.html": [ + [ + '', + '', + ], + ], + "a.vue": [ + [ + ``, + ``, + ], + ], + }; + const text = (at: 0 | 1) => (lines: [string, string][]) => lines.map(it => it[at] || it[0]).join("\n") + "\n"; + const options = { ...names, overrides: [{ files: "lwc/**", options: { parser: "lwc" } }] }; + const files = Object.fromEntries(Object.entries(rows).map(([name, lines]) => [name, text(0)(lines)])); + const result = await format({ ...project, ...config(options), ...files }, Object.keys(rows)); + expect(result.stderr).toBe(""); + expect(Object.fromEntries(Object.keys(rows).map((name, index) => [name, result.files[index]]))).toEqual( + Object.fromEntries(Object.entries(rows).map(([name, lines]) => [name, text(1)(lines)])), + ); + expect(result.exitCode).toBe(0); + }); + + test("a class stays apart from the text of a template that it touches", async () => { + // The code, what Bun makes of it, and what the plugin makes of it: one class of `x` and `flex`. + const rows: Record = { + "a.tsx": [ + [ + ';', + ';', + ';', + ], + [ + ';', + ';', + ';', + ], + ['cn(`x${" p-4 flex "}y`);', 'cn(`x${" flex p-4 "}y`);', 'cn(`x${"flex p-4"}y`);'], + ['cn(`${a}${" p-4 flex "}${b}`);', 'cn(`${a}${" flex p-4 "}${b}`);', 'cn(`${a}${"flex p-4"}${b}`);'], + ["cn(`x${` p-4 flex `} e`);", "cn(`x${` flex p-4`} e`);", "cn(`x${`flex p-4`} e`);"], + ], + "a.jsx": [ + ['cn(`x ${a} b${" p-4 flex "} e`);', 'cn(`x ${a} b${" flex p-4 "} e`);', 'cn(`x ${a} b${"flex p-4 "} e`);'], + ['cn(` x${" p-4 flex "} e`);', 'cn(` x${" flex p-4 "} e`);', 'cn(` x${"flex p-4 "} e`);'], + ['cn(`x${ " p-4 flex "} e`);', 'cn(`x${" flex p-4 "} e`);', 'cn(`x${"flex p-4 "} e`);'], + ], + "a.vue": [ + [ + "", + "", + "", + ], + ], + }; + const text = (at: 0 | 1) => (lines: string[][]) => lines.map(it => it[at]).join("\n") + "\n"; + for (const lines of Object.values(rows)) for (const it of lines) expect(it[1]).not.toBe(it[2]); + const files = Object.fromEntries(Object.entries(rows).map(([name, lines]) => [name, text(0)(lines)])); + const result = await format({ ...project, ...config(names), ...files }, Object.keys(rows)); + expect(result.stderr).toBe(""); + expect(result.files).toEqual(Object.values(rows).map(text(1))); + expect(result.exitCode).toBe(0); + }); + + test("with @prettier/plugin-oxc before it, it looks at the tree of that, and behind that it does nothing", async () => { + const code = 'cn?.("p-4 flex");\ncn(`x ${" p-4 flex "} y`);\n'; + const sortedCode = 'cn?.("flex p-4");\ncn(`x ${" flex p-4 "} y`);\n'; + const inVue = (text: string, lang = "") => `\n${text}\n`; + const files = { "a.js": code, "a.ts": code, "a.vue": inVue(code), "b.vue": inVue(code, ' lang="ts"') }; + const run = (plugins: string[]) => + format( + { ...project, ".prettierrc": JSON.stringify({ plugins, tailwindFunctions: ["cn"] }), ...files }, + Object.keys(files), + ); + // It does not read the JavaScript in HTML. + const byBabel = inVue('cn?.("p-4 flex");\ncn(`x ${" flex p-4 "} y`);\n'); + expect(await run(["@prettier/plugin-oxc", plugin])).toMatchObject({ + stderr: "", + files: [sortedCode, sortedCode, byBabel, inVue(sortedCode, ' lang="ts"')], + }); + expect(await run([plugin, "@prettier/plugin-oxc"])).toMatchObject({ + stderr: "", + files: [code, code, byBabel, inVue(code, ' lang="ts"')], + }); + }); + + test("in Angular it looks at the texts of a template as they are written", async () => { + const before = "\n"; + const after = + "\n"; + const result = await format({ ...project, ...config({}), "a.component.html": before }, ["a.component.html"]); + expect(result).toMatchObject({ stderr: "", files: [after], exitCode: 0 }); + }); + + describe("the Tailwind CSS that it brings along", () => { + /** Its `createSorter`, which knows what is in order.json. A file with `reversed` in it turns the order around. */ + const withSorter = { + ...installed, + [`node_modules/${plugin}/package.json`]: JSON.stringify({ + name: plugin, + type: "module", + exports: { ".": "./index.mjs", "./sorter": "./sorter.mjs" }, + }), + [`node_modules/${plugin}/sorter.mjs`]: `import { readFileSync } from "node:fs"; +const order = ${JSON.stringify(order)}; +export async function createSorter({ configPath, stylesheetPath }) { + const files = [configPath, stylesheetPath].filter(Boolean).map(it => readFileSync(it, "utf8")); + const sign = files.join().includes("reversed") ? -1 : 1; + const at = name => (order.includes(name) ? sign * order.indexOf(name) : -Infinity); + const sortClassLists = lists => + lists.map(list => list.toSorted((a, z) => at(a) - at(z)).filter((it, i, all) => !order.includes(it) || all.indexOf(it) === i)); + return { sortClassLists }; +} +`, + }; + const flipped = "module.exports = { reversed: true };\n"; + + test.each<[string, Record, string]>([ + ["there is no tailwindcss", config({}), sorted], + [ + "there is no tailwindcss, with the nearest tailwind.config.js", + { ...config({}), "tailwind.config.js": flipped }, + reversed, + ], + [ + "there is no tailwindcss, with a style sheet", + { ...config({ tailwindStylesheet: "a.css" }), "a.css": "/* reversed */" }, + reversed, + ], + [ + "tailwindConfig is set and version 4 is installed", + { ...version4, ...config({ tailwindConfig: "a.js" }), "a.js": flipped }, + reversed, + ], + [ + "tailwindStylesheet is set and version 3 is installed", + { ...version3, ...config({ tailwindStylesheet: "a.css" }), "a.css": "/* reversed */" }, + reversed, + ], + ])("is asked if %s", async (_, files, expected) => { + const before = ';\n'; + const result = await format({ ...withSorter, ...files, "src/a.jsx": before }, ["src/a.jsx"]); + expect(result).toMatchObject({ stderr: "", files: [expected.replace('"', '"x x ')], exitCode: 0 }); + }); + + test.each<[Record, string[]]>([ + [config({}), ["the tailwindcss package was not found from ", " Run bun add -d tailwindcss"]], + [ + { ...version4, ...config({ tailwindConfig: "a.js" }), "a.js": flipped }, + [ + "tailwindConfig is for Tailwind CSS 3, and another version is in ", + ` Run bun add -d ${plugin}, which brings its own`, + ], + ], + [ + { ...version3, ...config({ tailwindStylesheet: "a.css" }), "a.css": "" }, + [ + "tailwindStylesheet is for Tailwind CSS 4, and another version is in ", + ` Run bun add -d ${plugin}, which brings its own`, + ], + ], + ])("has to be installed for that: %#", async (files, said) => { + const result = await format({ ...files, "a.jsx": input }, ["a.jsx"]); + for (const line of said) + expect(result.stderr).toContain(`[error] ${line.startsWith(" ") ? "" : "sortTailwindcss: "}${line}`); + expect(result.files).toEqual([input]); + expect(result.exitCode).toBe(2); + }); + }); + + // Each stream has what Prettier's has. + test("--check says on standard output that an error occurred, and on standard error what it is", async () => { + const files = { ...config({}), "tailwind.config.js": 'throw new Error("boom");\n', "a.jsx": input }; + using dir = tempDir("bun-format-tailwind", { ...version3, ...installed, ...files }); + const result = await formatIn(String(dir), [], ["--log-level=log", "--check", "a.jsx"]); + expect(result.stdout).toBe( + "Checking formatting...\nError occurred when checking code style in the above file.\n", + ); + expect(result.stderr.split("\n")).toEqual([ + `[error] a.jsx: ${unknown} It was not formatted.`, + expect.stringMatching(/^\[error\] sortTailwindcss: \S+tailwind\.config\.js: boom$/), + "[error] 1 file with classes was not formatted.", + "", + ]); + expect(result.exitCode).toBe(2); + }); + + test("a flag or [fmt] counts for more", async () => { + const files = { ...config({}), "bunfig.toml": '[fmt.sortTailwindcss]\nfunctions = ["mine"]\n' }; + const result = await format({ ...project, ...files, "a.jsx": 'mine("p-4 flex");\n' }, ["a.jsx"]); + expect(result).toMatchObject({ stderr: "", files: ['mine("flex p-4");\n'], exitCode: 0 }); + }); + }); }); diff --git a/test/cli/format/toml-layout.test.ts b/test/cli/format/toml-layout.test.ts new file mode 100644 index 000000000000..2b7144ca81d3 --- /dev/null +++ b/test/cli/format/toml-layout.test.ts @@ -0,0 +1,536 @@ +// Tests for Bun's own TOML layout, which applies when there's no oxfmt config. `toml.test.ts` covers TOML under an oxfmt config. +import { afterAll, describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { endChildren, spawn } from "../children"; + +afterAll(endChildren); + +async function run(cwd: string, args: string[]) { + await using proc = spawn({ + cmd: [bunExe(), "fmt", "--log-level=warn", ...args], + env: { ...bunEnv, NO_COLOR: "1" }, + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +const cases: [name: string, input: string, output: string][] = [ + [ + "keeps a hugged table with a trailing comment as it is", + `[lint.rules] +"prefer-const" = ["warn", { + destructuring = "any", # "any" or "all" + ignoreReadBeforeAssign = false, +}] +`, + `[lint.rules] +"prefer-const" = ["warn", { + destructuring = "any", # "any" or "all" + ignoreReadBeforeAssign = false, +}] +`, + ], + [ + "puts one entry per line when an inline table has a comment", + `a = ["warn",{destructuring="any", # c +b=false}] +`, + `a = ["warn", { + destructuring = "any", # c + b = false, +}] +`, + ], + [ + "keeps a comment after the opening brace on that line", + `a = { # first + b = 1, +} +`, + `a = { # first + b = 1, +} +`, + ], + [ + "keeps a comment before the closing brace", + `a = { + b = 1, + # last +} +`, + `a = { + b = 1, + # last +} +`, + ], + [ + "keeps a comment after the last entry and its trailing comma", + `a = { + b = 1, # c +} +`, + `a = { + b = 1, # c +} +`, + ], + [ + "adds the trailing comma before a comment after the last entry", + `a = { + b = 1 # c +} +`, + `a = { + b = 1, # c +} +`, + ], + [ + "keeps a comment on its own line between entries", + `a = { + b = 1, + # about c + c = 2, +} +`, + `a = { + b = 1, + # about c + c = 2, +} +`, + ], + [ + "keeps a table that only holds a comment", + `a = { + # nothing +} +b = { # nothing +} +c = [{ + # nothing +}] +`, + `a = { + # nothing +} +b = { # nothing +} +c = [{ + # nothing +}] +`, + ], + [ + "collapses an empty table with line breaks", + `a = { +} +b = { + +} +`, + `a = {} +b = {} +`, + ], + [ + "handles tables in arrays in tables", + `a = { + b = [ + { c = 1 }, # one + { + d = [1, 2], # two + e = { f = 1, # three + }, + }, + ], +} +`, + `a = { + b = [ + { c = 1 }, # one + { + d = [1, 2], # two + e = { + f = 1, # three + }, + }, + ], +} +`, + ], + [ + "breaks an array that has its own comment next to a multi-line table", + `a = [ + "x", # about x + { + b = 1, # about b + }, +] +c = ["x", { + b = 1, +}, # after the table +] +d = [ # first + { + b = 1, + } +] +`, + `a = [ + "x", # about x + { + b = 1, # about b + }, +] +c = [ + "x", + { + b = 1, + }, # after the table +] +d = [ # first + { + b = 1, + }, +] +`, + ], + [ + "breaks an array whose multi-line table isn't last", + `a = [{ + b = 1, +}, "x"] +`, + `a = [ + { + b = 1, + }, + "x", +] +`, + ], + [ + "breaks an array with two multi-line tables", + `a = [{ + b = 1, +}, { + c = 2, +}] +`, + `a = [ + { + b = 1, + }, + { + c = 2, + }, +] +`, + ], + [ + "joins a table whose first entry is on the brace's line, if it fits", + `a = { b = 1, + c = 2 } +`, + `a = { b = 1, c = 2 } +`, + ], + [ + "puts one entry per line when a table with line breaks doesn't fit", + `a = { bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = 1, + ccccccccccccccccccccccccccccccccccccccccccccccccccc = 2 } +`, + `a = { + bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = 1, + ccccccccccccccccccccccccccccccccccccccccccccccccccc = 2, +} +`, + ], + [ + "never adds line breaks to a one-line table, because TOML 1.0 rejects them", + `a = { bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = 1, ccccccccccccccccccccccccccccccccccccccccccccccccccc = 2 } +`, + `a = { bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb = 1, ccccccccccccccccccccccccccccccccccccccccccccccccccc = 2 } +`, + ], + [ + "breaks only the array in the last entry of a one-line table that's too long", + `serde = { version = "1.0.200", default-features = false, features = ["derive", "rc", "alloc", "std"] } +`, + `serde = { version = "1.0.200", default-features = false, features = [ + "derive", + "rc", + "alloc", + "std", +] } +`, + ], + [ + "handles CRLF", + `a = ["warn", {\r + b = "any", # c\r + d = false,\r +}]\r +`, + `a = ["warn", { + b = "any", # c + d = false, +}] +`, + ], + [ + "removes empty lines next to the braces and keeps at most two between entries", + `a = { + + b = 1, + + + + c = 2, + +} +`, + `a = { + b = 1, + + + c = 2, +} +`, + ], + [ + "doesn't take a # in a key or a string for a comment", + `a = { + "b#c".d = "# not a comment", # comment + e = '#', +} +`, + `a = { + "b#c".d = "# not a comment", # comment + e = '#', +} +`, + ], + [ + "keeps a multi-line string in a table as it is", + `a = { + b = """ +x # y +""", + c = 1, +} +`, + `a = { + b = """ +x # y +""", + c = 1, +} +`, + ], + [ + "doesn't hug when the first line would be too long", + `"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" = ["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", { + c = 1, +}] +`, + `"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" = [ + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + { + c = 1, + }, +] +`, + ], + [ + "hugs at every level", + `a = ["x", { + b = ["y", { + c = 1, + }], +}] +`, + `a = ["x", { + b = ["y", { + c = 1, + }], +}] +`, + ], + [ + "keeps a comment after a hugged array", + `a = ["x", { + b = 1, +}] # after +`, + `a = ["x", { + b = 1, +}] # after +`, + ], + [ + "works under an array-of-tables header", + `[[x]] +a = { # c + b = 1 } +`, + `[[x]] +a = { # c + b = 1, +} +`, + ], + [ + "removes empty lines next to an array's brackets", + `a = [ + + 1, # c + + + + 2, + +] +`, + `a = [ + 1, # c + + + 2, +] +`, + ], + [ + "keeps an array on the line when more entries follow it in a one-line table", + `a = { features = ["derive", "rc", "alloc", "std", "unstable"], version = "1.0.200", default-features = false } +`, + `a = { features = ["derive", "rc", "alloc", "std", "unstable"], version = "1.0.200", default-features = false } +`, + ], + [ + "doesn't break arrays in a one-line table that's too long before they start", + `"react/sort-comp" = ["error", { order = ["lifecycle", "everything-else", "handlers", "rendering"], groups = { handlers = ["/^handle.+$/"], rendering = ["/^render.+$/", "render"] } }] +`, + `"react/sort-comp" = [ + "error", + { order = ["lifecycle", "everything-else", "handlers", "rendering"], groups = { handlers = ["/^handle.+$/"], rendering = ["/^render.+$/", "render"] } }, +] +`, + ], + [ + "lets each array decide whether it fits", + `a = { + short = [1, 2], + long = ["aaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbb", "cccccccccccccccccccccccc", "dddd"], + empty = [], +} +`, + `a = { + short = [1, 2], + long = [ + "aaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbb", + "cccccccccccccccccccccccc", + "dddd", + ], + empty = [], +} +`, + ], +]; + +describe("TOML layout", () => { + // All cases share one directory: format, check, format again. + const name = (index: number) => `${index}.toml`; + const done = (async () => { + using dir = tempDir("bun-format-toml", Object.fromEntries(cases.map((it, index) => [name(index), it[1]]))); + const read = () => cases.map((_, index) => readFileSync(join(String(dir), name(index)), "utf8")); + const first = await run(String(dir), ["."]); + const files = read(); + const check = await run(String(dir), ["--check", "."]); + const second = await run(String(dir), ["."]); + return { first, files, check, second, again: read() }; + })(); + + test("formats every case without errors, and --check passes afterwards", async () => { + const { first, check, second } = await done; + expect([first.stderr, check.stderr, second.stderr]).toEqual(["", "", ""]); + expect([first.exitCode, check.exitCode, second.exitCode]).toEqual([0, 0, 0]); + }); + + test.each(cases.map((it, index) => [it[0], index] as const))("%s", async (_, index) => { + const { files, again } = await done; + const [, input, output] = cases[index]; + expect(files[index]).toBe(output); + // A second run changes nothing. + expect(again[index]).toBe(output); + expect(Bun.TOML.parse(output)).toEqual(Bun.TOML.parse(input)); + }); + + test("respects trailingComma, useTabs and endOfLine in a multi-line table", async () => { + using dir = tempDir("bun-format-toml", { + "bunfig.toml": `[fmt]\ntrailingComma = "none"\nuseTabs = true\nendOfLine = "crlf"\n`, + "a.toml": `a = ["x", {\n b = 1, # c\n d = 2,\n}]\n`, + }); + const result = await run(String(dir), ["a.toml"]); + expect(result.stderr).toBe(""); + expect(readFileSync(join(String(dir), "a.toml"), "utf8")).toBe(`a = ["x", {\r\n\tb = 1, # c\r\n\td = 2\r\n}]\r\n`); + }); +}); + +describe.concurrent("a table with a comment", () => { + // oxfmt 0.72 joins these lines, which comments out the second entry: `{ b = 1 # c, d = 2 },`. + const commented = `a = ["x", {\n b = 1, # c\n d = 2,\n}]\n`; + const expanded = `a = [\n "x",\n {\n b = 1, # c\n d = 2,\n },\n]\n`; + + test("with an oxfmt config it has one entry per line", async () => { + using dir = tempDir("bun-format-toml", { ".oxfmtrc.json": "{}\n", "a.toml": commented, "b.toml": expanded }); + const result = await run(String(dir), ["."]); + expect(result.stderr).toBe(""); + expect(readFileSync(join(String(dir), "a.toml"), "utf8")).toBe(expanded); + expect(readFileSync(join(String(dir), "b.toml"), "utf8")).toBe(expanded); + expect(result.exitCode).toBe(0); + }); + + // The table, what Bun makes of it, and what oxfmt 0.72 makes of it. + test.each([ + ["a = { # c\n b = 1 }\n", "a = { # c\n b = 1,\n}\n", "a = { # c b = 1 }\n"], + ["a = { b = 1 # c\n, d = 2 }\n", "a = {\n b = 1, # c\n d = 2,\n}\n", "a = { b = 1 # c, d = 2 }\n"], + ["a = { b = 1,\n # c\n d = 2 }\n", "a = {\n b = 1,\n # c\n d = 2,\n}\n", "a = { b = 1 # c, d = 2 }\n"], + ["a = { b = 1, d = 2 # c\n }\n", "a = {\n b = 1,\n d = 2, # c\n}\n", "a = { b = 1, d = 2 # c }\n"], + ])("wherever the comment is: %j", async (before, after, ofOxfmt) => { + expect(after).not.toBe(ofOxfmt); + using dir = tempDir("bun-format-toml", { ".oxfmtrc.json": "{}\n", "a.toml": before, "b.toml": after }); + const checked = await run(String(dir), ["--debug-check", "."]); + expect(checked.stderr).toBe(""); + expect(checked.exitCode).toBe(0); + const result = await run(String(dir), ["."]); + expect(result.stderr).toBe(""); + expect(readFileSync(join(String(dir), "a.toml"), "utf8")).toBe(after); + expect(readFileSync(join(String(dir), "b.toml"), "utf8")).toBe(after); + expect(result.exitCode).toBe(0); + }); + + test("--debug-check has nothing to say about it", async () => { + using dir = tempDir("bun-format-toml", { ".oxfmtrc.json": "{}\n", "a.toml": commented }); + const result = await run(String(dir), ["--debug-check", "."]); + expect(result.stderr).toBe(""); + expect(readFileSync(join(String(dir), "a.toml"), "utf8")).toBe(commented); + expect(result.exitCode).toBe(0); + }); + + test("without an oxfmt config the same file is already formatted", async () => { + using dir = tempDir("bun-format-toml", { "a.toml": commented }); + const result = await run(String(dir), ["--check", "."]); + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + }); +}); diff --git a/test/cli/format/toml.test.ts b/test/cli/format/toml.test.ts index 0fe3efc1cebc..52c595af1387 100644 --- a/test/cli/format/toml.test.ts +++ b/test/cli/format/toml.test.ts @@ -10,7 +10,7 @@ afterAll(endChildren); async function format(files: Record, config: object, args: string[] = []) { using dir = tempDir("bun-format-toml", { ...files, ".oxfmtrc.json": JSON.stringify(config) }); await using proc = spawn({ - cmd: [bunExe(), "format", ...args], + cmd: [bunExe(), "fmt", ...args], env: { ...bunEnv, NO_COLOR: "1" }, cwd: String(dir), stdout: "pipe", diff --git a/test/cli/format/words.test.ts b/test/cli/format/words.test.ts new file mode 100644 index 000000000000..86a88a262a0c --- /dev/null +++ b/test/cli/format/words.test.ts @@ -0,0 +1,176 @@ +// Tests for what `bun fmt` prints when there's no Prettier or oxfmt config. +import { afterAll, describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { endChildren, spawn } from "../children"; + +afterAll(endChildren); + +const env = { ...bunEnv, NO_COLOR: undefined, FORCE_COLOR: undefined }; +const ugly = "a ;\n"; +const formatted = "a;\n"; + +async function format(files: Record, args: string[], more: Record = {}) { + using dir = tempDir("bun-format-words", files); + await using proc = spawn({ + cmd: [bunExe(), "fmt", ...args], + env: { ...env, ...more }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const after = Object.keys(files).map(name => [name, readFileSync(join(String(dir), name), "utf8")]); + return { stdout, stderr: stderr.replace(/ \[\d+(\.\d+)?m?s\]/g, ""), exitCode, files: Object.fromEntries(after) }; +} + +describe.concurrent("bun fmt --check", () => { + test("lists the files that need formatting, writes nothing and exits with 1", async () => { + const files = { "a.js": ugly, "b.js": formatted, "src/c.ts": ugly, "d.json": "{}\n" }; + expect(await format(files, ["--check"])).toEqual({ + stdout: "a.js\nsrc/c.ts\n", + stderr: "2 files need formatting. Run bun fmt to fix them.\n", + exitCode: 1, + files, + }); + }); + + test("uses the singular for one file", async () => { + const files = { "a.js": ugly, "b.js": formatted }; + expect(await format(files, ["--check"])).toEqual({ + stdout: "a.js\n", + stderr: "1 file needs formatting. Run bun fmt to fix it.\n", + exitCode: 1, + files, + }); + }); + + test("exits with 0 if every file is formatted", async () => { + const [four, one] = await Promise.all([ + format({ "a.js": formatted, "b.ts": formatted, "c.json": "{}\n", "d.css": "a {\n}\n" }, ["--check"]), + format({ "a.js": formatted }, ["--check"]), + ]); + expect(four).toMatchObject({ stdout: "", stderr: "All 4 files are formatted.\n", exitCode: 0 }); + expect(one).toMatchObject({ stdout: "", stderr: "1 file is formatted.\n", exitCode: 0 }); + }); + + test("files with syntax errors are counted separately", async () => { + const { stdout, stderr, exitCode } = await format( + { "a.js": ugly, "b.js": formatted, "broken.js": "const = 1;\n" }, + ["--check"], + ); + expect(stdout).toBe("a.js\n"); + expect(stderr).toStartWith("error: broken.js: SyntaxError: "); + expect(stderr).toContain("\n> 1 | const = 1;\n"); + expect(stderr).not.toContain("[error]"); + expect(stderr).toEndWith("\n1 file needs formatting. Run bun fmt to fix it.\nCould not check 1 file.\n"); + expect(exitCode).toBe(2); + }); + + test("[fmt] in bunfig.toml and an .editorconfig don't change the wording", async () => { + const files = { + "a.js": "a;\n", + "b.js": "if (a) {\n b\n}\n", + "bunfig.toml": "[fmt]\nsemi = false\n", + ".editorconfig": "[*]\nindent_size = 4\n", + }; + expect(await format(files, ["--check", "a.js", "b.js"])).toMatchObject({ + stdout: "a.js\n", + stderr: "1 file needs formatting. Run bun fmt to fix it.\n", + exitCode: 1, + }); + }); + + test("--log-level", async () => { + const files = { "a.js": ugly, "b.js": formatted }; + const [silent, error, warn] = await Promise.all( + ["silent", "error", "warn"].map(level => format(files, ["--check", "--log-level", level])), + ); + expect(silent).toMatchObject({ stdout: "", stderr: "", exitCode: 1 }); + expect(error).toMatchObject({ stdout: "", stderr: "", exitCode: 1 }); + expect(warn).toMatchObject({ + stdout: "a.js\n", + stderr: "1 file needs formatting. Run bun fmt to fix it.\n", + exitCode: 1, + }); + }); + + test("colored output has the same text", async () => { + const files = { "a.js": ugly, "b.js": formatted }; + const [plain, colored] = await Promise.all([ + format(files, ["--check"]), + format(files, ["--check"], { FORCE_COLOR: "1" }), + ]); + expect(colored.stderr).toContain("\x1b["); + expect(Bun.stripANSI(colored.stderr)).toBe(plain.stderr); + expect(colored.stdout).toBe(plain.stdout); + }); + + test.each([ + ["a .prettierrc", { ".prettierrc": "{}\n" }, []], + ["prettier in package.json", { "package.json": `{ "prettier": {} }` }, []], + ["--config", { "mine.json": "{}\n" }, ["--config", "mine.json"]], + ["--flavor prettier", {}, ["--flavor", "prettier"]], + ])("with %s the output is worded like Prettier's", async (_, configuration, flags) => { + const [different, same] = await Promise.all([ + format({ ...configuration, "a.js": ugly, "b.js": ugly }, [...flags, "--check", "a.js", "b.js"]), + format({ ...configuration, "a.js": formatted }, [...flags, "--check", "a.js"]), + ]); + expect(different).toMatchObject({ + stdout: "Checking formatting...\n", + stderr: "[warn] a.js\n[warn] b.js\n[warn] Code style issues found in 2 files. Run bun fmt to fix.\n", + exitCode: 1, + }); + expect(same).toMatchObject({ + stdout: "Checking formatting...\nAll matched files use Prettier code style!\n", + stderr: "", + exitCode: 0, + }); + }); +}); + +describe.concurrent("bun fmt", () => { + test("errors start with Bun's `error:`, or Prettier's `[error]` with a .prettierrc", async () => { + const [missing, ofPrettier] = await Promise.all([ + format({ "a.js": formatted }, ["nothing.js"]), + format({ "a.js": formatted, ".prettierrc": "{}\n" }, ["nothing.js"]), + ]); + expect(missing.stderr).toStartWith('error: No files matching the pattern were found: "nothing.js".\n'); + expect(missing.exitCode).toBe(2); + expect(ofPrettier.stderr).toStartWith('[error] No files matching the pattern were found: "nothing.js".\n'); + expect(ofPrettier.exitCode).toBe(2); + }); + + test("errors and warnings about flags get the same prefixes", async () => { + const flags = ["--plugin", "prettier-plugin-x"]; + const [refused, allowed, ofPrettier] = await Promise.all([ + format({ "a.js": formatted }, flags), + format({ "a.js": formatted }, [...flags, "--allow-unsupported"]), + format({ "a.js": formatted, ".prettierrc": "{}\n" }, flags), + ]); + expect(refused.stderr).toStartWith("error: bun fmt does not include the plugin prettier-plugin-x\n"); + expect(allowed.stderr).toStartWith("warn: --plugin has no effect "); + expect(ofPrettier.stderr).toStartWith("[error] bun fmt does not include the plugin prettier-plugin-x\n"); + expect([refused.exitCode, allowed.exitCode, ofPrettier.exitCode]).toEqual([2, 0, 2]); + }); + + test("lists the files it writes and counts them", async () => { + const { stdout, stderr, exitCode, files } = await format({ "a.js": ugly, "b.js": formatted, "c.js": ugly }, []); + expect(stdout).toBe("a.js\nc.js\n"); + expect(stderr).toBe("Formatted 2 files, 1 unchanged\n"); + expect(files).toEqual({ "a.js": formatted, "b.js": formatted, "c.js": formatted }); + expect(exitCode).toBe(0); + }); + + test("--check --write writes files like a plain run", async () => { + const { stdout, stderr, exitCode, files } = await format({ "a.js": ugly, "b.js": formatted }, [ + "--check", + "--write", + ]); + expect(stdout).toBe("a.js\n"); + expect(stderr).toBe("Formatted 1 file, 1 unchanged\n"); + expect(files).toEqual({ "a.js": formatted, "b.js": formatted }); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/cli/init/init.test.ts b/test/cli/init/init.test.ts index 2170dda67685..113be9d6abcc 100644 --- a/test/cli/init/init.test.ts +++ b/test/cli/init/init.test.ts @@ -60,6 +60,8 @@ const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" }; expect(readme).toInclude("v" + Bun.version.replaceAll("-debug", "")); expect(readme).toInclude("index.ts"); expect(readme).toInclude("bun check"); + expect(readme).toInclude("`bun lint`"); + expect(readme).toInclude("`bun fmt`"); expect(fs.existsSync(path.join(temp, "index.ts"))).toBe(true); expect(fs.existsSync(path.join(temp, ".gitignore"))).toBe(true); @@ -414,6 +416,34 @@ const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" }; expect(fs.existsSync(path.join(temp, "src/components/ui"))).toBe(true); }, 30_000); + test.each(["--react", "--react=tailwind", "--react=shadcn"])( + "bun init %s writes the same [lint] section to bunfig.toml as bun lint --init", + async flag => { + await using temp = tempDir("bun-init-lint-section", { "alone/a.js": "" }); + const run = async (cwd: string, ...args: string[]) => { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + cwd, + stdio: ["ignore", "pipe", "inherit"], + env: initEnv, + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + return { stdout, exitCode }; + }; + expect((await run(path.join(temp, "alone"), "lint", "--init")).exitCode).toBe(0); + expect((await run(String(temp), "init", flag, "app")).exitCode).toBe(0); + expect(fs.readFileSync(path.join(temp, "app/bunfig.toml"), "utf8")).toEndWith( + 'env = "BUN_PUBLIC_*"\n\n' + fs.readFileSync(path.join(temp, "alone/bunfig.toml"), "utf8"), + ); + expect(await run(path.join(temp, "app"), "lint", "--init")).toEqual({ + stdout: "bunfig.toml already has a [lint] section\n", + exitCode: 0, + }); + expect(fs.readFileSync(path.join(temp, "app/README.md"), "utf8")).toInclude("`bun lint`"); + }, + 30_000, + ); + // Every template declares `typescript: "^7"`, so the `bun install` that // `bun init` runs installs TypeScript 7. Typecheck and build with that // exact install. https://github.com/oven-sh/bun/issues/33050 @@ -467,6 +497,34 @@ const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" }; ]); expect({ checkStdout, checkStderr, checkExited }).toMatchObject({ checkStdout: "", checkExited: 0 }); + // `bun lint` reports nothing, not even a warning, and `bun fmt` has nothing to change. + await using lint = Bun.spawn({ + cmd: [bunExe(), "lint", "-f", "json"], + cwd: temp, + stdio: ["ignore", "pipe", "pipe"], + env: bunEnv, + }); + const [lintStdout, lintStderr, lintExited] = await Promise.all([ + lint.stdout.text(), + lint.stderr.text(), + lint.exited, + ]); + const problems = JSON.parse(lintStdout).flatMap((file: { messages: unknown[] }) => file.messages); + expect({ problems, lintStderr, lintExited }).toMatchObject({ problems: [], lintExited: 0 }); + + await using format = Bun.spawn({ + cmd: [bunExe(), "fmt", "--check"], + cwd: temp, + stdio: ["ignore", "pipe", "pipe"], + env: bunEnv, + }); + const [formatStdout, formatStderr, formatExited] = await Promise.all([ + format.stdout.text(), + format.stderr.text(), + format.exited, + ]); + expect({ formatStdout, formatStderr, formatExited }).toMatchObject({ formatExited: 0 }); + // The blank template has no `build` script; the react templates do. // --bun for the same reason: the script is `bun run build.ts`. const pkg = JSON.parse(fs.readFileSync(path.join(temp, "package.json"), "utf8")); @@ -488,6 +546,32 @@ const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" }; 180_000, ); + test.skipIf(isWindows)( + "`bun fmt --check` passes on the generated CLAUDE.md", + async () => { + await using temp = tempDir("bun-init-claude-md", { "bin/claude": "#!/bin/sh\n" }); + fs.chmodSync(path.join(temp, "bin/claude"), 0o755); + await using init = Bun.spawn({ + cmd: [bunExe(), "init", "-y", "app"], + cwd: temp, + stdio: ["ignore", "ignore", "inherit"], + env: { ...bunEnv, PATH: `${path.join(temp, "bin")}:${bunEnv.PATH}`, CURSOR_AGENT_RULE_DISABLED: "1" }, + }); + expect(await init.exited).toBe(0); + expect(fs.readFileSync(path.join(temp, "app/CLAUDE.md"), "utf8")).toStartWith("Default to using Bun"); + + await using format = Bun.spawn({ + cmd: [bunExe(), "fmt", "--check", "CLAUDE.md"], + cwd: path.join(temp, "app"), + stdio: ["ignore", "pipe", "pipe"], + env: bunEnv, + }); + const [stdout, stderr, exitCode] = await Promise.all([format.stdout.text(), format.stderr.text(), format.exited]); + expect({ stdout, stderr, exitCode }).toMatchObject({ exitCode: 0 }); + }, + 30_000, + ); + test("nested `bun install` output is inherited", async () => { // `bun init` spawns `bun install` via spawn_sync_inherit. The child must // inherit stdout/stderr so its output reaches the parent's pipe — a diff --git a/test/cli/install/bun-workspaces.test.ts b/test/cli/install/bun-workspaces.test.ts index 7a7604ff674d..a055c8d2822f 100644 --- a/test/cli/install/bun-workspaces.test.ts +++ b/test/cli/install/bun-workspaces.test.ts @@ -235,6 +235,36 @@ test.concurrent("allowing negative workspace patterns", async () => { }); }); +test.concurrent("extglob and brace sequence workspace patterns", async () => { + using ctx = await setupTest(); + const { packageDir, env } = ctx; + const good = ["packages/pkg1", "packages/pkg2", "apps/web", "apps/api", "libs/lib1", "libs/lib2"]; + // Installing any of these would fail. + const excluded = ["packages/internal", "apps/other", "libs/lib3"]; + await Promise.all([ + write( + join(packageDir, "package.json"), + JSON.stringify({ + name: "root", + workspaces: ["packages/!(internal)", "apps/@(web|api)", "libs/lib{1..2}"], + }), + ), + ...good.map(dir => write(join(packageDir, dir, "package.json"), JSON.stringify({ name: dir.split("/")[1] }))), + ...excluded.map(dir => + write( + join(packageDir, dir, "package.json"), + JSON.stringify({ name: dir.split("/")[1], dependencies: { "doesnt-exist-oops": "1.2.3" } }), + ), + ), + ]); + + const { exited } = await runBunInstall(env, packageDir); + expect(await exited).toBe(0); + + const lockfile = Bun.JSONC.parse(await file(join(packageDir, "bun.lock")).text()) as { workspaces: object }; + expect(Object.keys(lockfile.workspaces).sort()).toEqual(["", ...good].sort()); +}); + test("dependency on same name as workspace and dist-tag", async () => { using ctx = await setupTest(); const { packageDir, env } = ctx; diff --git a/test/cli/lint/bun-rules.test.ts b/test/cli/lint/bun-rules.test.ts index 3947d9563dd5..60651d450a44 100644 --- a/test/cli/lint/bun-rules.test.ts +++ b/test/cli/lint/bun-rules.test.ts @@ -760,6 +760,11 @@ const rules: Record = { { code: `const A = z.string();`, options: [{ roots: ["v"] }] }, `const A = userSchema();`, `const B = A.extend({ b: 1 });`, + // `lazySchema` itself ends with the factory suffix. + { + code: `${zod}const A = lazySchema(() => z.object({ a: userSchema() }));`, + options: [{ factorySuffix: "Schema", lazyWrapper: "lazySchema" }], + }, ], "no-side-effects-on-import": [ @@ -1411,6 +1416,8 @@ const rules: Record = { `${processes}const child = spawn("a");\nconst other = child;`, `${processes}const child = spawn("a");\nthis.child = child;`, `${processes}const child = spawn("a");\nexport { child };`, + `${processes}export const child = spawn("a");`, + `${processes}export let child;\nchild = spawn("a");`, `${files}await pipeline(createReadStream("a"), createWriteStream("b"));`, `${files}const source = createReadStream("a");\nfinished(source, done);`, `${files}const source = createReadStream("a");\nfor await (const piece of source) use(piece);`, @@ -1717,7 +1724,12 @@ const testRules: Record = { `test(«" a"», () => {\n expect(1).toMatchSnapshot();\n});`, `test(«a», () => {});`, { code: `describe(«add», () => {});`, options: [{ ignoreTypeOfDescribeName: false }] }, + { code: `test(«"adds"», () => {});`, options: [{ mustMatch: "/^ADDS/" }] }, + { code: `test(«"Adds"», () => {});`, options: [{ mustNotMatch: "/^adds/i" }] }, + { code: `it(«"adds"», () => {});\ntest("adds", () => {});`, options: [{ mustMatch: { it: "^should " } }] }, + { code: `test("adds", () => {});`, options: [{ mustMatch: "/^ADDS/i" }] }, + { code: `it("should add", () => {});`, options: [{ mustMatch: { it: "^should " } }] }, `test("a", () => {});`, `test(() => {});`, `test(callback);`, @@ -1741,6 +1753,33 @@ const testRules: Record = { `test.skip("a", () => {});`, `test.todo("a", () => {});`, `test.todoIf(a)("b");`, + `own("a", () => {});`, + // `**` also matches zero segments, and `*` matches any character of a name. + ...[ + `test("a", () => {\n request(b).expect(200);\n});`, + `test("a", () => {\n request(b).get("/").set("c").expect(200);\n});`, + `test("a", () => {\n check_all$(b);\n});`, + `test("a", () => {\n b.c.verify();\n});`, + `test("a", () => {\n verify();\n});`, + `test("a", () => {\n $assert(b);\n});`, + `«test»("a", () => {\n request(b).get("/");\n});`, + `«test»("a", () => {\n requests.expect(200);\n});`, + `«test»("a", () => {\n recheck(b);\n});`, + `«test»("a", () => {\n assert(b);\n});`, + ].map(code => ({ + code, + options: [{ assertFunctionNames: ["request.**.expect", "check*", "**.verify", "$ass*"] }], + })), + // A custom wrapper, a method and a hook. + ...[ + `«own»("a", () => {});`, + `«helpers.own»("a", () => {});`, + `«beforeAll»(() => {});`, + `own("a", () => {\n expect(1).toBe(1);\n});`, + `other("a", () => {});`, + `own.skip("a", () => {});`, + `own;`, + ].map(code => ({ code, options: [{ additionalTestBlockFunctions: ["own", "helpers.own", "beforeAll"] }] })), ].map(inBunTest), "no-done-callback": [ @@ -1764,6 +1803,13 @@ const testRules: Record = { "prefer-ending-with-an-expect": [ `«test»(() => {\n expect(1).toBe(1);\n a();\n});`, + `own("a", () => {\n b();\n});`, + ...[ + `«own»("a", () => {\n expect(1).toBe(1);\n b();\n});`, + `«beforeAll»(() => {\n b();\n});`, + `own("a", () => {\n b();\n expect(1).toBe(1);\n});`, + `other("a", () => {\n b();\n});`, + ].map(code => ({ code, options: [{ additionalTestBlockFunctions: ["own", "beforeAll"] }] })), `«test»("a", { retry: 1 }, () => {\n expect(1).toBe(1);\n a();\n});`, `test(() => {\n a();\n expect(1).toBe(1);\n});`, ].map(inBunTest), @@ -1798,6 +1844,66 @@ const testRules: Record = { .map(it => (it.code.includes("«") ? it : it.code)) .map(inBunTest), + // A test's count includes its nested blocks and the callbacks it passes to other functions. + "max-expects": [ + `test("a", () => {\n expect(1).toBe(1);\n «expect»(2).toBe(2);\n});`, + `test("a", () => {\n expect(1).toBe(1);\n for (const b of c) {\n «expect»(b).toBe(2);\n }\n});`, + `test("a", () => {\n expect(1).toBe(1);\n c.forEach(b => {\n «expect»(b).toBe(2);\n });\n});`, + `test("a", () => {\n if (b) expect(1).toBe(1);\n else «expect»(2).toBe(2);\n});`, + `function check() {\n expect(1).toBe(1);\n «expect»(2).toBe(2);\n}`, + + `test("a", () => {\n expect(1).toBe(1);\n});\ntest("b", () => {\n expect(2).toBe(2);\n});`, + `describe("a", () => {\n test("b", () => {\n expect(1).toBe(1);\n });\n test("c", () => {\n expect(2).toBe(2);\n });\n});`, + `test("a", () => {\n expect(1).toBe(1);\n function check() {\n expect(2).toBe(2);\n }\n});`, + `test("a", () => {\n expect.assertions(1);\n expect.hasAssertions();\n expect(1).toBe(1);\n});`, + ] + .map(code => ({ code, options: [{ max: 1 }] })) + .map(inBunTest), + + "no-conditional-expect": [ + `test("a", () => {\n if (b) «expect»(1).toBe(1);\n});`, + `test("a", function () {\n if (b) «expect»(1).toBe(1);\n});`, + `test("a", function named() {\n if (b) «expect»(1).toBe(1);\n});`, + `test("a", () => {\n each(function () {\n if (b) «expect»(1).toBe(1);\n });\n});`, + // Functions that a test calls, or that are passed as the test itself. + `function check() {\n if (b) «expect»(1).toBe(1);\n}\ntest("a", () => check());`, + `function check() {\n if (b) «expect»(1).toBe(1);\n}\ntest("a", check);`, + `const check = () => {\n if (b) «expect»(1).toBe(1);\n};\ntest("a", () => check());`, + `const check = function () {\n if (b) «expect»(1).toBe(1);\n};\ntest("a", check);`, + + `if (b) {\n test("a", function () {\n expect(1).toBe(1);\n });\n}`, + `test("a", () => {\n if (b) {\n const check = () => expect(1).toBe(1);\n }\n});`, + `function check() {\n if (b) expect(1).toBe(1);\n}`, + `const check = () => {\n if (b) expect(1).toBe(1);\n};\ncheck();`, + `const checks = {\n check() {\n if (b) expect(1).toBe(1);\n },\n};\ntest("a", () => checks.check());`, + ].map(inBunTest), + + // The condition for running a test, its title and its timeout aren't part of the test body. + "no-conditional-in-test": [ + `test("a", () => {\n «if (a) b();»\n});`, + `test.skipIf(a)("b", () => {\n const c = «d || e»;\n});`, + `test("a", { timeout: 1000 }, function () {\n return «b ? c : d»;\n});`, + `test(() => {\n «a ?? b»;\n}, 1000);`, + + `test.skipIf(a || b)("c", () => {});`, + `test.if(a && b)("c", () => {});`, + `test.todoIf(a ? b : c)("d", () => {});`, + `test.each(a ?? b)("c", () => {});`, + `test(a || "b", () => {});`, + `test("a", () => {}, b ? 1000 : 5000);`, + `test("a", { timeout: b ?? 1000 }, () => {});`, + ].map(inBunTest), + + "no-large-snapshots": [ + inTest("expect(a).«toMatchInlineSnapshot»(`\n1\n2\n3\n`);"), + inTest("expect(a).«toMatchInlineSnapshot»({ b: expect.any(Number) }, `\n1\n2\n3\n`);"), + inTest("expect(a).toMatchInlineSnapshot({\n b: expect.any(Number),\n c: expect.any(Number),\n}, `1`);"), + inTest("expect(a).toMatchInlineSnapshot(`1`);"), + inTest("expect(a).toMatchInlineSnapshot();"), + ] + .map(code => ({ code, options: [{ inlineMaxSize: 2 }] })) + .map(inBunTest), + "no-identical-title": [ `test("a", () => {});\ntest(«"a"», () => {});`, `test.failing("a", () => {});\ntest.serial(«"a"», () => {});`, @@ -1899,6 +2005,13 @@ const testRules: Record = { }, ].map(inBunTest), + "prefer-to-be": [ + { code: inTest(`expect(a).«toEqual»(1);`), output: inTest(`expect(a).toBe(1);`) }, + { code: inTest(`expect(a).«toEqual»(-1);`), output: inTest(`expect(a).toBe(-1);`) }, + inTest(`expect(a).toEqual(0.5);`), + inTest(`expect(a).toEqual(-0.5);`), + ].map(inBunTest), + "prefer-to-contain": [ { code: inTest(`expect(a.includes(1), "why").«toBe»(true);`), @@ -1920,11 +2033,53 @@ const testRules: Record = { }, ].map(inBunTest), + "prefer-mock-return-shorthand": [ + { code: `a.«mockImplementation»(() => 1);`, output: `a.mockReturnValue(1);` }, + { code: `a.«mockImplementationOnce»(() => ({ b: [c] }));`, output: `a.mockReturnValueOnce({ b: [c] });` }, + { code: `a.«mockImplementation»(function () {\n return 1;\n});`, output: `a.mockReturnValue(1);` }, + // These run each time the mock is called. + `a.mockImplementation(() => create());`, + `a.mockImplementation(() => new User());`, + `a.mockImplementation(() => ({ id: next() }));`, + "a.mockImplementation(() => sql`b`);", + `a.mockImplementation(() => import("b"));`, + // It returns nothing. + `a.mockImplementation(function () {\n b;\n});`, + ].map(inBunTest), + + "prefer-spy-on": [ + ...[ + { code: `a.b = «jest.fn»();`, suggestions: [`jest.spyOn(a, 'b').mockImplementation();`] }, + // No suggestion, because `spyOn` isn't imported. + `a.b = «mock»();`, + `a.b = other();`, + `const a = mock();`, + `const a = { b: mock() };`, + `function f(mock) {\n a.b = mock();\n}`, + ].map(inBunTest), + ...[ + { code: `a.b = «mock»();`, suggestions: [`spyOn(a, 'b').mockImplementation();`] }, + { code: `a.b = «mock»(() => 1);`, suggestions: [`spyOn(a, 'b').mockImplementation(() => 1);`] }, + { code: `a[b] = «mock»(c);`, suggestions: [`spyOn(a, b).mockImplementation(c);`] }, + ].map(({ code, suggestions }) => { + const head = `import { mock, spyOn } from "bun:test";\n`; + return { code: head + code, suggestions: suggestions.map(it => head + it) }; + }), + { + code: `import { mock as fake, spyOn } from "bun:test";\na.b = «fake»();`, + suggestions: [`import { mock as fake, spyOn } from "bun:test";\nspyOn(a, 'b').mockImplementation();`], + }, + `import { test } from "bun:test";\nimport { mock } from "./mock";\na.b = mock();`, + ], + "prefer-describe-function-title": [ { code: `import { add } from "./add";\ndescribe(«"add"», () => {});`, suggestions: [`import { add } from "./add";\ndescribe(add, () => {});`], }, + // A type can't be passed as a value. + `import type { Add } from "./add";\ndescribe("Add", () => {});`, + `import { type Add } from "./add";\ndescribe("Add", () => {});`, ].map(inBunTest), // oxlint applies these, and the fixes above of what is awaited, included and compared. A test that passes can fail afterwards. @@ -1935,6 +2090,14 @@ const testRules: Record = { "prefer-strict-boolean-matchers": [ { code: inTest(`expect(1).«toBeTruthy()»;`), suggestions: [inTest(`expect(1).toBe(true);`)] }, { code: inTest(`expect(0, "why").«toBeFalsy()»;`), suggestions: [inTest(`expect(0, "why").toBe(false);`)] }, + // `.not.toBe(true)` passes for everything but `true`. + { code: inTest(`expect(a).not.«toBeTruthy()»;`), suggestions: [inTest(`expect(a).toBe(false);`)] }, + { code: inTest(`expect(a).not.«toBeFalsy()»;`), suggestions: [inTest(`expect(a).toBe(true);`)] }, + { + code: inTest(`await expect(a).resolves.not.«toBeTruthy()»;`), + suggestions: [inTest(`await expect(a).resolves.toBe(false);`)], + }, + inTest(`expect(a)["not"].«toBeTruthy()»;`), ].map(inBunTest), "require-hook": [ @@ -2260,14 +2423,13 @@ describe.concurrent("the plugin bun", () => { expect(exitCode).toBe(1); }); - test("--rules lists them, without a category and without a page", async () => { + test("--rules lists them, without a category", async () => { const { stdout } = await lint({ ".oxlintrc.json": "{}" }, ["--rules", "-f", "json"]); const listed = (JSON.parse(stdout) as Record[]).filter(it => it.scope === "bun"); // `bun/format` is tested with `prettier/prettier`: prettier-rule.test.ts. expect(listed.map(it => it.value)).toEqual([...Object.keys(rules), "format"].toSorted()); - expect([...new Set(listed.map(it => JSON.stringify([it.category, it.default, it.docs_url])))]).toEqual([ - "[null,false,null]", - ]); + expect([...new Set(listed.map(it => JSON.stringify([it.category, it.default])))]).toEqual(["[null,false]"]); + expect(listed.map(it => it.docs_url)).toEqual(listed.map(it => `https://bun.com/docs/lint/rules/bun/${it.value}`)); }); test("options that a rule does not have are refused", async () => { @@ -2291,10 +2453,152 @@ describe.concurrent("the plugin bun", () => { const options = { patterns: [{ pattern: "(" }] }; const files = { "eslint.config.mjs": flat({ "bun/no-restricted-text": ["error", options] }), "a.js": eager }; const { stderr, exitCode } = await lint(files, ["a.js"]); - expect(stderr).toContain(`The pattern "(" is not a regular expression`); + expect(stderr).toContain(`Invalid pattern "(": Invalid regular expression: `); expect(exitCode).toBe(2); }); + test("each rule's message", async () => { + const files = { + "bunfig.toml": `[lint.rules] +"bun/no-eager-dynamic-import" = "error" +"bun/no-eager-native-addon" = "error" +"bun/no-env-at-module-scope" = "error" +"bun/no-literal-temp-dir" = "error" +"bun/no-negative-repeat-count" = "error" +"bun/no-redundant-catch-type" = "error" +"bun/no-useless-spread-fallback" = "error" +"bun/no-array-just-to-count" = "error" +"bun/prefer-builtin-sleep" = "error" +"bun/react-prefer-updater-function" = "error" +"bun/no-console-window-flash" = "error" +"bun/no-unportable-commands" = "error" +"bun/no-cwd-dependent-path" = "error" +"bun/prefer-lazy-schema" = "error" +"bun/no-side-effects-on-import" = "error" +"bun/no-unseeded-random-in-property-test" = "error" +"bun/react-no-namespace-hooks" = "error" +"bun/react-no-latest-value-ref" = "error" +"bun/jsx-no-forbidden-nesting" = "error" +"bun/consistent-directive-prefix" = "error" +"bun/no-restricted-text" = ["error", { patterns = [{ pattern = "foo" }] }] +"bun/prefer-timeout-signal" = "error" +"bun/prefer-url-for-sibling-files" = "error" +"bun/react-no-local-render-helpers" = "error" +"bun/prefer-local-module" = ["error", { modules = [{ package = "axios", use = "@/http" }] }] +"bun/no-unbounded-requests" = "error" +"bun/no-unhandled-emitter-errors" = "error" +"bun/no-cast-to-brand" = ["error", { branded = [{ module = "src/ids", type = "UserId", mint = ["toUserId"], mintedIn = ["src/session.ts"] }] }] +`, + "tsconfig.json": `{ "compilerOptions": { "strict": true, "jsx": "preserve" } }\n`, + "a.tsx": `import { exec, spawn } from "node:child_process"; +import fs from "node:fs"; +import { join } from "node:path"; +import { z } from "zod"; +import fc from "fast-check"; +import React, { useRef, useEffect, useState } from "react"; +import axios from "axios"; +const a1 = import("a"); +const addon = require("./addon.node"); +const port = process.env.PORT; +const env = { ...process.env }; +const t = "/tmp/a.txt"; +const r = " ".repeat(width - text.length); +try {} catch (error: unknown) {} +const s = { ...(b ? { c: 1 } : {}) }; +const n = b.split(",").length; +await new Promise(done => setTimeout(done, 250)); +spawn("npm", ["install"]); +spawn("ls", ["-l"]); +exec("a && b"); +exec("ps -ef"); +fs.readFileSync("a.txt"); +const A = z.string(); +start(); +a.b = 1; +fc.property(fc.integer(), a => fc.sample(fc.integer(), 1)); +fc.property(fc.integer(), a => a > Math.random()); +React.useState(0); +function C({ count }) { + const saved = useRef(count); + saved.current = count; + useEffect(() => { + saved.current = count; + }); + const [rows, setRows] = useState([]); + setRows([...rows, 1]); +} +const x = ; +// oxlint-disable-next-line no-debugger +debugger; +const f = "a foo b"; +const controller = new AbortController(); +setTimeout(() => controller.abort(), 2000); +await fetch(address, { signal: controller.signal }); +const j = join(import.meta.dirname, "a.txt"); +function Table({ rows }) { + const renderCell = row => ; + return {rows.map(renderCell)}
; +} +axios.get("/a"); +axios.get("/a", { timeout: 0 }); +const ps = "ps -ef"; +`, + "src/ids.ts": `export type UserId = string & { brand: 1 }; +export const toUserId = (a: string) => a as UserId; +`, + "src/views/page.ts": `import type { UserId } from "../ids"; +import { toUserId } from "../ids"; +const a = text as UserId; +const b = toUserId(text); +`, + }; + const { stdout, exitCode } = await lint(files, ["-f", "json"]); + const messages = (JSON.parse(stdout) as { messages: { ruleId: string | null; message: string }[] }[]) + .flatMap(it => it.messages) + .filter(it => it.ruleId?.startsWith("bun/")); + expect([...new Set(messages.map(it => `${it.ruleId}: ${it.message}`))].sort()).toEqual([ + "bun/consistent-directive-prefix: Use `eslint` instead of `oxlint`.", + "bun/jsx-no-forbidden-nesting: `` cannot be inside ``.", + "bun/no-array-just-to-count: `split()` builds an array only to read its length. Count the separators instead.", + "bun/no-cast-to-brand: This cast creates a `UserId` from an unchecked value. Only `src/ids` should create one.", + "bun/no-cast-to-brand: `toUserId` turns any value into a `UserId`, and this file is not allowed to import it.", + "bun/no-console-window-flash: On Windows, this process briefly opens a console window. Set `windowsHide: true`.", + 'bun/no-cwd-dependent-path: "a.txt" is relative to the working directory of the process. Resolve it from `import.meta.dirname`.', + "bun/no-eager-dynamic-import: This `import()` runs as soon as the module is imported. Call it where it is needed, or use an `import` statement.", + "bun/no-eager-native-addon: The native addon `./addon.node` loads as soon as the module is imported. Load it inside the function that needs it.", + "bun/no-env-at-module-scope: The environment is read as soon as the module is imported. Read it inside the function that needs it.", + "bun/no-env-at-module-scope: `process.env.PORT` is read as soon as the module is imported. Read it inside the function that needs it.", + "bun/no-literal-temp-dir: `/tmp` is not the temporary directory on every system. Use `os.tmpdir()`.", + "bun/no-negative-repeat-count: `repeat()` throws a `RangeError` if this count is negative. Give it a lower bound.", + "bun/no-redundant-catch-type: With `strict` or `useUnknownInCatchVariables`, a `catch` variable is already `unknown`. Remove the annotation.", + "bun/no-restricted-text: `foo` matches the restricted pattern `foo`.", + "bun/no-side-effects-on-import: This changes an object as soon as the module is imported. Move it into an exported function.", + "bun/no-side-effects-on-import: This runs as soon as the module is imported. Move it into an exported function.", + "bun/no-unbounded-requests: This axios request has no time limit. Set `timeout` or pass a `signal`.", + "bun/no-unbounded-requests: `timeout: 0` means no time limit.", + "bun/no-unhandled-emitter-errors: `spawn()` emits `error` when it fails, which crashes the process if nothing listens. Add an `error` listener.", + "bun/no-unportable-commands: On Windows, `npm` is the script `npm.cmd`, which cannot run without a shell.", + "bun/no-unportable-commands: `&&` is shell syntax, and the shell differs between systems.", + "bun/no-unportable-commands: `ls` does not exist on Windows.", + "bun/no-unportable-commands: `ps` does not exist on Windows.", + "bun/no-unportable-commands: `ps` options differ between systems, and `ps` does not exist on Windows.", + "bun/no-unseeded-random-in-property-test: `Math.random()` ignores the seed of the run, so a failure cannot be replayed or shrunk. Make the value a parameter of the property.", + "bun/no-unseeded-random-in-property-test: `sample()` ignores the seed of the run, so a failure cannot be replayed or shrunk. Make the value a parameter of the property.", + "bun/no-useless-spread-fallback: This empty object is not needed. Spreading `false`, `null` or `undefined` into an object does nothing.", + "bun/prefer-builtin-sleep: This promise only waits. Use `Bun.sleep(ms)` or `setTimeout(ms)` from `node:timers/promises`.", + "bun/prefer-lazy-schema: This schema is built as soon as the module is imported. Build it when it is first used.", + "bun/prefer-local-module: Import from `@/http` instead of `axios`.", + "bun/prefer-timeout-signal: Only a timer aborts this controller. Use `AbortSignal.timeout(ms)` instead.", + 'bun/prefer-url-for-sibling-files: Bundlers cannot follow this path. Use `new URL("./file", import.meta.url)`.', + "bun/react-no-latest-value-ref: This effect only copies a value into `saved.current`. Use `useEffectEvent()` to read the latest values without a ref.", + "bun/react-no-latest-value-ref: `saved.current` is set during render. React may render more than once, or throw the result away.", + "bun/react-no-local-render-helpers: `renderCell` is created again on every render, and React does not see it as a component. Move it out and make it a component.", + 'bun/react-no-namespace-hooks: Import `useState` by name from "react".', + "bun/react-prefer-updater-function: `rows` may be out of date when `setRows` is called. Pass a function: `setRows(previous => ..)`.", + ]); + expect(exitCode).toBe(1); + }); + test("bun/prefer-local-module leaves the files in exemptFiles alone", async () => { const options = { modules: [{ package: "axios", use: "@/http" }], exemptFiles: ["src/http.js"] }; const code = `import axios from "axios";\n`; @@ -2593,10 +2897,13 @@ expect(1).toBe(1); "8 bun:test/valid-expect-in-promise", ]); expect(own(reported(stdout, "quiet.test.js"))).toEqual([]); - // Errors, as what else is on, but for one. + // Skipping a test and exporting from a test file are often done on purpose. The rest are errors. const files = JSON.parse(stdout) as { messages: { ruleId: string; severity: number }[] }[]; - const warnings = files.flatMap(it => it.messages).filter(it => it.severity !== 2); - expect(warnings.map(it => it.ruleId)).toEqual(["bun:test/no-disabled-tests"]); + const ofBunTest = files.flatMap(it => it.messages).filter(it => it.ruleId.startsWith("bun:test/")); + expect(ofBunTest.filter(it => it.severity !== 2).map(it => it.ruleId)).toEqual([ + "bun:test/no-disabled-tests", + "bun:test/no-export", + ]); } // A test that is skipped on purpose does not fail the run. expect(unix(skipped.stdout)).toEqual(["2 Warning/bun:test/no-disabled-tests"]); @@ -2703,6 +3010,24 @@ expect(1).toBe(1); expect(named.exitCode).toBe(1); }); + test("`settings.vitest` doesn't apply to it", async () => { + const code = `import { describe } from "bun:test";\nimport { add } from "./add";\ndescribe("add", () => {});\ndescribe("", () => {});\n`; + const config = { + settings: { vitest: { typecheck: true } }, + rules: { "bun:test/prefer-describe-function-title": 2, "bun:test/valid-title": 2 }, + }; + const { stdout } = await lint({ ".oxlintrc.json": JSON.stringify(config), "a.test.ts": code }, ["-f", "unix", "."]); + expect(unix(stdout)).toEqual(["3 Error/bun:test(prefer-describe-function-title)", "4 Error/bun:test(valid-title)"]); + }); + + test("valid-title prints the pattern exactly as the option spells it", async () => { + const code = `import { it, test } from "bun:test";\nit("a", () => {});\ntest("b", () => {});\n`; + const rules = { "bun:test/valid-title": ["error", { mustMatch: { it: "^should ", test: "/^ADDS /i" } }] }; + const { stdout } = await lint({ "eslint.config.mjs": flat(rules), "a.test.js": code }, ["-f", "unix", "."]); + expect(stdout).toContain(":2:4: it should match ^should ["); + expect(stdout).toContain(":3:6: test should match /^ADDS /i ["); + }); + test("comments turn a rule off", async () => { const code = (prefix: string) => `import { test } from "bun:test"; // ${prefix}-disable-next-line ${name} @@ -2863,7 +3188,7 @@ var a = b; ]); expect(Object.entries(JSON.parse(plain.stdout).rules).filter(it => it[0].startsWith("bun:test/"))).toEqual([ ["bun:test/no-disabled-tests", [1]], - ["bun:test/no-export", [2]], + ["bun:test/no-export", [1]], ["bun:test/no-focused-tests", [2]], ["bun:test/valid-describe-callback", [2]], ["bun:test/valid-expect", [2]], @@ -2875,7 +3200,7 @@ var a = b; expect(JSON.parse(oxlint.stdout).plugins).toEqual([]); }); - test("--rules lists them, without a category and without a page", async () => { + test("--rules lists them, without a category", async () => { const [json, lines, alone] = await Promise.all([ lint({ ".oxlintrc.json": "{}" }, ["--rules", "-f", "json"]), lint({}, ["--rules"]), @@ -2885,9 +3210,10 @@ var a = b; expect(alone.stdout).toBe(json.stdout); const listed = (JSON.parse(json.stdout) as Record[]).filter(it => it.scope === "bun:test"); expect(listed.map(it => it.value)).toEqual(Object.keys(twins)); - expect([...new Set(listed.map(it => JSON.stringify([it.category, it.default, it.docs_url])))]).toEqual([ - "[null,false,null]", - ]); + expect([...new Set(listed.map(it => JSON.stringify([it.category, it.default])))]).toEqual(["[null,false]"]); + expect(listed.map(it => it.docs_url)).toEqual( + listed.map(it => `https://bun.com/docs/lint/rules/bun-test/${it.value}`), + ); expect(lines.stdout.split("\n").filter(it => it.startsWith("bun:test/"))).toEqual( Object.keys(twins).map(it => `bun:test/${it}`), ); @@ -2937,7 +3263,7 @@ var a = b; expect(taken.stderr).not.toContain("Key"); expect(taken.exitCode).toBe(0); const missing = await check({ "bun:test/no-mocks-import": "error" }); - expect(missing.stderr).toContain(`1 rule of the plugin "bun:test" did not run: bun:test/no-mocks-import`); + expect(missing.stderr).toContain("1 rule in eslint.config.mjs is not supported: bun:test/no-mocks-import"); expect(missing.exitCode).toBe(2); }); diff --git a/test/cli/lint/bunfig.test.ts b/test/cli/lint/bunfig.test.ts new file mode 100644 index 000000000000..7a24d93489ec --- /dev/null +++ b/test/cli/lint/bunfig.test.ts @@ -0,0 +1,956 @@ +// Tests for the `[lint]` section of bunfig.toml. +import { afterAll, describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { endChildren, spawn } from "../children"; + +afterAll(endChildren); + +// Disable AI agent and CI detection regardless of the environment the tests run in. +const env = { + ...bunEnv, + AGENT: "0", + CLAUDECODE: undefined, + REPL_ID: undefined, + GITHUB_ACTIONS: undefined, + GITHUB_WORKSPACE: undefined, + NO_COLOR: undefined, + FORCE_COLOR: undefined, +}; + +const config = (rules: Record) => `export default [{ rules: ${JSON.stringify(rules)} }];\n`; +const basic = config({ + "no-debugger": "error", + "no-unused-vars": "warn", + eqeqeq: "error", + yoda: ["error", "always"], + "no-var": "warn", +}); +const bad = "var a = 1\nif (a == 2) { debugger; }\n"; + +/** Runs `bun lint ...args` in a temp directory containing `files`. `reads` lists the files to read back afterwards. */ +async function lint(files: Record, args: string[], reads: string[] = []) { + using dir = tempDir("bun-lint", files); + await using proc = spawn({ + cmd: [bunExe(), "lint", ...args], + env, + cwd: String(dir), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const read = (name: string) => + existsSync(join(String(dir), name)) ? readFileSync(join(String(dir), name), "utf8") : null; + type Result = { filePath: string; messages: { ruleId: string | null; severity: number }[] }; + const results: Result[] = stdout.startsWith('{"results":') ? JSON.parse(stdout).results : []; + const problems = results.flatMap(({ filePath, messages }) => { + const file = normalizeBunSnapshot(filePath, String(dir)).replace("/", ""); + return messages.map(it => `${file} ${it.ruleId ?? "-"} ${it.severity === 2 ? "error" : "warn"}`); + }); + return { + /** One `file rule severity` string per problem. Empty unless the run used `-f json-with-metadata`. */ + problems: problems.sort(), + stdout: normalizeBunSnapshot(stdout, String(dir)), + stderr: normalizeBunSnapshot(stderr, String(dir)), + exitCode, + files: Object.fromEntries(reads.map(name => [name, read(name)])), + }; +} + +// Each `[lint]` key sets the default for the flag of the same name. +describe.concurrent("[lint] in bunfig.toml", () => { + /** Returns everything a run leaves behind: its output with timings masked, its exit code and the files. */ + async function run(files: Record, args: string[]) { + const reads = Object.keys(files).filter(name => name !== "bunfig.toml"); + const result = await lint(files, args, reads); + // JSON doubles the backslashes in Windows paths, so `normalizeBunSnapshot` misses the temp directory there. + // Mask its random suffix here. + const text = (printed: string) => printed.replace(/\d+(\.\d+)?m?s\b/g, "0ms").replace(/\bbun-lint_\w+/g, ""); + return { stdout: text(result.stdout), stderr: text(result.stderr), exitCode: result.exitCode, files: result.files }; + } + + const eslint = { "eslint.config.js": basic, "a.js": bad }; + const oxlint = { ".oxlintrc.json": `{ "rules": { "no-debugger": "error", "no-var": "warn" } }`, "a.js": bad }; + const typed = { + ".oxlintrc.json": `{ "rules": { "typescript/no-floating-promises": "error" } }`, + "tsconfig.json": `{ "compilerOptions": { "strict": true }, "include": ["*.ts"] }`, + "a.ts": "async function f() {}\nf();\nconst a: string = 1;\n", + }; + const many = Array.from({ length: 60 }, () => "debugger;\n").join(""); + const suppressions = JSON.stringify({ + "a.js": { "no-debugger": { count: 1 }, eqeqeq: { count: 1 }, yoda: { count: 1 } }, + }); + + // Each case: the bunfig.toml lines, the equivalent flags, flags that override them, the files, and the other args. + const keys: [lines: string, flags: string[], opposite: string[], files: Record, args: string[]][] = [ + [ + `config = "other.config.js"`, + ["--config", "other.config.js"], + ["-c", "eslint.config.js"], + { ...eslint, "other.config.js": config({ "no-var": "error" }) }, + ["a.js"], + ], + [`configLookup = false`, ["--no-config-lookup"], ["--config-lookup"], eslint, ["a.js"]], + [`flavor = "oxlint"`, ["--flavor=oxlint"], ["--flavor=eslint"], { ...eslint, ...oxlint }, ["a.js"]], + [`flavor = "eslint"`, ["--flavor=eslint"], ["--flavor=oxlint"], { ...eslint, ...oxlint }, ["a.js"]], + [`ext = [".foo"]`, ["--ext", ".foo"], [], { ...eslint, "b.foo": bad }, ["."]], + [`ext = ".foo"`, ["--ext", ".foo"], [], { ...eslint, "b.foo": bad }, ["."]], + [ + `ignorePatterns = ["b.js", "sub/"]`, + ["--ignore-pattern", "b.js", "--ignore-pattern=sub/"], + [], + { ...eslint, "b.js": bad, "sub/c.js": bad }, + ["."], + ], + [`ignorePath = "mine"`, ["--ignore-path", "mine"], [], { ...oxlint, "b.js": bad, mine: "b.js\n" }, ["."]], + [ + `ignore = false`, + ["--no-ignore"], + ["--ignore"], + { + "eslint.config.js": `export default [{ ignores: ["a.js"] }, { rules: { "no-debugger": "error" } }];`, + "a.js": bad, + }, + ["a.js"], + ], + [ + `warnIgnored = false`, + ["--no-warn-ignored"], + ["--warn-ignored"], + { "eslint.config.js": `export default [{ ignores: ["a.js"] }];`, "a.js": bad, "b.js": "" }, + ["a.js", "b.js"], + ], + [`quiet = true`, ["--quiet"], ["--no-quiet"], eslint, ["a.js"]], + [`silent = true`, ["--silent"], ["--no-silent"], oxlint, ["a.js"]], + [ + `maxWarnings = 0`, + ["--max-warnings", "0"], + ["--max-warnings=10"], + { ...eslint, "a.js": "var a = 1;\na;\n" }, + ["a.js"], + ], + [ + `denyWarnings = true`, + ["--deny-warnings"], + ["--no-deny-warnings"], + { ...oxlint, "a.js": "var a = 1;\na;\n" }, + ["a.js"], + ], + [`format = "unix"`, ["-f", "unix"], ["--format=json"], eslint, ["a.js"]], + [`all = true`, ["--all"], ["--no-all"], { ...eslint, "a.js": many }, ["-f", "pretty", "a.js"]], + [ + `inlineConfig = false`, + ["--no-inline-config"], + ["--inline-config"], + { ...eslint, "a.js": "// eslint-disable-next-line no-debugger\ndebugger;\n" }, + ["a.js"], + ], + [ + `reportUnusedDisableDirectives = true`, + ["--report-unused-disable-directives"], + ["--report-unused-disable-directives-severity=off"], + { ...eslint, "a.js": "// eslint-disable-next-line no-debugger\na;\n" }, + ["a.js"], + ], + [ + `reportUnusedDisableDirectives = "off"`, + ["--report-unused-disable-directives-severity", "off"], + ["--report-unused-disable-directives"], + { ...eslint, "a.js": "// eslint-disable-next-line no-debugger\na;\n" }, + ["a.js"], + ], + [ + `reportUnusedInlineConfigs = "error"`, + ["--report-unused-inline-configs=error"], + ["--report-unused-inline-configs=off"], + { ...eslint, "a.js": "/* eslint no-debugger: error */\na;\n" }, + ["a.js"], + ], + [ + `suppressionsLocation = "known.json"`, + ["--suppressions-location", "known.json"], + ["--suppressions-location=none.json"], + { ...eslint, "known.json": suppressions }, + ["a.js"], + ], + [ + `passOnUnprunedSuppressions = true`, + ["--pass-on-unpruned-suppressions"], + ["--no-pass-on-unpruned-suppressions"], + { ...eslint, "a.js": "a;\n", "eslint-suppressions.json": suppressions }, + ["a.js"], + ], + [ + `errorOnUnmatchedPattern = false`, + ["--no-error-on-unmatched-pattern"], + ["--error-on-unmatched-pattern"], + eslint, + ["none.js"], + ], + [`passOnNoPatterns = true`, ["--pass-on-no-patterns"], ["--no-pass-on-no-patterns"], eslint, []], + [ + `exitOnFatalError = true`, + ["--exit-on-fatal-error"], + ["--no-exit-on-fatal-error"], + { ...eslint, "a.js": "a(" }, + ["a.js"], + ], + [ + `allowUnsupported = true`, + ["--allow-unsupported"], + ["--no-allow-unsupported"], + { ".eslintrc.json": `{ "rules": { "no-debugger": "error", "require-jsdoc": "warn" } }`, "a.js": bad }, + ["a.js"], + ], + [ + `inferGlobals = true`, + ["--infer-globals"], + ["--no-infer-globals"], + { + "eslint.config.js": config({ "no-undef": "error" }), + "tsconfig.json": `{ "compilerOptions": { "allowJs": true }, "include": ["*.ts", "*.js"] }`, + "globals.d.ts": "declare const mine: number;\n", + "a.js": "mine;\nother;\n", + }, + ["a.js"], + ], + [`typeAware = true`, ["--type-aware"], ["--no-type-aware"], typed, ["a.ts"]], + [ + `typeAware = false`, + ["--no-type-aware"], + ["--type-aware"], + { + ...typed, + ".oxlintrc.json": `{ "options": { "typeAware": true }, "rules": { "typescript/no-floating-promises": "error" } }`, + }, + ["a.ts"], + ], + [`typeCheck = true`, ["--type-check"], ["--no-type-check"], typed, ["--type-aware", "a.ts"]], + [ + `tsconfig = "none.json"`, + ["--tsconfig", "none.json"], + ["--tsconfig", "tsconfig.json"], + typed, + ["--type-aware", "a.ts"], + ], + [`project = "none.json"`, ["--project", "none.json"], ["-p", "tsconfig.json"], typed, ["--type-aware", "a.ts"]], + [ + `disableNestedConfig = true`, + ["--disable-nested-config"], + ["--no-disable-nested-config"], + { ...oxlint, "sub/.oxlintrc.json": `{ "rules": { "no-debugger": "off" } }`, "sub/a.js": bad }, + ["sub/a.js"], + ], + [ + `fixType = ["suggestion"]`, + ["--fix-type", "suggestion"], + [], + { "eslint.config.js": config({ "unicode-bom": ["error", "always"], "no-var": "error" }), "a.js": bad }, + ["--fix", "a.js"], + ], + [ + `[lint.rules]\n"no-debugger" = "off"\neqeqeq = ["error", "smart"]\n"no-eval" = 2`, + ["--rule", `{"no-debugger": "off", "eqeqeq": ["error", "smart"], "no-eval": 2}`], + ["--rule", "no-debugger: warn"], + { ...eslint, "a.js": `${bad}eval(a == null);\n` }, + ["a.js"], + ], + [ + `[lint.rules]\n"no-debugger" = "off"\n"no-eval" = "error"`, + ["--rule", `{"no-debugger": "off", "no-eval": "error"}`, "--flavor=oxlint"], + ["--rule", "no-debugger: warn"], + { ...oxlint, "a.js": `${bad}eval(a);\n` }, + ["a.js"], + ], + [ + `[lint.rules]\n"no-restricted-imports" = ["error", { paths = ["lodash"], patterns = [{ group = ["a\\nb*"], message = "it's \\"so\\"" }] }]`, + ["--rule", `{"no-restricted-imports": ["error", { "paths": ["lodash"] }]}`], + [], + { ...eslint, "a.js": `import "lodash";\n` }, + ["a.js"], + ], + [ + `[lint.globals]\nmine = "readonly"\nyours = true\ntheirs = false\nours = "writable"`, + ["--global", "mine,yours:true", "--global", "theirs,ours:true"], + [], + { + "eslint.config.js": config({ "no-undef": "error", "no-global-assign": "error" }), + "a.js": "mine = yours = theirs = ours = other;\n", + }, + ["a.js"], + ], + [ + `[lint.parserOptions]\necmaFeatures = { jsx = true }`, + ["--parser-options", "ecmaFeatures: { jsx: true }"], + ["--parser-options", "ecmaFeatures: { jsx: false }"], + { ...eslint, "a.js": ";\n" }, + ["a.js"], + ], + [ + `[lint.categories]\nstyle = "warn"\nall = "off"\ncorrectness = "error"`, + ["-A", "all", "-W", "style", "-D", "correctness"], + [], + { ".oxlintrc.json": "{}", "a.js": `${bad}for (;;) {}\n` }, + ["a.js"], + ], + ]; + + test.each(keys)("%s", async (lines, flags, opposite, files, args) => { + const bunfig = { "bunfig.toml": lines.startsWith("[") ? `${lines}\n` : `[lint]\n${lines}\n` }; + const [plain, byKey, byFlag, underFlag, byOtherFlag] = await Promise.all([ + run(files, args), + run({ ...files, ...bunfig }, args), + run(files, [...flags, ...args]), + run({ ...files, ...bunfig }, [...opposite, ...args]), + // Flags add to list and table keys instead of replacing them. + run(files, [...(opposite.length === 0 || /^\[|= \[/.test(lines) ? flags : []), ...opposite, ...args]), + ]); + expect(byKey).toEqual(byFlag); + expect(byKey).not.toEqual(plain); + expect(underFlag).toEqual(byOtherFlag); + }); + + test("threads works like --threads and rejects a negative number", async () => { + const [byKey, byFlag, wrong] = await Promise.all([ + run({ ...eslint, "bunfig.toml": "[lint]\nthreads = 1\n" }, ["a.js"]), + run(eslint, ["--threads=1", "a.js"]), + run({ ...eslint, "bunfig.toml": "[lint]\nthreads = -1\n" }, ["a.js"]), + ]); + expect(byKey).toEqual(byFlag); + expect(wrong.stderr).toContain(`--threads takes a number, not "-1"`); + expect(wrong.exitCode).toBe(2); + }); + + // At 288 bytes per thread, these would take 19 GB, or more than the address space. + test.each(["67108864", "9007199254740991", "9223372036854775808"])( + "%s threads is capped at the maximum", + async count => { + const [byKey, byFlag, few] = await Promise.all([ + run({ ...eslint, "bunfig.toml": `[lint]\nthreads = ${count}\n` }, ["a.js"]), + run(eslint, [`--threads=${count}`, "a.js"]), + run(eslint, ["--threads=1", "a.js"]), + ]); + // Above 2^53 - 1, bunfig.toml fails to load before the key is read. + if (Number.isSafeInteger(Number(count))) expect(byKey).toEqual(few); + expect(byFlag).toEqual(few); + }, + ); + + // ESLint rejects --fix-type in a run that doesn't fix anything. + test("fixType is ignored unless the run fixes", async () => { + const files = { ...eslint, "bunfig.toml": `[lint]\nfixType = ["layout"]\n` }; + const [without, plain, dry, dryByFlag] = await Promise.all([ + run(files, ["a.js"]), + run(eslint, ["a.js"]), + run(files, ["--fix-dry-run", "-f", "json", "a.js"]), + run(eslint, ["--fix-dry-run", "--fix-type=layout", "-f", "json", "a.js"]), + ]); + expect(without).toEqual(plain); + expect(dry).toEqual(dryByFlag); + }); + + test("a key doesn't pick between an ESLint and an oxlint config", async () => { + const both = { ...eslint, ...oxlint }; + const undecided = "note: Using .oxlintrc.json. eslint.config.js is ignored. Use --config to pick it."; + const results = await Promise.all( + [ + `denyWarnings = true`, + `typeAware = true`, + `ext = [".js"]`, + `[lint.rules]\n"no-var" = "off"`, + `[lint.categories]\nstyle = "warn"`, + ].map(lines => + run({ ...both, "bunfig.toml": lines.startsWith("[") ? `${lines}\n` : `[lint]\n${lines}\n` }, ["a.js"]), + ), + ); + for (const result of results) { + expect(result.stderr).toContain(undecided); + expect(result.exitCode).toBe(1); + } + // With only an ESLint config, neither the key nor the flag switches the run to oxlint. + const [byKey, byFlag, plain] = await Promise.all([ + run({ ...eslint, "bunfig.toml": "[lint]\ndenyWarnings = true\n" }, ["-f", "unix", "a.js"]), + run(eslint, ["--deny-warnings", "-f", "unix", "a.js"]), + run(eslint, ["-f", "unix", "a.js"]), + ]); + expect(byKey.stdout).toBe(plain.stdout); + expect(byFlag.stdout).toBe(plain.stdout); + }); + + test("bun/format compares a file with bun fmt's output, including [fmt] options", async () => { + const files = { "a.js": "a('b')\n", "bunfig.toml": "[fmt]\nsemi = false\nsingleQuote = true\n" }; + const rule = (...options: unknown[]) => ({ + ".oxlintrc.json": JSON.stringify({ rules: { "bun/format": ["error", ...options] } }), + }); + const [withSection, without, overridden] = await Promise.all([ + run({ ...files, ...rule() }, ["-f", "unix", "a.js"]), + run({ "a.js": files["a.js"], ...rule() }, ["-f", "unix", "a.js"]), + run({ ...files, ...rule({ semi: true }) }, ["-f", "unix", "a.js"]), + ]); + expect(withSection.stdout).toBe(""); + expect(withSection.exitCode).toBe(0); + expect(without.stdout).toContain(`Replace \`'b')\` with \`"b");\``); + expect(overridden.stdout).toContain("Insert `;`"); + expect(overridden.stdout).not.toContain("Replace"); + }); + + test("bunfig.toml is read from the working directory, after --cwd is applied", async () => { + const files = { + ...eslint, + "sub/a.js": bad, + "bunfig.toml": `[lint]\nformat = "unix"\n`, + "sub/bunfig.toml": `[lint]\nquiet = true\nformat = "json"\n`, + }; + const [above, below] = await Promise.all([run(files, ["sub/a.js"]), run(files, ["--cwd=sub", "a.js"])]); + expect(above.stdout).toContain("[Warning/no-var]"); + expect(JSON.parse(below.stdout)[0].warningCount).toBe(0); + }); + + /** Runs `bun ...args` in a temp directory containing `files`. */ + async function bun(files: Record, args: string[]) { + using dir = tempDir("bun-lint-bunfig", files); + const stdio = { stdin: "ignore", stdout: "pipe", stderr: "pipe" } as const; + await using proc = spawn({ cmd: [bunExe(), ...args], env, cwd: String(dir), ...stdio }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + test("--config= before `lint` picks a file other than bunfig.toml", async () => { + const files = { + ...eslint, + "bunfig.toml": `[lint]\nformat = "unix"\n`, + "other.toml": `[lint]\nformat = "json"\n`, + }; + const results = await Promise.all([ + bun(files, ["lint", "a.js"]), + bun(files, ["--config=other.toml", "lint", "a.js"]), + bun(files, ["-c=other.toml", "lint", "a.js"]), + // Flags after `lint` belong to the linter. + bun(files, ["--config=other.toml", "lint", "-c", "eslint.config.js", "a.js"]), + bun(files, ["--config=none.toml", "lint", "a.js"]), + ]); + expect(results.map(it => [it.stdout.startsWith("[{"), it.exitCode])).toEqual([ + [false, 1], + [true, 1], + [true, 1], + [true, 1], + [false, 2], + ]); + expect(results[4].stderr).toContain(`while reading config "`); + }); + + test("errors in [lint] point at the line in bunfig.toml, like errors in other sections", async () => { + const bunfig = `[lint]\nquiet = true\n\n[lint.rules]\neqeqeq = ["error", "smart"]\n"no-var" = "always"\n`; + const result = await bun({ ...eslint, "bunfig.toml": bunfig }, ["lint", "a.js"]); + expect(result.stderr).toContain(`6 | "no-var" = "always"\n`); + expect(result.stderr).toMatch( + /error: "always" is not a valid severity for no-var\n Expected "off", "warn", or "error"\n\s+at bunfig\.toml:6:12\n$/, + ); + expect(result.stdout).toBe(""); + expect(result.exitCode).toBe(2); + }); + + test.each(["--config=", "-c="])( + "a mistake in the rules of bun %sother.toml lint is shown in that file", + async flag => { + const files = { + "a.js": "foo;\n", + "bunfig.toml": `# Not read.\n[lint.rules]\n"no-var" = "error"\n`, + "config/other.toml": `[lint.rules]\n"no-vra" = "error"\n`, + }; + const result = await bun(files, [flag + "config/other.toml", "lint", "a.js"]); + expect(result.stderr).toContain(`2 | "no-vra" = "error"\n`); + expect(result.stderr).toMatch( + /error: Unknown rule "no-vra"\n Did you mean "no-var"\?\n\s+at config\/other\.toml:2:1\n$/, + ); + expect(result.exitCode).toBe(2); + }, + ); + + // These flags describe a single run, not the project. + test.each([ + ["fix = true", "--fix"], + ["fixDryRun = true", "--fix-dry-run"], + ["fixSuggestions = true", "--fix-suggestions"], + ["fixDangerously = true", "--fix-dangerously"], + ["init = true", "--init"], + ["stdin = true", "--stdin"], + [`stdinFilename = "a.js"`, "--stdin-filename"], + [`printConfig = "a.js"`, "--print-config"], + ["suppressAll = true", "--suppress-all"], + [`suppressRule = ["no-var"]`, "--suppress-rule"], + ["pruneSuppressions = true", "--prune-suppressions"], + [`outputFile = "report.txt"`, "--output-file"], + ["color = true", "--color"], + ["timing = true", "--timing"], + [`cwd = "sub"`, "--cwd"], + ])("%s is rejected and nothing is written", async (line, flag) => { + const key = line.split(" ")[0]; + const result = await run({ ...eslint, "bunfig.toml": `[lint]\n${line}\n` }, ["a.js"]); + expect(result.stderr).toContain( + `error: "${key}" cannot be set in bunfig.toml\n Use ${flag} on the command line\n`, + ); + expect(result.stdout).toBe(""); + expect(result.files["a.js"]).toBe(bad); + expect(result.exitCode).toBe(2); + }); + + test.each([ + ["quiet = 1", "expected boolean but received number"], + [`maxWarnings = "0"`, "expected number but received string"], + ["maxWarnings = 1.5", "maxWarnings must be a whole number"], + ["config = false", "expected string but received boolean"], + ["ignorePatterns = true", "expected array but received boolean"], + ["ignorePatterns = [1]", "expected string but received number"], + [`flavor = "biome"`, '--flavor must be eslint or oxlint, not "biome"'], + [`format = "fancy"`, `Unknown format "fancy"\n Available formats: stylish, pretty, json,`], + [`fixType = ["all"]`, `Unknown fix type\n Expected "directive", "problem", "suggestion", or "layout"`], + ["fixType = true", "expected array but received boolean"], + [ + `reportUnusedDisableDirectives = "loud"`, + `"loud" is not a valid severity for reportUnusedDisableDirectives\n Expected "off", "warn", or "error"`, + ], + ["reportUnusedDisableDirectives = 3", "3 is not a valid severity for reportUnusedDisableDirectives"], + [`reportUnusedInlineConfigs = true`, "true is not a valid severity for reportUnusedInlineConfigs"], + ["rules = true", "expected object but received boolean"], + [ + `[lint.rules]\neqeqeq = "always"`, + `"always" is not a valid severity for eqeqeq\n Expected "off", "warn", or "error"`, + ], + [`[lint.rules]\neqeqeq = ["smart"]`, `"smart" is not a valid severity for eqeqeq`], + [`[lint.rules]\neqeqeq = []`, "[] is not a valid severity for eqeqeq"], + [`[lint.rules]\neqeqeq = true`, "true is not a valid severity for eqeqeq"], + [ + `[lint.globals]\na = "on"`, + `"on" is not a valid value for the global a\n Expected "readonly", "writable", or "off"`, + ], + [`[lint.globals]\na = 1`, "1 is not a valid value for the global a"], + [`[lint.categories]\nstyles = "warn"`, `Unknown key "styles" in [lint.categories]\n Did you mean "style"?`], + [`[lint.categories]\nsecurity = "warn"`, `Unknown key "security" in [lint.categories]\n at`], + [`[lint.categories]\nstyle = "deny"`, `"deny" is not a valid severity for style`], + ["maxWarning = 0", `Unknown key "maxWarning" in [lint]\n Did you mean "maxWarnings"?`], + ["max-warnings = 0", `Unknown key "max-warnings" in [lint]\n Did you mean "maxWarnings"?`], + // Flag names whose key is spelled differently. + [`ignorePattern = ["a"]`, `Unknown key "ignorePattern" in [lint]\n Did you mean "ignorePatterns"?`], + [`rule = "a"`, `Unknown key "rule" in [lint]\n Did you mean "rules"?`], + [`global = "a"`, `Unknown key "global" in [lint]\n Did you mean "globals"?`], + [`deny = "style"`, `Unknown key "deny" in [lint]\n Did you mean "categories"?`], + // No similar key, so no suggestion. + [`plugins = ["import"]`, `Unknown key "plugins" in [lint]\n at`], + [`extends = ["a"]`, `Unknown key "extends" in [lint]\n at`], + ["cache = true", `Unknown key "cache" in [lint]\n at`], + [`env = ["node"]`, `Unknown key "env" in [lint]\n at`], + ])("%s is rejected: %s", async (lines, message) => { + const bunfig = lines.startsWith("[") ? `${lines}\n` : `[lint]\n${lines}\n`; + const result = await run({ ...eslint, "bunfig.toml": bunfig }, ["a.js"]); + expect(result.stderr).toContain(message); + expect(result.stdout).toBe(""); + expect(result.exitCode).toBe(2); + }); + + test("every flag in --help has a key, or an error that names the flag to use instead", async () => { + const help = await lint({}, ["--help", "--all"]); + const flags = [...help.stdout.matchAll(/^\s+(?:-\w, )?--([a-z0-9-]+)/gm)].map(it => it[1]); + expect(flags.length).toBeGreaterThan(90); + const renamed: Record = { + "ignore-pattern": "ignorePatterns", + "report-unused-disable-directives-severity": "reportUnusedDisableDirectives", + rule: "rules", + global: "globals", + allow: "categories", + warn: "categories", + deny: "categories", + }; + const answers = await Promise.all( + flags.map(async flag => { + const positive = flag.replace(/^no-/, ""); + const key = renamed[positive] ?? positive.replace(/-(\w)/g, (_, it) => it.toUpperCase()); + // No key takes a nested array, so a key that exists complains about the type. + const { stderr } = await run({ "bunfig.toml": `[lint]\n${key} = [[1]]\n` }, ["--list-files"]); + return [flag, /expected .+ but received|is not a valid severity|cannot be set in bunfig\.toml/.test(stderr)]; + }), + ); + // ESLint and oxlint flags that aren't project settings, or that do nothing. + expect( + answers + .filter(it => !it[1]) + .map(it => it[0]) + .sort(), + ).toEqual([ + "cache", + "cache-file", + "cache-location", + "cache-strategy", + "concurrency", + "disable-oxc-plugin", + "disable-typescript-plugin", + "disable-unicorn-plugin", + "env", + "flag", + "import-plugin", + "jest-plugin", + "jsdoc-plugin", + "jsx-a11y-plugin", + "lsp", + "mcp", + "nextjs-plugin", + "no-eslintrc", + "node-plugin", + "parser", + "plugin", + "promise-plugin", + "react-perf-plugin", + "react-plugin", + "resolve-plugins-relative-to", + "rulesdir", + "vitest-plugin", + "vue-plugin", + ]); + }); +}); + +describe.concurrent("[[lint.overrides]]", () => { + /** Returns the problems as `file rule severity`. This output format is the same for every kind of config. */ + async function problems(files: Record, args: string[] = []) { + return (await lint(files, ["-f", "json-with-metadata", ...args])).problems; + } + + const log = "console.log(1);\n"; + const tree = { + "a.test.js": log, + "sub/b.test.js": log, + "sub/c.js": log, + "scripts/d.js": log, + "scripts/vendor/e.js": log, + "README.md": "# Not JavaScript\n", + }; + const bunfig = ` +[lint.rules] +"no-console" = "error" + +[[lint.overrides]] +files = ["*.test.js", "scripts/**"] +excludeFiles = ["scripts/vendor/**"] +rules = { "no-console" = "off" } + +[[lint.overrides]] +files = "*.md" +rules = { "no-var" = "error" } +`; + const left = ["scripts/vendor/e.js no-console error", "sub/c.js no-console error"]; + const flat = `module.exports = [{ rules: { "no-console": "warn" } }];\n`; + const oxlintrc = JSON.stringify({ categories: { correctness: "off" }, rules: { "no-console": "warn" } }); + const configs: Record> = { + "no config file": {}, + "eslint.config.js": { "eslint.config.js": flat }, + ".oxlintrc.json": { ".oxlintrc.json": oxlintrc }, + ".eslintrc.json": { ".eslintrc.json": JSON.stringify({ root: true, rules: { "no-console": "warn" } }) }, + }; + + describe.each(Object.keys(configs))("with %s", name => { + const files = { ...tree, ...configs[name], "bunfig.toml": bunfig }; + + // A pattern without a slash matches a file name in any directory. Others match the path relative to bunfig.toml. + test("an override applies to files that match `files` but not `excludeFiles`", async () => { + expect(await problems(files)).toEqual(left); + }); + + test("`files` doesn't add files to the run", async () => { + const { stdout, exitCode } = await lint(files, ["--list-files"]); + expect(stdout).toContain("c.js"); + expect(stdout).not.toContain("README.md"); + expect(exitCode).toBe(0); + }); + + test("--rule wins over an override, even when it repeats [lint.rules]", async () => { + const all = Object.keys(tree).filter(it => it.endsWith(".js")); + expect(await problems(files, ["--rule", "no-console: error"])).toEqual( + all.map(it => `${it} no-console error`).sort(), + ); + expect(await problems(files, ["--rule", "no-console: warn", "sub"])).toEqual([ + "sub/b.test.js no-console warn", + "sub/c.js no-console warn", + ]); + }); + + test("a later override wins over an earlier one", async () => { + const later = `${bunfig}\n[[lint.overrides]]\nfiles = "sub/**"\nrules = { "no-console" = "warn" }\n`; + expect(await problems({ ...files, "bunfig.toml": later })).toEqual([ + "scripts/vendor/e.js no-console error", + "sub/b.test.js no-console warn", + "sub/c.js no-console warn", + ]); + }); + + test("patterns use Bun.Glob syntax, including extglobs and brace ranges", async () => { + const names = ["a/x.js", "ab/x.js", "c/x.js", "f1.js", "f3.js", "f4.js"]; + const files = { ...configs[name], ...Object.fromEntries(names.map(it => [it, log])) }; + const override = (patterns: string) => + `[lint.rules]\n"no-console" = "error"\n[[lint.overrides]]\n${patterns}\nrules = { "no-console" = "off" }\n`; + const globs = `["+(a|b)/**", "f{1..3}.js"]`; + const reported = async (patterns: string) => + (await problems({ ...files, "bunfig.toml": override(patterns) })).map(it => it.split(" ")[0]); + expect(await reported(`files = ${globs}`)).toEqual(["c/x.js", "f4.js"]); + expect(await reported(`files = "**/*.js"\nexcludeFiles = ${globs}`)).toEqual([ + "a/x.js", + "ab/x.js", + "f1.js", + "f3.js", + ]); + }); + + test("a leading ! negates a pattern, with or without a slash", async () => { + const [a, aTest, b, bTest] = ["a.js", "a.test.js", "src/b.js", "src/b.test.js"]; + const files = { ...configs[name], ...Object.fromEntries([a, aTest, b, bTest].map(it => [it, log])) }; + // The patterns, and the files that still get reported because the override doesn't apply to them. + const rows: Record = { + 'files = ["*.test.js"]': [a, b], + 'files = ["!**/*.test.js"]': [aTest, bTest], + 'files = ["!*.test.js"]': [aTest, bTest], + 'files = ["!!*.test.js"]': [a, b], + 'files = ["!./a.js"]': [a], + 'files = ["!src/"]': [], + 'files = ["!src/**"]': [b, bTest], + // `!(` starts an extglob group. It isn't a negation. + 'files = ["!(a|b).js"]': [a, b], + 'files = ["!!(a|b).js"]': [aTest, bTest], + 'files = ["**/*.js"]\nexcludeFiles = ["!*.test.js"]': [a, b], + 'files = ["**/*.js"]\nexcludeFiles = ["!**/*.test.js"]': [a, b], + 'files = ["**/*.js"]\nexcludeFiles = ["!(a|b).js"]': [aTest, bTest], + }; + const reported = async (patterns: string) => { + const bunfig = `[lint.rules]\n"no-console" = "error"\n[[lint.overrides]]\n${patterns}\nrules = { "no-console" = "off" }\n`; + return (await problems({ ...files, "bunfig.toml": bunfig })).map(it => it.split(" ")[0]); + }; + const results = await Promise.all(Object.keys(rows).map(reported)); + expect(Object.fromEntries(Object.keys(rows).map((patterns, at) => [patterns, results[at]]))).toEqual(rows); + }); + + test("an override can set globals, and --global wins over it", async () => { + const files = { + ...configs[name], + "a.js": "mine;\n", + "a.test.js": "mine;\n", + "bunfig.toml": ` +[lint.rules] +"no-undef" = "error" +[lint.globals] +mine = "readonly" +[[lint.overrides]] +files = "*.test.js" +globals = { mine = "off" } +`, + }; + expect(await problems(files, ["a.js", "a.test.js"])).toEqual(["a.test.js no-undef error"]); + expect(await problems(files, ["--global", "mine", "a.js", "a.test.js"])).toEqual([]); + }); + }); + + // oxlint has no extglobs or brace ranges. It reads `{1..3}` as a group with one alternative, `1..3`. + // Checked against oxlint 1.87. + test("overrides in an .oxlintrc.json keep oxlint's glob syntax", async () => { + const overrides = [{ files: ["f{1..3}.js", "@(a|b)/**"], rules: { "no-console": "off" } }]; + const files = { + ".oxlintrc.json": JSON.stringify({ ...JSON.parse(oxlintrc), rules: { "no-console": "error" }, overrides }), + "f1.js": log, + "f1..3.js": log, + "a/x.js": log, + }; + expect(await problems(files)).toEqual(["a/x.js no-console error", "f1.js no-console error"]); + }); + + // oxlint puts `**/` in front of a pattern without a slash, even in front of a `!`, which then is a literal. Checked against + // oxlint 1.87. + test("a negated pattern without a slash in an .oxlintrc.json matches nothing, like in oxlint", async () => { + const config = (override: object) => + JSON.stringify({ + ...JSON.parse(oxlintrc), + rules: { "no-console": "error" }, + overrides: [{ ...override, rules: { "no-console": "off" } }], + }); + const files = { "a.js": log, "a.test.js": log }; + const reported = async (override: object) => + (await problems({ ...files, ".oxlintrc.json": config(override) })).map(it => it.split(" ")[0]); + expect(await reported({ files: ["!*.test.js"] })).toEqual(["a.js", "a.test.js"]); + expect(await reported({ files: ["!**/*.test.js"] })).toEqual(["a.test.js"]); + expect(await reported({ files: ["**/*.js"], excludeFiles: ["!*.test.js"] })).toEqual([]); + }); + + test("patterns are relative to bunfig.toml's directory, wherever the config file is", async () => { + const files = { ...tree, "bunfig.toml": bunfig, "config/mine.json": oxlintrc, "config/mine.js": flat }; + expect(await problems(files, ["--config", "config/mine.json"])).toEqual(left); + expect(await problems(files, ["--config", "config/mine.js"])).toEqual(left); + const nested = { ...tree, "bunfig.toml": bunfig, ".oxlintrc.json": "{}", "scripts/.oxlintrc.json": oxlintrc }; + expect(await problems(nested)).toEqual(left); + // That's the bunfig.toml in the working directory. + const below = { ...tree, "bunfig.toml": "", "scripts/bunfig.toml": bunfig.replaceAll("scripts/", "") }; + expect(await problems(below, ["--cwd", "scripts"])).toEqual(["scripts/vendor/e.js no-console error"]); + }); + + test("a severity on its own keeps the options from [lint.rules]", async () => { + const files = { + "a.js": "export const f = a => [a == null, a == 1];\n", + "bunfig.toml": `[lint.rules]\neqeqeq = ["error", "smart"]\n[[lint.overrides]]\nfiles = "*.js"\nrules = { eqeqeq = "warn" }\n`, + }; + expect(await problems(files)).toEqual(["a.js eqeqeq warn"]); + }); + + test("a core rule set for *.test.ts runs as its typescript-eslint version in every directory", async () => { + const files = { + "a.test.ts": "const unused = 1;\nexport {};\n", + "sub/b.test.ts": "const unused = 1;\nexport {};\n", + "bunfig.toml": `[[lint.overrides]]\nfiles = "*.test.ts"\nrules = { "no-unused-vars" = "error" }\n`, + }; + expect(await problems(files)).toEqual([ + "a.test.ts @typescript-eslint/no-unused-vars error", + "sub/b.test.ts @typescript-eslint/no-unused-vars error", + ]); + }); + + test("an override can set settings", async () => { + const files = { + "a.jsx": "export default ;\n", + "legacy/b.jsx": "export default ;\n", + "bunfig.toml": ` +[lint.rules] +"jsx-a11y/alt-text" = "error" +[lint.globals] +Picture = "readonly" +[[lint.overrides]] +files = "legacy/**" +settings = { "jsx-a11y" = { components = { Picture = "img" } } } +`, + }; + expect(await problems(files)).toEqual(["legacy/b.jsx jsx-a11y/alt-text error"]); + }); + + test("an override's tables can be written as [lint.overrides.*] sections", async () => { + const files = { + ...tree, + "bunfig.toml": ` +[lint.rules] +"no-console" = "error" +[[lint.overrides]] +files = "*.test.js" +[lint.overrides.rules] +"no-console" = "off" +[[lint.overrides]] +files = "scripts/*" +[lint.overrides.rules] +"no-console" = "warn" +`, + }; + expect(await problems(files)).toEqual([ + "scripts/d.js no-console warn", + "scripts/vendor/e.js no-console error", + "sub/c.js no-console error", + ]); + }); + + const inOverride = (lines: string) => `[[lint.overrides]]\nfiles = "*.js"\n${lines}`; + test.each([ + ["[lint]\noverrides = true", "expected array but received boolean", "2:13"], + [ + `[lint.overrides]\nfiles = "*.js"`, + `"overrides" is an array of tables\n Write [[lint.overrides]], with two brackets`, + "1:7", + ], + ["[lint]\noverrides = [1]", "expected object but received number", "2:14"], + [`[[lint.overrides]]\nrules = { "no-var" = "error" }`, `an override needs "files"`, "1:8"], + ["[[lint.overrides]]\nfiles = []", `an override needs "files"`, "1:8"], + ["[[lint.overrides]]\nfiles = 1", "expected string or array but received number", "2:9"], + [`[[lint.overrides]]\nfiles = ["a", 1]`, "expected string but received number", "2:15"], + [`[[lint.overrides]]\nfiles = "{a"`, "Invalid glob pattern `{a`: unclosed brace expansion", "2:9"], + [`[[lint.overrides]]\nfiles = "a"\nexcludeFiles = "a["`, "Invalid glob pattern `a[`", "3:16"], + [`[[lint.overrides]]\nfile = "a"`, `Unknown key "file" in [lint.overrides]\n Did you mean "files"?`, "2:1"], + [ + inOverride(`excludedFiles = "b"`), + `Unknown key "excludedFiles" in [lint.overrides]\n Did you mean "excludeFiles"?`, + "3:1", + ], + [inOverride("options = {}"), `Unknown key "options" in [lint.overrides]\n at`, "3:1"], + [inOverride("rules = 1"), "expected object but received number", "3:9"], + [inOverride(`rules = { "no-vra" = "error" }`), `Unknown rule "no-vra"\n Did you mean "no-var"?`, "3:11"], + [ + inOverride(`[lint.overrides.rules]\n"no-var" = "always"`), + `"always" is not a valid severity for no-var\n Expected "off", "warn", or "error"`, + "4:12", + ], + [ + inOverride(`[lint.overrides.rules]\neqeqeq = ["error", "sometimes"]`), + `"sometimes" is not a valid option for eqeqeq\n Expected "always", "smart", or "allow-null"`, + "4:20", + ], + [ + inOverride(`globals = { a = "yes" }`), + `"yes" is not a valid value for the global a\n Expected "readonly", "writable", or "off"`, + "3:17", + ], + [inOverride("settings = 1"), "expected object but received number", "3:12"], + ])("%s is rejected", async (bunfig, message, place) => { + const { stdout, stderr, exitCode } = await lint({ "a.js": log, "bunfig.toml": `${bunfig}\n` }, []); + expect(stderr).toContain(`error: ${message}`); + expect(stderr).toEndWith(`\n at bunfig.toml:${place}`); + expect(stdout).toBe(""); + expect(exitCode).toBe(2); + }); + + test.each(["eslint.config.js", ".eslintrc.json"])( + "with %s, an invalid rule option in an override is reported as an error in bunfig.toml", + async name => { + const bunfig = inOverride(`rules = { eqeqeq = ["error", "sometimes"] }\n`); + const { stderr, exitCode } = await lint({ ...configs[name], "a.js": log, "bunfig.toml": bunfig }, []); + expect(stderr).toContain("Invalid config in bunfig.toml\n"); + expect(exitCode).toBe(2); + }, + ); +}); + +// Which rules are on and whether type information is available are decided separately. +describe.concurrent("type information without a config file", () => { + const files = { + "tsconfig.json": `{ "compilerOptions": { "strict": true } }`, + "a.ts": "async function f() {}\nf();\nexport const a: string = 1;\n", + }; + const floating = `[lint.rules]\n"@typescript-eslint/no-floating-promises" = "error"\n`; + const run = async (bunfig: string, args: string[] = []) => { + const result = await lint({ ...files, "bunfig.toml": bunfig }, ["-f", "json-with-metadata", ...args]); + return [result.problems, result.stderr.includes("Skipped 1 rule that needs type information")]; + }; + + test("turning on a rule that needs types is enough to run it", async () => { + const reported = ["a.ts @typescript-eslint/no-floating-promises error"]; + expect(await run("")).toEqual([[], false]); + expect(await run(floating)).toEqual([reported, false]); + expect(await run("", ["--rule", "@typescript-eslint/no-floating-promises: error"])).toEqual([reported, false]); + const inOverride = floating.replace("[lint.rules]", `[[lint.overrides]]\nfiles = "*.ts"\n[lint.overrides.rules]`); + expect(await run(inOverride)).toEqual([reported, false]); + }); + + // `consistent-type-imports` only reads tsconfig.json when type information is on. + test("a rule that can use types but doesn't need them doesn't turn type information on", async () => { + const files = { + "tsconfig.json": `{ "compilerOptions": { "experimentalDecorators": true } }`, + "database.ts": "export class Database {}\n", + "a.ts": `import { Database } from "./database";\n@Injectable()\nexport class Users {\n constructor(private database: Database) {}\n}\n`, + }; + const uses = `[lint.rules]\n"@typescript-eslint/consistent-type-imports" = "error"\n`; + const args = ["-f", "json-with-metadata", "a.ts"]; + const reported = "a.ts @typescript-eslint/consistent-type-imports error"; + expect((await lint({ ...files, "bunfig.toml": uses }, args)).problems).toEqual([]); + expect((await lint({ ...files, "bunfig.toml": uses }, ["--type-aware", ...args])).problems).toContain(reported); + }); + + test("typeAware = false and --no-type-aware skip rules that need types", async () => { + expect(await run(`[lint]\ntypeAware = false\n${floating}`)).toEqual([[], true]); + expect(await run(floating, ["--no-type-aware"])).toEqual([[], true]); + }); + + test("typeCheck and --type-check report type errors without turning any rule on", async () => { + const reported = ["a.ts typescript/TS2322 error"]; + expect(await run("[lint]\ntypeCheck = true\n")).toEqual([reported, false]); + expect(await run("", ["--type-check"])).toEqual([reported, false]); + }); +}); diff --git a/test/cli/lint/config-files.test.ts b/test/cli/lint/config-files.test.ts index c803ede81c78..ff02c096ab27 100644 --- a/test/cli/lint/config-files.test.ts +++ b/test/cli/lint/config-files.test.ts @@ -133,7 +133,7 @@ describe.concurrent("a function in an eslint.config.js", () => { "b.js": code, }); expect(problems).toEqual([]); - expect(stderr).toContain(`Config "mine": Key "${key}": A function is not supported, at user-defined index 1.`); + expect(stderr).toContain(`Config "mine": Key "${key}": Functions are not supported, at user-defined index 1.`); expect(exitCode).toBe(2); }); @@ -797,7 +797,7 @@ test.concurrent( const { problems, stderr, exitCode } = await lint(files, ["a.mjs", "b.mjs"]); expect(problems).toEqual(["a.mjs:1:1 no-var"]); expect(stderr).toContain( - `1 file was not linted, only the parser of the configuration can read it: b.mjs. The package "eslint" lints with that parser, if it is installed`, + "1 file was not linted because only the parser in your config can read it: b.mjs\n To lint with that parser, install eslint: bun add -d eslint", ); expect(exitCode).toBe(2); expect((await lint(files, ["--allow-unsupported", "a.mjs", "b.mjs"])).exitCode).toBe(1); @@ -1084,10 +1084,10 @@ describe.concurrent("a note says that a plugin that is built in is installed in test("by a minor version or more", async () => { expect(await notes(typescript("8.43.0"))).toEqual([ - "note: @typescript-eslint/eslint-plugin 8.43.0 is installed; bun lint follows 8.71.1.", + "note: @typescript-eslint/eslint-plugin 8.43.0 is installed, but bun lint's built-in rules match 8.71.1", ]); expect(await notes(typescript("7.18.0"))).toEqual([ - "note: @typescript-eslint/eslint-plugin 7.18.0 is installed; bun lint follows 8.71.1.", + "note: @typescript-eslint/eslint-plugin 7.18.0 is installed, but bun lint's built-in rules match 8.71.1", ]); expect( await notes( @@ -1099,10 +1099,10 @@ describe.concurrent("a note says that a plugin that is built in is installed in }), ), ).toEqual([ - "note: eslint-plugin-n 17.16.2 is installed; bun lint follows 18.4.1.", - "note: eslint-plugin-import 2.31.0 is installed; bun lint follows 2.32.0.", - "note: eslint-plugin-react-hooks 5.2.0 is installed; bun lint follows 7.1.1.", - "note: eslint-plugin-react 7.33.2 is installed; bun lint follows 7.37.5.", + "note: eslint-plugin-n 17.16.2 is installed, but bun lint's built-in rules match 18.4.1", + "note: eslint-plugin-import 2.31.0 is installed, but bun lint's built-in rules match 2.32.0", + "note: eslint-plugin-react-hooks 5.2.0 is installed, but bun lint's built-in rules match 7.1.1", + "note: eslint-plugin-react 7.33.2 is installed, but bun lint's built-in rules match 7.37.5", ]); }); @@ -1114,6 +1114,24 @@ describe.concurrent("a note says that a plugin that is built in is installed in expect(await notes(flat({ n: { name: "eslint-plugin-node", version: "11.1.0" } }))).toEqual([]); }); + // eslint-plugin-react-hooks 7.1.1 ships with `meta.version: "7.0.0"`. + test("the version in the package's package.json wins over a stale meta.version", async () => { + const hooks = flat({ "react-hooks": { name: "eslint-plugin-react-hooks", version: "7.0.0" } }); + const installed = (version: string) => ({ + "node_modules/eslint-plugin-react-hooks/package.json": JSON.stringify({ + name: "eslint-plugin-react-hooks", + version, + }), + }); + expect(await notes({ ...hooks, ...installed("7.1.1") })).toEqual([]); + expect(await notes({ ...hooks, ...installed("6.1.0") })).toEqual([ + "note: eslint-plugin-react-hooks 6.1.0 is installed, but bun lint's built-in rules match 7.1.1", + ]); + expect(await notes(hooks)).toEqual([ + "note: eslint-plugin-react-hooks 7.0.0 is installed, but bun lint's built-in rules match 7.1.1", + ]); + }); + test("not where nobody reads it", async () => { for (const flags of [["--quiet"], ["--silent"], ["-f", "json"], ["-f", "unix"]]) { expect([flags, await notes(typescript("8.43.0"), ...flags)]).toEqual([flags, []]); @@ -1130,7 +1148,7 @@ describe.concurrent("a note says that a plugin that is built in is installed in "node_modules/@typescript-eslint/eslint-plugin/index.js": `module.exports = { rules: {} };`, }; expect(await notes(files)).toEqual([ - "note: @typescript-eslint/eslint-plugin 5.62.0 is installed; bun lint follows 8.71.1.", + "note: @typescript-eslint/eslint-plugin 5.62.0 is installed, but bun lint's built-in rules match 8.71.1", ]); }); }); @@ -1389,7 +1407,7 @@ describe.concurrent("what is built in is not loaded to evaluate an eslint.config const file = join(String(dir), "loaded.txt"); const loaded = existsSync(file) ? readFileSync(file, "utf8").split("\n").filter(Boolean) : []; const reported = [...stdout.matchAll(/a\.ts:(\d+:\d+): (.+) \[Error\/.+\]\r?$/gm)].map(it => `${it[1]} ${it[2]}`); - return { reported, loaded: [...new Set(loaded)].sort(), failure: /^error: .*/m.exec(stderr)?.[0] }; + return { reported, loaded: [...new Set(loaded)].sort(), failure: /^error: .*(\n.+)*/m.exec(stderr)?.[0] }; } const builtIn = "1:17 Unexpected any. Specify a different type."; const recommended = `export default [{ files: ["**/*.ts"], ...all.configs.recommended }];`; @@ -1545,6 +1563,332 @@ describe.concurrent("an .oxlintrc.json", () => { }); }); + // Each row is what oxlint 1.87 does. The name is the one it prints. + test.each<[config: object, name: string | undefined]>([ + [{ rules: { "no-debugger": ["error", { a: 1 }] } }, "no-debugger"], + [{ rules: { "no-debugger": ["off", 1] } }, "no-debugger"], + [{ rules: { "no-debugger": ["error", null] } }, "no-debugger"], + [{ rules: { "no-debugger": ["error", {}] } }, "no-debugger"], + [{ rules: { "eslint/no-debugger": ["error", 1] } }, "no-debugger"], + [{ overrides: [{ files: ["*.ts"], rules: { "no-debugger": ["error", 1] } }] }, "no-debugger"], + [{ rules: { "@typescript-eslint/no-non-null-assertion": ["error", 1] } }, "typescript/no-non-null-assertion"], + [{ plugins: ["jsx-a11y"], rules: { "jsx-a11y/html-has-lang": ["error", 1] } }, "jsx_a11y/html-has-lang"], + [{ plugins: ["import"], rules: { "import-x/no-self-import": ["error", 1] } }, "import/no-self-import"], + [{ plugins: ["jest"], rules: { "jest/no-focused-tests": ["error", 1] } }, "jest/no-focused-tests"], + [{ plugins: ["vitest"], rules: { "vitest/no-focused-tests": ["error", 1] } }, "vitest/no-focused-tests"], + [{ plugins: ["react"], rules: { "react/no-danger": ["error", 1] } }, "react/no-danger"], + // The plugin is not on. + [{ rules: { "react/no-danger": ["error", 1] } }, undefined], + [{ rules: { "no-debugger": ["error"] } }, undefined], + [{ rules: { eqeqeq: ["error", "always"] } }, undefined], + ])("options for a rule of oxlint that takes none: %j", async (config, name) => { + const files = { + ".oxlintrc.json": JSON.stringify({ categories: { correctness: "off" }, ...config }), + "a.js": "export {};\n", + }; + const { stdout, exitCode } = await lint(files, ["a.js"]); + const refusal = "This rule does not accept configuration options."; + if (name) expect(stdout).toContain(`Invalid configuration for rule \`${name}\`:\n ${refusal}`); + else expect(stdout).not.toContain(refusal); + expect(exitCode).toBe(name ? 1 : 0); + }); + + // Each row is what oxlint 1.87 says about the options of a rule: the lines below "Invalid configuration for rule", or `null` if it + // takes them. It says everything at once, in the order of the texts. + describe("the options of the rules of oxlint", () => { + // prettier-ignore + const rows: [rule: string, options: unknown[], refusal: string | null][] = [ + ["eqeqeq", ["sometimes"], " unknown variant `sometimes`, expected `always` or `smart`, received `[ \"sometimes\" ]`"], + ["eqeqeq", ["always", {"null": "x"}], " unknown variant `x`, expected one of `always`, `never`, `ignore`, received `[ \"always\", { \"null\": \"x\" } ]`"], + ["eqeqeq", ["always", {}, 1], " invalid length 3, expected fewer elements in array, received `[ \"always\", {}, 1 ]`"], + ["eqeqeq", [1], " invalid type: integer `1`, expected string or map, received `[ 1 ]`"], + ["eqeqeq", [{"always": 1}], " invalid type: integer `1`, expected unit, received `[ { \"always\": 1 } ]`"], + ["eqeqeq", [{}], " invalid value: map, expected map with a single key, received `[ {} ]`"], + ["max-lines", [-1], " invalid type: integer `-1`, expected struct MaxLinesConfig\n received config: `-1`"], + ["max-lines", [{"max": -1}], " invalid value: integer `-1`, expected u32\n received config: `{ \"max\": -1 }`"], + ["max-lines", [{"max": 1.5}], " invalid type: floating point `1.5`, expected u32\n received config: `{ \"max\": 1.5 }`"], + ["max-lines", [{"max": 4294967296}], " invalid value: integer `4294967296`, expected u32\n received config: `{ \"max\": 4294967296 }`"], + ["max-lines", ["300"], " invalid type: string \"300\", expected struct MaxLinesConfig\n received config: `\"300\"`"], + ["max-lines", [{"max": "300"}], " invalid type: string \"300\", expected u32\n received config: `{ \"max\": \"300\" }`"], + ["max-lines", [{"maxx": 1}], " unknown field `maxx`, expected one of `max`, `skipBlankLines`, `skipComments`\n received config: `{ \"maxx\": 1 }`"], + ["max-lines", [[1, true, true, 1]], " invalid length 4, expected fewer elements in array\n received config: `[ 1, true, true, 1 ]`"], + ["curly", ["nope"], " unknown variant `nope`, expected one of `all`, `multi`, `multi-line`, `multi-or-nest`, received `[ \"nope\" ]`"], + ["curly", ["all", "x"], " unknown variant `x`, expected `consistent`, received `[ \"all\", \"x\" ]`"], + ["no-debugger", [{}], " This rule does not accept configuration options."], + ["no-unused-vars", [{"varsIgnorePattern": "("}], " Invalid 'varsIgnorePattern' option for no-unused-vars: regex parse error:\n (\n ^\nerror: unclosed group"], + ["no-unused-vars", [{"vars": "x"}], " Invalid 'vars' option for no-unused-vars: Expected or all or local, got x"], + ["no-unused-vars", [{"args": 1}], " Invalid 'args' option for no-unused-vars: Expected a string, got 1"], + ["no-unused-vars", [{"fix": {"imports": true}}], " Invalid 'fix.imports' option for no-unused-vars: Expected a boolean or string, got true"], + ["no-unused-vars", [[1, {"a": null}]], " Invalid 'vars' option for no-unused-vars: Expected a string or an object, got [1,{\"a\":null}]"], + ["no-unused-vars", [{"argsIgnorePattern": "(?=a)", "args": "all"}], " Invalid 'argsIgnorePattern' option for no-unused-vars: regex parse error:\n (?=a)\n ^^^\nerror: look-around, including look-ahead and look-behind, is not supported"], + ["getter-return", [{"allowImplicit": "yes"}], " invalid type: string \"yes\", expected a boolean\n received config: `{ \"allowImplicit\": \"yes\" }`"], + ["getter-return", [null], " invalid type: null, expected struct GetterReturn\n received config: `null`"], + ["getter-return", [[1]], " invalid type: integer `1`, expected a boolean\n received config: `[ 1 ]`"], + ["getter-return", [{"zz": "a b\u00a0c"}], " unknown field `zz`, expected `allowImplicit`\n received config: `{ \"zz\": \"a b c\" }`"], + ["getter-return", [1e+21], " invalid type: floating point `1e+21`, expected struct GetterReturn\n received config: `1e+21`"], + ["getter-return", [1.5e-07], " invalid type: floating point `1.5e-7`, expected struct GetterReturn\n received config: `1.5e-7`"], + ["getter-return", [1e-05], " invalid type: floating point `0.00001`, expected struct GetterReturn\n received config: `0.00001`"], + ["getter-return", [10000000000000000], " invalid type: integer `10000000000000000`, expected struct GetterReturn\n received config: `10000000000000000`"], + ["getter-return", [123456789012], " invalid type: integer `123456789012`, expected struct GetterReturn\n received config: `123456789012`"], + ["getter-return", ["a\n\"b\\\u0001\u00e9\u200b"], " invalid type: string \"a\\n\\\"b\\\\\\u{1}\u00e9\\u{200b}\", expected struct GetterReturn\n received config: `\"a\\n\\\"b\\\\\\u0001\u00e9\u200b\"`"], + ["getter-return", [[true, 1]], " invalid length 2, expected fewer elements in array\n received config: `[ true, 1 ]`"], + ["yoda", ["never", {"exceptRange": 1}], " invalid type: integer `1`, expected a boolean, received `[ \"never\", { \"exceptRange\": 1 } ]`"], + ["id-length", [{"min": -1}], " invalid value: integer `-1`, expected u64\n received config: `{ \"min\": -1 }`"], + ["id-length", [{"exceptionPatterns": ["["]}], " regex parse error:\n [\n ^\nerror: unclosed character class\n received config: `{ \"exceptionPatterns\": [ \"[\" ] }`"], + ["id-length", [{"properties": "sometimes"}], " unknown variant `sometimes`, expected `always` or `never`\n received config: `{ \"properties\": \"sometimes\" }`"], + ["id-match", ["(?a)(?b)"], " regex parse error:\n (?a)(?b)\n ^ ^\nerror: duplicate capture group name, received `[ \"(?a)(?b)\" ]`"], + ["id-match", ["a{2,1}"], " regex parse error:\n a{2,1}\n ^^^^^\nerror: invalid repetition count range, the start must be <= the end, received `[ \"a{2,1}\" ]`"], + ["id-match", ["\\1"], " regex parse error:\n \\1\n ^^\nerror: backreferences are not supported, received `[ \"\\\\1\" ]`"], + ["id-match", ["[z-a]"], " regex parse error:\n [z-a]\n ^^^\nerror: invalid character class range, the start must be <= the end, received `[ \"[z-a]\" ]`"], + ["id-match", ["(?i-i)"], " regex parse error:\n (?i-i)\n ^ ^\nerror: duplicate flag, received `[ \"(?i-i)\" ]`"], + ["id-match", ["a\nb("], " regex parse error:\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n1: a\n2: b(\n ^\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\nerror: unclosed group, received `[ \"a\\nb(\" ]`"], + ["id-match", ["\\x{110000}"], " regex parse error:\n \\x{110000}\n ^^^^^^\nerror: hexadecimal literal is not a Unicode scalar value, received `[ \"\\\\x{110000}\" ]`"], + ["id-match", ["\u65e5\u672c("], " regex parse error:\n \u65e5\u672c(\n ^\nerror: unclosed group, received `[ \"\u65e5\u672c(\" ]`"], + ["id-denylist", ["a", 1], " invalid type: integer `1`, expected a string, received `[ \"a\", 1 ]`"], + ["func-names", ["always", {"generators": "x"}], " unknown variant `x`, expected one of `always`, `as-needed`, `never`, received `[ \"always\", { \"generators\": \"x\" } ]`"], + ["func-name-matching", ["x"], " Expected configuration to match either the first or second tuple type"], + ["func-name-matching", ["always", {"zz": 1}], " Error deserializing second tuple element: unknown field `zz`, expected `considerPropertyDescriptor` or `includeCommonJSModuleExports`"], + ["func-name-matching", ["always", {}, 1], " Expected at most 2 elements in the configuration array"], + ["func-name-matching", [{}, {}], " Error deserializing first tuple element: invalid value: map, expected map with a single key"], + ["logical-assignment-operators", ["never", {}], " options object is only valid with \"always\", received `[ \"never\", {} ]`"], + ["logical-assignment-operators", ["x"], " unknown variant `x`, expected `always` or `never`, received `[ \"x\" ]`"], + ["logical-assignment-operators", [1], " expected [], [\"always\"], [\"never\"], or [\"always\", options], received `[ 1 ]`"], + ["logical-assignment-operators", ["always", {"enforceForIfStatements": 1}], " invalid type: integer `1`, expected a boolean, received `[ \"always\", { \"enforceForIfStatements\": 1 } ]`"], + ["no-restricted-properties", [{}], " expected either `object` or `property`"], + ["no-restricted-properties", [{"object": "a", "allowObjects": ["b"]}], " `allowObjects` requires `property`"], + ["no-restricted-properties", [{"object": 1}], " invalid type: integer `1`, expected a string"], + ["no-restricted-properties", [{"object": "a"}, {"object": "a", "property": "b", "allowProperties": ["c"]}], " `allowProperties` cannot be used with `property`"], + ["sort-imports", [{"memberSyntaxSortOrder": ["none", "all"]}], " memberSyntaxSortOrder must contain exactly 4 kinds, got 2\n received config: `{ \"memberSyntaxSortOrder\": [ \"none\", \"all\" ] }`"], + ["sort-imports", [{"memberSyntaxSortOrder": ["none", "all", "multiple", "multiple"]}], " memberSyntaxSortOrder must contain 4 unique kinds, but duplicates were found\n received config: `{ \"memberSyntaxSortOrder\": [ \"none\", \"all\", \"multiple\", \"multiple\" ] }`"], + ["no-implicit-coercion", [{"allow": ["x"]}], " Invalid operator 'x' in allow list. Valid values are: \"!!\", \"~\", \"+\", \"- -\", \"-\", \"*\"\n received config: `{ \"allow\": [ \"x\" ] }`"], + ["no-implicit-coercion", [{"allow": "!!"}], " invalid type: string \"!!\", expected a sequence of operator strings\n received config: `{ \"allow\": \"!!\" }`"], + ["typescript/ban-ts-comment", [{"ts-check": "x"}], " expected a boolean, `allow-with-description`, or a descriptionFormat object\n received config: `{ \"ts-check\": \"x\" }`"], + ["typescript/ban-ts-comment", [{"ts-check": {"descriptionFormat": "("}}], " regex parse error:\n (\n ^\nerror: unclosed group\n received config: `{ \"ts-check\": { \"descriptionFormat\": \"(\" } }`"], + ["typescript/ban-ts-comment", [{"minimumDescriptionLength": -1}], " invalid value: integer `-1`, expected u64\n received config: `{ \"minimumDescriptionLength\": -1 }`"], + ["typescript/ban-ts-comment", [{"ts-ignore": {"zz": 1}}], " unknown field `zz`, expected `descriptionFormat`\n received config: `{ \"ts-ignore\": { \"zz\": 1 } }`"], + ["typescript/no-floating-promises", [{"allowForKnownSafeCalls": [{"from": "package", "name": "a"}]}], " data did not match any variant of untagged enum TypeOrValueSpecifier\n received config: `{ \"allowForKnownSafeCalls\": [ { \"from\": \"package\", \"name\": \"a\" } ] }`"], + ["typescript/no-restricted-types", [{"types": {"k": false}}], " data did not match any variant of untagged enum BanConfigValue\n received config: `{ \"types\": { \"k\": false } }`"], + ["typescript/array-type", [{"default": "x"}], " unknown variant `x`, expected one of `array`, `array-simple`, `generic`\n received config: `{ \"default\": \"x\" }`"], + ["typescript/no-this-alias", [{"allowNames": [], "allowedNames": []}], " duplicate field `allowedNames`\n received config: `{ \"allowNames\": [], \"allowedNames\": [] }`"], + ["unicorn/filename-case", [{"case": "x"}], " unknown variant `x`, expected one of `kebabCase`, `camelCase`, `snakeCase`, `pascalCase`, `lowercase`, `screamingSnakeCase`\n received config: `{ \"case\": \"x\" }`"], + ["unicorn/import-style", [{"styles": {"k": true}}], " module style override boolean must be `false`\n received config: `{ \"styles\": { \"k\": true } }`"], + ["react/jsx-no-literals", [{"zz": 1}], " unknown field `zz`\n received config: `{ \"zz\": 1 }`"], + ["react/jsx-no-literals", [{"noStrings": 1, "elementOverrides": 1}], " invalid type: integer `1`, expected a map\n received config: `{ \"noStrings\": 1, \"elementOverrides\": 1 }`"], + ["react/jsx-no-literals", [[]], " invalid type: sequence, expected struct JsxNoLiteralsConfig\n received config: `[]`"], + ["react/jsx-no-script-url", [{"zz": 1}], " Expected configuration to match either the first or second tuple type"], + ["react/jsx-no-script-url", [[], null], " Error deserializing second tuple element: invalid type: null, expected struct JsxNoScriptUrlOptions"], + ["react/forbid-component-props", [{"forbid": [{"message": "a"}]}], " data did not match any variant of untagged enum ForbidItem\n received config: `{ \"forbid\": [ { \"message\": \"a\" } ] }`"], + ["jsx-a11y/aria-role", [{"ignoreNonDOM": 1}], " invalid type: integer `1`, expected a boolean\n received config: `{ \"ignoreNonDOM\": 1 }`"], + ["react-perf/jsx-no-new-object-as-prop", [{"nativeAllowList": "x"}], " data did not match any variant of untagged enum NativeAllowList\n received config: `{ \"nativeAllowList\": \"x\" }`"], + ["jest/no-restricted-matchers", [{"toBe": 1}], " invalid type: integer `1`, expected a string\n received config: `{ \"toBe\": 1 }`"], + ["jest/no-restricted-matchers", [[]], " invalid type: sequence, expected struct NoRestrictedMatchersConfig\n received config: `[]`"], + ["jest/max-expects", [{"max": "x"}], " invalid type: string \"x\", expected u32\n received config: `{ \"max\": \"x\" }`"], + ["vitest/max-expects", [{"max": -1}], " invalid value: integer `-1`, expected u32\n received config: `{ \"max\": -1 }`"], + ["import/no-cycle", [{"maxDepth": -1}], " invalid value: integer `-1`, expected u32\n received config: `{ \"maxDepth\": -1 }`"], + ["import/first", ["x"], " unknown variant `x`, expected `absolute-first` or `disable-absolute-first`\n received config: `\"x\"`"], + ["import/no-nodejs-modules", [{}], " missing field `allow`\n received config: `{}`"], + ["import/no-nodejs-modules", [[]], " invalid length 0, expected struct NoNodejsModulesConfig with 1 element\n received config: `[]`"], + ["no-invalid-regexp", [{"allowConstructorFlags": ["ab"]}], " invalid value: string \"ab\", expected a character\n received config: `{ \"allowConstructorFlags\": [ \"ab\" ] }`"], + ["no-invalid-regexp", [{"allowConstructorFlags": [1]}], " invalid type: integer `1`, expected a character\n received config: `{ \"allowConstructorFlags\": [ 1 ] }`"], + ["no-fallthrough", [{"commentPattern": "("}], " regex parse error:\n (?iu)(\n ^\nerror: unclosed group\n received config: `{ \"commentPattern\": \"(\" }`"], + ["capitalized-comments", ["always", {"ignorePattern": "("}], " regex parse error:\n ^\\s*(?:()\n ^\nerror: unclosed group"], + ["capitalized-comments", ["always", 1], " invalid type: integer `1`, expected struct OptionsJson"], + ["capitalized-comments", ["always", {"line": {"zz": 1}}], " unknown field `zz`, expected one of `ignorePattern`, `ignoreInlineComments`, `ignoreConsecutiveComments`"], + ["promise/param-names", [{"resolvePattern": "["}], " regex parse error:\n [\n ^\nerror: unclosed character class\n received config: `{ \"resolvePattern\": \"[\" }`"], + ["one-var", [1], " data did not match any variant of untagged enum OneVarConfig\n received config: `1`"], + ["prefer-destructuring", [{"zz": 1}], " data did not match any variant of untagged enum PreferDestructuringOption, received `[ { \"zz\": 1 } ]`"], + ["complexity", [{"max": 1, "maximum": 2}], " duplicate field `max`\n received config: `{ \"max\": 1, \"maximum\": 2 }`"], + ["complexity", ["x"], " invalid type: string \"x\", expected struct ComplexityConfig\n received config: `\"x\"`"], + ["jsdoc/require-param", [1], " invalid type: integer `1`, expected struct RequireParamConfig"], + ["vue/max-props", [{"maxProps": -1}], " invalid value: integer `-1`, expected u32\n received config: `{ \"maxProps\": -1 }`"], + ["jsdoc/require-param-name", [1], " This rule does not accept configuration options."], + ["no-empty-function", [{"allow": ["x"]}], " unknown variant `x`, expected one of `function`, `functions`, `arrow-functions`, `arrowFunctions`, `generator-functions`, `generatorFunctions`, `method`, `methods`, `generator-methods`, `generatorMethods`, `getter`, `getters`, `setter`, `setters`, `constructor`, `constructors`, `async-functions`, `asyncFunctions`, `async-methods`, `asyncMethods`, `private-constructors`, `privateConstructors`, `protected-constructors`, `protectedConstructors`, `decorated-functions`, `decoratedFunctions`, `override-methods`, `overrideMethods`\n received config: `{ \"allow\": [ \"x\" ] }`"], + ["node/no-new-require", [1], " This rule does not accept configuration options."], + ["nextjs/no-img-element", [null], " This rule does not accept configuration options."], + ["oxc/no-barrel-file", [{"threshold": "x"}], " invalid type: string \"x\", expected u32\n received config: `{ \"threshold\": \"x\" }`"], + ["vue/no-reserved-props", [{"vueVersion": 4}], " vueVersion must be either 2 or 3\n received config: `{ \"vueVersion\": 4 }`"], + ["vue/no-reserved-props", [{"vueVersion": 256}], " invalid value: integer `256`, expected u8\n received config: `{ \"vueVersion\": 256 }`"], + ["vue/no-reserved-props", [[1, 1]], " vueVersion must be either 2 or 3\n received config: `[ 1, 1 ]`"], + ["jsx-a11y/control-has-associated-label", [{"depth": 26, "labelAttributes": 1}], " depth must be less than or equal to 25\n received config: `{ \"depth\": 26, \"labelAttributes\": 1 }`"], + ["jsx-a11y/control-has-associated-label", [{"labelAttributes": 1, "depth": 26}], " invalid type: integer `1`, expected a sequence\n received config: `{ \"labelAttributes\": 1, \"depth\": 26 }`"], + ["jest/expect-expect", [{"assertFunctionNames": [1, "a.(.b"]}], " invalid assert function pattern `a.(.b`: regex parse error:\n (?ui)^a\\.(\\.b(\\.|$)\n ^\nerror: unclosed group"], + ["jest/expect-expect", [{"assertFunctionNames": ["**("]}], " invalid assert function pattern `**(`: regex parse error:\n (?ui)^[a-z\\d]*[a-z\\d]*((\\.|$)\n ^\nerror: unclosed group"], + ["vitest/expect-expect", [{"assertFunctionNames": ["["]}], " invalid assert function pattern `[`: regex parse error:\n (?ui)^[(\\.|$)\n ^\nerror: unclosed character class"], + ["jest/prefer-ending-with-an-expect", [{"assertFunctionNames": ["a", "["]}], " regex parse error:\n (?ui)^[(\\.|$)\n ^\nerror: unclosed character class\n received config: `{ \"assertFunctionNames\": [ \"a\", \"[\" ] }`"], + ["jest/prefer-ending-with-an-expect", [{"assertFunctionNames": ["(", 1]}], " invalid type: integer `1`, expected a string\n received config: `{ \"assertFunctionNames\": [ \"(\", 1 ] }`"], + ["jest/prefer-ending-with-an-expect", [{"assertFunctionNames": ["("], "zz": 1}], " regex parse error:\n (?ui)^((\\.|$)\n ^\nerror: unclosed group\n received config: `{ \"assertFunctionNames\": [ \"(\" ], \"zz\": 1 }`"], + ["no-restricted-globals", ["a", {"globals": 1}], " Expected 'object' property to be an array"], + ["no-restricted-globals", [{"globals": ["a", 1]}], " Expected 'globals' array to contain either strings or objects"], + ["one-var", [[null, null]], " data did not match any variant of untagged enum OneVarConfig\n received config: `[ null, null ]`"], + ["react/forbid-elements", [{"forbid": [["x", null]]}], " data did not match any variant of untagged enum ForbidItem\n received config: `{ \"forbid\": [ [ \"x\", null ] ] }`"], + ["react/forbid-dom-props", [{"forbid": [[null, null, null, "x"]]}], " data did not match any variant of untagged enum ForbidDomPropsItem\n received config: `{ \"forbid\": [ [ null, null, null, \"x\" ] ] }`"], + ["typescript/no-restricted-types", [{"types": {"k": [null, null, null]}}], " data did not match any variant of untagged enum BanConfigValue\n received config: `{ \"types\": { \"k\": [ null, null, null ] } }`"], + ["capitalized-comments", ["always", {"line": []}], " invalid length 1, expected struct CommentConfigJson with 3 elements"], + ["capitalized-comments", ["always", {"block": ["a", true]}], " invalid length 2, expected struct CommentConfigJson with 3 elements"], + ["react/jsx-fragments", [{"mode": "x"}], " data did not match any variant of untagged enum JsxFragments\n received config: `{ \"mode\": \"x\" }`"], + ["react/jsx-fragments", [["syntax"]], " data did not match any variant of untagged enum JsxFragments\n received config: `[ \"syntax\" ]`"], + ["id-match", ["(?-u)\\xFF"], " regex parse error:\n (?-u)\\xFF\n ^^^^\nerror: pattern can match invalid UTF-8, received `[ \"(?-u)\\\\xFF\" ]`"], + ["id-match", ["(?-u:[^a])"], " regex parse error:\n (?-u:[^a])\n ^^^^\nerror: pattern can match invalid UTF-8, received `[ \"(?-u:[^a])\" ]`"], + ["id-match", ["(?-u)[\u00e9]"], " regex parse error:\n (?-u)[\u00e9]\n ^\nerror: Unicode not allowed here, received `[ \"(?-u)[\u00e9]\" ]`"], + ["id-match", ["a\\pXb"], " regex parse error:\n a\\pXb\n ^^^\nerror: Unicode property not found, received `[ \"a\\\\pXb\" ]`"], + ["vue/no-reserved-props", [{"vueVersion": 2}], null], + ["jsx-a11y/control-has-associated-label", [{"depth": 25}], null], + ["jest/expect-expect", [{"assertFunctionNames": ["a.*.b", "**", "x*", "+"], "additionalTestBlockFunctions": ["("]}], null], + ["jest/expect-expect", ["("], null], + ["jest/expect-expect", [{"assertFunctionNames": "("}], null], + ["jest/prefer-ending-with-an-expect", [{"assertFunctionNames": ["a.*.b", "**"], "additionalTestBlockFunctions": ["("]}], null], + ["no-restricted-globals", ["a", {"name": "b", "message": "m"}, 1], null], + ["no-restricted-globals", [{"globals": ["a", {"name": 1}], "checkGlobalObject": 1}], null], + ["one-var", [[null, true]], null], + ["react/forbid-dom-props", [{"forbid": [["x", null, null, null]]}], null], + ["capitalized-comments", ["always", {"line": [null, null, null]}], null], + ["react/jsx-fragments", [{"mode": "element", "zz": 1}], null], + ["id-match", ["(?-u)\u00e9\\x{FF}\\B((?u).)"], null], + ["id-match", ["\\pl\\p{Greek}"], null], + ["no-use-before-define", ["x"], " invalid type: string \"x\", expected struct NoUseBeforeDefineConfig\n received config: `\"x\"`"], + ["no-use-before-define", ["nofunc"], null], + ["eqeqeq", ["always", {"null": "ignore"}], null], + ["eqeqeq", ["smart"], null], + ["eqeqeq", [{"always": null}], null], + ["eqeqeq", [], null], + ["max-lines", [300], null], + ["max-lines", [{"max": 300, "skipComments": true}], null], + ["max-lines", [0], null], + ["max-lines", [1, 2], null], + ["max-lines", [[1, true]], null], + ["curly", ["multi", "consistent"], null], + ["curly", ["all", null], null], + ["getter-return", [{}], null], + ["getter-return", [{}, {"zz": 1}], null], + ["getter-return", [[]], null], + ["getter-return", [[true]], null], + ["no-unused-vars", [{"zz": 1}], null], + ["no-unused-vars", [null], null], + ["no-unused-vars", [{"varsIgnorePattern": "^_"}], null], + ["no-unused-vars", [{"caughtErrors": true}], null], + ["no-unused-vars", [{"varsIgnorePattern": 1}], null], + ["no-unused-vars", ["local"], null], + ["id-match", ["^[a-z]+$", {"properties": true}], null], + ["id-match", ["(?x) a # c"], null], + ["id-match", ["[[:alpha:]&&[^a]]\\b{start}\\p{Greek}\\x{41}"], null], + ["func-name-matching", [{}], null], + ["func-name-matching", ["never"], null], + ["func-name-matching", ["always", {}], null], + ["logical-assignment-operators", ["always", {"enforceForIfStatements": true}], null], + ["logical-assignment-operators", ["never"], null], + ["no-restricted-properties", [{"object": "a"}, {"property": "b", "allowObjects": ["c"]}], null], + ["typescript/ban-ts-comment", [{"ts-check": {"descriptionFormat": "^: .+$"}, "ts-ignore": "allow-with-description", "ts-nocheck": true}], null], + ["typescript/no-floating-promises", [{"allowForKnownSafeCalls": ["a", {"from": "file", "name": ["a"], "path": null}, {"from": "package", "name": "a", "package": "p"}]}], null], + ["typescript/no-restricted-types", [{"types": {"a": true, "b": "m", "c": {"message": null, "fixWith": "d"}}}], null], + ["radix", ["x"], null], + ["max-statements", ["x"], null], + ["no-restricted-imports", [{"paths": 1}], null], + ["react/jsx-no-literals", [{"elementOverrides": {"a": {"allowElement": true, "noStrings": true}}, "noStrings": true}], null], + ["react/forbid-component-props", [{"forbid": ["a", {"propName": "b", "message": null}, {"propNamePattern": "c"}]}], null], + ["sort-imports", [{"memberSyntaxSortOrder": ["single", "multiple", "all", "none"]}], null], + ["unicorn/import-style", [{"styles": {"k": false, "l": {"named": true}}}], null], + ["jest/no-restricted-matchers", [{"toBe": "m", "toEqual": null}], null], + ["capitalized-comments", ["x", {"zz": 1, "ignorePattern": "a", "line": {"ignoreInlineComments": true}}, 1], null], + ["complexity", [20], null], + ["complexity", [{"maximum": 2, "variant": "modified"}], null], + ["no-invalid-regexp", [{"allowConstructorFlags": ["a", "\u00e9"]}], null], + ]; + const plugins = [...new Set(rows.flatMap(([rule]) => (rule.includes("/") ? [rule.split("/")[0]] : [])))]; + // A rule is once in a file. + const rounds: (typeof rows)[] = []; + for (const row of rows) { + const round = rounds.find(it => it.every(other => other[0] !== row[0])); + if (round) round.push(row); + else rounds.push([row]); + } + + test.each(rounds.map((round, at) => [at, round] as const))("file %i", async (_, round) => { + const rules = Object.fromEntries(round.map(([rule, options]) => [rule, ["off", ...options]])); + const files = { + ".oxlintrc.json": JSON.stringify({ plugins, categories: { correctness: "off" }, rules }), + "a.js": "export {};\n", + }; + const { raw, exitCode } = await lint(files, ["a.js"]); + const expected = round + .filter(it => it[2] !== null) + .map(([rule, , refusal]) => { + const name = rule.replace(/^(jsx|react)-(a11y|perf)\//, "$1_$2/"); + return `Invalid configuration for rule \`${name}\`:\n${refusal}`; + }) + .sort(); + const at = raw.indexOf("Invalid configuration"); + expect(at < 0 ? "" : raw.slice(at).trimEnd()).toBe(expected.join("\n\n")); + expect(exitCode).toBe(expected.length > 0 ? 1 : 0); + }); + + const bad = (word: string) => ["error", word]; + const said = async (config: object, extended: object = {}, flags: string[] = []) => { + const files = { + ".oxlintrc.json": JSON.stringify({ categories: { correctness: "off" }, ...config }), + "extended.json": JSON.stringify(extended), + "a.js": "export {};\n", + }; + const { raw, exitCode } = await lint(files, [...flags, "a.js"]); + return [[...raw.matchAll(/Invalid configuration for rule `([^`]+)`/g)].map(it => it[1]), exitCode]; + }; + const forJs = (rules: object, more: object = {}) => ({ files: ["*.js"], rules, ...more }); + const key = { "react/jsx-key": ["error", 1] }; + const extending = { extends: ["./extended.json"] }; + + const looks: [name: string, rules: string[], config: object, extended?: object, flags?: string[]][] = [ + [ + "the file before its overrides", + ["eqeqeq"], + { rules: { eqeqeq: bad("x") }, overrides: [forJs({ curly: bad("x") })] }, + ], + [ + "one override after the other", + ["curly", "yoda"], + { overrides: [forJs({ yoda: bad("x"), curly: bad("x") }), forJs({ eqeqeq: bad("x") })] }, + ], + [ + "with what it extends", + ["curly", "yoda"], + { ...extending, rules: { yoda: bad("x") } }, + { rules: { curly: bad("x") } }, + ], + ["not what it says otherwise", [], { ...extending, rules: { curly: "off" } }, { rules: { curly: bad("x") } }], + [ + "the overrides of what it extends first", + ["curly"], + { ...extending, overrides: [forJs({ yoda: bad("x") })] }, + { overrides: [forJs({ curly: bad("x") })] }, + ], + ["a plugin of what it extends", ["react/jsx-key"], extending, { plugins: ["react"], rules: key }], + [ + "a plugin of what it extends, for its own rules", + ["react/jsx-key"], + { ...extending, rules: key }, + { plugins: ["react"] }, + ], + ["its plugin, for what it extends", ["react/jsx-key"], { ...extending, plugins: ["react"] }, { rules: key }], + ["both have plugins", ["react/jsx-key"], { ...extending, plugins: [] }, { plugins: ["react"], rules: key }], + ["a plugin of a flag, for what it extends", ["react/jsx-key"], extending, { rules: key }, ["--react-plugin"]], + [ + "a plugin of an override that it extends", + ["react/jsx-key"], + extending, + { overrides: [forJs(key, { plugins: ["react"] })] }, + ], + ["an override that it extends, without the plugin", [], extending, { overrides: [forJs(key)] }], + [ + "an override that it extends, with the plugin of that file", + ["react/jsx-key"], + extending, + { plugins: ["react"], overrides: [forJs(key)] }, + ], + ]; + test.each(looks.map(it => [it[0], it] as const))("what is looked at: %s", async (_, row) => { + const [, rules, config, extended, flags] = row; + expect(await said(config, extended, flags)).toEqual([rules, rules.length > 0 ? 1 : 0]); + }); + }); + // oxlint 1.87 takes each, and each is valid by its own configuration_schema.json. The schemas of ESLint's rules refuse them. test("options that oxlint takes and the schema of ESLint's rule does not", async () => { const sets: [rule: string, options: unknown[]][] = [ @@ -2085,11 +2429,123 @@ describe.concurrent("an .oxlintrc.json", () => { [{ rules: [] }, "invalid type: sequence, expected Record { - const { problems, stdout, exitCode } = await lint({ ".oxlintrc.json": JSON.stringify(config), "a.js": code }); + const files = { ".oxlintrc.json": JSON.stringify(config), "a.js": code }; + const { problems, raw, stdout, exitCode } = await lint(files); expect(problems).toEqual([]); expect(stdout).toContain("Cannot use the configuration file /.oxlintrc.json:\n"); - expect(stdout).toContain(why); + // As it was printed: it has backslashes. + expect(raw).toContain(why); expect(exitCode).toBe(1); }); @@ -2116,6 +2572,37 @@ describe.concurrent("an .oxlintrc.json", () => { expect(byAlias.exitCode).toBe(1); }); + // `plugins` refuses "bun", so the note must not ask for it. oxlint 1.87 takes a `name` in `jsPlugins`. + test("the note about a plugin in JavaScript that is called like Bun's own gives advice that the config takes", async () => { + const rule = `{ create: () => ({}) }`; + const files = { + "bun.js": `export default { meta: { name: "bun" }, rules: { "no-array-just-to-count": ${rule}, own: ${rule}, other: ${rule} } };`, + "a.js": "export {};\n", + }; + const advice = async (config: object) => { + const all = { categories: { correctness: "off" }, ...config }; + const result = await lint({ ...files, ".oxlintrc.json": JSON.stringify(all) }, ["-f", "stylish", "a.js"]); + expect(result.exitCode).toBe(0); + return result.stderr.split("\n").filter(it => it.startsWith(" To use ")); + }; + const builtIn = { "bun/no-array-just-to-count": "error" }; + expect(await advice({ jsPlugins: ["./bun.js"], rules: builtIn })).toEqual([ + ` To use the built-in rule, remove ./bun.js from "jsPlugins".`, + ]); + expect(await advice({ jsPlugins: ["./bun.js"], rules: { ...builtIn, "bun/own": "error" } })).toEqual([ + ` To use the built-in rule, give the plugin another name: "jsPlugins": [{ "name": "bun-js", "specifier": "./bun.js" }]. Then rename the other rule to "bun-js/..".`, + ]); + expect( + await advice({ jsPlugins: ["./bun.js"], rules: { ...builtIn, "bun/own": "error", "bun/other": "warn" } }), + ).toEqual([ + ` To use the built-in rule, give the plugin another name: "jsPlugins": [{ "name": "bun-js", "specifier": "./bun.js" }]. Then rename the other rules to "bun-js/..".`, + ]); + // What the notes ask for. + expect(await advice({ rules: builtIn })).toEqual([]); + const jsPlugins = [{ name: "bun-js", specifier: "./bun.js" }]; + expect(await advice({ jsPlugins, rules: { ...builtIn, "bun-js/own": "error" } })).toEqual([]); + }); + test("oxlint.config.mts is read, and two files of oxlint in one directory are refused", async () => { const program = `export default { categories: { correctness: "off" }, rules: { "no-var": "error" } };`; expect((await lint({ "oxlint.config.mts": program, "a.js": code }, ["a.js"])).problems).toEqual([ @@ -2520,6 +3007,785 @@ describe.concurrent("an .oxlintrc.json", () => { expect((await lint(enables, [".", "--report-unused-disable-directives"])).problems).toEqual(["a.js:2:3 -"]); }); + // Every row was run on oxlint 1.87. It matches one of its own rules by the text after the last `/` of the name, and reports + // every comment that silenced nothing, whatever the name is. + test("which names in a disable comment silence a built-in rule, and which comments are unused", async () => { + // `$` is the number of the line, to keep the names apart. + const rows: { file: string; bad: string; good: string; silence: string[]; nothing: string[] }[] = [ + { + file: "debugger.js", + bad: "debugger;", + good: "f();", + silence: [ + "no-debugger", + "eslint/no-debugger", + "typescript/no-debugger", + "@typescript-eslint/no-debugger", + "react/no-debugger", + "vitest/no-debugger", + "@stylistic/no-debugger", + "foo/no-debugger", + "a/b/no-debugger", + "foo/eslint/no-debugger", + "/no-debugger", + ], + nothing: ["eslint(no-debugger)", "xno-debugger", "no-debugger-x", "x-no-debugger", "NO-DEBUGGER", "no-debugge"], + }, + { + file: "any.ts", + bad: "export let a$: any;", + good: "export let a$: number;", + silence: [ + "no-explicit-any", + "typescript/no-explicit-any", + "@typescript-eslint/no-explicit-any", + "typescript-eslint/no-explicit-any", + "eslint/no-explicit-any", + "ts/no-explicit-any", + "react/no-explicit-any", + ], + nothing: [], + }, + { + file: "hooks.jsx", + bad: "function f$() { useState(); } f$();", + good: "function f$() { g(); } f$();", + silence: [ + "rules-of-hooks", + "react/rules-of-hooks", + "react-hooks/rules-of-hooks", + "react_hooks/rules-of-hooks", + "eslint/rules-of-hooks", + "foo/rules-of-hooks", + ], + nothing: [], + }, + { + file: "focused.test.js", + bad: "it.only('a', () => {});", + good: "it('a', () => {});", + silence: ["no-focused-tests", "jest/no-focused-tests", "vitest/no-focused-tests", "foo/no-focused-tests"], + nothing: [], + }, + { + file: "alt.jsx", + bad: "x = ;", + good: "x = ;", + silence: ["alt-text", "jsx-a11y/alt-text", "jsx_a11y/alt-text", "a11y/alt-text"], + nothing: [], + }, + { + file: "img.jsx", + bad: "x = a;", + good: "x = ;", + silence: [ + "no-img-element", + "nextjs/no-img-element", + "@next/next/no-img-element", + "next/no-img-element", + "@next/no-img-element", + ], + nothing: [], + }, + { + file: "require.js", + bad: "x = new require('a');", + good: "x = require('a');", + silence: ["no-new-require", "node/no-new-require", "n/no-new-require", "eslint/no-new-require"], + nothing: [], + }, + // Two rules have this name. Either prefix silences both. + { + file: "import-namespace.ts", + bad: "import * as a$ from 'a'; a$();", + good: "import a$ from 'a'; a$();", + silence: ["no-namespace", "import/no-namespace", "typescript/no-namespace", "foo/no-namespace"], + nothing: [], + }, + { + file: "namespace.ts", + bad: "export namespace N$ {}", + good: "export const N$ = 1;", + silence: ["no-namespace", "import/no-namespace", "@typescript-eslint/no-namespace", "foo/no-namespace"], + nothing: [], + }, + // Rules that are off, rules of plugins that are off, and names that are no rule's. + { + file: "off.js", + bad: "", + good: "console.log(1);", + silence: [], + nothing: [ + "no-console", + "eslint/no-console", + "foo/no-console", + "typescript/no-console", + "oxc/no-barrel-file", + "no-barrel-file", + "vitest/no-import-node-test", + "check-access", + "foo/check-access", + "jsdoc/check-access", + "jsdoc/require-param", + "vue/max-props", + "vue/no-export-in-script-setup", + "foo/no-alert", + "typescript/no-floating-promises", + "nope", + "foo/nope", + "eslint/nope", + "no-multiple-empty-lines", + "@stylistic/semi", + "prettier/prettier", + ], + }, + ]; + const comments = [ + (name: string) => `// oxlint-disable-next-line ${name}\n`, + (name: string) => `/* eslint-disable-next-line ${name} */\n`, + ]; + const files: Record = {}; + const expected: string[] = []; + for (const { file, bad, good, silence, nothing } of rows) { + let [text, line] = ["", 1]; + const add = (name: string, code: string, outcome: "silenced" | "unused" | "reported") => { + for (const comment of comments) { + text += comment(name) + code.replaceAll("$", String(line)) + "\n"; + if (outcome !== "silenced") expected.push(`${file}:${line} unused`); + if (outcome === "reported") expected.push(`${file}:${line + 1} reported`); + line += 2; + } + }; + for (const name of silence) { + add(name, bad, "silenced"); + add(name, good, "unused"); + } + for (const name of nothing) { + if (bad) add(name, bad, "reported"); + add(name, good, "unused"); + } + files[file] = text; + } + const config = { + categories: { correctness: "off" }, + plugins: ["typescript", "react", "import", "jest", "jsx-a11y", "nextjs", "node"], + rules: { + "no-debugger": "error", + "no-console": "off", + "typescript/no-explicit-any": "error", + "react/rules-of-hooks": "error", + "jest/no-focused-tests": "error", + "jsx-a11y/alt-text": "error", + "nextjs/no-img-element": "error", + "node/no-new-require": "error", + "import/no-namespace": "error", + "typescript/no-namespace": "error", + }, + }; + const { problems } = await lint({ ".oxlintrc.json": JSON.stringify(config), ...files }, [ + ".", + "--report-unused-disable-directives", + ]); + const found = problems.map(it => it.replace(/:\d+ (.*)$/, (_, rule) => (rule === "-" ? " unused" : " reported"))); + expect(found.sort()).toEqual(expected.sort()); + }); + + // oxlint 1.87 rejects this config: "Rule 'no-debuger' not found in plugin 'eslint'", "Plugin 'foo' not found". The rules here are + // those of one oxlint version, and a config for a later one can name a rule that is new, so it's a warning. + test("an unknown rule that an .oxlintrc.json turns off is a warning, once for each name", async () => { + const config = { + categories: { correctness: "off" }, + rules: { + "no-debugger": "error", + "no-debuger": "off", + "foo/bar": "off", + "unicorn/no-nul": "allow", + "typescript/no-explicit-an": 0, + // There is no list of the rules of this plugin. + "jsdoc/whatever": "off", + }, + overrides: [{ files: ["*.ts"], rules: { "no-debuger": "off", "react/jsx-kye": "off" } }], + }; + const files = { ".oxlintrc.json": JSON.stringify(config), "a.js": "debugger;\n", "b.js": "debugger;\n" }; + const { problems, stderr, exitCode } = await lint(files); + expect(problems).toEqual(["a.js:1:1 no-debugger", "b.js:1:1 no-debugger"]); + expect(stderr.trim().split("\n")).toEqual([ + 'warn: Unknown rule "no-debuger" is turned off in the oxlint config', + ' Did you mean "no-debugger"?', + 'warn: Unknown rule "foo/bar" is turned off in the oxlint config', + " It has no effect. Fix the name or remove it.", + 'warn: Unknown rule "unicorn/no-nul" is turned off in the oxlint config', + ' Did you mean "unicorn/no-null"?', + 'warn: Unknown rule "typescript/no-explicit-an" is turned off in the oxlint config', + ' Did you mean "typescript/no-explicit-any"?', + 'warn: Unknown rule "react/jsx-kye" is turned off in the oxlint config', + ' Did you mean "react/jsx-key"?', + ]); + expect(exitCode).toBe(1); + }); + + // oxlint 1.87 fails with this text for each of the first three, before it lints. + test("the options of a JS plugin's rule are checked for every entry of an .oxlintrc.json", async () => { + const plugin = `export default { meta: { name: "pl" }, rules: { opt: { meta: { schema: [{ enum: ["a", "b"] }] }, create: () => ({}) } } };\n`; + const run = async (config: object) => { + const oxlintrc = JSON.stringify({ categories: { correctness: "off" }, jsPlugins: ["./plugin.js"], ...config }); + const { stdout, stderr, exitCode } = await lint( + { ".oxlintrc.json": oxlintrc, "plugin.js": plugin, "a.js": "a;\n" }, + ["a.js"], + ); + const said = (stdout + stderr).includes( + `Failed to setup JS plugin options:\nError: Options validation failed for rule 'pl/opt':\nOptions:\n[\n "zzz"\n]\nErrors:\n\tValue "zzz" should be equal to one of the allowed values.`, + ); + return [said, exitCode]; + }; + expect( + await Promise.all([ + run({ rules: { "pl/opt": ["error", "zzz"] } }), + run({ rules: { "pl/opt": ["off", "zzz"] } }), + run({ overrides: [{ files: ["*.nothing"], rules: { "pl/opt": ["error", "zzz"] } }] }), + run({ rules: { "pl/opt": ["off", "a"] } }), + ]), + ).toEqual([ + [true, 1], + [true, 1], + [true, 1], + [false, 0], + ]); + }); + + // oxlint 1.87 says so for each of the first five, before it lints. + test("options for a JS plugin's rule that takes none are refused, also where the rule is off", async () => { + const plugin = `const create = () => ({}); + export default { meta: { name: "pl" }, rules: { none: { create }, meta: { meta: { type: "problem" }, create }, empty: { meta: { schema: [] }, create }, any: { meta: { schema: false }, create } } };\n`; + const run = async (rule: string, entry: unknown) => { + const config = { + categories: { correctness: "off" }, + jsPlugins: ["./plugin.js"], + rules: { [`pl/${rule}`]: entry }, + }; + const files = { ".oxlintrc.json": JSON.stringify(config), "plugin.js": plugin, "a.js": "a;\n" }; + const { stdout, stderr, exitCode } = await lint(files, ["a.js"]); + const text = `Failed to setup JS plugin options:\nError: Rule 'pl/${rule}' does not accept options`; + return [(stdout + stderr).includes(text), exitCode]; + }; + expect( + await Promise.all([ + run("none", ["off", {}]), + run("none", ["error", {}]), + run("none", ["off", null]), + run("meta", ["off", {}]), + run("empty", ["off", {}]), + run("any", ["off", {}]), + run("none", ["off"]), + run("none", "off"), + ]), + ).toEqual([ + [true, 1], + [true, 1], + [true, 1], + [true, 1], + [true, 1], + [false, 0], + [false, 0], + [false, 0], + ]); + }); + + // What oxlint 1.87 and ESLint 10.12 do. oxlint goes by `char::is_whitespace` of Rust, ESLint by `\s`. + test("white space beside the label of a disable comment: U+0085 for oxlint, U+FEFF for ESLint", async () => { + const codes = [0x20, 0x85, 0xa0, 0x1680, 0x2003, 0x3000, 0xfeff, 0x180e, 0x200b]; + const files = (tool: string) => + Object.fromEntries( + codes.flatMap(code => [ + [`a${code.toString(16)}.js`, `debugger; //${String.fromCodePoint(code)}${tool}-disable-line no-debugger\n`], + [`b${code.toString(16)}.js`, `debugger; // ${tool}-disable-line${String.fromCodePoint(code)}no-debugger\n`], + ]), + ); + const rules = { "no-debugger": "error" }; + const [oxlint, eslint] = await Promise.all([ + lint({ ".oxlintrc.json": JSON.stringify({ categories: { correctness: "off" }, rules }), ...files("oxlint") }), + lint({ "eslint.config.mjs": `export default [{ rules: ${JSON.stringify(rules)} }];\n`, ...files("eslint") }), + ]); + const notSilenced = (...names: string[]) => + names.flatMap(it => [`a${it}.js:1:1 no-debugger`, `b${it}.js:1:1 no-debugger`]).sort(); + expect(oxlint.problems).toEqual(notSilenced("feff", "180e", "200b")); + expect(eslint.problems).toEqual(notSilenced("85", "180e", "200b")); + }); + + // What oxlint 1.87 and ESLint 10.12 report. + test("no-irregular-whitespace: oxlint lets a line separator pass that ends a // comment", async () => { + const files = { + "a.js": "a; // c\u2028b;\n", + "b.js": "a; // c\u2029b;\n", + "c.js": "a; // c\u2028\u2028b;\n", + "d.js": "a;\u2028b;\n", + "e.js": "a; /* c\u2028d */ b;\n", + "f.js": "// c\u2028", + }; + const rules = { "no-irregular-whitespace": "error" }; + const [oxlint, eslint] = await Promise.all([ + lint({ ".oxlintrc.json": JSON.stringify({ categories: { correctness: "off" }, rules }), ...files }), + lint({ "eslint.config.mjs": `export default [{ rules: ${JSON.stringify(rules)} }];\n`, ...files }), + ]); + const places = (problems: string[]) => problems.map(it => it.replace(" no-irregular-whitespace", "")); + // The second separator of c.js. This format counts lines like ESLint. oxlint's own formats say 1:11. + expect(places(oxlint.problems)).toEqual(["c.js:2:1", "d.js:1:3", "e.js:1:8"]); + expect(places(eslint.problems)).toEqual([ + "a.js:1:8", + "b.js:1:8", + "c.js:1:8", + "c.js:2:1", + "d.js:1:3", + "e.js:1:8", + "f.js:1:5", + ]); + }); + + // The lines of oxlint 1.87, in its order. + test("the findings about a file: of the built-in rules by place, of JS plugins as they report, about comments last", async () => { + const plugin = `export default { meta: { name: "pl" }, rules: { + back: { create(c) { const all = []; return { Identifier(n) { all.push(n); }, "Program:exit"() { for (const n of all.reverse()) c.report({ node: n, message: "back " + n.name }); } }; } }, + fwd: { create(c) { return { Identifier(n) { c.report({ node: n, message: "fwd " + n.name }); } }; } }, + } };\n`; + const rules = { "pl/fwd": "error", "pl/back": "error", "no-var": "warn", "no-debugger": "error" }; + const code = [ + "// oxlint-disable-next-line no-alert", + "debugger;", + "a; b;", + "// oxlint-disable-next-line pl/back", + "debugger;", + "var c = 1; // oxlint-disable-line no-var", + "d; // oxlint-disable-line pl/fwd", + "// oxlint-disable-next-line no-eval", + "debugger;", + ]; + const files = { + ".oxlintrc.json": JSON.stringify({ categories: { correctness: "off" }, jsPlugins: ["./plugin.js"], rules }), + "plugin.js": plugin, + "c.js": code.join("\n") + "\n", + }; + const { stdout } = await lint(files, ["-f", "unix", "--report-unused-disable-directives", "c.js"]); + const lines = stdout.split("\n").filter(it => it.startsWith("c.js:")); + expect(lines.map(it => it.replace(/^c\.js:(\d+:\d+): .*?(\w+\([\w-]+\))?\]$/, "$1 $2").trim())).toEqual([ + "2:1 eslint(no-debugger)", + "5:1 eslint(no-debugger)", + "9:1 eslint(no-debugger)", + "3:1 pl(fwd)", + "3:4 pl(fwd)", + "6:5 pl(fwd)", + "7:1 pl(back)", + "6:5 pl(back)", + "3:4 pl(back)", + "3:1 pl(back)", + "1:1", + "4:1", + "8:1", + ]); + }); + + // Each count is what oxlint 1.87 prints. + test("number_of_rules is there for a file outside the working directory, unless it has another configuration", async () => { + const rc = (rules: object) => JSON.stringify({ categories: { correctness: "off" }, rules }); + const files = { + ".oxlintrc.json": rc({ "no-debugger": "error", "no-var": "error", "no-eval": "error" }), + "a.js": "debugger;\n", + "other/b.js": "debugger;\n", + "sub/.oxlintrc.json": rc({ "no-debugger": "error" }), + "sub/c.js": "debugger;\n", + "sub/in/d.js": "debugger;\n", + }; + const count = async (cwd: string, args: (dir: string) => string[]) => + JSON.parse((await lint(files, ["-f", "json"], { cwd, args })).raw).number_of_rules; + expect( + await Promise.all([ + count("sub/in", dir => [join(dir, "sub", "c.js")]), + count("sub/in", dir => [join(dir, "sub")]), + count("sub/in", dir => ["d.js", join(dir, "sub", "c.js")]), + count("other", dir => [join(dir, "a.js"), "b.js"]), + count("other", dir => ["-c", join(dir, "sub", ".oxlintrc.json"), join(dir, "sub", "c.js")]), + count("sub/in", dir => ["-c", join(dir, ".oxlintrc.json"), join(dir, "other", "b.js")]), + count("other", dir => ["--disable-nested-config", join(dir, "sub", "c.js")]), + // These have two configuration files. + count("sub/in", dir => [join(dir, "other", "b.js")]), + count("other", dir => [join(dir, "a.js"), join(dir, "sub", "c.js")]), + ]), + ).toEqual([1, 1, 1, 3, 1, 3, 3, null, null]); + }); + + // Each count is what oxlint 1.87 prints. The rules run on the files of the override in both tools: only the count leaves them out. + test("number_of_rules leaves out what categories turn on for a plugin that only an override has", async () => { + const base = { + plugins: ["unicorn", "typescript", "import"], + categories: { correctness: "warn", suspicious: "warn" }, + }; + const count = async (config: object) => { + const files = { ".oxlintrc.json": JSON.stringify(config), "a.ts": "debugger;\n", "a.test.ts": "debugger;\n" }; + return JSON.parse((await lint(files, ["-f", "json"])).raw).number_of_rules; + }; + const named = { "jest/no-focused-tests": "error" }; + expect( + await Promise.all([ + count(base), + count({ ...base, overrides: [{ files: ["*.test.ts"], plugins: ["jest"] }] }), + count({ ...base, overrides: [{ files: ["*.test.ts"], plugins: ["jest"], rules: named }] }), + count({ ...base, overrides: [{ files: ["*.nothing"], plugins: ["jest"], rules: named }] }), + count({ ...base, overrides: [{ files: ["*.ts"], plugins: ["jest", "react"] }] }), + count({ overrides: [{ files: ["*.test.ts"], plugins: ["jest"] }] }), + ]), + ).toEqual([116, 116, 117, 117, 116, 96]); + }); + + // Each config is accepted or rejected as oxlint 1.87 does it. It takes out the comments, has serde_json read the rest, and + // fills its structs from that: `null` is only good for an `Option`, and a list of nothing is as good as an empty struct. + test("an .oxlintrc.json is read as strictly as oxlint reads it, and no more strictly", async () => { + const rejected = [ + '{"rules":null}', + '{"categories":null}', + '{"settings":null}', + '{"options":null}', + '{"env":null}', + '{"globals":null}', + '{"overrides":null}', + '{"extends":null}', + '{"ignorePatterns":null}', + '{"overrides":[{"files":["*.js"],"rules":null}]}', + '{"overrides":[{"files":null}]}', + '{"globals":{"a":null}}', + '{"settings":[1]}', + '{"settings":[{},{},{},{},{},{},{},{}]}', + '{"options":[1]}', + '{"rules":{"eqeqeq":"off","eqeqeq":["error","x"]}}', + "{'rules':{}}", + '{"rules":{"no-debugger":\'error\'}}', + '\ufeff{"rules":{"no-debugger":"error"}}', + '{"rules":{"max-lines":["error",{"max":0x10}]}}', + '{"rules":{"max-lines":["error",{"max":1.}]}}', + '{"rules":{"max-lines":["error",{"max":.5}]}}', + '{"rules":{"max-lines":["error",{"max":01}]}}', + '{"rules":{"no-debugger":"err\\x6fr"}}', + '{"$schema":"\\ud800"}', + '\u00a0{"rules":{"no-debugger":"error"}}', + '\u000b{"rules":{"no-debugger":"error"}}', + ]; + const accepted = [ + '{"plugins":null}', + '{"jsPlugins":null}', + '{"$schema":null}', + '{"overrides":[{"files":["*.js"],"env":null}]}', + '{"overrides":[{"files":["*.js"],"globals":null}]}', + '{"overrides":[{"files":["*.js"],"plugins":null}]}', + '{"settings":[]}', + '{"settings":[{},{}]}', + '{"options":[]}', + '{"options":[null]}', + '{"options":{"typeAware":null}}', + '{"options":[false]}', + '{"options":[null,null,null,1]}', + '{"settings":[{},{},{},{},{},{}]}', + '{"settings":{"zz":1}}', + '{"settings":{"react":[]}}', + '{"settings":{"react":{"zz":1,"version":null}}}', + '{"settings":{"react":{"formComponents":["a",{"name":"a","attribute":"b","zz":1}]}}}', + '{"settings":{"jsx-a11y":[null,{},{}]}}', + '{"settings":{"next":{"rootDir":["a"]}}}', + '{"settings":{"jsdoc":{"tagNamePreference":{"a":false,"b":"c","d":{"message":"m"}}}}}', + '{"settings":{"jest":[1]}}', + '{"settings":{"jsx-a11y":[null]}}', + '{"settings":{"jest":{"version":"v29.7.0-beta.1"}}}', + '{"settings":{"jest":{"version":"29.x"}}}', + '{"settings":{"jest":{"version":29}}}', + '{"settings":{"jest":{"version":null}}}', + '{"settings":{"react":{"linkComponents":[{"name":"a","linkAttribute":"to"},{"name":"b","linkAttribute":["to"]}]}}}', + '{"settings":{"react":{"formComponents":[{"name":"a","formAttribute":"to"},{"name":"b","linkAttribute":"to"}]}}}', + '{"settings":{"react":{"linkComponents":[{"name":"a","attribute":"x","linkAttribute":["to"]}]}}}', + '{"overrides":[[["*.js"],[],null,null]]}', + "{,}", + "{}/", + "/**/{}", + "{}#x", + '{"rules":{"eqeqeq":["error","x"],"eqeqeq":"off"}}', + '{"rules":{"no-var":"nonsense","no-var":"off"}}', + '{"categories":{"correctness":"x","correctness":"warn"}}', + '#!x\n{"rules":{"no-debugger":"error"}}', + '{"rules":{"no-debugger":"error"}} # c', + '{"rules":{"no-debugger":"error"}} // c', + '// c\n{"rules":{"no-debugger":"error"}}', + '{"rules":{"no-debugger":"error"},}', + '{"rules":{"no-debugger":"err\\u006fr"}}', + '{"$schema":"\\/"}', + ]; + const run = async (config: string) => { + const { problems, exitCode } = await lint({ ".oxlintrc.json": config, "a.js": "debugger;\n" }); + return [config, problems.length === 0 && exitCode === 1 ? "rejected" : problems.join()]; + }; + expect(await Promise.all([...rejected, ...accepted].map(run))).toEqual([ + ...rejected.map(config => [config, "rejected"]), + ...accepted.map(config => [config, "a.js:1:1 no-debugger"]), + ]); + }); + + // What oxlint 1.87 prints below "Failed to parse oxlint config .", or in place of that. It makes blanks of the comments, of + // the line breaks in `/* */` too, and of a comma before a `}` or `]`, and has serde_json read the rest. Columns are in bytes. + test("an .oxlintrc.json that is no JSON is refused in the words of oxlint", async () => { + // prettier-ignore + const rows: [text: string, why: string][] = [ + ["{\n \"rules\": {\n \"no-debugger\": \"error\"\n \"no-var\": \"off\"\n }\n}\n", "expected `,` or `}` at line 4 column 5"], + ["{\n // c\n \"rules\": { /* a\n b */ no-debugger: 1 }\n}\n", "key must be a string at line 3 column 25"], + ["", "EOF while parsing a value at line 1 column 0"], + ["\n", "EOF while parsing a value at line 2 column 0"], + ["{", "EOF while parsing an object at line 1 column 1"], + ["[1,", "EOF while parsing a value at line 1 column 3"], + ["{\"a\"", "EOF while parsing an object at line 1 column 4"], + ["{\"a\":", "EOF while parsing a value at line 1 column 5"], + ["{\"a\":1", "EOF while parsing an object at line 1 column 6"], + ["{\"a\" 1}", "expected `:` at line 1 column 6"], + ["{\"a\":}", "expected value at line 1 column 6"], + ["{\"a\":1 \"b\":2}", "expected `,` or `}` at line 1 column 8"], + ["[1 2]", "expected `,` or `]` at line 1 column 4"], + ["{a:1}", "key must be a string at line 1 column 2"], + ["{\"a\":1,,}", "key must be a string at line 1 column 8"], + ["[1, ,]", "expected value at line 1 column 5"], + ["{\"a\":1,]", "expected `,` or `}` at line 1 column 8"], + ["[1,]]", "trailing characters at line 1 column 5"], + ["tru", "EOF while parsing a value at line 1 column 3"], + ["truex", "trailing characters at line 1 column 5"], + ["-", "EOF while parsing a value at line 1 column 1"], + ["-a", "invalid number at line 1 column 2"], + ["01", "invalid number at line 1 column 2"], + ["1.", "EOF while parsing a value at line 1 column 2"], + ["1.a", "invalid number at line 1 column 3"], + ["1e+", "EOF while parsing a value at line 1 column 3"], + ["1ea", "invalid number at line 1 column 3"], + ["0x10", "trailing characters at line 1 column 2"], + ["+1", "expected value at line 1 column 1"], + [".5", "expected value at line 1 column 1"], + ["1e400", "number out of range at line 1 column 5"], + ["NaN", "expected value at line 1 column 1"], + ["\"a", "EOF while parsing a string at line 1 column 2"], + ["\"\\x\"", "invalid escape at line 1 column 3"], + ["\"\\u12\"", "EOF while parsing a string at line 1 column 6"], + ["\"\\uzzzz\"", "invalid escape at line 1 column 7"], + ["\"\\ud800\"", "unexpected end of hex escape at line 1 column 8"], + ["\"\\ud800\\n\"", "unexpected end of hex escape at line 1 column 9"], + ["\"\\ud800\\u0041\"", "lone leading surrogate in hex escape at line 1 column 13"], + ["\"\\udc00\"", "lone leading surrogate in hex escape at line 1 column 7"], + ["\"a\tb\"", "control character (\\u0000-\\u001F) found while parsing a string at line 1 column 3"], + ["\"a\nb\"", "control character (\\u0000-\\u001F) found while parsing a string at line 2 column 0"], + ["{}\n\n x", "trailing characters at line 3 column 3"], + ["{},", "trailing characters at line 1 column 3"], + ["/* a", "EOF while parsing a value at line 1 column 4"], + ["/*\n\n*/x", "expected value at line 1 column 7"], + ["/*\n\n*/{\n x}", "key must be a string at line 2 column 2"], + ["// a\nx", "expected value at line 2 column 1"], + ["# a\nx", "expected value at line 2 column 1"], + ["{\"a\": /* c\n */ x}", "expected value at line 1 column 16"], + ["{\"//\": x}", "expected value at line 1 column 8"], + ["{'a':1}", "key must be a string at line 1 column 2"], + ["\ufeff{}", "expected value at line 1 column 1"], + ["\u00a0{}", "expected value at line 1 column 1"], + ["\r\n{\r\n x}", "key must be a string at line 3 column 2"], + ["\r{\r x}", "key must be a string at line 1 column 5"], + ["{\"\u65e5\u672c\": x}", "expected value at line 1 column 12"], + ["[1,2,3\n,4\n,x]", "expected value at line 3 column 2"], + ["{} / x", "Failed to parse jsonc file /.oxlintrc.json: Kind(InvalidData)"], + ["/x", "Failed to parse jsonc file /.oxlintrc.json: Kind(InvalidData)"], + ["[]", "Expected a JSON object."], + ["null", "Expected a JSON object."], + ["[1,]", "Expected a JSON object."], + ["[,]", "Expected a JSON object."], + [Buffer.alloc(127, "[").toString() + "x", "expected value at line 1 column 128"], + [Buffer.alloc(128, "[").toString() + "x", "recursion limit exceeded at line 1 column 128"], + ]; + const run = async ([text]: [string, string]) => { + const { stdout, exitCode } = await lint({ ".oxlintrc.json": text, "a.js": "debugger;\n" }, ["a.js"]); + const start = "error: Cannot use the configuration file /.oxlintrc.json:\n"; + const said = stdout.startsWith(start) ? stdout.slice(start.length).trimEnd() : stdout; + return [text, said.replace("Failed to parse oxlint config /.oxlintrc.json.\n", ""), exitCode]; + }; + // `stdout` has a slash for every backslash. + expect(await Promise.all(rows.map(run))).toEqual(rows.map(([text, why]) => [text, why.replaceAll("\\", "/"), 1])); + }); + + // What oxlint 1.87 reports. + test("an override of an .oxlintrc.json that is a list has the fields in their order", async () => { + const found = async (...fields: unknown[]) => { + const config = { categories: { correctness: "off" }, overrides: [fields] }; + return (await lint({ ".oxlintrc.json": JSON.stringify(config), "a.js": "debugger;\n" }, ["a.js"])).problems; + }; + const rules = { "no-debugger": "error" }; + expect(await found(["*.js"], [], null, null, null, null, rules)).toEqual(["a.js:1:1 no-debugger"]); + expect(await found(["*.ts"], [], null, null, null, null, rules)).toEqual([]); + expect(await found(["*.js"], ["a.js"], null, null, null, null, rules)).toEqual([]); + }); + + // Each row is what oxlint 1.87 reports. As for `JSON.parse`, the last of two values with one key counts. + test("of two entries with one key in an .oxlintrc.json the last counts", async () => { + const rows: [config: string, found: string[]][] = [ + [ + '{"rules":{"no-debugger":"error"},"rules":{"no-debugger":"off"}}', + ["2:5 no-unused-vars warning", "3:1 no-unused-expressions warning", "3:4 no-unused-expressions warning"], + ], + [ + '{"rules":{"no-debugger":"off"},"rules":{"no-debugger":"error"}}', + [ + "1:1 no-debugger error", + "2:5 no-unused-vars warning", + "3:1 no-unused-expressions warning", + "3:4 no-unused-expressions warning", + ], + ], + [ + '{"rules":{"no-debugger":"error","no-debugger":"off"}}', + ["2:5 no-unused-vars warning", "3:1 no-unused-expressions warning", "3:4 no-unused-expressions warning"], + ], + [ + '{"rules":{"no-debugger":"off","no-debugger":"error"}}', + [ + "1:1 no-debugger error", + "2:5 no-unused-vars warning", + "3:1 no-unused-expressions warning", + "3:4 no-unused-expressions warning", + ], + ], + [ + '{"rules":{"no-debugger":"error"},"rules":{"no-var":"error"}}', + [ + "1:1 no-debugger warning", + "2:1 no-var error", + "2:5 no-unused-vars warning", + "3:1 no-unused-expressions warning", + "3:4 no-unused-expressions warning", + ], + ], + [ + '{"categories":{"correctness":"off"},"categories":{"correctness":"error"}}', + [ + "1:1 no-debugger error", + "2:5 no-unused-vars error", + "3:1 no-unused-expressions error", + "3:4 no-unused-expressions error", + ], + ], + ['{"categories":{"correctness":"error","correctness":"off"}}', []], + [ + '{"plugins":["unicorn"],"plugins":[],"rules":{"unicorn/no-null":"error"}}', + [ + "1:1 no-debugger warning", + "2:5 no-unused-vars warning", + "3:1 no-unused-expressions warning", + "3:4 no-unused-expressions warning", + ], + ], + [ + '{"plugins":[],"plugins":["unicorn"],"rules":{"unicorn/no-null":"error"}}', + [ + "1:1 no-debugger warning", + "2:5 no-unused-vars warning", + "2:9 no-null error", + "3:1 no-unused-expressions warning", + "3:4 no-unused-expressions warning", + ], + ], + [ + '{"overrides":[{"files":["*.js"],"rules":{"no-debugger":"off"}}],"overrides":[]}', + [ + "1:1 no-debugger warning", + "2:5 no-unused-vars warning", + "3:1 no-unused-expressions warning", + "3:4 no-unused-expressions warning", + ], + ], + [ + '{"overrides":[],"overrides":[{"files":["*.js"],"rules":{"no-debugger":"off"}}]}', + ["2:5 no-unused-vars warning", "3:1 no-unused-expressions warning", "3:4 no-unused-expressions warning"], + ], + [ + '{"overrides":[{"files":["*.ts"],"files":["*.js"],"rules":{"no-debugger":"off"}}]}', + ["2:5 no-unused-vars warning", "3:1 no-unused-expressions warning", "3:4 no-unused-expressions warning"], + ], + [ + '{"globals":{"a":"off","a":"readonly"},"rules":{"no-undef":"error"}}', + [ + "1:1 no-debugger warning", + "2:5 no-unused-vars warning", + "3:1 no-unused-expressions warning", + "3:4 no-undef error", + "3:4 no-unused-expressions warning", + "4:1 no-undef error", + "4:17 no-undef error", + ], + ], + [ + '{"globals":{"a":"readonly"},"globals":{},"rules":{"no-undef":"error"}}', + [ + "1:1 no-debugger warning", + "2:5 no-unused-vars warning", + "3:1 no-undef error", + "3:1 no-unused-expressions warning", + "3:4 no-undef error", + "3:4 no-unused-expressions warning", + "4:1 no-undef error", + "4:17 no-undef error", + ], + ], + [ + '{"env":{"browser":true,"browser":false},"rules":{"no-undef":"error"}}', + [ + "1:1 no-debugger warning", + "2:5 no-unused-vars warning", + "3:1 no-undef error", + "3:1 no-unused-expressions warning", + "3:4 no-undef error", + "3:4 no-unused-expressions warning", + "4:1 no-undef error", + "4:17 no-undef error", + ], + ], + [ + '{"rules":{"no-console":["error",{"allow":["log"],"allow":["warn"]}]}}', + [ + "1:1 no-debugger warning", + "2:5 no-unused-vars warning", + "3:1 no-unused-expressions warning", + "3:4 no-unused-expressions warning", + "4:1 no-console error", + ], + ], + [ + '{"rules":{"eslint/no-debugger":"off","no-debugger":"error"}}', + [ + "1:1 no-debugger error", + "2:5 no-unused-vars warning", + "3:1 no-unused-expressions warning", + "3:4 no-unused-expressions warning", + ], + ], + [ + '{"rules":{"no-debugger":"error","eslint/no-debugger":"off"}}', + ["2:5 no-unused-vars warning", "3:1 no-unused-expressions warning", "3:4 no-unused-expressions warning"], + ], + ]; + const text = "debugger;\nvar v = null;\na; window;\nconsole.log(1); console.warn(2);\n"; + const nested = rows.flatMap(([config], i) => [ + [`d${i}/.oxlintrc.json`, config], + [`d${i}/a.js`, text], + ]); + const { raw } = await lint({ ".oxlintrc.json": "{}", ...Object.fromEntries(nested) }); + type Message = { ruleId: string; line: number; column: number; severity: number }; + const results: { filePath: string; messages: Message[] }[] = JSON.parse(raw).results; + const found = Object.fromEntries( + results.map(({ filePath, messages }) => [ + filePath.split(/[\\/]/).at(-2), + messages + .map( + it => `${it.line}:${it.column} ${it.ruleId.split("/").at(-1)} ${["off", "warning", "error"][it.severity]}`, + ) + .sort(), + ]), + ); + expect(found).toEqual(Object.fromEntries(rows.map(([, expected], i) => [`d${i}`, [...expected].sort()]))); + }); + test("a comment that disables a rule which does not run on the file is unused, as for oxlint", async () => { const { problems } = await lint( { @@ -2532,14 +3798,98 @@ describe.concurrent("an .oxlintrc.json", () => { expect(problems).toEqual(["a.vue:2:1 -"]); }); + // Every row is what oxlint 1.87 prints. Without --type-aware it acts as if the rules that need types didn't exist. + test("disable comments are unused under .oxlintrc.json when the only rules left are ones that need types", async () => { + const text = [ + "declare function g(): void;", + "export async function f() {", + " // oxlint-disable-next-line", + " g();", + " g(); // eslint-disable-line", + " // oxlint-disable-next-line typescript/await-thenable", + " g();", + " // eslint-disable-next-line @typescript-eslint/await-thenable", + " g();", + " g(); // oxlint-disable-line await-thenable", + " // oxlint-disable-next-line typescript/await-thenable, no-debugger", + " g();", + " // oxlint-disable-next-line typescript/await-thenable, no-debugger", + " debugger;", + " // oxlint-disable-next-line typescript/await-thenable", + " await 1;", + " // oxlint-disable-next-line", + " await 2;", + " /* oxlint-disable typescript/await-thenable */", + " await 3;", + " /* oxlint-enable typescript/await-thenable */", + " await 4;", + " /* oxlint-disable */", + " g();", + " /* oxlint-enable */", + "}", + "", + ].join("\n"); + const run = async (config: object, ...flags: string[]) => { + const files = { + ".oxlintrc.json": JSON.stringify(config), + "tsconfig.json": JSON.stringify({ + compilerOptions: { strict: true, noEmit: true, lib: ["es2022"], types: [] }, + }), + "a.ts": text, + }; + const { problems } = await lint(files, ["a.ts", "--report-unused-disable-directives", ...flags]); + const place = (it: string) => it.split(/[: ]/).slice(1, 3).map(Number); + return problems + .sort((a, b) => place(a)[0] - place(b)[0] || place(a)[1] - place(b)[1]) + .map(it => it.replace("a.ts:", "").replace(" -", " unused").replace(/ .*\//, " ")); + }; + const only = (severity: string) => ({ + categories: { correctness: "off" }, + rules: { "no-debugger": "error", "typescript/await-thenable": severity }, + }); + // These silence nothing, whether or not the rule runs. + const always = ["3:3", "5:8", "6:3", "8:3", "10:8", "11:3", "13:31"].map(it => `${it} unused`); + const withoutTheRule = [...always, "15:3 unused", "17:3 unused", "21:3 unused", "25:3 unused"]; + const results = await Promise.all([ + run({}), + run({}, "--type-aware"), + run(only("error")), + run(only("error"), "--type-aware"), + run(only("off")), + run(only("off"), "--type-aware"), + ]); + expect(results).toEqual([ + [ + ...always, + "15:3 unused", + "16:3 no-unnecessary-await", + "20:3 no-unnecessary-await", + "21:3 unused", + "22:3 no-unnecessary-await", + "25:3 unused", + ], + [ + ...always, + "16:3 no-unnecessary-await", + "20:3 no-unnecessary-await", + "22:3 no-unnecessary-await", + "22:9 await-thenable", + "25:3 unused", + ], + withoutTheRule, + [...always, "22:9 await-thenable", "25:3 unused"], + withoutTheRule, + withoutTheRule, + ]); + }); + test("the rules that a category turns on and that do not exist here are named, in a warning", async () => { const config = { plugins: ["unicorn", "oxc", "react", "jsx-a11y", "jest"], categories: { correctness: "warn" }, }; const named = (stderr: string) => - /rules that the categories turn on do not exist here yet and did not run: (.*)/.exec(stderr)?.[1].split(", ") ?? - []; + /rules enabled by "categories" are not supported yet and did not run: (.*)/.exec(stderr)?.[1].split(", ") ?? []; const { stderr, exitCode } = await lint({ ".oxlintrc.json": JSON.stringify(config), "a.js": "export {};\n" }); expect(exitCode).toBe(0); const lacking = named(stderr); @@ -2582,6 +3932,173 @@ test("--print-config of oxlint: what each plugin is called in the name of a rule expect(Object.keys(JSON.parse(stdout).rules).sort()).toEqual(names); }); +// A rule is handed such a key once, at its first place and with its last value, as `JSON.parse` makes it. So it sees the same +// whether it asks for the key or walks the entries. +describe.concurrent("a key that is written more than once in what a rule is handed", () => { + const plugin = ` + const show = (name, value) => console.log(name + " " + JSON.stringify(value) + " " + JSON.stringify(Object.keys(value))); + const create = context => (show("options", context.options[0]), show("settings", context.settings.mine ?? {}), {}); + module.exports = { meta: { name: "mine" }, rules: { show: { meta: { schema: false }, create } } };\n`; + const printed = (result: { raw: string; stderr: string }, name: string) => + (result.raw + result.stderr).split("\n").filter(it => it.startsWith(name + " ")); + + // What oxlint 1.87 prints. + test.each([ + [ + '{"size": 1, "color": "red", "size": 3, "shape": null, "size": 5}', + '{"size":5,"color":"red","shape":null} ["size","color","shape"]', + ], + // The whole of the last one: nothing is merged. + ['{"box": {"w": 1, "h": 2}, "n": 0, "box": {"h": 3}}', '{"box":{"h":3},"n":0} ["box","n"]'], + [ + '{"deep": {"list": [{"k": 1, "k": 2}], "list": [{"k": 3, "j": 4, "k": 5}]}}', + '{"deep":{"list":[{"k":5,"j":4}]}} ["deep"]', + ], + ['{"size": "big", "size": ["small"]}', '{"size":["small"]} ["size"]'], + ['{"size": 1, "\\u0073ize": 2}', '{"size":2} ["size"]'], + // A property like any other. + [ + '{"__proto__": {"size": 1}, "color": "red", "__proto__": {"size": 2}}', + '{"__proto__":{"size":2},"color":"red"} ["__proto__","color"]', + ], + ])("a rule in JavaScript, from an .oxlintrc.json: %s", async (written, handed) => { + const config = `{ "jsPlugins": ["./plugin.cjs"], "settings": { "mine": ${written} }, "rules": { "mine/show": ["error", ${written}] } }`; + const result = await lint({ ".oxlintrc.json": config, "plugin.cjs": plugin, "a.js": "export {};\n" }, ["a.js"]); + expect(printed(result, "options")).toEqual([`options ${handed}`]); + expect(printed(result, "settings")).toEqual([`settings ${handed}`]); + expect(result.exitCode).toBe(0); + }); + + // What oxlint 1.87 reports. The rule walks its one option, a map from the name of a matcher to what is said about it. + test.each([ + ['{"toBe": "first", "toEqual": "other", "toBe": "last"}', ["2 toBe last", "3 toEqual other"]], + ['{"toBe": "first", "toBe": null}', ["2 toBe undefined"]], + // The first value, which no map of the rule can have, is not looked at. + ['{"toBe": 1, "toBe": "last"}', ["2 toBe last"]], + ])("a built-in rule that walks the entries, from an .oxlintrc.json: %s", async (written, found) => { + const files = { + ".oxlintrc.json": `{ "plugins": ["jest"], "categories": { "correctness": "off" }, "rules": { "jest/no-restricted-matchers": ["error", ${written}] } }`, + "a.test.js": 'test("t", () => {\n expect(a).toBe(1);\n expect(a).toEqual(1);\n});\n', + }; + const { raw, exitCode } = await lint(files, ["-f", "json", "a.test.js"]); + type Diagnostic = { message: string; help?: string; labels: { span: { line: number } }[] }; + const diagnostics: Diagnostic[] = JSON.parse(raw).diagnostics; + const shown = diagnostics.map(it => `${it.labels[0].span.line} ${it.message.split("`")[1]} ${it.help}`); + expect(shown).toEqual(found); + expect(exitCode).toBe(1); + }); + + // What oxlint 1.87 and ESLint 10.12 do: `true` if the file, which has three lines, has too many, a text if the options are refused. + describe.each<[written: string, outcome: boolean | string]>([ + ['{"max": 1, "max": 100}', false], + ['{"max": 100, "max": 1}', true], + ['{"max": "many", "max": 100}', false], + ['{"max": 100, "max": "many"}', "many"], + ])("a built-in rule that asks for the key: %s", (written, outcome) => { + const found = outcome === true ? ["max-lines"] : []; + const rulesOf = (problems: string[]) => problems.map(it => it.split(" ")[1]); + + test("from an .oxlintrc.json", async () => { + const config = `{ "categories": { "correctness": "off" }, "rules": { "max-lines": ["error", ${written}] } }`; + const { problems, raw, exitCode } = await lint({ ".oxlintrc.json": config, "a.js": "a;\nb;\nc;\n" }, ["a.js"]); + expect(rulesOf(problems)).toEqual(found); + if (typeof outcome === "string") expect(raw).toContain(`invalid type: string "${outcome}", expected u32`); + expect(exitCode).toBe(outcome === false ? 0 : 1); + }); + + test("from a comment", async () => { + const files = { + "eslint.config.mjs": "export default [{ rules: {} }];\n", + "a.js": `/* eslint max-lines: ["error", ${written}] */\na;\nb;\n`, + }; + const { problems, raw, exitCode } = await lint(files, ["a.js"]); + expect(rulesOf(problems)).toEqual(outcome === false ? [] : ["max-lines"]); + if (typeof outcome === "string") expect(raw).toContain(`Value \\"${outcome}\\" should be integer.`); + expect(exitCode).toBe(outcome === false ? 0 : 1); + }); + + test("from --rule", async () => { + const files = { "eslint.config.mjs": "export default [{ rules: {} }];\n", "a.js": "a;\nb;\nc;\n" }; + const { problems, stderr, exitCode } = await lint(files, ["--rule", `max-lines: ["error", ${written}]`, "a.js"]); + expect(rulesOf(problems)).toEqual(found); + if (typeof outcome === "string") expect(stderr).toContain(`Value "${outcome}" should be integer.`); + expect(exitCode).toBe(typeof outcome === "string" ? 2 : Number(outcome)); + }); + }); + + // What ESLint 10.12 prints. It reads both with levn, which assigns each entry to an object: `__proto__` is no property then. + describe.each([ + [ + '{"size": 1, "color": "red", "size": 3, "shape": null, "size": 5}', + '{"size":5,"color":"red","shape":null} ["size","color","shape"]', + ], + ['{"box": {"w": 1, "h": 2}, "n": 0, "box": {"h": 3}}', '{"box":{"h":3},"n":0} ["box","n"]'], + [ + '{"deep": {"list": [{"k": 1, "k": 2}], "list": [{"k": 3, "j": 4, "k": 5}]}}', + '{"deep":{"list":[{"k":5,"j":4}]}} ["deep"]', + ], + [`{size: 1, "size": 2, 'size': 3}`, '{"size":3} ["size"]'], + ['{"__proto__": {"size": 1}, "color": "red"}', '{"color":"red"} ["color"]'], + ])("a rule in JavaScript, with an eslint.config.mjs: %s", (written, handed) => { + const files = { + "eslint.config.mjs": `import mine from "./plugin.cjs";\nexport default [{ plugins: { mine } }];\n`, + "plugin.cjs": plugin, + }; + + test("from a comment", async () => { + const code = `/* eslint mine/show: ["error", ${written}] */\nexport {};\n`; + expect(printed(await lint({ ...files, "a.js": code }, ["a.js"]), "options")).toEqual([`options ${handed}`]); + }); + + test("from --rule", async () => { + const result = await lint({ ...files, "a.js": "export {};\n" }, [ + "--rule", + `mine/show: ["error", ${written}]`, + "a.js", + ]); + expect(printed(result, "options")).toEqual([`options ${handed}`]); + }); + }); + + // TOML has no such thing. + test.each([ + 'max-lines = ["error", { max = 100, max = 1 }]\n', + 'max-lines = "off"\nmax-lines = ["error", { max = 1 }]\n', + '"max-lines" = "off"\nmax-lines = ["error", { max = 1 }]\n', + ])("a bunfig.toml is refused: %j", async rules => { + const { problems, stderr, exitCode } = await lint({ "bunfig.toml": `[lint.rules]\n${rules}`, "a.js": "a;\nb;\n" }, [ + "a.js", + ]); + expect(problems).toEqual([]); + expect(stderr).toContain("error: Cannot redefine key"); + expect(exitCode).toBe(2); + }); + + // The words of js-yaml 4, which reads these files for ESLint 8 and throws. It reads an `.eslintrc` too, which can be JSON. + test.each([ + [ + ".eslintrc.yaml", + "root: true\nrules:\n max-lines: [error, {max: 100, max: 1}]\n", + "duplicated mapping key (3:33)\n\n 1 | root: true\n 2 | rules:\n 3 | max-lines: [error, {max: 100, max: 1}]\n-------------------------------------^", + ], + [ + ".eslintrc.yml", + 'root: true\nrules:\n "no-var": off\n eqeqeq: off\n no-var: error\nenv: {}\nglobals: {}\nsettings: {}\n', + 'duplicated mapping key (5:3)\n\n 2 | rules:\n 3 | "no-var": off\n 4 | eqeqeq: off\n 5 | no-var: error\n-------^\n 6 | env: {}\n 7 | globals: {}', + ], + [ + ".eslintrc", + '{ "root": true, "rules": { "no-var": "off", "no-var": "error" } }\n', + 'duplicated mapping key (1:45)\n\n 1 | ... e, "rules": { "no-var": "off", "no-var": "error" } }\n-----------------------------------------^', + ], + ])("a %s is refused", async (name, text, why) => { + const { problems, stderr, exitCode } = await lint({ [name]: text, "a.js": "var a;\n" }, ["a.js"]); + expect(problems).toEqual([]); + expect(stderr).toContain(`Cannot read config file: /${name}\nError: ${why}`); + expect(exitCode).toBe(2); + }); +}); + describe.concurrent("the configuration files of ESLint 8", () => { test.each([ [".eslintrc.json", rc(eqeqeq)], @@ -3256,80 +4773,187 @@ describe.concurrent("whose configuration files count", () => { "sub/b.js": "var y = 1;\nif (y == 2) {}\n", }; - // `oxlint --fix && eslint --fix .`, each replaced by `bun lint`: one of the two tools would be left out, in silence. - test("where files of both tools count and nothing decides, nothing is linted", async () => { - const say = "Say which one this run is for: --flavor=oxlint or --flavor=eslint."; - const { problems, stdout, stderr, exitCode } = await lint(both, ["--fix", "a.js", "sub/b.js"]); - expect([problems, stdout]).toEqual([[], ""]); - expect(stderr).toBe(`error: Both /eslint.config.js and /.oxlintrc.json are here. ${say}`); - expect(exitCode).toBe(2); + const ignores = (ignored: string, used = ".oxlintrc.json") => + `note: Using ${used}. ${ignored} is ignored. Use --config to pick it.`; + + // A project with both configs is moving to oxlint. + test("the oxlint config wins over an ESLint config, and one note says so", async () => { + const { problems, stderr, exitCode } = await lint(both, ["a.js", "sub/b.js"]); + expect(problems).toEqual(["a.js:2:7 eqeqeq"]); + expect(stderr).toBe(ignores("eslint.config.js")); + expect(exitCode).toBe(1); // Also where one is further up than the other. const { "sub/.oxlintrc.json": _, ...above } = both; - const below = await lint(above, ["."], { cwd: "sub" }); - expect(below.stderr).toBe(`error: Both /sub/eslint.config.js and /.oxlintrc.json are here. ${say}`); - expect(below.exitCode).toBe(2); + const below = await lint(above, ["b.js"], { cwd: "sub" }); + expect(below.problems).toEqual(["sub/b.js:2:7 eqeqeq"]); + expect(below.stderr).toBe(ignores("eslint.config.js", "../.oxlintrc.json")); + expect(below.exitCode).toBe(1); + expect((await lint(both, ["--silent", "a.js"])).stderr).toBe(""); expect((await lint(both, ["--nonsense"])).exitCode).toBe(1); }); - test("oxlint needs no file: an eslint.config.js and the dependency", async () => { + const [ofESLint, ofOxlint, legacy] = [both["eslint.config.js"], oxlintrc({ "no-debugger": "error" }), rc(noVar)]; + const asModule = ofESLint.replace("module.exports =", "export default"); + test.each([ + ["eslint.config.js", ofESLint, "no-var"], + ["eslint.config.mjs", asModule, "no-var"], + ["eslint.config.cjs", ofESLint, "no-var"], + ["eslint.config.ts", asModule, "no-var"], + ["eslint.config.mts", asModule, "no-var"], + ["eslint.config.cts", ofESLint, "no-var"], + ["mine.js", ofESLint, "no-var"], + [".oxlintrc.json", ofOxlint, "no-debugger"], + [".oxlintrc.jsonc", `// Mine.\n${ofOxlint}`, "no-debugger"], + ["oxlint.config.ts", `export default ${ofOxlint};`, "no-debugger"], + ["oxlint.config.mts", `export default ${ofOxlint};`, "no-debugger"], + ["vite.config.ts", `export default { lint: ${ofOxlint} };`, "no-debugger"], + ["mine.json", ofOxlint, "no-debugger"], + [".eslintrc.json", legacy, "no-var"], + [".eslintrc", legacy, "no-var"], + [".eslintrc.js", `module.exports = ${legacy};`, "no-var"], + [".eslintrc.cjs", `module.exports = ${legacy};`, "no-var"], + [".eslintrc.yaml", yaml("no-var"), "no-var"], + [".eslintrc.yml", yaml("no-var"), "no-var"], + ["legacy.json", legacy, "no-var"], + ])("with both kinds of config file, --config picks %s and prints no note", async (name, text, rule) => { + const files = { ...both, [`picked/${name}`]: text }; + const [byFlag, byKey] = await Promise.all([ + lint(files, ["--config", `picked/${name}`, "a.js"]), + lint({ ...files, "bunfig.toml": `[lint]\nconfig = "picked/${name}"\n` }, ["a.js"]), + ]); + for (const { problems, stderr } of [byFlag, byKey]) { + expect(problems.map(it => it.split(" ")[1])).toEqual([rule]); + expect(stderr).not.toContain("is ignored"); + } + }); + + test("an eslint.config.js wins over a dependency on oxlint", async () => { const files = { "eslint.config.js": both["eslint.config.js"], "package.json": JSON.stringify({ devDependencies: { eslint: "*", oxlint: "*" } }), "a.js": code, }; - const { stderr, exitCode } = await lint(files, ["a.js"]); - expect(stderr).toStartWith( - "error: Both /eslint.config.js and the dependency on oxlint in /package.json are here. Say which one", - ); - expect(exitCode).toBe(2); + const { problems, stderr } = await lint(files, ["a.js"]); + expect(problems).toEqual(["a.js:1:1 no-var"]); + expect(stderr).not.toContain("is ignored"); expect((await lint(files, ["--flavor=eslint", "a.js"])).problems).toEqual(["a.js:1:1 no-var"]); // Its defaults. expect((await lint(files, ["--flavor=oxlint", "a.js"])).problems).toEqual(["a.js:2:13 no-debugger"]); - expect((await lint(files, ["--type-aware", "a.js"])).exitCode).toBe(2); - const { "package.json": _, ...alone } = files; - expect((await lint(alone, ["a.js"])).problems).toEqual(["a.js:1:1 no-var"]); - expect((await lint(alone, ["--type-aware", "a.js"])).problems).toEqual(["a.js:1:1 no-var"]); + expect((await lint(files, ["--type-aware", "a.js"])).problems).toEqual(["a.js:1:1 no-var"]); }); - test("--type-aware, which ESLint does not have, is for oxlint where there are files of both", async () => { - const { problems, stderr } = await lint(both, ["--type-aware", "a.js"]); - expect(problems).toEqual(["a.js:2:7 eqeqeq"]); - expect(stderr).not.toContain("Both "); - expect((await lint(both, ["--type-aware", "--cache", "a.js"])).problems).toEqual(["a.js:1:1 no-var"]); + test.each([ + [["--type-aware"], ["a.js:2:7 eqeqeq"]], + [["--cache"], ["a.js:2:7 eqeqeq"]], + [["--no-warn-ignored"], ["a.js:2:7 eqeqeq"]], + [ + ["--rule", "no-var: error"], + ["a.js:1:1 no-var", "a.js:2:7 eqeqeq"], + ], + [ + ["-D", "no-debugger"], + ["a.js:2:13 no-debugger", "a.js:2:7 eqeqeq"], + ], + ])("%j doesn't pick between an ESLint and an oxlint config", async (flags, expected) => { + const { problems, stderr } = await lint(both, [...flags, "a.js"]); + expect(problems).toEqual(expected); + expect(stderr).toBe(ignores("eslint.config.js")); }); - test.each([["--cache"], ["--no-warn-ignored"], ["--ext", ".js"], ["--rule", "no-var: error"]])( - "%s is a flag that only ESLint has, so the run is for ESLint", - async (...flags) => { - const { problems, stderr } = await lint(both, [...flags, "a.js"]); - expect(problems).toEqual(["a.js:1:1 no-var"]); - expect(stderr).not.toContain("Both "); - }, - ); - test.each([ ["eslint", ["a.js:1:1 no-var"]], ["oxlint", ["a.js:2:7 eqeqeq"]], ])("--flavor=%s", async (flavor, expected) => { const { problems, stderr } = await lint(both, [`--flavor=${flavor}`, "a.js", "sub/b.js"]); expect(problems).toEqual(expected); - expect(stderr).not.toContain("Both "); + expect(stderr).not.toContain("is ignored"); }); test("--flavor takes two values", async () => { const { stderr, exitCode } = await lint(both, ["--flavor=biome", "."]); - expect(stderr).toContain("Option flavor: 'biome' not one of eslint or oxlint."); + expect(stderr).toContain('--flavor must be eslint or oxlint, not "biome"'); expect(exitCode).toBe(1); }); - test("flags that only oxlint has, without a configuration file, are for oxlint", async () => { - // Its defaults warn about what is surely wrong. - const { problems, exitCode } = await lint({ "a.js": code }, ["-D", "no-var", "a.js"]); - expect(problems).toEqual(["a.js:1:1 no-var", "a.js:2:13 no-debugger"]); - expect(exitCode).toBe(1); - const allowed = await lint({ "a.js": code }, ["-A", "no-debugger", "a.js"]); - expect(allowed.problems).toEqual([]); - expect(allowed.exitCode).toBe(0); + // Covers every flag in the "Compatibility with oxlint" group of --help --all. + test("oxlint flags keep Bun's default rules and output without a config file", async () => { + const run = async (...args: string[]) => { + using dir = tempDir("bun-lint-oxlint-flags", { "a.js": "export function f(a) {\n if (a) {\n }\n}\n" }); + const stdio = { stdin: "ignore", stdout: "pipe", stderr: "pipe" } as const; + await using proc = spawn({ cmd: [...command, ...args], env, cwd: String(dir), ...stdio }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr: stderr.replace(/ \[[\d.]+m?s\]/, ""), exitCode }; + }; + const [help, plain, limited] = await Promise.all([run("--help", "--all"), run(), run("--max-warnings", "0")]); + expect(plain.stdout).toContain("warn: Empty block statement. no-empty\n"); + expect(plain.stdout).toEndWith("\n1 problem (0 errors, 1 warning)\n"); + expect([plain.stderr, plain.exitCode]).toEqual(["Linted 1 file\n", 0]); + expect(limited).toEqual({ + stdout: plain.stdout, + stderr: "error: Found too many warnings (maximum: 0).\nLinted 1 file\n", + exitCode: 1, + }); + + const error = (text: string) => ({ stdout: "", stderr: `error: ${text}\n`, exitCode: 2 }); + const expected: Record = { + allow: [["-A", "no-var"], plain], + warn: [["-W", "no-empty"], plain], + deny: [["-D", "no-var"], plain], + "deny-warnings": [["--deny-warnings"], limited], + silent: [["--silent"], { stdout: "", stderr: "", exitCode: 0 }], + "disable-nested-config": [["--disable-nested-config"], plain], + lsp: [["--lsp"], error("bun lint does not support --lsp")], + }; + const group = help.stdout.slice(help.stdout.indexOf("Compatibility with oxlint")); + const flags = [...group.matchAll(/^ +(?:-\w, )?--([a-z0-9-]+)/gm)].map(it => it[1]); + // Every built-in rule is available without a config file, so there are no plugins to turn on or off. + for (const flag of flags.filter(it => it.endsWith("-plugin"))) { + const why = `--${flag} is an oxlint flag and has no effect here\n This run uses Bun's defaults.`; + expected[flag] = [[`--${flag}`], error(why)]; + } + expect(Object.keys(expected).sort()).toEqual(flags.sort()); + const results = await Promise.all(Object.values(expected).map(([args]) => run(...args))); + expect(Object.fromEntries(Object.keys(expected).map((flag, at) => [flag, results[at]]))).toEqual( + Object.fromEntries(Object.entries(expected).map(([flag, [, result]]) => [flag, result])), + ); + expect(await run("--deny-warnings", "--silent")).toEqual({ stdout: "", stderr: "", exitCode: 1 }); + }); + + test("-A, -W and -D add to Bun's defaults without a config file", async () => { + const files = { "a.js": "var x = 1;\nexport function f(a) {\n if (a) {\n }\n return x;\n}\n" }; + const run = async (...flags: string[]) => { + const { problems, exitCode } = await lint(files, [...flags, "a.js"]); + return [problems, exitCode]; + }; + const [noVar, noEmpty] = ["a.js:1:1 no-var", "a.js:3:10 no-empty"]; + expect(await run()).toEqual([[noEmpty], 0]); + expect(await run("-D", "no-var")).toEqual([[noVar, noEmpty], 1]); + expect(await run("-W", "no-var")).toEqual([[noVar, noEmpty], 0]); + expect(await run("-D", "eslint/no-var")).toEqual([[noVar, noEmpty], 1]); + expect(await run("-D", "no-var", "-A", "no-var")).toEqual([[noEmpty], 0]); + expect(await run("-A", "no-empty")).toEqual([[], 0]); + // A category works like [lint.categories]. + const byKey = await lint({ ...files, "bunfig.toml": `[lint.categories]\nrestriction = "error"\n` }, ["a.js"]); + expect(byKey.problems).toContain(noVar); + expect(await run("-D", "restriction")).toEqual([byKey.problems, 1]); + }); + + test.each([ + [["-D", "no-vra"], `Unknown rule "no-vra"\n Did you mean "no-var"?`], + [["-A", "no-vra"], `Unknown rule "no-vra"\n Did you mean "no-var"?`], + [["-W", "indent"], `"indent" is a formatting rule\n bun fmt does that`], + ])("%j is rejected without a config file", async (flags, why) => { + const { problems, stderr, exitCode } = await lint({ "a.js": code }, [...flags, "a.js"]); + expect({ problems, stderr, exitCode }).toEqual({ problems: [], stderr: `error: ${why}`, exitCode: 2 }); + }); + + test("without a root config file, --disable-nested-config skips config files in subdirectories", async () => { + const files = { "a.js": code, "sub/.oxlintrc.json": oxlintrc({ eqeqeq: "error" }), "sub/b.js": code }; + expect((await lint(files)).problems).toEqual(["a.js:2:13 no-debugger", "sub/b.js:2:7 eqeqeq"]); + expect((await lint(files, ["--disable-nested-config", "."])).problems).toEqual([ + "a.js:2:13 no-debugger", + "sub/b.js:2:13 no-debugger", + ]); }); test("without a configuration file, the package.json tells", async () => { @@ -3347,11 +4971,11 @@ describe.concurrent("whose configuration files count", () => { expect(eslint.exitCode).toBe(1); const both = await lint({ ...files, "package.json": dependsOn("eslint", "oxlint") }); expect(both.stderr).toContain( - "No configuration file found, and /package.json has both eslint and oxlint: using the defaults of oxlint. Use --flavor=eslint for the other.", + "No config file found, and package.json depends on both eslint and oxlint\n Using oxlint's defaults. Add a config file to change that.", ); expect(both.exitCode).toBe(0); const chosen = await lint({ ...files, "package.json": dependsOn("eslint", "oxlint") }, ["--flavor=eslint", "."]); - expect(chosen.stderr).not.toContain("No configuration file found"); + expect(chosen.stderr).not.toContain("No config file found"); expect(chosen.exitCode).toBe(1); expect((await lint(files)).exitCode).toBe(1); }); @@ -3410,12 +5034,6 @@ describe.concurrent("whose configuration files count", () => { expect(withFile.problems).toEqual(["a.js:2:13 no-debugger", "packages/own/b.js:2:13 no-debugger"]); }); - test("flags that only oxlint has, where there are both, are for oxlint", async () => { - const { problems, stderr } = await lint(both, ["-D", "no-debugger", "a.js"]); - expect(problems).toEqual(["a.js:2:13 no-debugger", "a.js:2:7 eqeqeq"]); - expect(stderr).not.toContain("Both "); - }); - describe("the file that is found decides, whatever flags there are", () => { const flat = { "eslint.config.js": both["eslint.config.js"] }; const oxlint = { ".oxlintrc.json": both[".oxlintrc.json"] }; @@ -3425,6 +5043,7 @@ describe.concurrent("whose configuration files count", () => { ".eslintrc.json": { ".eslintrc.json": rc(noVar) }, ".oxlintrc.json": oxlint, "both": { ...flat, ...oxlint }, + ".eslintrc.json and .oxlintrc.json": { ".eslintrc.json": rc(noVar), ...oxlint }, "eslint.config.js and the dependency": { ...flat, "package.json": `{ "devDependencies": { "oxlint": "*" } }` }, "the dependency": { "package.json": `{ "devDependencies": { "oxlint": "*" } }` }, "files by other names": { "mine.js": both["eslint.config.js"], "mine.json": both[".oxlintrc.json"] }, @@ -3438,12 +5057,11 @@ describe.concurrent("whose configuration files count", () => { const [ofESLint, ofOxlint] = [[["a.js:1:1 no-var"], 1] as const, [["a.js:2:7 eqeqeq"], 1] as const]; const [defaults, builtIn] = [[["a.js:2:13 no-debugger"], 0] as const, [["a.js:2:13 no-debugger"], 1] as const]; const [nothing, failsInSilence] = [[[], 0] as const, [[], 1] as const]; - const asks = [[], 2, "Say which one this run is for: --flavor=oxlint or --flavor=eslint."] as const; const refused = (flag: string, whose: string, where: string) => - [[], 2, `error: ${flag} is a flag of ${whose}. It means nothing ${where}.`] as const; - const withFlat = "with /eslint.config.js, a configuration file of ESLint"; - const withLegacy = "with /.eslintrc.json, a configuration file of ESLint 8"; - const withOxlint = "with /.oxlintrc.json, a configuration file of oxlint"; + [[], 2, `error: ${flag} is an ${whose} flag and has no effect here\n This run uses ${where}.`] as const; + const withFlat = "eslint.config.js, an ESLint config"; + const withLegacy = ".eslintrc.json, an ESLint 8 config"; + const withOxlint = ".oxlintrc.json, an oxlint config"; test.each<[keyof typeof projects, string[], readonly [readonly string[], number, string?]]>([ ["eslint.config.js", [], ofESLint], @@ -3498,39 +5116,37 @@ describe.concurrent("whose configuration files count", () => { [".oxlintrc.json", ["--flavor=eslint"], builtIn], [".oxlintrc.json and [lint] ext", [], ofOxlint], - // Nothing else decides. ["no file", [], builtIn], - ["no file", ["--silent"], nothing], - ["no file", ["--deny-warnings"], [["a.js:2:13 no-debugger"], 1]], - ["no file", ["--import-plugin"], defaults], + ["no file", ["--silent"], failsInSilence], + ["no file", ["--deny-warnings"], builtIn], + ["no file", ["--disable-nested-config"], builtIn], + ["no file", ["-D", "correctness"], builtIn], + ["no file", ["--import-plugin"], refused("--import-plugin", "oxlint", "Bun's defaults")], + ["no file", ["--fix-suggestions"], builtIn], + ["no file", ["--fix-dangerously"], builtIn], + ["no file", ["--type-check"], builtIn], + // Only ESLint 8 has --env, so this is an ESLint 8 run, which has no rules on. ["no file", ["--env", "browser"], nothing], - [ - "no file", - ["--flavor=eslint", "--import-plugin"], - refused("--import-plugin", "oxlint", "in a run that stands in for ESLint"), - ], - [ - "no file", - ["--flavor=oxlint", "--env", "browser"], - refused("--env browser", "ESLint 8", "in a run that stands in for oxlint"), - ], + ["no file", ["--flavor=eslint", "--import-plugin"], refused("--import-plugin", "oxlint", "Bun's defaults")], + ["no file", ["--flavor=oxlint", "--env", "browser"], refused("--env browser", "ESLint 8", "oxlint's defaults")], ["the dependency", [], defaults], ["the dependency", ["--cache"], defaults], - [ - "the dependency", - ["--parser", "espree"], - refused("--parser espree", "ESLint", "in a run that stands in for oxlint"), - ], - ["both", [], asks], + ["the dependency", ["--parser", "espree"], refused("--parser espree", "ESLint", "oxlint's defaults")], + ["both", [], [...ofOxlint, ignores("eslint.config.js")]], + [".eslintrc.json and .oxlintrc.json", [], [...ofOxlint, ignores(".eslintrc.json")]], ["both", ["--silent"], failsInSilence], ["both", ["--deny-warnings"], ofOxlint], - ["both", ["--cache"], ofESLint], - ["both", ["--env", "browser"], refused("--env browser", "ESLint 8", withFlat)], + ["both", ["--cache"], ofOxlint], + ["both", ["--ext", ".js"], refused("--ext .js", "ESLint", withOxlint)], + ["both", ["--env", "browser"], refused("--env browser", "ESLint 8", withOxlint)], - // The command without the flag does not guess, and most flags of oxlint mean something to both. - ["eslint.config.js and the dependency", [], asks], - ["eslint.config.js and the dependency", ["--silent"], asks], - ["eslint.config.js and the dependency", ["--deny-warnings", "-D", "correctness"], asks], + ["eslint.config.js and the dependency", [], ofESLint], + ["eslint.config.js and the dependency", ["--silent"], failsInSilence], + [ + "eslint.config.js and the dependency", + ["--deny-warnings", "-D", "correctness"], + refused("-D correctness", "oxlint", withFlat), + ], ["eslint.config.js and the dependency", ["--cache"], ofESLint], ["eslint.config.js and the dependency", ["--rules"], nothing], @@ -3543,12 +5159,12 @@ describe.concurrent("whose configuration files count", () => { [ "files by other names", ["-c", "mine.js", "--import-plugin"], - refused("--import-plugin", "oxlint", "with /mine.js, a configuration file of ESLint"), + refused("--import-plugin", "oxlint", "mine.js, an ESLint config"), ], [ "files by other names", ["-c", "mine.json", "--ext", ".js"], - refused("--ext .js", "ESLint", "with /mine.json, a configuration file of oxlint"), + refused("--ext .js", "ESLint", "mine.json, an oxlint config"), ], ])("%s, %j", async (project, flags, [problems, exitCode, said]) => { const run = await lint({ ...projects[project], "a.js": code }, [...flags, "a.js"]); @@ -3581,24 +5197,24 @@ describe.concurrent("whose configuration files count", () => { ]); } expect((await lint(files, ["--silent", "one"])).exitCode).toBe(1); - const withFile = "It means nothing with /one/eslint.config.js, a configuration file of ESLint."; - for (const [flags, said] of [ - [["--env", "browser"], "--env browser is a flag of ESLint 8"], - [["--no-eslintrc"], "--no-eslintrc is a flag of ESLint 8"], - [["--import-plugin"], "--import-plugin is a flag of oxlint"], + const withFile = "one/eslint.config.js, an ESLint config."; + for (const [flags, said, uses] of [ + [["--env", "browser"], "--env browser is an ESLint 8 flag and has no effect here", withFile], + [["--no-eslintrc"], "--no-eslintrc is an ESLint 8 flag and has no effect here", withFile], + [["--import-plugin"], "--import-plugin is an oxlint flag and has no effect here", "Bun's defaults."], ] as const) { for (const args of [["."], ["one/a.js"]]) { const { problems, stderr, exitCode } = await lint(one, [...flags, ...args]); expect({ problems, stderr, exitCode }).toEqual({ problems: [], - stderr: `error: ${said}. ${withFile}`, + stderr: `error: ${said}\n This run uses ${uses}`, exitCode: 2, }); } } }); - test("--silent says nothing, and --deny-warnings fails a run that only has warnings", async () => { + test("--silent prints nothing, and --deny-warnings fails on a warning like --max-warnings 0", async () => { const files = { "eslint.config.js": `module.exports = [{ rules: { "no-var": "warn" } }];`, "a.js": "var x = 1;\n", @@ -3606,6 +5222,7 @@ describe.concurrent("whose configuration files count", () => { expect((await lint(files, ["a.js"])).exitCode).toBe(0); const denied = await lint(files, ["--deny-warnings", "a.js"]); expect([denied.problems, denied.exitCode]).toEqual([["a.js:1:1 no-var"], 1]); + expect(denied.stderr).toStartWith("error: Found too many warnings (maximum: 0).\n"); const { stdout, stderr, exitCode } = await lint(files, ["--deny-warnings", "--silent", "a.js"]); expect({ stdout, stderr, exitCode }).toEqual({ stdout: "", stderr: "", exitCode: 1 }); }); @@ -3636,11 +5253,11 @@ describe.concurrent("the command line of oxlint", () => { test.each([ [["--nonsense"], "Invalid option '--nonsense'"], - [["--threads=x"], '--threads takes a number, not "x".'], + [["--threads=x"], '--threads takes a number, not "x"'], [["--max-warnings=x"], "Invalid value for option 'max-warnings'"], [["--debug=nonsense"], "couldn't parse `nonsense`: 'nonsense' is not a known debug option"], [["--debug=files,timings"], "debug option 'files' cannot be combined with other debug options"], - [["-f", "nonsense"], 'There is no formatter "nonsense".'], + [["-f", "nonsense"], 'Unknown format "nonsense"\n Available formats: stylish, '], [["-c", "nowhere.json"], "nowhere.json"], [["--tsconfig", "nowhere.json"], 'nowhere.json" does not exist, Please provide a valid tsconfig file.'], [ @@ -3658,15 +5275,13 @@ describe.concurrent("the command line of oxlint", () => { expect(forEslint.exitCode).toBe(2); }); - test("so does a run that is oxlint's by --config, by the package.json or by a flag", async () => { + test("a run that uses oxlint because of --config or package.json also exits with 1", async () => { const broken = { "broken.json": "{", "a.js": code }; expect((await lint(broken, ["-c", "broken.json"])).exitCode).toBe(1); const files = { "package.json": JSON.stringify({ devDependencies: { oxlint: "*" } }), "a.js": code }; expect((await lint(files, ["--type-check"])).exitCode).toBe(1); - expect((await lint({ "a.js": code }, ["-D", "no-var", "--type-check"])).exitCode).toBe(1); - // Only oxlint has the flag. - expect((await lint({ "a.js": code }, ["--type-check"])).exitCode).toBe(1); - expect((await lint({ "a.js": code }, ["--flavor=eslint", "--type-check"])).exitCode).toBe(2); + // --type-check works on its own without a config file. + expect((await lint({ "a.js": code }, ["--type-check"])).problems).toEqual(["a.js:2:13 no-debugger"]); }); test("a configuration that cannot be used ends the run with 1", async () => { @@ -3712,6 +5327,84 @@ describe.concurrent("the command line of oxlint", () => { expect(alone.exitCode).toBe(1); }); + // The texts are those of oxlint 1.87.0. + test.each([ + [ + { settings: { react: { version: 1 } } }, + 'invalid type: integer `1`, expected a version string like \\"18.2.0\\" or \\"17.0\\"', + ], + [{ settings: { react: { version: "detect" } } }, "invalid major version in settings.react.version"], + [{ settings: { react: { version: "18.x" } } }, "invalid minor version in settings.react.version"], + [{ settings: { react: { version: "18.2.0-rc.1" } } }, "invalid patch version in settings.react.version"], + [{ settings: { react: { version: "18.2.0.1" } } }, "has too many components, expected major.minor.patch"], + [{ settings: { react: { version: "" } } }, "version string cannot be empty for settings.react.version"], + [ + { rules: { "no-unused-vars": ["error", { vars: "nope" }] } }, + "Invalid 'vars' option for no-unused-vars: Expected or all or local, got nope", + ], + [ + { rules: { "no-unused-vars": ["off", "nope"] } }, + "Invalid 'vars' option for no-unused-vars: Expected or all or local, got nope", + ], + [ + { rules: { "no-unused-vars": ["error", { args: 1 }] } }, + "Invalid 'args' option for no-unused-vars: Expected a string, got 1", + ], + [{ rules: { "no-unused-vars": ["error", { args: "some" }] } }, "Expected or after-used or all or none, got some"], + [ + { rules: { "no-unused-vars": ["error", { caughtErrors: "some" }] } }, + "Invalid 'caughtErrors' option for no-unused-vars: Expected or all or none, got some", + ], + [ + { rules: { "no-unused-vars": ["error", 1] } }, + "Invalid 'vars' option for no-unused-vars: Expected a string or an object, got 1", + ], + ])("an option value that oxlint rejects is a config error: %j", async (config, why) => { + // `stdout` has its backslashes normalized, and one of these texts has some. + const { raw, exitCode } = await lint({ ".oxlintrc.json": JSON.stringify(config), "a.js": code }); + expect(raw).toContain(why); + expect(exitCode).toBe(1); + }); + + test.each([ + { settings: { react: { version: "18" } } }, + { settings: { react: { version: "18.2" } } }, + { settings: { react: { version: null } } }, + { rules: { "no-unused-vars": ["error", "local"] } }, + { rules: { "no-unused-vars": ["error", { vars: "all", args: "after-used", caughtErrors: "none" }] } }, + { rules: { "no-unused-vars": ["error", { caughtErrors: false }] } }, + ])("an option value that oxlint accepts is not: %j", async config => { + const { stdout } = await lint({ ".oxlintrc.json": JSON.stringify(config), "a.js": code }); + expect(stdout).toStartWith('{"results":'); + }); + + // Rules that look at several files lint a file a second time, without types. + test("--type-check still reports type errors when import rules are on", async () => { + const files = { + "tsconfig.json": JSON.stringify({ compilerOptions: { strict: true, noEmit: true, types: [] } }), + "q.ts": "export const q = 1;\n", + "b.ts": [ + 'import { q } from "./q";', + 'import { q as r } from "./q";', + "// oxlint-disable-next-line", + 'const n: number = "x";', + "var old = await 1;", + "export default [n, q, r, old];", + "", + ].join("\n"), + }; + const rules = { "no-var": "error", "import/no-duplicates": "error", "typescript/await-thenable": "error" }; + const config = JSON.stringify({ plugins: ["import", "typescript"], categories: { correctness: "off" }, rules }); + const { problems, exitCode } = await lint({ ...files, ".oxlintrc.json": config }, ["--type-aware", "--type-check"]); + expect(problems).toEqual([ + "b.ts:1:19 import/no-duplicates", + "b.ts:4:7 typescript/TS2322", + "b.ts:5:1 no-var", + "b.ts:5:17 @typescript-eslint/await-thenable", + ]); + expect(exitCode).toBe(1); + }); + test("--print-config prints what oxlint prints", async () => { using dir = tempDir("bun-lint-print-config", { ".oxlintrc.json": JSON.stringify({ @@ -3866,60 +5559,6 @@ describe.concurrent("the command line of oxlint", () => { }); }); - test("--init writes the defaults of oxlint, and not over a file", async () => { - using dir = tempDir("bun-lint-init", { "a.js": "debugger;\n" }); - const run = async (...args: string[]) => { - await using proc = spawn({ - cmd: [...command, ...args], - env, - cwd: String(dir), - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - return { stdout, stderr: normalizeBunSnapshot(stderr, String(dir)), exitCode }; - }; - expect(await run("--init")).toEqual({ stdout: "Configuration file created\n", stderr: "", exitCode: 0 }); - expect(JSON.parse(readFileSync(join(String(dir), ".oxlintrc.json"), "utf8"))).toEqual({ - $schema: "./node_modules/oxlint/configuration_schema.json", - plugins: ["typescript", "unicorn", "oxc"], - categories: { correctness: "error" }, - rules: {}, - env: { builtin: true }, - }); - const again = await run("--init"); - expect(normalizeBunSnapshot(again.stdout, String(dir))).toContain("/.oxlintrc.json exists already."); - expect(again.exitCode).toBe(1); - // It is one that can be used. - expect((await run("--allow-unsupported", "a.js")).exitCode).toBe(1); - }); - - test.each(["eslint.config.mjs", ".eslintrc.json"])( - "--init beside %s writes nothing unless --flavor=oxlint", - async name => { - using dir = tempDir("bun-lint-init", { - [name]: name.endsWith(".json") ? rc(noVar) : "export default [];", - "sub/a.js": code, - }); - const run = async (...args: string[]) => { - await using proc = spawn({ - cmd: [...command, ...args], - env, - cwd: join(String(dir), "sub"), - stderr: "pipe", - }); - const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); - return { stderr: normalizeBunSnapshot(stderr, String(dir)), exitCode }; - }; - const refused = await run("--init"); - expect(refused.stderr).toContain(`which would count in place of /${name}. Use --flavor=oxlint to write it.`); - expect(refused.exitCode).toBe(2); - expect(existsSync(join(String(dir), "sub", ".oxlintrc.json"))).toBe(false); - expect((await run("--init", "--flavor=oxlint")).exitCode).toBe(0); - expect(existsSync(join(String(dir), "sub", ".oxlintrc.json"))).toBe(true); - }, - ); - test("node_modules is a directory like any other, which a .gitignore has", async () => { const files = { ".oxlintrc.json": oxlintrc, @@ -3950,9 +5589,9 @@ describe.concurrent("what the configuration asks for and cannot be done", () => test("a rule that does not exist: the rest is linted, and the run fails", async () => { const { problems, stderr, exitCode } = await lint(files); expect(problems).toEqual(["a.js:1:1 no-var"]); - expect(stderr).toContain("2 rules of ESLint did not run: require-jsdoc, valid-jsdoc"); + expect(stderr).toContain("2 rules in your .eslintrc config are not supported: require-jsdoc, valid-jsdoc"); expect(stderr).not.toContain("no-rule-that-is-off"); - expect(stderr).toContain("--allow-unsupported makes this a warning."); + expect(stderr).toContain("\n Everything else was linted. Use --allow-unsupported to make this a warning."); expect(exitCode).toBe(2); }); @@ -3965,11 +5604,19 @@ describe.concurrent("what the configuration asks for and cannot be done", () => test("--allow-unsupported makes it a warning", async () => { const { problems, stderr, exitCode } = await lint(files, ["--allow-unsupported", "."]); expect(problems).toEqual(["a.js:1:1 no-var"]); - expect(stderr).toContain("warn: 2 rules of ESLint did not run: require-jsdoc, valid-jsdoc"); + expect(stderr).toContain("warn: 2 rules in your .eslintrc config are not supported: require-jsdoc, valid-jsdoc"); expect(exitCode).toBe(1); expect((await lint({ ...files, "a.js": "let x = 1;\nx;\n" }, ["--allow-unsupported", "."])).exitCode).toBe(0); }); + test("without a config file, a comment that turns on a rule that isn't built in is reported as a problem", async () => { + const files = { "a.js": "/* eslint promise/not-there: error, jest/nope: error */\nexport {};\n" }; + const { problems, stderr, exitCode } = await lint(files); + expect(problems).toEqual(["a.js:1:1 jest/nope", "a.js:1:1 promise/not-there"]); + expect(stderr).not.toContain("did not run"); + expect(exitCode).toBe(1); + }); + test("files in a language that is not read here", async () => { const files = { "eslint.config.mjs": `export default [ @@ -3985,14 +5632,14 @@ describe.concurrent("what the configuration asks for and cannot be done", () => const { problems, stderr, exitCode } = await lint(files); expect(problems).toEqual(["a.js:1:1 no-var"]); expect(stderr).toContain( - "4 files were not linted, only JavaScript and TypeScript can be (3 *.one, 1 *.two): b.one, c.one, d.one, ..", + "4 files were not linted because bun lint only supports JavaScript and TypeScript (3 *.one, 1 *.two): b.one, c.one, d.one, ..", ); expect(exitCode).toBe(2); expect((await lint(files, ["--allow-unsupported", "."])).exitCode).toBe(1); }); }); -// ESLint has deprecated its rules about layout, and eslint-plugin-react its `jsx-space-before-closing`. They are not built in: `bun format` +// ESLint has deprecated its rules about layout, and eslint-plugin-react its `jsx-space-before-closing`. They are not built in: `bun fmt` // does their work. The configurations that people share still turn them on. describe.concurrent("the deprecated rules about layout", () => { const rules = { @@ -4010,8 +5657,7 @@ describe.concurrent("the deprecated rules about layout", () => { ]; const forPeople = ["-f", "stylish", "."]; const notes = (stderr: string) => stderr.split("\n").filter(it => it.startsWith("note: ")); - const note = (count: number, names: string) => - `note: ${count} formatting rules that are deprecated did not run, bun format does their work: ${names}`; + const note = (count: number, names: string) => `note: ${count} deprecated formatting rules did not run: ${names}`; test.each(flavors)("%s: they do not run, which is a note and no error", async (name, config) => { const files = { [name]: config(rules), "a.js": untidy }; @@ -4045,9 +5691,7 @@ describe.concurrent("the deprecated rules about layout", () => { test("one alone", async () => { const { stderr } = await lint({ "eslint.config.mjs": flavors[0][1]({ semi: 2 }), "a.js": "a" }, forPeople); - expect(notes(stderr)).toEqual([ - "note: 1 formatting rule that is deprecated did not run, bun format does its work: semi", - ]); + expect(notes(stderr)).toEqual(["note: 1 deprecated formatting rule did not run: semi"]); }); // As after eslint-config-prettier. @@ -4256,7 +5900,7 @@ describe.concurrent("the deprecated rules about layout", () => { expect(builtIn.problems).toEqual([]); expect(builtIn.exitCode).toBe(0); expect(notes(shown.stderr)).toEqual([ - "note: 1 formatting rule that is deprecated did not run, bun format does its work: react/jsx-space-before-closing", + "note: 1 deprecated formatting rule did not run: react/jsx-space-before-closing", ]); expect(ofThePackage.problems).toEqual(["a.js:1:1 react/jsx-space-before-closing"]); }); @@ -4271,7 +5915,7 @@ describe.concurrent("the deprecated rules about layout", () => { }; const { problems, stderr, exitCode } = await lint(files, ["a.js"]); expect(problems).toEqual(["a.js:1:1 no-debugger"]); - expect(stderr).toContain("2 rules of ESLint did not run: semi, quotes"); + expect(stderr).toContain("2 rules in .oxlintrc.json are not supported: semi, quotes"); expect(stderr).not.toContain("formatting"); expect(exitCode).toBe(2); }); @@ -4288,16 +5932,16 @@ describe.concurrent("the plugins jsdoc and vue of oxlint, of which no rule is bu const clean = { "a.js": "export {};\n", "b.vue": "\n" }; const quiet = { categories: { correctness: "off" }, rules: { "no-debugger": "warn" } }; const named = (plugin: string, ...rules: string[]) => - `${rules.length} rule${rules.length > 1 ? "s" : ""} of the plugin "${plugin}" did not run: ${rules.join(", ")}. None is built in: turn them off, or take them out of the configuration`; + `${rules.length} ${rules.length > 1 ? "rules" : "rule"} in .oxlintrc.json ${rules.length > 1 ? "are" : "is"} not supported: ${rules.join(", ")}`; const ofCategories = (...plugins: string[]) => - `note: The rules of the plugin${plugins.length > 1 ? "s" : ""} "${plugins.join('" and "')}" that the categories turn on did not run. None is built in: take ${plugins.length > 1 ? "them" : "it"} out of "plugins"`; + `note: bun lint has no "${plugins.join('" or "')}" rules, so none of them ran`; const silent = { problems: found, exitCode: 0, said: [], notes: [] }; /** What is reported, the exit code, what is said about what cannot be done, and the notes. */ async function run(config: object, flags: string[] = [], more: Record = {}) { const all = { ...files, ...more, ".oxlintrc.json": JSON.stringify(config) }; const { problems, stderr, exitCode } = await lint(all, [...flags, "."]); const lines = stderr.split("\n"); - const said = lines.slice(lines.findIndex(it => it.includes("cannot be done yet")) + 1 || lines.length, -1); + const said = lines.filter(it => it.startsWith("error: ")).map(it => it.slice("error: ".length)); const notes = lines.filter(it => it.startsWith("note: ")); return { problems, exitCode, said: said.map(it => it.trim()), notes }; } @@ -4306,8 +5950,7 @@ describe.concurrent("the plugins jsdoc and vue of oxlint, of which no rule is bu const rules = { ...quiet.rules, "jsdoc/require-param": "error", - // Nothing looks at the options. - "vue/no-dupe-keys": ["warn", { nothing: 1 }], + "vue/no-dupe-keys": ["warn", { groups: ["a"] }], "vue/max-props": "deny", }; expect(await run({ ...quiet, plugins: ["jsdoc", "vue"], rules })).toEqual({ @@ -4320,7 +5963,9 @@ describe.concurrent("the plugins jsdoc and vue of oxlint, of which no rule is bu { ...files, ".oxlintrc.json": JSON.stringify({ ...quiet, plugins: ["jsdoc", "vue"], rules }) }, ["--allow-unsupported", "."], ); - expect(allowed.stderr).toContain(`warn: ${named("jsdoc", "jsdoc/require-param")}`); + expect(allowed.stderr).toContain( + `warn: ${named("jsdoc", "jsdoc/require-param")}\n bun lint has no "jsdoc" rules. Turn it off or remove it.\n`, + ); expect(allowed.exitCode).toBe(0); }); @@ -4438,8 +6083,10 @@ describe.concurrent("the plugins jsdoc and vue of oxlint, of which no rule is bu expect(exitCode).toBe(1); }); - test("a comment that disables one of their rules is neither wrong nor unused, and stays", async () => { + test("a comment that disables one of their rules is neither wrong nor unused, and stays, if such a rule is on", async () => { const text = [ + // oxlint's jsdoc rules look at comments like this one. Without any, none of them could have reported anything. + "/** A function. */", "// oxlint-disable-next-line jsdoc/require-param", "a;", "// eslint-disable-next-line vue/no-dupe-keys, no-debugger", @@ -4447,9 +6094,10 @@ describe.concurrent("the plugins jsdoc and vue of oxlint, of which no rule is bu "/* oxlint-disable vue/max-props */", "", ].join("\n"); - const all = { "a.js": text, "b.vue": `\n`, ".oxlintrc.json": JSON.stringify(quiet) }; + const on = { ...quiet, plugins: ["jsdoc", "vue"], rules: { ...quiet.rules, "jsdoc/require-param": "warn" } }; + const all = { "a.js": text, "b.vue": `\n`, ".oxlintrc.json": JSON.stringify(on) }; const { problems, raw } = await lint(all, ["--report-unused-disable-directives", "."]); - expect(problems).toEqual(["a.js:3:47 -", "b.vue:4:47 -"]); + expect(problems).toEqual(["a.js:4:47 -", "b.vue:5:47 -"]); expect(JSON.parse(raw).results[0].messages[0].message).toBe( "Unused eslint-disable directive (no problems were reported from no-debugger).", ); @@ -4458,6 +6106,40 @@ describe.concurrent("the plugins jsdoc and vue of oxlint, of which no rule is bu expect(JSON.parse(fixed.raw).results[0].output).toBe(`\n`); }); + // What oxlint 1.87 reports: each of these comments, whole. Its jsdoc rules only look at `/**` comments, so in a file without one + // none of them was skipped, and a comment that silences nothing here silences nothing there. + test("without a /** comment, comments are unused as for oxlint although rules of jsdoc are on", async () => { + const text = [ + "// oxlint-disable-next-line jsdoc/require-param", + "a;", + "// eslint-disable-next-line vue/no-dupe-keys, no-debugger", + "a;", + "// oxlint-disable-next-line", + "a;", + "// oxlint-disable-next-line foo/no-debugger, no-dupe-keys", + "a;", + "/* oxlint-disable vue/max-props */", + "", + ].join("\n"); + const on = { ...quiet, plugins: ["jsdoc", "vue"], rules: { ...quiet.rules, "jsdoc/require-param": "warn" } }; + const all = { "a.js": text, "b.vue": `\n`, ".oxlintrc.json": JSON.stringify(on) }; + const { problems } = await lint(all, ["--report-unused-disable-directives", "."]); + expect(problems).toEqual([1, 3, 5, 7, 9].flatMap(line => [`a.js:${line}:1 -`, `b.vue:${line + 1}:1 -`]).sort()); + // oxlint reports the same with these. Here nobody knows what their rules would have reported, and a comment that is called + // unused is one that --fix-suggestions removes. So only a name that none of their rules has is reported. + const withBlock = { ...all, "a.js": `/** A function. */\n${text}`, "b.vue": "" }; + const withVue = { + ...all, + "b.vue": "", + ".oxlintrc.json": JSON.stringify({ ...on, rules: { "vue/max-props": "warn" } }), + }; + const [block, vue] = await Promise.all( + [withBlock, withVue].map(files => lint(files, ["--report-unused-disable-directives", "a.js"])), + ); + expect(block.problems).toEqual(["a.js:4:47 -", "a.js:8:29 -"]); + expect(vue.problems).toEqual(["a.js:3:47 -", "a.js:7:29 -"]); + }); + test("--rules does not list them", async () => { const { raw } = await lint({ ".oxlintrc.json": "{}" }, ["--rules", "-f", "json"]); const scopes = new Set(JSON.parse(raw).map((it: { scope: string }) => it.scope)); @@ -4919,7 +6601,7 @@ describe.concurrent("a search that leaves the project of an eslint.config.js", ( ["..", { links: { "linked": "theirs" } }, "linked"], ])("a file without a configuration file ends the run: %s %j", async (argument, more, file) => { const { ran, files, stderr, exitCode } = await run("mine", [argument], more); - expect(stderr).toContain(`Could not find config file. /w/${file} has no eslint.config.* `); + expect(stderr).toContain(`Could not find config file.\n No eslint.config.* was found next to /w/${file} or`); expect(ran.filter(it => !it.startsWith("mine"))).toEqual([]); expect({ files, exitCode }).toEqual({ files: [], exitCode: 2 }); }); @@ -5211,7 +6893,7 @@ describe("an infinite number in an eslint.config.mjs", () => { }; const { raw, stderr, exitCode } = await lint(files, ["a.js"]); if (typeof expected === "string") { - expect(stderr).toBe(`error: ${expected}`); + expect(stderr).toBe(`error: Invalid config in eslint.config.mjs\n${expected}`); expect(exitCode).toBe(2); return; } @@ -5273,7 +6955,7 @@ describe("an infinite number in an eslint.config.mjs", () => { }; const { raw, stderr, exitCode } = await lint(files, ["a.js"]); if (typeof expected === "string") { - expect(stderr).toBe(`error: ${expected}`); + expect(stderr).toBe(`error: Invalid config in eslint.config.mjs\n${expected}`); expect(exitCode).toBe(2); return; } diff --git a/test/cli/lint/defaults.test.ts b/test/cli/lint/defaults.test.ts new file mode 100644 index 000000000000..f8c2a585449c --- /dev/null +++ b/test/cli/lint/defaults.test.ts @@ -0,0 +1,1558 @@ +// Tests for what `bun lint` does in a project without an ESLint or oxlint config. +import { afterAll, describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { endChildren, spawn } from "../children"; + +afterAll(endChildren); + +const env = { + ...bunEnv, + AGENT: "0", + CLAUDECODE: undefined, + REPL_ID: undefined, + GITHUB_ACTIONS: undefined, + NO_COLOR: "1", +}; + +type Result = { filePath: string; messages: { line: number; column: number; severity: number; ruleId: string }[] }; + +/** `reports` has one `a.js:1:1 error no-debugger` string per problem. `texts` has the files' contents after the run. */ +async function lint(files: Record, args: string[], stdin?: string, cwd = ".") { + using dir = tempDir("bun-lint-defaults", files); + await using proc = spawn({ + cmd: [bunExe(), "lint", "-f", "json", ...args], + env, + cwd: join(String(dir), cwd), + stdin: stdin === undefined ? "ignore" : Buffer.from(stdin), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const json = JSON.parse(stdout || "null"); + const nameOf = (path: string) => normalizeBunSnapshot(path, String(dir)).slice("/".length); + const reports = (Array.isArray(json) ? (json as Result[]) : []).flatMap(({ filePath, messages }) => + messages.map( + it => `${nameOf(filePath)}:${it.line}:${it.column} ${it.severity === 2 ? "error" : "warning"} ${it.ruleId}`, + ), + ); + const texts = Object.fromEntries( + Object.keys(files).map(name => [name, readFileSync(join(String(dir), name), "utf8")]), + ); + return { json, reports, texts, stderr, exitCode }; +} + +describe.concurrent("globals", () => { + test("every global that Bun has is defined", async () => { + // `bun -e` also makes `fs`, `path` and other built-in modules global, so ask a file. + using dir = tempDir("bun-lint-defaults-globals", { + "names.js": "console.log(JSON.stringify(Object.getOwnPropertyNames(globalThis)));", + }); + await using proc = spawn({ cmd: [bunExe(), "names.js"], env, cwd: String(dir), stdout: "pipe", stderr: "inherit" }); + const names = (JSON.parse(await proc.stdout.text()) as string[]).filter(name => /^[A-Za-z_$][\w$]*$/.test(name)); + expect(names).toContain("HTMLRewriter"); + const { reports } = await lint({ "a.js": `console.log(\n${names.join(",\n")},\n);\n` }, []); + expect(reports).toEqual([]); + }); + + const tests = ` + describe("a", () => { + before(() => {}); + beforeAll(() => {}); + beforeEach(() => {}); + it("b", () => expect(1).toBe(1)); + test("c", () => expectTypeOf(jest.fn()).toBeFunction()); + fit("d", () => vi.fn()); + xit("e", () => {}); + afterEach(() => {}); + afterAll(() => {}); + after(() => {}); + }); + fdescribe("f", () => {}); + xdescribe("g", () => {}); + `; + + test.each([ + "a.test.js", + "a.spec.mjs", + "a_test.cjs", + "a_spec.jsx", + "__tests__/a.js", + "src/__tests__/deep/a.js", + "test/a.js", + "packages/x/test/a.mjs", + "tests/a.js", + "spec/a.js", + "a-test.js", + "a.e2e.js", + "e2e/a.js", + "__mocks__/fs.js", + "jest.setup.js", + "jest-setup.cjs", + "vitest.setup.mjs", + "src/setupTests.js", + "config/setup-jest.js", + "testSetup.js", + ])("the test runners' globals are defined in %s", async name => { + const { reports, exitCode } = await lint({ [name]: tests }, []); + expect(reports).toEqual([]); + expect(exitCode).toBe(0); + }); + + test.each(["cypress/e2e/login.cy.js", "cypress/support/commands.js", "src/login.cy.js"])( + "Cypress's globals are defined in %s", + async name => { + const code = 'describe("a", () => it("b", () => cy.get("a").then(() => assert.ok(expect(Cypress.env("c"))))));\n'; + const { reports, exitCode } = await lint({ [name]: code }, []); + expect(reports).toEqual([]); + expect(exitCode).toBe(0); + }, + ); + + test.each([ + "a.js", + "src/a.js", + "testing/a.js", + "latest/a.js", + "src/test.js", + "setup.js", + "src/contest.js", + "src/cy.js", + ])("the test runners' globals are not defined in %s", async name => { + const { reports, exitCode } = await lint({ [name]: 'describe("a", () => {});\n' }, []); + expect(reports).toEqual([`${name}:1:1 error no-undef`]); + expect(exitCode).toBe(1); + }); + + // Which names `**/*{.,_,-}{test,spec,e2e,cy}.{js,mjs,cjs,jsx,ts,mts,cts,tsx}` is for. A global of a test runner can't be + // assigned to. In another file nothing defines it, which is only reported in JavaScript. + test("a file is a test by what its name ends with", async () => { + const [stems, separators] = [ + ["a", "", "a.b", "test"], + [".", "_", "-", "", "+"], + ]; + const words = ["test", "spec", "e2e", "cy", "tes", "tests", "Test", "atest"]; + const extensions = ["js", "mjs", "cjs", "jsx", "ts", "mts", "cts", "tsx"]; + // To Windows and macOS `a.Test.js` is the file `a.test.js`. + const folderOf = (word: string) => (word === "Test" ? "cased/" : ""); + const names = stems.flatMap(stem => + separators.flatMap(separator => + words.flatMap(word => extensions.map(extension => `${folderOf(word)}${stem}${separator}${word}.${extension}`)), + ), + ); + names.push("a.test.js.js", "a.test.jsx.ts", "a.test.d.ts", "a.test..js", "a.spec.ts.mts"); + names.push("src/deep/a_e2e.cts", "src/a.cy/b.js", "src/a.test.js/b.ts"); + const expected = [...new Set(names)].flatMap(name => { + if (/[._-](test|spec|e2e|cy)\.(js|mjs|cjs|jsx|ts|mts|cts|tsx)$/.test(name)) return [`${name} no-global-assign`]; + return /\.[mc]?jsx?$/.test(name) ? [`${name} no-undef`] : []; + }); + const { reports } = await lint(Object.fromEntries(names.map(name => [name, "describe = 1;\n"])), []); + expect(reports.map(it => it.replace(/:1:1 \w+ /, " ")).sort()).toEqual(expected.sort()); + }); + + test("an eslint-env comment defines its globals, and is only an error with an ESLint config", async () => { + const files = { "a.js": '/* eslint-env mocha, nonsense */\ndescribe("a", () => {});\njQuery("a");\n' }; + const config = { "eslint.config.mjs": `export default [{ rules: { "no-undef": "error" } }];\n` }; + const [native, eslint] = await Promise.all([lint(files, []), lint({ ...files, ...config }, ["a.js"])]); + expect(native.reports).toEqual(["a.js:3:1 error no-undef"]); + expect(eslint.reports).toEqual(["a.js:1:1 error null", "a.js:2:1 error no-undef", "a.js:3:1 error no-undef"]); + }); + + test("a service worker's globals are defined", async () => { + const code = `self.addEventListener("install", event => event.waitUntil(skipWaiting())); +self.addEventListener("activate", () => clients.claim()); +self.addEventListener("push", event => registration.showNotification(event.data.text())); +importScripts("./lib.js"); +skipWaitng(); +`; + const { reports } = await lint({ "public/sw.js": code }, []); + expect(reports).toEqual(["public/sw.js:5:1 error no-undef"]); + }); + + test.each(["test", "tests", "spec", "__tests__"])( + "a project inside a folder named %s isn't all tests", + async folder => { + const files = { + [`${folder}/app/package.json`]: "{}", + [`${folder}/app/src/a.js`]: 'describe("a", () => {});\n', + [`${folder}/app/test/b.js`]: 'describe("b", () => {});\n', + }; + for (const cwd of [`${folder}/app`, `${folder}/app/src`]) { + const { reports } = await lint(files, [], undefined, cwd); + expect({ cwd, reports }).toEqual({ cwd, reports: [`${folder}/app/src/a.js:1:1 error no-undef`] }); + } + }, + ); + + test("in a repository, a test folder with its own package.json gives the same result from every directory", async () => { + const files = { + ".git/HEAD": "ref: refs/heads/main\n", + "package.json": "{}", + "src/a.js": 'describe("a", () => {});\n', + "test/package.json": "{}", + "test/b.js": 'describe("b", () => {});\n', + "test/unit/c.js": 'describe("c", () => {});\n', + }; + for (const cwd of ["test", "test/unit"]) { + const { reports, exitCode } = await lint(files, [], undefined, cwd); + expect({ cwd, reports, exitCode }).toEqual({ cwd, reports: [], exitCode: 0 }); + } + expect((await lint(files, [])).reports).toEqual(["src/a.js:1:1 error no-undef"]); + }); + + test("a repository inside a folder named test isn't all tests, with or without a package.json", async () => { + for (const extra of [{}, { "test/app/package.json": "{}" }]) { + const files = { + "test/app/.git/HEAD": "ref: refs/heads/main\n", + "test/app/src/a.js": 'describe("a", () => {});\n', + "test/app/test/b.js": 'describe("b", () => {});\n', + ...extra, + }; + for (const cwd of ["test/app", "test/app/src"]) { + const { reports } = await lint(files, [], undefined, cwd); + expect({ cwd, reports }).toEqual({ cwd, reports: ["test/app/src/a.js:1:1 error no-undef"] }); + } + } + }); + + test("the test folders count from the closest package.json, also when run from inside one", async () => { + const files = { + "package.json": "{}", + "test/a.js": 'describe("a", () => {});\n', + "test/unit/b.js": 'describe("b", () => {});\n', + "packages/x/package.json": "{}", + "packages/x/test/c.js": 'describe("c", () => {});\n', + }; + for (const cwd of [".", "test", "test/unit", "packages/x", "packages/x/test"]) { + const { reports, exitCode } = await lint(files, [], undefined, cwd); + expect({ cwd, reports, exitCode }).toEqual({ cwd, reports: [], exitCode: 0 }); + } + }); +}); + +describe.concurrent("severities", () => { + test("code that's often written on purpose is a warning, not an error", async () => { + const { reports, exitCode } = await lint( + { + "a.js": `export const strip = text => text.replace(/\\x1b\\[[0-9;]*m/g, ""); +export function words(text, re, object) { + let match; + while (match = re.exec(text)) console.log(match[0]); + try { + return object.hasOwnProperty(text); + } catch (error) { + error = new Error(String(error)); + throw error; + } +} +`, + "b.ts": `export type Keep = T extends Function ? T : never; +// @ts-ignore +export const name: String = "a"; +export enum Level { Off = 0, None = 0 } +export interface Emitter { on(): void } +export class Emitter {} +export const text = document.querySelector("h1")?.textContent!; +`, + "c.test.ts": `import { expect, test } from "bun:test"; +export const fixture = 1; +test("a", () => { + expect(fixture).toBe(1); +}); +`, + }, + [], + ); + expect(reports).toEqual([ + "a.js:1:43 warning no-control-regex", + "a.js:4:10 warning no-cond-assign", + "a.js:6:19 warning no-prototype-builtins", + "a.js:8:5 warning no-ex-assign", + "b.ts:1:33 warning @typescript-eslint/no-unsafe-function-type", + "b.ts:2:1 warning @typescript-eslint/ban-ts-comment", + "b.ts:3:20 warning @typescript-eslint/no-wrapper-object-types", + "b.ts:4:30 warning @typescript-eslint/no-duplicate-enum-values", + "b.ts:5:18 warning @typescript-eslint/no-unsafe-declaration-merging", + "b.ts:6:14 warning @typescript-eslint/no-unsafe-declaration-merging", + "b.ts:7:21 warning @typescript-eslint/no-non-null-asserted-optional-chain", + "c.test.ts:2:14 warning bun:test/no-export", + ]); + expect(exitCode).toBe(0); + }); + + test("require() in a TypeScript file isn't reported", async () => { + const code = `import path = require("node:path"); +export default { path, plugins: [require("tailwindcss-animate")] }; +`; + const on = `[lint.rules]\n"@typescript-eslint/no-require-imports" = "error"\n`; + const [plain, turnedOn] = await Promise.all([ + lint({ "tailwind.config.ts": code }, []), + lint({ "tailwind.config.ts": code, "bunfig.toml": on }, []), + ]); + expect(plain.reports).toEqual([]); + expect(turnedOn.reports).toEqual([ + "tailwind.config.ts:1:15 error @typescript-eslint/no-require-imports", + "tailwind.config.ts:2:34 error @typescript-eslint/no-require-imports", + ]); + }); + + const files = { + "a.js": "debugger;\nif (Bun) {\n}\nlet unused;\n", + "b.ts": "export let a: any = 1;\nexport interface B {\n constructor(): void;\n}\n", + }; + + test("default rules that catch bugs are errors, and the rest are warnings", async () => { + const { reports, exitCode } = await lint(files, []); + expect(reports).toEqual([ + "a.js:1:1 error no-debugger", + "a.js:2:10 warning no-empty", + "a.js:4:5 warning no-unused-vars", + "b.ts:1:12 warning prefer-const", + "b.ts:1:15 warning @typescript-eslint/no-explicit-any", + "b.ts:3:3 error @typescript-eslint/no-misused-new", + ]); + expect(exitCode).toBe(1); + }); + + test("the rules that --type-aware adds are split the same way", async () => { + const { reports, exitCode } = await lint( + { + "tsconfig.json": "{}", + "a.ts": "export async function f() {\n return 1;\n}\nf();\nawait 1;\n", + }, + ["--type-aware"], + ); + expect(reports).toEqual([ + "a.ts:1:8 warning @typescript-eslint/require-await", + "a.ts:4:1 error @typescript-eslint/no-floating-promises", + "a.ts:5:1 warning @typescript-eslint/await-thenable", + ]); + expect(exitCode).toBe(1); + }); + + test("with --type-aware, using a value of type any is a warning", async () => { + const { reports, exitCode } = await lint( + { + "tsconfig.json": `{ "compilerOptions": { "strict": true, "lib": ["esnext", "dom"], "types": [] } }`, + "a.ts": `export async function load(url: string, log: (text: string) => void) { + const body = await (await fetch(url)).json(); + log(body); + body.run(); + return body; +} +`, + }, + ["--type-aware"], + ); + expect(reports).toEqual([ + "a.ts:2:9 warning @typescript-eslint/no-unsafe-assignment", + "a.ts:3:7 warning @typescript-eslint/no-unsafe-argument", + "a.ts:4:3 warning @typescript-eslint/no-unsafe-call", + "a.ts:4:8 warning @typescript-eslint/no-unsafe-member-access", + "a.ts:5:3 warning @typescript-eslint/no-unsafe-return", + ]); + expect(exitCode).toBe(0); + }); + + test("warnings don't fail the run unless --max-warnings is exceeded, and --quiet hides them", async () => { + const warned = { "a.js": files["a.js"].replace("debugger;", "") }; + const [plain, limited, quiet] = await Promise.all([ + lint(warned, []), + lint(warned, ["--max-warnings", "0"]), + lint(files, ["--quiet"]), + ]); + expect(plain.reports).toEqual(["a.js:2:10 warning no-empty", "a.js:4:5 warning no-unused-vars"]); + expect(limited.reports).toEqual(plain.reports); + expect(limited.stderr).toContain("Found too many warnings (maximum: 0)."); + expect(quiet.reports).toEqual(["a.js:1:1 error no-debugger", "b.ts:3:3 error @typescript-eslint/no-misused-new"]); + expect([plain.exitCode, limited.exitCode, quiet.exitCode]).toEqual([0, 1, 1]); + }); + + test("severities from bunfig.toml and --rule are kept as written", async () => { + const bunfig = `[lint.rules]\n"no-empty" = "error"\n"no-debugger" = "warn"\n"no-var" = "error"\n`; + const [byFile, byFlag] = await Promise.all([ + lint({ ...files, "bunfig.toml": bunfig, "c.js": "var c = 1;\nexport { c };\n" }, []), + lint(files, ["--rule", "prefer-const: error", "--rule", "no-debugger: off"]), + ]); + expect(byFile.reports).toEqual([ + "a.js:1:1 warning no-debugger", + "a.js:2:10 error no-empty", + "a.js:4:5 warning no-unused-vars", + "b.ts:1:12 warning prefer-const", + "b.ts:1:15 warning @typescript-eslint/no-explicit-any", + "b.ts:3:3 error @typescript-eslint/no-misused-new", + "c.js:1:1 error no-var", + ]); + expect(byFlag.reports).toEqual([ + "a.js:2:10 warning no-empty", + "a.js:4:5 warning no-unused-vars", + "b.ts:1:12 error prefer-const", + "b.ts:1:15 warning @typescript-eslint/no-explicit-any", + "b.ts:3:3 error @typescript-eslint/no-misused-new", + ]); + }); + + test("the same presets are all errors in an eslint.config.js", async () => { + const config = `export default [ + { files: ["**/*.{js,ts}"], extends: ["eslint:recommended"], languageOptions: { globals: { Bun: "readonly" } } }, + { files: ["**/*.ts"], extends: ["typescript-eslint/recommended"] }, + ];\n`; + const { reports } = await lint({ ...files, "eslint.config.js": config }, ["a.js", "b.ts"]); + expect(reports).toEqual([ + "a.js:1:1 error no-debugger", + "a.js:2:10 error no-empty", + "a.js:4:5 error no-unused-vars", + "b.ts:1:12 error prefer-const", + "b.ts:1:15 error @typescript-eslint/no-explicit-any", + "b.ts:3:3 error @typescript-eslint/no-misused-new", + ]); + }); + + test("bun:test rules that catch a broken test are errors", async () => { + const code = `import { expect, test } from "bun:test";\ntest.skip("a", () => {});\ntest.only("b", () => {\n expect(1);\n});\n`; + const { reports } = await lint({ "a.test.ts": code }, []); + expect(reports).toEqual([ + "a.test.ts:2:1 warning bun:test/no-disabled-tests", + "a.test.ts:3:6 error bun:test/no-focused-tests", + "a.test.ts:4:3 error bun:test/valid-expect", + ]); + }); + + test("--print-config shows the default severities and options", async () => { + const { json } = await lint(files, ["--print-config", "b.ts"]); + const { rules } = json; + const unusedOnPurpose = { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + destructuredArrayIgnorePattern: "^_", + ignoreRestSiblings: true, + }; + expect({ + "no-debugger": rules["no-debugger"], + "no-empty": rules["no-empty"], + "prefer-const": rules["prefer-const"], + "@typescript-eslint/no-explicit-any": rules["@typescript-eslint/no-explicit-any"], + "@typescript-eslint/no-unused-vars": rules["@typescript-eslint/no-unused-vars"], + "no-unused-vars": rules["no-unused-vars"], + }).toEqual({ + "no-debugger": [2], + "no-empty": [1], + "prefer-const": [1], + "@typescript-eslint/no-explicit-any": [1], + "@typescript-eslint/no-unused-vars": [1, unusedOnPurpose], + "no-unused-vars": [0, unusedOnPurpose], + }); + }); +}); + +describe.concurrent("defaults that differ from ESLint's", () => { + /** Returns the reports of `rules`, with no config and with the same rules turned on in an eslint.config.mjs. */ + async function inBothModes(files: Record, rules: string[], args: string[] = []) { + const on = JSON.stringify(Object.fromEntries(rules.map(rule => [rule, "error"]))); + const config = `const plugin = name => ({ meta: { name: "eslint-plugin-" + name }, rules: {} }); +export default [ + { plugins: { "react-hooks": plugin("react-hooks"), react: plugin("react") } }, + { files: ["**/*.{js,jsx}"], languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } } }, + { + files: ["**/*.{ts,tsx}"], + extends: ["typescript-eslint/base"], + languageOptions: { parserOptions: { projectService: true } }, + }, + { rules: ${on} }, +]; +`; + const names = Object.keys(files).filter(name => /\.[jt]sx?$/.test(name)); + const [native, eslint] = await Promise.all([ + lint(files, args), + lint({ ...files, "eslint.config.mjs": config }, names), + ]); + const own = (reports: string[]) => reports.filter(it => rules.some(rule => it.endsWith(` ${rule}`))); + return { native: own(native.reports), eslint: own(eslint.reports) }; + } + + test("no-irregular-whitespace checks code, not text", async () => { + const files = { + "a.jsx": `// At most 10\u00a0kg, コメント\u3000全角 +export const price =

Prix\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(): Promise; +export const form =
; +[1].forEach(save); +if (save()) save().catch(console.error); +`, + }; + const rule = "@typescript-eslint/no-misused-promises"; + const { native, eslint } = await inBothModes(files, [rule], ["--type-aware"]); + expect(native).toEqual([`a.tsx:10:13 error ${rule}`, `a.tsx:11:5 error ${rule}`]); + expect(eslint).toEqual([`a.tsx:9:36 error ${rule}`, `a.tsx:10:13 error ${rule}`, `a.tsx:11:5 error ${rule}`]); + }); + + test("no-fallthrough accepts the usual ways to write the comment", async () => { + const comments = [ + "// falls through", + "/* intentional fall-through */", + "// FALLTHROUGH", + "// no break", + "// and the next", + ]; + const cases = comments.map((comment, i) => ` case ${i}:\n console.log(${i});\n ${comment}\n`); + const files = { "a.js": `export function f(a) {\n switch (a) {\n${cases.join("")} default:\n }\n}\n` }; + const { native, eslint } = await inBothModes(files, ["no-fallthrough"]); + expect(native).toEqual(["a.js:18:5 error no-fallthrough"]); + expect(eslint).toEqual([9, 15, 18].map(line => `a.js:${line}:5 error no-fallthrough`)); + }); + + test("a namespace inside declare global is allowed", async () => { + const files = { + "a.ts": `declare global { + namespace NodeJS { + interface ProcessEnv { + API_URL: string; + } + } +} +export namespace Shapes { + export const unit = 1; +} +`, + }; + const rule = "@typescript-eslint/no-namespace"; + const { native, eslint } = await inBothModes(files, [rule]); + expect(native).toEqual([`a.ts:8:8 warning ${rule}`]); + expect(eslint).toEqual([`a.ts:2:3 error ${rule}`, `a.ts:8:8 error ${rule}`]); + }); + + const react = { "package.json": JSON.stringify({ dependencies: { react: "^19.0.0" } }) }; + + test("a parameter named use isn't React's hook", async () => { + const files = { + ...react, + "e2e/fixtures.ts": `import { test as base } from "@playwright/test"; +export const test = base.extend<{ user: string }>({ + user: async ({}, use) => { + await use("alice"); + }, +}); +`, + "src/imported.ts": `import { use } from "react"; +export function load(promise: Promise) { + return use(promise); +} +`, + "src/global.js": `export function load(promise) { + return use(promise); +} +`, + }; + const rule = "react-hooks/rules-of-hooks"; + const { native, eslint } = await inBothModes(files, [rule]); + const real = [`src/global.js:2:10 error ${rule}`, `src/imported.ts:3:10 error ${rule}`]; + expect(native).toEqual(real); + expect(eslint).toEqual([`e2e/fixtures.ts:4:11 error ${rule}`, ...real]); + }); + + test("a story's render function can call hooks", async () => { + const files = { + ...react, + "src/Button.stories.tsx": `import { useState } from "react"; +export const Controlled = { + render: (args: object) => { + const [on] = useState(false); + return