diff --git a/packages/rstack/src/fmt/swcNextPlugin.ts b/packages/rstack/src/fmt/swcNextPlugin.ts index e8e335d..b43e129 100644 --- a/packages/rstack/src/fmt/swcNextPlugin.ts +++ b/packages/rstack/src/fmt/swcNextPlugin.ts @@ -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'; @@ -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; @@ -323,14 +331,12 @@ 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 => { @@ -338,10 +344,10 @@ const rebalanceLogicalTree = (node: AstNode): AstNode => { 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', @@ -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. @@ -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; @@ -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; } diff --git a/packages/rstack/tests/fmt/swcNextPlugin.test.ts b/packages/rstack/tests/fmt/swcNextPlugin.test.ts index ae49ed9..2f4b884 100644 --- a/packages/rstack/tests/fmt/swcNextPlugin.test.ts +++ b/packages/rstack/tests/fmt/swcNextPlugin.test.ts @@ -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'; @@ -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=({(item)})', - expected: - 'const view = {item};\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=({(item)})', + expected: + 'const view = {item};\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; + const astWithHashbang = (await parser.parse( + '#!/usr/bin/env node\nconst value = 1', + options, + )) as Record; + + 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 () => { @@ -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; - const astWithHashbang = (await parser.parse( - '#!/usr/bin/env node\nconst value = 1', - options, - )) as Record; - - 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',