diff --git a/packages/rstack/src/fmt/yukuPlugin.ts b/packages/rstack/src/fmt/yukuPlugin.ts index 20a5635..cf517ad 100644 --- a/packages/rstack/src/fmt/yukuPlugin.ts +++ b/packages/rstack/src/fmt/yukuPlugin.ts @@ -1,17 +1,16 @@ import * as prettierEstreePlugin from 'prettier/plugins/estree'; import type { Parser, ParserOptions, Plugin, SupportLanguage } from 'prettier'; import { + langFromPath, 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]; @@ -200,15 +199,24 @@ const mergeNestedJsdocComments = (comments: PrettierComment[]): void => { }; const stripComments = (originalText: string, comments: PrettierComment[]): string => { - let text = originalText; + if (comments.length === 0) { + return originalText; + } + const chunks: string[] = []; + let cursor = 0; + + // Yuku returns comments in source order, so mask each range while copying the source only once. 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); + chunks.push(originalText.slice(cursor, start)); + chunks.push(originalText.slice(start, end).replace(/[^\n]/g, ' ')); + cursor = end; } - return text; + chunks.push(originalText.slice(cursor)); + return chunks.join(''); }; const setContentEnd = ( @@ -441,11 +449,7 @@ const parseWithOptions = (text: string, options: ParseOptions): ParseResult => { return result; }; -const getSourceType = (filepath: unknown): SourceType | undefined => { - if (typeof filepath !== 'string') { - return undefined; - } - +const getSourceType = (filepath: string): SourceType | undefined => { if (/\.(?:mjs|mts)$/i.test(filepath)) { return 'module'; } @@ -457,20 +461,6 @@ const getSourceType = (filepath: unknown): SourceType | undefined => { 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; @@ -505,9 +495,9 @@ const parseJavaScript = (text: string, options: ParserOptions): AstNode 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 lang = langFromPath(options.filepath.toLowerCase()); + const combinations = (sourceType ? [sourceType] : SOURCE_TYPE_COMBINATIONS).map( + (candidate) => () => parseWithOptions(text, { sourceType: candidate, lang }), ); const { program, comments } = tryCombinations(combinations); diff --git a/packages/rstack/tests/fmt/yukuPlugin.test.ts b/packages/rstack/tests/fmt/yukuPlugin.test.ts index 5d22144..b4841ce 100644 --- a/packages/rstack/tests/fmt/yukuPlugin.test.ts +++ b/packages/rstack/tests/fmt/yukuPlugin.test.ts @@ -7,9 +7,9 @@ const formatWithYuku = ( options: Options & { parser: 'yuku' | 'yuku-ts' }, ): Promise => format(source, { - filepath: `example.${options.parser === 'yuku' ? 'js' : 'ts'}`, plugins: [yukuPlugin], ...options, + filepath: options.filepath ?? `example.${options.parser === 'yuku' ? 'js' : 'ts'}`, }); test('exposes the same JavaScript and TypeScript language mappings as the official plugin', async () => { @@ -34,6 +34,39 @@ test('exposes the same JavaScript and TypeScript language mappings as the offici ]); }); +test('parses JSX in JavaScript files', async () => { + await expect( + formatWithYuku('const view=', { + filepath: 'example.js', + parser: 'yuku', + }), + ).resolves.toBe('const view = ;\n'); +}); + +test.each(['example.ts', 'example.mts', 'example.cts'])( + 'rejects JSX syntax in %s', + async (filepath) => { + await expect( + formatWithYuku('const view=', { + filepath, + parser: 'yuku-ts', + }), + ).rejects.toThrow(); + }, +); + +test.each(['example.d.ts', 'example.d.mts', 'example.d.cts'])( + 'rejects function implementations in %s', + async (filepath) => { + await expect( + formatWithYuku('export function value() { return 1; }', { + filepath, + parser: 'yuku-ts', + }), + ).rejects.toThrow('An implementation cannot be declared in ambient contexts'); + }, +); + test.each([ { name: 'hashbangs and unicode locations',