diff --git a/Cargo.lock b/Cargo.lock index 6b867479176d1..50126c7269c44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2538,6 +2538,7 @@ dependencies = [ "oxc_language_server", "oxc_parser", "oxc_span", + "phf", "rayon", "simdutf8", "tokio", diff --git a/apps/oxfmt/Cargo.toml b/apps/oxfmt/Cargo.toml index 2738316ec7e26..62c987529d865 100644 --- a/apps/oxfmt/Cargo.toml +++ b/apps/oxfmt/Cargo.toml @@ -38,6 +38,7 @@ bpaf = { workspace = true, features = ["autocomplete", "bright-color", "derive"] cow-utils = { workspace = true } ignore = { workspace = true, features = ["simd-accel"] } miette = { workspace = true } +phf = { workspace = true, features = ["macros"] } rayon = { workspace = true } simdutf8 = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "io-std", "macros"] } diff --git a/apps/oxfmt/src/cli/walk.rs b/apps/oxfmt/src/cli/walk.rs index b37611c8da923..0ee725990b4a0 100644 --- a/apps/oxfmt/src/cli/walk.rs +++ b/apps/oxfmt/src/cli/walk.rs @@ -222,13 +222,13 @@ impl ignore::ParallelVisitor for WalkVisitor { // Use `is_file()` to detect symlinks to the directory named `.js` #[expect(clippy::filetype_is_file)] if file_type.is_file() { - let path = entry.path(); // Determine this file should be handled or NOT // Tier 1 = `.js`, `.tsx`, etc: JS/TS files supported by `oxc_formatter` // Tier 2 = `.html`, `.json`, etc: Other files supported by Prettier // (Tier 3 = `.astro`, `.svelte`, etc: Other files supported by Prettier plugins) // Tier 4 = everything else: Not handled - let Ok(format_file_source) = FormatFileSource::try_from(path) else { + let Ok(format_file_source) = FormatFileSource::try_from(entry.into_path()) + else { return ignore::WalkState::Continue; }; diff --git a/apps/oxfmt/src/core/support.rs b/apps/oxfmt/src/core/support.rs index dea262d0fd951..c0bf79f3623bd 100644 --- a/apps/oxfmt/src/core/support.rs +++ b/apps/oxfmt/src/core/support.rs @@ -1,5 +1,7 @@ use std::path::{Path, PathBuf}; +use phf::phf_set; + use oxc_formatter::get_supported_source_type; use oxc_span::SourceType; @@ -8,30 +10,25 @@ pub enum FormatFileSource { path: PathBuf, source_type: SourceType, }, - #[expect(dead_code)] ExternalFormatter { path: PathBuf, - parser_name: String, + #[cfg_attr(not(feature = "napi"), expect(dead_code))] + parser_name: &'static str, }, } -impl TryFrom<&Path> for FormatFileSource { +impl TryFrom for FormatFileSource { type Error = (); - fn try_from(path: &Path) -> Result { + fn try_from(path: PathBuf) -> Result { // TODO: This logic should(can) move to this file, after LSP support is also moved here. - if let Some(source_type) = get_supported_source_type(path) { - return Ok(Self::OxcFormatter { path: path.to_path_buf(), source_type }); + if let Some(source_type) = get_supported_source_type(&path) { + return Ok(Self::OxcFormatter { path, source_type }); } - // TODO: Support more files with `ExternalFormatter` - // - JSON - // - HTML(include .vue) - // - CSS - // - GraphQL - // - Markdown - // - YAML - // - Handlebars + if let Some(parser_name) = get_external_parser_name(&path) { + return Ok(Self::ExternalFormatter { path, parser_name }); + } Err(()) } @@ -44,3 +41,206 @@ impl FormatFileSource { } } } + +// --- + +/// Returns the Prettier parser name for file at `path`, if supported. +/// See also `prettier --support-info | jq '.languages[]'` +/// NOTE: The order matters: more specific matches (like `package.json`) must come before generic ones. +fn get_external_parser_name(path: &Path) -> Option<&'static str> { + let file_name = path.file_name()?.to_str()?; + let extension = path.extension().and_then(|ext| ext.to_str()); + + // O(1) file name check goes first + if JSON_STRINGIFY_FILENAMES.contains(file_name) || extension == Some("importmap") { + return Some("json-stringify"); + } + if JSON_FILENAMES.contains(file_name) { + return Some("json"); + } + + // Then, check by extension + if (file_name.starts_with("tsconfig.") || file_name.starts_with("jsconfig.")) + && extension == Some("json") + { + return Some("jsonc"); + } + if let Some(ext) = extension + && JSON_EXTENSIONS.contains(ext) + { + return Some("json"); + } + if let Some(ext) = extension + && JSONC_EXTENSIONS.contains(ext) + { + return Some("jsonc"); + } + if extension == Some("json5") { + return Some("json5"); + } + + // TODO: Support more default supported file types + // { + // "extensions": [".html", ".hta", ".htm", ".html.hl", ".inc", ".xht", ".xhtml"], + // "name": "HTML", + // "parsers": ["html"] + // }, + // { + // "extensions": [".mjml"], + // "name": "MJML", + // "parsers": ["mjml"] + // }, + // { + // "extensions": [".component.html"], + // "name": "Angular", + // "parsers": ["angular"] + // }, + // { + // "extensions": [".vue"], + // "name": "Vue", + // "parsers": ["vue"] + // }, + // + // { + // "extensions": [".css", ".wxss"], + // "name": "CSS", + // "parsers": ["css"] + // }, + // { + // "extensions": [".less"], + // "name": "Less", + // "parsers": ["less"] + // }, + // { + // "extensions": [".pcss", ".postcss"], + // "group": "CSS", + // "name": "PostCSS", + // "parsers": ["css"] + // }, + // { + // "extensions": [".scss"], + // "name": "SCSS", + // "parsers": ["scss"] + // }, + // + // { + // "extensions": [".graphql", ".gql", ".graphqls"], + // "name": "GraphQL", + // "parsers": ["graphql"] + // }, + // + // { + // "extensions": [".handlebars", ".hbs"], + // "name": "Handlebars", + // "parsers": ["glimmer"] + // }, + // + // { + // "extensions": [".md", ".livemd", ".markdown", ".mdown", ".mdwn", ".mkd", ".mkdn", ".mkdown", ".ronn", ".scd", ".workbook"], + // "filenames": ["contents.lr", "README"], + // "name": "Markdown", + // "parsers": ["markdown"] + // }, + // { + // "extensions": [".mdx"], + // "name": "MDX", + // "parsers": ["mdx"] + // }, + + None +} + +static JSON_STRINGIFY_FILENAMES: phf::Set<&'static str> = phf_set! { + "package.json", + "package-lock.json", + "composer.json", +}; + +static JSON_EXTENSIONS: phf::Set<&'static str> = phf_set! { + "json", + "4DForm", + "4DProject", + "avsc", + "geojson", + "gltf", + "har", + "ice", + "JSON-tmLanguage", + "json.example", + "mcmeta", + "sarif", + "tact", + "tfstate", + "tfstate.backup", + "topojson", + "webapp", + "webmanifest", + "yy", + "yyp", +}; + +static JSON_FILENAMES: phf::Set<&'static str> = phf_set! { + ".all-contributorsrc", + ".arcconfig", + ".auto-changelog", + ".c8rc", + ".htmlhintrc", + ".imgbotconfig", + ".nycrc", + ".tern-config", + ".tern-project", + ".watchmanconfig", + ".babelrc", + ".jscsrc", + ".jshintrc", + ".jslintrc", + ".swcrc", +}; + +static JSONC_EXTENSIONS: phf::Set<&'static str> = phf_set! { + "jsonc", + "code-snippets", + "code-workspace", + "sublime-build", + "sublime-color-scheme", + "sublime-commands", + "sublime-completions", + "sublime-keymap", + "sublime-macro", + "sublime-menu", + "sublime-mousemap", + "sublime-project", + "sublime-settings", + "sublime-theme", + "sublime-workspace", + "sublime_metrics", + "sublime_session", +}; + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn test_get_external_parser_name() { + let test_cases = vec![ + ("package.json", Some("json-stringify")), + ("package-lock.json", Some("json-stringify")), + ("config.importmap", Some("json-stringify")), + ("tsconfig.json", Some("jsonc")), + ("jsconfig.dev.json", Some("jsonc")), + ("data.json", Some("json")), + ("schema.avsc", Some("json")), + ("config.code-workspace", Some("jsonc")), + ("unknown.txt", None), + ("prof.png", None), + ("foo", None), + ]; + + for (file_name, expected) in test_cases { + let result = get_external_parser_name(Path::new(file_name)); + assert_eq!(result, expected, "`{file_name}` should be parsed as {expected:?}"); + } + } +} diff --git a/apps/oxfmt/test/__snapshots__/config_file.test.ts.snap b/apps/oxfmt/test/__snapshots__/config_file.test.ts.snap index d78e5d5769dd1..5e914e94b388c 100644 --- a/apps/oxfmt/test/__snapshots__/config_file.test.ts.snap +++ b/apps/oxfmt/test/__snapshots__/config_file.test.ts.snap @@ -2,7 +2,7 @@ exports[`config_file > auto discovery > nested 1`] = ` "-------------------- -arguments: --check +arguments: --check !*.{json,jsonc} working directory: fixtures/config_file/nested exit code: 1 --- STDOUT --------- @@ -19,7 +19,7 @@ Finished in ms on 1 files using 1 threads. exports[`config_file > auto discovery > nested_deep 1`] = ` "-------------------- -arguments: --check +arguments: --check !*.{json,jsonc} working directory: fixtures/config_file/nested/deep exit code: 1 --- STDOUT --------- @@ -36,7 +36,7 @@ Finished in ms on 1 files using 1 threads. exports[`config_file > auto discovery > root 1`] = ` "-------------------- -arguments: --check +arguments: --check !*.{json,jsonc} working directory: fixtures/config_file exit code: 1 --- STDOUT --------- @@ -53,7 +53,7 @@ Finished in ms on 1 files using 1 threads. exports[`config_file > explicit config 1`] = ` "-------------------- -arguments: --check --config ./fmt.json +arguments: --check !*.{json,jsonc} --config ./fmt.json working directory: fixtures/config_file exit code: 1 --- STDOUT --------- @@ -67,7 +67,7 @@ Finished in ms on 1 files using 1 threads. -------------------- -------------------- -arguments: --check --config ./fmt.jsonc +arguments: --check !*.{json,jsonc} --config ./fmt.jsonc working directory: fixtures/config_file exit code: 1 --- STDOUT --------- diff --git a/apps/oxfmt/test/__snapshots__/config_ignore_patterns.test.ts.snap b/apps/oxfmt/test/__snapshots__/config_ignore_patterns.test.ts.snap index 562ed734a394a..e05989afd9f4b 100644 --- a/apps/oxfmt/test/__snapshots__/config_ignore_patterns.test.ts.snap +++ b/apps/oxfmt/test/__snapshots__/config_ignore_patterns.test.ts.snap @@ -4,21 +4,25 @@ exports[`config_ignore_patterns > should respect ignorePatterns in config 1`] = "-------------------- arguments: --check working directory: fixtures/config_ignore_patterns -exit code: 1 +exit code: 0 --- STDOUT --------- Checking formatting... +All matched files use the correct format. +Finished in ms on 2 files using 1 threads. --- STDERR --------- -Expected at least one target file + -------------------- -------------------- arguments: --check --config fmtrc.jsonc working directory: fixtures/config_ignore_patterns -exit code: 1 +exit code: 0 --- STDOUT --------- Checking formatting... +All matched files use the correct format. +Finished in ms on 2 files using 1 threads. --- STDERR --------- -Expected at least one target file + --------------------" `; diff --git a/apps/oxfmt/test/__snapshots__/external_formatter.test.ts.snap b/apps/oxfmt/test/__snapshots__/external_formatter.test.ts.snap new file mode 100644 index 0000000000000..9ec37bf618aa4 --- /dev/null +++ b/apps/oxfmt/test/__snapshots__/external_formatter.test.ts.snap @@ -0,0 +1,84 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`external_formatter > should format json by default 1`] = ` +"--- FILE ----------- +foo.json +--- BEFORE --------- +{"x":1} + +--- AFTER ---------- +{ "x": 1 } + +-------------------- + +--- FILE ----------- +package.json +--- BEFORE --------- +{ + "name": "fixture", + "version": "0.0.0", + "private": true, + "scripts": { + "build": "pnpm run build-napi-release && pnpm run build-js", + + "build-napi-release": "pnpm run build-napi --release --features allocator", + + "build-js": "node scripts/build.js", + + "test": "tsc && vitest --dir test run" + }, + "engines": { "node": "^20.19.0 || >=22.12.0" }, + "dependencies": { "prettier": "3.7.4" }, + "devDependencies": { + "@types/node": "catalog:", + "execa": "^9.6.0" + } +} + +--- AFTER ---------- +{ + "name": "fixture", + "version": "0.0.0", + "private": true, + "scripts": { + "build": "pnpm run build-napi-release && pnpm run build-js", + "build-napi-release": "pnpm run build-napi --release --features allocator", + "build-js": "node scripts/build.js", + "test": "tsc && vitest --dir test run" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "dependencies": { + "prettier": "3.7.4" + }, + "devDependencies": { + "@types/node": "catalog:", + "execa": "^9.6.0" + } +} + +-------------------- + +--- FILE ----------- +tsconfig.dummy.json +--- BEFORE --------- +{ + // Allow comments! + "compilerOptions": { + "module": "commonjs", + "strict": true, + }, +} + +--- AFTER ---------- +{ + // Allow comments! + "compilerOptions": { + "module": "commonjs", + "strict": true, + }, +} + +--------------------" +`; diff --git a/apps/oxfmt/test/config_file.test.ts b/apps/oxfmt/test/config_file.test.ts index 6185d4df23a2f..353aefadbb182 100644 --- a/apps/oxfmt/test/config_file.test.ts +++ b/apps/oxfmt/test/config_file.test.ts @@ -11,10 +11,9 @@ describe("config_file", () => { { name: "nested", cwd: join(fixturesDir, "config_file", "nested") }, { name: "nested_deep", cwd: join(fixturesDir, "config_file", "nested", "deep") }, ]; - for (const { name, cwd } of testCases) { // oxlint-disable no-await-in-loop - const snapshot = await runAndSnapshot(cwd, [["--check"]]); + const snapshot = await runAndSnapshot(cwd, [["--check", "!*.{json,jsonc}"]]); expect(snapshot).toMatchSnapshot(name); } }); @@ -22,8 +21,8 @@ describe("config_file", () => { it("explicit config", async () => { const cwd = join(fixturesDir, "config_file"); const testCases = [ - ["--check", "--config", "./fmt.json"], - ["--check", "--config", "./fmt.jsonc"], + ["--check", "!*.{json,jsonc}", "--config", "./fmt.json"], + ["--check", "!*.{json,jsonc}", "--config", "./fmt.jsonc"], ["--check", "--config", "NOT_EXISTS.json"], ]; diff --git a/apps/oxfmt/test/external_formatter.test.ts b/apps/oxfmt/test/external_formatter.test.ts new file mode 100644 index 0000000000000..158fef8b44af8 --- /dev/null +++ b/apps/oxfmt/test/external_formatter.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { join } from "node:path"; +import { runWriteModeAndSnapshot } from "./utils"; + +const fixturesDir = join(__dirname, "fixtures", "external_formatter"); + +describe("external_formatter", () => { + it("should format json by default", async () => { + const snapshot = await runWriteModeAndSnapshot(fixturesDir, [ + "foo.json", + "package.json", + "tsconfig.dummy.json", + ]); + expect(snapshot).toMatchSnapshot(); + }); +}); diff --git a/apps/oxfmt/test/fixtures/config_file/.oxfmtrc.jsonc b/apps/oxfmt/test/fixtures/config_file/.oxfmtrc.jsonc index cce9d3c080177..ddcc4cad06ad0 100644 --- a/apps/oxfmt/test/fixtures/config_file/.oxfmtrc.jsonc +++ b/apps/oxfmt/test/fixtures/config_file/.oxfmtrc.jsonc @@ -1,3 +1,3 @@ { - "semi": false + "semi": false, } diff --git a/apps/oxfmt/test/fixtures/config_file/fmt.jsonc b/apps/oxfmt/test/fixtures/config_file/fmt.jsonc index 094cc1415c770..9a77303b5dd29 100644 --- a/apps/oxfmt/test/fixtures/config_file/fmt.jsonc +++ b/apps/oxfmt/test/fixtures/config_file/fmt.jsonc @@ -1,4 +1,4 @@ { // Supports JSONC! - "semi": true + "semi": true, } diff --git a/apps/oxfmt/test/fixtures/config_file/nested/.oxfmtrc.jsonc b/apps/oxfmt/test/fixtures/config_file/nested/.oxfmtrc.jsonc index 732e2200080f7..a3f79db022204 100644 --- a/apps/oxfmt/test/fixtures/config_file/nested/.oxfmtrc.jsonc +++ b/apps/oxfmt/test/fixtures/config_file/nested/.oxfmtrc.jsonc @@ -1,3 +1,3 @@ { - "semi": true + "semi": true, } diff --git a/apps/oxfmt/test/fixtures/config_ignore_patterns/fmtrc.jsonc b/apps/oxfmt/test/fixtures/config_ignore_patterns/fmtrc.jsonc index a1edb22e84d19..4f863bd295b65 100644 --- a/apps/oxfmt/test/fixtures/config_ignore_patterns/fmtrc.jsonc +++ b/apps/oxfmt/test/fixtures/config_ignore_patterns/fmtrc.jsonc @@ -1,3 +1,3 @@ { - "ignorePatterns": ["*.js"], + "ignorePatterns": ["*.js"], } diff --git a/apps/oxfmt/test/fixtures/external_formatter/foo.json b/apps/oxfmt/test/fixtures/external_formatter/foo.json new file mode 100644 index 0000000000000..c0c1bfafb3231 --- /dev/null +++ b/apps/oxfmt/test/fixtures/external_formatter/foo.json @@ -0,0 +1 @@ +{"x":1} diff --git a/apps/oxfmt/test/fixtures/external_formatter/package.json b/apps/oxfmt/test/fixtures/external_formatter/package.json new file mode 100644 index 0000000000000..52161cdfd1f2a --- /dev/null +++ b/apps/oxfmt/test/fixtures/external_formatter/package.json @@ -0,0 +1,20 @@ +{ + "name": "fixture", + "version": "0.0.0", + "private": true, + "scripts": { + "build": "pnpm run build-napi-release && pnpm run build-js", + + "build-napi-release": "pnpm run build-napi --release --features allocator", + + "build-js": "node scripts/build.js", + + "test": "tsc && vitest --dir test run" + }, + "engines": { "node": "^20.19.0 || >=22.12.0" }, + "dependencies": { "prettier": "3.7.4" }, + "devDependencies": { + "@types/node": "catalog:", + "execa": "^9.6.0" + } +} diff --git a/apps/oxfmt/test/fixtures/external_formatter/tsconfig.dummy.json b/apps/oxfmt/test/fixtures/external_formatter/tsconfig.dummy.json new file mode 100644 index 0000000000000..2147309f399d1 --- /dev/null +++ b/apps/oxfmt/test/fixtures/external_formatter/tsconfig.dummy.json @@ -0,0 +1,7 @@ +{ + // Allow comments! + "compilerOptions": { + "module": "commonjs", + "strict": true, + }, +}