diff --git a/.github/actions/clone-submodules/action.yml b/.github/actions/clone-submodules/action.yml index 22a9e0ac43..6dabea1f31 100644 --- a/.github/actions/clone-submodules/action.yml +++ b/.github/actions/clone-submodules/action.yml @@ -21,12 +21,6 @@ inputs: type: boolean description: Whether to clone typescript submodule - prettier: - default: true - required: false - type: boolean - description: Whether to clone prettier submodule - estree-conformance: default: true required: false @@ -49,7 +43,6 @@ runs: TEST_262: ${{ inputs.test262 }} BABEL: ${{ inputs.babel }} TYPESCRIPT: ${{ inputs.typescript }} - PRETTIER: ${{ inputs.prettier }} ESTREE_CONFORMANCE: ${{ inputs.estree-conformance }} NODE_COMPAT_TABLE: ${{ inputs.node-compat-table }} run: | @@ -57,6 +50,5 @@ runs: "${TEST_262}" \ "${BABEL}" \ "${TYPESCRIPT}" \ - "${PRETTIER}" \ "${ESTREE_CONFORMANCE}" \ "${NODE_COMPAT_TABLE}" diff --git a/.github/scripts/clone-parallel.mjs b/.github/scripts/clone-parallel.mjs index 7bf2727b9d..e7d1461c4d 100644 --- a/.github/scripts/clone-parallel.mjs +++ b/.github/scripts/clone-parallel.mjs @@ -2,7 +2,7 @@ // oxlint-disable no-console // Clone submodules in parallel for faster setup -// Usage: node clone-parallel.mjs [test262] [babel] [typescript] [prettier] [estree-conformance] [node-compat-table] +// Usage: node clone-parallel.mjs [test262] [babel] [typescript] [estree-conformance] [node-compat-table] // Arguments: "true" or "false" for each submodule import { spawn } from "node:child_process"; @@ -15,8 +15,6 @@ const BABEL_SHA = "1eac4481473df52fbbcb452c4dca8d79039dbb63"; const TYPESCRIPT_SHA = "b465fdbfe175304d9b977da137b2c178ae1091d3"; const ESTREE_CONFORMANCE_SHA = "8d4e1faac9f350f914af9b8a6ec02c2d31ca799c"; const NODE_COMPAT_TABLE_SHA = "499beb6f1daa36f10c26b85a7f3ec3b3448ded23"; -// NOTE: Prettier version is now pinned to `v3.9.6` (not updated by workflow above), update manually as needed -const PRETTIER_SHA = "8f0c95057cc91d5836409466cd9d9af3bb901e84"; const repoRoot = join(import.meta.dirname, "..", ".."); @@ -25,9 +23,8 @@ const args = process.argv.slice(2); const TEST262 = args[0] !== "false"; const BABEL = args[1] !== "false"; const TYPESCRIPT = args[2] !== "false"; -const PRETTIER = args[3] !== "false"; -const ESTREE_CONFORMANCE = args[4] !== "false"; -const NODE_COMPAT_TABLE = args[5] !== "false"; +const ESTREE_CONFORMANCE = args[3] !== "false"; +const NODE_COMPAT_TABLE = args[4] !== "false"; /** * Run a git command and return a promise @@ -130,13 +127,6 @@ async function main() { TYPESCRIPT_SHA, "typescript", ), - cloneRepo( - PRETTIER, - "prettier/prettier", - "tasks/prettier_conformance/prettier", - PRETTIER_SHA, - "prettier", - ), cloneRepo( ESTREE_CONFORMANCE, "oxc-project/estree-conformance", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9188e9aa39..a038e7cc5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -215,7 +215,6 @@ jobs: if: steps.filter.outputs.changed == 'true' with: babel: false - prettier: false node-compat-table: false - if: steps.filter.outputs.changed == 'true' name: Run tests @@ -320,8 +319,8 @@ jobs: - uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1 if: steps.filter-conformance.outputs.changed == 'true' with: - # Cache `~/.degit` to reuse degit's tarball cache across CI runs - path: /home/runner/.degit + # Warm fixtures skip download entirely (`.version` stamps in download-fixtures.js) + path: ./apps/oxfmt/conformance/fixtures/externals - if: steps.filter-conformance.outputs.changed == 'true' name: Download conformance fixtures run: pnpm --filter "./apps/oxfmt" download-fixtures @@ -367,7 +366,6 @@ jobs: if: steps.filter.outputs.changed == 'true' with: babel: false - prettier: false node-compat-table: false - if: steps.filter.outputs.changed == 'true' name: Run tests @@ -547,7 +545,9 @@ jobs: - uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1 if: steps.filter.outputs.changed == 'true' with: - path: /home/runner/.rustup + path: | + /home/runner/.rustup + ./crates/oxc_formatter_tests/prettier cache: | rust pnpm diff --git a/.github/workflows/update_submodules.yml b/.github/workflows/update_submodules.yml index fcb300ca88..34477c2fa2 100644 --- a/.github/workflows/update_submodules.yml +++ b/.github/workflows/update_submodules.yml @@ -1,4 +1,3 @@ -# NOTE: Prettier version is now pinned to v3.7.0 (not updated by this workflow) name: Update Submodules permissions: {} @@ -128,14 +127,12 @@ jobs: TEST262_CURRENT=$(grep 'const TEST262_SHA' .github/scripts/clone-parallel.mjs | cut -d'"' -f2) BABEL_CURRENT=$(grep 'const BABEL_SHA' .github/scripts/clone-parallel.mjs | cut -d'"' -f2) TYPESCRIPT_CURRENT=$(grep 'const TYPESCRIPT_SHA' .github/scripts/clone-parallel.mjs | cut -d'"' -f2) - # PRETTIER_CURRENT=$(grep 'const PRETTIER_SHA' .github/scripts/clone-parallel.mjs | cut -d'"' -f2) ESTREE_CONFORMANCE_CURRENT=$(grep 'const ESTREE_CONFORMANCE_SHA' .github/scripts/clone-parallel.mjs | cut -d'"' -f2) NODE_COMPAT_TABLE_CURRENT=$(grep 'const NODE_COMPAT_TABLE_SHA' .github/scripts/clone-parallel.mjs | cut -d'"' -f2) echo "TEST262_CURRENT=$TEST262_CURRENT" >> $GITHUB_OUTPUT echo "BABEL_CURRENT=$BABEL_CURRENT" >> $GITHUB_OUTPUT echo "TYPESCRIPT_CURRENT=$TYPESCRIPT_CURRENT" >> $GITHUB_OUTPUT - # echo "PRETTIER_CURRENT=$PRETTIER_CURRENT" >> $GITHUB_OUTPUT echo "ESTREE_CONFORMANCE_CURRENT=$ESTREE_CONFORMANCE_CURRENT" >> $GITHUB_OUTPUT echo "NODE_COMPAT_TABLE_CURRENT=$NODE_COMPAT_TABLE_CURRENT" >> $GITHUB_OUTPUT @@ -143,7 +140,6 @@ jobs: echo " TEST262: $TEST262_CURRENT" echo " BABEL: $BABEL_CURRENT" echo " TYPESCRIPT: $TYPESCRIPT_CURRENT" - # echo " PRETTIER: $PRETTIER_CURRENT" echo " ESTREE_CONFORMANCE: $ESTREE_CONFORMANCE_CURRENT" echo " NODE_COMPAT_TABLE: $NODE_COMPAT_TABLE_CURRENT" @@ -156,8 +152,6 @@ jobs: BABEL_CURRENT: ${{ steps.current-shas.outputs.BABEL_CURRENT }} TYPESCRIPT_LATEST: ${{ steps.get-shas.outputs.TYPESCRIPT_LATEST }} TYPESCRIPT_CURRENT: ${{ steps.current-shas.outputs.TYPESCRIPT_CURRENT }} - # PRETTIER_LATEST: ${{ steps.get-shas.outputs.PRETTIER_LATEST }} - # PRETTIER_CURRENT: ${{ steps.current-shas.outputs.PRETTIER_CURRENT }} ESTREE_CONFORMANCE_LATEST: ${{ steps.get-shas.outputs.ESTREE_CONFORMANCE_LATEST }} ESTREE_CONFORMANCE_CURRENT: ${{ steps.current-shas.outputs.ESTREE_CONFORMANCE_CURRENT }} NODE_COMPAT_TABLE_LATEST: ${{ steps.get-shas.outputs.NODE_COMPAT_TABLE_LATEST }} @@ -185,12 +179,6 @@ jobs: echo "TYPESCRIPT needs update: $TYPESCRIPT_CURRENT -> $TYPESCRIPT_LATEST" fi - # if [ "$PRETTIER_LATEST" != "$PRETTIER_CURRENT" ]; then - # updates_needed=true - # update_summary="${update_summary}- prettier: \`${PRETTIER_CURRENT:0:7}\` → \`${PRETTIER_LATEST:0:7}\`\n" - # echo "PRETTIER needs update: $PRETTIER_CURRENT -> $PRETTIER_LATEST" - # fi - if [ "$ESTREE_CONFORMANCE_LATEST" != "$ESTREE_CONFORMANCE_CURRENT" ]; then updates_needed=true update_summary="${update_summary}- estree-conformance: \`${ESTREE_CONFORMANCE_CURRENT:0:7}\` → \`${ESTREE_CONFORMANCE_LATEST:0:7}\`\n" @@ -220,13 +208,11 @@ jobs: TEST262_LATEST: ${{ steps.get-shas.outputs.TEST262_LATEST }} BABEL_LATEST: ${{ steps.get-shas.outputs.BABEL_LATEST }} TYPESCRIPT_LATEST: ${{ steps.get-shas.outputs.TYPESCRIPT_LATEST }} - # PRETTIER_LATEST: ${{ steps.get-shas.outputs.PRETTIER_LATEST }} ESTREE_CONFORMANCE_LATEST: ${{ steps.get-shas.outputs.ESTREE_CONFORMANCE_LATEST }} NODE_COMPAT_TABLE_LATEST: ${{ steps.get-shas.outputs.NODE_COMPAT_TABLE_LATEST }} TEST262_CURRENT: ${{ steps.current-shas.outputs.TEST262_CURRENT }} BABEL_CURRENT: ${{ steps.current-shas.outputs.BABEL_CURRENT }} TYPESCRIPT_CURRENT: ${{ steps.current-shas.outputs.TYPESCRIPT_CURRENT }} - # PRETTIER_CURRENT: ${{ steps.current-shas.outputs.PRETTIER_CURRENT }} ESTREE_CONFORMANCE_CURRENT: ${{ steps.current-shas.outputs.ESTREE_CONFORMANCE_CURRENT }} NODE_COMPAT_TABLE_CURRENT: ${{ steps.current-shas.outputs.NODE_COMPAT_TABLE_CURRENT }} run: | @@ -248,11 +234,6 @@ jobs: echo "Updated TYPESCRIPT_SHA" fi - # if [ "$PRETTIER_LATEST" != "$PRETTIER_CURRENT" ]; then - # sed -i "s/const PRETTIER_SHA = \"$PRETTIER_CURRENT\";/const PRETTIER_SHA = \"$PRETTIER_LATEST\";/g" "$js_script" - # echo "Updated PRETTIER_SHA" - # fi - if [ "$ESTREE_CONFORMANCE_LATEST" != "$ESTREE_CONFORMANCE_CURRENT" ]; then sed -i "s/const ESTREE_CONFORMANCE_SHA = \"$ESTREE_CONFORMANCE_CURRENT\";/const ESTREE_CONFORMANCE_SHA = \"$ESTREE_CONFORMANCE_LATEST\";/g" "$js_script" echo "Updated ESTREE_CONFORMANCE_SHA" diff --git a/.gitignore b/.gitignore index d7b1bb2995..c4deb05e03 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,7 @@ tasks/coverage/typescript/ tasks/coverage/estree-conformance/ tasks/coverage/estree-conformance-diff/ tasks/coverage/node-compat-table/ -tasks/prettier_conformance/prettier/ +crates/oxc_formatter_tests/prettier/ # Ignore accidental files from the root /*.js diff --git a/.ignore b/.ignore index 1a0fbe77b8..833af38a44 100644 --- a/.ignore +++ b/.ignore @@ -6,7 +6,7 @@ target/** tasks/coverage/test262/** tasks/coverage/babel/** tasks/coverage/typescript/** -tasks/prettier_conformance/prettier/** +crates/oxc_formatter_tests/prettier/** apps/**/dist **/*.snap diff --git a/.typos.toml b/.typos.toml index 852029fb42..96beef82b8 100644 --- a/.typos.toml +++ b/.typos.toml @@ -10,6 +10,7 @@ extend-exclude = [ "apps/oxlint/src-js/package/config.generated.ts", "apps/oxfmt/test/**/fixtures/**", "apps/oxfmt/conformance/snapshots/diffs/**/", + "crates/oxc_formatter_tests/prettier", "crates/oxc_linter/fixtures", "crates/oxc_react_compiler/fixtures", "crates/oxc_linter/src/rules/eslint/no_undefined.rs", @@ -29,7 +30,6 @@ extend-exclude = [ "tasks/coverage/typescript", "tasks/coverage/snapshots", "tasks/prettier_conformance/jsdoc", - "tasks/prettier_conformance/prettier", "tasks/prettier_conformance/snapshots", "tasks/transform_conformance/tests/babel-plugin-transform-react-jsx/test/fixtures/text/escapes/input.jsx", "tasks/transform_conformance/tests/**/output.js", diff --git a/.vscode/settings.json b/.vscode/settings.json index 7020821d59..758f129029 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,6 +20,7 @@ // Paths are workspace-root relative. Globs are not supported, so each is listed explicitly. "rust-analyzer.files.exclude": [ "apps/oxfmt/node_modules", + "crates/oxc_formatter_tests/prettier", "apps/oxlint/conformance/submodules", "apps/oxlint/node_modules", "apps/shared/node_modules", @@ -37,7 +38,6 @@ "tasks/coverage/babel", "tasks/coverage/typescript", "tasks/coverage/estree-conformance", - "tasks/prettier_conformance/prettier", "tasks/transform_conformance/node_modules" ] } diff --git a/AGENTS.md b/AGENTS.md index d5af4db0ef..864592aee4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -250,13 +250,14 @@ just test-transform --filter # Filter tests Git submodules managed via `just submodules`: -| Submodule | Description | Location | Used by Crates | -| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | -------------------------------------------------------- | -| `test262` | **ECMAScript Conformance Suite**
Official JavaScript test suite from TC39, testing compliance with the ECMAScript specification | `tasks/coverage/test262` | parser, semantic, codegen, transformer, minifier, estree | -| `babel` | **Babel Test Suite**
Comprehensive transformation and parsing tests from the Babel compiler, covering modern JavaScript features and edge cases | `tasks/coverage/babel` | parser, semantic, codegen, transformer, minifier | -| `typescript` | **TypeScript Test Suite**
Microsoft's TypeScript compiler tests, ensuring correct handling of TypeScript syntax and semantics | `tasks/coverage/typescript` | parser, semantic, codegen, transformer, estree | -| `prettier` | **Prettier Formatting Tests**
Prettier's comprehensive formatting test suite, ensuring code formatting matches industry standards | `tasks/prettier_conformance/prettier` | formatter (conformance) | -| `estree-conformance` | **ESTree Conformance Tests**
Test262, TypeScript, and acorn-jsx suites adapted for ESTree format validation, ensuring correct AST structure | `tasks/coverage/estree-conformance` | estree | +| Submodule | Description | Location | Used by Crates | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | -------------------------------------------------------- | +| `test262` | **ECMAScript Conformance Suite**
Official JavaScript test suite from TC39, testing compliance with the ECMAScript specification | `tasks/coverage/test262` | parser, semantic, codegen, transformer, minifier, estree | +| `babel` | **Babel Test Suite**
Comprehensive transformation and parsing tests from the Babel compiler, covering modern JavaScript features and edge cases | `tasks/coverage/babel` | parser, semantic, codegen, transformer, minifier | +| `typescript` | **TypeScript Test Suite**
Microsoft's TypeScript compiler tests, ensuring correct handling of TypeScript syntax and semantics | `tasks/coverage/typescript` | parser, semantic, codegen, transformer, estree | +| `estree-conformance` | **ESTree Conformance Tests**
Test262, TypeScript, and acorn-jsx suites adapted for ESTree format validation, ensuring correct AST structure | `tasks/coverage/estree-conformance` | estree | + +The **Prettier Formatting Tests** suite (`crates/oxc_formatter_tests/prettier`, used by formatter conformance) is NOT managed by `just submodules`: `oxc_formatter_tests` self-provisions it on demand, pinned by the `prettier` version in `apps/oxfmt/package.json` (the same Prettier oxfmt bundles as the oracle). **These suites provide:** @@ -274,7 +275,6 @@ These test suites are pre-cloned and ready to search: - **Test262** (`tasks/coverage/test262/`) - ECMAScript spec compliance - **Babel** (`tasks/coverage/babel/`) - Parsing and transformation edge cases - **TypeScript** (`tasks/coverage/typescript/`) - TypeScript syntax and semantics -- **Prettier** (`tasks/prettier_conformance/prettier/`) - Formatting expectations NOTE: These suites are script-cloned and fully gitignored. ripgrep respects `.gitignore`, so a plain `rg` inside them silently returns nothing. Use `rg --no-ignore` (or `-u`) when searching them. diff --git a/Cargo.lock b/Cargo.lock index 8a08f5f2e8..53599a0dec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2452,6 +2452,7 @@ dependencies = [ "oxc_formatter_css", "oxc_formatter_graphql", "oxc_formatter_json", + "oxc_formatter_tests", "oxc_formatter_yaml", "oxc_parser", "oxc_span", diff --git a/apps/oxfmt/conformance/download-fixtures.js b/apps/oxfmt/conformance/download-fixtures.js index 1e8a978c60..63f13ac4d2 100644 --- a/apps/oxfmt/conformance/download-fixtures.js +++ b/apps/oxfmt/conformance/download-fixtures.js @@ -1,7 +1,7 @@ // oxlint-disable no-console, no-await-in-loop import { exec } from "node:child_process"; -import { rmSync } from "node:fs"; +import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { promisify } from "node:util"; import pkg from "../package.json" with { type: "json" }; @@ -89,10 +89,20 @@ await Promise.all( [...sourcesByRepo.values()].map(async (group) => { for (const { name, repo, version } of group) { const dest = join(externalsDir, name); + + // Stamp-based skip (same scheme as `oxc_formatter_tests`' suite provisioning): + // the stamp is written last, so a half-downloaded tree is always re-done. + const stamp = join(dest, ".version"); + const pin = `${repo}#${version}`; + if (existsSync(stamp) && readFileSync(stamp, "utf8").trim() === pin) { + console.log(`Up-to-date: ${name}@${version}`); + continue; + } rmSync(dest, { recursive: true, force: true }); console.log(`Downloading ${name}@${version} fixtures...`); await execAsync(`pnpm exec degit ${repo}#${version} "${dest}"`, { cwd }); + writeFileSync(stamp, pin); console.log(`Done: ${name}@${version}`); } }), diff --git a/crates/oxc_formatter_core/FORMATTER_POLICY.md b/crates/oxc_formatter_core/FORMATTER_POLICY.md index 08255b7a2e..4be0a37009 100644 --- a/crates/oxc_formatter_core/FORMATTER_POLICY.md +++ b/crates/oxc_formatter_core/FORMATTER_POLICY.md @@ -30,7 +30,7 @@ Each crate's `AGENTS.md` holds only language-specific rules and the crate-local - Implementation strategies legitimately differ (e.g. Prettier pre-classifies comments per context; we decide on the spot with positional cursors) - Compatibility is judged on bytes out, not code shape - Before matching a mismatch, always consider whether it is a Prettier bug or artifact (see "Known divergences") -- The oracle version is the one pinned by the conformance submodule and `apps/oxfmt` bundles; fixtures are verified against it +- The oracle version is the `prettier` pinned in `apps/oxfmt/package.json`: the bundle, the conformance suite (via `oxc_formatter_tests`), and fixture verification all derive from that one version - The LATEST Prettier is still worth consulting as a forward-looking aid: whether a bug we diverged on has been fixed upstream, or a behavior is about to change - When the pin catches up to an upstream fix, converge and drop the divergence entry diff --git a/crates/oxc_formatter_tests/AGENTS.md b/crates/oxc_formatter_tests/AGENTS.md index b8b7ee90fe..6bcad40b03 100644 --- a/crates/oxc_formatter_tests/AGENTS.md +++ b/crates/oxc_formatter_tests/AGENTS.md @@ -4,6 +4,15 @@ Test infrastructure shared by the formatter crates (e.g. `oxc_formatter`, `oxc_f - `codegen`: build-script helper — consumers call `generate_tests` from `build.rs` via `[build-dependencies]` to emit one `#[test]` per fixture file - `harness`: fixture runtime — consumers implement `FixtureFormatter` in `tests/fixtures/mod.rs` via `[dev-dependencies]` +- `suite`: Prettier test-suite provisioning — `ensure_prettier_suite()` maintains the pinned suite at `prettier/` (gitignored), no separate clone step anywhere + +## Prettier suite + +The pin is the `prettier` version in `apps/oxfmt/package.json` — the same Prettier oxfmt bundles as the oracle, so suite and oracle cannot drift. Provisioning is degit-style: the release tarball from codeload, `tests/format/` extracted only (~40MB, no git objects), `.version` stamp written last. A warm suite is verified offline by reading the stamp; a stale one is wiped and re-extracted. Tarball extraction always yields LF content, so Windows needs no autocrlf care. CI only mounts `prettier/` as a cache volume — there is no clone step to keep in sync. + +- Bumping Prettier = bump `apps/oxfmt/package.json` + regenerate conformance snapshots (one change set; the suite re-provisions itself on the next run) +- Fixture-test callers treat provisioning `Err` (offline, no curl/tar) as skip, not failure; the conformance runner fails loudly +- Requires `curl` and `tar` on PATH (standard on macOS, Linux, and Windows 10+) ## Dependency rule diff --git a/crates/oxc_formatter_tests/src/lib.rs b/crates/oxc_formatter_tests/src/lib.rs index 4918b39c32..4e74743ccf 100644 --- a/crates/oxc_formatter_tests/src/lib.rs +++ b/crates/oxc_formatter_tests/src/lib.rs @@ -9,9 +9,11 @@ mod codegen; mod harness; +mod suite; pub use codegen::{GenerateConfig, generate_tests}; pub use harness::{ FixtureFormatter, FixtureSnapshot, OptionSet, apply_core_options, build_fixture_snapshot, format_options_display, resolve_options, }; +pub use suite::{ensure_prettier_suite, prettier_suite_root}; diff --git a/crates/oxc_formatter_tests/src/suite.rs b/crates/oxc_formatter_tests/src/suite.rs new file mode 100644 index 0000000000..8e3ab22a0a --- /dev/null +++ b/crates/oxc_formatter_tests/src/suite.rs @@ -0,0 +1,129 @@ +//! Prettier test-suite provisioning. +//! +//! The conformance tests compare output against the Prettier repository's own test suite (`tests/format`). +//! The pin is the `prettier` version in `apps/oxfmt/package.json`, +//! the same Prettier that oxfmt bundles as the oracle. +//! So the suite and the oracle cannot drift apart. +//! [`ensure_prettier_suite`] downloads the release tarball on demand (degit-style, no git objects, `tests/format` only), +//! so neither CI nor local runs need a separate clone step; +//! a warm checkout is verified offline. +//! +//! Bumping Prettier = +//! bumping `apps/oxfmt/package.json` + regenerating the conformance snapshots against it +//! (they must change together; the suite re-provisions itself). + +use std::{ + fs::{self, File}, + path::Path, + process::Command, + sync::OnceLock, +}; + +/// The version pin. Also serves as the cross-process provisioning lock. +const PACKAGE_JSON: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../apps/oxfmt/package.json"); + +/// Root of the extracted Prettier suite (gitignored). Contains `tests/format` +/// plus a `.version` stamp written after a successful extraction. +#[must_use] +pub fn prettier_suite_root() -> &'static Path { + Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/prettier")) +} + +/// Ensures the suite at [`prettier_suite_root`] matches the pinned Prettier version +/// and returns the root. Convergent: +/// +/// - `.version` stamp matches the pin: return immediately — no network, no subprocess +/// - missing / stale (version bumped): wipe, download the release tarball from +/// codeload, extract `tests/format`, stamp +/// +/// Cross-process exclusion (parallel test binaries, e.g. under nextest) uses an +/// advisory lock on the package.json handle; within a process the result is memoized. +/// +/// # Errors +/// Any download/extraction failure, as a display string. Fixture-test callers skip on +/// `Err` so offline `cargo test` runs stay green; the conformance runner fails +/// loudly instead. +pub fn ensure_prettier_suite() -> Result<&'static Path, String> { + static RESULT: OnceLock> = OnceLock::new(); + RESULT.get_or_init(provision).clone()?; + Ok(prettier_suite_root()) +} + +fn provision() -> Result<(), String> { + let root = prettier_suite_root(); + let stamp = root.join(".version"); + + // Advisory lock; released when the handle drops. Locking the pin file itself + // spares a separate (gitignore-managed) lockfile, and the handle doubles as the + // single source of the pinned version. + let pkg_json = File::open(PACKAGE_JSON).map_err(|e| format!("open {PACKAGE_JSON}: {e}"))?; + pkg_json.lock().map_err(|e| format!("lock {PACKAGE_JSON}: {e}"))?; + let version = prettier_version(&pkg_json)?; + + if fs::read_to_string(&stamp).is_ok_and(|s| s.trim() == version) { + return Ok(()); + } + + // Wipe-and-extract keeps this convergent; the stamp is written last, so a + // half-provisioned tree is always re-done. Only the CONTENTS are wiped: in CI + // the root is a cache-volume mount point, and removing it fails with EBUSY. + if root.exists() { + for entry in fs::read_dir(root).map_err(|e| format!("read {}: {e}", root.display()))? { + let path = entry.map_err(|e| format!("read {}: {e}", root.display()))?.path(); + if path.is_dir() { fs::remove_dir_all(&path) } else { fs::remove_file(&path) } + .map_err(|e| format!("remove {}: {e}", path.display()))?; + } + } else { + fs::create_dir_all(root).map_err(|e| format!("create {}: {e}", root.display()))?; + } + + let tarball = std::env::temp_dir().join(format!("oxc-prettier-{version}.tar.gz")); + let url = format!("https://codeload.github.com/prettier/prettier/tar.gz/refs/tags/{version}"); + run("curl", &["-fsSL", "-o", &tarball.to_string_lossy(), &url], root)?; + // Extract only what the conformance tests read; drop the `prettier-/` prefix. + run( + "tar", + &[ + "-xzf", + &tarball.to_string_lossy(), + "--strip-components=1", + &format!("prettier-{version}/tests/format"), + ], + root, + )?; + let _ = fs::remove_file(&tarball); + + fs::write(&stamp, &version).map_err(|e| format!("write {}: {e}", stamp.display()))?; + Ok(()) +} + +/// Reads `dependencies.prettier` from the (already-opened) oxfmt package.json. +fn prettier_version(pkg_json: &File) -> Result { + let json: serde_json::Value = + serde_json::from_reader(pkg_json).map_err(|e| format!("parse {PACKAGE_JSON}: {e}"))?; + let version = json["dependencies"]["prettier"] + .as_str() + .ok_or_else(|| format!("no dependencies.prettier in {PACKAGE_JSON}"))?; + // The tarball URL needs an exact tag; a semver range would mean the pin is gone. + if !version.chars().next().is_some_and(|c| c.is_ascii_digit()) { + return Err(format!("dependencies.prettier must be an exact version, got {version}")); + } + Ok(version.to_string()) +} + +fn run(program: &str, args: &[&str], cwd: &Path) -> Result<(), String> { + let output = Command::new(program) + .args(args) + .current_dir(cwd) + .output() + .map_err(|e| format!("spawn {program}: {e}"))?; + if output.status.success() { + Ok(()) + } else { + Err(format!( + "{program} {}: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + )) + } +} diff --git a/tasks/prettier_conformance/Cargo.toml b/tasks/prettier_conformance/Cargo.toml index b9d074bff9..cc3d981106 100644 --- a/tasks/prettier_conformance/Cargo.toml +++ b/tasks/prettier_conformance/Cargo.toml @@ -31,6 +31,7 @@ oxc_formatter_core = { workspace = true } oxc_formatter_css = { workspace = true } oxc_formatter_graphql = { workspace = true } oxc_formatter_json = { workspace = true } +oxc_formatter_tests = { workspace = true } oxc_formatter_yaml = { workspace = true } oxc_parser = { workspace = true } oxc_span = { workspace = true } diff --git a/tasks/prettier_conformance/src/lib.rs b/tasks/prettier_conformance/src/lib.rs index c9452b4f2e..72630f2b3d 100644 --- a/tasks/prettier_conformance/src/lib.rs +++ b/tasks/prettier_conformance/src/lib.rs @@ -50,7 +50,10 @@ fn root() -> PathBuf { } fn fixtures_root() -> PathBuf { - root().join("prettier").join("tests").join("format") + oxc_formatter_tests::ensure_prettier_suite() + .expect("failed to provision the Prettier suite") + .join("tests") + .join("format") } fn snap_root() -> PathBuf {