From ff41c38bcc885b38113153b05b54c722e99fecd0 Mon Sep 17 00:00:00 2001 From: neverland Date: Mon, 3 Aug 2026 22:15:07 +0800 Subject: [PATCH 1/4] perf(fmt): reduce Yuku plugin bundle size --- packages/rstack/THIRD_PARTY_NOTICES.md | 41 +- packages/rstack/package.json | 1 - packages/rstack/src/fmt/prettierPlugins.ts | 2 +- packages/rstack/src/fmt/yukuPlugin.ts | 476 +++++++++++++++++++ packages/rstack/tests/fmt/yukuPlugin.test.ts | 164 +++++++ pnpm-lock.yaml | 17 - pnpm-workspace.yaml | 4 - 7 files changed, 647 insertions(+), 58 deletions(-) create mode 100644 packages/rstack/src/fmt/yukuPlugin.ts create mode 100644 packages/rstack/tests/fmt/yukuPlugin.test.ts diff --git a/packages/rstack/THIRD_PARTY_NOTICES.md b/packages/rstack/THIRD_PARTY_NOTICES.md index 2e0e08c..78ab78d 100644 --- a/packages/rstack/THIRD_PARTY_NOTICES.md +++ b/packages/rstack/THIRD_PARTY_NOTICES.md @@ -29,10 +29,13 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -## @prettier/plugin-yuku +## Prettier Yuku parser adapter -This package includes bundled code from -[@prettier/plugin-yuku](https://github.com/prettier/prettier/tree/main/packages/plugin-yuku). +The local Yuku parser adapter includes portions derived from +[@prettier/plugin-yuku](https://github.com/prettier/prettier/tree/main/packages/plugin-yuku) +and Prettier's JavaScript parser postprocessing. The adapter reuses the public +ESTree printer, formatter options, and parser utilities from the installed +`prettier` package. License: MIT @@ -56,38 +59,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -The bundled plugin also contains MIT-licensed code from: - -- emoji-regex 10.6.0, copyright Mathias Bynens -- escape-string-regexp 5.0.0, copyright Sindre Sorhus -- get-east-asian-width 1.6.0, copyright Sindre Sorhus -- index-to-position 1.2.0, copyright Sindre Sorhus -- is-es5-identifier-name 1.0.1, copyright fisker Cheung -- jest-docblock 30.4.0, copyright Meta Platforms, Inc. and Jest contributors -- narrow-emojis 0.0.3, copyright fisker Cheung -- Prettier 3.10.0-dev, copyright James Long and contributors -- to-fast-properties 4.0.0, copyright Petka Antonov, Benjamin Gruenbaum, - John-David Dalton, and Sindre Sorhus -- trim-newlines 5.0.0, copyright Sindre Sorhus - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notices and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - ## fast-ignore This package includes bundled code from [fast-ignore](https://github.com/fabiospampinato/fast-ignore). diff --git a/packages/rstack/package.json b/packages/rstack/package.json index 1de706e..b6eafae 100644 --- a/packages/rstack/package.json +++ b/packages/rstack/package.json @@ -62,7 +62,6 @@ "yuku-parser": "catalog:" }, "devDependencies": { - "@prettier/plugin-yuku": "catalog:", "@rspress/core": "catalog:", "@rstackjs/load-config": "catalog:", "@rstackjs/test-utils": "catalog:", diff --git a/packages/rstack/src/fmt/prettierPlugins.ts b/packages/rstack/src/fmt/prettierPlugins.ts index c575758..829d7e6 100644 --- a/packages/rstack/src/fmt/prettierPlugins.ts +++ b/packages/rstack/src/fmt/prettierPlugins.ts @@ -1,6 +1,6 @@ -import * as yukuPlugin from '@prettier/plugin-yuku'; import type { Options as PrettierOptions, Plugin } from 'prettier'; import type { ResolvedFmtOptions } from './types.ts'; +import { yukuPlugin } from './yukuPlugin.ts'; type PrettierPlugins = NonNullable; diff --git a/packages/rstack/src/fmt/yukuPlugin.ts b/packages/rstack/src/fmt/yukuPlugin.ts new file mode 100644 index 0000000..bef0693 --- /dev/null +++ b/packages/rstack/src/fmt/yukuPlugin.ts @@ -0,0 +1,476 @@ +import { parsers as prettierBabelParsers } from 'prettier/plugins/babel'; +import * as prettierEstreePlugin from 'prettier/plugins/estree'; +import type { Parser, ParserOptions, Plugin, SupportLanguage } from 'prettier'; +import { + parse as parseWithYuku, + type Comment, + type Diagnostic, + type ParseOptions, + type ParseResult, + type SourceLang, + type SourceType, +} from 'yuku-parser'; + +const AST_FORMAT = 'estree-yuku'; +const JSX_REGEXP = /^[^"'`]*<\/|^[^/]{2}.*\/>/m; +const SOURCE_TYPE_COMBINATIONS: SourceType[] = ['module', 'commonjs']; + +type Range = [start: number, end: number]; + +type Locatable = { + end?: number; + range?: Range; + start?: number; + type?: string; +}; + +type AstNode = Locatable & { + [key: string]: unknown; + type: string; +}; + +type PrettierComment = Comment & { + range?: Range; +}; + +type EstreePlugin = typeof prettierEstreePlugin & { + languages: SupportLanguage[]; + options: NonNullable; +}; + +const estreePlugin = prettierEstreePlugin as EstreePlugin; +const estreePrinter = estreePlugin.printers.estree; +const babelParser = prettierBabelParsers.babel; +const locStart = babelParser.locStart as (node: Locatable) => number; +const locEnd = babelParser.locEnd as (node: Locatable) => number; + +const getVisitorKeys = estreePrinter.getVisitorKeys as ((node: AstNode) => string[]) | undefined; + +if (!getVisitorKeys) { + throw new Error('The Prettier ESTree printer does not expose visitor keys.'); +} + +const isAstNode = (value: unknown): value is AstNode => + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + typeof (value as { type?: unknown }).type === 'string'; + +const asAstNode = (value: unknown): AstNode => { + if (!isAstNode(value)) { + throw new TypeError('Expected a Yuku AST node.'); + } + return value; +}; + +const withExtra = (node: AstNode, extra: Record): Record => ({ + ...(node.extra !== null && typeof node.extra === 'object' + ? (node.extra as Record) + : undefined), + ...extra, +}); + +const isIndentableBlockComment = (comment: PrettierComment): boolean => { + if (comment.type !== 'Block' || !comment.value.includes('\n')) { + return false; + } + + for (let line of `*${comment.value}*`.split('\n')) { + line = line.trimStart(); + if (!line.startsWith('*')) { + return false; + } + } + + return true; +}; + +const mergeNestedJsdocComments = (comments: PrettierComment[]): void => { + let followingComment: PrettierComment | undefined; + + for (let index = comments.length - 1; index >= 0; index--) { + const comment = comments[index]; + + if ( + followingComment && + locEnd(comment) === locStart(followingComment) && + isIndentableBlockComment(comment) && + isIndentableBlockComment(followingComment) + ) { + comments.splice(index + 1, 1); + comment.value += `*//*${followingComment.value}`; + comment.range = [locStart(comment), locEnd(followingComment)]; + } + + followingComment = comment; + } +}; + +const stripComments = (originalText: string, comments: PrettierComment[]): string => { + let text = originalText; + + for (const comment of comments) { + const start = locStart(comment); + const end = locEnd(comment); + text = text.slice(0, start) + text.slice(start, end).replace(/[^\n]/g, ' ') + text.slice(end); + } + + return text; +}; + +const CONTENT_END_NODE_TYPES = new Set([ + 'ExpressionStatement', + 'Directive', + 'ImportDeclaration', + 'ExportDefaultDeclaration', + 'ExportNamedDeclaration', + 'ExportAllDeclaration', + 'ReturnStatement', + 'ThrowStatement', + 'DoWhileStatement', +]); + +const setContentEnd = ( + node: AstNode, + originalText: string, + getTextWithoutComments: () => string, +): void => { + if (!CONTENT_END_NODE_TYPES.has(node.type)) { + return; + } + + let end = node.range?.[1] ?? node.end; + if (end === undefined || originalText[end - 1] !== ';') { + return; + } + + end -= 1; + const textWithoutComments = getTextWithoutComments(); + const textBeforeSemicolon = textWithoutComments.slice(locStart(node), end); + const cleanedText = textBeforeSemicolon.trimEnd(); + node.__contentEnd = end - (textBeforeSemicolon.length - cleanedText.length); +}; + +const isTypeCastComment = (comment: PrettierComment): boolean => + comment.type === 'Block' && + comment.value.startsWith('*') && + /@(?:type|satisfies)\b/.test(comment.value); + +type VisitOptions = { + onEnter?: (node: AstNode) => AstNode | undefined; + onLeave?: (node: AstNode) => AstNode | undefined; +}; + +const visitNode = (value: unknown, options: VisitOptions): unknown => { + if (value === null || typeof value !== 'object') { + return value; + } + + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index++) { + value[index] = visitNode(value[index], options); + } + return value; + } + + let node = asAstNode(value); + + if (options.onEnter) { + const result = options.onEnter(node) ?? node; + if (result !== node) { + return visitNode(result, options); + } + node = result; + } + + for (const key of getVisitorKeys(node)) { + node[key] = visitNode(node[key], options); + } + + return options.onLeave?.(node) ?? node; +}; + +const isUnbalancedLogicalTree = (node: AstNode): boolean => { + if (node.type !== 'LogicalExpression' || !isAstNode(node.right)) { + return false; + } + + return node.right.type === 'LogicalExpression' && node.operator === node.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); + + return rebalanceLogicalTree({ + type: 'LogicalExpression', + operator: node.operator, + left: rebalanceLogicalTree({ + type: 'LogicalExpression', + operator: node.operator, + left, + right: rightLeft, + range: [locStart(left), locEnd(rightLeft)], + }), + right: rightRight, + range: [locStart(node), locEnd(node)], + }); +}; + +const postprocess = ( + ast: AstNode, + comments: PrettierComment[], + text: string, + astType: 'yuku-js' | 'yuku-ts', +): AstNode => { + mergeNestedJsdocComments(comments); + + if (isAstNode(ast.hashbang)) { + comments.unshift(ast.hashbang as unknown as PrettierComment); + delete ast.hashbang; + } + + ast.comments = comments; + ast.range = [0, text.length]; + + let textWithoutComments: string | undefined; + const getTextWithoutComments = (): string => { + textWithoutComments ??= stripComments(text, comments); + return textWithoutComments; + }; + let typeCastCommentEnds: number[] | undefined; + + return visitNode(ast, { + onEnter(node) { + setContentEnd(node, text, getTextWithoutComments); + + switch (node.type) { + case 'ParenthesizedExpression': { + const expression = asAstNode(node.expression); + const start = locStart(node); + + typeCastCommentEnds ??= comments + .filter(isTypeCastComment) + .map((comment) => locEnd(comment)); + + const previousCommentEnd = typeCastCommentEnds.findLast((end) => end <= start); + const shouldKeepParentheses = + previousCommentEnd !== undefined && + text.slice(previousCommentEnd, start).trim().length === 0; + + if (shouldKeepParentheses) { + return undefined; + } + + expression.extra = withExtra(expression, { parenthesized: true }); + 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 === 'yuku-ts') { + const start = locStart(node) + 1; + const end = locEnd(node) - (node.tail ? 1 : 2); + node.range = [start, end]; + } + break; + } + + case 'TSParenthesizedType': + return asAstNode(node.typeAnnotation); + + case 'TopicReference': + ast.extra = withExtra(ast, { __isUsingHackPipeline: true }); + break; + + case 'TSUnionType': + case 'TSIntersectionType': { + const types = node.types as unknown[]; + if (types.length === 1) { + return asAstNode(types[0]); + } + break; + } + } + + return undefined; + }, + onLeave(node) { + return isUnbalancedLogicalTree(node) ? rebalanceLogicalTree(node) : undefined; + }, + }) as AstNode; +}; + +const indexToPosition = (text: string, index: number): { column: number; line: number } => { + const lineBreakBefore = index === 0 ? -1 : text.lastIndexOf('\n', index - 1); + let line = 1; + + for (let current = 0; current <= lineBreakBefore; current++) { + if (text[current] === '\n') { + line++; + } + } + + return { + column: index - lineBreakBefore, + line, + }; +}; + +const createParseError = (error: Diagnostic, text: string): Diagnostic | SyntaxError => { + if (typeof error?.start !== 'number' || typeof error?.end !== 'number') { + return error; + } + + const start = indexToPosition(text, error.start); + const end = indexToPosition(text, error.end); + + return Object.assign(new SyntaxError(`${error.message} (${start.line}:${start.column})`), { + cause: error, + loc: { start, end }, + }); +}; + +const parseWithOptions = (text: string, options: ParseOptions): ParseResult => { + const result = parseWithYuku(text, { + preserveParens: true, + semanticErrors: false, + attachComments: false, + ...options, + }); + + if (result.diagnostics.length > 0) { + throw createParseError(result.diagnostics[0], text); + } + + return result; +}; + +const getSourceType = (filepath: unknown): SourceType | undefined => { + if (typeof filepath !== 'string') { + return undefined; + } + + if (/\.(?:mjs|mts)$/i.test(filepath)) { + return 'module'; + } + + if (/\.(?:cjs|cts)$/i.test(filepath)) { + return 'commonjs'; + } + + return undefined; +}; + +const getLanguageCombinations = (text: string, filepath: unknown): SourceLang[] => { + if (typeof filepath === 'string') { + if (/\.(?:jsx|tsx)$/i.test(filepath)) { + return ['tsx']; + } + + if (filepath.toLowerCase().endsWith('.d.ts')) { + return ['dts']; + } + } + + return JSX_REGEXP.test(text) ? ['tsx', 'ts', 'dts'] : ['ts', 'tsx', 'dts']; +}; + +const tryCombinations = (combinations: (() => ParseResult)[]): ParseResult => { + let firstError: unknown; + let hasError = false; + + for (const combination of combinations) { + try { + return combination(); + } catch (error) { + if (!hasError) { + firstError = error; + hasError = true; + } + } + } + + if (hasError) { + throw firstError; + } + + throw new Error('No Yuku parser combinations were provided.'); +}; + +const parseJavaScript = (text: string, options: ParserOptions): AstNode => { + const sourceType = getSourceType(options.filepath); + const combinations = (sourceType ? [sourceType] : SOURCE_TYPE_COMBINATIONS).map( + (candidate) => () => parseWithOptions(text, { sourceType: candidate, lang: 'jsx' }), + ); + const { program, comments } = tryCombinations(combinations); + + return postprocess(program as unknown as AstNode, comments as PrettierComment[], text, 'yuku-js'); +}; + +const parseTypeScript = (text: string, options: ParserOptions): AstNode => { + const sourceType = getSourceType(options.filepath); + const languages = getLanguageCombinations(text, options.filepath); + const combinations = (sourceType ? [sourceType] : SOURCE_TYPE_COMBINATIONS).flatMap((candidate) => + languages.map((lang) => () => parseWithOptions(text, { sourceType: candidate, lang })), + ); + const { program, comments } = tryCombinations(combinations); + + return postprocess(program as unknown as AstNode, comments as PrettierComment[], text, 'yuku-ts'); +}; + +const createParser = ( + parse: (text: string, options: ParserOptions) => AstNode, +): Parser => ({ + astFormat: AST_FORMAT, + hasIgnorePragma: babelParser.hasIgnorePragma, + hasPragma: babelParser.hasPragma, + locEnd, + locStart, + parse, +}); + +const parserNames = new Map([ + ['babel', 'yuku'], + ['typescript', 'yuku-ts'], +]); + +const languages: SupportLanguage[] = estreePlugin.languages.flatMap((language) => { + const parsers = [ + ...new Set( + language.parsers + .map((parser) => parserNames.get(parser)) + .filter((parser): parser is string => parser !== undefined), + ), + ]; + + return parsers.length > 0 ? [{ ...language, parsers }] : []; +}); + +const yukuPlugin: Plugin = { + languages, + options: estreePlugin.options, + parsers: { + yuku: createParser(parseJavaScript), + 'yuku-ts': createParser(parseTypeScript), + }, + printers: { + [AST_FORMAT]: estreePrinter, + }, +}; + +export { yukuPlugin }; diff --git a/packages/rstack/tests/fmt/yukuPlugin.test.ts b/packages/rstack/tests/fmt/yukuPlugin.test.ts new file mode 100644 index 0000000..db4302c --- /dev/null +++ b/packages/rstack/tests/fmt/yukuPlugin.test.ts @@ -0,0 +1,164 @@ +import { format, getFileInfo, type Options, type ParserOptions } from 'prettier'; +import { expect, test } from 'rstack/test'; +import { yukuPlugin } from '../../src/fmt/yukuPlugin.ts'; + +const formatWithYuku = ( + source: string, + options: Options & { parser: 'yuku' | 'yuku-ts' }, +): Promise => + format(source, { + filepath: `example.${options.parser === 'yuku' ? 'js' : 'ts'}`, + plugins: [yukuPlugin], + ...options, + }); + +test('exposes the same JavaScript and TypeScript language mappings as the official plugin', async () => { + expect(yukuPlugin.languages?.map(({ name, parsers }) => ({ name, parsers }))).toEqual([ + { name: 'JavaScript', parsers: ['yuku', 'yuku-ts'] }, + { name: 'JSX', parsers: ['yuku', 'yuku-ts'] }, + { name: 'TypeScript', parsers: ['yuku-ts'] }, + { name: 'TSX', parsers: ['yuku-ts'] }, + ]); + + await expect( + Promise.all( + ['js', 'jsx', 'ts', 'tsx'].map(async (extension) => + getFileInfo(`example.${extension}`, { plugins: [yukuPlugin] }), + ), + ), + ).resolves.toEqual([ + { ignored: false, inferredParser: 'yuku' }, + { ignored: false, inferredParser: 'yuku' }, + { ignored: false, inferredParser: 'yuku-ts' }, + { ignored: false, inferredParser: 'yuku-ts' }, + ]); +}); + +test.each([ + { + name: 'hashbangs and unicode locations', + parser: 'yuku' as const, + source: '#!/usr/bin/env node\n// 中文 😀\nconst 你好={值:"😀"}', + expected: '#!/usr/bin/env node\n// 中文 😀\nconst 你好 = { 值: "😀" };\n', + }, + { + name: 'Closure-style type casts', + parser: 'yuku' as const, + source: '/** @type {Foo} */ (value).method()', + expected: '/** @type {Foo} */ (value).method();\n', + }, + { + name: 'comments before semicolons', + parser: 'yuku' as const, + source: 'foo /* trailing */ ;', + expected: 'foo; /* trailing */\n', + }, + { + name: 'adjacent multiline JSDoc comments', + parser: 'yuku' 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: 'yuku' as const, + source: 'const value = a || (b || c)', + expected: 'const value = a || b || c;\n', + }, + { + name: 'parenthesized TypeScript types', + parser: 'yuku-ts' as const, + source: 'type Value = (((string | number)));', + expected: 'type Value = string | number;\n', + }, + { + name: 'TypeScript template expressions', + parser: 'yuku-ts' as const, + source: 'const result = `value: ${foo satisfies string}`', + expected: 'const result = `value: ${foo satisfies string}`;\n', + }, + { + name: 'TSX expressions', + parser: 'yuku-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( + formatWithYuku(fixture.source, { + filepath: fixture.filepath, + parser: fixture.parser, + }), + ).resolves.toBe(fixture.expected); +}); + +test('reuses Prettier options and pragma handling', async () => { + await expect( + formatWithYuku('/** @format */\nconst value={answer:"yes"}', { + parser: 'yuku', + requirePragma: true, + singleQuote: true, + }), + ).resolves.toBe("/** @format */\nconst value = { answer: 'yes' };\n"); + + await expect( + formatWithYuku('/** @noformat */\nconst value={answer:"yes"}', { + checkIgnorePragma: true, + parser: 'yuku', + }), + ).resolves.toBe('/** @noformat */\nconst value={answer:"yes"}'); +}); + +test('supports CommonJS source semantics for .cjs files', async () => { + await expect( + formatWithYuku('return require("example")', { + filepath: 'example.cjs', + parser: 'yuku', + }), + ).resolves.toBe('return require("example");\n'); +}); + +test('matches the official hashbang AST shape', async () => { + const parser = yukuPlugin.parsers?.yuku; + if (!parser) { + throw new Error('The Yuku 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; + + expect(Object.hasOwn(astWithoutHashbang, 'hashbang')).toBe(true); + expect(astWithoutHashbang.hashbang).toBeNull(); + expect(Object.hasOwn(astWithHashbang, 'hashbang')).toBe(false); +}); + +test('reports Yuku diagnostics with Prettier locations', async () => { + try { + await formatWithYuku('\n\nconst = 1', { parser: 'yuku-ts' }); + throw new Error('Expected Yuku to report a syntax error.'); + } catch (error) { + if (!(error instanceof SyntaxError)) { + throw error; + } + + const parseError = error as SyntaxError & { + loc: { + end: { column: number; line: number }; + start: { column: number; line: number }; + }; + }; + expect(Object.keys(parseError.loc)).toEqual(['start', 'end']); + expect(parseError.loc).toEqual({ + start: { column: 7, line: 3 }, + end: { column: 8, line: 3 }, + }); + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08551dc..8c3c623 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,9 +7,6 @@ settings: catalogs: default: - '@prettier/plugin-yuku': - specifier: 0.0.1 - version: 0.0.1 '@rsbuild/core': specifier: ~2.1.9 version: 2.1.9 @@ -137,9 +134,6 @@ catalogs: specifier: 0.8.3 version: 0.8.3 -overrides: - '@prettier/plugin-yuku>yuku-parser': 0.8.3 - importers: .: @@ -355,9 +349,6 @@ importers: specifier: 'catalog:' version: 0.8.3 devDependencies: - '@prettier/plugin-yuku': - specifier: 'catalog:' - version: 0.0.1 '@rspress/core': specifier: 'catalog:' version: 2.0.19(micromark-util-types@2.0.2)(micromark@4.0.2)(supports-color@8.1.1) @@ -652,10 +643,6 @@ packages: resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} engines: {node: '>= 10.0.0'} - '@prettier/plugin-yuku@0.0.1': - resolution: {integrity: sha512-6Z3XcE5afL5pdBcG5KSl2eZ6HvNR0erXJn7FjR5OWeSoajrQPAIOpVCSZ+CXnJJSwUKi/UcqvUMcuYjRDd2IKQ==} - engines: {node: '>=14'} - '@rsbuild/core@2.1.8': resolution: {integrity: sha512-Y70LMcCZspVoQ7Oip1W2Agu5wVhWZ2x3cYl4s9GLQG4VYphBud53DY/jkrqkyQF8ASYZDDDrW2KWNFj0kNLLuA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2592,10 +2579,6 @@ snapshots: '@parcel/watcher-win32-x64': 2.5.6 optional: true - '@prettier/plugin-yuku@0.0.1': - dependencies: - yuku-parser: 0.8.3 - '@rsbuild/core@2.1.8': dependencies: '@rspack/core': 2.1.5(@swc/helpers@0.5.23) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c4c1735..b19b744 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,7 +12,6 @@ catalogMode: prefer cleanupUnusedCatalogs: true catalog: - '@prettier/plugin-yuku': '0.0.1' '@rsbuild/core': '~2.1.9' '@rsbuild/plugin-react': '^2.1.0' '@rsbuild/plugin-sass': '^2.0.1' @@ -56,9 +55,6 @@ catalog: 'typescript': '^7.0.2' yuku-parser: '0.8.3' -overrides: - '@prettier/plugin-yuku>yuku-parser': 'catalog:' - dedupePeers: true autoInstallPeers: false From ebb48cac56fe8fd3a0b5d0fbabbb7c9f6e24455b Mon Sep 17 00:00:00 2001 From: neverland Date: Mon, 3 Aug 2026 22:24:10 +0800 Subject: [PATCH 2/4] fix(ci): satisfy spell checks --- packages/rstack/THIRD_PARTY_NOTICES.md | 2 +- scripts/dictionary.txt | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/rstack/THIRD_PARTY_NOTICES.md b/packages/rstack/THIRD_PARTY_NOTICES.md index 78ab78d..048171d 100644 --- a/packages/rstack/THIRD_PARTY_NOTICES.md +++ b/packages/rstack/THIRD_PARTY_NOTICES.md @@ -29,7 +29,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -## Prettier Yuku parser adapter +## Prettier yuku parser adapter The local Yuku parser adapter includes portions derived from [@prettier/plugin-yuku](https://github.com/prettier/prettier/tree/main/packages/plugin-yuku) diff --git a/scripts/dictionary.txt b/scripts/dictionary.txt index 754f9f3..6a10682 100644 --- a/scripts/dictionary.txt +++ b/scripts/dictionary.txt @@ -5,8 +5,11 @@ errexit extglob fnames huskyrc +indentable llms +noformat nosystem +quasis rsbuild rslib rslint From 01aba4a96338ec42a045d9cf0d33b461a64f3cb9 Mon Sep 17 00:00:00 2001 From: neverland Date: Mon, 3 Aug 2026 22:46:07 +0800 Subject: [PATCH 3/4] perf(fmt): avoid loading Prettier Babel plugin --- packages/rstack/src/fmt/yukuPlugin.ts | 117 +++++++++++++++--- packages/rstack/tests/fmt/yukuPlugin.test.ts | 119 +++++++++++++++++++ 2 files changed, 218 insertions(+), 18 deletions(-) diff --git a/packages/rstack/src/fmt/yukuPlugin.ts b/packages/rstack/src/fmt/yukuPlugin.ts index bef0693..20a5635 100644 --- a/packages/rstack/src/fmt/yukuPlugin.ts +++ b/packages/rstack/src/fmt/yukuPlugin.ts @@ -1,4 +1,3 @@ -import { parsers as prettierBabelParsers } from 'prettier/plugins/babel'; import * as prettierEstreePlugin from 'prettier/plugins/estree'; import type { Parser, ParserOptions, Plugin, SupportLanguage } from 'prettier'; import { @@ -18,7 +17,15 @@ const SOURCE_TYPE_COMBINATIONS: SourceType[] = ['module', 'commonjs']; type Range = [start: number, end: number]; type Locatable = { + __contentEnd?: number; + alternate?: Locatable | null; + body?: Locatable; + consequent?: Locatable; + declaration?: { decorators?: Locatable[] }; + declarations?: Locatable[]; + decorators?: Locatable[]; end?: number; + label?: Locatable | null; range?: Range; start?: number; type?: string; @@ -40,9 +47,95 @@ type EstreePlugin = typeof prettierEstreePlugin & { const estreePlugin = prettierEstreePlugin as EstreePlugin; const estreePrinter = estreePlugin.printers.estree; -const babelParser = prettierBabelParsers.babel; -const locStart = babelParser.locStart as (node: Locatable) => number; -const locEnd = babelParser.locEnd as (node: Locatable) => number; + +const CONTENT_END_NODE_TYPES = new Set([ + 'ExpressionStatement', + 'Directive', + 'ImportDeclaration', + 'ExportDefaultDeclaration', + 'ExportNamedDeclaration', + 'ExportAllDeclaration', + 'ReturnStatement', + 'ThrowStatement', + 'DoWhileStatement', +]); + +/** Mirrors Prettier's JavaScript location helpers without loading its Babel plugin. */ +const locStart = (node: Locatable): number => { + const start = (node.range?.[0] ?? node.start) as number; + const firstDecorator = (node.declaration?.decorators ?? node.decorators)?.[0]; + + return firstDecorator ? Math.min(locStart(firstDecorator), start) : start; +}; + +const locEndWithFullText = (node: Locatable): number => (node.range?.[1] ?? node.end) as number; + +const locEnd = (node: Locatable): number => { + switch (node.type) { + case 'IfStatement': + return locEnd((node.alternate ?? node.consequent) as Locatable); + + case 'ForInStatement': + case 'ForOfStatement': + case 'ForStatement': + case 'LabeledStatement': + case 'WithStatement': + case 'WhileStatement': + return locEnd(node.body as Locatable); + + case 'BreakStatement': + return node.label ? locEnd(node.label) : locStart(node) + 'break'.length; + + case 'ContinueStatement': + return node.label ? locEnd(node.label) : locStart(node) + 'continue'.length; + + case 'DebuggerStatement': + return locStart(node) + 'debugger'.length; + + case 'VariableDeclaration': + return locEnd(node.declarations?.at(-1) as Locatable); + + default: + return CONTENT_END_NODE_TYPES.has(node.type ?? '') + ? (node.__contentEnd ?? locEndWithFullText(node)) + : locEndWithFullText(node); + } +}; + +const DOCBLOCK_REGEXP = /^\s*(\/\*\*?(.|\r?\n)*?\*\/)/; +const COMMENT_END_REGEXP = /\*\/$/; +const COMMENT_START_REGEXP = /^\/\*\*?/; +const DOCBLOCK_LINE_START_REGEXP = /(\r?\n|^) *\* ?/g; +const PRAGMA_REGEXP = /(?:^|\r?\n) *@(\S+) *([^\n\r]*)/g; +const FORMAT_PRAGMAS = new Set(['format', 'prettier']); +const FORMAT_IGNORE_PRAGMAS = new Set(['noformat', 'noprettier']); + +/** Matches Prettier's leading JavaScript docblock pragma handling. */ +const hasPragmaFrom = (originalText: string, pragmas: Set): boolean => { + let text = originalText; + + if (text.startsWith('#!')) { + const lineEnd = text.indexOf('\n'); + text = text.slice((lineEnd === -1 ? text.length : lineEnd) + 1); + } + + const docblock = (text.match(DOCBLOCK_REGEXP)?.[0] ?? '') + .trimStart() + .replace(COMMENT_START_REGEXP, '') + .replace(COMMENT_END_REGEXP, '') + .replaceAll(DOCBLOCK_LINE_START_REGEXP, '$1'); + + for (const match of docblock.matchAll(PRAGMA_REGEXP)) { + if (pragmas.has(match[1])) { + return true; + } + } + + return false; +}; + +const hasPragma = (text: string): boolean => hasPragmaFrom(text, FORMAT_PRAGMAS); +const hasIgnorePragma = (text: string): boolean => hasPragmaFrom(text, FORMAT_IGNORE_PRAGMAS); const getVisitorKeys = estreePrinter.getVisitorKeys as ((node: AstNode) => string[]) | undefined; @@ -118,18 +211,6 @@ const stripComments = (originalText: string, comments: PrettierComment[]): strin return text; }; -const CONTENT_END_NODE_TYPES = new Set([ - 'ExpressionStatement', - 'Directive', - 'ImportDeclaration', - 'ExportDefaultDeclaration', - 'ExportNamedDeclaration', - 'ExportAllDeclaration', - 'ReturnStatement', - 'ThrowStatement', - 'DoWhileStatement', -]); - const setContentEnd = ( node: AstNode, originalText: string, @@ -437,8 +518,8 @@ const createParser = ( parse: (text: string, options: ParserOptions) => AstNode, ): Parser => ({ astFormat: AST_FORMAT, - hasIgnorePragma: babelParser.hasIgnorePragma, - hasPragma: babelParser.hasPragma, + hasIgnorePragma, + hasPragma, locEnd, locStart, parse, diff --git a/packages/rstack/tests/fmt/yukuPlugin.test.ts b/packages/rstack/tests/fmt/yukuPlugin.test.ts index db4302c..5d22144 100644 --- a/packages/rstack/tests/fmt/yukuPlugin.test.ts +++ b/packages/rstack/tests/fmt/yukuPlugin.test.ts @@ -110,6 +110,125 @@ test('reuses Prettier options and pragma handling', async () => { ).resolves.toBe('/** @noformat */\nconst value={answer:"yes"}'); }); +test.each([ + { + source: '/** @prettier */\nconst value=1', + hasPragma: true, + hasIgnorePragma: false, + }, + { + source: '/* @format */\nconst value=1', + hasPragma: true, + hasIgnorePragma: false, + }, + { + source: '#!/usr/bin/env node\r\n/** @format */\r\nconst value=1', + hasPragma: true, + hasIgnorePragma: false, + }, + { + source: '/**\n * @prettier\n * @noformat\n */\nconst value=1', + hasPragma: true, + hasIgnorePragma: true, + }, + { + source: '/** @prettier @noformat */\nconst value=1', + hasPragma: true, + hasIgnorePragma: false, + }, + { + source: '/** text @prettier */\nconst value=1', + hasPragma: false, + hasIgnorePragma: false, + }, + { + source: '// before\n/** @prettier */\nconst value=1', + hasPragma: false, + hasIgnorePragma: false, + }, +])('matches Prettier pragma detection for $source', ({ source, hasPragma, hasIgnorePragma }) => { + const parser = yukuPlugin.parsers?.yuku; + if (!parser?.hasPragma || !parser.hasIgnorePragma) { + throw new Error('The Yuku parser does not expose pragma handlers.'); + } + + expect(parser.hasPragma(source)).toBe(hasPragma); + expect(parser.hasIgnorePragma(source)).toBe(hasIgnorePragma); +}); + +test('matches Prettier JavaScript location overrides', () => { + const parser = yukuPlugin.parsers?.yuku; + if (!parser) { + throw new Error('The Yuku parser is not registered.'); + } + + expect( + parser.locStart({ + type: 'ClassDeclaration', + range: [10, 80], + decorators: [{ type: 'Decorator', range: [2, 9] }], + }), + ).toBe(2); + + expect( + parser.locStart({ + type: 'ExportNamedDeclaration', + range: [10, 80], + declaration: { decorators: [{ type: 'Decorator', range: [2, 9] }] }, + }), + ).toBe(2); + + const endCases = [ + { + expected: 44, + node: { + type: 'IfStatement', + range: [0, 50], + consequent: { type: 'BlockStatement', range: [3, 20] }, + alternate: { type: 'BlockStatement', range: [21, 44] }, + }, + }, + { + expected: 45, + node: { + type: 'ForStatement', + range: [0, 50], + body: { type: 'BlockStatement', range: [20, 45] }, + }, + }, + { expected: 15, node: { type: 'BreakStatement', range: [10, 50] } }, + { + expected: 21, + node: { + type: 'BreakStatement', + range: [10, 50], + label: { type: 'Identifier', range: [16, 21] }, + }, + }, + { expected: 18, node: { type: 'ContinueStatement', range: [10, 50] } }, + { expected: 18, node: { type: 'DebuggerStatement', range: [10, 50] } }, + { + expected: 22, + node: { + type: 'VariableDeclaration', + range: [0, 30], + declarations: [ + { type: 'VariableDeclarator', range: [4, 10] }, + { type: 'VariableDeclarator', range: [12, 22] }, + ], + }, + }, + { + expected: 10, + node: { type: 'ExpressionStatement', range: [0, 12], __contentEnd: 10 }, + }, + ]; + + for (const { node, expected } of endCases) { + expect(parser.locEnd(node)).toBe(expected); + } +}); + test('supports CommonJS source semantics for .cjs files', async () => { await expect( formatWithYuku('return require("example")', { From 5f85a8a93203c12ea8eda7574a7a7b20aeecffc3 Mon Sep 17 00:00:00 2001 From: neverland Date: Tue, 4 Aug 2026 08:20:30 +0800 Subject: [PATCH 4/4] fix(ci): add noprettier to dictionary --- scripts/dictionary.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/dictionary.txt b/scripts/dictionary.txt index 6a10682..942bbe6 100644 --- a/scripts/dictionary.txt +++ b/scripts/dictionary.txt @@ -8,6 +8,7 @@ huskyrc indentable llms noformat +noprettier nosystem quasis rsbuild