Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 21 additions & 28 deletions packages/rstack/src/fmt/swcNextPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
// Adapted from @prettier/plugin-yuku to use SWC Next:
// https://github.com/prettier/prettier/tree/main/packages/plugin-yuku
// Adapted from @prettier/plugin-yuku:
// https://github.com/prettier/prettier/blob/315f28198200d7678dadd3fd5eece499b127ff2a/packages/plugin-yuku/index.js
// https://github.com/prettier/prettier/blob/315f28198200d7678dadd3fd5eece499b127ff2a/src/language-js/parse/postprocess/index.js
//
// Differences from upstream:
// - Bundles the adapter locally and reuses Prettier's printer to avoid duplication.
// - Uses SWC Next instead of Yuku, removing unused branches and redundant checks.
// - Avoids repeated source copying and linear searches when processing comments.
// - Registers babel/typescript aliases for other plugins; uses Babel's File root for Vue.

import * as prettierEstreePlugin from 'prettier/plugins/estree';
import type { Parser, ParserOptions, Plugin } from 'prettier';
Expand Down Expand Up @@ -305,7 +312,8 @@ const visitNode = (value: unknown, options: VisitOptions): unknown => {
return value;
}

let node = asAstNode(value);
// Trust the parser's child nodes; validate the root at the parser entry point.
let node = value as AstNode;

