diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 4441020677cbe..daad3ec7d12f8 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -19,7 +19,6 @@ on: - "Cargo.lock" - "rust-toolchain.toml" - ".github/workflows/benchmark.yml" - - "tasks/benchmark/codspeed/*.mjs" push: branches: - main @@ -30,7 +29,6 @@ on: - "Cargo.lock" - "rust-toolchain.toml" - ".github/workflows/benchmark.yml" - - "tasks/benchmark/codspeed/*.mjs" concurrency: group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.event.pull_request.number || github.sha }} diff --git a/.ignore b/.ignore index 0be9b377cc423..1a0fbe77b8e8b 100644 --- a/.ignore +++ b/.ignore @@ -7,6 +7,7 @@ tasks/coverage/test262/** tasks/coverage/babel/** tasks/coverage/typescript/** tasks/prettier_conformance/prettier/** +apps/**/dist **/*.snap diff --git a/Cargo.lock b/Cargo.lock index 6936b3758b690..e211ad0c2683c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -715,7 +715,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.0", + "windows-sys 0.52.0", ] [[package]] @@ -1201,7 +1201,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.15.5", "rayon", "serde", "serde_core", @@ -1655,7 +1655,7 @@ checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e" [[package]] name = "oxc" -version = "0.91.0" +version = "0.92.0" dependencies = [ "oxc_allocator", "oxc_ast", @@ -1732,7 +1732,7 @@ dependencies = [ [[package]] name = "oxc_allocator" -version = "0.91.0" +version = "0.92.0" dependencies = [ "allocator-api2", "bumpalo", @@ -1747,7 +1747,7 @@ dependencies = [ [[package]] name = "oxc_ast" -version = "0.91.0" +version = "0.92.0" dependencies = [ "bitflags 2.9.4", "oxc_allocator", @@ -1762,7 +1762,7 @@ dependencies = [ [[package]] name = "oxc_ast_macros" -version = "0.91.0" +version = "0.92.0" dependencies = [ "phf", "proc-macro2", @@ -1797,7 +1797,7 @@ dependencies = [ [[package]] name = "oxc_ast_visit" -version = "0.91.0" +version = "0.92.0" dependencies = [ "oxc_allocator", "oxc_ast", @@ -1831,7 +1831,7 @@ dependencies = [ [[package]] name = "oxc_cfg" -version = "0.91.0" +version = "0.92.0" dependencies = [ "bitflags 2.9.4", "itertools", @@ -1844,7 +1844,7 @@ dependencies = [ [[package]] name = "oxc_codegen" -version = "0.91.0" +version = "0.92.0" dependencies = [ "bitflags 2.9.4", "cow-utils", @@ -1867,7 +1867,7 @@ dependencies = [ [[package]] name = "oxc_compat" -version = "0.91.0" +version = "0.92.0" dependencies = [ "cow-utils", "oxc-browserslist", @@ -1919,14 +1919,14 @@ dependencies = [ [[package]] name = "oxc_data_structures" -version = "0.91.0" +version = "0.92.0" dependencies = [ "ropey", ] [[package]] name = "oxc_diagnostics" -version = "0.91.0" +version = "0.92.0" dependencies = [ "cow-utils", "oxc-miette", @@ -1935,7 +1935,7 @@ dependencies = [ [[package]] name = "oxc_ecmascript" -version = "0.91.0" +version = "0.92.0" dependencies = [ "cow-utils", "num-bigint", @@ -1948,7 +1948,7 @@ dependencies = [ [[package]] name = "oxc_estree" -version = "0.91.0" +version = "0.92.0" dependencies = [ "dragonbox_ecma", "itoa", @@ -1982,7 +1982,7 @@ dependencies = [ [[package]] name = "oxc_isolated_declarations" -version = "0.91.0" +version = "0.92.0" dependencies = [ "bitflags 2.9.4", "insta", @@ -2000,7 +2000,7 @@ dependencies = [ [[package]] name = "oxc_language_server" -version = "1.17.0" +version = "1.18.0" dependencies = [ "env_logger", "futures", @@ -2023,7 +2023,7 @@ dependencies = [ [[package]] name = "oxc_linter" -version = "1.17.0" +version = "1.18.0" dependencies = [ "bitflags 2.9.4", "constcat", @@ -2060,6 +2060,7 @@ dependencies = [ "oxc_semantic", "oxc_span", "oxc_syntax", + "papaya", "phf", "project-root", "rayon", @@ -2095,7 +2096,7 @@ dependencies = [ [[package]] name = "oxc_mangler" -version = "0.91.0" +version = "0.92.0" dependencies = [ "itertools", "oxc_allocator", @@ -2110,7 +2111,7 @@ dependencies = [ [[package]] name = "oxc_minifier" -version = "0.91.0" +version = "0.92.0" dependencies = [ "cow-utils", "insta", @@ -2120,6 +2121,7 @@ dependencies = [ "oxc_ast_visit", "oxc_codegen", "oxc_compat", + "oxc_data_structures", "oxc_ecmascript", "oxc_mangler", "oxc_parser", @@ -2135,7 +2137,7 @@ dependencies = [ [[package]] name = "oxc_minify_napi" -version = "0.91.0" +version = "0.92.0" dependencies = [ "mimalloc-safe", "napi", @@ -2174,7 +2176,7 @@ dependencies = [ [[package]] name = "oxc_napi" -version = "0.91.0" +version = "0.92.0" dependencies = [ "napi", "napi-build", @@ -2188,7 +2190,7 @@ dependencies = [ [[package]] name = "oxc_parser" -version = "0.91.0" +version = "0.92.0" dependencies = [ "bitflags 2.9.4", "cow-utils", @@ -2211,7 +2213,7 @@ dependencies = [ [[package]] name = "oxc_parser_napi" -version = "0.91.0" +version = "0.92.0" dependencies = [ "mimalloc-safe", "napi", @@ -2262,7 +2264,7 @@ dependencies = [ [[package]] name = "oxc_regular_expression" -version = "0.91.0" +version = "0.92.0" dependencies = [ "bitflags 2.9.4", "oxc_allocator", @@ -2276,9 +2278,9 @@ dependencies = [ [[package]] name = "oxc_resolver" -version = "11.8.2" +version = "11.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49c4a4d746f42bac28538163952aa66da2f0ea781a0708772d8ad3f6fc066963" +checksum = "c553f3d6a88eb57513b4bb6b8387ab71c7701721ecd242b673ffeb3dc99cfd08" dependencies = [ "cfg-if", "indexmap", @@ -2310,7 +2312,7 @@ dependencies = [ [[package]] name = "oxc_semantic" -version = "0.91.0" +version = "0.92.0" dependencies = [ "insta", "itertools", @@ -2348,7 +2350,7 @@ dependencies = [ [[package]] name = "oxc_span" -version = "0.91.0" +version = "0.92.0" dependencies = [ "compact_str", "oxc-miette", @@ -2361,7 +2363,7 @@ dependencies = [ [[package]] name = "oxc_syntax" -version = "0.91.0" +version = "0.92.0" dependencies = [ "bitflags 2.9.4", "cow-utils", @@ -2435,7 +2437,7 @@ dependencies = [ [[package]] name = "oxc_transform_napi" -version = "0.91.0" +version = "0.92.0" dependencies = [ "mimalloc-safe", "napi", @@ -2449,7 +2451,7 @@ dependencies = [ [[package]] name = "oxc_transformer" -version = "0.91.0" +version = "0.92.0" dependencies = [ "base64", "compact_str", @@ -2480,7 +2482,7 @@ dependencies = [ [[package]] name = "oxc_transformer_plugins" -version = "0.91.0" +version = "0.92.0" dependencies = [ "cow-utils", "insta", @@ -2507,7 +2509,7 @@ dependencies = [ [[package]] name = "oxc_traverse" -version = "0.91.0" +version = "0.92.0" dependencies = [ "itoa", "oxc_allocator", @@ -2543,7 +2545,7 @@ dependencies = [ [[package]] name = "oxlint" -version = "1.17.0" +version = "1.18.0" dependencies = [ "bpaf", "cow-utils", @@ -2926,7 +2928,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.0", + "windows-sys 0.52.0", ] [[package]] @@ -3030,7 +3032,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.61.0", + "windows-sys 0.52.0", ] [[package]] @@ -3291,7 +3293,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.61.0", + "windows-sys 0.52.0", ] [[package]] @@ -3799,7 +3801,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.0", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ee518e42621ca..0dddf7e4a5b5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -103,33 +103,33 @@ multiple_crate_versions = "allow" [workspace.dependencies] # publish = true -oxc = { version = "0.91.0", path = "crates/oxc" } -oxc_allocator = { version = "0.91.0", path = "crates/oxc_allocator" } -oxc_ast = { version = "0.91.0", path = "crates/oxc_ast" } -oxc_ast_macros = { version = "0.91.0", path = "crates/oxc_ast_macros" } -oxc_ast_visit = { version = "0.91.0", path = "crates/oxc_ast_visit" } -oxc_cfg = { version = "0.91.0", path = "crates/oxc_cfg" } -oxc_codegen = { version = "0.91.0", path = "crates/oxc_codegen" } -oxc_compat = { version = "0.91.0", path = "crates/oxc_compat" } -oxc_data_structures = { version = "0.91.0", path = "crates/oxc_data_structures" } -oxc_diagnostics = { version = "0.91.0", path = "crates/oxc_diagnostics" } -oxc_ecmascript = { version = "0.91.0", path = "crates/oxc_ecmascript" } -oxc_estree = { version = "0.91.0", path = "crates/oxc_estree" } -oxc_isolated_declarations = { version = "0.91.0", path = "crates/oxc_isolated_declarations" } -oxc_mangler = { version = "0.91.0", path = "crates/oxc_mangler" } -oxc_minifier = { version = "0.91.0", path = "crates/oxc_minifier" } -oxc_minify_napi = { version = "0.91.0", path = "napi/minify" } -oxc_napi = { version = "0.91.0", path = "crates/oxc_napi" } -oxc_parser = { version = "0.91.0", path = "crates/oxc_parser", features = ["regular_expression"] } -oxc_parser_napi = { version = "0.91.0", path = "napi/parser" } -oxc_regular_expression = { version = "0.91.0", path = "crates/oxc_regular_expression" } -oxc_semantic = { version = "0.91.0", path = "crates/oxc_semantic" } -oxc_span = { version = "0.91.0", path = "crates/oxc_span" } -oxc_syntax = { version = "0.91.0", path = "crates/oxc_syntax" } -oxc_transform_napi = { version = "0.91.0", path = "napi/transform" } -oxc_transformer = { version = "0.91.0", path = "crates/oxc_transformer" } -oxc_transformer_plugins = { version = "0.91.0", path = "crates/oxc_transformer_plugins" } -oxc_traverse = { version = "0.91.0", path = "crates/oxc_traverse" } +oxc = { version = "0.92.0", path = "crates/oxc" } +oxc_allocator = { version = "0.92.0", path = "crates/oxc_allocator" } +oxc_ast = { version = "0.92.0", path = "crates/oxc_ast" } +oxc_ast_macros = { version = "0.92.0", path = "crates/oxc_ast_macros" } +oxc_ast_visit = { version = "0.92.0", path = "crates/oxc_ast_visit" } +oxc_cfg = { version = "0.92.0", path = "crates/oxc_cfg" } +oxc_codegen = { version = "0.92.0", path = "crates/oxc_codegen" } +oxc_compat = { version = "0.92.0", path = "crates/oxc_compat" } +oxc_data_structures = { version = "0.92.0", path = "crates/oxc_data_structures" } +oxc_diagnostics = { version = "0.92.0", path = "crates/oxc_diagnostics" } +oxc_ecmascript = { version = "0.92.0", path = "crates/oxc_ecmascript" } +oxc_estree = { version = "0.92.0", path = "crates/oxc_estree" } +oxc_isolated_declarations = { version = "0.92.0", path = "crates/oxc_isolated_declarations" } +oxc_mangler = { version = "0.92.0", path = "crates/oxc_mangler" } +oxc_minifier = { version = "0.92.0", path = "crates/oxc_minifier" } +oxc_minify_napi = { version = "0.92.0", path = "napi/minify" } +oxc_napi = { version = "0.92.0", path = "crates/oxc_napi" } +oxc_parser = { version = "0.92.0", path = "crates/oxc_parser", features = ["regular_expression"] } +oxc_parser_napi = { version = "0.92.0", path = "napi/parser" } +oxc_regular_expression = { version = "0.92.0", path = "crates/oxc_regular_expression" } +oxc_semantic = { version = "0.92.0", path = "crates/oxc_semantic" } +oxc_span = { version = "0.92.0", path = "crates/oxc_span" } +oxc_syntax = { version = "0.92.0", path = "crates/oxc_syntax" } +oxc_transform_napi = { version = "0.92.0", path = "napi/transform" } +oxc_transformer = { version = "0.92.0", path = "crates/oxc_transformer" } +oxc_transformer_plugins = { version = "0.92.0", path = "crates/oxc_transformer_plugins" } +oxc_traverse = { version = "0.92.0", path = "crates/oxc_traverse" } # publish = false oxc_formatter = { path = "crates/oxc_formatter" } diff --git a/apps/oxlint/CHANGELOG.md b/apps/oxlint/CHANGELOG.md index 25135022802a1..b03964c381ff4 100644 --- a/apps/oxlint/CHANGELOG.md +++ b/apps/oxlint/CHANGELOG.md @@ -4,6 +4,52 @@ All notable changes to this package will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0). +## [1.18.0] - 2025-09-24 + +### ๐Ÿ› Bug Fixes + +- 314c27d linter/plugins: `definePlugin` apply `defineRule` to rules (#14065) (overlookmotel) +- 7bd01ed linter/plugins: `defineRule` call `createOnce` lazily (#14062) (overlookmotel) +- fb3e7e3 linter/plugins: `defineRule` accept visitor with no `before` / `after` hooks (#14060) (overlookmotel) + +### ๐Ÿšœ Refactor + +- 3a706a7 linter: Rename `LintRunner` to `CliRunner` (#14050) (camc314) + +### โšก Performance + +- ce538c7 linter/plugins: Load methods of globals into local vars (#14073) (overlookmotel) + +### ๐Ÿงช Testing + +- 2fd4b1e linter/plugins: Rename test (#14064) (overlookmotel) +- f2b3934 linter/plugins: Test returning `false` from `before` hook skips visitation in ESLint (#14061) (overlookmotel) +- b109419 linter/plugins: Align ESLint plugin with Oxlint (#14059) (overlookmotel) + + +## [1.18.0] - 2025-09-24 + +### ๐Ÿ› Bug Fixes + +- 314c27d linter/plugins: `definePlugin` apply `defineRule` to rules (#14065) (overlookmotel) +- 7bd01ed linter/plugins: `defineRule` call `createOnce` lazily (#14062) (overlookmotel) +- fb3e7e3 linter/plugins: `defineRule` accept visitor with no `before` / `after` hooks (#14060) (overlookmotel) + +### ๐Ÿšœ Refactor + +- 3a706a7 linter: Rename `LintRunner` to `CliRunner` (#14050) (camc314) + +### โšก Performance + +- ce538c7 linter/plugins: Load methods of globals into local vars (#14073) (overlookmotel) + +### ๐Ÿงช Testing + +- 2fd4b1e linter/plugins: Rename test (#14064) (overlookmotel) +- f2b3934 linter/plugins: Test returning `false` from `before` hook skips visitation in ESLint (#14061) (overlookmotel) +- b109419 linter/plugins: Align ESLint plugin with Oxlint (#14059) (overlookmotel) + + ## [1.17.0] - 2025-09-23 ### ๐Ÿš€ Features diff --git a/apps/oxlint/Cargo.toml b/apps/oxlint/Cargo.toml index c52c0ec737073..d67ae870f9270 100644 --- a/apps/oxlint/Cargo.toml +++ b/apps/oxlint/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxlint" -version = "1.17.0" +version = "1.18.0" authors.workspace = true categories.workspace = true edition.workspace = true diff --git a/apps/oxlint/package.json b/apps/oxlint/package.json index f84d8728cfdb7..6e7afdcf32215 100644 --- a/apps/oxlint/package.json +++ b/apps/oxlint/package.json @@ -1,6 +1,6 @@ { "name": "oxlint", - "version": "1.17.0", + "version": "1.18.0", "type": "module", "main": "dist/index.js", "bin": "dist/cli.js", diff --git a/apps/oxlint/src-js/bindings.js b/apps/oxlint/src-js/bindings.js index ee981eb287021..0963a4f37bef0 100644 --- a/apps/oxlint/src-js/bindings.js +++ b/apps/oxlint/src-js/bindings.js @@ -81,8 +81,8 @@ function requireNative() { try { const binding = require('@oxlint/android-arm64') const bindingPackageVersion = require('@oxlint/android-arm64/package.json').version - if (bindingPackageVersion !== '1.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.18.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.18.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -97,8 +97,8 @@ function requireNative() { try { const binding = require('@oxlint/android-arm-eabi') const bindingPackageVersion = require('@oxlint/android-arm-eabi/package.json').version - if (bindingPackageVersion !== '1.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.18.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.18.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -117,8 +117,8 @@ function requireNative() { try { const binding = require('@oxlint/win32-x64') const bindingPackageVersion = require('@oxlint/win32-x64/package.json').version - if (bindingPackageVersion !== '1.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.18.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.18.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -133,8 +133,8 @@ function requireNative() { try { const binding = require('@oxlint/win32-ia32') const bindingPackageVersion = require('@oxlint/win32-ia32/package.json').version - if (bindingPackageVersion !== '1.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.18.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.18.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -149,8 +149,8 @@ function requireNative() { try { const binding = require('@oxlint/win32-arm64') const bindingPackageVersion = require('@oxlint/win32-arm64/package.json').version - if (bindingPackageVersion !== '1.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.18.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.18.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -168,8 +168,8 @@ function requireNative() { try { const binding = require('@oxlint/darwin-universal') const bindingPackageVersion = require('@oxlint/darwin-universal/package.json').version - if (bindingPackageVersion !== '1.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.18.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.18.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -184,8 +184,8 @@ function requireNative() { try { const binding = require('@oxlint/darwin-x64') const bindingPackageVersion = require('@oxlint/darwin-x64/package.json').version - if (bindingPackageVersion !== '1.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.18.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.18.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -200,8 +200,8 @@ function requireNative() { try { const binding = require('@oxlint/darwin-arm64') const bindingPackageVersion = require('@oxlint/darwin-arm64/package.json').version - if (bindingPackageVersion !== '1.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.18.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.18.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -220,8 +220,8 @@ function requireNative() { try { const binding = require('@oxlint/freebsd-x64') const bindingPackageVersion = require('@oxlint/freebsd-x64/package.json').version - if (bindingPackageVersion !== '1.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.18.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.18.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -236,8 +236,8 @@ function requireNative() { try { const binding = require('@oxlint/freebsd-arm64') const bindingPackageVersion = require('@oxlint/freebsd-arm64/package.json').version - if (bindingPackageVersion !== '1.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.18.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.18.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -257,8 +257,8 @@ function requireNative() { try { const binding = require('@oxlint/linux-x64-musl') const bindingPackageVersion = require('@oxlint/linux-x64-musl/package.json').version - if (bindingPackageVersion !== '1.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.18.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.18.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -273,8 +273,8 @@ function requireNative() { try { const binding = require('@oxlint/linux-x64-gnu') const bindingPackageVersion = require('@oxlint/linux-x64-gnu/package.json').version - if (bindingPackageVersion !== '1.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.18.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.18.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -291,8 +291,8 @@ function requireNative() { try { const binding = require('@oxlint/linux-arm64-musl') const bindingPackageVersion = require('@oxlint/linux-arm64-musl/package.json').version - if (bindingPackageVersion !== '1.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.18.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.18.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -307,8 +307,8 @@ function requireNative() { try { const binding = require('@oxlint/linux-arm64-gnu') const bindingPackageVersion = require('@oxlint/linux-arm64-gnu/package.json').version - if (bindingPackageVersion !== '1.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.18.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.18.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -325,8 +325,8 @@ function requireNative() { try { const binding = require('@oxlint/linux-arm-musleabihf') const bindingPackageVersion = require('@oxlint/linux-arm-musleabihf/package.json').version - if (bindingPackageVersion !== '1.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.18.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.18.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -341,8 +341,8 @@ function requireNative() { try { const binding = require('@oxlint/linux-arm-gnueabihf') const bindingPackageVersion = require('@oxlint/linux-arm-gnueabihf/package.json').version - if (bindingPackageVersion !== '1.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.18.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.18.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -359,8 +359,8 @@ function requireNative() { try { const binding = require('@oxlint/linux-loong64-musl') const bindingPackageVersion = require('@oxlint/linux-loong64-musl/package.json').version - if (bindingPackageVersion !== '1.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.18.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.18.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -375,8 +375,8 @@ function requireNative() { try { const binding = require('@oxlint/linux-loong64-gnu') const bindingPackageVersion = require('@oxlint/linux-loong64-gnu/package.json').version - if (bindingPackageVersion !== '1.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.18.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.18.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -393,8 +393,8 @@ function requireNative() { try { const binding = require('@oxlint/linux-riscv64-musl') const bindingPackageVersion = require('@oxlint/linux-riscv64-musl/package.json').version - if (bindingPackageVersion !== '1.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.18.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.18.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -409,8 +409,8 @@ function requireNative() { try { const binding = require('@oxlint/linux-riscv64-gnu') const bindingPackageVersion = require('@oxlint/linux-riscv64-gnu/package.json').version - if (bindingPackageVersion !== '1.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.18.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.18.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -426,8 +426,8 @@ function requireNative() { try { const binding = require('@oxlint/linux-ppc64-gnu') const bindingPackageVersion = require('@oxlint/linux-ppc64-gnu/package.json').version - if (bindingPackageVersion !== '1.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.18.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.18.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -442,8 +442,8 @@ function requireNative() { try { const binding = require('@oxlint/linux-s390x-gnu') const bindingPackageVersion = require('@oxlint/linux-s390x-gnu/package.json').version - if (bindingPackageVersion !== '1.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.18.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.18.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -462,8 +462,8 @@ function requireNative() { try { const binding = require('@oxlint/openharmony-arm64') const bindingPackageVersion = require('@oxlint/openharmony-arm64/package.json').version - if (bindingPackageVersion !== '1.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.18.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.18.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -478,8 +478,8 @@ function requireNative() { try { const binding = require('@oxlint/openharmony-x64') const bindingPackageVersion = require('@oxlint/openharmony-x64/package.json').version - if (bindingPackageVersion !== '1.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.18.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.18.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { @@ -494,8 +494,8 @@ function requireNative() { try { const binding = require('@oxlint/openharmony-arm') const bindingPackageVersion = require('@oxlint/openharmony-arm/package.json').version - if (bindingPackageVersion !== '1.17.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { - throw new Error(`Native binding package version mismatch, expected 1.17.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + if (bindingPackageVersion !== '1.18.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 1.18.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) } return binding } catch (e) { diff --git a/apps/oxlint/src-js/index.ts b/apps/oxlint/src-js/index.ts index de8c61267eec0..6e1da4e705231 100644 --- a/apps/oxlint/src-js/index.ts +++ b/apps/oxlint/src-js/index.ts @@ -1,47 +1,68 @@ import type { Context } from './plugins/context.ts'; -import type { Plugin, Rule } from './plugins/load.ts'; +import type { CreateOnceRule, Plugin, Rule } from './plugins/load.ts'; +import type { BeforeHook, Visitor, VisitorWithHooks } from './plugins/types.ts'; -const { defineProperty, getPrototypeOf, setPrototypeOf } = Object; +const { defineProperty, getPrototypeOf, hasOwn, setPrototypeOf, create: ObjectCreate } = Object; const dummyOptions: unknown[] = [], dummyReport = () => {}; -// Define a plugin. +/** + * Define a plugin. + * + * Converts any rules with `createOnce` method to have an ESLint-compatible `create` method. + * + * The `plugin` object passed in is mutated in-place. + * + * @param plugin - Plugin to define + * @returns Plugin with all rules having `create` method + * @throws {Error} If `plugin` is not an object, or `plugin.rules` is not an object + */ export function definePlugin(plugin: Plugin): Plugin { + // Validate type of `plugin` + if (plugin === null || typeof plugin !== 'object') throw new Error('Plugin must be an object'); + + const { rules } = plugin; + if (rules === null || typeof rules !== 'object') throw new Error('Plugin must have an object as `rules` property'); + + // Make each rule in the plugin ESLint-compatible by calling `defineRule` on it + for (const ruleName in rules) { + if (hasOwn(rules, ruleName)) { + rules[ruleName] = defineRule(rules[ruleName]); + } + } + return plugin; } -// Define a rule. -// If rule has `createOnce` method, add an ESLint-compatible `create` method which delegates to `createOnce`. +/** + * Define a rule. + * + * If rules does not already have a `create` method, create an ESLint-compatible `create` method + * which delegates to `createOnce`. + * + * The `rule` object passed in is mutated in-place. + * + * @param rule - Rule to define + * @returns Rule with `create` method + * @throws {Error} If `rule` is not an object + */ export function defineRule(rule: Rule): Rule { - if (!('createOnce' in rule)) return rule; - if ('create' in rule) throw new Error('Rules must define only `create` or `createOnce` methods, not both'); + // Validate type of `rule` + if (rule === null || typeof rule !== 'object') throw new Error('Rule must be an object'); - // Run `createOnce` with empty context object. - // Really, `context` should be an instance of `Context`, which would throw error on accessing e.g. `id` - // in body of `createOnce`. But any such bugs should have been caught when testing the rule in Oxlint, - // so should be OK to take this shortcut. - const context = Object.create(null, { - id: { value: '', enumerable: true, configurable: true }, - options: { value: dummyOptions, enumerable: true, configurable: true }, - report: { value: dummyReport, enumerable: true, configurable: true }, - }); - - const { before: beforeHook, after: afterHook, ...visitor } = rule.createOnce(context as Context); + // If rule already has `create` method, return it as is + if ('create' in rule) return rule; - // Add `after` hook to `Program:exit` visit fn - if (afterHook !== null) { - const programExit = visitor['Program:exit']; - visitor['Program:exit'] = programExit - ? (node) => { - programExit(node); - afterHook(); - } - : (_node) => afterHook(); - } + // Add `create` function to `rule` + let context: Context = null, visitor: Visitor, beforeHook: BeforeHook | null; - // Create `create` function rule.create = (eslintContext) => { + // Lazily call `createOnce` on first invocation of `create` + if (context === null) { + ({ context, visitor, beforeHook } = createContextAndVisitor(rule)); + } + // Copy properties from ESLint's context object to `context`. // ESLint's context object is an object of form `{ id, options, report }`, with all other properties // and methods on another object which is its prototype. @@ -50,7 +71,7 @@ export function defineRule(rule: Rule): Rule { defineProperty(context, 'report', { value: eslintContext.report }); setPrototypeOf(context, getPrototypeOf(eslintContext)); - // If `before` hook returns `false`, skip rest of traversal by returning an empty object as visitor + // If `before` hook returns `false`, skip traversal by returning an empty object as visitor if (beforeHook !== null) { const shouldRun = beforeHook(); if (shouldRun === false) return {}; @@ -62,3 +83,53 @@ export function defineRule(rule: Rule): Rule { return rule; } + +/** + * Call `createOnce` method of rule, and return `Context`, `Visitor`, and `beforeHook` (if any). + * + * @param rule - Rule with `createOnce` method + * @returns Object with `context`, `visitor`, and `beforeHook` properties + */ +function createContextAndVisitor(rule: CreateOnceRule): { + context: Context; + visitor: Visitor; + beforeHook: BeforeHook | null; +} { + // Validate type of `createOnce` + const { createOnce } = rule; + if (createOnce == null) throw new Error('Rules must define either a `create` or `createOnce` method'); + if (typeof createOnce !== 'function') throw new Error('Rule `createOnce` property must be a function'); + + // Call `createOnce` with empty context object. + // Really, `context` should be an instance of `Context`, which would throw error on accessing e.g. `id` + // in body of `createOnce`. But any such bugs should have been caught when testing the rule in Oxlint, + // so should be OK to take this shortcut. + const context = ObjectCreate(null, { + id: { value: '', enumerable: true, configurable: true }, + options: { value: dummyOptions, enumerable: true, configurable: true }, + report: { value: dummyReport, enumerable: true, configurable: true }, + }); + + let { before: beforeHook, after: afterHook, ...visitor } = createOnce.call(rule, context) as VisitorWithHooks; + + if (beforeHook === void 0) { + beforeHook = null; + } else if (beforeHook !== null && typeof beforeHook !== 'function') { + throw new Error('`before` property of visitor must be a function if defined'); + } + + // Add `after` hook to `Program:exit` visit fn + if (afterHook != null) { + if (typeof afterHook !== 'function') throw new Error('`after` property of visitor must be a function if defined'); + + const programExit = visitor['Program:exit']; + visitor['Program:exit'] = programExit == null + ? (_node) => afterHook() + : (node) => { + programExit(node); + afterHook(); + }; + } + + return { context, visitor, beforeHook }; +} diff --git a/apps/oxlint/src-js/plugins/load.ts b/apps/oxlint/src-js/plugins/load.ts index eec513ef0ba9d..f7f6251119c04 100644 --- a/apps/oxlint/src-js/plugins/load.ts +++ b/apps/oxlint/src-js/plugins/load.ts @@ -3,6 +3,8 @@ import { getErrorMessage } from './utils.js'; import type { AfterHook, BeforeHook, Visitor, VisitorWithHooks } from './types.ts'; +const ObjectKeys = Object.keys; + // Linter plugin, comprising multiple rules export interface Plugin { meta: { @@ -22,7 +24,7 @@ interface CreateRule { create: (context: Context) => Visitor; } -interface CreateOnceRule { +export interface CreateOnceRule { create?: (context: Context) => Visitor; createOnce: (context: Context) => VisitorWithHooks; } @@ -86,7 +88,7 @@ async function loadPluginImpl(path: string): Promise { const pluginName = plugin.meta.name; const offset = registeredRules.length; const { rules } = plugin; - const ruleNames = Object.keys(rules); + const ruleNames = ObjectKeys(rules); const ruleNamesLen = ruleNames.length; for (let i = 0; i < ruleNamesLen; i++) { diff --git a/apps/oxlint/src-js/plugins/visitor.ts b/apps/oxlint/src-js/plugins/visitor.ts index a5c30d2a691b1..7b171dbb3bc1c 100644 --- a/apps/oxlint/src-js/plugins/visitor.ts +++ b/apps/oxlint/src-js/plugins/visitor.ts @@ -85,7 +85,8 @@ import { assertIs } from './utils.js'; import type { CompiledVisitorEntry, EnterExit, Node, VisitFn, Visitor } from './types.ts'; -const { isArray } = Array; +const ObjectKeys = Object.keys, + { isArray } = Array; // Types for temporary state of entries of `compiledVisitor` // between calling `initCompiledVisitor` and `finalizeCompiledVisitor`. @@ -210,7 +211,7 @@ export function addVisitorToCompiled(visitor: Visitor): void { } // Exit if is empty visitor - const keys = Object.keys(visitor), + const keys = ObjectKeys(visitor), keysLen = keys.length; if (keysLen === 0) return; diff --git a/apps/oxlint/src/lib.rs b/apps/oxlint/src/lib.rs index 76d78824a04ea..fc19621834dd7 100644 --- a/apps/oxlint/src/lib.rs +++ b/apps/oxlint/src/lib.rs @@ -12,7 +12,7 @@ mod tester; /// Re-exported CLI-related items for use in `tasks/website`. pub mod cli { - pub use super::{command::*, lint::LintRunner, result::CliRunResult}; + pub use super::{command::*, lint::CliRunner, result::CliRunResult}; } // Only include code to run linter when the `napi` feature is enabled. diff --git a/apps/oxlint/src/lint.rs b/apps/oxlint/src/lint.rs index efdd3e6ff2d5b..8049aeb5756d4 100644 --- a/apps/oxlint/src/lint.rs +++ b/apps/oxlint/src/lint.rs @@ -28,13 +28,13 @@ use crate::{ use oxc_linter::LintIgnoreMatcher; #[derive(Debug)] -pub struct LintRunner { +pub struct CliRunner { options: LintCommand, cwd: PathBuf, external_linter: Option, } -impl LintRunner { +impl CliRunner { pub(crate) fn new(options: LintCommand, external_linter: Option) -> Self { Self { options, @@ -411,7 +411,7 @@ impl LintRunner { } } -impl LintRunner { +impl CliRunner { const DEFAULT_OXLINTRC: &'static str = ".oxlintrc.json"; #[must_use] @@ -618,7 +618,7 @@ fn render_report(handler: &GraphicalReportHandler, diagnostic: &OxcDiagnostic) - mod test { use std::{fs, path::PathBuf}; - use super::LintRunner; + use super::CliRunner; use crate::tester::Tester; // lints the full directory of fixtures, @@ -988,14 +988,14 @@ mod test { #[test] fn test_init_config() { - assert!(!fs::exists(LintRunner::DEFAULT_OXLINTRC).unwrap()); + assert!(!fs::exists(CliRunner::DEFAULT_OXLINTRC).unwrap()); let args = &["--init"]; Tester::new().with_cwd("fixtures".into()).test(args); - assert!(fs::exists(LintRunner::DEFAULT_OXLINTRC).unwrap()); + assert!(fs::exists(CliRunner::DEFAULT_OXLINTRC).unwrap()); - fs::remove_file(LintRunner::DEFAULT_OXLINTRC).unwrap(); + fs::remove_file(CliRunner::DEFAULT_OXLINTRC).unwrap(); } #[test] @@ -1190,17 +1190,17 @@ mod test { // Test case 1: Invalid path that should fail let invalid_config = PathBuf::from("child/../../fixtures/linter/eslintrc.json"); - let result = LintRunner::find_oxlint_config(&cwd, Some(&invalid_config)); + let result = CliRunner::find_oxlint_config(&cwd, Some(&invalid_config)); assert!(result.is_err(), "Expected config lookup to fail with invalid path"); // Test case 2: Valid path that should pass let valid_config = PathBuf::from("fixtures/linter/eslintrc.json"); - let result = LintRunner::find_oxlint_config(&cwd, Some(&valid_config)); + let result = CliRunner::find_oxlint_config(&cwd, Some(&valid_config)); assert!(result.is_ok(), "Expected config lookup to succeed with valid path"); // Test case 3: Valid path using parent directory (..) syntax that should pass let valid_parent_config = PathBuf::from("fixtures/linter/../linter/eslintrc.json"); - let result = LintRunner::find_oxlint_config(&cwd, Some(&valid_parent_config)); + let result = CliRunner::find_oxlint_config(&cwd, Some(&valid_parent_config)); assert!(result.is_ok(), "Expected config lookup to succeed with parent directory syntax"); // Verify the resolved path is correct diff --git a/apps/oxlint/src/run.rs b/apps/oxlint/src/run.rs index 8244adffa644d..61274f1ce0ca2 100644 --- a/apps/oxlint/src/run.rs +++ b/apps/oxlint/src/run.rs @@ -10,7 +10,7 @@ use napi::{ }; use napi_derive::napi; -use crate::{lint::LintRunner, result::CliRunResult}; +use crate::{lint::CliRunner, result::CliRunResult}; /// JS callback to load a JS plugin. #[napi] @@ -113,7 +113,7 @@ fn lint_impl(load_plugin: JsLoadPluginCb, lint_file: JsLintFileCb) -> CliRunResu // See `https://github.com/rust-lang/rust/issues/60673`. let mut stdout = BufWriter::new(std::io::stdout()); - LintRunner::new(command, external_linter).run(&mut stdout) + CliRunner::new(command, external_linter).run(&mut stdout) } /// Initialize the data which relies on `is_atty` system calls so they don't block subsequent threads. diff --git a/apps/oxlint/src/tester.rs b/apps/oxlint/src/tester.rs index ac4bc8aacd6f7..37b2449984902 100644 --- a/apps/oxlint/src/tester.rs +++ b/apps/oxlint/src/tester.rs @@ -3,7 +3,7 @@ use std::{env, path::PathBuf}; use cow_utils::CowUtils; use lazy_regex::Regex; -use crate::cli::{LintRunner, lint_command}; +use crate::cli::{CliRunner, lint_command}; pub struct Tester { cwd: PathBuf, @@ -32,7 +32,7 @@ impl Tester { let options = lint_command().run_inner(new_args.as_slice()).unwrap(); let mut output = Vec::new(); - let _ = LintRunner::new(options, None).with_cwd(self.cwd.clone()).run(&mut output); + let _ = CliRunner::new(options, None).with_cwd(self.cwd.clone()).run(&mut output); } pub fn test_fix(file: &str, before: &str, after: &str) { @@ -78,7 +78,7 @@ impl Tester { format!("working directory: {}\n", relative_dir.to_str().unwrap()).as_bytes(), ); output.extend_from_slice(b"----------\n"); - let result = LintRunner::new(options, None).with_cwd(self.cwd.clone()).run(&mut output); + let result = CliRunner::new(options, None).with_cwd(self.cwd.clone()).run(&mut output); output.extend_from_slice(b"----------\n"); output.extend_from_slice(format!("CLI result: {result:?}\n").as_bytes()); diff --git a/apps/oxlint/test/__snapshots__/e2e.test.ts.snap b/apps/oxlint/test/__snapshots__/e2e.test.ts.snap index 717a4880346fb..f705740f5b38d 100644 --- a/apps/oxlint/test/__snapshots__/e2e.test.ts.snap +++ b/apps/oxlint/test/__snapshots__/e2e.test.ts.snap @@ -640,6 +640,24 @@ Finished in Xms on 1 file using X threads." exports[`oxlint CLI > should support \`createOnce\` 1`] = ` " + x create-once-plugin(after-only): after hook: filename: files/1.js + ,-[files/1.js:1:1] + 1 | let x; + : ^ + \`---- + + x create-once-plugin(after-only): after hook: id: create-once-plugin/after-only + ,-[files/1.js:1:1] + 1 | let x; + : ^ + \`---- + + x create-once-plugin(always-run): createOnce: call count: 1 + ,-[files/1.js:1:1] + 1 | let x; + : ^ + \`---- + x create-once-plugin(always-run): createOnce: filename: Cannot access \`context.filename\` in \`createOnce\` ,-[files/1.js:1:1] 1 | let x; @@ -700,6 +718,18 @@ exports[`oxlint CLI > should support \`createOnce\` 1`] = ` : ^ \`---- + x create-once-plugin(before-only): before hook: filename: files/1.js + ,-[files/1.js:1:1] + 1 | let x; + : ^ + \`---- + + x create-once-plugin(before-only): before hook: id: create-once-plugin/before-only + ,-[files/1.js:1:1] + 1 | let x; + : ^ + \`---- + x create-once-plugin(skip-run): before hook: filename: files/1.js ,-[files/1.js:1:1] 1 | let x; @@ -712,12 +742,48 @@ exports[`oxlint CLI > should support \`createOnce\` 1`] = ` : ^ \`---- + x create-once-plugin(after-only): ident visit fn "x": filename: files/1.js + ,-[files/1.js:1:5] + 1 | let x; + : ^ + \`---- + x create-once-plugin(always-run): ident visit fn "x": filename: files/1.js ,-[files/1.js:1:5] 1 | let x; : ^ \`---- + x create-once-plugin(before-only): ident visit fn "x": filename: files/1.js + ,-[files/1.js:1:5] + 1 | let x; + : ^ + \`---- + + x create-once-plugin(no-hooks): ident visit fn "x": filename: files/1.js + ,-[files/1.js:1:5] + 1 | let x; + : ^ + \`---- + + x create-once-plugin(after-only): after hook: filename: files/2.js + ,-[files/2.js:1:1] + 1 | let y; + : ^ + \`---- + + x create-once-plugin(after-only): after hook: id: create-once-plugin/after-only + ,-[files/2.js:1:1] + 1 | let y; + : ^ + \`---- + + x create-once-plugin(always-run): createOnce: call count: 1 + ,-[files/2.js:1:1] + 1 | let y; + : ^ + \`---- + x create-once-plugin(always-run): createOnce: filename: Cannot access \`context.filename\` in \`createOnce\` ,-[files/2.js:1:1] 1 | let y; @@ -778,6 +844,18 @@ exports[`oxlint CLI > should support \`createOnce\` 1`] = ` : ^ \`---- + x create-once-plugin(before-only): before hook: filename: files/2.js + ,-[files/2.js:1:1] + 1 | let y; + : ^ + \`---- + + x create-once-plugin(before-only): before hook: id: create-once-plugin/before-only + ,-[files/2.js:1:1] + 1 | let y; + : ^ + \`---- + x create-once-plugin(skip-run): before hook: filename: files/2.js ,-[files/2.js:1:1] 1 | let y; @@ -790,26 +868,53 @@ exports[`oxlint CLI > should support \`createOnce\` 1`] = ` : ^ \`---- + x create-once-plugin(after-only): ident visit fn "y": filename: files/2.js + ,-[files/2.js:1:5] + 1 | let y; + : ^ + \`---- + x create-once-plugin(always-run): ident visit fn "y": filename: files/2.js ,-[files/2.js:1:5] 1 | let y; : ^ \`---- -Found 0 warnings and 26 errors. + x create-once-plugin(before-only): ident visit fn "y": filename: files/2.js + ,-[files/2.js:1:5] + 1 | let y; + : ^ + \`---- + + x create-once-plugin(no-hooks): ident visit fn "y": filename: files/2.js + ,-[files/2.js:1:5] + 1 | let y; + : ^ + \`---- + +Found 0 warnings and 42 errors. Finished in Xms on 2 files using X threads." `; -exports[`oxlint CLI > should support \`defineRule\` + \`definePlugin\` 1`] = ` +exports[`oxlint CLI > should support \`definePlugin\` 1`] = ` " - x define-rule-plugin(create): create body: + x define-plugin-plugin(create): create body: | this === rule: true ,-[files/1.js:1:1] 1 | let a, b; : ^ \`---- - x define-rule-plugin(create-once): after hook: + x define-plugin-plugin(create-once): before hook: + | createOnce call count: 1 + | this === rule: true + | filename: files/1.js + ,-[files/1.js:1:1] + 1 | let a, b; + : ^ + \`---- + + x define-plugin-plugin(create-once): after hook: | identNum: 2 | filename: files/1.js ,-[files/1.js:1:1] @@ -817,22 +922,35 @@ exports[`oxlint CLI > should support \`defineRule\` + \`definePlugin\` 1`] = ` : ^ \`---- - x define-rule-plugin(create-once): before hook: - | this === rule: true + x define-plugin-plugin(create-once-after-only): after hook: | filename: files/1.js ,-[files/1.js:1:1] 1 | let a, b; : ^ \`---- - x define-rule-plugin(create): ident visit fn "a": + x define-plugin-plugin(create-once-before-false): before hook: + | filename: files/1.js + ,-[files/1.js:1:1] + 1 | let a, b; + : ^ + \`---- + + x define-plugin-plugin(create-once-before-only): before hook: + | filename: files/1.js + ,-[files/1.js:1:1] + 1 | let a, b; + : ^ + \`---- + + x define-plugin-plugin(create): ident visit fn "a": | filename: files/1.js ,-[files/1.js:1:5] 1 | let a, b; : ^ \`---- - x define-rule-plugin(create-once): ident visit fn "a": + x define-plugin-plugin(create-once): ident visit fn "a": | identNum: 1 | filename: files/1.js ,-[files/1.js:1:5] @@ -840,14 +958,35 @@ exports[`oxlint CLI > should support \`defineRule\` + \`definePlugin\` 1`] = ` : ^ \`---- - x define-rule-plugin(create): ident visit fn "b": + x define-plugin-plugin(create-once-after-only): ident visit fn "a": + | filename: files/1.js + ,-[files/1.js:1:5] + 1 | let a, b; + : ^ + \`---- + + x define-plugin-plugin(create-once-before-only): ident visit fn "a": + | filename: files/1.js + ,-[files/1.js:1:5] + 1 | let a, b; + : ^ + \`---- + + x define-plugin-plugin(create-once-no-hooks): ident visit fn "a": + | filename: files/1.js + ,-[files/1.js:1:5] + 1 | let a, b; + : ^ + \`---- + + x define-plugin-plugin(create): ident visit fn "b": | filename: files/1.js ,-[files/1.js:1:8] 1 | let a, b; : ^ \`---- - x define-rule-plugin(create-once): ident visit fn "b": + x define-plugin-plugin(create-once): ident visit fn "b": | identNum: 2 | filename: files/1.js ,-[files/1.js:1:8] @@ -855,14 +994,44 @@ exports[`oxlint CLI > should support \`defineRule\` + \`definePlugin\` 1`] = ` : ^ \`---- - x define-rule-plugin(create): create body: + x define-plugin-plugin(create-once-after-only): ident visit fn "b": + | filename: files/1.js + ,-[files/1.js:1:8] + 1 | let a, b; + : ^ + \`---- + + x define-plugin-plugin(create-once-before-only): ident visit fn "b": + | filename: files/1.js + ,-[files/1.js:1:8] + 1 | let a, b; + : ^ + \`---- + + x define-plugin-plugin(create-once-no-hooks): ident visit fn "b": + | filename: files/1.js + ,-[files/1.js:1:8] + 1 | let a, b; + : ^ + \`---- + + x define-plugin-plugin(create): create body: | this === rule: true ,-[files/2.js:1:1] 1 | let c, d; : ^ \`---- - x define-rule-plugin(create-once): after hook: + x define-plugin-plugin(create-once): before hook: + | createOnce call count: 1 + | this === rule: true + | filename: files/2.js + ,-[files/2.js:1:1] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-plugin(create-once): after hook: | identNum: 2 | filename: files/2.js ,-[files/2.js:1:1] @@ -870,22 +1039,42 @@ exports[`oxlint CLI > should support \`defineRule\` + \`definePlugin\` 1`] = ` : ^ \`---- - x define-rule-plugin(create-once): before hook: - | this === rule: true + x define-plugin-plugin(create-once-after-only): after hook: | filename: files/2.js ,-[files/2.js:1:1] 1 | let c, d; : ^ \`---- - x define-rule-plugin(create): ident visit fn "c": + x define-plugin-plugin(create-once-before-false): before hook: + | filename: files/2.js + ,-[files/2.js:1:1] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-plugin(create-once-before-false): after hook: + | filename: files/2.js + ,-[files/2.js:1:1] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-plugin(create-once-before-only): before hook: + | filename: files/2.js + ,-[files/2.js:1:1] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-plugin(create): ident visit fn "c": | filename: files/2.js ,-[files/2.js:1:5] 1 | let c, d; : ^ \`---- - x define-rule-plugin(create-once): ident visit fn "c": + x define-plugin-plugin(create-once): ident visit fn "c": | identNum: 1 | filename: files/2.js ,-[files/2.js:1:5] @@ -893,22 +1082,600 @@ exports[`oxlint CLI > should support \`defineRule\` + \`definePlugin\` 1`] = ` : ^ \`---- - x define-rule-plugin(create): ident visit fn "d": + x define-plugin-plugin(create-once-after-only): ident visit fn "c": + | filename: files/2.js + ,-[files/2.js:1:5] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-plugin(create-once-before-false): ident visit fn "c": + | filename: files/2.js + ,-[files/2.js:1:5] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-plugin(create-once-before-only): ident visit fn "c": + | filename: files/2.js + ,-[files/2.js:1:5] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-plugin(create-once-no-hooks): ident visit fn "c": + | filename: files/2.js + ,-[files/2.js:1:5] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-plugin(create): ident visit fn "d": | filename: files/2.js ,-[files/2.js:1:8] 1 | let c, d; : ^ \`---- - x define-rule-plugin(create-once): ident visit fn "d": + x define-plugin-plugin(create-once): ident visit fn "d": + | identNum: 2 + | filename: files/2.js + ,-[files/2.js:1:8] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-plugin(create-once-after-only): ident visit fn "d": + | filename: files/2.js + ,-[files/2.js:1:8] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-plugin(create-once-before-false): ident visit fn "d": + | filename: files/2.js + ,-[files/2.js:1:8] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-plugin(create-once-before-only): ident visit fn "d": + | filename: files/2.js + ,-[files/2.js:1:8] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-plugin(create-once-no-hooks): ident visit fn "d": + | filename: files/2.js + ,-[files/2.js:1:8] + 1 | let c, d; + : ^ + \`---- + +Found 0 warnings and 35 errors. +Finished in Xms on 2 files using X threads." +`; + +exports[`oxlint CLI > should support \`definePlugin\` and \`defineRule\` together 1`] = ` +" + x define-plugin-and-rule-plugin(create): create body: + | this === rule: true + ,-[files/1.js:1:1] + 1 | let a, b; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once): before hook: + | createOnce call count: 1 + | this === rule: true + | filename: files/1.js + ,-[files/1.js:1:1] + 1 | let a, b; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once): after hook: | identNum: 2 + | filename: files/1.js + ,-[files/1.js:1:1] + 1 | let a, b; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once-after-only): after hook: + | filename: files/1.js + ,-[files/1.js:1:1] + 1 | let a, b; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once-before-false): before hook: + | filename: files/1.js + ,-[files/1.js:1:1] + 1 | let a, b; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once-before-only): before hook: + | filename: files/1.js + ,-[files/1.js:1:1] + 1 | let a, b; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create): ident visit fn "a": + | filename: files/1.js + ,-[files/1.js:1:5] + 1 | let a, b; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once): ident visit fn "a": + | identNum: 1 + | filename: files/1.js + ,-[files/1.js:1:5] + 1 | let a, b; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once-after-only): ident visit fn "a": + | filename: files/1.js + ,-[files/1.js:1:5] + 1 | let a, b; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once-before-only): ident visit fn "a": + | filename: files/1.js + ,-[files/1.js:1:5] + 1 | let a, b; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once-no-hooks): ident visit fn "a": + | filename: files/1.js + ,-[files/1.js:1:5] + 1 | let a, b; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create): ident visit fn "b": + | filename: files/1.js + ,-[files/1.js:1:8] + 1 | let a, b; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once): ident visit fn "b": + | identNum: 2 + | filename: files/1.js + ,-[files/1.js:1:8] + 1 | let a, b; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once-after-only): ident visit fn "b": + | filename: files/1.js + ,-[files/1.js:1:8] + 1 | let a, b; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once-before-only): ident visit fn "b": + | filename: files/1.js + ,-[files/1.js:1:8] + 1 | let a, b; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once-no-hooks): ident visit fn "b": + | filename: files/1.js + ,-[files/1.js:1:8] + 1 | let a, b; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create): create body: + | this === rule: true + ,-[files/2.js:1:1] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once): before hook: + | createOnce call count: 1 + | this === rule: true + | filename: files/2.js + ,-[files/2.js:1:1] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once): after hook: + | identNum: 2 + | filename: files/2.js + ,-[files/2.js:1:1] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once-after-only): after hook: + | filename: files/2.js + ,-[files/2.js:1:1] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once-before-false): before hook: + | filename: files/2.js + ,-[files/2.js:1:1] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once-before-false): after hook: + | filename: files/2.js + ,-[files/2.js:1:1] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once-before-only): before hook: + | filename: files/2.js + ,-[files/2.js:1:1] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create): ident visit fn "c": + | filename: files/2.js + ,-[files/2.js:1:5] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once): ident visit fn "c": + | identNum: 1 + | filename: files/2.js + ,-[files/2.js:1:5] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once-after-only): ident visit fn "c": + | filename: files/2.js + ,-[files/2.js:1:5] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once-before-false): ident visit fn "c": + | filename: files/2.js + ,-[files/2.js:1:5] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once-before-only): ident visit fn "c": + | filename: files/2.js + ,-[files/2.js:1:5] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once-no-hooks): ident visit fn "c": + | filename: files/2.js + ,-[files/2.js:1:5] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create): ident visit fn "d": + | filename: files/2.js + ,-[files/2.js:1:8] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once): ident visit fn "d": + | identNum: 2 + | filename: files/2.js + ,-[files/2.js:1:8] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once-after-only): ident visit fn "d": + | filename: files/2.js + ,-[files/2.js:1:8] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once-before-false): ident visit fn "d": + | filename: files/2.js + ,-[files/2.js:1:8] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once-before-only): ident visit fn "d": + | filename: files/2.js + ,-[files/2.js:1:8] + 1 | let c, d; + : ^ + \`---- + + x define-plugin-and-rule-plugin(create-once-no-hooks): ident visit fn "d": + | filename: files/2.js + ,-[files/2.js:1:8] + 1 | let c, d; + : ^ + \`---- + +Found 0 warnings and 35 errors. +Finished in Xms on 2 files using X threads." +`; + +exports[`oxlint CLI > should support \`defineRule\` 1`] = ` +" + x define-rule-plugin(create): create body: + | this === rule: true + ,-[files/1.js:1:1] + 1 | let a, b; + : ^ + \`---- + + x define-rule-plugin(create-once): before hook: + | createOnce call count: 1 + | this === rule: true + | filename: files/1.js + ,-[files/1.js:1:1] + 1 | let a, b; + : ^ + \`---- + + x define-rule-plugin(create-once): after hook: + | identNum: 2 + | filename: files/1.js + ,-[files/1.js:1:1] + 1 | let a, b; + : ^ + \`---- + + x define-rule-plugin(create-once-after-only): after hook: + | filename: files/1.js + ,-[files/1.js:1:1] + 1 | let a, b; + : ^ + \`---- + + x define-rule-plugin(create-once-before-false): before hook: + | filename: files/1.js + ,-[files/1.js:1:1] + 1 | let a, b; + : ^ + \`---- + + x define-rule-plugin(create-once-before-only): before hook: + | filename: files/1.js + ,-[files/1.js:1:1] + 1 | let a, b; + : ^ + \`---- + + x define-rule-plugin(create): ident visit fn "a": + | filename: files/1.js + ,-[files/1.js:1:5] + 1 | let a, b; + : ^ + \`---- + + x define-rule-plugin(create-once): ident visit fn "a": + | identNum: 1 + | filename: files/1.js + ,-[files/1.js:1:5] + 1 | let a, b; + : ^ + \`---- + + x define-rule-plugin(create-once-after-only): ident visit fn "a": + | filename: files/1.js + ,-[files/1.js:1:5] + 1 | let a, b; + : ^ + \`---- + + x define-rule-plugin(create-once-before-only): ident visit fn "a": + | filename: files/1.js + ,-[files/1.js:1:5] + 1 | let a, b; + : ^ + \`---- + + x define-rule-plugin(create-once-no-hooks): ident visit fn "a": + | filename: files/1.js + ,-[files/1.js:1:5] + 1 | let a, b; + : ^ + \`---- + + x define-rule-plugin(create): ident visit fn "b": + | filename: files/1.js + ,-[files/1.js:1:8] + 1 | let a, b; + : ^ + \`---- + + x define-rule-plugin(create-once): ident visit fn "b": + | identNum: 2 + | filename: files/1.js + ,-[files/1.js:1:8] + 1 | let a, b; + : ^ + \`---- + + x define-rule-plugin(create-once-after-only): ident visit fn "b": + | filename: files/1.js + ,-[files/1.js:1:8] + 1 | let a, b; + : ^ + \`---- + + x define-rule-plugin(create-once-before-only): ident visit fn "b": + | filename: files/1.js + ,-[files/1.js:1:8] + 1 | let a, b; + : ^ + \`---- + + x define-rule-plugin(create-once-no-hooks): ident visit fn "b": + | filename: files/1.js + ,-[files/1.js:1:8] + 1 | let a, b; + : ^ + \`---- + + x define-rule-plugin(create): create body: + | this === rule: true + ,-[files/2.js:1:1] + 1 | let c, d; + : ^ + \`---- + + x define-rule-plugin(create-once): before hook: + | createOnce call count: 1 + | this === rule: true + | filename: files/2.js + ,-[files/2.js:1:1] + 1 | let c, d; + : ^ + \`---- + + x define-rule-plugin(create-once): after hook: + | identNum: 2 + | filename: files/2.js + ,-[files/2.js:1:1] + 1 | let c, d; + : ^ + \`---- + + x define-rule-plugin(create-once-after-only): after hook: + | filename: files/2.js + ,-[files/2.js:1:1] + 1 | let c, d; + : ^ + \`---- + + x define-rule-plugin(create-once-before-false): before hook: + | filename: files/2.js + ,-[files/2.js:1:1] + 1 | let c, d; + : ^ + \`---- + + x define-rule-plugin(create-once-before-false): after hook: + | filename: files/2.js + ,-[files/2.js:1:1] + 1 | let c, d; + : ^ + \`---- + + x define-rule-plugin(create-once-before-only): before hook: + | filename: files/2.js + ,-[files/2.js:1:1] + 1 | let c, d; + : ^ + \`---- + + x define-rule-plugin(create): ident visit fn "c": + | filename: files/2.js + ,-[files/2.js:1:5] + 1 | let c, d; + : ^ + \`---- + + x define-rule-plugin(create-once): ident visit fn "c": + | identNum: 1 + | filename: files/2.js + ,-[files/2.js:1:5] + 1 | let c, d; + : ^ + \`---- + + x define-rule-plugin(create-once-after-only): ident visit fn "c": + | filename: files/2.js + ,-[files/2.js:1:5] + 1 | let c, d; + : ^ + \`---- + + x define-rule-plugin(create-once-before-false): ident visit fn "c": + | filename: files/2.js + ,-[files/2.js:1:5] + 1 | let c, d; + : ^ + \`---- + + x define-rule-plugin(create-once-before-only): ident visit fn "c": + | filename: files/2.js + ,-[files/2.js:1:5] + 1 | let c, d; + : ^ + \`---- + + x define-rule-plugin(create-once-no-hooks): ident visit fn "c": + | filename: files/2.js + ,-[files/2.js:1:5] + 1 | let c, d; + : ^ + \`---- + + x define-rule-plugin(create): ident visit fn "d": + | filename: files/2.js + ,-[files/2.js:1:8] + 1 | let c, d; + : ^ + \`---- + + x define-rule-plugin(create-once): ident visit fn "d": + | identNum: 2 + | filename: files/2.js + ,-[files/2.js:1:8] + 1 | let c, d; + : ^ + \`---- + + x define-rule-plugin(create-once-after-only): ident visit fn "d": + | filename: files/2.js + ,-[files/2.js:1:8] + 1 | let c, d; + : ^ + \`---- + + x define-rule-plugin(create-once-before-false): ident visit fn "d": + | filename: files/2.js + ,-[files/2.js:1:8] + 1 | let c, d; + : ^ + \`---- + + x define-rule-plugin(create-once-before-only): ident visit fn "d": + | filename: files/2.js + ,-[files/2.js:1:8] + 1 | let c, d; + : ^ + \`---- + + x define-rule-plugin(create-once-no-hooks): ident visit fn "d": | filename: files/2.js ,-[files/2.js:1:8] 1 | let c, d; : ^ \`---- -Found 0 warnings and 14 errors. +Found 0 warnings and 35 errors. Finished in Xms on 2 files using X threads." `; diff --git a/apps/oxlint/test/__snapshots__/eslint-compat.test.ts.snap b/apps/oxlint/test/__snapshots__/eslint-compat.test.ts.snap index 923475359e7cf..c7007e737773f 100644 --- a/apps/oxlint/test/__snapshots__/eslint-compat.test.ts.snap +++ b/apps/oxlint/test/__snapshots__/eslint-compat.test.ts.snap @@ -1,47 +1,271 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`ESLint compatibility > \`defineRule\` + \`definePlugin\` should work 1`] = ` +exports[`ESLint compatibility > \`definePlugin\` and \`defineRule\` together should work 1`] = ` " -/apps/oxlint/test/fixtures/define/files/1.js +/apps/oxlint/test/fixtures/definePlugin_and_defineRule/files/1.js 0:1 error create body: -this === rule: true testPlugin/create +this === rule: true define-plugin-and-rule-plugin/create 0:1 error before hook: +createOnce call count: 1 this === rule: true -filename: files/1.js testPlugin/create-once +filename: files/1.js define-plugin-and-rule-plugin/create-once + 0:1 error before hook: +filename: files/1.js define-plugin-and-rule-plugin/create-once-before-false + 0:1 error before hook: +filename: files/1.js define-plugin-and-rule-plugin/create-once-before-only 0:1 error after hook: identNum: 2 -filename: files/1.js testPlugin/create-once +filename: files/1.js define-plugin-and-rule-plugin/create-once + 0:1 error after hook: +filename: files/1.js define-plugin-and-rule-plugin/create-once-after-only 1:5 error ident visit fn "a": -filename: files/1.js testPlugin/create +filename: files/1.js define-plugin-and-rule-plugin/create 1:5 error ident visit fn "a": identNum: 1 -filename: files/1.js testPlugin/create-once +filename: files/1.js define-plugin-and-rule-plugin/create-once + 1:5 error ident visit fn "a": +filename: files/1.js define-plugin-and-rule-plugin/create-once-before-only + 1:5 error ident visit fn "a": +filename: files/1.js define-plugin-and-rule-plugin/create-once-after-only + 1:5 error ident visit fn "a": +filename: files/1.js define-plugin-and-rule-plugin/create-once-no-hooks 1:8 error ident visit fn "b": -filename: files/1.js testPlugin/create +filename: files/1.js define-plugin-and-rule-plugin/create 1:8 error ident visit fn "b": identNum: 2 -filename: files/1.js testPlugin/create-once +filename: files/1.js define-plugin-and-rule-plugin/create-once + 1:8 error ident visit fn "b": +filename: files/1.js define-plugin-and-rule-plugin/create-once-before-only + 1:8 error ident visit fn "b": +filename: files/1.js define-plugin-and-rule-plugin/create-once-after-only + 1:8 error ident visit fn "b": +filename: files/1.js define-plugin-and-rule-plugin/create-once-no-hooks -/apps/oxlint/test/fixtures/define/files/2.js +/apps/oxlint/test/fixtures/definePlugin_and_defineRule/files/2.js 0:1 error create body: -this === rule: true testPlugin/create +this === rule: true define-plugin-and-rule-plugin/create 0:1 error before hook: +createOnce call count: 1 this === rule: true -filename: files/2.js testPlugin/create-once +filename: files/2.js define-plugin-and-rule-plugin/create-once + 0:1 error before hook: +filename: files/2.js define-plugin-and-rule-plugin/create-once-before-false + 0:1 error before hook: +filename: files/2.js define-plugin-and-rule-plugin/create-once-before-only 0:1 error after hook: identNum: 2 -filename: files/2.js testPlugin/create-once +filename: files/2.js define-plugin-and-rule-plugin/create-once + 0:1 error after hook: +filename: files/2.js define-plugin-and-rule-plugin/create-once-before-false + 0:1 error after hook: +filename: files/2.js define-plugin-and-rule-plugin/create-once-after-only 1:5 error ident visit fn "c": -filename: files/2.js testPlugin/create +filename: files/2.js define-plugin-and-rule-plugin/create 1:5 error ident visit fn "c": identNum: 1 -filename: files/2.js testPlugin/create-once +filename: files/2.js define-plugin-and-rule-plugin/create-once + 1:5 error ident visit fn "c": +filename: files/2.js define-plugin-and-rule-plugin/create-once-before-false + 1:5 error ident visit fn "c": +filename: files/2.js define-plugin-and-rule-plugin/create-once-before-only + 1:5 error ident visit fn "c": +filename: files/2.js define-plugin-and-rule-plugin/create-once-after-only + 1:5 error ident visit fn "c": +filename: files/2.js define-plugin-and-rule-plugin/create-once-no-hooks + 1:8 error ident visit fn "d": +filename: files/2.js define-plugin-and-rule-plugin/create + 1:8 error ident visit fn "d": +identNum: 2 +filename: files/2.js define-plugin-and-rule-plugin/create-once + 1:8 error ident visit fn "d": +filename: files/2.js define-plugin-and-rule-plugin/create-once-before-false 1:8 error ident visit fn "d": -filename: files/2.js testPlugin/create +filename: files/2.js define-plugin-and-rule-plugin/create-once-before-only 1:8 error ident visit fn "d": +filename: files/2.js define-plugin-and-rule-plugin/create-once-after-only + 1:8 error ident visit fn "d": +filename: files/2.js define-plugin-and-rule-plugin/create-once-no-hooks + +โœ– 35 problems (35 errors, 0 warnings) +" +`; + +exports[`ESLint compatibility > \`definePlugin\` should work 1`] = ` +" +/apps/oxlint/test/fixtures/definePlugin/files/1.js + 0:1 error create body: +this === rule: true define-plugin-plugin/create + 0:1 error before hook: +createOnce call count: 1 +this === rule: true +filename: files/1.js define-plugin-plugin/create-once + 0:1 error before hook: +filename: files/1.js define-plugin-plugin/create-once-before-false + 0:1 error before hook: +filename: files/1.js define-plugin-plugin/create-once-before-only + 0:1 error after hook: +identNum: 2 +filename: files/1.js define-plugin-plugin/create-once + 0:1 error after hook: +filename: files/1.js define-plugin-plugin/create-once-after-only + 1:5 error ident visit fn "a": +filename: files/1.js define-plugin-plugin/create + 1:5 error ident visit fn "a": +identNum: 1 +filename: files/1.js define-plugin-plugin/create-once + 1:5 error ident visit fn "a": +filename: files/1.js define-plugin-plugin/create-once-before-only + 1:5 error ident visit fn "a": +filename: files/1.js define-plugin-plugin/create-once-after-only + 1:5 error ident visit fn "a": +filename: files/1.js define-plugin-plugin/create-once-no-hooks + 1:8 error ident visit fn "b": +filename: files/1.js define-plugin-plugin/create + 1:8 error ident visit fn "b": +identNum: 2 +filename: files/1.js define-plugin-plugin/create-once + 1:8 error ident visit fn "b": +filename: files/1.js define-plugin-plugin/create-once-before-only + 1:8 error ident visit fn "b": +filename: files/1.js define-plugin-plugin/create-once-after-only + 1:8 error ident visit fn "b": +filename: files/1.js define-plugin-plugin/create-once-no-hooks + +/apps/oxlint/test/fixtures/definePlugin/files/2.js + 0:1 error create body: +this === rule: true define-plugin-plugin/create + 0:1 error before hook: +createOnce call count: 1 +this === rule: true +filename: files/2.js define-plugin-plugin/create-once + 0:1 error before hook: +filename: files/2.js define-plugin-plugin/create-once-before-false + 0:1 error before hook: +filename: files/2.js define-plugin-plugin/create-once-before-only + 0:1 error after hook: identNum: 2 -filename: files/2.js testPlugin/create-once +filename: files/2.js define-plugin-plugin/create-once + 0:1 error after hook: +filename: files/2.js define-plugin-plugin/create-once-before-false + 0:1 error after hook: +filename: files/2.js define-plugin-plugin/create-once-after-only + 1:5 error ident visit fn "c": +filename: files/2.js define-plugin-plugin/create + 1:5 error ident visit fn "c": +identNum: 1 +filename: files/2.js define-plugin-plugin/create-once + 1:5 error ident visit fn "c": +filename: files/2.js define-plugin-plugin/create-once-before-false + 1:5 error ident visit fn "c": +filename: files/2.js define-plugin-plugin/create-once-before-only + 1:5 error ident visit fn "c": +filename: files/2.js define-plugin-plugin/create-once-after-only + 1:5 error ident visit fn "c": +filename: files/2.js define-plugin-plugin/create-once-no-hooks + 1:8 error ident visit fn "d": +filename: files/2.js define-plugin-plugin/create + 1:8 error ident visit fn "d": +identNum: 2 +filename: files/2.js define-plugin-plugin/create-once + 1:8 error ident visit fn "d": +filename: files/2.js define-plugin-plugin/create-once-before-false + 1:8 error ident visit fn "d": +filename: files/2.js define-plugin-plugin/create-once-before-only + 1:8 error ident visit fn "d": +filename: files/2.js define-plugin-plugin/create-once-after-only + 1:8 error ident visit fn "d": +filename: files/2.js define-plugin-plugin/create-once-no-hooks + +โœ– 35 problems (35 errors, 0 warnings) +" +`; + +exports[`ESLint compatibility > \`defineRule\` should work 1`] = ` +" +/apps/oxlint/test/fixtures/defineRule/files/1.js + 0:1 error create body: +this === rule: true define-rule-plugin/create + 0:1 error before hook: +createOnce call count: 1 +this === rule: true +filename: files/1.js define-rule-plugin/create-once + 0:1 error before hook: +filename: files/1.js define-rule-plugin/create-once-before-false + 0:1 error before hook: +filename: files/1.js define-rule-plugin/create-once-before-only + 0:1 error after hook: +identNum: 2 +filename: files/1.js define-rule-plugin/create-once + 0:1 error after hook: +filename: files/1.js define-rule-plugin/create-once-after-only + 1:5 error ident visit fn "a": +filename: files/1.js define-rule-plugin/create + 1:5 error ident visit fn "a": +identNum: 1 +filename: files/1.js define-rule-plugin/create-once + 1:5 error ident visit fn "a": +filename: files/1.js define-rule-plugin/create-once-before-only + 1:5 error ident visit fn "a": +filename: files/1.js define-rule-plugin/create-once-after-only + 1:5 error ident visit fn "a": +filename: files/1.js define-rule-plugin/create-once-no-hooks + 1:8 error ident visit fn "b": +filename: files/1.js define-rule-plugin/create + 1:8 error ident visit fn "b": +identNum: 2 +filename: files/1.js define-rule-plugin/create-once + 1:8 error ident visit fn "b": +filename: files/1.js define-rule-plugin/create-once-before-only + 1:8 error ident visit fn "b": +filename: files/1.js define-rule-plugin/create-once-after-only + 1:8 error ident visit fn "b": +filename: files/1.js define-rule-plugin/create-once-no-hooks + +/apps/oxlint/test/fixtures/defineRule/files/2.js + 0:1 error create body: +this === rule: true define-rule-plugin/create + 0:1 error before hook: +createOnce call count: 1 +this === rule: true +filename: files/2.js define-rule-plugin/create-once + 0:1 error before hook: +filename: files/2.js define-rule-plugin/create-once-before-false + 0:1 error before hook: +filename: files/2.js define-rule-plugin/create-once-before-only + 0:1 error after hook: +identNum: 2 +filename: files/2.js define-rule-plugin/create-once + 0:1 error after hook: +filename: files/2.js define-rule-plugin/create-once-before-false + 0:1 error after hook: +filename: files/2.js define-rule-plugin/create-once-after-only + 1:5 error ident visit fn "c": +filename: files/2.js define-rule-plugin/create + 1:5 error ident visit fn "c": +identNum: 1 +filename: files/2.js define-rule-plugin/create-once + 1:5 error ident visit fn "c": +filename: files/2.js define-rule-plugin/create-once-before-false + 1:5 error ident visit fn "c": +filename: files/2.js define-rule-plugin/create-once-before-only + 1:5 error ident visit fn "c": +filename: files/2.js define-rule-plugin/create-once-after-only + 1:5 error ident visit fn "c": +filename: files/2.js define-rule-plugin/create-once-no-hooks + 1:8 error ident visit fn "d": +filename: files/2.js define-rule-plugin/create + 1:8 error ident visit fn "d": +identNum: 2 +filename: files/2.js define-rule-plugin/create-once + 1:8 error ident visit fn "d": +filename: files/2.js define-rule-plugin/create-once-before-false + 1:8 error ident visit fn "d": +filename: files/2.js define-rule-plugin/create-once-before-only + 1:8 error ident visit fn "d": +filename: files/2.js define-rule-plugin/create-once-after-only + 1:8 error ident visit fn "d": +filename: files/2.js define-rule-plugin/create-once-no-hooks -โœ– 14 problems (14 errors, 0 warnings) +โœ– 35 problems (35 errors, 0 warnings) " `; diff --git a/apps/oxlint/test/e2e.test.ts b/apps/oxlint/test/e2e.test.ts index 6e66c32bf55be..fa01fb6929ef7 100644 --- a/apps/oxlint/test/e2e.test.ts +++ b/apps/oxlint/test/e2e.test.ts @@ -154,8 +154,20 @@ describe('oxlint CLI', () => { expect(normalizeOutput(stdout)).toMatchSnapshot(); }); - it('should support `defineRule` + `definePlugin`', async () => { - const { stdout, exitCode } = await runOxlint('test/fixtures/define'); + it('should support `defineRule`', async () => { + const { stdout, exitCode } = await runOxlint('test/fixtures/defineRule'); + expect(exitCode).toBe(1); + expect(normalizeOutput(stdout)).toMatchSnapshot(); + }); + + it('should support `definePlugin`', async () => { + const { stdout, exitCode } = await runOxlint('test/fixtures/definePlugin'); + expect(exitCode).toBe(1); + expect(normalizeOutput(stdout)).toMatchSnapshot(); + }); + + it('should support `definePlugin` and `defineRule` together', async () => { + const { stdout, exitCode } = await runOxlint('test/fixtures/definePlugin_and_defineRule'); expect(exitCode).toBe(1); expect(normalizeOutput(stdout)).toMatchSnapshot(); }); diff --git a/apps/oxlint/test/eslint-compat.test.ts b/apps/oxlint/test/eslint-compat.test.ts index a80e02b8687c8..55c3ae9042161 100644 --- a/apps/oxlint/test/eslint-compat.test.ts +++ b/apps/oxlint/test/eslint-compat.test.ts @@ -24,8 +24,20 @@ function normalizeOutput(output: string): string { } describe('ESLint compatibility', () => { - it('`defineRule` + `definePlugin` should work', async () => { - const { stdout, exitCode } = await runEslint('test/fixtures/define'); + it('`defineRule` should work', async () => { + const { stdout, exitCode } = await runEslint('test/fixtures/defineRule'); + expect(exitCode).toBe(1); + expect(normalizeOutput(stdout)).toMatchSnapshot(); + }); + + it('`definePlugin` should work', async () => { + const { stdout, exitCode } = await runEslint('test/fixtures/definePlugin'); + expect(exitCode).toBe(1); + expect(normalizeOutput(stdout)).toMatchSnapshot(); + }); + + it('`definePlugin` and `defineRule` together should work', async () => { + const { stdout, exitCode } = await runEslint('test/fixtures/definePlugin_and_defineRule'); expect(exitCode).toBe(1); expect(normalizeOutput(stdout)).toMatchSnapshot(); }); diff --git a/apps/oxlint/test/fixtures/createOnce/.oxlintrc.json b/apps/oxlint/test/fixtures/createOnce/.oxlintrc.json index 420f65a46c1e6..ca3cd35d81c63 100644 --- a/apps/oxlint/test/fixtures/createOnce/.oxlintrc.json +++ b/apps/oxlint/test/fixtures/createOnce/.oxlintrc.json @@ -3,7 +3,10 @@ "categories": {"correctness": "off"}, "rules": { "create-once-plugin/always-run": "error", - "create-once-plugin/skip-run": "error" + "create-once-plugin/skip-run": "error", + "create-once-plugin/before-only": "error", + "create-once-plugin/after-only": "error", + "create-once-plugin/no-hooks": "error" }, "ignorePatterns": ["test_plugin/**"] } diff --git a/apps/oxlint/test/fixtures/createOnce/test_plugin/index.js b/apps/oxlint/test/fixtures/createOnce/test_plugin/index.js index 66e5216572a47..b47f63fbc44b4 100644 --- a/apps/oxlint/test/fixtures/createOnce/test_plugin/index.js +++ b/apps/oxlint/test/fixtures/createOnce/test_plugin/index.js @@ -8,8 +8,11 @@ const relativePath = sep === '/' ? path => path.slice(PARENT_DIR_PATH_LEN) : path => path.slice(PARENT_DIR_PATH_LEN).replace(/\\/g, '/'); +let createOnceCallCount = 0; const alwaysRunRule = { createOnce(context) { + createOnceCallCount++; + const topLevelThis = this; // Check that these APIs throw here @@ -21,6 +24,7 @@ const alwaysRunRule = { return { before() { + context.report({ message: `createOnce: call count: ${createOnceCallCount}`, node: SPAN }); context.report({ message: `createOnce: this === rule: ${topLevelThis === alwaysRunRule}`, node: SPAN }); context.report({ message: `createOnce: id: ${idError?.message}`, node: SPAN }); context.report({ message: `createOnce: filename: ${filenameError?.message}`, node: SPAN }); @@ -64,6 +68,53 @@ const skipRunRule = { }, }; +const beforeOnlyRule = { + createOnce(context) { + return { + before() { + context.report({ message: `before hook: id: ${context.id}`, node: SPAN }); + context.report({ message: `before hook: filename: ${relativePath(context.filename)}`, node: SPAN }); + }, + Identifier(node) { + context.report({ + message: `ident visit fn "${node.name}": filename: ${relativePath(context.filename)}`, + node, + }); + }, + }; + }, +}; + +const afterOnlyRule = { + createOnce(context) { + return { + Identifier(node) { + context.report({ + message: `ident visit fn "${node.name}": filename: ${relativePath(context.filename)}`, + node, + }); + }, + after() { + context.report({ message: `after hook: id: ${context.id}`, node: SPAN }); + context.report({ message: `after hook: filename: ${relativePath(context.filename)}`, node: SPAN }); + }, + }; + }, +}; + +const noHooksRule = { + createOnce(context) { + return { + Identifier(node) { + context.report({ + message: `ident visit fn "${node.name}": filename: ${relativePath(context.filename)}`, + node, + }); + }, + }; + }, +}; + export default { meta: { name: "create-once-plugin", @@ -71,6 +122,9 @@ export default { rules: { "always-run": alwaysRunRule, "skip-run": skipRunRule, + "before-only": beforeOnlyRule, + "after-only": afterOnlyRule, + "no-hooks": noHooksRule, }, }; diff --git a/apps/oxlint/test/fixtures/define/.oxlintrc.json b/apps/oxlint/test/fixtures/define/.oxlintrc.json deleted file mode 100644 index 3f2263bf1c12b..0000000000000 --- a/apps/oxlint/test/fixtures/define/.oxlintrc.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "plugins": ["./test_plugin"], - "categories": {"correctness": "off"}, - "rules": { - "define-rule-plugin/create": "error", - "define-rule-plugin/create-once": "error" - }, - "ignorePatterns": ["test_plugin/**", "eslint.config.js"] -} diff --git a/apps/oxlint/test/fixtures/define/eslint.config.js b/apps/oxlint/test/fixtures/define/eslint.config.js deleted file mode 100644 index 7bf128e34ceb4..0000000000000 --- a/apps/oxlint/test/fixtures/define/eslint.config.js +++ /dev/null @@ -1,14 +0,0 @@ -import testPlugin from './test_plugin/index.js'; - -export default [ - { - files: ["files/*.js"], - plugins: { - testPlugin, - }, - rules: { - "testPlugin/create": "error", - "testPlugin/create-once": "error", - }, - }, -]; diff --git a/apps/oxlint/test/fixtures/definePlugin/.oxlintrc.json b/apps/oxlint/test/fixtures/definePlugin/.oxlintrc.json new file mode 100644 index 0000000000000..9907d5df9d0d5 --- /dev/null +++ b/apps/oxlint/test/fixtures/definePlugin/.oxlintrc.json @@ -0,0 +1,13 @@ +{ + "plugins": ["./test_plugin"], + "categories": {"correctness": "off"}, + "rules": { + "define-plugin-plugin/create": "error", + "define-plugin-plugin/create-once": "error", + "define-plugin-plugin/create-once-before-false": "error", + "define-plugin-plugin/create-once-before-only": "error", + "define-plugin-plugin/create-once-after-only": "error", + "define-plugin-plugin/create-once-no-hooks": "error" + }, + "ignorePatterns": ["test_plugin/**", "eslint.config.js"] +} diff --git a/apps/oxlint/test/fixtures/definePlugin/eslint.config.js b/apps/oxlint/test/fixtures/definePlugin/eslint.config.js new file mode 100644 index 0000000000000..12d1d420dfc30 --- /dev/null +++ b/apps/oxlint/test/fixtures/definePlugin/eslint.config.js @@ -0,0 +1,18 @@ +import testPlugin from './test_plugin/index.js'; + +export default [ + { + files: ["files/*.js"], + plugins: { + "define-plugin-plugin": testPlugin, + }, + rules: { + "define-plugin-plugin/create": "error", + "define-plugin-plugin/create-once": "error", + "define-plugin-plugin/create-once-before-false": "error", + "define-plugin-plugin/create-once-before-only": "error", + "define-plugin-plugin/create-once-after-only": "error", + "define-plugin-plugin/create-once-no-hooks": "error", + }, + }, +]; diff --git a/apps/oxlint/test/fixtures/define/files/1.js b/apps/oxlint/test/fixtures/definePlugin/files/1.js similarity index 100% rename from apps/oxlint/test/fixtures/define/files/1.js rename to apps/oxlint/test/fixtures/definePlugin/files/1.js diff --git a/apps/oxlint/test/fixtures/define/files/2.js b/apps/oxlint/test/fixtures/definePlugin/files/2.js similarity index 100% rename from apps/oxlint/test/fixtures/define/files/2.js rename to apps/oxlint/test/fixtures/definePlugin/files/2.js diff --git a/apps/oxlint/test/fixtures/definePlugin/test_plugin/index.js b/apps/oxlint/test/fixtures/definePlugin/test_plugin/index.js new file mode 100644 index 0000000000000..1d21f22f83d3e --- /dev/null +++ b/apps/oxlint/test/fixtures/definePlugin/test_plugin/index.js @@ -0,0 +1,210 @@ +import { dirname, sep } from 'node:path'; +import { definePlugin } from '../../../../dist/index.js'; + +// `loc` field is required for ESLint. +// TODO: Remove this workaround when AST nodes have a `loc` field. +const SPAN = { + start: 0, + end: 0, + loc: { + start: { line: 0, column: 0 }, + end: { line: 0, column: 0 }, + }, +}; + +const PARENT_DIR_PATH_LEN = dirname(import.meta.dirname).length + 1; + +const relativePath = sep === '/' + ? path => path.slice(PARENT_DIR_PATH_LEN) + : path => path.slice(PARENT_DIR_PATH_LEN).replace(/\\/g, '/'); + +const createRule = { + create(context) { + context.report({ message: `create body:\nthis === rule: ${this === createRule}`, node: SPAN }); + + return { + Identifier(node) { + context.report({ + message: `ident visit fn "${node.name}":\nfilename: ${relativePath(context.filename)}`, + node: { ...SPAN, ...node }, + }); + }, + }; + }, +}; + +// This aims to test that `createOnce` is called once only, and `before` hook is called once per file. +// i.e. Oxlint calls `createOnce` directly, and not the `create` method that `defineRule` (via `definePlugin`) +// adds to the rule. +let createOnceCallCount = 0; +const createOnceRule = { + createOnce(context) { + createOnceCallCount++; + + // `fileNum` should be different for each file. + // `identNum` should start at 1 for each file. + let fileNum = 0, identNum; + // Note: Files are processed in unpredictable order, so `files/1.js` may be `fileNum` 1 or 2. + // Therefore, collect all visits and check them in `after` hook of the 2nd file. + const visits = []; + + // `this` should be the rule object + const topLevelThis = this; + + return { + before() { + fileNum++; + identNum = 0; + + context.report({ + message: 'before hook:\n' + + `createOnce call count: ${createOnceCallCount}\n` + + `this === rule: ${topLevelThis === createOnceRule}\n` + + `filename: ${relativePath(context.filename)}`, + node: SPAN, + }); + }, + Identifier(node) { + identNum++; + visits.push({ fileNum, identNum }); + + context.report({ + message: `ident visit fn "${node.name}":\n` + + `identNum: ${identNum}\n` + + `filename: ${relativePath(context.filename)}`, + node: { ...SPAN, ...node }, + }); + }, + after() { + context.report({ + message: 'after hook:\n' + + `identNum: ${identNum}\n` + + `filename: ${relativePath(context.filename)}`, + node: SPAN, + }); + + if (fileNum === 2) { + visits.sort((v1, v2) => v1.fileNum - v2.fileNum); + + const expectedVisits = [ + { fileNum: 1, identNum: 1 }, + { fileNum: 1, identNum: 2 }, + { fileNum: 2, identNum: 1 }, + { fileNum: 2, identNum: 2 }, + ]; + + if ( + visits.length !== expectedVisits.length + || visits.some((v, i) => v.fileNum !== expectedVisits[i].fileNum || v.identNum !== expectedVisits[i].identNum) + ) { + context.report({ message: `Unexpected visits: ${JSON.stringify(visits)}`, node: SPAN }); + } + } + }, + }; + }, +}; + +// Tests that `before` hook returning `false` disables visiting AST for the file. +const createOnceBeforeFalseRule = { + createOnce(context) { + return { + before() { + context.report({ + message: 'before hook:\n' + + `filename: ${relativePath(context.filename)}`, + node: SPAN, + }); + + // Only visit AST for `files/2.js` + return context.filename.endsWith('2.js'); + }, + Identifier(node) { + context.report({ + message: `ident visit fn "${node.name}":\n` + + `filename: ${relativePath(context.filename)}`, + node: { ...SPAN, ...node }, + }); + }, + after() { + context.report({ + message: 'after hook:\n' + + `filename: ${relativePath(context.filename)}`, + node: SPAN, + }); + }, + }; + }, +}; + +// These 3 rules test that `createOnce` without `before` and `after` hooks works correctly. + +const createOnceBeforeOnlyRule = { + createOnce(context) { + return { + before() { + context.report({ + message: 'before hook:\n' + + `filename: ${relativePath(context.filename)}`, + node: SPAN, + }); + }, + Identifier(node) { + context.report({ + message: `ident visit fn "${node.name}":\n` + + `filename: ${relativePath(context.filename)}`, + node: { ...SPAN, ...node }, + }); + }, + }; + }, +}; + +const createOnceAfterOnlyRule = { + createOnce(context) { + return { + Identifier(node) { + context.report({ + message: `ident visit fn "${node.name}":\n` + + `filename: ${relativePath(context.filename)}`, + node: { ...SPAN, ...node }, + }); + }, + after() { + context.report({ + message: 'after hook:\n' + + `filename: ${relativePath(context.filename)}`, + node: SPAN, + }); + }, + }; + }, +}; + +const createOnceNoHooksRule = { + createOnce(context) { + return { + Identifier(node) { + context.report({ + message: `ident visit fn "${node.name}":\n` + + `filename: ${relativePath(context.filename)}`, + node: { ...SPAN, ...node }, + }); + }, + }; + }, +}; + +export default definePlugin({ + meta: { + name: "define-plugin-plugin", + }, + rules: { + create: createRule, + "create-once": createOnceRule, + "create-once-before-false": createOnceBeforeFalseRule, + "create-once-before-only": createOnceBeforeOnlyRule, + "create-once-after-only": createOnceAfterOnlyRule, + "create-once-no-hooks": createOnceNoHooksRule, + }, +}); diff --git a/apps/oxlint/test/fixtures/definePlugin_and_defineRule/.oxlintrc.json b/apps/oxlint/test/fixtures/definePlugin_and_defineRule/.oxlintrc.json new file mode 100644 index 0000000000000..8220514d5f211 --- /dev/null +++ b/apps/oxlint/test/fixtures/definePlugin_and_defineRule/.oxlintrc.json @@ -0,0 +1,13 @@ +{ + "plugins": ["./test_plugin"], + "categories": {"correctness": "off"}, + "rules": { + "define-plugin-and-rule-plugin/create": "error", + "define-plugin-and-rule-plugin/create-once": "error", + "define-plugin-and-rule-plugin/create-once-before-false": "error", + "define-plugin-and-rule-plugin/create-once-before-only": "error", + "define-plugin-and-rule-plugin/create-once-after-only": "error", + "define-plugin-and-rule-plugin/create-once-no-hooks": "error" + }, + "ignorePatterns": ["test_plugin/**", "eslint.config.js"] +} diff --git a/apps/oxlint/test/fixtures/definePlugin_and_defineRule/eslint.config.js b/apps/oxlint/test/fixtures/definePlugin_and_defineRule/eslint.config.js new file mode 100644 index 0000000000000..f52d67238fe0b --- /dev/null +++ b/apps/oxlint/test/fixtures/definePlugin_and_defineRule/eslint.config.js @@ -0,0 +1,18 @@ +import testPlugin from './test_plugin/index.js'; + +export default [ + { + files: ["files/*.js"], + plugins: { + "define-plugin-and-rule-plugin": testPlugin, + }, + rules: { + "define-plugin-and-rule-plugin/create": "error", + "define-plugin-and-rule-plugin/create-once": "error", + "define-plugin-and-rule-plugin/create-once-before-false": "error", + "define-plugin-and-rule-plugin/create-once-before-only": "error", + "define-plugin-and-rule-plugin/create-once-after-only": "error", + "define-plugin-and-rule-plugin/create-once-no-hooks": "error", + }, + }, +]; diff --git a/apps/oxlint/test/fixtures/definePlugin_and_defineRule/files/1.js b/apps/oxlint/test/fixtures/definePlugin_and_defineRule/files/1.js new file mode 100644 index 0000000000000..d46d9e85cf36d --- /dev/null +++ b/apps/oxlint/test/fixtures/definePlugin_and_defineRule/files/1.js @@ -0,0 +1 @@ +let a, b; diff --git a/apps/oxlint/test/fixtures/definePlugin_and_defineRule/files/2.js b/apps/oxlint/test/fixtures/definePlugin_and_defineRule/files/2.js new file mode 100644 index 0000000000000..ef4904e39912c --- /dev/null +++ b/apps/oxlint/test/fixtures/definePlugin_and_defineRule/files/2.js @@ -0,0 +1 @@ +let c, d; diff --git a/apps/oxlint/test/fixtures/definePlugin_and_defineRule/test_plugin/index.js b/apps/oxlint/test/fixtures/definePlugin_and_defineRule/test_plugin/index.js new file mode 100644 index 0000000000000..6ae2318c21edb --- /dev/null +++ b/apps/oxlint/test/fixtures/definePlugin_and_defineRule/test_plugin/index.js @@ -0,0 +1,209 @@ +import { dirname, sep } from 'node:path'; +import { definePlugin, defineRule } from '../../../../dist/index.js'; + +// `loc` field is required for ESLint. +// TODO: Remove this workaround when AST nodes have a `loc` field. +const SPAN = { + start: 0, + end: 0, + loc: { + start: { line: 0, column: 0 }, + end: { line: 0, column: 0 }, + }, +}; + +const PARENT_DIR_PATH_LEN = dirname(import.meta.dirname).length + 1; + +const relativePath = sep === '/' + ? path => path.slice(PARENT_DIR_PATH_LEN) + : path => path.slice(PARENT_DIR_PATH_LEN).replace(/\\/g, '/'); + +const createRule = defineRule({ + create(context) { + context.report({ message: `create body:\nthis === rule: ${this === createRule}`, node: SPAN }); + + return { + Identifier(node) { + context.report({ + message: `ident visit fn "${node.name}":\nfilename: ${relativePath(context.filename)}`, + node: { ...SPAN, ...node }, + }); + }, + }; + }, +}); + +// This aims to test that `createOnce` is called once only, and `before` hook is called once per file. +// i.e. Oxlint calls `createOnce` directly, and not the `create` method that `defineRule` adds to the rule. +let createOnceCallCount = 0; +const createOnceRule = defineRule({ + createOnce(context) { + createOnceCallCount++; + + // `fileNum` should be different for each file. + // `identNum` should start at 1 for each file. + let fileNum = 0, identNum; + // Note: Files are processed in unpredictable order, so `files/1.js` may be `fileNum` 1 or 2. + // Therefore, collect all visits and check them in `after` hook of the 2nd file. + const visits = []; + + // `this` should be the rule object returned by `defineRule` + const topLevelThis = this; + + return { + before() { + fileNum++; + identNum = 0; + + context.report({ + message: 'before hook:\n' + + `createOnce call count: ${createOnceCallCount}\n` + + `this === rule: ${topLevelThis === createOnceRule}\n` + + `filename: ${relativePath(context.filename)}`, + node: SPAN, + }); + }, + Identifier(node) { + identNum++; + visits.push({ fileNum, identNum }); + + context.report({ + message: `ident visit fn "${node.name}":\n` + + `identNum: ${identNum}\n` + + `filename: ${relativePath(context.filename)}`, + node: { ...SPAN, ...node }, + }); + }, + after() { + context.report({ + message: 'after hook:\n' + + `identNum: ${identNum}\n` + + `filename: ${relativePath(context.filename)}`, + node: SPAN, + }); + + if (fileNum === 2) { + visits.sort((v1, v2) => v1.fileNum - v2.fileNum); + + const expectedVisits = [ + { fileNum: 1, identNum: 1 }, + { fileNum: 1, identNum: 2 }, + { fileNum: 2, identNum: 1 }, + { fileNum: 2, identNum: 2 }, + ]; + + if ( + visits.length !== expectedVisits.length + || visits.some((v, i) => v.fileNum !== expectedVisits[i].fileNum || v.identNum !== expectedVisits[i].identNum) + ) { + context.report({ message: `Unexpected visits: ${JSON.stringify(visits)}`, node: SPAN }); + } + } + }, + }; + }, +}); + +// Tests that `before` hook returning `false` disables visiting AST for the file. +const createOnceBeforeFalseRule = defineRule({ + createOnce(context) { + return { + before() { + context.report({ + message: 'before hook:\n' + + `filename: ${relativePath(context.filename)}`, + node: SPAN, + }); + + // Only visit AST for `files/2.js` + return context.filename.endsWith('2.js'); + }, + Identifier(node) { + context.report({ + message: `ident visit fn "${node.name}":\n` + + `filename: ${relativePath(context.filename)}`, + node: { ...SPAN, ...node }, + }); + }, + after() { + context.report({ + message: 'after hook:\n' + + `filename: ${relativePath(context.filename)}`, + node: SPAN, + }); + }, + }; + }, +}); + +// These 3 rules test that `createOnce` without `before` and `after` hooks works correctly. + +const createOnceBeforeOnlyRule = defineRule({ + createOnce(context) { + return { + before() { + context.report({ + message: 'before hook:\n' + + `filename: ${relativePath(context.filename)}`, + node: SPAN, + }); + }, + Identifier(node) { + context.report({ + message: `ident visit fn "${node.name}":\n` + + `filename: ${relativePath(context.filename)}`, + node: { ...SPAN, ...node }, + }); + }, + }; + }, +}); + +const createOnceAfterOnlyRule = defineRule({ + createOnce(context) { + return { + Identifier(node) { + context.report({ + message: `ident visit fn "${node.name}":\n` + + `filename: ${relativePath(context.filename)}`, + node: { ...SPAN, ...node }, + }); + }, + after() { + context.report({ + message: 'after hook:\n' + + `filename: ${relativePath(context.filename)}`, + node: SPAN, + }); + }, + }; + }, +}); + +const createOnceNoHooksRule = defineRule({ + createOnce(context) { + return { + Identifier(node) { + context.report({ + message: `ident visit fn "${node.name}":\n` + + `filename: ${relativePath(context.filename)}`, + node: { ...SPAN, ...node }, + }); + }, + }; + }, +}); + +export default definePlugin({ + meta: { + name: "define-plugin-and-rule-plugin", + }, + rules: { + create: createRule, + "create-once": createOnceRule, + "create-once-before-false": createOnceBeforeFalseRule, + "create-once-before-only": createOnceBeforeOnlyRule, + "create-once-after-only": createOnceAfterOnlyRule, + "create-once-no-hooks": createOnceNoHooksRule, + }, +}); diff --git a/apps/oxlint/test/fixtures/defineRule/.oxlintrc.json b/apps/oxlint/test/fixtures/defineRule/.oxlintrc.json new file mode 100644 index 0000000000000..af0100d6b454b --- /dev/null +++ b/apps/oxlint/test/fixtures/defineRule/.oxlintrc.json @@ -0,0 +1,13 @@ +{ + "plugins": ["./test_plugin"], + "categories": {"correctness": "off"}, + "rules": { + "define-rule-plugin/create": "error", + "define-rule-plugin/create-once": "error", + "define-rule-plugin/create-once-before-false": "error", + "define-rule-plugin/create-once-before-only": "error", + "define-rule-plugin/create-once-after-only": "error", + "define-rule-plugin/create-once-no-hooks": "error" + }, + "ignorePatterns": ["test_plugin/**", "eslint.config.js"] +} diff --git a/apps/oxlint/test/fixtures/defineRule/eslint.config.js b/apps/oxlint/test/fixtures/defineRule/eslint.config.js new file mode 100644 index 0000000000000..39ca496c8e089 --- /dev/null +++ b/apps/oxlint/test/fixtures/defineRule/eslint.config.js @@ -0,0 +1,18 @@ +import testPlugin from './test_plugin/index.js'; + +export default [ + { + files: ["files/*.js"], + plugins: { + "define-rule-plugin": testPlugin, + }, + rules: { + "define-rule-plugin/create": "error", + "define-rule-plugin/create-once": "error", + "define-rule-plugin/create-once-before-false": "error", + "define-rule-plugin/create-once-before-only": "error", + "define-rule-plugin/create-once-after-only": "error", + "define-rule-plugin/create-once-no-hooks": "error", + }, + }, +]; diff --git a/apps/oxlint/test/fixtures/defineRule/files/1.js b/apps/oxlint/test/fixtures/defineRule/files/1.js new file mode 100644 index 0000000000000..d46d9e85cf36d --- /dev/null +++ b/apps/oxlint/test/fixtures/defineRule/files/1.js @@ -0,0 +1 @@ +let a, b; diff --git a/apps/oxlint/test/fixtures/defineRule/files/2.js b/apps/oxlint/test/fixtures/defineRule/files/2.js new file mode 100644 index 0000000000000..ef4904e39912c --- /dev/null +++ b/apps/oxlint/test/fixtures/defineRule/files/2.js @@ -0,0 +1 @@ +let c, d; diff --git a/apps/oxlint/test/fixtures/define/test_plugin/index.js b/apps/oxlint/test/fixtures/defineRule/test_plugin/index.js similarity index 54% rename from apps/oxlint/test/fixtures/define/test_plugin/index.js rename to apps/oxlint/test/fixtures/defineRule/test_plugin/index.js index 524eedbb14bd1..a6afbee3f4b1c 100644 --- a/apps/oxlint/test/fixtures/define/test_plugin/index.js +++ b/apps/oxlint/test/fixtures/defineRule/test_plugin/index.js @@ -1,5 +1,5 @@ import { dirname, sep } from 'node:path'; -import { defineRule, definePlugin } from '../../../../dist/index.js'; +import { defineRule } from '../../../../dist/index.js'; // `loc` field is required for ESLint. // TODO: Remove this workaround when AST nodes have a `loc` field. @@ -35,8 +35,11 @@ const createRule = defineRule({ // This aims to test that `createOnce` is called once only, and `before` hook is called once per file. // i.e. Oxlint calls `createOnce` directly, and not the `create` method that `defineRule` adds to the rule. +let createOnceCallCount = 0; const createOnceRule = defineRule({ createOnce(context) { + createOnceCallCount++; + // `fileNum` should be different for each file. // `identNum` should start at 1 for each file. let fileNum = 0, identNum; @@ -54,6 +57,7 @@ const createOnceRule = defineRule({ context.report({ message: 'before hook:\n' + + `createOnce call count: ${createOnceCallCount}\n` + `this === rule: ${topLevelThis === createOnceRule}\n` + `filename: ${relativePath(context.filename)}`, node: SPAN, @@ -100,12 +104,106 @@ const createOnceRule = defineRule({ }, }); -export default definePlugin({ +// Tests that `before` hook returning `false` disables visiting AST for the file. +const createOnceBeforeFalseRule = defineRule({ + createOnce(context) { + return { + before() { + context.report({ + message: 'before hook:\n' + + `filename: ${relativePath(context.filename)}`, + node: SPAN, + }); + + // Only visit AST for `files/2.js` + return context.filename.endsWith('2.js'); + }, + Identifier(node) { + context.report({ + message: `ident visit fn "${node.name}":\n` + + `filename: ${relativePath(context.filename)}`, + node: { ...SPAN, ...node }, + }); + }, + after() { + context.report({ + message: 'after hook:\n' + + `filename: ${relativePath(context.filename)}`, + node: SPAN, + }); + }, + }; + }, +}); + +// These 3 rules test that `createOnce` without `before` and `after` hooks works correctly. + +const createOnceBeforeOnlyRule = defineRule({ + createOnce(context) { + return { + before() { + context.report({ + message: 'before hook:\n' + + `filename: ${relativePath(context.filename)}`, + node: SPAN, + }); + }, + Identifier(node) { + context.report({ + message: `ident visit fn "${node.name}":\n` + + `filename: ${relativePath(context.filename)}`, + node: { ...SPAN, ...node }, + }); + }, + }; + }, +}); + +const createOnceAfterOnlyRule = defineRule({ + createOnce(context) { + return { + Identifier(node) { + context.report({ + message: `ident visit fn "${node.name}":\n` + + `filename: ${relativePath(context.filename)}`, + node: { ...SPAN, ...node }, + }); + }, + after() { + context.report({ + message: 'after hook:\n' + + `filename: ${relativePath(context.filename)}`, + node: SPAN, + }); + }, + }; + }, +}); + +const createOnceNoHooksRule = defineRule({ + createOnce(context) { + return { + Identifier(node) { + context.report({ + message: `ident visit fn "${node.name}":\n` + + `filename: ${relativePath(context.filename)}`, + node: { ...SPAN, ...node }, + }); + }, + }; + }, +}); + +export default { meta: { name: "define-rule-plugin", }, rules: { create: createRule, "create-once": createOnceRule, + "create-once-before-false": createOnceBeforeFalseRule, + "create-once-before-only": createOnceBeforeOnlyRule, + "create-once-after-only": createOnceAfterOnlyRule, + "create-once-no-hooks": createOnceNoHooksRule, }, -}); +}; diff --git a/crates/oxc/CHANGELOG.md b/crates/oxc/CHANGELOG.md index 824d73caff11f..a7405da4ff135 100644 --- a/crates/oxc/CHANGELOG.md +++ b/crates/oxc/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this package will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0). +## [0.92.0] - 2025-09-24 + +### ๐Ÿš€ Features + +- 0fe4d95 mangler: Mangle private class members (#14027) (sapphi-red) + + ## [0.91.0] - 2025-09-22 ### ๐Ÿ’ผ Other diff --git a/crates/oxc/Cargo.toml b/crates/oxc/Cargo.toml index 8052b832b9d65..2a3f9b8434eaf 100644 --- a/crates/oxc/Cargo.toml +++ b/crates/oxc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxc" -version = "0.91.0" +version = "0.92.0" authors.workspace = true categories.workspace = true edition.workspace = true diff --git a/crates/oxc/src/compiler.rs b/crates/oxc/src/compiler.rs index dce0c01981be9..2d45c68666c23 100644 --- a/crates/oxc/src/compiler.rs +++ b/crates/oxc/src/compiler.rs @@ -5,7 +5,7 @@ use oxc_ast::ast::Program; use oxc_codegen::{Codegen, CodegenOptions, CodegenReturn}; use oxc_diagnostics::OxcDiagnostic; use oxc_isolated_declarations::{IsolatedDeclarations, IsolatedDeclarationsOptions}; -use oxc_mangler::{MangleOptions, Mangler}; +use oxc_mangler::{MangleOptions, Mangler, ManglerReturn}; use oxc_minifier::{CompressOptions, Compressor}; use oxc_parser::{ParseOptions, Parser, ParserReturn}; use oxc_semantic::{Scoping, SemanticBuilder, SemanticBuilderReturn}; @@ -282,7 +282,7 @@ pub trait CompilerInterface { Compressor::new(allocator).build(program, options); } - fn mangle(&self, program: &mut Program<'_>, options: MangleOptions) -> Scoping { + fn mangle(&self, program: &mut Program<'_>, options: MangleOptions) -> ManglerReturn { Mangler::new().with_options(options).build(program) } @@ -290,13 +290,20 @@ pub trait CompilerInterface { &self, program: &Program<'_>, source_path: &Path, - scoping: Option, + mangler_return: Option, options: CodegenOptions, ) -> CodegenReturn { let mut options = options; if self.enable_sourcemap() { options.source_map_path = Some(source_path.to_path_buf()); } - Codegen::new().with_options(options).with_scoping(scoping).build(program) + let (scoping, class_private_mappings) = mangler_return + .map(|m| (Some(m.scoping), Some(m.class_private_mappings))) + .unwrap_or_default(); + Codegen::new() + .with_options(options) + .with_scoping(scoping) + .with_private_member_mappings(class_private_mappings) + .build(program) } } diff --git a/crates/oxc_allocator/CHANGELOG.md b/crates/oxc_allocator/CHANGELOG.md index 702efa99b105f..a174dd8299c56 100644 --- a/crates/oxc_allocator/CHANGELOG.md +++ b/crates/oxc_allocator/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this package will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0). + ## [0.91.0] - 2025-09-22 ### ๐Ÿš€ Features diff --git a/crates/oxc_allocator/Cargo.toml b/crates/oxc_allocator/Cargo.toml index 893b52c20bc4f..dfe5fe4b81f94 100644 --- a/crates/oxc_allocator/Cargo.toml +++ b/crates/oxc_allocator/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxc_allocator" -version = "0.91.0" +version = "0.92.0" authors.workspace = true categories.workspace = true edition.workspace = true diff --git a/crates/oxc_ast/CHANGELOG.md b/crates/oxc_ast/CHANGELOG.md index 5db3f1b963d8e..f62d573e913e6 100644 --- a/crates/oxc_ast/CHANGELOG.md +++ b/crates/oxc_ast/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this package will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0). + ## [0.91.0] - 2025-09-22 ### ๐Ÿš€ Features diff --git a/crates/oxc_ast/Cargo.toml b/crates/oxc_ast/Cargo.toml index b66a754d7ba22..baf189d5f4429 100644 --- a/crates/oxc_ast/Cargo.toml +++ b/crates/oxc_ast/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxc_ast" -version = "0.91.0" +version = "0.92.0" authors.workspace = true categories.workspace = true edition.workspace = true diff --git a/crates/oxc_ast_macros/CHANGELOG.md b/crates/oxc_ast_macros/CHANGELOG.md index 53a8a39343c71..8dd096d718808 100644 --- a/crates/oxc_ast_macros/CHANGELOG.md +++ b/crates/oxc_ast_macros/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this package will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0). + ## [0.91.0] - 2025-09-22 ### ๐Ÿ’ผ Other diff --git a/crates/oxc_ast_macros/Cargo.toml b/crates/oxc_ast_macros/Cargo.toml index dab3dc1af54ce..fe0e7273d79d5 100644 --- a/crates/oxc_ast_macros/Cargo.toml +++ b/crates/oxc_ast_macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxc_ast_macros" -version = "0.91.0" +version = "0.92.0" authors.workspace = true categories.workspace = true edition.workspace = true diff --git a/crates/oxc_ast_visit/CHANGELOG.md b/crates/oxc_ast_visit/CHANGELOG.md index b020d1e911e0a..46c71a457bd3c 100644 --- a/crates/oxc_ast_visit/CHANGELOG.md +++ b/crates/oxc_ast_visit/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this package will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0). + ## [0.91.0] - 2025-09-22 ### ๐Ÿš€ Features diff --git a/crates/oxc_ast_visit/Cargo.toml b/crates/oxc_ast_visit/Cargo.toml index dec878265920d..b8282e8f3bd9c 100644 --- a/crates/oxc_ast_visit/Cargo.toml +++ b/crates/oxc_ast_visit/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxc_ast_visit" -version = "0.91.0" +version = "0.92.0" authors.workspace = true categories.workspace = true edition.workspace = true diff --git a/crates/oxc_cfg/CHANGELOG.md b/crates/oxc_cfg/CHANGELOG.md index 94ddd06f8035a..d1014ee3819f4 100644 --- a/crates/oxc_cfg/CHANGELOG.md +++ b/crates/oxc_cfg/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this package will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0). + ## [0.91.0] - 2025-09-22 ### ๐Ÿ’ผ Other diff --git a/crates/oxc_cfg/Cargo.toml b/crates/oxc_cfg/Cargo.toml index 896fc6a21e8c9..421b35c58f3c7 100644 --- a/crates/oxc_cfg/Cargo.toml +++ b/crates/oxc_cfg/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxc_cfg" -version = "0.91.0" +version = "0.92.0" authors.workspace = true categories.workspace = true edition.workspace = true diff --git a/crates/oxc_codegen/CHANGELOG.md b/crates/oxc_codegen/CHANGELOG.md index 3f23bd172447c..3c7fef4cb1f95 100644 --- a/crates/oxc_codegen/CHANGELOG.md +++ b/crates/oxc_codegen/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this package will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0). +## [0.92.0] - 2025-09-24 + +### ๐Ÿš€ Features + +- 0fe4d95 mangler: Mangle private class members (#14027) (sapphi-red) + + ## [0.91.0] - 2025-09-22 ### ๐Ÿ› Bug Fixes diff --git a/crates/oxc_codegen/Cargo.toml b/crates/oxc_codegen/Cargo.toml index 46604f3db673c..71d2028fd0c52 100644 --- a/crates/oxc_codegen/Cargo.toml +++ b/crates/oxc_codegen/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxc_codegen" -version = "0.91.0" +version = "0.92.0" authors.workspace = true categories.workspace = true edition.workspace = true diff --git a/crates/oxc_codegen/src/gen.rs b/crates/oxc_codegen/src/gen.rs index 916fe21381d62..0f5c075fbed57 100644 --- a/crates/oxc_codegen/src/gen.rs +++ b/crates/oxc_codegen/src/gen.rs @@ -2264,6 +2264,7 @@ impl Gen for Class<'_> { let n = p.code_len(); let wrap = self.is_expression() && (p.start_of_stmt == n || p.start_of_default_export == n); p.wrap(wrap, |p| { + p.enter_class(); p.print_decorators(&self.decorators, ctx); p.print_space_before_identifier(); p.add_source_mapping(self.span); @@ -2295,6 +2296,7 @@ impl Gen for Class<'_> { p.print_soft_space(); self.body.print(p, ctx); p.needs_semicolon = false; + p.exit_class(); }); } } @@ -2747,9 +2749,20 @@ impl Gen for AccessorProperty<'_> { impl Gen for PrivateIdentifier<'_> { fn r#gen(&self, p: &mut Codegen, _ctx: Context) { + let name = if let Some(class_index) = p.current_class_index() + && let Some(mangled) = &p + .private_member_mappings + .as_ref() + .and_then(|m| m[class_index].get(self.name.as_str())) + { + (*mangled).clone() + } else { + self.name.into_compact_str() + }; + p.print_ascii_byte(b'#'); p.add_source_mapping_for_name(self.span, &self.name); - p.print_str(self.name.as_str()); + p.print_str(name.as_str()); } } diff --git a/crates/oxc_codegen/src/lib.rs b/crates/oxc_codegen/src/lib.rs index f77a51c46ecbe..20bdc12c0fdc3 100644 --- a/crates/oxc_codegen/src/lib.rs +++ b/crates/oxc_codegen/src/lib.rs @@ -12,12 +12,13 @@ use cow_utils::CowUtils; use oxc_ast::ast::*; use oxc_data_structures::{code_buffer::CodeBuffer, stack::Stack}; use oxc_semantic::Scoping; -use oxc_span::{GetSpan, Span}; +use oxc_span::{CompactStr, GetSpan, Span}; use oxc_syntax::{ identifier::{is_identifier_part, is_identifier_part_ascii}, operator::{BinaryOperator, UnaryOperator, UpdateOperator}, precedence::Precedence, }; +use rustc_hash::FxHashMap; mod binary_expr_visitor; mod comment; @@ -82,6 +83,9 @@ pub struct Codegen<'a> { scoping: Option, + /// Private member name mappings for mangling + private_member_mappings: Option>>, + /// Output Code code: CodeBuffer, @@ -91,6 +95,7 @@ pub struct Codegen<'a> { need_space_before_dot: usize, print_next_indent_as_space: bool, binary_expr_stack: Stack>, + class_stack_pos: usize, /// Indicates the output is JSX type, it is set in [`Program::gen`] and the result /// is obtained by [`oxc_span::SourceType::is_jsx`] is_jsx: bool, @@ -146,11 +151,13 @@ impl<'a> Codegen<'a> { options, source_text: None, scoping: None, + private_member_mappings: None, code: CodeBuffer::default(), needs_semicolon: false, need_space_before_dot: 0, print_next_indent_as_space: false, binary_expr_stack: Stack::with_capacity(12), + class_stack_pos: 0, prev_op_end: 0, prev_reg_exp_end: 0, prev_op: None, @@ -190,6 +197,19 @@ impl<'a> Codegen<'a> { self } + /// Set private member name mappings for mangling. + /// + /// This allows renaming of private class members like `#field` -> `#a`. + /// The Vec contains per-class mappings, indexed by class declaration order. + #[must_use] + pub fn with_private_member_mappings( + mut self, + mappings: Option>>, + ) -> Self { + self.private_member_mappings = mappings; + self + } + /// Print a [`Program`] into a string of source code. /// /// A source map will be generated if [`CodegenOptions::source_map_path`] is set. @@ -445,6 +465,21 @@ impl<'a> Codegen<'a> { } } + #[inline] + fn enter_class(&mut self) { + self.class_stack_pos += 1; + } + + #[inline] + fn exit_class(&mut self) { + self.class_stack_pos -= 1; + } + + #[inline] + fn current_class_index(&self) -> Option { + if self.class_stack_pos > 0 { Some(self.class_stack_pos - 1) } else { None } + } + #[inline] fn wrap(&mut self, wrap: bool, mut f: F) { if wrap { diff --git a/crates/oxc_compat/CHANGELOG.md b/crates/oxc_compat/CHANGELOG.md index 5c5cc73b8b60f..e0614a9b02305 100644 --- a/crates/oxc_compat/CHANGELOG.md +++ b/crates/oxc_compat/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this package will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0). + ## [0.91.0] - 2025-09-22 ### ๐Ÿš€ Features diff --git a/crates/oxc_compat/Cargo.toml b/crates/oxc_compat/Cargo.toml index cd71d1dbad29b..55bf661e0f7e7 100644 --- a/crates/oxc_compat/Cargo.toml +++ b/crates/oxc_compat/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxc_compat" -version = "0.91.0" +version = "0.92.0" authors.workspace = true categories.workspace = true edition.workspace = true diff --git a/crates/oxc_data_structures/CHANGELOG.md b/crates/oxc_data_structures/CHANGELOG.md index 93a8752c34224..8405f9a6e110e 100644 --- a/crates/oxc_data_structures/CHANGELOG.md +++ b/crates/oxc_data_structures/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this package will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0). + ## [0.91.0] - 2025-09-22 ### ๐Ÿš€ Features diff --git a/crates/oxc_data_structures/Cargo.toml b/crates/oxc_data_structures/Cargo.toml index 3f4bd49e1111c..7fde82dcec574 100644 --- a/crates/oxc_data_structures/Cargo.toml +++ b/crates/oxc_data_structures/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxc_data_structures" -version = "0.91.0" +version = "0.92.0" authors.workspace = true categories.workspace = true edition.workspace = true diff --git a/crates/oxc_data_structures/src/stack/standard.rs b/crates/oxc_data_structures/src/stack/standard.rs index 4ba98af48456b..f77753e8418b6 100644 --- a/crates/oxc_data_structures/src/stack/standard.rs +++ b/crates/oxc_data_structures/src/stack/standard.rs @@ -334,6 +334,25 @@ impl Stack { unsafe { self.cursor.read() } } + /// Clear the stack, removing all values. + /// + /// Note that this method has no effect on the allocated capacity + /// of the stack. + #[inline] + pub fn clear(&mut self) { + if self.is_empty() { + return; + } + + debug_assert!(self.end > self.start); + + // SAFETY: Checked above that stack is not empty, so stack is allocated. + // Stack contains `self.len()` initialized entries, starting at `self.start` + unsafe { self.drop_contents() }; + // Move cursor back to start. This is equivalent to setting len to 0 + self.cursor = self.start; + } + /// Get number of entries on stack. #[inline] pub fn len(&self) -> usize { @@ -588,6 +607,20 @@ mod tests { assert_len_cap_last!(stack, 2, 4, Some(&22)); } + #[test] + fn clear() { + let mut stack = Stack::::new(); + assert_len_cap_last!(stack, 0, 0, None); + + stack.clear(); + assert_len_cap_last!(stack, 0, 0, None); + + stack.push(10); + assert_len_cap_last!(stack, 1, 4, Some(&10)); + stack.clear(); + assert_len_cap_last!(stack, 0, 4, None); + } + #[test] #[expect(clippy::items_after_statements)] fn drop() { diff --git a/crates/oxc_diagnostics/CHANGELOG.md b/crates/oxc_diagnostics/CHANGELOG.md index dba5ac36ade57..5ab60b65de899 100644 --- a/crates/oxc_diagnostics/CHANGELOG.md +++ b/crates/oxc_diagnostics/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this package will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0). + ## [0.91.0] - 2025-09-22 ### ๐Ÿš€ Features diff --git a/crates/oxc_diagnostics/Cargo.toml b/crates/oxc_diagnostics/Cargo.toml index f71db1c63d25d..82f4291e11780 100644 --- a/crates/oxc_diagnostics/Cargo.toml +++ b/crates/oxc_diagnostics/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxc_diagnostics" -version = "0.91.0" +version = "0.92.0" authors.workspace = true categories.workspace = true edition.workspace = true diff --git a/crates/oxc_diagnostics/src/lib.rs b/crates/oxc_diagnostics/src/lib.rs index 3cc9e56776d6f..45c6b6c1f0911 100644 --- a/crates/oxc_diagnostics/src/lib.rs +++ b/crates/oxc_diagnostics/src/lib.rs @@ -338,4 +338,9 @@ impl OxcDiagnostic { pub fn with_source_code(self, code: T) -> Error { Error::from(self).with_source_code(code) } + + /// Consumes the diagnostic and returns the inner owned data. + pub fn inner_owned(self) -> OxcDiagnosticInner { + *self.inner + } } diff --git a/crates/oxc_ecmascript/CHANGELOG.md b/crates/oxc_ecmascript/CHANGELOG.md index c80d9e4bb92fd..061a556dd072f 100644 --- a/crates/oxc_ecmascript/CHANGELOG.md +++ b/crates/oxc_ecmascript/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this package will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0). + ## [0.91.0] - 2025-09-22 ### ๐Ÿ’ผ Other diff --git a/crates/oxc_ecmascript/Cargo.toml b/crates/oxc_ecmascript/Cargo.toml index 51e7cf2a22134..a8afe28e73abe 100644 --- a/crates/oxc_ecmascript/Cargo.toml +++ b/crates/oxc_ecmascript/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxc_ecmascript" -version = "0.91.0" +version = "0.92.0" authors.workspace = true categories.workspace = true edition.workspace = true diff --git a/crates/oxc_estree/CHANGELOG.md b/crates/oxc_estree/CHANGELOG.md index 0e8390844daef..599897bd0f468 100644 --- a/crates/oxc_estree/CHANGELOG.md +++ b/crates/oxc_estree/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this package will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0). + ## [0.91.0] - 2025-09-22 ### ๐Ÿ’ผ Other diff --git a/crates/oxc_estree/Cargo.toml b/crates/oxc_estree/Cargo.toml index 87883e8c1e670..fd8726f16996d 100644 --- a/crates/oxc_estree/Cargo.toml +++ b/crates/oxc_estree/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxc_estree" -version = "0.91.0" +version = "0.92.0" authors.workspace = true categories.workspace = true edition.workspace = true diff --git a/crates/oxc_formatter/examples/formatter.rs b/crates/oxc_formatter/examples/formatter.rs index ade1b9472c4a0..f6dc0de2984e7 100644 --- a/crates/oxc_formatter/examples/formatter.rs +++ b/crates/oxc_formatter/examples/formatter.rs @@ -23,6 +23,7 @@ use pico_args::Arguments; fn main() -> Result<(), String> { let mut args = Arguments::from_env(); let no_semi = args.contains("--no-semi"); + let show_ir = args.contains("--ir"); let name = args.free_from_str().unwrap_or_else(|_| "test.js".to_string()); // Read source file @@ -57,9 +58,19 @@ fn main() -> Result<(), String> { semicolons, ..Default::default() }; - let code = Formatter::new(&allocator, options).build(&ret.program); - println!("{code}"); + let formatter = Formatter::new(&allocator, options); + if show_ir { + let doc = formatter.doc(&ret.program); + println!("["); + for el in doc.iter() { + println!(" {el:?},"); + } + println!("]"); + } else { + let code = formatter.build(&ret.program); + println!("{code}"); + } Ok(()) } diff --git a/crates/oxc_formatter/src/formatter/comments.rs b/crates/oxc_formatter/src/formatter/comments.rs index e42abba8f367d..390e2dd824389 100644 --- a/crates/oxc_formatter/src/formatter/comments.rs +++ b/crates/oxc_formatter/src/formatter/comments.rs @@ -128,17 +128,29 @@ pub struct Comments<'a> { /// The index of the type cast comment that has been printed already. /// Used to prevent duplicate processing of special TypeScript type cast comments. handled_type_cast_comment: usize, + /// Optional limit for the unprinted_comments view. + /// + /// When set, [`Self::unprinted_comments()`] will only return comments up to this index, + /// effectively hiding comments beyond this point from the formatter. + pub view_limit: Option, } impl<'a> Comments<'a> { pub fn new(source_text: SourceText<'a>, comments: &'a [Comment]) -> Self { - Comments { source_text, comments, printed_count: 0, handled_type_cast_comment: 0 } + Comments { + source_text, + comments, + printed_count: 0, + handled_type_cast_comment: 0, + view_limit: None, + } } /// Returns comments that have not been printed yet. #[inline] pub fn unprinted_comments(&self) -> &'a [Comment] { - &self.comments[self.printed_count..] + let end = self.view_limit.unwrap_or(self.comments.len()); + &self.comments[self.printed_count..end] } /// Returns comments that have already been printed. @@ -486,6 +498,34 @@ impl<'a> Comments<'a> { pub fn is_already_handled_type_cast_comment(&self) -> bool { self.printed_count == self.handled_type_cast_comment } + + /// Temporarily limits the unprinted comments view to only those before the given position. + /// Returns the previous view limit to allow restoration. + pub fn limit_comments_up_to(&mut self, end_pos: u32) -> Option { + // Save the original limit for restoration + let original_limit = self.view_limit; + + // Find the index of the first comment that starts at or after end_pos + // Using binary search would be more efficient for large comment arrays + let limit_index = self.comments[self.printed_count..] + .iter() + .position(|c| c.span.start >= end_pos) + .map_or(self.comments.len(), |idx| self.printed_count + idx); + + // Only update if we're actually limiting the view + if limit_index < self.comments.len() { + self.view_limit = Some(limit_index); + } + + original_limit + } + + /// Restores the view limit to a previously saved value. + /// This is typically used after temporarily limiting the view with `limit_comments_up_to`. + #[inline] + pub fn restore_view_limit(&mut self, limit: Option) { + self.view_limit = limit; + } } /// Checks if a pattern matches at the given position. diff --git a/crates/oxc_formatter/src/generated/format.rs b/crates/oxc_formatter/src/generated/format.rs index 7c0dce60de564..be896e49014a2 100644 --- a/crates/oxc_formatter/src/generated/format.rs +++ b/crates/oxc_formatter/src/generated/format.rs @@ -4512,9 +4512,19 @@ impl<'a> Format<'a> for AstNode<'a, TSInferType<'a>> { impl<'a> Format<'a> for AstNode<'a, TSTypeQuery<'a>> { fn fmt(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { let is_suppressed = f.comments().is_suppressed(self.span().start); + if !is_suppressed && format_type_cast_comment_node(self, false, f)? { + return Ok(()); + } self.format_leading_comments(f)?; + let needs_parentheses = self.needs_parentheses(f); + if needs_parentheses { + "(".fmt(f)?; + } let result = if is_suppressed { FormatSuppressedNode(self.span()).fmt(f) } else { self.write(f) }; + if needs_parentheses { + ")".fmt(f)?; + } self.format_trailing_comments(f)?; result } @@ -4611,9 +4621,19 @@ impl<'a> Format<'a> for AstNode<'a, TSFunctionType<'a>> { impl<'a> Format<'a> for AstNode<'a, TSConstructorType<'a>> { fn fmt(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { let is_suppressed = f.comments().is_suppressed(self.span().start); + if !is_suppressed && format_type_cast_comment_node(self, false, f)? { + return Ok(()); + } self.format_leading_comments(f)?; + let needs_parentheses = self.needs_parentheses(f); + if needs_parentheses { + "(".fmt(f)?; + } let result = if is_suppressed { FormatSuppressedNode(self.span()).fmt(f) } else { self.write(f) }; + if needs_parentheses { + ")".fmt(f)?; + } self.format_trailing_comments(f)?; result } diff --git a/crates/oxc_formatter/src/lib.rs b/crates/oxc_formatter/src/lib.rs index 0c89180d8c7c2..dcaef4b2032e5 100644 --- a/crates/oxc_formatter/src/lib.rs +++ b/crates/oxc_formatter/src/lib.rs @@ -36,7 +36,7 @@ use write::FormatWrite; pub use crate::options::*; pub use crate::service::source_type::get_supported_source_type; use crate::{ - formatter::FormatContext, + formatter::{FormatContext, Formatted, format_element::document::Document}, generated::ast_nodes::{AstNode, AstNodes}, }; @@ -53,20 +53,31 @@ impl<'a> Formatter<'a> { Self { allocator, source_text: "", options } } + /// Formats the given AST `Program` and returns the IR before printing. + pub fn doc(mut self, program: &'a Program<'a>) -> Document<'a> { + let formatted = self.format(program); + formatted.into_document() + } + + /// Formats the given AST `Program` and returns the formatted string. pub fn build(mut self, program: &Program<'a>) -> String { + let formatted = self.format(program); + formatted.print().unwrap().into_code() + } + + fn format(mut self, program: &'a Program<'a>) -> Formatted<'a> { let parent = self.allocator.alloc(AstNodes::Dummy()); let program_node = AstNode::new(program, parent, self.allocator); let source_text = program.source_text; self.source_text = source_text; let context = FormatContext::new(program, self.allocator, self.options); - let formatted = formatter::format( + formatter::format( program, context, formatter::Arguments::new(&[formatter::Argument::new(&program_node)]), ) - .unwrap(); - formatted.print().unwrap().into_code() + .unwrap() } } diff --git a/crates/oxc_formatter/src/parentheses/expression.rs b/crates/oxc_formatter/src/parentheses/expression.rs index fb77a1d909133..682df959a121d 100644 --- a/crates/oxc_formatter/src/parentheses/expression.rs +++ b/crates/oxc_formatter/src/parentheses/expression.rs @@ -3,6 +3,7 @@ use oxc_ast::ast::*; use oxc_data_structures::stack; use oxc_span::GetSpan; use oxc_syntax::{ + keyword::is_reserved_keyword, operator, precedence::{GetPrecedence, Precedence}, }; @@ -84,12 +85,31 @@ impl<'a> NeedsParentheses<'a> for AstNode<'a, IdentifierReference<'a>> { AstNodes::ForOfStatement(stmt) => { return stmt.left.span().contains_inclusive(self.span); } + AstNodes::TSSatisfiesExpression(expr) => { + return expr.expression.span() == self.span(); + } _ => parent = parent.parent(), } } unreachable!() } - _ => false, + name => { + // + matches!( + self.parent, + AstNodes::TSSatisfiesExpression(_) | AstNodes::TSAsExpression(_) + ) && matches!( + name, + "await" + | "interface" + | "module" + | "using" + | "yield" + | "component" + | "hook" + | "type" + ) + } } } } @@ -463,6 +483,8 @@ impl<'a> NeedsParentheses<'a> for AstNode<'a, AssignmentExpression<'a>> { } true } + // `interface { [a = 1]; }` and `class { [a = 1]; }` not need parens + AstNodes::TSPropertySignature(_) | AstNodes::PropertyDefinition(_) | // Never need parentheses in these contexts: // - `a = (b = c)` = nested assignments don't need extra parens AstNodes::AssignmentExpression(_) => false, @@ -765,6 +787,7 @@ fn member_chain_callee_needs_parens(e: &Expression) -> bool { std::iter::successors(Some(e), |e| match e { Expression::ComputedMemberExpression(e) => Some(&e.object), Expression::StaticMemberExpression(e) => Some(&e.object), + Expression::TaggedTemplateExpression(e) => Some(&e.tag), Expression::TSNonNullExpression(e) => Some(&e.expression), _ => None, }) diff --git a/crates/oxc_formatter/src/parentheses/ts_type.rs b/crates/oxc_formatter/src/parentheses/ts_type.rs index 4a367ebc835ef..0061424a1c494 100644 --- a/crates/oxc_formatter/src/parentheses/ts_type.rs +++ b/crates/oxc_formatter/src/parentheses/ts_type.rs @@ -19,6 +19,7 @@ impl<'a> NeedsParentheses<'a> for AstNode<'a, TSType<'a>> { AstNodes::TSIntersectionType(it) => it.needs_parentheses(f), AstNodes::TSConditionalType(it) => it.needs_parentheses(f), AstNodes::TSTypeOperator(it) => it.needs_parentheses(f), + AstNodes::TSTypeQuery(it) => it.needs_parentheses(f), _ => { // TODO: incomplete false @@ -30,31 +31,24 @@ impl<'a> NeedsParentheses<'a> for AstNode<'a, TSType<'a>> { impl<'a> NeedsParentheses<'a> for AstNode<'a, TSFunctionType<'a>> { #[inline] fn needs_parentheses(&self, f: &Formatter<'_, 'a>) -> bool { - match self.parent { - AstNodes::TSConditionalType(ty) => { - ty.extends_type().span() == self.span() || ty.check_type().span() == self.span() - } - AstNodes::TSUnionType(_) | AstNodes::TSIntersectionType(_) => true, - _ => false, - } + function_like_type_needs_parentheses(self.span(), self.parent, Some(&self.return_type)) } } impl<'a> NeedsParentheses<'a> for AstNode<'a, TSInferType<'a>> { fn needs_parentheses(&self, f: &Formatter<'_, 'a>) -> bool { - matches!(self.parent, AstNodes::TSArrayType(_) | AstNodes::TSTypeOperator(_)) + match self.parent { + AstNodes::TSIntersectionType(_) | AstNodes::TSUnionType(_) => true, + AstNodes::TSRestType(_) => false, + _ => operator_type_or_higher_needs_parens(self.span, self.parent), + } } } impl<'a> NeedsParentheses<'a> for AstNode<'a, TSConstructorType<'a>> { + #[inline] fn needs_parentheses(&self, f: &Formatter<'_, 'a>) -> bool { - match self.parent { - AstNodes::TSConditionalType(ty) => { - ty.extends_type().span() == self.span() || ty.check_type().span() == self.span() - } - AstNodes::TSUnionType(_) | AstNodes::TSIntersectionType(_) => true, - _ => false, - } + function_like_type_needs_parentheses(self.span(), self.parent, Some(&self.return_type)) } } @@ -71,9 +65,60 @@ impl<'a> NeedsParentheses<'a> for AstNode<'a, TSUnionType<'a>> { } /// Returns `true` if a TS primary type needs parentheses +/// Common logic for determining if function-like types (TSFunctionType, TSConstructorType) +/// need parentheses based on their parent context. +/// +/// Ported from Biome's function_like_type_needs_parentheses +fn function_like_type_needs_parentheses<'a>( + span: Span, + parent: &'a AstNodes<'a>, + return_type: Option<&'a TSTypeAnnotation<'a>>, +) -> bool { + match parent { + // Arrow function return types need parens + AstNodes::TSTypeAnnotation(type_annotation) => { + matches!(type_annotation.parent, AstNodes::ArrowFunctionExpression(_)) + } + // In conditional types + AstNodes::TSConditionalType(conditional) => { + let is_check_type = conditional.check_type().span() == span; + if is_check_type { + return true; + } + + let is_extends_type = conditional.extends_type().span() == span; + if is_extends_type { + // Need parentheses if return type is TSInferType with constraint + // or TSTypePredicate with type annotation + if let Some(return_type) = return_type { + match &return_type.type_annotation { + TSType::TSInferType(infer_type) => { + return infer_type.type_parameter.constraint.is_some(); + } + TSType::TSTypePredicate(predicate) => { + return predicate.type_annotation.is_some(); + } + _ => {} + } + } + } + false + } + AstNodes::TSUnionType(_) | AstNodes::TSIntersectionType(_) => true, + _ => operator_type_or_higher_needs_parens(span, parent), + } +} + +/// Returns `true` if a TS primary type needs parentheses +/// This is for types that have higher precedence operators as parents fn operator_type_or_higher_needs_parens(span: Span, parent: &AstNodes) -> bool { match parent { - AstNodes::TSArrayType(_) | AstNodes::TSTypeOperator(_) | AstNodes::TSRestType(_) => true, + // These parent types always require parentheses for their operands + AstNodes::TSArrayType(_) + | AstNodes::TSTypeOperator(_) + | AstNodes::TSRestType(_) + | AstNodes::TSOptionalType(_) => true, + // Indexed access requires parens if this is the object type AstNodes::TSIndexedAccessType(indexed) => indexed.object_type.span() == span, _ => false, } @@ -97,18 +142,36 @@ impl<'a> NeedsParentheses<'a> for AstNode<'a, TSConditionalType<'a>> { ty.extends_type().span() == self.span() || ty.check_type().span() == self.span() } AstNodes::TSUnionType(_) | AstNodes::TSIntersectionType(_) => true, - _ => false, + _ => operator_type_or_higher_needs_parens(self.span, self.parent), } } } impl<'a> NeedsParentheses<'a> for AstNode<'a, TSTypeOperator<'a>> { fn needs_parentheses(&self, f: &Formatter<'_, 'a>) -> bool { - matches!( - self.parent, - AstNodes::TSArrayType(_) - | AstNodes::TSTypeOperator(_) - | AstNodes::TSIndexedAccessType(_) - ) + operator_type_or_higher_needs_parens(self.span(), self.parent) + } +} + +impl<'a> NeedsParentheses<'a> for AstNode<'a, TSTypeQuery<'a>> { + fn needs_parentheses(&self, f: &Formatter<'_, 'a>) -> bool { + match self.parent { + AstNodes::TSArrayType(_) => true, + // Typeof operators are parenthesized when used as an object type in an indexed access + // to avoid ambiguity of precedence, as it's higher than the JS equivalent: + // ```typescript + // const array = [1, 2, 3] + // type T = typeof array[0]; // => number + // type T2 = (typeof array)[0]; // => number + // const J1 = typeof array[0]; // => 'number' + // const J2 = (typeof array)[0]; // => 'o', because `typeof array` is 'object' + // ``` + AstNodes::TSIndexedAccessType(indexed) => { + // The typeof operator only needs parens if it's the object of the indexed access. + // If it's the index_type, then the braces already act as the visual precedence. + indexed.object_type().span() == self.span() + } + _ => false, + } } } diff --git a/crates/oxc_formatter/src/utils/assignment_like.rs b/crates/oxc_formatter/src/utils/assignment_like.rs index 507a801ba23b1..b6a5826c8b51b 100644 --- a/crates/oxc_formatter/src/utils/assignment_like.rs +++ b/crates/oxc_formatter/src/utils/assignment_like.rs @@ -9,18 +9,19 @@ use crate::{ formatter::{ Buffer, BufferExtensions, Format, FormatResult, Formatter, VecBuffer, prelude::{FormatElements, format_once, line_suffix_boundary, *}, - trivia::format_dangling_comments, + trivia::{FormatLeadingComments, format_dangling_comments}, }, generated::ast_nodes::{AstNode, AstNodes}, options::Expand, utils::{ + format_node_without_trailing_comments::FormatNodeWithoutTrailingComments, member_chain::is_member_call_chain, object::{format_property_key, write_member_name}, }, write, write::{ BinaryLikeExpression, FormatJsArrowFunctionExpression, - FormatJsArrowFunctionExpressionOptions, + FormatJsArrowFunctionExpressionOptions, FormatWrite, }, }; @@ -32,7 +33,7 @@ pub enum AssignmentLike<'a, 'b> { AssignmentExpression(&'b AstNode<'a, AssignmentExpression<'a>>), ObjectProperty(&'b AstNode<'a, ObjectProperty<'a>>), PropertyDefinition(&'b AstNode<'a, PropertyDefinition<'a>>), - // TODO: Add TSTypeAliasDeclaration when needed + TSTypeAliasDeclaration(&'b AstNode<'a, TSTypeAliasDeclaration<'a>>), } /// Determines how a assignment like be formatted @@ -184,7 +185,6 @@ impl<'a> AssignmentLike<'a, '_> { } } AssignmentLike::PropertyDefinition(property) => { - // Write modifiers write!(f, property.decorators())?; if property.declare { write!(f, ["declare", space()])?; @@ -192,11 +192,14 @@ impl<'a> AssignmentLike<'a, '_> { if let Some(accessibility) = property.accessibility { write!(f, [accessibility.as_str(), space()])?; } + if property.r#static { + write!(f, ["static", space()])?; + } if property.r#type == PropertyDefinitionType::TSAbstractPropertyDefinition { write!(f, ["abstract", space()])?; } - if property.r#static { - write!(f, ["static", space()])?; + if property.r#override { + write!(f, ["override", space()])?; } if property.readonly { write!(f, ["readonly", space()])?; @@ -209,16 +212,31 @@ impl<'a> AssignmentLike<'a, '_> { format_property_key(property.key(), f)?; } - // Write optional and type annotation + // Write optional, definite, and type annotation if property.optional { write!(f, "?")?; } + if property.definite { + write!(f, "!")?; + } if let Some(type_annotation) = property.type_annotation() { write!(f, type_annotation)?; } Ok(false) // Class properties don't use "short" key logic } + AssignmentLike::TSTypeAliasDeclaration(declaration) => { + write!( + f, + [ + declaration.declare.then_some("declare "), + "type ", + declaration.id(), + declaration.type_parameters() + ] + )?; + Ok(false) + } } } @@ -239,6 +257,9 @@ impl<'a> AssignmentLike<'a, '_> { { write!(f, [space(), "="]) } + Self::TSTypeAliasDeclaration(_) => { + write!(f, [space(), "="]) + } _ => Ok(()), } } @@ -269,6 +290,14 @@ impl<'a> AssignmentLike<'a, '_> { [space(), with_assignment_layout(property.value().unwrap(), Some(layout))] ) } + Self::TSTypeAliasDeclaration(declaration) => { + if let AstNodes::TSUnionType(union) = declaration.type_annotation().as_ast_nodes() { + union.write(f)?; + union.format_trailing_comments(f) + } else { + write!(f, [space(), declaration.type_annotation()]) + } + } } } @@ -300,14 +329,14 @@ impl<'a> AssignmentLike<'a, '_> { return AssignmentLikeLayout::NeverBreakAfterOperator; } - if self.should_break_left_hand_side() { - return AssignmentLikeLayout::BreakLeftHandSide; - } - if self.should_break_after_operator(right_expression, f) { return AssignmentLikeLayout::BreakAfterOperator; } + if self.should_break_left_hand_side() { + return AssignmentLikeLayout::BreakLeftHandSide; + } + if is_left_short { return AssignmentLikeLayout::NeverBreakAfterOperator; } @@ -365,6 +394,7 @@ impl<'a> AssignmentLike<'a, '_> { AssignmentLike::PropertyDefinition(property_class_member) => { property_class_member.value() } + AssignmentLike::TSTypeAliasDeclaration(declaration) => None, } } @@ -372,7 +402,7 @@ impl<'a> AssignmentLike<'a, '_> { /// usually, when a [variable declarator](VariableDeclarator) doesn't have initializer fn has_only_left_hand_side(&self) -> bool { match self { - Self::AssignmentExpression(_) => false, + Self::AssignmentExpression(_) | Self::TSTypeAliasDeclaration(_) => false, Self::VariableDeclarator(declarator) => declarator.init.is_none(), Self::PropertyDefinition(property) => property.value().is_none(), Self::ObjectProperty(property) => property.shorthand, @@ -436,17 +466,20 @@ impl<'a> AssignmentLike<'a, '_> { return true; } - // TODO: Add is_complex_type_alias when TypeAliasDeclaration is supported - let is_complex_type_alias = false; + if self.is_complex_type_alias() { + return true; + } - if !self - .get_right_expression() - .is_some_and(|expr| matches!(expr.as_ref(), Expression::ArrowFunctionExpression(_))) - { + let Self::VariableDeclarator(declarator) = self else { return false; - } + }; + + let type_annotation = declarator.id.type_annotation.as_ref(); - matches!(self, Self::VariableDeclarator(decl) if decl.id.type_annotation.as_ref().is_some_and(|ann| is_complex_type_annotation(ann))) + type_annotation.is_some_and(|ann| is_complex_type_annotation(ann)) + || (self.get_right_expression().is_some_and(|expr| { + matches!(expr.as_ref(), Expression::ArrowFunctionExpression(_)) + }) && type_annotation.is_some_and(|ann| is_annotation_breakable(ann))) } /// Checks if the current assignment is eligible for [AssignmentLikeLayout::BreakAfterOperator] @@ -461,33 +494,49 @@ impl<'a> AssignmentLike<'a, '_> { let comments = f.context().comments(); if let Some(right_expression) = right_expression { should_break_after_operator(right_expression, f) + } else if let AssignmentLike::TSTypeAliasDeclaration(decl) = self { + // For TSTypeAliasDeclaration, check if the type annotation is a union type with comments + match &decl.type_annotation { + TSType::TSConditionalType(conditional_type) => { + let is_generic = |ts_type: &TSType<'a>| -> bool { + match ts_type { + TSType::TSFunctionType(function) => function.type_parameters.is_some(), + TSType::TSTypeReference(reference) => { + reference.type_arguments.is_some() + } + _ => false, + } + }; + + is_generic(&conditional_type.check_type) + || is_generic(&conditional_type.extends_type) + } + _ => { + // Check for leading comments on any other type + comments.has_comment_before(decl.type_annotation.span().start) + } + } } else { - // RightAssignmentLike::AnyTsType(AnyTsType::TsUnionType(ty)) => { - // // Recursively checks if the union type is nested and identifies the innermost union type. - // // If a leading comment is found while navigating to the inner union type, - // // it is considered as having leading comments. - // let mut union_type = ty.clone(); - // let mut has_leading_comments = comments.has_leading_comments(union_type.syntax()); - // while is_nested_union_type(&union_type)? && !has_leading_comments { - // if let Some(Ok(inner_union_type)) = union_type.types().last() { - // let inner_union_type = TsUnionType::cast(inner_union_type.into_syntax()); - // if let Some(inner_union_type) = inner_union_type { - // has_leading_comments = - // comments.has_leading_comments(inner_union_type.syntax()); - // union_type = inner_union_type; - // } else { - // break; - // } - // } else { - // break; - // } - // } - // has_leading_comments - // } false } } + fn is_complex_type_alias(&self) -> bool { + let AssignmentLike::TSTypeAliasDeclaration(type_alias) = self else { + return false; + }; + + let Some(type_parameters) = &type_alias.type_parameters else { + return false; + }; + + type_parameters.params.len() > 1 + && type_parameters + .params + .iter() + .any(|param| param.constraint.is_some() || param.default.is_some()) + } + fn is_complex_destructuring(&self) -> bool { match self { AssignmentLike::VariableDeclarator(variable_decorator) => { @@ -517,7 +566,9 @@ impl<'a> AssignmentLike<'a, '_> { AssignmentTargetProperty::AssignmentTargetPropertyProperty(_) => true, }) } - AssignmentLike::ObjectProperty(_) | AssignmentLike::PropertyDefinition(_) => false, + AssignmentLike::ObjectProperty(_) + | AssignmentLike::PropertyDefinition(_) + | AssignmentLike::TSTypeAliasDeclaration(_) => false, } } } @@ -878,10 +929,7 @@ fn is_complex_type_arguments(type_arguments: &TSTypeParameterInstantiation) -> b let is_first_argument_complex = ts_type_argument_list.first().is_some_and(|first_argument| { matches!( first_argument, - TSType::TSUnionType(_) - | TSType::TSIntersectionType(_) - | TSType::TSTupleType(_) - | TSType::TSTypeLiteral(_) + TSType::TSUnionType(_) | TSType::TSIntersectionType(_) | TSType::TSTypeLiteral(_) ) }); @@ -895,20 +943,6 @@ fn is_complex_type_arguments(type_arguments: &TSTypeParameterInstantiation) -> b false } -/// If a union type has only one type and it's a union type, then it's a nested union type -/// ```js -/// type A = | (A | B) -/// ^^^^^^^^^^ -/// ``` -/// The final format will only keep the inner union type -fn is_nested_union_type(union_type: &TSUnionType) -> bool { - if union_type.types.len() == 1 { - let ty = &union_type.types[0]; - return matches!(ty, TSType::TSUnionType(_)); - } - false -} - /// Checks if the annotation is breakable fn is_annotation_breakable(annotation: &TSTypeAnnotation) -> bool { matches!( diff --git a/crates/oxc_formatter/src/utils/conditional.rs b/crates/oxc_formatter/src/utils/conditional.rs index 92f407e4cf2c6..898ee6b092327 100644 --- a/crates/oxc_formatter/src/utils/conditional.rs +++ b/crates/oxc_formatter/src/utils/conditional.rs @@ -7,7 +7,7 @@ use crate::{ Format, FormatResult, FormatWrite, formatter::{Formatter, prelude::*, trivia::FormatTrailingComments}, generated::ast_nodes::{AstNode, AstNodes}, - utils::expression::FormatExpressionWithoutTrailingComments, + utils::format_node_without_trailing_comments::FormatNodeWithoutTrailingComments, write, }; @@ -171,32 +171,35 @@ impl<'a> FormatConditionalLike<'a, '_> { fn layout(&self, f: &mut Formatter<'_, 'a>) -> ConditionalLayout { let self_span = self.span(); - let (is_test, is_consequent) = match self.parent() { + match self.parent() { AstNodes::ConditionalExpression(parent) => { let parent_expr = parent.as_ref(); - (parent_expr.test.span() == self_span, parent_expr.consequent.span() == self_span) + if parent_expr.test.span() == self_span { + ConditionalLayout::NestedTest + } else if parent_expr.consequent.span() == self_span { + ConditionalLayout::NestedConsequent + } else { + ConditionalLayout::NestedAlternate + } } AstNodes::TSConditionalType(parent) => { let parent_type = parent.as_ref(); // For TS conditional types, both check_type and extends_type are part of the test let is_test = parent_type.check_type.span() == self_span || parent_type.extends_type.span() == self_span; - let is_consequent = parent_type.true_type.span() == self_span; - (is_test, is_consequent) + if is_test { + ConditionalLayout::NestedTest + } else if parent_type.true_type.span() == self_span { + ConditionalLayout::NestedConsequent + } else { + ConditionalLayout::NestedAlternate + } } _ => { let jsx_chain = f.context().source_type().is_jsx() && self.is_jsx_conditional_chain(); - return ConditionalLayout::Root { jsx_chain }; + ConditionalLayout::Root { jsx_chain } } - }; - - if is_test { - ConditionalLayout::NestedTest - } else if is_consequent { - ConditionalLayout::NestedConsequent - } else { - ConditionalLayout::NestedAlternate } } @@ -374,7 +377,7 @@ impl<'a> FormatConditionalLike<'a, '_> { ) -> FormatResult<()> { let format_inner = format_with(|f| match self.conditional { ConditionalLike::ConditionalExpression(conditional) => { - write!(f, FormatExpressionWithoutTrailingComments(conditional.test()))?; + write!(f, FormatNodeWithoutTrailingComments(conditional.test()))?; format_trailing_comments( conditional.test.span().end, conditional.consequent.span().start, @@ -390,8 +393,15 @@ impl<'a> FormatConditionalLike<'a, '_> { space(), "extends", space(), - conditional.extends_type() + FormatNodeWithoutTrailingComments(conditional.extends_type()) ] + )?; + + format_trailing_comments( + conditional.extends_type.span().end, + conditional.true_type.span().start, + b'?', + f, ) } }); @@ -411,53 +421,60 @@ impl<'a> FormatConditionalLike<'a, '_> { ) -> FormatResult<()> { write!(f, [soft_line_break_or_space(), "?", space()])?; - let format_consequent = format_with(|f| match self.conditional { - ConditionalLike::ConditionalExpression(conditional) => { - let is_consequent_nested = match self.conditional { + let format_consequent = format_with(|f| { + let format_consequent_with_trailing_comments = + format_once(|f| match self.conditional { ConditionalLike::ConditionalExpression(conditional) => { - matches!(conditional.consequent, Expression::ConditionalExpression(_)) + write!(f, FormatNodeWithoutTrailingComments(conditional.consequent()))?; + format_trailing_comments( + conditional.consequent.span().end, + conditional.alternate.span().start, + b':', + f, + ) } ConditionalLike::TSConditionalType(conditional) => { - matches!(conditional.true_type, TSType::TSConditionalType(_)) - } - }; - - let format_consequent = format_once(|f| { - write!(f, FormatExpressionWithoutTrailingComments(conditional.consequent()))?; - format_trailing_comments( - conditional.consequent.span().end, - conditional.alternate.span().start, - b':', - f, - ) - }); - - let format_consequent = format_with(|f| { - if f.options().indent_style.is_space() { - write!(f, [align(2, &format_consequent)]) - } else { - write!(f, [indent(&format_consequent)]) + write!(f, FormatNodeWithoutTrailingComments(conditional.true_type()))?; + format_trailing_comments( + conditional.true_type.span().end, + conditional.false_type.span().start, + b':', + f, + ) } }); - if is_consequent_nested { - // Add parentheses around the consequent if it is a conditional expression and fits on the same line - // so that it's easier to identify the parts that belong to a conditional expression. - // `a ? b ? c: d : e` -> `a ? (b ? c: d) : e` - write!( - f, - [ - if_group_fits_on_line(&text("(")), - format_consequent, - if_group_fits_on_line(&text(")")) - ] - ) + let format_consequent_with_proper_indentation = format_with(|f| { + if f.options().indent_style.is_space() { + write!(f, [align(2, &format_consequent_with_trailing_comments)]) } else { - write!(f, format_consequent) + write!(f, [indent(&format_consequent_with_trailing_comments)]) } - } - ConditionalLike::TSConditionalType(conditional) => { - write!(f, [conditional.true_type()]) + }); + + let is_nested_consequent = match self.conditional { + ConditionalLike::ConditionalExpression(conditional) => { + matches!(conditional.consequent, Expression::ConditionalExpression(_)) + } + ConditionalLike::TSConditionalType(conditional) => { + matches!(conditional.true_type, TSType::TSConditionalType(_)) + } + }; + + if is_nested_consequent { + // Add parentheses around the consequent if it is a conditional expression and fits on the same line + // so that it's easier to identify the parts that belong to a conditional expression. + // `a ? b ? c: d : e` -> `a ? (b ? c: d) : e` + write!( + f, + [ + if_group_fits_on_line(&text("(")), + format_consequent_with_proper_indentation, + if_group_fits_on_line(&text(")")) + ] + ) + } else { + write!(f, format_consequent_with_proper_indentation) } }); @@ -662,7 +679,7 @@ impl<'a> Format<'a> for FormatJsxChainExpression<'a, '_> { } .fmt(f) } else { - FormatExpressionWithoutTrailingComments(self.expression).fmt(f) + FormatNodeWithoutTrailingComments(self.expression).fmt(f) } }); diff --git a/crates/oxc_formatter/src/utils/expression.rs b/crates/oxc_formatter/src/utils/expression.rs deleted file mode 100644 index adb2e81ff24a3..0000000000000 --- a/crates/oxc_formatter/src/utils/expression.rs +++ /dev/null @@ -1,255 +0,0 @@ -use oxc_ast::ast::Expression; -use oxc_span::GetSpan; - -use crate::{ - Format, - formatter::{FormatResult, Formatter, prelude::*}, - generated::ast_nodes::{AstNode, AstNodes}, - parentheses::NeedsParentheses, - utils::typecast::format_type_cast_comment_node, - write, - write::FormatWrite, -}; - -pub struct FormatExpressionWithoutTrailingComments<'a, 'b>(pub &'b AstNode<'a, Expression<'a>>); - -impl<'a> Format<'a> for FormatExpressionWithoutTrailingComments<'a, '_> { - fn fmt(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { - let is_object_or_array_expression = matches!( - self.0.as_ast_nodes(), - AstNodes::ObjectExpression(_) | AstNodes::ArrayExpression(_) - ); - if format_type_cast_comment_node(self.0, is_object_or_array_expression, f)? { - return Ok(()); - } - - let needs_parentheses = self.0.needs_parentheses(f); - let print_left_paren = - |f: &mut Formatter<'_, 'a>| write!(f, needs_parentheses.then_some("(")); - - match self.0.as_ast_nodes() { - AstNodes::BooleanLiteral(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::NullLiteral(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::NumericLiteral(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::BigIntLiteral(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::RegExpLiteral(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::StringLiteral(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::TemplateLiteral(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::IdentifierReference(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::MetaProperty(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::Super(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::ArrayExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::ArrowFunctionExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::AssignmentExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::AwaitExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::BinaryExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::CallExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::ChainExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::Class(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::ConditionalExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::Function(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::ImportExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::LogicalExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::NewExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::ObjectExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::ParenthesizedExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::SequenceExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::TaggedTemplateExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::ThisExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::UnaryExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::UpdateExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::YieldExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::PrivateInExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::JSXElement(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::JSXFragment(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::TSAsExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::TSSatisfiesExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::TSTypeAssertion(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::TSNonNullExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::TSInstantiationExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::V8IntrinsicExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::StaticMemberExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::ComputedMemberExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - AstNodes::PrivateFieldExpression(n) => { - n.format_leading_comments(f)?; - print_left_paren(f)?; - n.write(f) - } - _ => unreachable!(), - }?; - - if needs_parentheses { - write!(f, [")"])?; - } - - Ok(()) - } -} diff --git a/crates/oxc_formatter/src/utils/format_node_without_trailing_comments.rs b/crates/oxc_formatter/src/utils/format_node_without_trailing_comments.rs new file mode 100644 index 0000000000000..0bf3547e02949 --- /dev/null +++ b/crates/oxc_formatter/src/utils/format_node_without_trailing_comments.rs @@ -0,0 +1,33 @@ +use oxc_span::GetSpan; + +use crate::{Format, FormatResult, formatter::Formatter}; + +/// Generic wrapper for formatting a node without its trailing comments. +/// +/// This wrapper temporarily hides comments that appear after the node's span end position, +/// effectively preventing trailing comments from being formatted while preserving all other +/// comment formatting behavior (leading comments, internal comments). +pub struct FormatNodeWithoutTrailingComments<'b, T>(pub &'b T); + +impl<'a, T> Format<'a> for FormatNodeWithoutTrailingComments<'_, T> +where + T: Format<'a> + GetSpan, +{ + fn fmt(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { + let node_end = self.0.span().end; + + // Save the current comment view limit and temporarily restrict it + // to hide comments that start at or after the node's end position + let previous_limit = f.context_mut().comments_mut().limit_comments_up_to(node_end); + + // Format the node with the restricted comment view + // This allows all comments within the node's span to be formatted normally, + // but hides any trailing comments that come after it + let result = self.0.fmt(f); + + // Restore the previous comment view limit + f.context_mut().comments_mut().restore_view_limit(previous_limit); + + result + } +} diff --git a/crates/oxc_formatter/src/utils/mod.rs b/crates/oxc_formatter/src/utils/mod.rs index 9685e2c3532d3..44cfdad6c5f01 100644 --- a/crates/oxc_formatter/src/utils/mod.rs +++ b/crates/oxc_formatter/src/utils/mod.rs @@ -1,13 +1,14 @@ pub mod assignment_like; pub mod call_expression; pub mod conditional; -pub mod expression; +pub mod format_node_without_trailing_comments; pub mod jsx; pub mod member_chain; pub mod object; pub mod string_utils; pub mod suppressed; pub mod typecast; +pub mod typescript; use oxc_allocator::Address; use oxc_ast::{AstKind, ast::CallExpression}; diff --git a/crates/oxc_formatter/src/utils/object.rs b/crates/oxc_formatter/src/utils/object.rs index 5e208cd53c6c8..104472ad3b820 100644 --- a/crates/oxc_formatter/src/utils/object.rs +++ b/crates/oxc_formatter/src/utils/object.rs @@ -15,12 +15,21 @@ pub fn format_property_key<'a>( f: &mut Formatter<'_, 'a>, ) -> FormatResult<()> { if let PropertyKey::StringLiteral(s) = key.as_ref() { + // `"constructor"` property in the class should be kept quoted + let kind = if matches!(key.parent, AstNodes::PropertyDefinition(_)) + && matches!(key.as_ref(), PropertyKey::StringLiteral(string) if string.value == "constructor") + { + StringLiteralParentKind::Expression + } else { + StringLiteralParentKind::Member + }; + FormatLiteralStringToken::new( f.source_text().text_for(s.as_ref()), s.span, /* jsx */ false, - StringLiteralParentKind::Member, + kind, ) .fmt(f) } else { diff --git a/crates/oxc_formatter/src/utils/typescript.rs b/crates/oxc_formatter/src/utils/typescript.rs new file mode 100644 index 0000000000000..f79ecc3a5c35b --- /dev/null +++ b/crates/oxc_formatter/src/utils/typescript.rs @@ -0,0 +1,72 @@ +use oxc_ast::ast::{TSType, TSUnionType}; +use oxc_span::GetSpan; + +use crate::{formatter::Formatter, generated::ast_nodes::AstNode}; + +/// Check if a TSType is a simple type (primitives, keywords, simple references) +pub fn is_simple_type(ty: &TSType) -> bool { + match ty { + TSType::TSAnyKeyword(_) + | TSType::TSNullKeyword(_) + | TSType::TSThisType(_) + | TSType::TSVoidKeyword(_) + | TSType::TSNumberKeyword(_) + | TSType::TSBooleanKeyword(_) + | TSType::TSBigIntKeyword(_) + | TSType::TSStringKeyword(_) + | TSType::TSSymbolKeyword(_) + | TSType::TSNeverKeyword(_) + | TSType::TSObjectKeyword(_) + | TSType::TSUndefinedKeyword(_) + | TSType::TSTemplateLiteralType(_) + | TSType::TSLiteralType(_) + | TSType::TSUnknownKeyword(_) => true, + TSType::TSTypeReference(reference) => { + // Simple reference without type arguments + reference.type_arguments.is_none() + } + _ => false, + } +} + +/// Check if a TSType is object-like (object literal, mapped type, etc.) +pub fn is_object_like_type(ty: &TSType) -> bool { + matches!(ty, TSType::TSTypeLiteral(_) | TSType::TSMappedType(_)) +} + +pub fn should_hug_type(node: &TSUnionType<'_>, f: &Formatter<'_, '_>) -> bool { + let types = &node.types; + + if types.len() == 1 { + return true; + } + + let has_object_type = + types.iter().any(|t| matches!(t, TSType::TSTypeLiteral(_) | TSType::TSTypeReference(_))); + + if !has_object_type { + return false; + } + + let void_count = types + .iter() + .filter(|t| matches!(t, TSType::TSVoidKeyword(_) | TSType::TSNullKeyword(_))) + .count(); + + if types.len() - 1 != void_count { + return false; + } + + // `{ a: string } /* comment */ | null | /* comment */ */ undefined` + // ^^^^^^^^^^^^ ^^^^^^^^^^^^ + // Check whether there are comments between the types, if so, we should not hug + let mut start = node.span.start; + for t in types { + if f.comments().has_comment_in_range(start, t.span().start) { + return false; + } + start = t.span().end; + } + + true +} diff --git a/crates/oxc_formatter/src/write/arrow_function_expression.rs b/crates/oxc_formatter/src/write/arrow_function_expression.rs index 7b51af2bc7445..98f57df25e49e 100644 --- a/crates/oxc_formatter/src/write/arrow_function_expression.rs +++ b/crates/oxc_formatter/src/write/arrow_function_expression.rs @@ -15,9 +15,11 @@ use crate::{ options::FormatTrailingCommas, utils::assignment_like::AssignmentLikeLayout, write, - write::parameter_list::has_only_simple_parameters, + write::function::FormatContentWithCacheMode, }; +use super::parameters::has_only_simple_parameters; + #[derive(Clone, Copy)] pub struct FormatJsArrowFunctionExpression<'a, 'b> { arrow: &'b AstNode<'a, ArrowFunctionExpression<'a>>, @@ -29,7 +31,7 @@ pub struct FormatJsArrowFunctionExpressionOptions { pub assignment_layout: Option, pub call_arg_layout: Option, // Determine whether the signature and body should be cached. - pub cache_mode: FunctionBodyCacheMode, + pub cache_mode: FunctionCacheMode, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -52,7 +54,7 @@ impl GroupedCallArgumentLayout { } #[derive(Default, Debug, Clone, Copy)] -pub enum FunctionBodyCacheMode { +pub enum FunctionCacheMode { /// Format the body without caching it or retrieving it from the cache. #[default] NoCache, @@ -357,7 +359,7 @@ impl<'a, 'b> ArrowFunctionLayout<'a, 'b> { return true; } - let has_parameters = parameters.items.is_empty(); + let has_parameters = !parameters.items.is_empty(); let has_type_and_parameters = arrow.return_type.is_some() && has_parameters; has_type_and_parameters || has_rest_object_or_array_parameter(parameters) } @@ -491,7 +493,11 @@ impl<'a> Format<'a> for ArrowChain<'a, '_> { // This tracks that state and is used to prevent the insertion of // additional indents under `format_arrow_signatures`, then also to // add the outer indent under `format_inner`. - let has_initial_indent = is_callee || is_assignment_rhs; + let has_initial_indent = is_callee + || self + .options + .assignment_layout + .is_some_and(|layout| layout != AssignmentLikeLayout::BreakAfterOperator); let format_arrow_signatures = format_with(|f| { let join_signatures = format_with(|f| { @@ -848,43 +854,32 @@ fn format_signature<'a, 'b>( arrow: &'b AstNode<'a, ArrowFunctionExpression<'a>>, is_first_or_last_call_argument: bool, is_first_in_chain: bool, - cache_mode: FunctionBodyCacheMode, + cache_mode: FunctionCacheMode, ) -> impl Format<'a> + 'b { format_with(move |f| { - let signatures = format_once(|f| { - write!( - f, - [group(&format_args!( - maybe_space(!is_first_in_chain), - arrow.r#async().then_some("async "), - arrow.type_parameters(), - arrow.params(), - group(&arrow.return_type()) - ))] - ) - }); - - // The [`call_arguments`] will format the argument that can be grouped in different ways until - // find the best layout. So we have to cache the parameters because it never be broken. - let cached_signature = format_once(|f| { - if matches!(cache_mode, FunctionBodyCacheMode::NoCache) { - signatures.fmt(f) - } else if let Some(grouped) = f.context().get_cached_element(&arrow.params.span) { - f.write_element(grouped) - } else { - if let Ok(Some(grouped)) = f.intern(&signatures) { - f.context_mut().cache_element(&arrow.params.span, grouped.clone()); - f.write_element(grouped.clone()); - } - Ok(()) - } + let content = format_once(|f| { + group(&format_args!( + maybe_space(!is_first_in_chain), + arrow.r#async().then_some("async "), + arrow.type_parameters(), + arrow.params(), + format_once(|f| { + let needs_space = arrow.return_type.as_ref().is_some_and(|return_type| { + f.context().comments().has_comment_before(return_type.span.start) + }); + maybe_space(needs_space).fmt(f) + }), + group(&arrow.return_type()) + )) + .fmt(f) }); + let format_head = FormatContentWithCacheMode::new(arrow.params.span, content, cache_mode); if is_first_or_last_call_argument { let mut buffer = RemoveSoftLinesBuffer::new(f); let mut recording = buffer.start_recording(); - write!(recording, cached_signature)?; + write!(recording, format_head)?; if recording.stop().will_break() { return Err(FormatError::PoorLayout); @@ -898,7 +893,7 @@ fn format_signature<'a, 'b>( // line and can't break pre-emptively without also causing // the parent (i.e., this ArrowChain) to break first. (!is_first_in_chain).then_some(soft_line_break_or_space()), - cached_signature + format_head ] )?; } @@ -921,38 +916,20 @@ pub struct FormatMaybeCachedFunctionBody<'a, 'b> { pub expression: bool, /// If the body should be cached or if the formatter should try to retrieve it from the cache. - pub mode: FunctionBodyCacheMode, -} - -impl<'a> FormatMaybeCachedFunctionBody<'a, '_> { - fn format(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { - if self.expression - && let AstNodes::ExpressionStatement(s) = - &self.body.statements().first().unwrap().as_ast_nodes() - { - return s.expression().fmt(f); - } - self.body.fmt(f) - } + pub mode: FunctionCacheMode, } impl<'a> Format<'a> for FormatMaybeCachedFunctionBody<'a, '_> { fn fmt(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { - match self.mode { - FunctionBodyCacheMode::NoCache => self.format(f), - FunctionBodyCacheMode::Cache => { - if let Some(cached) = f.context().get_cached_element(&self.body.span) { - f.write_element(cached) - } else { - match f.intern(&format_once(|f| self.format(f)))? { - Some(interned) => { - f.context_mut().cache_element(&self.body.span, interned.clone()); - f.write_element(interned) - } - None => Ok(()), - } - } + let content = format_once(|f| { + if self.expression + && let AstNodes::ExpressionStatement(s) = + &self.body.statements().first().unwrap().as_ast_nodes() + { + return s.expression().fmt(f); } - } + self.body.fmt(f) + }); + FormatContentWithCacheMode::new(self.body.span, content, self.mode).fmt(f) } } diff --git a/crates/oxc_formatter/src/write/binary_like_expression.rs b/crates/oxc_formatter/src/write/binary_like_expression.rs index 4d7f8e53fdfb6..da3f8da0ccba1 100644 --- a/crates/oxc_formatter/src/write/binary_like_expression.rs +++ b/crates/oxc_formatter/src/write/binary_like_expression.rs @@ -9,7 +9,7 @@ use crate::{ Format, formatter::{FormatResult, Formatter}, generated::ast_nodes::{AstNode, AstNodes}, - utils::expression::FormatExpressionWithoutTrailingComments, + utils::format_node_without_trailing_comments::FormatNodeWithoutTrailingComments, }; use crate::{format_args, formatter::prelude::*, write}; @@ -322,7 +322,7 @@ impl<'a> Format<'a> for BinaryLeftOrRightSide<'a, '_> { } if *root { - write!(f, FormatExpressionWithoutTrailingComments(right)) + write!(f, FormatNodeWithoutTrailingComments(right)) } else { write!(f, right) } diff --git a/crates/oxc_formatter/src/write/call_arguments.rs b/crates/oxc_formatter/src/write/call_arguments.rs index f81d2768cd4d2..563bc8b76bb1b 100644 --- a/crates/oxc_formatter/src/write/call_arguments.rs +++ b/crates/oxc_formatter/src/write/call_arguments.rs @@ -25,17 +25,17 @@ use crate::{ write::{ FormatFunctionOptions, arrow_function_expression::is_multiline_template_starting_on_same_line, - parameter_list::has_only_simple_parameters, }, }; use super::{ array_element_list::can_concisely_print_array_list, arrow_function_expression::{ - FormatJsArrowFunctionExpression, FormatJsArrowFunctionExpressionOptions, - FunctionBodyCacheMode, GroupedCallArgumentLayout, + FormatJsArrowFunctionExpression, FormatJsArrowFunctionExpressionOptions, FunctionCacheMode, + GroupedCallArgumentLayout, }, function, + parameters::has_only_simple_parameters, }; impl<'a> Format<'a> for AstNode<'a, ArenaVec<'a, Argument<'a>>> { @@ -299,11 +299,14 @@ fn should_group_last_argument( match last.and_then(|arg| arg.as_expression()) { Some(last) => { let penultimate = iter.next_back(); - if let Some(penultimate) = &penultimate { - // TODO: check if both last and penultimate are same kind of expression. - // if penultimate.syntax().kind() == last.syntax().kind() { - // return Ok(false); - // } + if let Some(penultimate) = &penultimate + && matches!( + (penultimate, last), + (Argument::ObjectExpression(_), Expression::ObjectExpression(_)) + | (Argument::ArrayExpression(_), Expression::ArrayExpression(_)) + ) + { + return false; } let previous_span = penultimate.map_or(call_like_span.start, |a| a.span().end); @@ -570,7 +573,7 @@ fn write_grouped_arguments<'a>( has_cached = true; return function.fmt_with_options( FormatFunctionOptions { - cache_mode: FunctionBodyCacheMode::Cache, + cache_mode: FunctionCacheMode::Cache, ..Default::default() }, f, @@ -580,7 +583,7 @@ fn write_grouped_arguments<'a>( has_cached = true; return arrow.fmt_with_options( FormatJsArrowFunctionExpressionOptions { - cache_mode: FunctionBodyCacheMode::Cache, + cache_mode: FunctionCacheMode::Cache, ..FormatJsArrowFunctionExpressionOptions::default() }, f, @@ -798,7 +801,7 @@ impl<'a> Format<'a> for FormatGroupedFirstArgument<'a, '_> { AstNodes::ArrowFunctionExpression(arrow) => with_token_tracking_disabled(f, |f| { arrow.fmt_with_options( FormatJsArrowFunctionExpressionOptions { - cache_mode: FunctionBodyCacheMode::Cache, + cache_mode: FunctionCacheMode::Cache, call_arg_layout: Some(GroupedCallArgumentLayout::GroupedFirstArgument), ..FormatJsArrowFunctionExpressionOptions::default() }, @@ -832,7 +835,7 @@ impl<'a> Format<'a> for FormatGroupedLastArgument<'a, '_> { with_token_tracking_disabled(f, |f| { function.fmt_with_options( FormatFunctionOptions { - cache_mode: FunctionBodyCacheMode::Cache, + cache_mode: FunctionCacheMode::Cache, call_argument_layout: Some( GroupedCallArgumentLayout::GroupedLastArgument, ), @@ -845,7 +848,7 @@ impl<'a> Format<'a> for FormatGroupedLastArgument<'a, '_> { AstNodes::ArrowFunctionExpression(arrow) => with_token_tracking_disabled(f, |f| { arrow.fmt_with_options( FormatJsArrowFunctionExpressionOptions { - cache_mode: FunctionBodyCacheMode::Cache, + cache_mode: FunctionCacheMode::Cache, call_arg_layout: Some(GroupedCallArgumentLayout::GroupedLastArgument), ..FormatJsArrowFunctionExpressionOptions::default() }, diff --git a/crates/oxc_formatter/src/write/class.rs b/crates/oxc_formatter/src/write/class.rs index 7b792afa8f92c..398e6a49e909d 100644 --- a/crates/oxc_formatter/src/write/class.rs +++ b/crates/oxc_formatter/src/write/class.rs @@ -5,7 +5,7 @@ use oxc_ast::{AstKind, ast::*}; use oxc_span::GetSpan; use crate::{ - TrailingSeparator, format_args, + Semicolons, TrailingSeparator, format_args, formatter::{ Buffer, FormatResult, Formatter, prelude::*, @@ -15,14 +15,12 @@ use crate::{ generated::ast_nodes::{AstNode, AstNodes}, parentheses::NeedsParentheses, utils::{ - assignment_like::AssignmentLike, expression::FormatExpressionWithoutTrailingComments, + assignment_like::AssignmentLike, + format_node_without_trailing_comments::FormatNodeWithoutTrailingComments, object::format_property_key, }, write, - write::{ - semicolon::{ClassPropertySemicolon, OptionalSemicolon}, - type_parameters, - }, + write::{semicolon::OptionalSemicolon, type_parameters}, }; use super::{ @@ -59,17 +57,22 @@ impl<'a> Format<'a> for (&AstNode<'a, ClassElement<'a>>, Option<&AstNode<'a, Cla impl<'a> FormatWrite<'a> for AstNode<'a, MethodDefinition<'a>> { fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { + // Write modifiers in the correct order: + // decorators -> accessibility -> static -> abstract -> override -> async -> generator write!(f, [self.decorators()])?; if let Some(accessibility) = &self.accessibility() { write!(f, [accessibility.as_str(), space()])?; } - if self.r#type().is_abstract() { + if self.r#static { + write!(f, ["static", space()])?; + } + if self.r#type.is_abstract() { write!(f, ["abstract", space()])?; } - if self.r#static() { - write!(f, ["static", space()])?; + if self.r#override { + write!(f, ["override", space()])?; } - match &self.kind() { + match &self.kind { MethodDefinitionKind::Constructor | MethodDefinitionKind::Method => {} MethodDefinitionKind::Get => { write!(f, ["get", space()])?; @@ -78,10 +81,11 @@ impl<'a> FormatWrite<'a> for AstNode<'a, MethodDefinition<'a>> { write!(f, ["set", space()])?; } } - if self.value().r#async() { + let value = self.value(); + if value.r#async { write!(f, ["async", space()])?; } - if self.value().generator() { + if value.generator { write!(f, "*")?; } if self.computed { @@ -93,10 +97,9 @@ impl<'a> FormatWrite<'a> for AstNode<'a, MethodDefinition<'a>> { if self.optional() { write!(f, "?")?; } - if let Some(type_parameters) = &self.value().type_parameters() { + if let Some(type_parameters) = &value.type_parameters() { write!(f, type_parameters)?; } - let value = self.value(); // Handle comments between method name and parameters // Example: method /* comment */ (param) {} let comments = f.context().comments().comments_before(value.params().span.start); @@ -160,24 +163,24 @@ impl<'a> FormatWrite<'a> for AstNode<'a, AccessorProperty<'a>> { let comments = f.context().comments().comments_before_character(self.span.start, b'a'); FormatLeadingComments::Comments(comments).fmt(f)?; - if self.r#type().is_abstract() { - write!(f, ["abstract", space()])?; - } if let Some(accessibility) = self.accessibility() { write!(f, [accessibility.as_str(), space()])?; } - if self.r#static() { + if self.r#static { write!(f, ["static", space()])?; } - if self.r#override() { + if self.r#type.is_abstract() { + write!(f, ["abstract", space()])?; + } + if self.r#override { write!(f, ["override", space()])?; } write!(f, ["accessor", space()])?; - if self.computed() { + if self.computed { write!(f, "[")?; } write!(f, self.key())?; - if self.computed() { + if self.computed { write!(f, "]")?; } if let Some(type_annotation) = &self.type_annotation() { @@ -190,6 +193,42 @@ impl<'a> FormatWrite<'a> for AstNode<'a, AccessorProperty<'a>> { } } +impl<'a> FormatWrite<'a> for AstNode<'a, TSIndexSignature<'a>> { + fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { + if self.r#static { + write!(f, ["static", space()])?; + } + if self.readonly { + write!(f, ["readonly", space()])?; + } + let is_class = matches!(self.parent, AstNodes::ClassBody(_)); + write!( + f, + [ + "[", + self.parameters(), + "]", + self.type_annotation(), + is_class.then_some(OptionalSemicolon) + ] + ) + } +} + +impl<'a> Format<'a> for AstNode<'a, Vec<'a, TSIndexSignatureName<'a>>> { + fn fmt(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { + f.join_with(&soft_line_break_or_space()) + .entries_with_trailing_separator(self.iter(), ",", TrailingSeparator::Disallowed) + .finish() + } +} + +impl<'a> FormatWrite<'a> for AstNode<'a, TSIndexSignatureName<'a>> { + fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { + write!(f, [dynamic_text(self.name().as_str()), self.type_annotation()]) + } +} + impl<'a> Format<'a> for AstNode<'a, Vec<'a, TSClassImplements<'a>>> { fn fmt(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { write!( @@ -378,7 +417,7 @@ impl<'a> Format<'a> for FormatClass<'a, '_> { type_arguments.fmt(f) } } else if implements.is_empty() { - FormatExpressionWithoutTrailingComments(extends).fmt(f)?; + FormatNodeWithoutTrailingComments(extends).fmt(f)?; // Only add trailing comments if they're not line comments // Line comments are handled separately to ensure proper placement if !has_trailing_comments { @@ -515,3 +554,76 @@ fn should_group<'a>(class: &Class<'a>, f: &Formatter<'_, 'a>) -> bool { } false } + +pub struct ClassPropertySemicolon<'a, 'b> { + element: &'b AstNode<'a, ClassElement<'a>>, + next_element: Option<&'b AstNode<'a, ClassElement<'a>>>, +} + +impl<'a, 'b> ClassPropertySemicolon<'a, 'b> { + pub fn new( + element: &'b AstNode<'a, ClassElement<'a>>, + next_element: Option<&'b AstNode<'a, ClassElement<'a>>>, + ) -> Self { + Self { element, next_element } + } + + fn needs_semicolon(&self) -> bool { + let Self { element, next_element, .. } = self; + + if let ClassElement::PropertyDefinition(def) = element.as_ref() + && def.value.is_none() + && def.type_annotation.is_none() + && matches!(&def.key, PropertyKey::StaticIdentifier(ident) if matches!(ident.name.as_str(), "static" | "get" | "set") ) + { + return true; + } + + let Some(next_element) = next_element else { return false }; + + match next_element.as_ref() { + // When the name starts with the generator token or `[` + ClassElement::MethodDefinition(def) if !def.value.r#async => { + (def.computed + && !(def.kind.is_accessor() + || def.r#static + || def.accessibility.is_some() + || def.r#override)) + || def.value.generator + } + ClassElement::PropertyDefinition(def) => { + def.computed + && !(def.accessibility.is_some() + || def.r#static + || def.declare + || def.r#override + || def.readonly) + } + ClassElement::AccessorProperty(def) => { + def.computed && !(def.accessibility.is_some() || def.r#static || def.r#override) + } + ClassElement::TSIndexSignature(_) => true, + _ => false, + } + } +} + +impl<'a> Format<'a> for ClassPropertySemicolon<'a, '_> { + fn fmt(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { + if !matches!( + self.element.as_ref(), + ClassElement::PropertyDefinition(_) | ClassElement::AccessorProperty(_) + ) { + return Ok(()); + } + + if match f.options().semicolons { + Semicolons::Always => true, + Semicolons::AsNeeded => self.needs_semicolon(), + } { + write!(f, ";") + } else { + Ok(()) + } + } +} diff --git a/crates/oxc_formatter/src/write/export_declarations.rs b/crates/oxc_formatter/src/write/export_declarations.rs index 24c25811f7615..e044f75d8c112 100644 --- a/crates/oxc_formatter/src/write/export_declarations.rs +++ b/crates/oxc_formatter/src/write/export_declarations.rs @@ -221,3 +221,15 @@ impl<'a> FormatWrite<'a> for AstNode<'a, ExportSpecifier<'a>> { } } } + +impl<'a> FormatWrite<'a> for AstNode<'a, TSExportAssignment<'a>> { + fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { + write!(f, ["export = ", self.expression(), OptionalSemicolon]) + } +} + +impl<'a> FormatWrite<'a> for AstNode<'a, TSNamespaceExportDeclaration<'a>> { + fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { + write!(f, ["export as namespace ", self.id(), OptionalSemicolon]) + } +} diff --git a/crates/oxc_formatter/src/write/function.rs b/crates/oxc_formatter/src/write/function.rs index 2261c97e8da2c..7e40f827f5c9d 100644 --- a/crates/oxc_formatter/src/write/function.rs +++ b/crates/oxc_formatter/src/write/function.rs @@ -4,7 +4,7 @@ use oxc_ast::ast::*; use super::{ FormatWrite, - arrow_function_expression::{FunctionBodyCacheMode, GroupedCallArgumentLayout}, + arrow_function_expression::{FunctionCacheMode, GroupedCallArgumentLayout}, block_statement::is_empty_block, }; use crate::{ @@ -26,7 +26,7 @@ use crate::{ pub struct FormatFunctionOptions { pub call_argument_layout: Option, // Determine whether the signature and body should be cached. - pub cache_mode: FunctionBodyCacheMode, + pub cache_mode: FunctionCacheMode, } pub struct FormatFunction<'a, 'b> { @@ -44,47 +44,35 @@ impl<'a> Deref for FormatFunction<'a, '_> { impl<'a> FormatWrite<'a> for FormatFunction<'a, '_> { fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { - if self.declare() { - write!(f, ["declare", space()])?; - } - - if self.r#async() { - write!(f, ["async", space()])?; - } - - write!( - f, - [ - "function", - self.generator().then_some("*"), - space(), - self.id(), - group(&self.type_parameters()), - ] - ); - - // The [`call_arguments`] will format the argument that can be grouped in different ways until - // find the best layout. So we have to cache the parameters because it never be broken. - let cached_signature = format_once(|f| { - if matches!(self.options.cache_mode, FunctionBodyCacheMode::NoCache) { - self.params().fmt(f) - } else if let Some(grouped) = f.context().get_cached_element(&self.params.span) { - f.write_element(grouped) - } else { - if let Ok(Some(grouped)) = f.intern(&self.params()) { - f.context_mut().cache_element(&self.params.span, grouped.clone()); - f.write_element(grouped.clone()); - } - Ok(()) - } + let head = format_once(|f| { + write!( + f, + [ + self.declare.then_some("declare "), + self.r#async.then_some("async "), + "function", + self.generator().then_some("*"), + space(), + self.id(), + group(&self.type_parameters()), + ] + ) }); + FormatContentWithCacheMode::new(self.span, head, self.options.cache_mode).fmt(f)?; - let format_parameters = format_with(|f: &mut Formatter<'_, 'a>| { + let format_parameters = FormatContentWithCacheMode::new( + self.params.span, + self.params(), + self.options.cache_mode, + ) + .memoized(); + + let mut format_parameters = format_once(|f: &mut Formatter<'_, 'a>| { if self.options.call_argument_layout.is_some() { let mut buffer = RemoveSoftLinesBuffer::new(f); let mut recording = buffer.start_recording(); - write!(recording, cached_signature)?; + write!(recording, format_parameters)?; let recorded = recording.stop(); if recorded.will_break() { @@ -93,20 +81,38 @@ impl<'a> FormatWrite<'a> for FormatFunction<'a, '_> { Ok(()) } else { - cached_signature.fmt(f) + format_parameters.fmt(f) } - }); + }) + .memoized(); + + let mut format_return_type = self + .return_type() + .map(|return_type| { + let content = format_once(move |f| { + let needs_space = + f.context().comments().has_comment_before(return_type.span.start); + write!(f, [maybe_space(needs_space), return_type]) + }); + FormatContentWithCacheMode::new(return_type.span, content, self.options.cache_mode) + }) + .memoized(); write!( f, - [group(&format_with(|f| { + [group(&format_once(|f| { let params = &self.params; - let mut format_return_type_annotation = self.return_type().memoized(); + // Inspect early, in case the `return_type` is formatted before `parameters` + // in `should_group_function_parameters`. + format_parameters.inspect(f)?; + let group_parameters = should_group_function_parameters( self.type_parameters.as_deref(), - params.items.len() + usize::from(params.rest.is_some()), + params.items.len() + + usize::from(params.rest.is_some()) + + usize::from(self.this_param.is_some()), self.return_type.as_deref(), - &mut format_return_type_annotation, + &mut format_return_type, f, )?; @@ -116,7 +122,7 @@ impl<'a> FormatWrite<'a> for FormatFunction<'a, '_> { write!(f, [format_parameters])?; } - write!(f, [format_return_type_annotation]) + write!(f, [format_return_type]) }))] )?; @@ -180,16 +186,11 @@ pub fn should_group_function_parameters<'a>( formatted_return_type: &mut Memoized<'a, impl Format<'a>>, f: &mut Formatter<'_, 'a>, ) -> FormatResult { - let return_type = match return_type { - Some(return_type) => &return_type.type_annotation, - None => return Ok(false), - }; - if let Some(type_parameters) = type_parameters { - let mut params = type_parameters.params.iter(); - match params.next() { - None => {} // fall through - Some(first) if params.count() == 0 => { + match type_parameters.params.len() { + 0 => {} // fall through + 1 => { + let first = type_parameters.params.iter().next().unwrap(); if first.constraint.is_some() || first.default.is_some() { return Ok(false); } @@ -198,7 +199,52 @@ pub fn should_group_function_parameters<'a>( } } - Ok(parameter_count != 1 - && (matches!(return_type, TSType::TSLiteralType(_) | TSType::TSMappedType(_)) + let return_type = match return_type { + Some(return_type) => &return_type.type_annotation, + None => return Ok(false), + }; + + Ok(parameter_count == 1 + && (matches!(return_type, TSType::TSTypeLiteral(_) | TSType::TSMappedType(_)) || formatted_return_type.inspect(f)?.will_break())) } + +/// A wrapper that formats content and caches the result based on the given cache mode. +/// +/// It is useful in cases like in [`super::call_arguments`] because it allows printing a node +/// a few times to find a proper layout. +/// However, the current architecture of the formatter isn't able to do things like this, +/// because it will cause the comments printed after the first printing to be lost in the +/// subsequent printing, because comments only can be printed once. +/// This wrapper solves this problem by caching the result of the first printing +/// and reusing it in the subsequent printing. +pub struct FormatContentWithCacheMode { + key: Span, + content: T, + cache_mode: FunctionCacheMode, +} + +impl FormatContentWithCacheMode { + pub fn new(key: Span, content: T, cache_mode: FunctionCacheMode) -> Self { + Self { key, content, cache_mode } + } +} + +impl<'a, T> Format<'a> for FormatContentWithCacheMode +where + T: Format<'a>, +{ + fn fmt(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { + if matches!(self.cache_mode, FunctionCacheMode::NoCache) { + self.content.fmt(f) + } else if let Some(grouped) = f.context().get_cached_element(&self.key) { + f.write_element(grouped) + } else { + if let Ok(Some(grouped)) = f.intern(&self.content) { + f.context_mut().cache_element(&self.key, grouped.clone()); + f.write_element(grouped.clone()); + } + Ok(()) + } + } +} diff --git a/crates/oxc_formatter/src/write/import_declaration.rs b/crates/oxc_formatter/src/write/import_declaration.rs index 9fefd81f669c9..637380c54f149 100644 --- a/crates/oxc_formatter/src/write/import_declaration.rs +++ b/crates/oxc_formatter/src/write/import_declaration.rs @@ -227,3 +227,28 @@ impl<'a> FormatWrite<'a> for AstNode<'a, ImportAttribute<'a>> { write!(f, [":", space(), self.value()]) } } + +impl<'a> FormatWrite<'a> for AstNode<'a, TSImportEqualsDeclaration<'a>> { + fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { + write!( + f, + [ + "import", + space(), + self.import_kind(), + self.id(), + space(), + "=", + space(), + self.module_reference(), + OptionalSemicolon + ] + ) + } +} + +impl<'a> FormatWrite<'a> for AstNode<'a, TSExternalModuleReference<'a>> { + fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { + write!(f, ["require(", self.expression(), ")"]) + } +} diff --git a/crates/oxc_formatter/src/write/intersection_type.rs b/crates/oxc_formatter/src/write/intersection_type.rs index 547d59ee4fb37..5de022386b6f7 100644 --- a/crates/oxc_formatter/src/write/intersection_type.rs +++ b/crates/oxc_formatter/src/write/intersection_type.rs @@ -7,60 +7,18 @@ use crate::{ formatter::{FormatResult, Formatter, prelude::*}, generated::ast_nodes::{AstNode, AstNodes}, parentheses::NeedsParentheses, + utils::typescript::is_object_like_type, write, write::FormatWrite, }; impl<'a> FormatWrite<'a> for AstNode<'a, TSIntersectionType<'a>> { fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { - let types = self.types(); - - if types.len() == 1 { - return write!(f, self.types().first()); - } - - let content = format_with(|f| { - if self.needs_parentheses(f) { - return write!( - f, - [ - indent(&format_once(|f| format_intersection_types(types, f))), - soft_line_break() - ] - ); - } - - let is_inside_complex_tuple_type = match self.parent { - AstNodes::TSTupleType(tuple) => tuple.element_types().len() > 1, - _ => false, - }; - - if is_inside_complex_tuple_type { - write!( - f, - [ - indent(&format_args!( - if_group_breaks(&format_args!(text("("), soft_line_break())), - format_once(|f| format_intersection_types(types, f)) - )), - soft_line_break(), - if_group_breaks(&text(")")) - ] - ) - } else { - format_intersection_types(types, f) - } - }); - + let content = format_once(|f| format_intersection_types(self.types(), f)); write!(f, [group(&content)]) } } -/// Check if a TSType is object-like (object literal, mapped type, etc.) -fn is_object_like_type(ty: &TSType) -> bool { - matches!(ty, TSType::TSTypeLiteral(_) | TSType::TSMappedType(_)) -} - // [Prettier applies]: https://github.com/prettier/prettier/blob/cd3e530c2e51fb8296c0fb7738a9afdd3a3a4410/src/language-js/print/type-annotation.js#L93-L120 fn format_intersection_types<'a>( node: &AstNode<'a, Vec<'a, TSType<'a>>>, diff --git a/crates/oxc_formatter/src/write/jsx/mod.rs b/crates/oxc_formatter/src/write/jsx/mod.rs index dcfabbec69b88..eb80d9fe8136c 100644 --- a/crates/oxc_formatter/src/write/jsx/mod.rs +++ b/crates/oxc_formatter/src/write/jsx/mod.rs @@ -18,7 +18,6 @@ use crate::{ trivia::{DanglingIndentMode, FormatDanglingComments}, }, generated::ast_nodes::{AstNode, AstNodes}, - utils::expression::FormatExpressionWithoutTrailingComments, write, }; diff --git a/crates/oxc_formatter/src/write/mapped_type.rs b/crates/oxc_formatter/src/write/mapped_type.rs new file mode 100644 index 0000000000000..4afe685004649 --- /dev/null +++ b/crates/oxc_formatter/src/write/mapped_type.rs @@ -0,0 +1,99 @@ +use oxc_ast::ast::{TSMappedType, TSMappedTypeModifierOperator}; + +use crate::{ + FormatResult, + formatter::{Formatter, SourceText, prelude::*, trivia::FormatLeadingComments}, + generated::ast_nodes::AstNode, + write, + write::semicolon::OptionalSemicolon, +}; + +use super::FormatWrite; + +impl<'a> FormatWrite<'a> for AstNode<'a, TSMappedType<'a>> { + fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { + let type_parameter = self.type_parameter(); + let name_type = self.name_type(); + let should_expand = has_line_break_before_property_name(self, f.source_text()); + + let type_annotation_has_leading_comment = + f.comments().has_comment_before(type_parameter.span.start); + + let format_inner = format_with(|f| { + if should_expand { + let comments = + f.context().comments().comments_before_character(self.span.start, b'['); + write!(f, FormatLeadingComments::Comments(comments))?; + } + + if let Some(readonly) = self.readonly() { + let prefix = match readonly { + TSMappedTypeModifierOperator::True => "", + TSMappedTypeModifierOperator::Plus => "+", + TSMappedTypeModifierOperator::Minus => "-", + }; + write!(f, [prefix, "readonly", space()])?; + } + + let format_inner_inner = format_with(|f| { + write!(f, "[")?; + write!(f, type_parameter.name())?; + if let Some(constraint) = &type_parameter.constraint() { + write!(f, [space(), "in", space(), constraint])?; + } + if let Some(default) = &type_parameter.default() { + write!(f, [space(), "=", space(), default])?; + } + if let Some(name_type) = &name_type { + write!(f, [space(), "as", space(), name_type])?; + } + write!(f, "]")?; + if let Some(optional) = self.optional() { + write!( + f, + match optional { + TSMappedTypeModifierOperator::True => "?", + TSMappedTypeModifierOperator::Plus => "+?", + TSMappedTypeModifierOperator::Minus => "-?", + } + )?; + } + Ok(()) + }); + + write!(f, [space(), group(&format_inner_inner)])?; + if let Some(type_annotation) = &self.type_annotation() { + write!(f, [":", space(), type_annotation])?; + } + write!(f, if_group_breaks(&OptionalSemicolon)) + }); + + let should_insert_space_around_brackets = f.options().bracket_spacing.value(); + write!( + f, + [ + "{", + group(&soft_block_indent_with_maybe_space( + &format_inner, + should_insert_space_around_brackets + )) + .should_expand(should_expand), + "}", + ] + ) + } +} + +/// Check if the user introduced a new line inside the node, but only if +/// that new line occurs at or before the property name. For example, +/// this would break: +/// { [ +/// A in B]: T} +/// Because the line break occurs before `A`, the property name. But this +/// would _not_ break: +/// { [A +/// in B]: T} +/// Because the break is _after_ the `A`. +fn has_line_break_before_property_name(node: &TSMappedType, f: SourceText) -> bool { + f.contains_newline_between(node.span.start, node.type_parameter.span.start) +} diff --git a/crates/oxc_formatter/src/write/member_expression.rs b/crates/oxc_formatter/src/write/member_expression.rs index 69f226b0f281c..1bbbc45638b5d 100644 --- a/crates/oxc_formatter/src/write/member_expression.rs +++ b/crates/oxc_formatter/src/write/member_expression.rs @@ -72,7 +72,13 @@ fn layout<'a>( node: &AstNode<'a, StaticMemberExpression<'a>>, is_member_chain: bool, ) -> StaticMemberLayout { - let parent = node.parent; + // `a.b.c!` and `a.b?.c` + // `TSNonNullExpression` is a wrapper node for `!`, and `ChainExpression` is a wrapper node for `?.`, + // so we need to skip them to find the real parent node. + let mut parent = node.parent; + while matches!(parent, AstNodes::TSNonNullExpression(_) | AstNodes::ChainExpression(_)) { + parent = parent.parent(); + } let object = &node.object; let is_nested = match parent { diff --git a/crates/oxc_formatter/src/write/mod.rs b/crates/oxc_formatter/src/write/mod.rs index be07199b6632d..407f3f9ed5ae7 100644 --- a/crates/oxc_formatter/src/write/mod.rs +++ b/crates/oxc_formatter/src/write/mod.rs @@ -15,10 +15,11 @@ mod import_declaration; mod import_expression; mod intersection_type; mod jsx; +mod mapped_type; mod member_expression; mod object_like; mod object_pattern_like; -mod parameter_list; +mod parameters; mod program; mod return_or_throw_statement; mod semicolon; @@ -26,6 +27,7 @@ mod sequence_expression; mod switch_statement; mod template; mod try_statement; +mod tuple_type; mod type_parameters; mod union_type; mod utils; @@ -45,7 +47,7 @@ use oxc_ast::{AstKind, ast::*}; use oxc_span::GetSpan; use crate::{ - best_fitting, format_args, + Expand, best_fitting, format_args, formatter::{ Buffer, Format, FormatResult, Formatter, prelude::*, @@ -63,21 +65,22 @@ use crate::{ assignment_like::AssignmentLike, call_expression::{contains_a_test_pattern, is_test_call_expression, is_test_each_pattern}, conditional::ConditionalLike, + format_node_without_trailing_comments::FormatNodeWithoutTrailingComments, member_chain::MemberChain, object::format_property_key, string_utils::{FormatLiteralStringToken, StringLiteralParentKind}, suppressed::FormatSuppressedNode, }, write, - write::parameter_list::{can_avoid_parentheses, should_hug_function_parameters}, + write::parameters::{can_avoid_parentheses, should_hug_function_parameters}, }; use self::{ array_expression::FormatArrayExpression, object_like::ObjectLike, object_pattern_like::ObjectPatternLike, - parameter_list::{ParameterLayout, ParameterList}, - semicolon::{ClassPropertySemicolon, OptionalSemicolon}, + parameters::{ParameterLayout, ParameterList}, + semicolon::OptionalSemicolon, type_parameters::{FormatTSTypeParameters, FormatTSTypeParametersOptions}, utils::{ array::{TrailingSeparatorMode, write_array_node}, @@ -526,12 +529,17 @@ fn expression_statement_needs_semicolon<'a>( assignment.as_ref(), AssignmentTarget::ArrayAssignmentTarget(_) | AssignmentTarget::TSTypeAssertion(_) + | AssignmentTarget::TSAsExpression(_) + | AssignmentTarget::TSSatisfiesExpression(_) + | AssignmentTarget::TSNonNullExpression(_) ) } ExpressionLeftSide::SimpleAssignmentTarget(assignment) => { matches!( assignment.as_ref(), - | SimpleAssignmentTarget::TSTypeAssertion(_) + SimpleAssignmentTarget::TSTypeAssertion(_) + | SimpleAssignmentTarget::TSAsExpression(_) + | SimpleAssignmentTarget::TSNonNullExpression(_) ) } _ => false, @@ -834,6 +842,8 @@ impl<'a> FormatWrite<'a> for AstNode<'a, BindingPattern<'a>> { write!(f, self.kind())?; if self.optional() { write!(f, "?")?; + } else if let AstNodes::VariableDeclarator(declarator) = self.parent { + write!(f, declarator.definite.then_some("!"))?; } if let Some(type_annotation) = &self.type_annotation() { write!(f, type_annotation)?; @@ -931,102 +941,6 @@ impl<'a> FormatWrite<'a> for AstNode<'a, BindingRestElement<'a>> { } } -impl<'a> FormatWrite<'a> for AstNode<'a, FormalParameters<'a>> { - fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { - let comments = f.context().comments().comments_before(self.span.start); - if !comments.is_empty() { - write!(f, [space(), FormatTrailingComments::Comments(comments)])?; - } - - let parentheses_not_needed = if let AstNodes::ArrowFunctionExpression(arrow) = self.parent { - can_avoid_parentheses(arrow, f) - } else { - false - }; - - let has_any_decorated_parameter = - self.items.iter().any(|param| !param.decorators.is_empty()); - - let can_hug = should_hug_function_parameters(self, parentheses_not_needed, f) - && !has_any_decorated_parameter; - - let layout = if !self.has_parameter() { - ParameterLayout::NoParameters - } else if can_hug || { - // `self.parent`: Function - // `self.parent.parent()`: Argument - // `self.parent.parent().parent()` CallExpression - if let AstNodes::CallExpression(call) = self.parent.parent().parent() { - is_test_call_expression(call) - } else { - false - } - } { - ParameterLayout::Hug - } else { - ParameterLayout::Default - }; - - if !parentheses_not_needed { - write!(f, "(")?; - } - - match layout { - ParameterLayout::NoParameters => { - write!(f, format_dangling_comments(self.span()).with_soft_block_indent())?; - } - ParameterLayout::Hug => { - write!(f, ParameterList::with_layout(self, layout))?; - } - ParameterLayout::Default => { - write!(f, soft_block_indent(&ParameterList::with_layout(self, layout)))?; - } - } - - if !parentheses_not_needed { - write!(f, ")")?; - } - - Ok(()) - } -} - -impl<'a> FormatWrite<'a> for AstNode<'a, FormalParameter<'a>> { - fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { - let content = format_with(|f| { - if let Some(accessibility) = self.accessibility() { - write!(f, [accessibility.as_str(), space()])?; - } - if self.r#override() { - write!(f, ["override", space()])?; - } - if self.readonly() { - write!(f, ["readonly", space()])?; - } - write!(f, self.pattern()) - }); - - // TODO - let is_hug_parameter = false; - // let is_hug_parameter = node - // .syntax() - // .grand_parent() - // .and_then(FormatAnyJsParameters::cast) - // .is_some_and(|parameters| { - // should_hug_function_parameters(¶meters, f.comments(), false).unwrap_or(false) - // }); - - let decorators = self.decorators(); - if is_hug_parameter && decorators.is_empty() { - write!(f, [decorators, content]) - } else if decorators.is_empty() { - write!(f, [decorators, group(&content)]) - } else { - write!(f, [group(&decorators), group(&content)]) - } - } -} - impl<'a> FormatWrite<'a, FormatJsArrowFunctionExpressionOptions> for AstNode<'a, ArrowFunctionExpression<'a>> { @@ -1123,16 +1037,6 @@ impl<'a> FormatWrite<'a> for AstNode<'a, RegExpLiteral<'a>> { } } -impl<'a> FormatWrite<'a> for AstNode<'a, TSThisParameter<'a>> { - fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { - write!(f, "this")?; - if let Some(type_annotation) = self.type_annotation() { - type_annotation.fmt(f); - } - Ok(()) - } -} - impl<'a> FormatWrite<'a> for AstNode<'a, TSEnumDeclaration<'a>> { fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { if self.declare() { @@ -1236,7 +1140,16 @@ impl<'a> FormatWrite<'a> for AstNode<'a, TSTypeOperator<'a>> { impl<'a> FormatWrite<'a> for AstNode<'a, TSArrayType<'a>> { fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { - write!(f, [self.element_type(), "[]"]) + if let AstNodes::TSUnionType(union) = self.element_type().as_ast_nodes() { + // `TSUnionType` has special logic for comments, so we need to delegate to it. + union.fmt(f)?; + } else { + FormatNodeWithoutTrailingComments(self.element_type()).fmt(f)?; + } + let comments = + f.context().comments().comments_before_character(self.element_type.span().end, b'['); + FormatTrailingComments::Comments(comments).fmt(f)?; + write!(f, ["[]"]) } } @@ -1246,19 +1159,6 @@ impl<'a> FormatWrite<'a> for AstNode<'a, TSIndexedAccessType<'a>> { } } -impl<'a> FormatWrite<'a> for AstNode<'a, TSTupleType<'a>> { - fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { - write!(f, "[")?; - for (i, ty) in self.element_types().iter().enumerate() { - if i != 0 { - write!(f, [",", space()])?; - } - write!(f, ty)?; - } - write!(f, "]") - } -} - impl<'a> FormatWrite<'a> for AstNode<'a, TSNamedTupleMember<'a>> { fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { write!(f, self.label())?; @@ -1385,22 +1285,7 @@ impl<'a> FormatWrite<'a> for AstNode<'a, TSTypeParameterDeclaration<'a>> { impl<'a> FormatWrite<'a> for AstNode<'a, TSTypeAliasDeclaration<'a>> { fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { - let assignment_like = format_with(|f| { - write!( - f, - [self.id(), self.type_parameters(), space(), "=", space(), self.type_annotation()] - ) - }); - write!( - f, - [ - self.declare().then_some("declare "), - "type", - space(), - group(&assignment_like), - OptionalSemicolon - ] - ) + write!(f, [AssignmentLike::TSTypeAliasDeclaration(self), OptionalSemicolon]) } } @@ -1411,10 +1296,14 @@ impl<'a> FormatWrite<'a> for AstNode<'a, TSInterfaceDeclaration<'a>> { let extends = self.extends(); let body = self.body(); - let should_indent_extends_only = type_parameters.as_ref().is_some_and(|params| - // TODO: - // !f.comments().has_trailing_line_comment(params.span().end) - true); + let should_indent_extends_only = type_parameters.as_ref().is_some_and(|params| { + !extends.as_ref().first().is_some_and(|first| { + f.comments() + .comments_in_range(params.span().end, first.span().start) + .iter() + .any(|c| c.is_line()) + }) + }); let type_parameter_group = if should_indent_extends_only && !extends.is_empty() { Some(f.group_id("type_parameters")) @@ -1423,7 +1312,11 @@ impl<'a> FormatWrite<'a> for AstNode<'a, TSInterfaceDeclaration<'a>> { }; let format_id = format_with(|f| { - write!(f, id)?; + if type_parameters.is_none() && extends.is_empty() { + FormatNodeWithoutTrailingComments(id).fmt(f)?; + } else { + write!(f, [id])?; + } if let Some(type_parameters) = type_parameters { write!( @@ -1442,6 +1335,21 @@ impl<'a> FormatWrite<'a> for AstNode<'a, TSInterfaceDeclaration<'a>> { }); let format_extends = format_with(|f| { + let Some(first_extend) = extends.as_ref().first() else { + return Ok(()); + }; + + let has_leading_own_line_comment = + f.context().comments().has_leading_own_line_comment(first_extend.span().start); + if has_leading_own_line_comment { + write!( + f, + FormatTrailingComments::Comments( + f.context().comments().comments_before(first_extend.span().start) + ) + )?; + } + if !extends.is_empty() { if should_indent_extends_only { write!( @@ -1455,12 +1363,22 @@ impl<'a> FormatWrite<'a> for AstNode<'a, TSInterfaceDeclaration<'a>> { } else { write!(f, soft_line_break_or_space())?; } - write!(f, ["extends", space()])?; + + write!(f, [line_suffix_boundary(), "extends", space()])?; + if extends.len() == 1 { write!(f, extends)?; } else { write!(f, indent(&extends))?; } + + let has_leading_own_line_comment = + f.context().comments().has_leading_own_line_comment(self.body.span().start); + + if !has_leading_own_line_comment { + write!(f, [space()])?; + body.format_leading_comments(f)?; + } } Ok(()) @@ -1473,17 +1391,12 @@ impl<'a> FormatWrite<'a> for AstNode<'a, TSInterfaceDeclaration<'a>> { write!(f, ["interface", space()])?; - // TODO: - // let id_has_trailing_comments = f.comments().has_trailing_comments(id.span().end); - let id_has_trailing_comments = false; - if id_has_trailing_comments || !extends.is_empty() { - if should_indent_extends_only { - write!(f, [group(&format_args!(format_id, indent(&format_extends)))])?; - } else { - write!(f, [group(&indent(&format_args!(format_id, format_extends)))])?; - } - } else { + if extends.is_empty() { write!(f, [format_id, format_extends])?; + } else if should_indent_extends_only { + write!(f, [group(&format_args!(format_id, indent(&format_extends)))])?; + } else { + write!(f, [group(&indent(&format_args!(format_id, format_extends)))])?; } write!(f, [space(), "{"])?; @@ -1540,7 +1453,7 @@ impl GetSpan for FormatTSSignature<'_, '_> { impl<'a> Format<'a> for FormatTSSignature<'a, '_> { fn fmt(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { - self.signature.fmt(f)?; + write!(f, [group(&self.signature)])?; match f.options().semicolons { Semicolons::Always => { @@ -1574,16 +1487,6 @@ impl<'a> Format<'a> for AstNode<'a, Vec<'a, TSSignature<'a>>> { } } -impl<'a> FormatWrite<'a> for AstNode<'a, TSIndexSignature<'a>> { - fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { - if self.readonly() { - write!(f, ["readonly", space()])?; - } - // TODO: parameters only have one element for now. - write!(f, ["[", self.parameters().first().unwrap(), "]", self.type_annotation(),]) - } -} - impl<'a> FormatWrite<'a> for AstNode<'a, TSCallSignatureDeclaration<'a>> { fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { if let Some(type_parameters) = &self.type_parameters() { @@ -1661,17 +1564,23 @@ impl<'a> FormatWrite<'a> for AstNode<'a, TSConstructSignatureDeclaration<'a>> { } } -impl<'a> FormatWrite<'a> for AstNode<'a, TSIndexSignatureName<'a>> { - fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { - write!(f, [dynamic_text(self.name().as_str()), self.type_annotation()]) - } -} - impl<'a> Format<'a> for AstNode<'a, Vec<'a, TSInterfaceHeritage<'a>>> { fn fmt(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { - f.join_with(soft_line_break_or_space()) - .entries_with_trailing_separator(self.iter(), ",", TrailingSeparator::Disallowed) - .finish() + let last_index = self.len().saturating_sub(1); + let mut joiner = f.join_with(soft_line_break_or_space()); + + for (i, heritage) in FormatSeparatedIter::new(self.into_iter(), ",") + .with_trailing_separator(TrailingSeparator::Disallowed) + .enumerate() + { + if i == last_index { + joiner.entry(&FormatNodeWithoutTrailingComments(&heritage)); + } else { + joiner.entry(&heritage); + } + } + + joiner.finish() } } @@ -1699,12 +1608,13 @@ impl<'a> FormatWrite<'a> for AstNode<'a, TSModuleDeclaration<'a>> { if self.declare() { write!(f, ["declare", space()])?; } - write!(f, self.kind().as_str())?; - if !self.kind().is_global() { - write!(f, [space(), self.id()])?; + if !self.kind.is_global() { + write!(f, self.kind().as_str())?; } + write!(f, [space(), self.id()])?; + if let Some(body) = self.body() { let mut body = body; loop { @@ -1836,15 +1746,12 @@ impl<'a> FormatWrite<'a> for AstNode<'a, TSConstructorType<'a>> { let params = self.params(); let return_type = self.return_type(); - if self.needs_parentheses(f) { - write!(f, "(")?; - } if r#abstract { write!(f, ["abstract", space()])?; } write!( f, - [ + [group(&format_args!( "new", space(), type_parameters, @@ -1853,84 +1760,12 @@ impl<'a> FormatWrite<'a> for AstNode<'a, TSConstructorType<'a>> { "=>", space(), return_type.type_annotation() - ] + ))] ); - if self.needs_parentheses(f) { - write!(f, ")")?; - } Ok(()) } } -impl<'a> FormatWrite<'a> for AstNode<'a, TSMappedType<'a>> { - fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { - let type_parameter = self.type_parameter(); - let name_type = self.name_type(); - - let should_expand = false; // TODO has_line_break_before_property_name(node)?; - - let type_annotation_has_leading_comment = false; - //TODO - // - // mapped_type - // .as_ref() - // .is_some_and(|annotation| comments.has_leading_comments(annotation.syntax())); - - let format_inner = format_with(|f| { - // TODO: - // write!(f, FormatLeadingComments::Comments(comments.dangling_comments(self.span())))?; - - match self.readonly() { - Some(TSMappedTypeModifierOperator::True) => write!(f, ["readonly", space()])?, - Some(TSMappedTypeModifierOperator::Plus) => write!(f, ["+readonly", space()])?, - Some(TSMappedTypeModifierOperator::Minus) => write!(f, ["-readonly", space()])?, - None => {} - } - - let format_inner_inner = format_with(|f| { - write!(f, "[")?; - write!(f, type_parameter.name())?; - if let Some(constraint) = &type_parameter.constraint() { - write!(f, [space(), "in", space(), constraint])?; - } - if let Some(default) = &type_parameter.default() { - write!(f, [space(), "=", space(), default])?; - } - if let Some(name_type) = &name_type { - write!(f, [space(), "as", space(), name_type])?; - } - write!(f, "]")?; - match self.optional() { - Some(TSMappedTypeModifierOperator::True) => write!(f, "?"), - Some(TSMappedTypeModifierOperator::Plus) => write!(f, "+?"), - Some(TSMappedTypeModifierOperator::Minus) => write!(f, "-?"), - None => Ok(()), - } - }); - - write!(f, [space(), group(&format_inner_inner)])?; - if let Some(type_annotation) = &self.type_annotation() { - write!(f, [":", space(), type_annotation])?; - } - write!(f, if_group_breaks(&OptionalSemicolon)) - }); - - let should_insert_space_around_brackets = f.options().bracket_spacing.value(); - write!( - f, - [ - "{", - group(&soft_block_indent_with_maybe_space( - &format_inner, - should_insert_space_around_brackets - )) - .should_expand(should_expand), - "}", - ] - ) - } -} - impl<'a> FormatWrite<'a> for AstNode<'a, TSTypeAssertion<'a>> { fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { let break_after_cast = !matches!( @@ -1967,49 +1802,12 @@ impl<'a> FormatWrite<'a> for AstNode<'a, TSTypeAssertion<'a>> { } } -impl<'a> FormatWrite<'a> for AstNode<'a, TSImportEqualsDeclaration<'a>> { - fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { - write!( - f, - [ - "import", - space(), - self.import_kind(), - self.id(), - space(), - "=", - space(), - self.module_reference(), - OptionalSemicolon - ] - ) - } -} - -impl<'a> FormatWrite<'a> for AstNode<'a, TSExternalModuleReference<'a>> { - fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { - write!(f, ["require(", self.expression(), ")"]) - } -} - impl<'a> FormatWrite<'a> for AstNode<'a, TSNonNullExpression<'a>> { fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { write!(f, [self.expression(), "!"]) } } -impl<'a> FormatWrite<'a> for AstNode<'a, TSExportAssignment<'a>> { - fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { - write!(f, ["export = ", self.expression()]) - } -} - -impl<'a> FormatWrite<'a> for AstNode<'a, TSNamespaceExportDeclaration<'a>> { - fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { - write!(f, ["export as namespace ", self.id()]) - } -} - impl<'a> FormatWrite<'a> for AstNode<'a, TSInstantiationExpression<'a>> { fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { write!(f, [self.expression(), self.type_arguments()]) diff --git a/crates/oxc_formatter/src/write/object_like.rs b/crates/oxc_formatter/src/write/object_like.rs index 3c306a6be92b6..4efdee383cdc8 100644 --- a/crates/oxc_formatter/src/write/object_like.rs +++ b/crates/oxc_formatter/src/write/object_like.rs @@ -10,6 +10,7 @@ use crate::{ generated::ast_nodes::{AstNode, AstNodes}, options::Expand, write, + write::parameters::should_hug_function_parameters, }; #[derive(Clone, Copy)] @@ -26,25 +27,35 @@ impl<'a> ObjectLike<'a, '_> { } } - fn should_hug(&self) -> bool { + fn should_hug(&self, f: &Formatter<'_, 'a>) -> bool { // Check if the object type is the type annotation of the only parameter in a function. // This prevents breaking object properties in cases like: // const fn = ({ foo }: { foo: string }) => { ... }; - match self { - Self::TSTypeLiteral(node) => { - // Check if parent is TSTypeAnnotation - matches!(node.parent, AstNodes::TSTypeAnnotation(type_ann) if { - // Check if that parent is FormalParameter - matches!(type_ann.parent, AstNodes::FormalParameter(param) if { - // Check if that parent is FormalParameters with only one item - matches!(param.parent, AstNodes::FormalParameters(params) if { - params.items.len() == 1 + matches!(self, Self::TSTypeLiteral(node) if { + // Check if parent is TSTypeAnnotation + matches!(node.parent, AstNodes::TSTypeAnnotation(type_ann) if { + match &type_ann.parent { + AstNodes::FormalParameter(param) => { + let AstNodes::FormalParameters(parameters) = ¶m.parent else { + unreachable!() + }; + let this_param = if let AstNodes::Function(function) = parameters.parent { + function.this_param() + } else { + None + }; + should_hug_function_parameters(parameters, this_param, false, f) + + } + AstNodes::TSThisParameter(param) => { + matches!(param.parent, AstNodes::Function(func) if { + should_hug_function_parameters(func.params(), Some(param), false, f) }) - }) - }) - } - Self::ObjectExpression(node) => false, - } + }, + _ => false, + } + }) + }) } fn members_have_leading_newline(&self, f: &Formatter<'_, 'a>) -> bool { @@ -95,7 +106,7 @@ impl<'a> Format<'a> for ObjectLike<'a, '_> { // const fn = ({ foo }: { foo: string }) => { ... }; // ^ do not break properties here // ``` - let should_hug = self.should_hug(); + let should_hug = self.should_hug(f); let inner = soft_block_indent_with_maybe_space(&members, should_insert_space_around_brackets); diff --git a/crates/oxc_formatter/src/write/object_pattern_like.rs b/crates/oxc_formatter/src/write/object_pattern_like.rs index d2de82c85bb64..58590344f535b 100644 --- a/crates/oxc_formatter/src/write/object_pattern_like.rs +++ b/crates/oxc_formatter/src/write/object_pattern_like.rs @@ -8,7 +8,7 @@ use crate::{ }, generated::ast_nodes::{AstNode, AstNodes}, write, - write::parameter_list::should_hug_function_parameters, + write::parameters::should_hug_function_parameters, }; use super::{ @@ -38,12 +38,8 @@ impl<'a> ObjectPatternLike<'a, '_> { fn is_inline(&self, f: &Formatter<'_, 'a>) -> bool { match self { - Self::ObjectPattern(node) => { - matches!(node.parent, AstNodes::FormalParameter(_)) || self.is_hug_parameter(f) - } - Self::ObjectAssignmentTarget(node) => { - matches!(node.parent, AstNodes::FormalParameter(_)) - } + Self::ObjectPattern(node) => self.is_hug_parameter(f), + Self::ObjectAssignmentTarget(node) => false, } } @@ -53,9 +49,7 @@ impl<'a> ObjectPatternLike<'a, '_> { Self::ObjectPattern(node) => { matches!(node.parent, AstNodes::CatchParameter(_) | AstNodes::FormalParameter(_)) } - Self::ObjectAssignmentTarget(node) => { - matches!(node.parent, AstNodes::CatchParameter(_) | AstNodes::FormalParameter(_)) - } + Self::ObjectAssignmentTarget(node) => false, }; if parent_is_catch_or_parameter { @@ -104,16 +98,18 @@ impl<'a> ObjectPatternLike<'a, '_> { } fn is_hug_parameter(&self, f: &Formatter<'_, 'a>) -> bool { - match self { - Self::ObjectAssignmentTarget(_) => false, - Self::ObjectPattern(node) => { - matches!(node.parent, AstNodes::FormalParameter(param) if { - matches!(param.parent, AstNodes::FormalParameters(params) if { - should_hug_function_parameters(params, false, f) - }) + matches!(self, Self::ObjectPattern(node) if { + matches!(node.parent, AstNodes::FormalParameter(param) if { + matches!(param.parent, AstNodes::FormalParameters(params) if { + let this_param = if let AstNodes::Function(function) = params.parent { + function.this_param() + } else { + None + }; + should_hug_function_parameters(params, this_param, false, f) }) - } - } + }) + }) } fn layout(&self, f: &Formatter<'_, 'a>) -> ObjectPatternLayout { diff --git a/crates/oxc_formatter/src/write/parameter_list.rs b/crates/oxc_formatter/src/write/parameters.rs similarity index 55% rename from crates/oxc_formatter/src/write/parameter_list.rs rename to crates/oxc_formatter/src/write/parameters.rs index a6e02e73b1bf3..60680560719fd 100644 --- a/crates/oxc_formatter/src/write/parameter_list.rs +++ b/crates/oxc_formatter/src/write/parameters.rs @@ -2,12 +2,126 @@ use oxc_ast::ast::*; use oxc_span::GetSpan; use crate::{ - formatter::{Format, FormatResult, Formatter, prelude::*, separated::FormatSeparatedIter}, - generated::ast_nodes::{AstNode, AstNodeIterator}, + format_args, + formatter::{ + Format, FormatResult, Formatter, prelude::*, separated::FormatSeparatedIter, + trivia::FormatTrailingComments, + }, + generated::ast_nodes::{AstNode, AstNodeIterator, AstNodes}, options::{FormatTrailingCommas, TrailingSeparator}, + utils::call_expression::is_test_call_expression, + write, }; +use super::FormatWrite; + +impl<'a> FormatWrite<'a> for AstNode<'a, FormalParameters<'a>> { + fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { + let comments = f.context().comments().comments_before(self.span.start); + if !comments.is_empty() { + write!(f, [space(), FormatTrailingComments::Comments(comments)])?; + } + + let parentheses_not_needed = if let AstNodes::ArrowFunctionExpression(arrow) = self.parent { + can_avoid_parentheses(arrow, f) + } else { + false + }; + + let this_param = if let AstNodes::Function(function) = self.parent { + function.this_param() + } else { + None + }; + + let has_any_decorated_parameter = + self.items.iter().any(|param| !param.decorators.is_empty()); + + let can_hug = should_hug_function_parameters(self, this_param, parentheses_not_needed, f) + && !has_any_decorated_parameter; + + let layout = if !self.has_parameter() && this_param.is_none() { + ParameterLayout::NoParameters + } else if can_hug || { + // `self.parent`: Function + // `self.parent.parent()`: Argument + // `self.parent.parent().parent()` CallExpression + if let AstNodes::CallExpression(call) = self.parent.parent().parent() { + is_test_call_expression(call) + } else { + false + } + } { + ParameterLayout::Hug + } else { + ParameterLayout::Default + }; + + if !parentheses_not_needed { + write!(f, "(")?; + } + + match layout { + ParameterLayout::NoParameters => { + write!(f, format_dangling_comments(self.span()).with_soft_block_indent())?; + } + ParameterLayout::Hug => { + write!(f, ParameterList::with_layout(self, this_param, layout))?; + } + ParameterLayout::Default => { + write!( + f, + soft_block_indent(&format_args!( + &ParameterList::with_layout(self, this_param, layout), + format_once(|f| { + let comments = f.context().comments().comments_before(self.span.end); + write!(f, [FormatTrailingComments::Comments(comments)]) + }) + )) + ); + } + } + + if !parentheses_not_needed { + write!(f, [")"])?; + } + + Ok(()) + } +} + +impl<'a> FormatWrite<'a> for AstNode<'a, FormalParameter<'a>> { + fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { + let content = format_with(|f| { + if let Some(accessibility) = self.accessibility() { + write!(f, [accessibility.as_str(), space()])?; + } + if self.r#override() { + write!(f, ["override", space()])?; + } + if self.readonly() { + write!(f, ["readonly", space()])?; + } + write!(f, self.pattern()) + }); + + let decorators = self.decorators(); + if decorators.is_empty() { + write!(f, [decorators, content]) + } else { + write!(f, [group(&decorators), group(&content)]) + } + } +} + +impl<'a> FormatWrite<'a> for AstNode<'a, TSThisParameter<'a>> { + fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { + write!(f, ["this", self.type_annotation()]) + } +} + enum Parameter<'a, 'b> { + This(&'b AstNode<'a, TSThisParameter<'a>>), FormalParameter(&'b AstNode<'a, FormalParameter<'a>>), Rest(&'b AstNode<'a, BindingRestElement<'a>>), } @@ -15,6 +129,7 @@ enum Parameter<'a, 'b> { impl GetSpan for Parameter<'_, '_> { fn span(&self) -> Span { match self { + Self::This(param) => param.span(), Self::FormalParameter(param) => param.span(), Self::Rest(e) => e.span(), } @@ -24,6 +139,7 @@ impl GetSpan for Parameter<'_, '_> { impl<'a> Format<'a> for Parameter<'a, '_> { fn fmt(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { match self { + Self::This(param) => param.fmt(f), Self::FormalParameter(param) => param.fmt(f), Self::Rest(e) => e.fmt(f), } @@ -31,13 +147,14 @@ impl<'a> Format<'a> for Parameter<'a, '_> { } struct FormalParametersIter<'a, 'b> { + this: Option<&'b AstNode<'a, TSThisParameter<'a>>>, params: AstNodeIterator<'a, FormalParameter<'a>>, rest: Option<&'b AstNode<'a, BindingRestElement<'a>>>, } -impl<'a, 'b> From<&'b AstNode<'a, FormalParameters<'a>>> for FormalParametersIter<'a, 'b> { - fn from(value: &'b AstNode<'a, FormalParameters<'a>>) -> Self { - Self { params: value.items().iter(), rest: value.rest() } +impl<'a, 'b> From<&'b ParameterList<'a, 'b>> for FormalParametersIter<'a, 'b> { + fn from(value: &'b ParameterList<'a, 'b>) -> Self { + Self { this: value.this, params: value.list.items().iter(), rest: value.list.rest() } } } @@ -45,15 +162,18 @@ impl<'a, 'b> Iterator for FormalParametersIter<'a, 'b> { type Item = Parameter<'a, 'b>; fn next(&mut self) -> Option { - self.params - .next() - .map(Parameter::FormalParameter) - .or_else(|| self.rest.take().map(Parameter::Rest)) + self.this.take().map(Parameter::This).or_else(|| { + self.params + .next() + .map(Parameter::FormalParameter) + .or_else(|| self.rest.take().map(Parameter::Rest)) + }) } } pub struct ParameterList<'a, 'b> { list: &'b AstNode<'a, FormalParameters<'a>>, + this: Option<&'b AstNode<'a, TSThisParameter<'a>>>, layout: Option, } @@ -93,9 +213,10 @@ pub enum ParameterLayout { impl<'a, 'b> ParameterList<'a, 'b> { pub fn with_layout( list: &'b AstNode<'a, FormalParameters<'a>>, + this: Option<&'b AstNode<'a, TSThisParameter<'a>>>, layout: ParameterLayout, ) -> Self { - Self { list, layout: Some(layout) } + Self { list, this, layout: Some(layout) } } } @@ -123,7 +244,7 @@ impl<'a> Format<'a> for ParameterList<'a, '_> { }; joiner .entries_with_trailing_separator( - FormalParametersIter::from(self.list), + FormalParametersIter::from(self), ",", trailing_separator, ) @@ -132,7 +253,7 @@ impl<'a> Format<'a> for ParameterList<'a, '_> { Some(ParameterLayout::Hug) => { let mut join = f.join_with(space()); join.entries_with_trailing_separator( - self.list.items().iter(), + FormalParametersIter::from(self), ",", TrailingSeparator::Omit, ); @@ -163,16 +284,35 @@ pub fn can_avoid_parentheses( pub fn should_hug_function_parameters<'a>( parameters: &AstNode<'a, FormalParameters<'a>>, + this_param: Option<&AstNode<'a, TSThisParameter<'a>>>, parentheses_not_needed: bool, f: &Formatter<'_, 'a>, ) -> bool { let list = ¶meters.items(); - if list.len() != 1 || parameters.rest.is_some() { + + if list.len() > 1 || parameters.rest.is_some() { return false; } + if let Some(this_param) = this_param { + // `(/* comment before */ this /* comment after */)` + // Checker whether there are comments around the only parameter. + + if f.comments().has_comment_in_range(parameters.span.start, this_param.span.start) + || f.comments().has_comment_in_range(this_param.span.end, parameters.span.end) + { + return false; + } + + return list.is_empty() + && this_param + .type_annotation + .as_ref() + .is_none_or(|ty| matches!(ty.type_annotation, TSType::TSTypeLiteral(_))); + } + // Safe because of the length check above - let only_parameter = list.first().unwrap(); + let Some(only_parameter) = list.first() else { return false }; if only_parameter.has_modifier() { return false; diff --git a/crates/oxc_formatter/src/write/semicolon.rs b/crates/oxc_formatter/src/write/semicolon.rs index c095b25731c66..3592b94260acc 100644 --- a/crates/oxc_formatter/src/write/semicolon.rs +++ b/crates/oxc_formatter/src/write/semicolon.rs @@ -25,76 +25,3 @@ impl<'a> Format<'a> for MaybeOptionalSemicolon { if self.0 { OptionalSemicolon.fmt(f) } else { Ok(()) } } } - -pub struct ClassPropertySemicolon<'a, 'b> { - element: &'b AstNode<'a, ClassElement<'a>>, - next_element: Option<&'b AstNode<'a, ClassElement<'a>>>, -} - -impl<'a, 'b> ClassPropertySemicolon<'a, 'b> { - pub fn new( - element: &'b AstNode<'a, ClassElement<'a>>, - next_element: Option<&'b AstNode<'a, ClassElement<'a>>>, - ) -> Self { - Self { element, next_element } - } - - fn needs_semicolon(&self) -> bool { - let Self { element, next_element, .. } = self; - - if let ClassElement::PropertyDefinition(def) = element.as_ref() - && def.value.is_none() - && def.type_annotation.is_none() - && matches!(&def.key, PropertyKey::StaticIdentifier(ident) if matches!(ident.name.as_str(), "static" | "get" | "set") ) - { - return true; - } - - let Some(next_element) = next_element else { return false }; - - match next_element.as_ref() { - // When the name starts with the generator token or `[` - ClassElement::MethodDefinition(def) if !def.value.r#async => { - (def.computed - && !(def.kind.is_accessor() - || def.r#static - || def.accessibility.is_some() - || def.r#override)) - || def.value.generator - } - ClassElement::PropertyDefinition(def) => { - def.computed - && !(def.accessibility.is_some() - || def.r#static - || def.declare - || def.r#override - || def.readonly) - } - ClassElement::AccessorProperty(def) => { - def.computed && !(def.accessibility.is_some() || def.r#static || def.r#override) - } - ClassElement::TSIndexSignature(_) => true, - _ => false, - } - } -} - -impl<'a> Format<'a> for ClassPropertySemicolon<'a, '_> { - fn fmt(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { - if matches!( - self.element.as_ref(), - ClassElement::StaticBlock(_) | ClassElement::MethodDefinition(_) - ) { - return Ok(()); - } - - if match f.options().semicolons { - Semicolons::Always => true, - Semicolons::AsNeeded => self.needs_semicolon(), - } { - write!(f, ";") - } else { - Ok(()) - } - } -} diff --git a/crates/oxc_formatter/src/write/template.rs b/crates/oxc_formatter/src/write/template.rs index 28e26d0124d4b..f998f23429080 100644 --- a/crates/oxc_formatter/src/write/template.rs +++ b/crates/oxc_formatter/src/write/template.rs @@ -18,7 +18,8 @@ use crate::{ }, generated::ast_nodes::{AstNode, AstNodeIterator}, utils::{ - call_expression::is_test_each_pattern, expression::FormatExpressionWithoutTrailingComments, + call_expression::is_test_each_pattern, + format_node_without_trailing_comments::FormatNodeWithoutTrailingComments, }, write, }; @@ -245,7 +246,7 @@ impl<'a> Format<'a> for FormatTemplateExpression<'a, '_> { TemplateExpression::Expression(e) => { let leading_comments = f.context().comments().comments_before(e.span().start); FormatLeadingComments::Comments(leading_comments).fmt(f)?; - FormatExpressionWithoutTrailingComments(e).fmt(f)?; + FormatNodeWithoutTrailingComments(e).fmt(f)?; let trailing_comments = f.context().comments().comments_before_character(e.span().start, b'}'); has_comment_in_expression = diff --git a/crates/oxc_formatter/src/write/tuple_type.rs b/crates/oxc_formatter/src/write/tuple_type.rs new file mode 100644 index 0000000000000..604caaa280847 --- /dev/null +++ b/crates/oxc_formatter/src/write/tuple_type.rs @@ -0,0 +1,37 @@ +use oxc_allocator::Vec; +use oxc_ast::ast::*; + +use crate::{ + Expand, Format, FormatResult, FormatTrailingCommas, + formatter::{Formatter, prelude::*, trivia::format_dangling_comments}, + generated::ast_nodes::AstNode, + write, +}; + +use super::FormatWrite; + +impl<'a> Format<'a> for AstNode<'a, Vec<'a, TSTupleElement<'a>>> { + fn fmt(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { + let trailing_separator = FormatTrailingCommas::ES5.trailing_separator(f.options()); + f.join_nodes_with_soft_line() + .entries_with_trailing_separator(self.iter(), ",", trailing_separator) + .finish() + } +} + +impl<'a> FormatWrite<'a> for AstNode<'a, TSTupleType<'a>> { + fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { + write!(f, "[")?; + + let element_types = self.element_types(); + if element_types.is_empty() { + write!(f, [format_dangling_comments(self.span).with_block_indent()])?; + } else { + let should_expand = f.options().expand == Expand::Always; + + write!(f, [group(&soft_block_indent(&element_types)).should_expand(should_expand)])?; + } + + write!(f, "]") + } +} diff --git a/crates/oxc_formatter/src/write/type_parameters.rs b/crates/oxc_formatter/src/write/type_parameters.rs index 0157483faa2ea..a4d82aea5f62a 100644 --- a/crates/oxc_formatter/src/write/type_parameters.rs +++ b/crates/oxc_formatter/src/write/type_parameters.rs @@ -11,7 +11,10 @@ use crate::{ }, generated::ast_nodes::{AstNode, AstNodes}, options::{FormatTrailingCommas, TrailingSeparator}, - utils::call_expression::is_test_call_expression, + utils::{ + call_expression::is_test_call_expression, + typescript::{is_object_like_type, is_simple_type, should_hug_type}, + }, write, }; @@ -144,7 +147,7 @@ impl<'a> FormatWrite<'a> for AstNode<'a, TSTypeParameterInstantiation<'a>> { let first_arg_can_be_hugged = if params.len() == 1 { if let Some(first_type) = params.first() { matches!(first_type.as_ref(), TSType::TSNullKeyword(_)) - || should_hug_single_type(first_type.as_ref()) + || should_hug_single_type(first_type.as_ref(), f) } else { false } @@ -169,39 +172,8 @@ impl<'a> FormatWrite<'a> for AstNode<'a, TSTypeParameterInstantiation<'a>> { } } -/// Check if a TSType is a simple type (primitives, keywords, simple references) -fn is_simple_type(ty: &TSType) -> bool { - match ty { - TSType::TSAnyKeyword(_) - | TSType::TSNullKeyword(_) - | TSType::TSThisType(_) - | TSType::TSVoidKeyword(_) - | TSType::TSNumberKeyword(_) - | TSType::TSBooleanKeyword(_) - | TSType::TSBigIntKeyword(_) - | TSType::TSStringKeyword(_) - | TSType::TSSymbolKeyword(_) - | TSType::TSNeverKeyword(_) - | TSType::TSObjectKeyword(_) - | TSType::TSUndefinedKeyword(_) - | TSType::TSTemplateLiteralType(_) - | TSType::TSLiteralType(_) - | TSType::TSUnknownKeyword(_) => true, - TSType::TSTypeReference(reference) => { - // Simple reference without type arguments - reference.type_arguments.is_none() - } - _ => false, - } -} - -/// Check if a TSType is object-like (object literal, mapped type, etc.) -fn is_object_like_type(ty: &TSType) -> bool { - matches!(ty, TSType::TSTypeLiteral(_) | TSType::TSMappedType(_)) -} - /// Check if a single type should be "hugged" (kept inline) -fn should_hug_single_type(ty: &TSType) -> bool { +fn should_hug_single_type(ty: &TSType, f: &mut Formatter<'_, '_>) -> bool { // Simple types and object-like types can be hugged if is_simple_type(ty) || is_object_like_type(ty) { return true; @@ -209,35 +181,7 @@ fn should_hug_single_type(ty: &TSType) -> bool { // Check for union types with mostly void types and one object type // (e.g., `SomeType`) - if let TSType::TSUnionType(union_type) = ty { - let types = &union_type.types; - - // Must have at least 2 types - if types.len() < 2 { - return types.len() == 1 && should_hug_single_type(&types[0]); - } - - let has_object_type = types - .iter() - .any(|t| matches!(t, TSType::TSTypeLiteral(_) | TSType::TSTypeReference(_))); - - let void_count = types - .iter() - .filter(|t| { - matches!( - t, - TSType::TSVoidKeyword(_) - | TSType::TSNullKeyword(_) - | TSType::TSUndefinedKeyword(_) - ) - }) - .count(); - - // Union is huggable if it's mostly void types with one object/reference type - (types.len() - 1 == void_count && has_object_type) || types.len() == 1 - } else { - false - } + matches!(ty, TSType::TSUnionType(union) if should_hug_type(union, f)) } /// Check if this type parameter instantiation is in an arrow function variable context @@ -256,8 +200,9 @@ fn is_arrow_function_variable_type_argument<'a>( return false; } + // `node.parent` is `TSTypeReference` matches!( - &node.parent, + &node.parent.parent(), AstNodes::TSTypeAnnotation(type_annotation) if matches!( &type_annotation.parent, diff --git a/crates/oxc_formatter/src/write/union_type.rs b/crates/oxc_formatter/src/write/union_type.rs index a24d8812249d2..e416a9e43ed2e 100644 --- a/crates/oxc_formatter/src/write/union_type.rs +++ b/crates/oxc_formatter/src/write/union_type.rs @@ -4,9 +4,14 @@ use oxc_span::GetSpan; use crate::{ format_args, - formatter::{FormatResult, Formatter, prelude::*, trivia::FormatTrailingComments}, + formatter::{ + FormatResult, Formatter, + prelude::*, + trivia::{FormatLeadingComments, FormatTrailingComments}, + }, generated::ast_nodes::{AstNode, AstNodes}, parentheses::NeedsParentheses, + utils::typescript::should_hug_type, write, write::FormatWrite, }; @@ -15,17 +20,13 @@ impl<'a> FormatWrite<'a> for AstNode<'a, TSUnionType<'a>> { fn write(&self, f: &mut Formatter<'_, 'a>) -> FormatResult<()> { let types = self.types(); - if types.len() == 1 { - return write!(f, self.types().first()); - } - // ```ts // { // a: string // } | null | void // ``` // should be inlined and not be printed in the multi-line variant - let should_hug = should_hug_type(self); + let should_hug = should_hug_type(self, f); if should_hug { return format_union_types(self.types(), true, f); } @@ -42,14 +43,11 @@ impl<'a> FormatWrite<'a> for AstNode<'a, TSUnionType<'a>> { // So the head of the current nested union type chain is `| (| (A | B))` // if we encounter a leading comment when navigating up the chain, // we consider the current union type as having leading comments - let mut has_leading_comments = f.comments().has_comment_before(self.span().start); + let leading_comments = f.context().comments().comments_before(self.span().start); + let has_leading_comments = !leading_comments.is_empty(); let mut union_type_at_top = self; - while let AstNodes::TSUnionType(parent) = union_type_at_top.parent { if parent.types().len() == 1 { - if f.comments().has_comment_before(parent.span().start) { - has_leading_comments = true; - } union_type_at_top = parent; } else { break; @@ -71,7 +69,7 @@ impl<'a> FormatWrite<'a> for AstNode<'a, TSUnionType<'a>> { let types = format_with(|f| { if has_leading_comments { - write!(f, [soft_line_break()])?; + write!(f, FormatLeadingComments::Comments(leading_comments))?; } let leading_soft_line_break_or_space = should_indent && !has_leading_comments; @@ -125,26 +123,6 @@ impl<'a> FormatWrite<'a> for AstNode<'a, TSUnionType<'a>> { } } -fn should_hug_type(node: &AstNode<'_, TSUnionType<'_>>) -> bool { - // Simple heuristic: hug unions with object types and simple nullable types - let types = node.types(); - - if types.len() <= 3 { - let has_object_type = types.iter().any(|t| matches!(t.as_ref(), TSType::TSTypeLiteral(_))); - - let has_simple_types = types.iter().any(|t| { - matches!( - t.as_ref(), - TSType::TSNullKeyword(_) | TSType::TSUndefinedKeyword(_) | TSType::TSVoidKeyword(_) - ) - }); - - return has_object_type && has_simple_types; - } - - false -} - pub struct FormatTSType<'a, 'b> { next_node_span: Option, element: &'b AstNode<'a, TSType<'a>>, diff --git a/crates/oxc_isolated_declarations/CHANGELOG.md b/crates/oxc_isolated_declarations/CHANGELOG.md index d8c63f7c84aff..b464ea3cc8213 100644 --- a/crates/oxc_isolated_declarations/CHANGELOG.md +++ b/crates/oxc_isolated_declarations/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this package will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0). + ## [0.91.0] - 2025-09-22 ### ๐Ÿ’ผ Other diff --git a/crates/oxc_isolated_declarations/Cargo.toml b/crates/oxc_isolated_declarations/Cargo.toml index 6b0568bc63f65..4bda17d247d2d 100644 --- a/crates/oxc_isolated_declarations/Cargo.toml +++ b/crates/oxc_isolated_declarations/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxc_isolated_declarations" -version = "0.91.0" +version = "0.92.0" authors.workspace = true categories.workspace = true edition.workspace = true diff --git a/crates/oxc_language_server/CHANGELOG.md b/crates/oxc_language_server/CHANGELOG.md index be9c7fd5e5b0b..2a6e83664440a 100644 --- a/crates/oxc_language_server/CHANGELOG.md +++ b/crates/oxc_language_server/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this package will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0). + ## [1.17.0] - 2025-09-23 ### ๐Ÿš€ Features diff --git a/crates/oxc_language_server/Cargo.toml b/crates/oxc_language_server/Cargo.toml index 69868c6dc4d29..c2fc661364ff1 100644 --- a/crates/oxc_language_server/Cargo.toml +++ b/crates/oxc_language_server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxc_language_server" -version = "1.17.0" +version = "1.18.0" authors.workspace = true categories.workspace = true edition.workspace = true diff --git a/crates/oxc_linter/CHANGELOG.md b/crates/oxc_linter/CHANGELOG.md index 57be5cb9b6e59..8617a57485e4a 100644 --- a/crates/oxc_linter/CHANGELOG.md +++ b/crates/oxc_linter/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this package will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0). +## [1.18.0] - 2025-09-24 + +### ๐Ÿ› Bug Fixes + +- 444fcf0 linter: Fix false positive in `vue/no-required-prop-with-default` (#14066) (yefan) +- 2186b28 linter: Fix Arc memory leak and lifecycle issues (#14049) (Boshen) + +### โšก Performance + +- c2f7459 language_server: Avoid cloning on message conversion (#14058) (Sysix) + + ## [1.17.0] - 2025-09-23 ### ๐Ÿš€ Features diff --git a/crates/oxc_linter/Cargo.toml b/crates/oxc_linter/Cargo.toml index d5e9e5a4492f2..7a147075b77c1 100644 --- a/crates/oxc_linter/Cargo.toml +++ b/crates/oxc_linter/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxc_linter" -version = "1.17.0" +version = "1.18.0" authors.workspace = true categories.workspace = true edition.workspace = true @@ -60,6 +60,7 @@ language-tags = { workspace = true } lazy-regex = { workspace = true } memchr = { workspace = true } nonmax = { workspace = true } +papaya = { workspace = true } phf = { workspace = true, features = ["macros"] } rayon = { workspace = true } rust-lapper = { workspace = true } diff --git a/crates/oxc_linter/src/lib.rs b/crates/oxc_linter/src/lib.rs index f350c46f75816..a057ad40ce710 100644 --- a/crates/oxc_linter/src/lib.rs +++ b/crates/oxc_linter/src/lib.rs @@ -1,17 +1,20 @@ #![expect(clippy::self_named_module_files)] // for rules.rs -#![allow(clippy::literal_string_with_formatting_args)] -use std::{path::Path, rc::Rc}; +use std::{ + mem, + path::Path, + ptr::{self, NonNull}, + rc::Rc, +}; use oxc_allocator::Allocator; -use oxc_ast::ast_kind::AST_TYPE_MAX; +use oxc_ast::{ast::Program, ast_kind::AST_TYPE_MAX}; use oxc_ast_macros::ast; use oxc_ast_visit::utf8_to_utf16::Utf8ToUtf16; use oxc_data_structures::box_macros::boxed_array; +use oxc_diagnostics::OxcDiagnostic; use oxc_semantic::AstNode; - -#[cfg(test)] -mod tester; +use oxc_span::Span; mod ast_util; mod config; @@ -42,6 +45,9 @@ mod generated { mod rule_runner_impls; } +#[cfg(test)] +mod tester; + pub use crate::{ config::{ BuiltinLintPlugins, Config, ConfigBuilderError, ConfigStore, ConfigStoreBuilder, @@ -67,7 +73,7 @@ pub use crate::{ use crate::{ config::{LintConfig, OxlintEnv, OxlintGlobals, OxlintSettings}, context::ContextHost, - fixer::{Fixer, Message}, + fixer::{Fixer, Message, PossibleFixes}, rules::RuleEnum, utils::iter_possible_jest_call_node, }; @@ -329,17 +335,6 @@ impl Linter { ctx_host: &mut Rc>, allocator: &'a Allocator, ) { - use std::{ - mem, - ptr::{self, NonNull}, - }; - - use oxc_ast::ast::Program; - use oxc_diagnostics::OxcDiagnostic; - use oxc_span::Span; - - use crate::fixer::PossibleFixes; - if external_rules.is_empty() { return; } @@ -466,13 +461,14 @@ impl RawTransferMetadata { #[cfg(test)] mod test { + use std::fs; + + use project_root::get_project_root; + use super::Oxlintrc; #[test] fn test_schema_json() { - use std::fs; - - use project_root::get_project_root; let path = get_project_root().unwrap().join("npm/oxlint/configuration_schema.json"); let schema = schemars::schema_for!(Oxlintrc); let json = serde_json::to_string_pretty(&schema).unwrap(); diff --git a/crates/oxc_linter/src/lsp.rs b/crates/oxc_linter/src/lsp.rs index 1e000aa614976..81e0fc0703245 100644 --- a/crates/oxc_linter/src/lsp.rs +++ b/crates/oxc_linter/src/lsp.rs @@ -69,11 +69,13 @@ pub struct MessageWithPosition<'a> { // we assume that the fix offset will not exceed 2GB in either direction #[expect(clippy::cast_possible_truncation)] pub fn oxc_diagnostic_to_message_with_position<'a>( - diagnostic: &OxcDiagnostic, + diagnostic: OxcDiagnostic, source_text: &str, rope: &Rope, ) -> MessageWithPosition<'a> { - let labels = diagnostic.labels.as_ref().map(|labels| { + let inner = diagnostic.inner_owned(); + + let labels = inner.labels.as_ref().map(|labels| { labels .iter() .map(|labeled_span| { @@ -89,56 +91,33 @@ pub fn oxc_diagnostic_to_message_with_position<'a>( }); MessageWithPosition { - message: diagnostic.message.clone(), - severity: diagnostic.severity, - help: diagnostic.help.clone(), - url: diagnostic.url.clone(), - code: diagnostic.code.clone(), + message: inner.message, + severity: inner.severity, + help: inner.help, + url: inner.url, + code: inner.code, labels, fixes: PossibleFixesWithPosition::None, } } -// clippy: the source field is checked and assumed to be less than 4GB, and -// we assume that the fix offset will not exceed 2GB in either direction -#[expect(clippy::cast_possible_truncation)] pub fn message_to_message_with_position<'a>( - message: &Message<'a>, + message: Message<'a>, source_text: &str, rope: &Rope, ) -> MessageWithPosition<'a> { - let labels = message.error.labels.as_ref().map(|labels| { - labels - .iter() - .map(|labeled_span| { - let offset = labeled_span.offset() as u32; - let start_position = offset_to_position(rope, offset, source_text); - let end_position = - offset_to_position(rope, offset + labeled_span.len() as u32, source_text); - let message = labeled_span.label().map(|label| Cow::Owned(label.to_string())); - - SpanPositionMessage::new(start_position, end_position).with_message(message) - }) - .collect::>() - }); - - MessageWithPosition { - message: message.error.message.clone(), - severity: message.error.severity, - help: message.error.help.clone(), - url: message.error.url.clone(), - code: message.error.code.clone(), - labels, - fixes: match &message.fixes { - PossibleFixes::None => PossibleFixesWithPosition::None, - PossibleFixes::Single(fix) => { - PossibleFixesWithPosition::Single(fix_to_fix_with_position(fix, rope, source_text)) - } - PossibleFixes::Multiple(fixes) => PossibleFixesWithPosition::Multiple( - fixes.iter().map(|fix| fix_to_fix_with_position(fix, rope, source_text)).collect(), - ), - }, - } + let mut result = oxc_diagnostic_to_message_with_position(message.error, source_text, rope); + result.fixes = match &message.fixes { + PossibleFixes::None => PossibleFixesWithPosition::None, + PossibleFixes::Single(fix) => { + PossibleFixesWithPosition::Single(fix_to_fix_with_position(fix, rope, source_text)) + } + PossibleFixes::Multiple(fixes) => PossibleFixesWithPosition::Multiple( + fixes.iter().map(|fix| fix_to_fix_with_position(fix, rope, source_text)).collect(), + ), + }; + + result } #[derive(Debug)] diff --git a/crates/oxc_linter/src/module_graph_visitor.rs b/crates/oxc_linter/src/module_graph_visitor.rs index 90220e6f2101a..9ae687aaf0a82 100644 --- a/crates/oxc_linter/src/module_graph_visitor.rs +++ b/crates/oxc_linter/src/module_graph_visitor.rs @@ -189,16 +189,21 @@ impl ModuleGraphVisitor { enter: &mut EnterMod, leave: &mut LeaveMod, ) -> VisitFoldWhile { - for pair in module_record.loaded_modules.read().unwrap().iter() { + for (key, weak_module_record) in module_record.loaded_modules().iter() { if self.depth > self.max_depth { return VisitFoldWhile::Stop(accumulator.into_inner()); } - let path = &pair.1.resolved_absolute_path; + let loaded_module_record = weak_module_record.upgrade().unwrap(); + + let path = &loaded_module_record.resolved_absolute_path; + if !self.traversed.insert(path.clone()) { continue; } + let pair = (key, &loaded_module_record); + if !filter(pair, module_record) { continue; } diff --git a/crates/oxc_linter/src/module_record.rs b/crates/oxc_linter/src/module_record.rs index fbd82c2195ccb..b927e78f882fc 100644 --- a/crates/oxc_linter/src/module_record.rs +++ b/crates/oxc_linter/src/module_record.rs @@ -3,7 +3,7 @@ use std::{ fmt, path::{Path, PathBuf}, - sync::{Arc, OnceLock, RwLock}, + sync::{Arc, OnceLock, RwLock, RwLockReadGuard, RwLockWriteGuard, Weak}, }; use rustc_hash::FxHashMap; @@ -46,7 +46,9 @@ pub struct ModuleRecord { /// /// Note that Oxc does not support cross-file analysis, so this map will be empty after /// [`ModuleRecord`] is created. You must link the module records yourself. - pub loaded_modules: RwLock>>, + /// + /// Use [ModuleRecord::get_loaded_module] to get a `ModuleRecord`. + loaded_modules: RwLock>>, /// `[[ImportEntries]]` /// @@ -508,18 +510,46 @@ impl ModuleRecord { } } + /// # Panics + /// + /// * If the RwLock is poisoned (which only happens if a thread panicked while holding the lock). + pub fn loaded_modules(&self) -> RwLockReadGuard<'_, FxHashMap>> { + self.loaded_modules.read().unwrap() + } + + /// # Panics + /// + /// * If the RwLock is poisoned (which only happens if a thread panicked while holding the lock). + pub fn write_loaded_modules( + &self, + ) -> RwLockWriteGuard<'_, FxHashMap>> { + self.loaded_modules.write().unwrap() + } + + /// Get a loaded module by upgrading the weak reference to an Arc. + /// Returns None if the module has been dropped or not found. + /// + /// # Panics + /// + /// * If the RwLock is poisoned (which only happens if a thread panicked while holding the lock). + /// * If `ModuleRecord` is dropped (fails to Weak::upgrade). + pub fn get_loaded_module(&self, key: &str) -> Option> { + let loaded_modules = self.loaded_modules(); + loaded_modules.get(key).map(|weak| Weak::upgrade(weak).unwrap()) + } + pub(crate) fn exported_bindings_from_star_export( &self, ) -> &FxHashMap> { self.exported_bindings_from_star_export.get_or_init(|| { let mut exported_bindings_from_star_export: FxHashMap> = FxHashMap::default(); - let loaded_modules = self.loaded_modules.read().unwrap(); for export_entry in &self.star_export_entries { let Some(module_request) = &export_entry.module_request else { continue; }; - let Some(remote_module_record) = loaded_modules.get(module_request.name()) else { + let Some(remote_module_record) = self.get_loaded_module(module_request.name()) + else { continue; }; // Append both remote `bindings` and `exported_bindings_from_star_export` diff --git a/crates/oxc_linter/src/rules/import/default.rs b/crates/oxc_linter/src/rules/import/default.rs index 8dd7dcd7a10ca..08a830fcdc445 100644 --- a/crates/oxc_linter/src/rules/import/default.rs +++ b/crates/oxc_linter/src/rules/import/default.rs @@ -54,14 +54,13 @@ declare_oxc_lint!( impl Rule for Default { fn run_once(&self, ctx: &LintContext<'_>) { let module_record = ctx.module_record(); - let loaded_modules = module_record.loaded_modules.read().unwrap(); for import_entry in &module_record.import_entries { let ImportImportName::Default(default_span) = import_entry.import_name else { continue; }; let specifier = import_entry.module_request.name(); - let Some(remote_module_record) = loaded_modules.get(specifier) else { + let Some(remote_module_record) = module_record.get_loaded_module(specifier) else { continue; }; if !remote_module_record.has_module_syntax { diff --git a/crates/oxc_linter/src/rules/import/export.rs b/crates/oxc_linter/src/rules/import/export.rs index b2e4e10125a7e..9e02886638051 100644 --- a/crates/oxc_linter/src/rules/import/export.rs +++ b/crates/oxc_linter/src/rules/import/export.rs @@ -56,7 +56,6 @@ impl Rule for Export { let mut all_export_names = FxHashMap::default(); let mut visited = FxHashSet::default(); - let loaded_modules = module_record.loaded_modules.read().unwrap(); module_record.star_export_entries.iter().for_each(|star_export_entry| { if star_export_entry.is_type { return; @@ -66,11 +65,12 @@ impl Rule for Export { let Some(module_request) = &star_export_entry.module_request else { return; }; - let Some(remote_module_record) = loaded_modules.get(module_request.name()) else { + let Some(remote_module_record) = module_record.get_loaded_module(module_request.name()) + else { return; }; - walk_exported_recursive(remote_module_record, &mut export_names, &mut visited); + walk_exported_recursive(&remote_module_record, &mut export_names, &mut visited); if export_names.is_empty() { ctx.diagnostic(no_named_export(module_request.name(), module_request.span)); @@ -118,15 +118,15 @@ fn walk_exported_recursive( for name in module_record.exported_bindings.keys() { result.insert(name.clone()); } - let loaded_modules = module_record.loaded_modules.read().unwrap(); for export_entry in &module_record.star_export_entries { let Some(module_request) = &export_entry.module_request else { continue; }; - let Some(remote_module_record) = loaded_modules.get(module_request.name()) else { + let Some(remote_module_record) = module_record.get_loaded_module(module_request.name()) + else { continue; }; - walk_exported_recursive(remote_module_record, result, visited); + walk_exported_recursive(&remote_module_record, result, visited); } } diff --git a/crates/oxc_linter/src/rules/import/named.rs b/crates/oxc_linter/src/rules/import/named.rs index ca98eefc48b4f..ce270807344c7 100644 --- a/crates/oxc_linter/src/rules/import/named.rs +++ b/crates/oxc_linter/src/rules/import/named.rs @@ -91,7 +91,6 @@ impl Rule for Named { let module_record = ctx.module_record(); - let loaded_modules = module_record.loaded_modules.read().unwrap(); for import_entry in &module_record.import_entries { // Get named import let ImportImportName::Name(import_name) = &import_entry.import_name else { @@ -99,7 +98,7 @@ impl Rule for Named { }; let specifier = import_entry.module_request.name(); // Get remote module record - let Some(remote_module_record) = loaded_modules.get(specifier) else { + let Some(remote_module_record) = module_record.get_loaded_module(specifier) else { continue; }; if !remote_module_record.has_module_syntax { @@ -127,7 +126,6 @@ impl Rule for Named { ctx.diagnostic(named_diagnostic(name, specifier, import_span)); } - let loaded_modules = module_record.loaded_modules.read().unwrap(); for export_entry in &module_record.indirect_export_entries { let Some(module_request) = &export_entry.module_request else { continue; @@ -137,7 +135,7 @@ impl Rule for Named { }; let specifier = module_request.name(); // Get remote module record - let Some(remote_module_record) = loaded_modules.get(specifier) else { + let Some(remote_module_record) = module_record.get_loaded_module(specifier) else { continue; }; if !remote_module_record.has_module_syntax { diff --git a/crates/oxc_linter/src/rules/import/namespace.rs b/crates/oxc_linter/src/rules/import/namespace.rs index 26b9dcd6c6a32..ea9964908d320 100644 --- a/crates/oxc_linter/src/rules/import/namespace.rs +++ b/crates/oxc_linter/src/rules/import/namespace.rs @@ -124,32 +124,30 @@ impl Rule for Namespace { return; } - let loaded_modules = module_record.loaded_modules.read().unwrap(); - for entry in &module_record.import_entries { let (source, module) = match &entry.import_name { ImportImportName::NamespaceObject => { let source = entry.module_request.name(); - let Some(module) = loaded_modules.get(source) else { + let Some(module) = module_record.get_loaded_module(source) else { return; }; - (source.to_string(), Arc::clone(module)) + (source.to_string(), module) } ImportImportName::Name(name) => { - let Some(loaded_module) = loaded_modules.get(entry.module_request.name()) + let Some(loaded_module) = + module_record.get_loaded_module(entry.module_request.name()) else { return; }; - let Some(source) = get_module_request_name(name.name(), loaded_module) else { + let Some(source) = get_module_request_name(name.name(), &loaded_module) else { return; }; - - let loaded_module = loaded_module.loaded_modules.read().unwrap(); - let Some(loaded_module) = loaded_module.get(source.as_str()) else { + let Some(loaded_module_for_source) = + loaded_module.get_loaded_module(source.as_str()) + else { return; }; - - (source, Arc::clone(loaded_module)) + (source, loaded_module_for_source) } ImportImportName::Default(_) => { // TODO: Hard to confirm if it's a namespace object @@ -280,11 +278,10 @@ fn check_deep_namespace_for_node( if let Some(module_source) = get_module_request_name(name, module) { let parent_node = ctx.nodes().parent_node(node.id()); - let loaded_modules = module.loaded_modules.read().unwrap(); - let module_record = loaded_modules.get(module_source.as_str())?; + let module_record = module.get_loaded_module(module_source.as_str())?; let mut namespaces = namespaces.to_owned(); namespaces.push(name.into()); - check_deep_namespace_for_node(parent_node, source, &namespaces, module_record, ctx); + check_deep_namespace_for_node(parent_node, source, &namespaces, &module_record, ctx); } else { check_binding_exported( name, @@ -321,12 +318,11 @@ fn check_deep_namespace_for_object_pattern( let mut next_namespaces = namespaces.to_owned(); next_namespaces.push(name.to_string()); - let loaded_modules = module.loaded_modules.read().unwrap(); check_deep_namespace_for_object_pattern( pattern, source, next_namespaces.as_slice(), - loaded_modules.get(module_source.as_str()).unwrap(), + &module.get_loaded_module(module_source.as_str()).unwrap(), ctx, ); continue; diff --git a/crates/oxc_linter/src/rules/import/no_duplicates.rs b/crates/oxc_linter/src/rules/import/no_duplicates.rs index 198c14d7833d1..56f8dcba0236a 100644 --- a/crates/oxc_linter/src/rules/import/no_duplicates.rs +++ b/crates/oxc_linter/src/rules/import/no_duplicates.rs @@ -92,12 +92,11 @@ impl Rule for NoDuplicates { fn run_once(&self, ctx: &LintContext<'_>) { let module_record = ctx.module_record(); - let loaded_modules = module_record.loaded_modules.read().unwrap(); let groups = module_record .requested_modules .iter() .map(|(source, requested_modules)| { - let resolved_absolute_path = loaded_modules.get(source).map_or_else( + let resolved_absolute_path = module_record.get_loaded_module(source).map_or_else( || source.to_string(), |module| module.resolved_absolute_path.to_string_lossy().to_string(), ); diff --git a/crates/oxc_linter/src/rules/import/no_named_as_default.rs b/crates/oxc_linter/src/rules/import/no_named_as_default.rs index d5d1dc1c8d106..6156f32d8fb35 100644 --- a/crates/oxc_linter/src/rules/import/no_named_as_default.rs +++ b/crates/oxc_linter/src/rules/import/no_named_as_default.rs @@ -66,8 +66,7 @@ impl Rule for NoNamedAsDefault { }; let specifier = import_entry.module_request.name(); - let remote_module_record = module_record.loaded_modules.read().unwrap(); - let Some(remote_module_record) = remote_module_record.get(specifier) else { + let Some(remote_module_record) = module_record.get_loaded_module(specifier) else { continue; }; diff --git a/crates/oxc_linter/src/rules/import/no_named_as_default_member.rs b/crates/oxc_linter/src/rules/import/no_named_as_default_member.rs index e73c98a03421f..ca5c469902f8c 100644 --- a/crates/oxc_linter/src/rules/import/no_named_as_default_member.rs +++ b/crates/oxc_linter/src/rules/import/no_named_as_default_member.rs @@ -1,5 +1,3 @@ -use std::sync::Arc; - use oxc_ast::{ AstKind, ast::{BindingPatternKind, Expression, IdentifierReference}, @@ -85,8 +83,7 @@ impl Rule for NoNamedAsDefaultMember { }; let specifier = import_entry.module_request.name(); - let remote_module_record = module_record.loaded_modules.read().unwrap(); - let Some(remote_module_record) = remote_module_record.get(specifier) else { + let Some(remote_module_record) = module_record.get_loaded_module(specifier) else { continue; }; @@ -99,10 +96,8 @@ impl Rule for NoNamedAsDefaultMember { return; }; - has_members_map.insert( - symbol_id, - (Arc::clone(remote_module_record), import_entry.module_request.clone()), - ); + has_members_map + .insert(symbol_id, (remote_module_record, import_entry.module_request.clone())); } if has_members_map.is_empty() { diff --git a/crates/oxc_linter/src/rules/import/no_self_import.rs b/crates/oxc_linter/src/rules/import/no_self_import.rs index 217d945617d8a..2cfd164f21e02 100644 --- a/crates/oxc_linter/src/rules/import/no_self_import.rs +++ b/crates/oxc_linter/src/rules/import/no_self_import.rs @@ -46,8 +46,7 @@ impl Rule for NoSelfImport { let module_record = ctx.module_record(); let resolved_absolute_path = &module_record.resolved_absolute_path; for (request, requested_modules) in &module_record.requested_modules { - let remote_module_record = module_record.loaded_modules.read().unwrap(); - let Some(remote_module_record) = remote_module_record.get(request) else { + let Some(remote_module_record) = module_record.get_loaded_module(request) else { continue; }; if remote_module_record.resolved_absolute_path == *resolved_absolute_path { diff --git a/crates/oxc_linter/src/rules/jest/prefer_to_contain.rs b/crates/oxc_linter/src/rules/jest/prefer_to_contain.rs index 8d09292b777fb..89d1c5aeb3edf 100644 --- a/crates/oxc_linter/src/rules/jest/prefer_to_contain.rs +++ b/crates/oxc_linter/src/rules/jest/prefer_to_contain.rs @@ -144,6 +144,7 @@ impl PreferToContain { fn tests() { use crate::tester::Tester; + #[expect(clippy::literal_string_with_formatting_args)] let pass = vec![ ("expect.hasAssertions", None), ("expect.hasAssertions()", None), @@ -178,6 +179,7 @@ fn tests() { ("expect(a.includes(b)).toEqual(0 as boolean);", None), ]; + #[expect(clippy::literal_string_with_formatting_args)] let fail = vec![ ("expect(a.includes(b)).toEqual(true);", None), ("expect(a.includes(b,),).toEqual(true,)", None), diff --git a/crates/oxc_linter/src/rules/oxc/no_barrel_file.rs b/crates/oxc_linter/src/rules/oxc/no_barrel_file.rs index 80684ef42ca90..4b97fdfb067b2 100644 --- a/crates/oxc_linter/src/rules/oxc/no_barrel_file.rs +++ b/crates/oxc_linter/src/rules/oxc/no_barrel_file.rs @@ -108,9 +108,8 @@ impl Rule for NoBarrelFile { // the own module is counted as well total += 1; - if let Some(remote_module) = - module_record.loaded_modules.read().unwrap().get(module_request.name()) - && let Some(count) = count_loaded_modules(remote_module) + if let Some(remote_module) = module_record.get_loaded_module(module_request.name()) + && let Some(count) = count_loaded_modules(&remote_module) { total += count; labels.push(module_request.span.label(format!("{count} modules"))); @@ -129,7 +128,7 @@ impl Rule for NoBarrelFile { } fn count_loaded_modules(module_record: &ModuleRecord) -> Option { - if module_record.loaded_modules.read().unwrap().is_empty() { + if module_record.loaded_modules().is_empty() { return None; } Some( diff --git a/crates/oxc_linter/src/rules/react/exhaustive_deps.rs b/crates/oxc_linter/src/rules/react/exhaustive_deps.rs index f22df1084db84..618c3c674a408 100644 --- a/crates/oxc_linter/src/rules/react/exhaustive_deps.rs +++ b/crates/oxc_linter/src/rules/react/exhaustive_deps.rs @@ -181,6 +181,17 @@ fn ref_accessed_directly_in_effect_cleanup_diagnostic(span: Span) -> OxcDiagnost .with_error_code_scope(SCOPE) } +fn functions_returned_from_use_effect_event_must_not_be_included_in_dependency_array( + span: Span, +) -> OxcDiagnostic { + OxcDiagnostic::warn( + "Functions returned from `useEffectEvent` must not be included in the dependency array.", + ) + .with_label(span) + .with_help("Remove the dependency from the dependency array.") + .with_error_code_scope(SCOPE) +} + #[derive(Debug, Default, Clone)] pub struct ExhaustiveDeps(Box); @@ -591,6 +602,18 @@ impl Rule for ExhaustiveDeps { ); } + for dep in &declared_dependencies { + if let Some(symbol_id) = dep.symbol_id + && let AstKind::VariableDeclarator(var_decl) = + ctx.semantic().symbol_declaration(symbol_id).kind() + && let Some(Expression::CallExpression(call_expr)) = &var_decl.init + && let Some(name) = func_call_without_react_namespace(call_expr) + && name == "useEffectEvent" + { + ctx.diagnostic(functions_returned_from_use_effect_event_must_not_be_included_in_dependency_array(dep.span)); + } + } + // effects are allowed to have extra dependencies if !is_effect { // lastly, we need co compare for any unnecessary deps @@ -1047,7 +1070,7 @@ fn is_stable_value<'a, 'b>( return false; }; - if init_name == "useRef" { + if init_name == "useRef" || init_name == "useEffectEvent" { return true; } @@ -2605,7 +2628,17 @@ fn test() { r"function MyComponent(props) { useEffect(() => { console.log((props.foo).bar) }, [props.foo!.bar]) }", r"function MyComponent(props) { const external = {}; const y = useMemo(() => { const z = foo(); return z; }, []) }", r#"function Test() { const [state, setState] = useState(); useEffect(() => { console.log("state", state); }); }"#, - "function Test() { const [foo, setFoo] = useState(true); _setFoo = setFoo; useEffect(() => { setFoo(false) }, []); }", + "function MyComponent({ theme }) { + const onStuff = useEffectEvent(() => { + showNotification(theme); + }); + useEffect(() => { + onStuff(); + }, []); + React.useEffect(() => { + onStuff(); + }, []); + }", ]; let fail = vec![ @@ -4063,6 +4096,17 @@ fn test() { log(); }, []); }"#, + r"function MyComponent({ theme }) { + const onStuff = useEffectEvent(() => { + showNotification(theme); + }); + useEffect(() => { + onStuff(); + }, [onStuff]); + React.useEffect(() => { + onStuff(); + }, [onStuff]); + }", ]; let pass_additional_hooks = vec![( diff --git a/crates/oxc_linter/src/rules/unicorn/no_useless_spread/mod.rs b/crates/oxc_linter/src/rules/unicorn/no_useless_spread/mod.rs index 8827adc1b7d22..dedbfee614de7 100644 --- a/crates/oxc_linter/src/rules/unicorn/no_useless_spread/mod.rs +++ b/crates/oxc_linter/src/rules/unicorn/no_useless_spread/mod.rs @@ -607,6 +607,7 @@ fn test() { "[ ...new Uint8Array([ 1, 2, 3 ]) ].map(byte => byte.toString())", ]; + #[expect(clippy::literal_string_with_formatting_args)] let fail = vec![ r"const array = [...[a]]", r"const object = {...{a}}", diff --git a/crates/oxc_linter/src/rules/vue/no_required_prop_with_default.rs b/crates/oxc_linter/src/rules/vue/no_required_prop_with_default.rs index edcb77176af54..3f29c710f2611 100644 --- a/crates/oxc_linter/src/rules/vue/no_required_prop_with_default.rs +++ b/crates/oxc_linter/src/rules/vue/no_required_prop_with_default.rs @@ -217,7 +217,13 @@ fn collect_hash_from_variable_declarator( let key_hash: FxHashSet = obj_pattern .properties .iter() - .filter_map(|prop| prop.key.static_name()) + .filter_map(|prop| { + if matches!(prop.value.kind, BindingPatternKind::AssignmentPattern(_)) { + prop.key.static_name() + } else { + None + } + }) .map(|key| key.to_string()) .collect(); Some(key_hash) @@ -407,6 +413,18 @@ fn test() { use std::path::PathBuf; let pass = vec![ + ( + r#" + + "#, + None, + None, + Some(PathBuf::from("test.vue")), + ), ( r#"