mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 10:29:58 +00:00
feat(oxfmt): add embedded language formatting with Prettier integration (#14820)
## Summary
Adds support for formatting embedded languages (CSS, GraphQL, HTML, Markdown) in JavaScript/TypeScript template literals using Prettier via NAPI bindings.
## Motivation
JavaScript/TypeScript developers often embed CSS, GraphQL queries, HTML, and Markdown in their code using tagged template literals. Without proper formatting support, these embedded languages remain unformatted, leading to inconsistent code style and reduced readability.
This PR enables oxfmt to format these embedded languages using Prettier, providing a seamless formatting experience similar to Prettier's own embedded language support.
## Changes
### Core Functionality
- **Embedded language detection**: Recognizes `css`, `styled`, `gql`, `graphql`, `html`, `md`, and `markdown` template tags
- **Prettier integration**: Uses `@prettier/sync` for synchronous formatting of embedded code
- **NAPI bridge**: Rust-to-JavaScript interop using NAPI ThreadsafeFunction
- **Graceful fallback**: Returns original code if formatting fails or tag is unsupported
### Implementation Details
**Rust side** (`crates/oxc_formatter/`, `apps/oxfmt/src/prettier_plugins/`):
- New `EmbeddedFormatter` trait with callback-based API
- NAPI bindings using `FnArgs` pattern for clean multi-argument calls
- Template formatter enhancement to detect and format tagged templates
- Proper indentation handling: splits Prettier output into lines, uses `hard_line_break()` separators, wraps in `indent()` for context-aware indentation
- Arena allocator pattern to avoid borrow checker conflicts
**JavaScript side** (`apps/oxfmt/src-js/`):
- TypeScript bindings with clean signatures: `formatEmbeddedCode(tagName: string, code: string)`
- Prettier configuration: 80 char width, 2 space indent, semicolons, double quotes
- Tag-to-parser mapping for supported languages
- Error handling with console logging and fallback
**Testing**:
- E2E test suite using Vitest (8 test cases)
- Test fixtures for each supported language
- Snapshot testing with timing normalization for stability
- All tests passing consistently
## Examples
**Before** (unformatted):
```js
const styles = css`.button { color: red; background: blue; }`;
```
**After** (formatted with proper indentation):
```js
const styles = css`
.button {
color: red;
background: blue;
}
`;
```
**Supported tags:**
```js
// CSS
const styles = css`...`;
const component = styled`...`;
// GraphQL
const query = gql`...`;
const schema = graphql`...`;
// HTML
const template = html`...`;
// Markdown
const docs = md`...`;
const readme = markdown`...`;
```
## Testing
```bash
# Run oxfmt tests
just oxfmt
# Watch mode for development
just watch-oxfmt
```
All 8 E2E tests pass:
- ✓ embedded_css
- ✓ embedded_graphql
- ✓ embedded_html
- ✓ embedded_markdown
- ✓ mixed_embedded
- ✓ no_embedded
- ✓ unsupported_tag
- ✓ check_mode
## Related
Related to #13427
## Future Work
Potential enhancements:
- Support for additional embedded languages (SQL, YAML, etc.)
- Custom Prettier configuration via oxfmt config
- Performance optimization for large embedded blocks
- Integration with language servers for inline diagnostics
This commit is contained in:
1 parent
2c15353822
commit
898d6fec12
31 files changed
+1144
-31
No files matched your search
@@ -25,6 +25,9 @@ rustflags = ["-C", "target-feature=+crt-static"]
|
||||
[target.aarch64-pc-windows-msvc]
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
|
||||
[target.'cfg(target_os = "linux")']
|
||||
rustflags = ["-C", "linker-features=-lld", "-C", "link-args=-Wl,--warn-unresolved-symbols"]
|
||||
|
||||
# To be able to run unit tests on Windows, support compilation to 'x86_64-pc-windows-msvc'.
|
||||
# Use Hybrid CRT to reduce the size of the binary (Coming by default with Windows 10 and later versions).
|
||||
[target.'cfg(target_os = "windows")']
|
||||
|
||||
@@ -38,34 +38,42 @@ jobs:
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
code-target: win32-x64
|
||||
build-oxfmt-args: ""
|
||||
|
||||
- os: windows-latest
|
||||
target: aarch64-pc-windows-msvc
|
||||
code-target: win32-arm64
|
||||
build-oxfmt-args: ""
|
||||
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
code-target: linux-x64-gnu
|
||||
build-oxfmt-args: --use-napi-cross
|
||||
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-gnu
|
||||
code-target: linux-arm64-gnu
|
||||
build-oxfmt-args: --use-napi-cross
|
||||
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
code-target: linux-x64-musl
|
||||
build-oxfmt-args: -x
|
||||
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
code-target: linux-arm64-musl
|
||||
build-oxfmt-args: -x
|
||||
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
code-target: darwin-x64
|
||||
build-oxfmt-args: ""
|
||||
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
code-target: darwin-arm64
|
||||
build-oxfmt-args: ""
|
||||
|
||||
name: Package ${{ matrix.code-target }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -89,28 +97,37 @@ jobs:
|
||||
- name: Add Rust Target
|
||||
run: rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Build
|
||||
- uses: oxc-project/setup-node@fdbf0dfd334c4e6d56ceeb77d91c76339c2a0885 # v1.0.4
|
||||
|
||||
- uses: goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406 # v2.2.1
|
||||
if: ${{ contains(matrix.target, 'musl') }}
|
||||
with:
|
||||
version: 0.13.0
|
||||
|
||||
- name: Build oxfmt
|
||||
working-directory: apps/oxfmt
|
||||
run: pnpm run build-napi-release --target ${{ matrix.target }} ${{ matrix.build-oxfmt-args }}
|
||||
shell: bash
|
||||
env:
|
||||
TARGET_CC: clang # for mimalloc
|
||||
run: |
|
||||
cross build --release -p oxfmt --bin oxfmt --features allocator --target=${{ matrix.target }}
|
||||
|
||||
# The binaries are zipped to fix permission loss https://github.com/actions/upload-artifact#permission-loss
|
||||
- name: Archive Binaries
|
||||
if: runner.os == 'Windows'
|
||||
# Windows `.node` files have `-msvc` postfix e.g. `oxfmt.win32-x64-msvc.node`. Remove the postfix.
|
||||
run: |
|
||||
OXFMT_BIN_NAME=oxfmt-${{ matrix.code-target }}
|
||||
mv target/${{ matrix.target }}/release/oxfmt.exe $OXFMT_BIN_NAME.exe
|
||||
7z a $OXFMT_BIN_NAME.zip $OXFMT_BIN_NAME.exe
|
||||
OXFMT_BIN_SRC_NAME=oxfmt.${{ matrix.code-target }}-msvc.node
|
||||
OXFMT_BIN_NAME=oxfmt.${{ matrix.code-target }}.node
|
||||
mv apps/oxfmt/src-js/$OXFMT_BIN_SRC_NAME $OXFMT_BIN_NAME
|
||||
7z a oxfmt-${{ matrix.code-target }}.zip $OXFMT_BIN_NAME
|
||||
|
||||
# The binaries are zipped to fix permission loss https://github.com/actions/upload-artifact#permission-loss
|
||||
- name: Archive Binaries
|
||||
if: runner.os != 'Windows'
|
||||
run: |
|
||||
OXFMT_BIN_NAME=oxfmt-${{ matrix.code-target }}
|
||||
mv target/${{ matrix.target }}/release/oxfmt $OXFMT_BIN_NAME
|
||||
tar czf $OXFMT_BIN_NAME.tar.gz $OXFMT_BIN_NAME
|
||||
OXFMT_BIN_NAME=oxfmt.${{ matrix.code-target }}.node
|
||||
mv apps/oxfmt/src-js/$OXFMT_BIN_NAME .
|
||||
tar czf oxfmt-${{ matrix.code-target }}.tar.gz $OXFMT_BIN_NAME
|
||||
|
||||
- name: Upload Binary
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
@@ -164,6 +181,10 @@ jobs:
|
||||
|
||||
- uses: oxc-project/setup-node@fdbf0dfd334c4e6d56ceeb77d91c76339c2a0885 # v1.0.4
|
||||
|
||||
- name: Generate oxfmt JS build
|
||||
working-directory: apps/oxfmt
|
||||
run: pnpm run build-js
|
||||
|
||||
- name: Generate npm packages
|
||||
run: |
|
||||
node npm/oxfmt/scripts/generate-packages.js
|
||||
|
||||
Generated
+5
@@ -1454,6 +1454,8 @@ dependencies = [
|
||||
"napi-sys",
|
||||
"nohash-hasher",
|
||||
"rustc-hash",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -2805,6 +2807,9 @@ dependencies = [
|
||||
"insta",
|
||||
"lazy-regex",
|
||||
"mimalloc-safe",
|
||||
"napi",
|
||||
"napi-build",
|
||||
"napi-derive",
|
||||
"oxc-miette",
|
||||
"oxc_allocator 0.96.0",
|
||||
"oxc_diagnostics 0.96.0",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/node_modules/
|
||||
/dist/
|
||||
*.node
|
||||
/src-js/bindings.js
|
||||
/src-js/bindings.d.ts
|
||||
/src-js/oxfmt
|
||||
@@ -16,7 +16,7 @@ description.workspace = true
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["lib"]
|
||||
crate-type = ["lib", "cdylib"]
|
||||
path = "src/lib.rs"
|
||||
doctest = false
|
||||
|
||||
@@ -40,6 +40,13 @@ miette = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = [] } # Omit the `regex` feature
|
||||
|
||||
# NAPI dependencies (conditional on napi feature)
|
||||
napi = { version = "3", features = ["napi9", "async", "serde-json"], optional = true }
|
||||
napi-derive = { version = "3", optional = true }
|
||||
|
||||
[build-dependencies]
|
||||
napi-build = "2.1.3"
|
||||
|
||||
[target.'cfg(not(any(target_os = "linux", target_os = "freebsd", target_arch = "arm", target_family = "wasm")))'.dependencies]
|
||||
mimalloc-safe = { workspace = true, optional = true, features = ["skip_collect_on_exit"] }
|
||||
|
||||
@@ -55,4 +62,5 @@ lazy-regex = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
napi = ["dep:napi", "dep:napi-derive"]
|
||||
allocator = ["dep:mimalloc-safe"]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
napi_build::setup();
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "oxfmt",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": "dist/cli.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "pnpm run build-napi-release && pnpm run build-js",
|
||||
"build-dev": "pnpm run build-napi && pnpm run build-js",
|
||||
"build-test": "pnpm run build-napi-test && pnpm run build-js",
|
||||
"build-napi": "napi build --esm --platform --js ./bindings.js --dts ./bindings.d.ts --output-dir src-js --no-dts-cache --features napi",
|
||||
"build-napi-test": "pnpm run build-napi",
|
||||
"build-napi-release": "pnpm run build-napi --release --features napi,allocator",
|
||||
"build-js": "node scripts/build.js",
|
||||
"test": "tsc && vitest --dir ./test run"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"description": "Formatter for the JavaScript Oxidation Compiler",
|
||||
"author": "Boshen and oxc contributors",
|
||||
"license": "MIT",
|
||||
"homepage": "https://oxc.rs",
|
||||
"bugs": "https://github.com/oxc-project/oxc/issues",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/oxc-project/oxc.git",
|
||||
"directory": "apps/oxfmt"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"prettier": "3.6.2",
|
||||
"@prettier/sync": "0.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"execa": "^9.6.0",
|
||||
"tsdown": "^0.15.5",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"napi": {
|
||||
"binaryName": "oxfmt",
|
||||
"packageName": "@oxfmt/binding",
|
||||
"targets": [
|
||||
"win32-x64",
|
||||
"win32-arm64",
|
||||
"linux-x64-gnu",
|
||||
"linux-arm64-gnu",
|
||||
"linux-x64-musl",
|
||||
"linux-arm64-musl",
|
||||
"darwin-x64",
|
||||
"darwin-arm64"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { copyFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const oxfmtDirPath = join(import.meta.dirname, '..'),
|
||||
distDirPath = join(oxfmtDirPath, 'dist');
|
||||
|
||||
// Build with tsdown
|
||||
console.log('Building with tsdown...');
|
||||
execSync('pnpm tsdown', { stdio: 'inherit', cwd: oxfmtDirPath });
|
||||
|
||||
// Copy native `.node` files from `src-js`
|
||||
console.log('Copying `.node` files...');
|
||||
|
||||
for (const filename of readdirSync(join(oxfmtDirPath, 'src-js'))) {
|
||||
if (!filename.endsWith('.node')) continue;
|
||||
copyFile(join(oxfmtDirPath, 'src-js', filename), join(distDirPath, filename));
|
||||
}
|
||||
|
||||
console.log('Build complete!');
|
||||
|
||||
/**
|
||||
* Copy a file, creating parent directories if needed.
|
||||
* @param {string} srcPath - Source file path, absolute
|
||||
* @param {string} destPath - Destination file path, absolute
|
||||
* @returns {void}
|
||||
*/
|
||||
function copyFile(srcPath, destPath) {
|
||||
mkdirSync(join(destPath, '..'), { recursive: true });
|
||||
copyFileSync(srcPath, destPath);
|
||||
console.log(`- Copied ${srcPath.split('/').pop()}`);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { format } from './bindings.js';
|
||||
import { formatEmbeddedCode } from './embedded.js';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
// Call the Rust formatter with our JS callback
|
||||
const success = await format(args, formatEmbeddedCode);
|
||||
|
||||
process.exit(success ? 0 : 1);
|
||||
@@ -0,0 +1,51 @@
|
||||
import prettier from '@prettier/sync';
|
||||
|
||||
// Map template tag names to Prettier parsers
|
||||
const TAG_TO_PARSER: Record<string, string> = {
|
||||
// CSS
|
||||
css: 'css',
|
||||
styled: 'css',
|
||||
|
||||
// GraphQL
|
||||
gql: 'graphql',
|
||||
graphql: 'graphql',
|
||||
|
||||
// HTML
|
||||
html: 'html',
|
||||
|
||||
// Markdown
|
||||
md: 'markdown',
|
||||
markdown: 'markdown',
|
||||
};
|
||||
|
||||
/**
|
||||
* Format embedded code using Prettier (synchronous).
|
||||
* Note: Called from Rust via NAPI ThreadsafeFunction with FnArgs
|
||||
* @param tagName - The template tag name (e.g., "css", "gql", "html")
|
||||
* @param code - The code to format
|
||||
* @returns Formatted code
|
||||
*/
|
||||
export function formatEmbeddedCode(tagName: string, code: string): string {
|
||||
const parser = TAG_TO_PARSER[tagName];
|
||||
|
||||
if (!parser) {
|
||||
// Unknown tag, return original code
|
||||
return code;
|
||||
}
|
||||
|
||||
try {
|
||||
const formatted = prettier.format(code, {
|
||||
parser,
|
||||
printWidth: 80,
|
||||
tabWidth: 2,
|
||||
semi: true,
|
||||
singleQuote: false,
|
||||
});
|
||||
|
||||
// Remove trailing newline that Prettier adds
|
||||
return formatted.trimEnd();
|
||||
} catch {
|
||||
// If Prettier fails to format, return original code
|
||||
return code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './bindings.js';
|
||||
export { formatEmbeddedCode } from './embedded.js';
|
||||
@@ -21,6 +21,8 @@ use crate::{
|
||||
pub struct FormatRunner {
|
||||
options: FormatCommand,
|
||||
cwd: PathBuf,
|
||||
#[cfg(feature = "napi")]
|
||||
external_formatter: Option<crate::prettier_plugins::ExternalFormatter>,
|
||||
}
|
||||
|
||||
impl FormatRunner {
|
||||
@@ -29,7 +31,12 @@ impl FormatRunner {
|
||||
/// # Panics
|
||||
/// Panics if the current working directory cannot be determined.
|
||||
pub fn new(options: FormatCommand) -> Self {
|
||||
Self { options, cwd: env::current_dir().expect("Failed to get current working directory") }
|
||||
Self {
|
||||
options,
|
||||
cwd: env::current_dir().expect("Failed to get current working directory"),
|
||||
#[cfg(feature = "napi")]
|
||||
external_formatter: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -38,6 +45,16 @@ impl FormatRunner {
|
||||
self
|
||||
}
|
||||
|
||||
#[cfg(feature = "napi")]
|
||||
#[must_use]
|
||||
pub fn with_external_formatter(
|
||||
mut self,
|
||||
external_formatter: Option<crate::prettier_plugins::ExternalFormatter>,
|
||||
) -> Self {
|
||||
self.external_formatter = external_formatter;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn run(self, stdout: &mut dyn Write) -> CliRunResult {
|
||||
let start_time = Instant::now();
|
||||
|
||||
@@ -100,9 +117,13 @@ impl FormatRunner {
|
||||
}
|
||||
|
||||
let output_options_clone = output_options.clone();
|
||||
#[cfg(feature = "napi")]
|
||||
let external_formatter_clone = self.external_formatter;
|
||||
// Spawn a thread to run formatting service with streaming entries
|
||||
rayon::spawn(move || {
|
||||
let format_service = FormatService::new(cwd, output_options_clone, format_options);
|
||||
#[cfg(feature = "napi")]
|
||||
let format_service = format_service.with_external_formatter(external_formatter_clone);
|
||||
format_service.run_streaming(rx_entry, &tx_error, tx_count);
|
||||
});
|
||||
|
||||
|
||||
+18
-10
@@ -13,15 +13,23 @@ pub mod cli {
|
||||
};
|
||||
}
|
||||
|
||||
use std::io::BufWriter;
|
||||
|
||||
use cli::{CliRunResult, FormatRunner, format_command};
|
||||
// Only include code to run formatter when the `napi` feature is enabled.
|
||||
#[cfg(feature = "napi")]
|
||||
mod prettier_plugins;
|
||||
#[cfg(feature = "napi")]
|
||||
mod run;
|
||||
#[cfg(feature = "napi")]
|
||||
pub use run::*;
|
||||
|
||||
#[cfg(all(feature = "allocator", not(miri), not(target_family = "wasm")))]
|
||||
#[global_allocator]
|
||||
static GLOBAL: mimalloc_safe::MiMalloc = mimalloc_safe::MiMalloc;
|
||||
|
||||
pub fn format() -> CliRunResult {
|
||||
use std::io::BufWriter;
|
||||
|
||||
use crate::cli::{CliRunResult, FormatRunner, format_command};
|
||||
|
||||
pub fn format_cli() -> CliRunResult {
|
||||
init_tracing();
|
||||
init_miette();
|
||||
|
||||
@@ -55,9 +63,14 @@ pub fn format() -> CliRunResult {
|
||||
FormatRunner::new(command).run(&mut stdout)
|
||||
}
|
||||
|
||||
/// Initialize the data which relies on `is_atty` system calls so they don't block subsequent threads.
|
||||
pub(crate) fn init_miette() {
|
||||
miette::set_hook(Box::new(|_| Box::new(miette::MietteHandlerOpts::new().build()))).unwrap();
|
||||
}
|
||||
|
||||
/// To debug `oxc_formatter`:
|
||||
/// `OXC_LOG=oxc_formatter oxfmt`
|
||||
fn init_tracing() {
|
||||
pub(crate) fn init_tracing() {
|
||||
use tracing_subscriber::{filter::Targets, prelude::*};
|
||||
|
||||
// Usage without the `regex` feature.
|
||||
@@ -73,8 +86,3 @@ fn init_tracing() {
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
}
|
||||
|
||||
// Initialize the data which relies on `is_atty` system calls so they don't block subsequent threads.
|
||||
fn init_miette() {
|
||||
miette::set_hook(Box::new(|_| Box::new(miette::MietteHandlerOpts::new().build()))).unwrap();
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use oxfmt::{cli::CliRunResult, format};
|
||||
use oxfmt::{cli::CliRunResult, format_cli};
|
||||
|
||||
fn main() -> CliRunResult {
|
||||
format()
|
||||
format_cli()
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use napi::{
|
||||
Status,
|
||||
bindgen_prelude::FnArgs,
|
||||
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
|
||||
};
|
||||
use oxc_formatter::EmbeddedFormatterCallback;
|
||||
|
||||
/// Type alias for the callback function signature.
|
||||
/// Takes (tag_name, code) as separate arguments and returns formatted code.
|
||||
pub type JsFormatEmbeddedCb = ThreadsafeFunction<
|
||||
// Input arguments
|
||||
FnArgs<(String, String)>, // (tag_name, code) as separate arguments
|
||||
// Return type (what JS function returns)
|
||||
String,
|
||||
// Arguments (repeated)
|
||||
FnArgs<(String, String)>,
|
||||
// Error status
|
||||
Status,
|
||||
// CalleeHandled
|
||||
false,
|
||||
>;
|
||||
|
||||
/// External formatter that wraps a JS callback.
|
||||
#[derive(Clone)]
|
||||
pub struct ExternalFormatter {
|
||||
pub format_embedded: EmbeddedFormatterCallback,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ExternalFormatter {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ExternalFormatter").field("format_embedded", &"<callback>").finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl ExternalFormatter {
|
||||
pub fn new(format_embedded: EmbeddedFormatterCallback) -> Self {
|
||||
Self { format_embedded }
|
||||
}
|
||||
|
||||
/// Convert this external formatter to the oxc_formatter::EmbeddedFormatter type
|
||||
pub fn to_embedded_formatter(&self) -> oxc_formatter::EmbeddedFormatter {
|
||||
let callback = Arc::clone(&self.format_embedded);
|
||||
// The callback already expects &str, so just use it directly
|
||||
oxc_formatter::EmbeddedFormatter::new(callback)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap JS `formatEmbeddedCode` callback as a normal Rust function.
|
||||
///
|
||||
/// Uses a channel to capture the result from the JS callback.
|
||||
pub fn wrap_format_embedded(cb: JsFormatEmbeddedCb) -> EmbeddedFormatterCallback {
|
||||
let cb = Arc::new(cb);
|
||||
Arc::new(move |tag_name: &str, code: &str| {
|
||||
let cb = Arc::clone(&cb);
|
||||
|
||||
// Use a channel to capture the result from the JS callback
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
let tag_name_str = tag_name.to_string();
|
||||
let code_str = code.to_string();
|
||||
|
||||
// Call the JS function with separate arguments
|
||||
let status = cb.call_with_return_value(
|
||||
FnArgs::from((tag_name_str.clone(), code_str)),
|
||||
ThreadsafeFunctionCallMode::Blocking,
|
||||
move |result: Result<String, napi::Error>, _env| {
|
||||
// Send the result through the channel
|
||||
let _ = tx.send(result);
|
||||
Ok(())
|
||||
},
|
||||
);
|
||||
|
||||
if status != napi::Status::Ok {
|
||||
return Err(format!(
|
||||
"Failed to call JS formatter for tag '{tag_name_str}': {status:?}"
|
||||
));
|
||||
}
|
||||
|
||||
// Wait for the result from the channel
|
||||
match rx.recv() {
|
||||
Ok(Ok(formatted)) => Ok(formatted),
|
||||
Ok(Err(e)) => Err(format!("JS formatter failed for tag '{tag_name_str}': {e}")),
|
||||
Err(_) => {
|
||||
Err(format!("Failed to receive result from JS formatter for tag '{tag_name_str}'"))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Create an [`ExternalFormatter`] from JS callbacks.
|
||||
pub fn create_external_formatter(format_embedded_cb: JsFormatEmbeddedCb) -> ExternalFormatter {
|
||||
let rust_format_embedded = wrap_format_embedded(format_embedded_cb);
|
||||
ExternalFormatter::new(rust_format_embedded)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mod external_formatter;
|
||||
|
||||
pub use external_formatter::{ExternalFormatter, JsFormatEmbeddedCb, create_external_formatter};
|
||||
@@ -0,0 +1,56 @@
|
||||
use std::{
|
||||
io::BufWriter,
|
||||
process::{ExitCode, Termination},
|
||||
};
|
||||
|
||||
use napi_derive::napi;
|
||||
|
||||
use crate::{
|
||||
cli::{CliRunResult, FormatRunner, format_command},
|
||||
prettier_plugins::{JsFormatEmbeddedCb, create_external_formatter},
|
||||
};
|
||||
|
||||
/// NAPI entry point.
|
||||
///
|
||||
/// JS side passes in:
|
||||
/// 1. `args`: Command line arguments (process.argv.slice(2))
|
||||
/// 2. `format_embedded_cb`: Callback to format embedded code in templates
|
||||
///
|
||||
/// Returns `true` if formatting succeeded without errors, `false` otherwise.
|
||||
#[expect(clippy::allow_attributes)]
|
||||
#[allow(clippy::trailing_empty_array, clippy::unused_async)] // https://github.com/napi-rs/napi-rs/issues/2758
|
||||
#[napi]
|
||||
pub async fn format(args: Vec<String>, format_embedded_cb: JsFormatEmbeddedCb) -> bool {
|
||||
format_impl(&args, format_embedded_cb).report() == ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
/// Run the formatter.
|
||||
fn format_impl(args: &[String], format_embedded_cb: JsFormatEmbeddedCb) -> CliRunResult {
|
||||
crate::init_tracing();
|
||||
crate::init_miette();
|
||||
|
||||
// Parse command line arguments
|
||||
let command = match format_command()
|
||||
.run_inner(args.iter().map(|s| s.as_ref() as &str).collect::<Vec<_>>().as_slice())
|
||||
{
|
||||
Ok(cmd) => cmd,
|
||||
Err(e) => {
|
||||
e.print_message(100);
|
||||
return if e.exit_code() == 0 {
|
||||
CliRunResult::None
|
||||
} else {
|
||||
CliRunResult::InvalidOptionConfig
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
command.handle_threads();
|
||||
|
||||
// Create external formatter from JS callback
|
||||
let external_formatter = create_external_formatter(format_embedded_cb);
|
||||
|
||||
// stdio is blocked by LineWriter, use a BufWriter to reduce syscalls.
|
||||
// See `https://github.com/rust-lang/rust/issues/60673`.
|
||||
let mut stdout = BufWriter::new(std::io::stdout());
|
||||
FormatRunner::new(command).with_external_formatter(Some(external_formatter)).run(&mut stdout)
|
||||
}
|
||||
@@ -14,6 +14,8 @@ pub struct FormatService {
|
||||
cwd: Box<Path>,
|
||||
output_options: OutputOptions,
|
||||
format_options: FormatOptions,
|
||||
#[cfg(feature = "napi")]
|
||||
external_formatter: Option<crate::prettier_plugins::ExternalFormatter>,
|
||||
}
|
||||
|
||||
impl FormatService {
|
||||
@@ -21,9 +23,29 @@ impl FormatService {
|
||||
where
|
||||
T: Into<Box<Path>>,
|
||||
{
|
||||
Self { cwd: cwd.into(), output_options, format_options }
|
||||
Self {
|
||||
cwd: cwd.into(),
|
||||
output_options,
|
||||
format_options,
|
||||
#[cfg(feature = "napi")]
|
||||
external_formatter: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "napi")]
|
||||
#[must_use]
|
||||
pub fn with_external_formatter(
|
||||
mut self,
|
||||
external_formatter: Option<crate::prettier_plugins::ExternalFormatter>,
|
||||
) -> Self {
|
||||
self.external_formatter = external_formatter;
|
||||
self
|
||||
}
|
||||
|
||||
// TODO: Support reading from stdin for formatting, similar to `prettier --stdin-filepath <path>`
|
||||
// This would allow formatting code from stdin and optionally specifying the file path for correct
|
||||
// syntax detection (e.g., `echo "const x=1" | oxfmt --stdin-filepath file.js`)
|
||||
|
||||
/// Process entries as they are received from the channel
|
||||
#[expect(clippy::needless_pass_by_value)]
|
||||
pub fn run_streaming(
|
||||
@@ -66,7 +88,22 @@ impl FormatService {
|
||||
return;
|
||||
}
|
||||
|
||||
let code = Formatter::new(&allocator, self.format_options.clone()).build(&ret.program);
|
||||
let embedded_formatter = {
|
||||
#[cfg(feature = "napi")]
|
||||
{
|
||||
self.external_formatter
|
||||
.as_ref()
|
||||
.map(crate::prettier_plugins::ExternalFormatter::to_embedded_formatter)
|
||||
}
|
||||
#[cfg(not(feature = "napi"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let code = Formatter::new(&allocator, self.format_options.clone())
|
||||
.with_embedded_formatter(embedded_formatter)
|
||||
.build(&ret.program);
|
||||
|
||||
let elapsed = start_time.elapsed();
|
||||
let is_changed = source_text != code;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import { join as pathJoin } from 'node:path';
|
||||
import { describe, it } from 'vitest';
|
||||
import { PACKAGE_ROOT_PATH, testFixtureWithCommand } from './utils.js';
|
||||
|
||||
const CLI_PATH = pathJoin(PACKAGE_ROOT_PATH, 'dist/cli.js');
|
||||
|
||||
// Options to pass to `testFixture`.
|
||||
interface TestOptions {
|
||||
// Arguments to pass to the CLI.
|
||||
args?: string[];
|
||||
// Name of the snapshot file.
|
||||
// Defaults to `output`.
|
||||
// Supply a different name when there are multiple tests for a single fixture.
|
||||
snapshotName?: string;
|
||||
// Function to get extra data to include in the snapshot
|
||||
getExtraSnapshotData?: (dirPath: string) => Promise<{ [key: string]: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a test fixture.
|
||||
* @param fixtureName - Name of the fixture directory within `test/fixtures`
|
||||
* @param options - Options to customize the test (optional)
|
||||
*/
|
||||
async function testFixture(fixtureName: string, options?: TestOptions): Promise<void> {
|
||||
const args = options?.args ?? [];
|
||||
|
||||
await testFixtureWithCommand({
|
||||
// Use current NodeJS executable, rather than `node`, to avoid problems with a Node version manager
|
||||
// installed on system resulting in using wrong NodeJS version
|
||||
command: process.execPath,
|
||||
args: [CLI_PATH, ...args, 'files'],
|
||||
fixtureName,
|
||||
snapshotName: options?.snapshotName ?? 'output',
|
||||
getExtraSnapshotData: options?.getExtraSnapshotData,
|
||||
});
|
||||
}
|
||||
|
||||
describe('oxfmt NAPI - Embedded Language Formatting', () => {
|
||||
// oxlint-disable-next-line vitest/expect-expect -- Assertion is inside testFixtureWithCommand helper
|
||||
it('should format embedded languages (CSS, GraphQL, HTML, Markdown)', async () => {
|
||||
await testFixture('embedded_languages', {
|
||||
getExtraSnapshotData: async (dirPath) => {
|
||||
const formattedCode = await fs.readFile(pathJoin(dirPath, 'files/index.js'), 'utf-8');
|
||||
return { 'Formatted Output': formattedCode };
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
// ============================================================================
|
||||
// CSS - Tagged template literals with css and styled tags
|
||||
// ============================================================================
|
||||
|
||||
const styles = css`.button{color:red;background:blue;padding:10px 20px;}.container{display:flex;justify-content:center;}`;
|
||||
|
||||
const styledComponent = styled`background-color:#ffffff;border-radius:4px;padding:8px;`;
|
||||
|
||||
// ============================================================================
|
||||
// GraphQL - Tagged template literals with gql and graphql tags
|
||||
// ============================================================================
|
||||
|
||||
const query = gql`query GetUser($id:ID!){user(id:$id){name email posts{title}}}`;
|
||||
|
||||
const mutation = graphql`mutation CreatePost($input:PostInput!){createPost(input:$input){id title}}`;
|
||||
|
||||
// ============================================================================
|
||||
// HTML - Tagged template literals with html tag
|
||||
// ============================================================================
|
||||
|
||||
const template = html`<div class="container"><h1>Hello World</h1><p>This is a paragraph with <strong>bold</strong> text.</p></div>`;
|
||||
|
||||
const component = html`<button type="button" onclick="handleClick()">Click me</button>`;
|
||||
|
||||
// ============================================================================
|
||||
// Markdown - Tagged template literals with md and markdown tags
|
||||
// ============================================================================
|
||||
|
||||
const documentation = md`#Heading
|
||||
This is **bold** and this is _italic_.
|
||||
-Item 1
|
||||
-Item 2`;
|
||||
|
||||
const readme = markdown`##Installation
|
||||
\`\`\`bash
|
||||
npm install package
|
||||
\`\`\``;
|
||||
|
||||
// ============================================================================
|
||||
// Mixed - Multiple embedded languages in one file
|
||||
// ============================================================================
|
||||
|
||||
const mixedStyles = css`.button{color:red;}`;
|
||||
|
||||
const mixedQuery = gql`query{users{name}}`;
|
||||
|
||||
const mixedTemplate = html`<div><h1>Title</h1></div>`;
|
||||
|
||||
const mixedDocs = md`#Documentation
|
||||
This is **important**.`;
|
||||
|
||||
// ============================================================================
|
||||
// No Embedded Languages - Regular JavaScript (no tagged templates)
|
||||
// ============================================================================
|
||||
|
||||
function greet(name) {
|
||||
return `Hello, ${name}!`;
|
||||
}
|
||||
|
||||
const message = `This is a regular template string`;
|
||||
|
||||
class Formatter {
|
||||
format(text) {
|
||||
return text.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// prettier-ignore - Skip formatting with prettier-ignore comments
|
||||
// ============================================================================
|
||||
|
||||
// prettier-ignore
|
||||
const unformatted = css`.button{color:red;background:blue;border:1px solid green;}`;
|
||||
|
||||
const formattedCss = css`.container{display:flex;align-items:center;}`;
|
||||
|
||||
// prettier-ignore
|
||||
const ignoredGql = gql`query GetUser($id:ID!){user(id:$id){name email}}`;
|
||||
|
||||
const normalGql = gql`query GetPosts{posts{title author}}`;
|
||||
|
||||
// ============================================================================
|
||||
// Unsupported Tags - Tags not recognized by the formatter
|
||||
// ============================================================================
|
||||
|
||||
const unknown = customTag`
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
This is some content that won't be formatted
|
||||
because customTag is not recognized.
|
||||
`;
|
||||
|
||||
const sql = sql`
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
SELECT * FROM users WHERE id = 1
|
||||
`;
|
||||
@@ -0,0 +1,200 @@
|
||||
# Exit code
|
||||
0
|
||||
|
||||
# stdout
|
||||
```
|
||||
files/index.js Xms
|
||||
|
||||
Formatted 1 files.
|
||||
Finished in Xms on 1 files using X threads.
|
||||
```
|
||||
|
||||
# stderr
|
||||
```
|
||||
```
|
||||
|
||||
# Formatted Output
|
||||
```
|
||||
// ============================================================================
|
||||
// CSS - Tagged template literals with css and styled tags
|
||||
// ============================================================================
|
||||
|
||||
const styles = css`
|
||||
.button {
|
||||
color: red;
|
||||
background: blue;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
`;
|
||||
|
||||
const styledComponent = styled`
|
||||
background-color: #ffffff;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
`;
|
||||
|
||||
// ============================================================================
|
||||
// GraphQL - Tagged template literals with gql and graphql tags
|
||||
// ============================================================================
|
||||
|
||||
const query = gql`
|
||||
query GetUser($id: ID!) {
|
||||
user(id: $id) {
|
||||
name
|
||||
email
|
||||
posts {
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const mutation = graphql`
|
||||
mutation CreatePost($input: PostInput!) {
|
||||
createPost(input: $input) {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ============================================================================
|
||||
// HTML - Tagged template literals with html tag
|
||||
// ============================================================================
|
||||
|
||||
const template = html`
|
||||
<div class="container">
|
||||
<h1>Hello World</h1>
|
||||
<p>This is a paragraph with <strong>bold</strong> text.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const component = html`
|
||||
<button type="button" onclick="handleClick()">Click me</button>
|
||||
`;
|
||||
|
||||
// ============================================================================
|
||||
// Markdown - Tagged template literals with md and markdown tags
|
||||
// ============================================================================
|
||||
|
||||
const documentation = md`
|
||||
#Heading
|
||||
This is **bold** and this is _italic_.
|
||||
-Item 1
|
||||
-Item 2
|
||||
`;
|
||||
|
||||
const readme = markdown`
|
||||
##Installation
|
||||
\`\`\`bash
|
||||
npm install package
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
// ============================================================================
|
||||
// Mixed - Multiple embedded languages in one file
|
||||
// ============================================================================
|
||||
|
||||
const mixedStyles = css`
|
||||
.button {
|
||||
color: red;
|
||||
}
|
||||
`;
|
||||
|
||||
const mixedQuery = gql`
|
||||
query {
|
||||
users {
|
||||
name
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const mixedTemplate = html`
|
||||
<div><h1>Title</h1></div>
|
||||
`;
|
||||
|
||||
const mixedDocs = md`
|
||||
#Documentation
|
||||
This is **important**.
|
||||
`;
|
||||
|
||||
// ============================================================================
|
||||
// No Embedded Languages - Regular JavaScript (no tagged templates)
|
||||
// ============================================================================
|
||||
|
||||
function greet(name) {
|
||||
return `Hello, ${name}!`;
|
||||
}
|
||||
|
||||
const message = `This is a regular template string`;
|
||||
|
||||
class Formatter {
|
||||
format(text) {
|
||||
return text.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// prettier-ignore - Skip formatting with prettier-ignore comments
|
||||
// ============================================================================
|
||||
|
||||
// prettier-ignore
|
||||
const unformatted = css`.button{color:red;background:blue;border:1px solid green;}`;
|
||||
|
||||
const formattedCss = css`
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
|
||||
// prettier-ignore
|
||||
const ignoredGql = gql`query GetUser($id:ID!){user(id:$id){name email}}`;
|
||||
|
||||
const normalGql = gql`
|
||||
query GetPosts {
|
||||
posts {
|
||||
title
|
||||
author
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
// ============================================================================
|
||||
// Unsupported Tags - Tags not recognized by the formatter
|
||||
// ============================================================================
|
||||
|
||||
const unknown = customTag`
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
This is some content that won't be formatted
|
||||
because customTag is not recognized.
|
||||
`;
|
||||
|
||||
const sql = sql`
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
SELECT * FROM users WHERE id = 1
|
||||
`;
|
||||
|
||||
```
|
||||
@@ -0,0 +1,114 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import { join as pathJoin } from 'node:path';
|
||||
|
||||
import { execa } from 'execa';
|
||||
import { expect } from 'vitest';
|
||||
|
||||
export const PACKAGE_ROOT_PATH = pathJoin(import.meta.dirname, '..');
|
||||
export const FIXTURES_DIR_PATH = pathJoin(import.meta.dirname, 'fixtures');
|
||||
|
||||
const REPO_ROOT_PATH = pathJoin(PACKAGE_ROOT_PATH, '../../');
|
||||
|
||||
// Options to pass to `testFixtureWithCommand`.
|
||||
interface TestFixtureOptions {
|
||||
// Command
|
||||
command: string;
|
||||
// Arguments to execute command with
|
||||
args: string[];
|
||||
// Fixture name
|
||||
fixtureName: string;
|
||||
// Name of the snapshot file
|
||||
snapshotName: string;
|
||||
// Function to get extra data to include in the snapshot
|
||||
getExtraSnapshotData?: (dirPath: string) => Promise<{ [key: string]: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a test fixture.
|
||||
* @param options - Options for running the test
|
||||
*/
|
||||
export async function testFixtureWithCommand(options: TestFixtureOptions): Promise<void> {
|
||||
const fixtureDirPath = pathJoin(FIXTURES_DIR_PATH, options.fixtureName);
|
||||
const sourceFilesDir = pathJoin(fixtureDirPath, 'files');
|
||||
|
||||
// Create a temporary directory to avoid modifying the original fixture files
|
||||
const tempDir = await fs.mkdtemp(pathJoin(os.tmpdir(), 'oxfmt-test-'));
|
||||
const tempFilesDir = pathJoin(tempDir, 'files');
|
||||
|
||||
try {
|
||||
// TODO: use stdin instead of copy to tmp dir.
|
||||
|
||||
// Copy fixture files to temp directory
|
||||
await fs.cp(sourceFilesDir, tempFilesDir, { recursive: true });
|
||||
|
||||
let { stdout, stderr, exitCode } = await execa(options.command, options.args, {
|
||||
cwd: tempDir,
|
||||
reject: false,
|
||||
});
|
||||
|
||||
const snapshotPath = pathJoin(fixtureDirPath, `${options.snapshotName}.snap.md`);
|
||||
|
||||
stdout = normalizeStdout(stdout);
|
||||
stderr = normalizeStdout(stderr);
|
||||
let snapshot =
|
||||
`# Exit code\n${exitCode}\n\n` + `# stdout\n\`\`\`\n${stdout}\`\`\`\n\n` + `# stderr\n\`\`\`\n${stderr}\`\`\`\n`;
|
||||
|
||||
if (options.getExtraSnapshotData) {
|
||||
const extraSnapshots = await options.getExtraSnapshotData(tempDir);
|
||||
for (const [name, data] of Object.entries(extraSnapshots)) {
|
||||
snapshot += `\n# ${name}\n\`\`\`\n${data}\n\`\`\`\n`;
|
||||
}
|
||||
}
|
||||
|
||||
await expect(snapshot).toMatchFileSnapshot(snapshotPath);
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize output, so it's the same on every machine.
|
||||
*
|
||||
* - Remove timing + thread count info.
|
||||
* - Replace start of file paths with `<root>`.
|
||||
* - Normalize line breaks.
|
||||
*
|
||||
* @param stdout Output from process
|
||||
* @returns Normalized output
|
||||
*/
|
||||
function normalizeStdout(stdout: string): string {
|
||||
// Normalize line breaks, and trim line breaks from start and end
|
||||
stdout = stdout.replace(/\r\n?/g, '\n').replace(/^\n+/, '').replace(/\n+$/, '');
|
||||
if (stdout === '') return '';
|
||||
|
||||
let lines = stdout.split('\n');
|
||||
|
||||
// Remove timing and thread count info which can vary between runs
|
||||
// Match patterns like "Finished in 123ms on 5 files using 8 threads."
|
||||
// and per-file timings like "files/index.js 23ms (unchanged)" or "files/index.js (23ms)"
|
||||
lines = lines.map((line) => {
|
||||
// Normalize the summary line
|
||||
line = line.replace(
|
||||
/^Finished in \d+(?:\.\d+)?(?:s|ms|us|ns) on (\d+) file(s?) using \d+ threads\.$/,
|
||||
'Finished in Xms on $1 file$2 using X threads.',
|
||||
);
|
||||
// Normalize per-file timing: "files/index.js 23ms" -> "files/index.js Xms"
|
||||
line = line.replace(/^(.*?)\s+\d+(?:\.\d+)?(?:s|ms|us|ns)(\s|$)/, '$1 Xms$2');
|
||||
// Normalize check mode timing: "files/index.js (23ms)" -> "files/index.js (Xms)"
|
||||
line = line.replace(/\((\d+(?:\.\d+)?(?:s|ms|us|ns))\)/g, '(Xms)');
|
||||
return line;
|
||||
});
|
||||
|
||||
// Replace absolute paths with <root>
|
||||
lines = lines.map((line) => {
|
||||
if (line.startsWith(REPO_ROOT_PATH)) {
|
||||
return `<root>/${line.slice(REPO_ROOT_PATH.length).replace(/\\/g, '/')}`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
|
||||
if (lines.length === 0) return '';
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "Preserve",
|
||||
"moduleResolution": "Bundler",
|
||||
"noEmit": true,
|
||||
"target": "ESNext",
|
||||
"noImplicitAny": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": [
|
||||
"src-js"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src-js/index.ts', 'src-js/cli.ts'],
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
target: 'node20',
|
||||
dts: true,
|
||||
clean: true,
|
||||
outDir: 'dist',
|
||||
shims: false,
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { configDefaults, defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
exclude: [...configDefaults.exclude, 'fixtures/**', 'test/fixtures/**'],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Callback function type for formatting embedded code.
|
||||
/// Takes (tag_name, code) and returns formatted code or an error.
|
||||
pub type EmbeddedFormatterCallback =
|
||||
Arc<dyn Fn(&str, &str) -> Result<String, String> + Send + Sync>;
|
||||
|
||||
/// Formatter for embedded languages in template literals.
|
||||
///
|
||||
/// This allows formatting code embedded in template literals like:
|
||||
/// - CSS in `css\`...\``
|
||||
/// - GraphQL in `gql\`...\``
|
||||
/// - HTML in `html\`...\``
|
||||
#[derive(Clone)]
|
||||
pub struct EmbeddedFormatter {
|
||||
callback: EmbeddedFormatterCallback,
|
||||
}
|
||||
|
||||
/// See <apps/oxfmt/src-js/embedded.ts> for supported tags.
|
||||
const SUPPORTED_TAGS: &[&str] = &["css", "styled", "gql", "graphql", "html", "md", "markdown"];
|
||||
|
||||
impl EmbeddedFormatter {
|
||||
/// Create a new embedded formatter with the given callback.
|
||||
pub fn new(callback: EmbeddedFormatterCallback) -> Self {
|
||||
Self { callback }
|
||||
}
|
||||
|
||||
/// Check if the given tag name is supported for embedded formatting.
|
||||
pub fn is_supported_tag(tag_name: &str) -> bool {
|
||||
SUPPORTED_TAGS.contains(&tag_name)
|
||||
}
|
||||
|
||||
/// Format embedded code with the given tag name.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `tag_name` - The template tag (e.g., "css", "gql", "html")
|
||||
/// * `code` - The code to format
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(String)` - The formatted code
|
||||
/// * `Err(String)` - An error message if formatting failed
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the embedded formatter fails to format the code
|
||||
pub fn format(&self, tag_name: &str, code: &str) -> Result<String, String> {
|
||||
(self.callback)(tag_name, code)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,10 @@ use oxc_ast::{
|
||||
use oxc_span::{GetSpan, SourceType, Span};
|
||||
use rustc_hash::FxHashMap;
|
||||
|
||||
use crate::{ast_nodes::AstNode, formatter::FormatElement, options::FormatOptions};
|
||||
use crate::{
|
||||
ast_nodes::AstNode, embedded_formatter::EmbeddedFormatter, formatter::FormatElement,
|
||||
options::FormatOptions,
|
||||
};
|
||||
|
||||
use super::{Comments, SourceText};
|
||||
|
||||
@@ -26,6 +29,8 @@ pub struct FormatContext<'ast> {
|
||||
cached_elements: FxHashMap<Span, FormatElement<'ast>>,
|
||||
|
||||
allocator: &'ast Allocator,
|
||||
|
||||
embedded_formatter: Option<EmbeddedFormatter>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FormatContext<'_> {
|
||||
@@ -56,9 +61,20 @@ impl<'ast> FormatContext<'ast> {
|
||||
comments: Comments::new(source_text, comments),
|
||||
allocator,
|
||||
cached_elements: FxHashMap::default(),
|
||||
embedded_formatter: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the embedded formatter for handling embedded languages
|
||||
pub fn set_embedded_formatter(&mut self, embedded_formatter: Option<EmbeddedFormatter>) {
|
||||
self.embedded_formatter = embedded_formatter;
|
||||
}
|
||||
|
||||
/// Get the embedded formatter if one is set
|
||||
pub fn embedded_formatter(&self) -> Option<&EmbeddedFormatter> {
|
||||
self.embedded_formatter.as_ref()
|
||||
}
|
||||
|
||||
/// Returns the formatting options
|
||||
pub fn options(&self) -> &FormatOptions {
|
||||
&self.options
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
mod ast_nodes;
|
||||
#[cfg(feature = "detect_code_removal")]
|
||||
mod detect_code_removal;
|
||||
mod embedded_formatter;
|
||||
mod formatter;
|
||||
mod ir_transform;
|
||||
mod options;
|
||||
@@ -33,6 +34,7 @@ use oxc_ast::{AstKind, ast::*};
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
use write::FormatWrite;
|
||||
|
||||
pub use crate::embedded_formatter::{EmbeddedFormatter, EmbeddedFormatterCallback};
|
||||
pub use crate::options::*;
|
||||
pub use crate::service::{oxfmtrc::Oxfmtrc, parse_utils::*};
|
||||
use crate::{
|
||||
@@ -47,11 +49,22 @@ pub struct Formatter<'a> {
|
||||
allocator: &'a Allocator,
|
||||
source_text: &'a str,
|
||||
options: FormatOptions,
|
||||
embedded_formatter: Option<EmbeddedFormatter>,
|
||||
}
|
||||
|
||||
impl<'a> Formatter<'a> {
|
||||
pub fn new(allocator: &'a Allocator, options: FormatOptions) -> Self {
|
||||
Self { allocator, source_text: "", options }
|
||||
Self { allocator, source_text: "", options, embedded_formatter: None }
|
||||
}
|
||||
|
||||
/// Set the embedded formatter for handling embedded languages in templates
|
||||
#[must_use]
|
||||
pub fn with_embedded_formatter(
|
||||
mut self,
|
||||
embedded_formatter: Option<EmbeddedFormatter>,
|
||||
) -> Self {
|
||||
self.embedded_formatter = embedded_formatter;
|
||||
self
|
||||
}
|
||||
|
||||
/// Formats the given AST `Program` and returns the formatted string.
|
||||
@@ -69,13 +82,15 @@ impl<'a> Formatter<'a> {
|
||||
|
||||
let experimental_sort_imports = self.options.experimental_sort_imports;
|
||||
|
||||
let context = FormatContext::new(
|
||||
let mut context = FormatContext::new(
|
||||
program.source_text,
|
||||
program.source_type,
|
||||
&program.comments,
|
||||
self.allocator,
|
||||
self.options,
|
||||
);
|
||||
context.set_embedded_formatter(self.embedded_formatter);
|
||||
|
||||
let mut formatted = formatter::format(
|
||||
context,
|
||||
formatter::Arguments::new(&[formatter::Argument::new(&program_node)]),
|
||||
|
||||
@@ -8,7 +8,7 @@ use oxc_span::{GetSpan, Span};
|
||||
use oxc_syntax::identifier::is_line_terminator;
|
||||
|
||||
use crate::{
|
||||
IndentWidth,
|
||||
EmbeddedFormatter, IndentWidth,
|
||||
ast_nodes::{AstNode, AstNodeIterator},
|
||||
format, format_args,
|
||||
formatter::{
|
||||
@@ -43,7 +43,9 @@ impl<'a> FormatWrite<'a> for AstNode<'a, TaggedTemplateExpression<'a>> {
|
||||
|
||||
quasi.format_leading_comments(f);
|
||||
|
||||
if is_test_each_pattern(&self.tag) {
|
||||
if let Some(result) = try_format_embedded_template(self, f) {
|
||||
result
|
||||
} else if is_test_each_pattern(&self.tag) {
|
||||
let template = &EachTemplateTable::from_template(quasi, f)?;
|
||||
// Use table formatting
|
||||
write!(f, template)
|
||||
@@ -694,3 +696,49 @@ impl<'a> Format<'a> for EachTemplateTable<'a> {
|
||||
write!(f, ["`", indent(&format_args!(table_content)), hard_line_break(), "`"])
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to format a tagged template with the embedded formatter if supported.
|
||||
/// Returns `Some(result)` if formatting was attempted, `None` if not applicable.
|
||||
fn try_format_embedded_template<'a>(
|
||||
tagged: &AstNode<'a, TaggedTemplateExpression<'a>>,
|
||||
f: &mut Formatter<'_, 'a>,
|
||||
) -> Option<FormatResult<()>> {
|
||||
let quasi = &tagged.quasi;
|
||||
if !quasi.is_no_substitution_template() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let Expression::Identifier(ident) = &tagged.tag else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let tag_name = ident.name.as_str();
|
||||
// Check if the tag is supported by the embedded formatter
|
||||
if !EmbeddedFormatter::is_supported_tag(tag_name) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Get the embedded formatter from the context
|
||||
let embedded_formatter = f.context().embedded_formatter()?;
|
||||
let template_content = quasi.quasis[0].value.raw.as_str();
|
||||
|
||||
let Ok(formatted) = embedded_formatter.format(tag_name, template_content) else {
|
||||
return None;
|
||||
};
|
||||
|
||||
// Format with proper template literal structure:
|
||||
// - Opening backtick
|
||||
// - Hard line break (newline after backtick)
|
||||
// - Indented content (each line will be indented)
|
||||
// - Hard line break (newline before closing backtick)
|
||||
// - Closing backtick
|
||||
let format_content = format_once(|f: &mut Formatter<'_, 'a>| {
|
||||
let content = f.context().allocator().alloc_str(&formatted);
|
||||
for line in content.split('\n') {
|
||||
write!(f, [dynamic_text(line), hard_line_break()])?;
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
|
||||
Some(write!(f, ["`", block_indent(&format_content), "`"]))
|
||||
}
|
||||
@@ -167,7 +167,12 @@ alias new-typescript-rule := new-ts-rule
|
||||
|
||||
# ==================== FORMATTER ====================
|
||||
|
||||
# Formatter-specific commands will be added here as needed
|
||||
# Build oxfmt in release build
|
||||
oxfmt:
|
||||
pnpm -C apps/oxfmt run build
|
||||
|
||||
watch-oxfmt *args='':
|
||||
just watch 'pnpm run -C apps/oxfmt build-dev && node apps/oxfmt/dist/cli.js {{args}}'
|
||||
|
||||
# ==================== TRANSFORMER ====================
|
||||
|
||||
|
||||
Generated
+47
@@ -57,6 +57,31 @@ importers:
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.3(@types/node@24.9.1)(@vitest/browser-playwright@4.0.3)(jiti@2.6.1)
|
||||
|
||||
apps/oxfmt:
|
||||
dependencies:
|
||||
'@prettier/sync':
|
||||
specifier: 0.6.1
|
||||
version: 0.6.1(prettier@3.6.2)
|
||||
prettier:
|
||||
specifier: 3.6.2
|
||||
version: 3.6.2
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.0.0
|
||||
version: 24.9.1
|
||||
execa:
|
||||
specifier: ^9.6.0
|
||||
version: 9.6.0
|
||||
tsdown:
|
||||
specifier: ^0.15.5
|
||||
version: 0.15.10(@arethetypeswrong/core@0.18.2)(publint@0.3.15)(typescript@5.9.3)
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.3(@types/node@24.9.1)(@vitest/browser-playwright@4.0.3)(jiti@2.6.1)
|
||||
|
||||
apps/oxlint:
|
||||
devDependencies:
|
||||
'@types/esquery':
|
||||
@@ -1579,6 +1604,11 @@ packages:
|
||||
'@polka/url@1.0.0-next.29':
|
||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||
|
||||
'@prettier/sync@0.6.1':
|
||||
resolution: {integrity: sha512-yF9G8vK/LYUTF3Cijd7VC9La3b20F20/J/fgoR4H0B8JGOWnZVZX6+I6+vODPosjmMcpdlUV+gUqJQZp3kLOcw==}
|
||||
peerDependencies:
|
||||
prettier: '*'
|
||||
|
||||
'@publint/pack@0.1.2':
|
||||
resolution: {integrity: sha512-S+9ANAvUmjutrshV4jZjaiG8XQyuJIZ8a4utWmN/vW1sgQ9IfBnPndwkmQYw53QmouOIytT874u65HEmu6H5jw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3064,6 +3094,9 @@ packages:
|
||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
make-synchronized@0.8.0:
|
||||
resolution: {integrity: sha512-DZu4lwc0ffoFz581BSQa/BJl+1ZqIkoRQ+VejMlH0VrP4E86StAODnZujZ4sepumQj8rcP7wUnUBGM8Gu+zKUA==}
|
||||
|
||||
markdown-it@14.1.0:
|
||||
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
|
||||
hasBin: true
|
||||
@@ -3383,6 +3416,11 @@ packages:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
prettier@3.6.2:
|
||||
resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==}
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
pretty-ms@9.3.0:
|
||||
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5273,6 +5311,11 @@ snapshots:
|
||||
|
||||
'@polka/url@1.0.0-next.29': {}
|
||||
|
||||
'@prettier/sync@0.6.1(prettier@3.6.2)':
|
||||
dependencies:
|
||||
make-synchronized: 0.8.0
|
||||
prettier: 3.6.2
|
||||
|
||||
'@publint/pack@0.1.2': {}
|
||||
|
||||
'@quansync/fs@0.1.5':
|
||||
@@ -6769,6 +6812,8 @@ snapshots:
|
||||
dependencies:
|
||||
semver: 7.7.3
|
||||
|
||||
make-synchronized@0.8.0: {}
|
||||
|
||||
markdown-it@14.1.0:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
@@ -7113,6 +7158,8 @@ snapshots:
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
prettier@3.6.2: {}
|
||||
|
||||
pretty-ms@9.3.0:
|
||||
dependencies:
|
||||
parse-ms: 4.0.0
|
||||
|
||||
Reference in new issue
Block a user