if (options.onEnter) {
const result = options.onEnter(node) ?? node;
Expand All @@ -323,25 +331,23 @@ const visitNode = (value: unknown, options: VisitOptions): unknown => {
};

const isUnbalancedLogicalTree = (node: AstNode): boolean => {
if (node.type !== 'LogicalExpression' || !isAstNode(node.right)) {
if (node.type !== 'LogicalExpression') {
return false;
}

return (
node.right.type === 'LogicalExpression' &&
node.operator === node.right.operator
);
const right = node.right as AstNode;
return right.type === 'LogicalExpression' && node.operator === right.operator;
};

const rebalanceLogicalTree = (node: AstNode): AstNode => {
if (!isUnbalancedLogicalTree(node)) {
return node;
}

const left = asAstNode(node.left);
const right = asAstNode(node.right);
const rightLeft = asAstNode(right.left);
const rightRight = asAstNode(right.right);
const left = node.left as AstNode;
const right = node.right as AstNode;
const rightLeft = right.left as AstNode;
const rightRight = right.right as AstNode;

return rebalanceLogicalTree({
type: 'LogicalExpression',
Expand Down Expand Up @@ -387,7 +393,7 @@ const postprocess = (

switch (node.type) {
case 'ParenthesizedExpression': {
const expression = asAstNode(node.expression);
const expression = node.expression as AstNode;
const start = locStart(node);

// SWC Next comments are in source order, so these end offsets are sorted.
Expand All @@ -411,15 +417,6 @@ const postprocess = (
return expression;
}

case 'TemplateLiteral': {
const expressions = node.expressions as unknown[];
const quasis = node.quasis as unknown[];
if (expressions.length !== quasis.length - 1) {
throw new Error('Malformed template literal.');
}
break;
}

case 'TemplateElement': {
if (astType === 'swc-next-ts') {
const start = locStart(node) + 1;
Expand All @@ -430,17 +427,13 @@ const postprocess = (
}

case 'TSParenthesizedType':
return asAstNode(node.typeAnnotation);

case 'TopicReference':
ast.extra = withExtra(ast, { __isUsingHackPipeline: true });
break;
return node.typeAnnotation as AstNode;

case 'TSUnionType':
case 'TSIntersectionType': {
const types = node.types as unknown[];
if (types.length === 1) {
return asAstNode(types[0]);
return types[0] as AstNode;
}
break;
}
Expand Down
182 changes: 102 additions & 80 deletions packages/rstack/tests/fmt/swcNextPlugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
type Options,
type ParserOptions,
} from 'prettier';
import { expect, test } from 'rstack/test';
import { describe, expect, test } from 'rstack/test';
import { getPrettierPlugins } from '../../src/fmt/prettierPlugins.ts';
import { swcNextPlugin } from '../../src/fmt/swcNextPlugin.ts';

Expand Down Expand Up @@ -107,64 +107,107 @@ test.each(['example.d.ts', 'example.d.mts', 'example.d.cts'])(
},
);

test.each([
{
name: 'hashbangs and unicode locations',
parser: 'swc-next' as const,
source: '#!/usr/bin/env node\n// 中文 😀\nconst 你好={值:"😀"}',
expected: '#!/usr/bin/env node\n// 中文 😀\nconst 你好 = { 值: "😀" };\n',
},
{
name: 'Closure-style type casts',
parser: 'swc-next' as const,
source: '/** @type {Foo} */ (value).method()',
expected: '/** @type {Foo} */ (value).method();\n',
},
{
name: 'comments before semicolons',
parser: 'swc-next' as const,
source: 'foo /* trailing */ ;',
expected: 'foo; /* trailing */\n',
},
{
name: 'adjacent multiline JSDoc comments',
parser: 'swc-next' as const,
source: '/**\n * outer\n *//**\n * inner\n */\nfoo()',
expected: '/**\n * outer\n *//**\n * inner\n */\nfoo();\n',
},
{
name: 'right-nested logical expressions',
parser: 'swc-next' as const,
source: 'const value = a || (b || c)',
expected: 'const value = a || b || c;\n',
},
{
name: 'parenthesized TypeScript types',
parser: 'swc-next-ts' as const,
source: 'type Value = (((string | number)));',
expected: 'type Value = string | number;\n',
},
{
name: 'TypeScript template expressions',
parser: 'swc-next-ts' as const,
source: 'const result = `value: ${foo satisfies string}`',
expected: 'const result = `value: ${foo satisfies string}`;\n',
},
{
name: 'TSX expressions',
parser: 'swc-next-ts' as const,
filepath: 'example.tsx',
source: 'const view=(<Component value={{foo:1}}>{(item)}</Component>)',
expected:
'const view = <Component value={{ foo: 1 }}>{item}</Component>;\n',
},
])('normalizes $name for the ESTree printer', async (fixture) => {
await expect(
formatWithSwcNext(fixture.source, {
filepath: fixture.filepath,
parser: fixture.parser,
}),
).resolves.toBe(fixture.expected);
describe("should match Prettier's postprocess behavior", () => {
test.each([
{
name: 'hashbangs and unicode locations',
parser: 'swc-next' as const,
source: '#!/usr/bin/env node\n// 中文 😀\nconst 你好={值:"😀"}',
expected: '#!/usr/bin/env node\n// 中文 😀\nconst 你好 = { 值: "😀" };\n',
},
{
name: 'Closure-style type casts',
parser: 'swc-next' as const,
source: '/** @type {Foo} */ (value).method()',
expected: '/** @type {Foo} */ (value).method();\n',
},
{
name: 'comments before semicolons',
parser: 'swc-next' as const,
source: 'foo /* trailing */ ;',
expected: 'foo; /* trailing */\n',
},
{
name: 'adjacent multiline JSDoc comments',
parser: 'swc-next' as const,
source: '/**\n * outer\n *//**\n * inner\n */\nfoo()',
expected: '/**\n * outer\n *//**\n * inner\n */\nfoo();\n',
},
{
name: 'right-nested logical expressions',
parser: 'swc-next' as const,
source: 'const value = a || (b || c)',
expected: 'const value = a || b || c;\n',
},
{
name: 'parenthesized TypeScript types',
parser: 'swc-next-ts' as const,
source: 'type Value = (((string | number)));',
expected: 'type Value = string | number;\n',
},
{
name: 'single-member unions with comments',
parser: 'swc-next-ts' as const,
source: 'type Value = | // value\nstring;',
expected: 'type Value =\n // value\n string;\n',
},
{
name: 'TypeScript template expressions',
parser: 'swc-next-ts' as const,
source: 'const result = `value: ${foo satisfies string}`',
expected: 'const result = `value: ${foo satisfies string}`;\n',
},
{
name: 'TSX expressions',
parser: 'swc-next-ts' as const,
filepath: 'example.tsx',
source: 'const view=(<Component value={{foo:1}}>{(item)}</Component>)',
expected:
'const view = <Component value={{ foo: 1 }}>{item}</Component>;\n',
},
])('normalizes $name for the ESTree printer', async (fixture) => {
await expect(
formatWithSwcNext(fixture.source, {
filepath: fixture.filepath,
parser: fixture.parser,
}),
).resolves.toBe(fixture.expected);
});

test.each(['swc-next', 'swc-next-ts'] as const)(
'normalizes nested template expressions with %s',
async (parser) => {
const source =
'const result=`outer ${(`inner ${(a || (b || c))}`)} ${/** @type {Foo} */ (value)}`';
const formatted = await formatWithSwcNext(source, { parser });

expect(formatted).toBe(
'const result = `outer ${`inner ${a || b || c}`} ${/** @type {Foo} */ (value)}`;\n',
);
expect(await formatWithSwcNext(formatted, { parser })).toBe(formatted);
},
);

test('matches the official hashbang AST shape', async () => {
const parser = swcNextPlugin.parsers?.['swc-next'];
if (!parser) {
throw new Error('The SWC Next parser is not registered.');
}

const options = { filepath: 'example.js' } as ParserOptions;
const astWithoutHashbang = (await parser.parse(
'const value = 1',
options,
)) as Record<string, unknown>;
const astWithHashbang = (await parser.parse(
'#!/usr/bin/env node\nconst value = 1',
options,
)) as Record<string, unknown>;

expect(Object.hasOwn(astWithoutHashbang, 'hashbang')).toBe(true);
expect(astWithoutHashbang.hashbang).toBeNull();
expect(Object.hasOwn(astWithHashbang, 'hashbang')).toBe(false);
});
});

test('reuses Prettier options and pragma handling', async () => {
Expand Down Expand Up @@ -315,27 +358,6 @@ test('supports CommonJS source semantics for .cjs files', async () => {
).resolves.toBe('return require("example");\n');
});

test('matches the official hashbang AST shape', async () => {
const parser = swcNextPlugin.parsers?.['swc-next'];
if (!parser) {
throw new Error('The SWC Next parser is not registered.');
}

const options = { filepath: 'example.js' } as ParserOptions;
const astWithoutHashbang = (await parser.parse(
'const value = 1',
options,
)) as Record<string, unknown>;
const astWithHashbang = (await parser.parse(
'#!/usr/bin/env node\nconst value = 1',
options,
)) as Record<string, unknown>;

expect(Object.hasOwn(astWithoutHashbang, 'hashbang')).toBe(true);
expect(astWithoutHashbang.hashbang).toBeNull();
expect(Object.hasOwn(astWithHashbang, 'hashbang')).toBe(false);
});

test('reports SWC Next diagnostics with Prettier locations', async () => {
const error = await formatWithSwcNext('\n\nconst = 1', {
parser: 'swc-next-ts',
Expand Down
Loading