diff --git a/.agents/skills/migrate-to-rstack-cli/references/prettier.md b/.agents/skills/migrate-to-rstack-cli/references/prettier.md index ace43032..8303012d 100644 --- a/.agents/skills/migrate-to-rstack-cli/references/prettier.md +++ b/.agents/skills/migrate-to-rstack-cli/references/prettier.md @@ -15,6 +15,8 @@ Read this reference when the project uses the `prettier` CLI or API, `package.js 7. Delete old config and ignore files only after their behavior is represented in `define.fmt`. 8. Remove direct dependencies only when no script, config, API call, plugin peer requirement, or other tool still needs them. +`rs fmt` ignores `package-lock.json` and `pnpm-lock.yaml` by default. Drop redundant ignore entries during migration, but keep intentional negations. + `rs fmt` does not read Prettier configuration files, `.prettierignore`, or `.editorconfig`. Keep `.editorconfig` when editors or other tools use it. Keep Prettier when application code uses APIs such as `prettier.format()`; `rs fmt` is not a drop-in replacement for the programmatic API. diff --git a/packages/rstack/THIRD_PARTY_NOTICES.md b/packages/rstack/THIRD_PARTY_NOTICES.md index 2e0e08c8..048171dc 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 1de706e8..6bd9e14d 100644 --- a/packages/rstack/package.json +++ b/packages/rstack/package.json @@ -1,6 +1,6 @@ { "name": "rstack", - "version": "0.3.1", + "version": "0.3.2", "repository": "https://github.com/rstackjs/rstack-cli", "license": "MIT", "type": "module", @@ -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/rslib.config.ts b/packages/rstack/rslib.config.ts index 45704b75..662a0fba 100644 --- a/packages/rstack/rslib.config.ts +++ b/packages/rstack/rslib.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from '@rslib/core'; import pkgJson from './package.json' with { type: 'json' }; -const fullyMinifiedChunks = /lintStaged\.js$/; +const fullyMinifiedChunks = /(?:fmt(?:Plugins)?|sortPackageJsonPlugin|staged)\.js$/; export default defineConfig({ lib: [{ syntax: 'es2023', dts: true }], @@ -32,7 +32,7 @@ export default defineConfig({ css: false, jsOptions: [ { - // Fully minify the bundled lint-staged code to reduce package size. + // Fully minify formatter and staged command bundles to reduce package size. include: fullyMinifiedChunks, }, { diff --git a/packages/rstack/src/cli/commands.ts b/packages/rstack/src/cli/commands.ts index 315ce4ca..cbf2eae0 100644 --- a/packages/rstack/src/cli/commands.ts +++ b/packages/rstack/src/cli/commands.ts @@ -1,8 +1,6 @@ import { join } from 'node:path'; import { color } from 'rslog'; import { getConfigState } from '../config.ts'; -import { runSetupCLI } from '../setup/index.ts'; -import { runStagedCLI } from '../staged.ts'; import { insertConfigArg, parseCliArgs } from './args.ts'; declare global { @@ -154,11 +152,19 @@ export async function setupCommands(): Promise { } if (command === 'staged') { + const { runStagedCLI } = await import( + /* rspackChunkName: 'staged' */ + '../staged.ts' + ); await runStagedCLI(args.slice(1)); return; } if (command === 'setup') { + const { runSetupCLI } = await import( + /* rspackChunkName: 'setup' */ + '../setup/index.ts' + ); runSetupCLI(args.slice(1)); return; } diff --git a/packages/rstack/src/fmt/cli.ts b/packages/rstack/src/fmt/cli.ts index d16c6b67..06fda1d1 100644 --- a/packages/rstack/src/fmt/cli.ts +++ b/packages/rstack/src/fmt/cli.ts @@ -6,13 +6,15 @@ import { loadRstackConfig } from '../config.ts'; import { resolveFmtConfig } from './config.ts'; import { discoverFmtFiles } from './discovery.ts'; import { runFmtFiles } from './runner.ts'; -import type { FmtMode, FmtRunResult } from './types.ts'; +import type { FmtMode, FmtRunResult, ResolvedFmtConfig } from './types.ts'; interface ParsedFmtCLIArgs { mode: FmtMode; patterns: string[]; maxWorkers?: number; help: boolean; + /** Path the stdin content is formatted as; it need not exist on disk. */ + stdinFilepath?: string; } const fmtHelpMessage: string = `Rstack v${RSTACK_VERSION} @@ -27,6 +29,7 @@ ${color.cyan('Options')}: --check Check whether files are formatted --list-different Print paths of unformatted files --parallel-workers Number of parallel workers + --stdin-filepath Format stdin as if it were saved at -h, --help Display this help message`; const parseMaxWorkers = ( @@ -56,6 +59,8 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { listDifferent: { type: 'boolean' }, 'parallel-workers': { type: 'string' }, parallelWorkers: { type: 'string' }, + 'stdin-filepath': { type: 'string' }, + stdinFilepath: { type: 'string' }, help: { type: 'boolean', short: 'h' }, }, allowPositionals: true, @@ -70,12 +75,26 @@ const parseFmtCLIArgs = (args: string[]): ParsedFmtCLIArgs => { const mode = values.check ? 'check' : listDifferent ? 'list-different' : 'write'; const maxWorkers = parseMaxWorkers(values['parallel-workers'], values.parallelWorkers); + const stdinFilepath = values['stdin-filepath'] ?? values.stdinFilepath; + + if (stdinFilepath !== undefined) { + if (modes.length > 0) { + throw new Error( + 'The --stdin-filepath option cannot be used with --write, --check, or --list-different.', + ); + } + + if (positionals.length > 0) { + throw new Error('The --stdin-filepath option cannot be used with file arguments.'); + } + } return { mode, patterns: positionals, maxWorkers, help: values.help ?? false, + stdinFilepath, }; }; @@ -174,23 +193,39 @@ const logFmtResult = ( } }; -const runFmtCLI = async (args: string[]): Promise => { - const { help, maxWorkers, mode, patterns } = parseFmtCLIArgs(args); - if (help) { - logger.log(fmtHelpMessage); - return; - } +const loadFmtConfig = async (cwd: string): Promise => { + const { configs, filePath } = await loadRstackConfig(); + return resolveFmtConfig({ + definition: configs.fmt, + configFilePath: filePath, + cwd, + }); +}; + +const runFmtCLI = async (args: string[]): Promise => { const cwd = process.cwd(); const startTime = performance.now(); + // Argument errors are reported like every other failure so that a single + // exit code identifies "rs fmt refused to run". try { - const { configs, filePath } = await loadRstackConfig(); - const config = await resolveFmtConfig({ - definition: configs.fmt, - configFilePath: filePath, - cwd, - }); + const { help, maxWorkers, mode, patterns, stdinFilepath } = parseFmtCLIArgs(args); + if (help) { + logger.log(fmtHelpMessage); + return; + } + + if (stdinFilepath !== undefined) { + const { runFmtStdin } = await import( + /* rspackChunkName: 'fmtStdin' */ + './stdin.ts' + ); + await runFmtStdin({ filepath: stdinFilepath, cwd, loadConfig: () => loadFmtConfig(cwd) }); + return; + } + + const config = await loadFmtConfig(cwd); const files = await discoverFmtFiles({ cwd, patterns, config }); if (files.length === 0) { diff --git a/packages/rstack/src/fmt/discoverPaths.ts b/packages/rstack/src/fmt/discoverPaths.ts index 9f9fe7f4..896aba0b 100644 --- a/packages/rstack/src/fmt/discoverPaths.ts +++ b/packages/rstack/src/fmt/discoverPaths.ts @@ -164,6 +164,15 @@ class GitIgnoreMatcher { } #matches(relativePath: string, isDirectory: boolean): boolean { + // Most repositories only use a root `.gitignore`. Avoid checking every path + // segment when no nested matcher can override its result. + const rootMatcher = this.#matchers.size === 1 ? this.#matchers.get(this.#rootPath) : undefined; + if (rootMatcher) { + // `ignore` expects POSIX separators and uses a trailing slash to distinguish directories. + const pathFromMatcher = toPosixPath(relativePath); + return rootMatcher.test(isDirectory ? `${pathFromMatcher}/` : pathFromMatcher).ignored; + } + const segments = relativePath.split(path.sep); let directoryPath = this.#rootPath; let pathFromMatcher = segments.join('/'); diff --git a/packages/rstack/src/fmt/discovery.ts b/packages/rstack/src/fmt/discovery.ts index 591560fd..9a90a287 100644 --- a/packages/rstack/src/fmt/discovery.ts +++ b/packages/rstack/src/fmt/discovery.ts @@ -1,16 +1,11 @@ import { resolveFmtOptions } from './config.ts'; import { discoverFmtPaths } from './discoverPaths.ts'; import { createFmtIgnoreMatcher } from './ignore.ts'; -import { createFmtPluginResolver, type FmtPluginResolver } from './plugins.ts'; import type { DiscoverFmtFilesOptions, FmtFileRequest, ResolvedFmtConfig } from './types.ts'; -const createFileRequest = ( - filePath: string, - config: ResolvedFmtConfig, - resolvePlugins: FmtPluginResolver, -): FmtFileRequest => ({ +const createFileRequest = (filePath: string, config: ResolvedFmtConfig): FmtFileRequest => ({ path: filePath, - options: resolvePlugins(resolveFmtOptions(filePath, config)), + options: resolveFmtOptions(filePath, config), }); /** Discovers worker-ready files without reading Prettier config files or `.prettierignore`. */ @@ -24,13 +19,20 @@ const discoverFmtFiles = async ({ return []; } - const isFmtIgnored = config.ignorePatterns.length ? createFmtIgnoreMatcher(config) : undefined; - const filePaths = isFmtIgnored - ? candidates.filter((filePath) => !isFmtIgnored(filePath)) - : candidates; + const isFmtIgnored = createFmtIgnoreMatcher(config); + const filePaths = candidates.filter((filePath) => !isFmtIgnored(filePath)); + const files = filePaths.map((filePath) => createFileRequest(filePath, config)); + if (!files.some((file) => file.options.plugins?.length)) { + return files; + } + + const { createFmtPluginResolver } = await import( + /* rspackChunkName: 'fmtPlugins' */ + './plugins.ts' + ); const resolvePlugins = createFmtPluginResolver(config.rootPath); - return filePaths.map((filePath) => createFileRequest(filePath, config, resolvePlugins)); + return files.map((file) => ({ ...file, options: resolvePlugins(file.options) })); }; -export { discoverFmtFiles }; +export { createFileRequest, discoverFmtFiles }; diff --git a/packages/rstack/src/fmt/format.ts b/packages/rstack/src/fmt/format.ts new file mode 100644 index 00000000..0aa653af --- /dev/null +++ b/packages/rstack/src/fmt/format.ts @@ -0,0 +1,59 @@ +// Derived from @prettier/cli, see THIRD_PARTY_NOTICES.md + +import { + format, + getFileInfo, + type FileInfoOptions, + type Options as PrettierOptions, +} from 'prettier'; +import { getPrettierPlugins } from './prettierPlugins.ts'; +import type { FmtFileRequest } from './types.ts'; + +type PrettierPlugins = NonNullable; + +type FormatFmtSourceResult = + { status: 'unsupported' } | { status: 'formatted'; source: string; formatted: string }; + +const fileInfoOptions = { + ignorePath: [], + resolveConfig: false, + withNodeModules: true, +} satisfies FileInfoOptions; + +/** Uses the configured parser or infers one without loading Prettier config. */ +const resolveFmtParser = async ( + filePath: string, + options: PrettierOptions, + plugins: PrettierPlugins, +): Promise => + options.parser ?? + ( + await getFileInfo(filePath, { + ...fileInfoOptions, + plugins, + }) + ).inferredParser; + +/** Formats file contents, requesting the source only once a parser is known. */ +const formatFmtSource = async ( + { path, options }: FmtFileRequest, + readSource: () => string, +): Promise => { + const plugins = await getPrettierPlugins(options, path); + const parser = await resolveFmtParser(path, options, plugins); + if (!parser) { + return { status: 'unsupported' }; + } + + const source = readSource(); + const formatted = await format(source, { + ...options, + filepath: path, + parser, + plugins, + }); + + return { status: 'formatted', source, formatted }; +}; + +export { formatFmtSource }; diff --git a/packages/rstack/src/fmt/ignore.ts b/packages/rstack/src/fmt/ignore.ts index 09b33bca..41f80bf7 100644 --- a/packages/rstack/src/fmt/ignore.ts +++ b/packages/rstack/src/fmt/ignore.ts @@ -2,9 +2,17 @@ import { relative } from 'node:path'; import fastIgnore from 'fast-ignore'; import type { ResolvedFmtConfig } from './types.ts'; -/** Creates a reusable matcher for config-level ignore patterns. */ +/** + * Common lock files that Prettier can format but `rs fmt` leaves to package managers. + * + * Prettier already skips other generated lock files when it cannot infer a parser, so this list + * contains only the additional defaults owned by `rs fmt`. + */ +const defaultIgnorePatterns = ['package-lock.json', 'pnpm-lock.yaml']; + +/** Creates a reusable matcher for default and config-level ignore patterns. */ const createFmtIgnoreMatcher = (config: ResolvedFmtConfig): ((filePath: string) => boolean) => { - const matches = fastIgnore(config.ignorePatterns.join('\n')); + const matches = fastIgnore([...defaultIgnorePatterns, ...config.ignorePatterns].join('\n')); return (filePath) => matches(relative(config.rootPath, filePath)); }; diff --git a/packages/rstack/src/fmt/prettierPlugins.ts b/packages/rstack/src/fmt/prettierPlugins.ts index c5757587..829d7e6d 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/stdin.ts b/packages/rstack/src/fmt/stdin.ts new file mode 100644 index 00000000..dc946dc3 --- /dev/null +++ b/packages/rstack/src/fmt/stdin.ts @@ -0,0 +1,84 @@ +import { resolve } from 'node:path'; +import { createFileRequest } from './discovery.ts'; +import { formatFmtSource } from './format.ts'; +import { createFmtIgnoreMatcher } from './ignore.ts'; +import type { ResolvedFmtConfig } from './types.ts'; + +interface RunFmtStdinOptions { + /** Path used for per-file options and parser inference; it need not exist on disk. */ + filepath: string; + /** Absolute directory used to resolve the path. */ + cwd: string; + /** Loads the project config; its failures surface only after stdin is drained. */ + loadConfig: () => Promise; +} + +const readStdin = async (): Promise => { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer); + } + + return Buffer.concat(chunks).toString('utf8'); +}; + +/** + * Writes to stdout directly to keep the output byte-exact. A reader that + * closes the pipe early (`| head`) surfaces EPIPE, which is not a failure; + * other stream errors reject so the CLI-level handler reports them. + */ +const writeStdout = (output: string): Promise => + new Promise((resolvePromise, reject) => { + // The stream also emits 'error' for the same failure; swallow the event so + // only the write callback reports it. + process.stdout.once('error', () => {}); + process.stdout.write(output, (error) => { + if (error && (error as NodeJS.ErrnoException).code !== 'EPIPE') { + reject(error); + } else { + resolvePromise(); + } + }); + }); + +/** + * Formats stdin on the main thread and writes the result to stdout. + * Nothing but the formatted output may reach stdout in this mode. + */ +const runFmtStdin = async ({ filepath, cwd, loadConfig }: RunFmtStdinOptions): Promise => { + const configPromise = loadConfig(); + // Drain stdin before surfacing any failure, otherwise a writer that already + // queued more than the pipe buffer sees EPIPE instead of the real error. + configPromise.catch(() => {}); + const source = await readStdin(); + const config = await configPromise; + + const absolutePath = resolve(cwd, filepath); + if (createFmtIgnoreMatcher(config)(absolutePath)) { + await writeStdout(source); + return; + } + + if (source === '') { + return; + } + + let file = createFileRequest(absolutePath, config); + if (file.options.plugins?.length) { + const { createFmtPluginResolver } = await import( + /* rspackChunkName: 'fmtPlugins' */ + './plugins.ts' + ); + file = { ...file, options: createFmtPluginResolver(config.rootPath)(file.options) }; + } + + const result = await formatFmtSource(file, () => source); + + if (result.status === 'unsupported') { + throw new Error(`No parser could be inferred for "${filepath}".`); + } + + await writeStdout(result.formatted); +}; + +export { runFmtStdin }; diff --git a/packages/rstack/src/fmt/worker.ts b/packages/rstack/src/fmt/worker.ts index b346b389..1b861e70 100644 --- a/packages/rstack/src/fmt/worker.ts +++ b/packages/rstack/src/fmt/worker.ts @@ -1,16 +1,9 @@ // Derived from @prettier/cli, see THIRD_PARTY_NOTICES.md import { readFileSync, writeFileSync } from 'node:fs'; -import { - format, - getFileInfo, - type FileInfoOptions, - type Options as PrettierOptions, -} from 'prettier'; -import { getPrettierPlugins } from './prettierPlugins.ts'; +import { formatFmtSource } from './format.ts'; import type { FmtFileRequest } from './types.ts'; -type PrettierPlugins = NonNullable; type FormatFileResult = 'changed' | 'unchanged' | 'unsupported'; interface FormatFileTask { @@ -18,54 +11,23 @@ interface FormatFileTask { shouldWrite: boolean; } -const fileInfoOptions = { - ignorePath: [], - resolveConfig: false, - withNodeModules: true, -} satisfies FileInfoOptions; - -/** Uses the configured parser or infers one without loading Prettier config. */ -const resolveFmtParser = async ( - filePath: string, - options: PrettierOptions, - plugins: PrettierPlugins, -): Promise => - options.parser ?? - ( - await getFileInfo(filePath, { - ...fileInfoOptions, - plugins, - }) - ).inferredParser; - /** * Use synchronous direct I/O inside the dedicated worker to avoid libuv * scheduling overhead. This prioritizes throughput over crash-safe replacement. */ -const formatFile = async ({ - file: { path, options }, - shouldWrite, -}: FormatFileTask): Promise => { - const plugins = await getPrettierPlugins(options, path); - const parser = await resolveFmtParser(path, options, plugins); - if (!parser) { +const formatFile = async ({ file, shouldWrite }: FormatFileTask): Promise => { + const result = await formatFmtSource(file, () => readFileSync(file.path, 'utf8')); + if (result.status === 'unsupported') { return 'unsupported'; } - const source = readFileSync(path, 'utf8'); - const formatted = await format(source, { - ...options, - filepath: path, - parser, - plugins, - }); - + const { source, formatted } = result; if (source === formatted) { return 'unchanged'; } if (shouldWrite) { - writeFileSync(path, formatted, 'utf8'); + writeFileSync(file.path, formatted, 'utf8'); } return 'changed'; diff --git a/packages/rstack/src/fmt/yukuPlugin.ts b/packages/rstack/src/fmt/yukuPlugin.ts new file mode 100644 index 00000000..20a5635f --- /dev/null +++ b/packages/rstack/src/fmt/yukuPlugin.ts @@ -0,0 +1,557 @@ +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 = { + __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; +}; + +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 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; + +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 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, + 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/src/staged.ts b/packages/rstack/src/staged.ts index abfdb91d..f08ade3f 100644 --- a/packages/rstack/src/staged.ts +++ b/packages/rstack/src/staged.ts @@ -1,4 +1,5 @@ import { parseArgs } from 'node:util'; +import lintStaged from 'lint-staged'; import { color } from 'rslog'; import { loadRstackConfig } from './config.ts'; @@ -70,10 +71,6 @@ export async function runStagedCLI(args: string[]): Promise { ); } - const { default: lintStaged } = await import( - /* rspackChunkName: 'lintStaged' */ - 'lint-staged' - ); const success = await lintStaged({ allowEmpty: values['allow-empty'] ?? values.allowEmpty, concurrent: values.concurrent === undefined ? undefined : JSON.parse(values.concurrent), diff --git a/packages/rstack/tests/cli/fmt/index.test.ts b/packages/rstack/tests/cli/fmt/index.test.ts index f4096933..bcfbba51 100644 --- a/packages/rstack/tests/cli/fmt/index.test.ts +++ b/packages/rstack/tests/cli/fmt/index.test.ts @@ -33,7 +33,7 @@ const writeFixturePlugin = (): void => { ); }; -const runCLI = (args: string[]) => { +const runCLI = (args: string[], input?: string) => { const env: NodeJS.ProcessEnv = { ...process.env, NO_COLOR: '1' }; delete env.FORCE_COLOR; @@ -41,11 +41,14 @@ const runCLI = (args: string[]) => { cwd: projectPath, encoding: 'utf8', env, + input, }); }; const runFmt = (args: string[] = []) => runCLI(['fmt', ...args]); +const runFmtStdin = (args: string[], input: string) => runCLI(['fmt', ...args], input); + const normalizeDuration = (output: string): string => output.replace(/\d+m(?: \d+(?:\.\d+)?s)?|\d+(?:\.\d+)?s/g, ''); @@ -96,16 +99,24 @@ test('supports format as an alias for fmt', () => { expect(readProjectFile('index.ts')).toBe('const message = "hello";\n'); }); -test('returns exit code 1 for invalid arguments', () => { +test('returns exit code 2 for invalid arguments', () => { const result = runFmt(['--write', '--check']); - expect(result.status).toBe(1); + expect(result.status).toBe(2); expect(result.stdout).toBe(''); expect(result.stderr).toContain( 'The --write, --check, and --list-different options cannot be used together.', ); }); +test('returns exit code 2 for unknown options', () => { + const result = runFmt(['--bogus']); + + expect(result.status).toBe(2); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('--bogus'); +}); + test('formats the current directory with Prettier defaults', () => { writeProjectFile('index.ts', 'const message="hello"'); @@ -383,6 +394,148 @@ test('reports partial writes when formatting fails', () => { expect(readProjectFile('invalid.ts')).toBe('const invalid = ;'); }); +test('formats stdin for the given filepath', () => { + const result = runFmtStdin(['--stdin-filepath', 'src/index.ts'], 'const message="hello"'); + + expect(result.status).toBe(0); + expect(result.stdout).toBe('const message = "hello";\n'); + expect(result.stderr).toBe(''); +}); + +test('formats stdin with the camel-case option', () => { + const result = runFmtStdin(['--stdinFilepath', 'data.json'], '{"a":1,"b":[2,3]}'); + + expect(result.status).toBe(0); + expect(result.stdout).toBe('{ "a": 1, "b": [2, 3] }\n'); + expect(result.stderr).toBe(''); +}); + +test('applies define.fmt options and overrides to stdin', () => { + writeProjectFile( + 'rstack.config.ts', + `import { define } from 'rstack'; + +define.fmt({ + singleQuote: true, + overrides: [ + { + files: '*.test.ts', + options: { + semi: false, + }, + }, + ], +}); +`, + ); + + const result = runFmtStdin(['--stdin-filepath', 'src/index.test.ts'], 'const test="test"'); + + expect(result.status).toBe(0); + expect(result.stdout).toBe("const test = 'test'\n"); + expect(result.stderr).toBe(''); +}); + +test('sorts package.json from stdin', () => { + writeProjectFile( + 'rstack.config.ts', + `import { define } from 'rstack'; + +define.fmt({ sortPackageJson: true }); +`, + ); + + const result = runFmtStdin(['--stdin-filepath', 'package.json'], packageJsonSource); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(sortedPackageJson); + expect(result.stderr).toBe(''); +}); + +test('echoes ignored stdin paths verbatim', () => { + writeProjectFile( + 'rstack.config.ts', + `import { define } from 'rstack'; + +define.fmt({ ignorePatterns: ['src/ignored.ts'] }); +`, + ); + + const source = 'const ignored="ignored"'; + const result = runFmtStdin(['--stdin-filepath', 'src/ignored.ts'], source); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(source); + expect(result.stderr).toBe(''); +}); + +test('echoes stdin for default ignored lock files', () => { + const source = 'lockfileVersion: "9.0"\n'; + const result = runFmtStdin(['--stdin-filepath', 'pnpm-lock.yaml'], source); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(source); + expect(result.stderr).toBe(''); +}); + +test('returns exit code 2 when no parser can be inferred for stdin', () => { + const result = runFmtStdin(['--stdin-filepath', 'data.unknown'], 'value'); + + expect(result.status).toBe(2); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('No parser could be inferred for "data.unknown".'); +}); + +test('returns exit code 2 for stdin parse errors', () => { + const result = runFmtStdin(['--stdin-filepath', 'index.ts'], 'const value = ;'); + + expect(result.status).toBe(2); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain("Unexpected token ';'"); +}); + +test.each(['--write', '--check', '--list-different'])( + 'returns exit code 2 for %s with --stdin-filepath', + (option) => { + const result = runFmtStdin(['--stdin-filepath', 'index.ts', option], 'const value=1'); + + expect(result.status).toBe(2); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain( + 'The --stdin-filepath option cannot be used with --write, --check, or --list-different.', + ); + }, +); + +test('returns exit code 2 for file arguments with --stdin-filepath', () => { + const result = runFmtStdin(['--stdin-filepath', 'index.ts', 'src/other.ts'], 'const value=1'); + + expect(result.status).toBe(2); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain( + 'The --stdin-filepath option cannot be used with file arguments.', + ); +}); + +test('accepts --parallel-workers with --stdin-filepath', () => { + const result = runFmtStdin( + ['--stdin-filepath', 'index.ts', '--parallel-workers', '2'], + 'const value=1', + ); + + expect(result.status).toBe(0); + expect(result.stdout).toBe('const value = 1;\n'); + expect(result.stderr).toBe(''); +}); + +test('writes nothing for empty stdin', () => { + const result = runFmtStdin(['--stdin-filepath', 'index.ts'], ''); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe(''); +}); + test('reports when no files match', () => { const writeResult = runFmt(['missing/**/*.ts']); diff --git a/packages/rstack/tests/fmt/cli.test.ts b/packages/rstack/tests/fmt/cli.test.ts index 6708c832..e9639fbc 100644 --- a/packages/rstack/tests/fmt/cli.test.ts +++ b/packages/rstack/tests/fmt/cli.test.ts @@ -89,12 +89,48 @@ test.each(['--help', '-h'])('parses %s', (option) => { expect(parseFmtCLIArgs([option]).help).toBe(true); }); +test.each(['--stdin-filepath', '--stdinFilepath'])('parses %s', (option) => { + expect(parseFmtCLIArgs([option, 'src/index.ts'])).toEqual({ + mode: 'write', + patterns: [], + maxWorkers: undefined, + help: false, + stdinFilepath: 'src/index.ts', + }); +}); + +test('accepts a worker count with --stdin-filepath', () => { + expect(parseFmtCLIArgs(['--stdin-filepath', 'index.ts', '--parallel-workers', '2'])).toEqual({ + mode: 'write', + patterns: [], + maxWorkers: 2, + help: false, + stdinFilepath: 'index.ts', + }); +}); + +test.each(['--write', '--check', '--list-different', '--listDifferent'])( + 'rejects %s with --stdin-filepath', + (option) => { + expect(() => parseFmtCLIArgs(['--stdin-filepath', 'index.ts', option])).toThrow( + 'The --stdin-filepath option cannot be used with --write, --check, or --list-different.', + ); + }, +); + +test('rejects file arguments with --stdin-filepath', () => { + expect(() => parseFmtCLIArgs(['--stdin-filepath', 'index.ts', 'src/other.ts'])).toThrow( + 'The --stdin-filepath option cannot be used with file arguments.', + ); +}); + test('provides command help', () => { expect(fmtHelpMessage).toContain('Usage:\n $ rs fmt [options] [files/globs...]'); expect(fmtHelpMessage).toContain('--write'); expect(fmtHelpMessage).toContain('--check'); expect(fmtHelpMessage).toContain('--list-different'); expect(fmtHelpMessage).toContain('--parallel-workers '); + expect(fmtHelpMessage).toContain('--stdin-filepath '); expect(fmtHelpMessage).toContain('-h, --help'); }); diff --git a/packages/rstack/tests/fmt/format.test.ts b/packages/rstack/tests/fmt/format.test.ts new file mode 100644 index 00000000..e8a7abe2 --- /dev/null +++ b/packages/rstack/tests/fmt/format.test.ts @@ -0,0 +1,62 @@ +import path from 'node:path'; +import { expect, test } from 'rstack/test'; +import { formatFmtSource } from '../../src/fmt/format.ts'; + +const rootPath = path.join(import.meta.dirname, 'fixture'); + +test('formats sources without touching the file system', async () => { + await expect( + formatFmtSource( + { path: path.join(rootPath, 'missing.ts'), options: {} }, + () => 'const value=1', + ), + ).resolves.toEqual({ + status: 'formatted', + source: 'const value=1', + formatted: 'const value = 1;\n', + }); +}); + +test('applies resolved options to the source', async () => { + const result = await formatFmtSource( + { path: path.join(rootPath, 'missing.ts'), options: { singleQuote: true, semi: false } }, + () => 'const message="hello"', + ); + + expect(result).toEqual({ + status: 'formatted', + source: 'const message="hello"', + formatted: "const message = 'hello'\n", + }); +}); + +test('sorts package.json when the option is enabled', async () => { + const result = await formatFmtSource( + { path: path.join(rootPath, 'package.json'), options: { sortPackageJson: true } }, + () => '{"version":"1.0.0","name":"fixture"}', + ); + + expect(result).toEqual({ + status: 'formatted', + source: '{"version":"1.0.0","name":"fixture"}', + formatted: '{\n "name": "fixture",\n "version": "1.0.0"\n}\n', + }); +}); + +test('reports unsupported files before reading the source', async () => { + let read = false; + + await expect( + formatFmtSource({ path: path.join(rootPath, 'missing.unknown'), options: {} }, () => { + read = true; + return ''; + }), + ).resolves.toEqual({ status: 'unsupported' }); + expect(read).toBe(false); +}); + +test('rejects sources that cannot be parsed', async () => { + await expect( + formatFmtSource({ path: path.join(rootPath, 'invalid.ts'), options: {} }, () => 'const x = ;'), + ).rejects.toThrow("Unexpected token ';'"); +}); diff --git a/packages/rstack/tests/fmt/ignore.test.ts b/packages/rstack/tests/fmt/ignore.test.ts index 0bfc14b7..63501dd9 100644 --- a/packages/rstack/tests/fmt/ignore.test.ts +++ b/packages/rstack/tests/fmt/ignore.test.ts @@ -22,13 +22,22 @@ test('matches gitignore patterns relative to the config root', () => { test('applies negated patterns in declaration order', () => { const isIgnored = createMatcher(['*.js', '!src/keep.js']); const isIgnoredAgain = createMatcher(['*.js', '!src/keep.js', 'src/keep.js']); - const isReincluded = createMatcher(['dist', '!dist']); + const isIgnoredAfterReinclude = createMatcher(['dist', '!dist']); const filePath = path.join(rootPath, 'src/keep.js'); expect(isIgnored(filePath)).toBe(false); expect(isIgnored(path.join(rootPath, 'src/drop.js'))).toBe(true); expect(isIgnoredAgain(filePath)).toBe(true); - expect(isReincluded(path.join(rootPath, 'dist'))).toBe(false); + expect(isIgnoredAfterReinclude(path.join(rootPath, 'dist'))).toBe(false); +}); + +test('ignores common lock files by default and allows explicit negation', () => { + const isIgnored = createMatcher([]); + const isIgnoredAfterReinclude = createMatcher(['!pnpm-lock.yaml']); + + expect(isIgnored(path.join(rootPath, 'package-lock.json'))).toBe(true); + expect(isIgnored(path.join(rootPath, 'packages/app/pnpm-lock.yaml'))).toBe(true); + expect(isIgnoredAfterReinclude(path.join(rootPath, 'pnpm-lock.yaml'))).toBe(false); }); test('does not let explicit files bypass ignore patterns', () => { @@ -45,7 +54,7 @@ test('matches parent directory patterns without validation', () => { expect(isIgnored(path.join(rootPath, 'shared/index.js'))).toBe(false); }); -test('does not ignore files when no patterns are configured', () => { +test('does not ignore other files when no patterns are configured', () => { const isIgnored = createMatcher([]); expect(isIgnored(path.join(rootPath, 'src/index.js'))).toBe(false); diff --git a/packages/rstack/tests/fmt/worker.test.ts b/packages/rstack/tests/fmt/worker.test.ts index 530618c4..78730aa0 100644 --- a/packages/rstack/tests/fmt/worker.test.ts +++ b/packages/rstack/tests/fmt/worker.test.ts @@ -1,5 +1,4 @@ import { readFileSync } from 'node:fs'; -import path from 'node:path'; import { expect, test } from 'rstack/test'; import { formatFile } from '../../src/fmt/worker.ts'; import { withTempProject, writeProjectFile } from './helpers.ts'; @@ -42,19 +41,3 @@ test('infers the parser for an explicitly provided node_modules file', async () expect(readFileSync(filePath, 'utf8')).toBe(source); }); }); - -test('skips unsupported files before reading them', async () => { - await withTempProject(async (rootPath) => { - const filePath = path.join(rootPath, 'missing.unknown'); - - await expect( - formatFile({ - file: { - path: filePath, - options: {}, - }, - shouldWrite: true, - }), - ).resolves.toBe('unsupported'); - }); -}); diff --git a/packages/rstack/tests/fmt/yukuPlugin.test.ts b/packages/rstack/tests/fmt/yukuPlugin.test.ts new file mode 100644 index 00000000..5d221446 --- /dev/null +++ b/packages/rstack/tests/fmt/yukuPlugin.test.ts @@ -0,0 +1,283 @@ +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.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")', { + 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 08551dc0..8c3c6232 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 c4c17352..b19b7441 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 diff --git a/rstack.config.ts b/rstack.config.ts index 0753840b..f451cc2a 100644 --- a/rstack.config.ts +++ b/rstack.config.ts @@ -40,7 +40,6 @@ define.lint(async () => { }); define.fmt({ - ignorePatterns: ['**/dist/**', 'pnpm-lock.yaml'], printWidth: 100, singleQuote: true, sortPackageJson: true, diff --git a/scripts/dictionary.txt b/scripts/dictionary.txt index 754f9f3c..942bbe64 100644 --- a/scripts/dictionary.txt +++ b/scripts/dictionary.txt @@ -5,8 +5,12 @@ errexit extglob fnames huskyrc +indentable llms +noformat +noprettier nosystem +quasis rsbuild rslib rslint diff --git a/website/docs/en/guide/cli/fmt.mdx b/website/docs/en/guide/cli/fmt.mdx index fd1eb63d..f6a59b18 100644 --- a/website/docs/en/guide/cli/fmt.mdx +++ b/website/docs/en/guide/cli/fmt.mdx @@ -37,6 +37,9 @@ rs format | `--check` | Check formatting without writing files. Exits with code 1 when files are different. | | `--list-different` | Print only unformatted paths. Exits with code 1 when files are different. | | `--parallel-workers ` | Set the maximum number of formatting workers. | +| `--stdin-filepath ` | Format stdin as if it were saved at `` and print the result to stdout. | | `-h, --help` | Display usage and option information. | > `--write`, `--check`, and `--list-different` are mutually exclusive. + +> `--stdin-filepath` cannot be combined with `--write`, `--check`, `--list-different`, or file arguments. diff --git a/website/docs/en/guide/formatting.mdx b/website/docs/en/guide/formatting.mdx index 130f858b..9fdddb51 100644 --- a/website/docs/en/guide/formatting.mdx +++ b/website/docs/en/guide/formatting.mdx @@ -71,6 +71,16 @@ When scanning directories or globs, `rs fmt` follows `.gitignore` rules, skips b `.gitignore` applies only when scanning directories and globs. It does not exclude files passed explicitly on the command line. To always exclude a file, use [`ignorePatterns`](#ignore-files). +## Formatting stdin + +Use `--stdin-filepath` to format content piped through stdin, for example from an editor integration. The provided path determines the parser and the matching [overrides](#overrides); it does not need to exist on disk: + +```bash +cat src/index.ts | rs fmt --stdin-filepath src/index.ts +``` + +The formatted result is written to stdout, and diagnostics go to stderr. When the path matches [`ignorePatterns`](#ignore-files) or a default [lock file](#lock-files), `rs fmt` skips formatting and writes the input unchanged. When no parser can be inferred from the path, or the content cannot be parsed, `rs fmt` prints an error and exits with code 2. + ## Ignore files Use `ignorePatterns` to exclude files from formatting: @@ -85,6 +95,20 @@ define.fmt({ Patterns follow Gitignore syntax and are resolved relative to the directory containing the Rstack configuration file. Because they are applied after the files are selected, they also exclude files passed explicitly on the command line. +### Lock files + +By default, `rs fmt` ignores common lock files, including `package-lock.json` and `pnpm-lock.yaml`. + +To format these files, use a negated pattern to explicitly include them: + +```ts title="rstack.config.ts" +import { define } from 'rstack'; + +define.fmt({ + ignorePatterns: ['!pnpm-lock.yaml'], +}); +``` + ## Sort package.json fields \{#sort-package-json} Enable `sortPackageJson` to sort fields in each selected `package.json` with [`sort-package-json`](https://github.com/keithamus/sort-package-json): diff --git a/website/docs/zh/guide/cli/fmt.mdx b/website/docs/zh/guide/cli/fmt.mdx index 848dee2b..689a9cef 100644 --- a/website/docs/zh/guide/cli/fmt.mdx +++ b/website/docs/zh/guide/cli/fmt.mdx @@ -31,12 +31,15 @@ rs format ## 选项 \{#options} -| 选项 | 说明 | -| ---------------------------- | ----------------------------------------------------- | -| `--write` | 将格式化结果写回文件。这是默认模式。 | -| `--check` | 检查格式但不写入文件;存在格式差异时以状态码 1 退出。 | -| `--list-different` | 仅输出未格式化的路径;存在格式差异时以状态码 1 退出。 | -| `--parallel-workers ` | 设置格式化 worker 的最大数量。 | -| `-h, --help` | 显示命令用法和选项。 | +| 选项 | 说明 | +| ---------------------------- | --------------------------------------------------------- | +| `--write` | 将格式化结果写回文件。这是默认模式。 | +| `--check` | 检查格式但不写入文件;存在格式差异时以状态码 1 退出。 | +| `--list-different` | 仅输出未格式化的路径;存在格式差异时以状态码 1 退出。 | +| `--parallel-workers ` | 设置格式化 worker 的最大数量。 | +| `--stdin-filepath ` | 将标准输入按保存在 `` 的文件格式化并输出到 stdout。 | +| `-h, --help` | 显示命令用法和选项。 | > `--write`、`--check` 和 `--list-different` 不能同时使用。 + +> `--stdin-filepath` 不能与 `--write`、`--check`、`--list-different` 或文件参数同时使用。 diff --git a/website/docs/zh/guide/formatting.mdx b/website/docs/zh/guide/formatting.mdx index 53bc43c7..9f04b823 100644 --- a/website/docs/zh/guide/formatting.mdx +++ b/website/docs/zh/guide/formatting.mdx @@ -71,6 +71,16 @@ rs fmt "src/**/*.{js,ts}" "!src/generated/**" `.gitignore` 只在扫描目录和 glob 时生效,不会排除命令行中显式传入的文件。如果需要始终排除某个文件,请使用 [`ignorePatterns`](#ignore-files)。 +## 格式化标准输入 \{#formatting-stdin} + +使用 `--stdin-filepath` 可以格式化通过 stdin 传入的内容,例如来自编辑器集成的调用。传入的路径决定使用的 parser 和匹配的[覆盖配置](#overrides),并不需要在磁盘上真实存在: + +```bash +cat src/index.ts | rs fmt --stdin-filepath src/index.ts +``` + +格式化结果输出到 stdout,诊断信息输出到 stderr。当路径匹配 [`ignorePatterns`](#ignore-files) 或默认的 [lock 文件](#lock-files)时,`rs fmt` 会跳过格式化,将输入原样输出。当无法从路径推断 parser 或内容无法解析时,`rs fmt` 输出错误并以状态码 2 退出。 + ## 忽略文件 \{#ignore-files} 使用 `ignorePatterns` 排除不需要格式化的文件: @@ -85,6 +95,20 @@ define.fmt({ 这些模式遵循 Gitignore 语法,并且基于 Rstack 配置文件所在的目录解析。由于规则会在确定格式化范围后生效,因此也会排除命令行中显式传入的文件。 +### Lock 文件 \{#lock-files} + +`rs fmt` 默认忽略常见的 lock 文件,包括 `package-lock.json` 和 `pnpm-lock.yaml`。 + +如果你需要格式化这些文件,可以使用否定模式主动包含它们: + +```ts title="rstack.config.ts" +import { define } from 'rstack'; + +define.fmt({ + ignorePatterns: ['!pnpm-lock.yaml'], +}); +``` + ## 排序 package.json 字段 \{#sort-package-json} 启用 `sortPackageJson` 后,`rs fmt` 会使用 [`sort-package-json`](https://github.com/keithamus/sort-package-json) 对每个待格式化的 `package.json` 中的字段排序: