Skip to content

Commit 204128f

Browse files
committed
feat(oxfmt): Support gql-in-js substitution
1 parent a447840 commit 204128f

File tree

18 files changed

+1435
-168
lines changed

18 files changed

+1435
-168
lines changed

apps/oxfmt/cmp.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// oxlint-disable
2+
3+
import { readdir, readFile } from "node:fs/promises";
4+
import { join } from "node:path";
5+
import * as prettier from "prettier";
6+
import { format as oxfmtFormat } from "./dist/index.js";
7+
8+
const FIXTURES_DIR = join(
9+
import.meta.dirname,
10+
"../../tasks/prettier_conformance/prettier/tests/format/js/multiparser-graphql",
11+
);
12+
13+
const EXCLUDE = new Set([
14+
"format.test.js",
15+
"comment-tag.js", // /* GraphQL */ comment tag (not yet supported)
16+
"expressions.js", // graphql() function call pattern (not yet supported)
17+
"graphql.js", // graphql() function call pattern (not yet supported)
18+
]);
19+
20+
const files = (await readdir(FIXTURES_DIR))
21+
.filter((f) => f.endsWith(".js") && !EXCLUDE.has(f))
22+
.sort();
23+
24+
let matchCount = 0;
25+
let mismatchCount = 0;
26+
let errorCount = 0;
27+
28+
for (const file of files) {
29+
const filePath = join(FIXTURES_DIR, file);
30+
const source = await readFile(filePath, "utf8");
31+
32+
const prettierOutput = await prettier.format(source, {
33+
parser: "babel",
34+
printWidth: 80,
35+
});
36+
37+
let oxfmtOutput;
38+
try {
39+
const oxfmtResult = await oxfmtFormat(file, source, { printWidth: 80 });
40+
oxfmtOutput = oxfmtResult.code;
41+
} catch (e) {
42+
console.log(`✗ ${file} (ERROR: ${e.message})`);
43+
errorCount++;
44+
continue;
45+
}
46+
47+
if (prettierOutput === oxfmtOutput) {
48+
console.log(`✓ ${file}`);
49+
matchCount++;
50+
} else {
51+
console.log(`✗ ${file}`);
52+
mismatchCount++;
53+
printUnifiedDiff(prettierOutput, oxfmtOutput);
54+
}
55+
}
56+
57+
console.log(`\n--- Summary ---`);
58+
console.log(
59+
`Match: ${matchCount}, Mismatch: ${mismatchCount}, Error: ${errorCount}, Total: ${files.length}`,
60+
);
61+
62+
function printUnifiedDiff(expected, actual) {
63+
const expectedLines = expected.split("\n");
64+
const actualLines = actual.split("\n");
65+
console.log(" --- prettier");
66+
console.log(" +++ oxfmt");
67+
const maxLen = Math.max(expectedLines.length, actualLines.length);
68+
for (let i = 0; i < maxLen; i++) {
69+
const e = expectedLines[i];
70+
const a = actualLines[i];
71+
if (e === a) {
72+
console.log(` ${e ?? ""}`);
73+
} else {
74+
if (e !== undefined) console.log(` -${e}`);
75+
if (a !== undefined) console.log(` +${a}`);
76+
}
77+
}
78+
console.log();
79+
}

apps/oxfmt/src-js/bindings.d.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export declare const enum Severity {
3434
* # Panics
3535
* Panics if the current working directory cannot be determined.
3636
*/
37-
export declare function format(filename: string, sourceText: string, options: any | undefined | null, initExternalFormatterCb: (numThreads: number) => Promise<string[]>, formatEmbeddedCb: (options: Record<string, any>, code: string) => Promise<string>, formatFileCb: (options: Record<string, any>, code: string) => Promise<string>, sortTailwindClassesCb: (options: Record<string, any>, classes: string[]) => Promise<string[]>): Promise<FormatResult>
37+
export declare function format(filename: string, sourceText: string, options: any | undefined | null, initExternalFormatterCb: (numThreads: number) => Promise<string[]>, formatEmbeddedCb: (options: Record<string, any>, code: string) => Promise<string>, formatEmbeddedDocCb: (options: Record<string, any>, texts: string[]) => Promise<string[]>, formatFileCb: (options: Record<string, any>, code: string) => Promise<string>, sortTailwindClassesCb: (options: Record<string, any>, classes: string[]) => Promise<string[]>): Promise<FormatResult>
3838

3939
export interface FormatResult {
4040
/** The formatted code. */
@@ -49,7 +49,7 @@ export interface FormatResult {
4949
* This API is specialized for JS/TS snippets embedded in non-JS files.
5050
* Unlike `format()`, it is called only for js-in-xxx `textToDoc()` flow.
5151
*/
52-
export declare function jsTextToDoc(sourceExt: string, sourceText: string, oxfmtPluginOptionsJson: string, parentContext: string, initExternalFormatterCb: (numThreads: number) => Promise<string[]>, formatEmbeddedCb: (options: Record<string, any>, code: string) => Promise<string>, formatFileCb: (options: Record<string, any>, code: string) => Promise<string>, sortTailwindClassesCb: (options: Record<string, any>, classes: string[]) => Promise<string[]>): Promise<string | null>
52+
export declare function jsTextToDoc(sourceExt: string, sourceText: string, oxfmtPluginOptionsJson: string, parentContext: string, initExternalFormatterCb: (numThreads: number) => Promise<string[]>, formatEmbeddedCb: (options: Record<string, any>, code: string) => Promise<string>, formatEmbeddedDocCb: (options: Record<string, any>, texts: string[]) => Promise<string[]>, formatFileCb: (options: Record<string, any>, code: string) => Promise<string>, sortTailwindClassesCb: (options: Record<string, any>, classes: string[]) => Promise<string[]>): Promise<string | null>
5353

5454
/**
5555
* NAPI based JS CLI entry point.
@@ -66,4 +66,4 @@ export declare function jsTextToDoc(sourceExt: string, sourceText: string, oxfmt
6666
* - `mode`: If main logic will run in JS side, use this to indicate which mode
6767
* - `exitCode`: If main logic already ran in Rust side, return the exit code
6868
*/
69-
export declare function runCli(args: Array<string>, initExternalFormatterCb: (numThreads: number) => Promise<string[]>, formatEmbeddedCb: (options: Record<string, any>, code: string) => Promise<string>, formatFileCb: (options: Record<string, any>, code: string) => Promise<string>, sortTailwindcssClassesCb: (options: Record<string, any>, classes: string[]) => Promise<string[]>): Promise<[string, number | undefined | null]>
69+
export declare function runCli(args: Array<string>, initExternalFormatterCb: (numThreads: number) => Promise<string[]>, formatEmbeddedCb: (options: Record<string, any>, code: string) => Promise<string>, formatEmbeddedDocCb: (options: Record<string, any>, texts: string[]) => Promise<string[]>, formatFileCb: (options: Record<string, any>, code: string) => Promise<string>, sortTailwindcssClassesCb: (options: Record<string, any>, classes: string[]) => Promise<string[]>): Promise<[string, number | undefined | null]>

apps/oxfmt/src-js/cli-worker.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
// `oxfmt` CLI - Worker Thread Entry Point
22

33
// Re-exports core functions for use in `worker_threads`
4-
export { formatEmbeddedCode, formatFile, sortTailwindClasses } from "./libs/apis";
4+
export {
5+
formatEmbeddedCode,
6+
formatEmbeddedDoc,
7+
formatFile,
8+
sortTailwindClasses,
9+
} from "./libs/apis";

apps/oxfmt/src-js/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { runCli } from "./bindings";
22
import {
33
initExternalFormatter,
44
formatEmbeddedCode,
5+
formatEmbeddedDoc,
56
formatFile,
67
sortTailwindClasses,
78
disposeExternalFormatter,
@@ -28,6 +29,7 @@ void (async () => {
2829
args,
2930
initExternalFormatter,
3031
formatEmbeddedCode,
32+
formatEmbeddedDoc,
3133
formatFile,
3234
sortTailwindClasses,
3335
);

apps/oxfmt/src-js/cli/worker-proxy.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Tinypool from "tinypool";
22
import { resolvePlugins } from "../libs/apis";
33
import type {
44
FormatEmbeddedCodeParam,
5+
FormatEmbeddedDocParam,
56
FormatFileParam,
67
SortTailwindClassesArgs,
78
} from "../libs/apis";
@@ -40,6 +41,17 @@ export async function formatEmbeddedCode(
4041
.catch(rethrowAsError);
4142
}
4243

44+
export async function formatEmbeddedDoc(
45+
options: FormatEmbeddedDocParam["options"],
46+
texts: string[],
47+
): Promise<string[]> {
48+
return pool!
49+
.run({ options, texts } satisfies FormatEmbeddedDocParam, {
50+
name: "formatEmbeddedDoc",
51+
})
52+
.catch(rethrowAsError);
53+
}
54+
4355
export async function formatFile(
4456
options: FormatFileParam["options"],
4557
code: string,

apps/oxfmt/src-js/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { format as napiFormat, jsTextToDoc as napiJsTextToDoc } from "./bindings";
2-
import { resolvePlugins, formatEmbeddedCode, formatFile, sortTailwindClasses } from "./libs/apis";
2+
import {
3+
resolvePlugins,
4+
formatEmbeddedCode,
5+
formatEmbeddedDoc,
6+
formatFile,
7+
sortTailwindClasses,
8+
} from "./libs/apis";
39
import type { Options } from "prettier";
410

511
// napi-JS `oxfmt` API entry point
@@ -18,6 +24,7 @@ export async function format(fileName: string, sourceText: string, options?: For
1824
options ?? {},
1925
resolvePlugins,
2026
(options, code) => formatEmbeddedCode({ options, code }),
27+
(options, texts) => formatEmbeddedDoc({ options, texts }),
2128
(options, code) => formatFile({ options, code }),
2229
(options, classes) => sortTailwindClasses({ options, classes }),
2330
);
@@ -39,6 +46,7 @@ export async function jsTextToDoc(
3946
parentContext,
4047
resolvePlugins,
4148
(options, code) => formatEmbeddedCode({ options, code }),
49+
(options, texts) => formatEmbeddedDoc({ options, texts }),
4250
(_options, _code) => Promise.reject(/* Unreachable */),
4351
(options, classes) => sortTailwindClasses({ options, classes }),
4452
);

apps/oxfmt/src-js/libs/apis.ts

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,12 @@ export type FormatEmbeddedCodeParam = {
5050
};
5151

5252
/**
53-
* Format xxx-in-js code snippets
53+
* Format xxx-in-js code snippets into formatted string.
54+
*
55+
* This will be gradually replaced by `formatEmbeddedDoc` which returns `Doc`.
56+
* For now, html|css|md-in-js are using this.
5457
*
5558
* @returns Formatted code snippet
56-
* TODO: In the future, this should return `Doc` instead of string,
57-
* otherwise, we cannot calculate `printWidth` correctly.
5859
*/
5960
export async function formatEmbeddedCode({
6061
code,
@@ -74,6 +75,60 @@ export async function formatEmbeddedCode({
7475

7576
// ---
7677

78+
export type FormatEmbeddedDocParam = {
79+
texts: string[];
80+
options: Options;
81+
};
82+
83+
/**
84+
* Format xxx-in-js code snippets into Prettier `Doc` JSON strings.
85+
*
86+
* This makes `oxc_formatter` correctly handle `printWidth` even for embedded code.
87+
* - For gql-in-js, `texts` contains multiple parts split by `${}` in a template literal
88+
* - For others, `texts` always contains a single string with `${}` parts replaced by placeholders
89+
* However, this function does not need to be aware of that,
90+
* as it simply formats each text part independently and returns an array of formatted parts.
91+
*
92+
* @returns Doc JSON strings (one per input text)
93+
*/
94+
export async function formatEmbeddedDoc({
95+
texts,
96+
options,
97+
}: FormatEmbeddedDocParam): Promise<string[]> {
98+
const prettier = await loadPrettier();
99+
100+
// Enable Tailwind CSS plugin for embedded code (e.g., html`...` in JS) if needed
101+
await setupTailwindPlugin(options);
102+
103+
// NOTE: This will throw if:
104+
// - Specified parser is not available
105+
// - Or, code has syntax errors
106+
// In such cases, Rust side will fallback to original code
107+
return Promise.all(
108+
texts.map(async (text) => {
109+
// @ts-expect-error: Use internal API, but it's necessary and only way to get `Doc`
110+
const doc = await prettier.__debug.printToDoc(text, options);
111+
112+
// Serialize Doc to JSON, handling special values in a single pass:
113+
// - Symbol group IDs (used by `group`, `if-break`, `indent-if-break`) → numeric counters
114+
// - -Infinity (used by `dedentToRoot` via `align`) → marker string
115+
const symbolToNumber = new Map<symbol, number>();
116+
let nextId = 1;
117+
118+
return JSON.stringify(doc, (_key, value) => {
119+
if (typeof value === "symbol") {
120+
if (!symbolToNumber.has(value)) symbolToNumber.set(value, nextId++);
121+
return symbolToNumber.get(value);
122+
}
123+
if (value === -Infinity) return "__NEGATIVE_INFINITY__";
124+
return value;
125+
});
126+
}),
127+
);
128+
}
129+
130+
// ---
131+
77132
export type FormatFileParam = {
78133
code: string;
79134
options: Options;

apps/oxfmt/src/api/format_api.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ use serde_json::Value;
55
use oxc_napi::OxcError;
66

77
use crate::core::{
8-
ExternalFormatter, FormatFileStrategy, FormatResult, JsFormatEmbeddedCb, JsFormatFileCb,
9-
JsInitExternalFormatterCb, JsSortTailwindClassesCb, SourceFormatter,
8+
ExternalFormatter, FormatFileStrategy, FormatResult, JsFormatEmbeddedCb, JsFormatEmbeddedDocCb,
9+
JsFormatFileCb, JsInitExternalFormatterCb, JsSortTailwindClassesCb, SourceFormatter,
1010
resolve_options_from_value,
1111
};
1212

@@ -25,6 +25,7 @@ pub fn run(
2525
options: Option<Value>,
2626
init_external_formatter_cb: JsInitExternalFormatterCb,
2727
format_embedded_cb: JsFormatEmbeddedCb,
28+
format_embedded_doc_cb: JsFormatEmbeddedDocCb,
2829
format_file_cb: JsFormatFileCb,
2930
sort_tailwind_classes_cb: JsSortTailwindClassesCb,
3031
) -> ApiFormatResult {
@@ -37,6 +38,7 @@ pub fn run(
3738
let external_formatter = ExternalFormatter::new(
3839
init_external_formatter_cb,
3940
format_embedded_cb,
41+
format_embedded_doc_cb,
4042
format_file_cb,
4143
sort_tailwind_classes_cb,
4244
);

apps/oxfmt/src/api/text_to_doc_api.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ use oxc_span::SourceType;
1414

1515
use crate::{
1616
core::{
17-
ExternalFormatter, FormatFileStrategy, FormatResult, JsFormatEmbeddedCb, JsFormatFileCb,
18-
JsInitExternalFormatterCb, JsSortTailwindClassesCb, ResolvedOptions, SourceFormatter,
19-
resolve_options_from_value,
17+
ExternalFormatter, FormatFileStrategy, FormatResult, JsFormatEmbeddedCb,
18+
JsFormatEmbeddedDocCb, JsFormatFileCb, JsInitExternalFormatterCb, JsSortTailwindClassesCb,
19+
ResolvedOptions, SourceFormatter, resolve_options_from_value,
2020
},
2121
prettier_compat::to_prettier_doc,
2222
};
@@ -52,6 +52,7 @@ pub fn run(
5252
parent_context: &str,
5353
init_external_formatter_cb: JsInitExternalFormatterCb,
5454
format_embedded_cb: JsFormatEmbeddedCb,
55+
format_embedded_doc_cb: JsFormatEmbeddedDocCb,
5556
format_file_cb: JsFormatFileCb,
5657
sort_tailwind_classes_cb: JsSortTailwindClassesCb,
5758
) -> Option<String> {
@@ -72,6 +73,7 @@ pub fn run(
7273
oxfmt_plugin_options_json,
7374
init_external_formatter_cb,
7475
format_embedded_cb,
76+
format_embedded_doc_cb,
7577
format_file_cb,
7678
sort_tailwind_classes_cb,
7779
)?
@@ -92,6 +94,7 @@ fn run_full(
9294
oxfmt_plugin_options_json: &str,
9395
init_external_formatter_cb: JsInitExternalFormatterCb,
9496
format_embedded_cb: JsFormatEmbeddedCb,
97+
format_embedded_doc_cb: JsFormatEmbeddedDocCb,
9598
format_file_cb: JsFormatFileCb,
9699
sort_tailwind_classes_cb: JsSortTailwindClassesCb,
97100
) -> Option<Value> {
@@ -105,6 +108,7 @@ fn run_full(
105108
let external_formatter = ExternalFormatter::new(
106109
init_external_formatter_cb,
107110
format_embedded_cb,
111+
format_embedded_doc_cb,
108112
format_file_cb,
109113
sort_tailwind_classes_cb,
110114
);

0 commit comments

Comments
 (0)