From 63b375a0a51f25ed66f45b3169c833bc2230e6d1 Mon Sep 17 00:00:00 2001 From: neverland Date: Mon, 3 Aug 2026 14:57:29 +0800 Subject: [PATCH] perf(fmt): precompile override matchers --- packages/rstack/src/fmt/config.ts | 97 +++++++++++++++++------- packages/rstack/src/fmt/types.ts | 10 ++- packages/rstack/tests/fmt/config.test.ts | 49 ++++++++++++ 3 files changed, 127 insertions(+), 29 deletions(-) create mode 100644 packages/rstack/tests/fmt/config.test.ts diff --git a/packages/rstack/src/fmt/config.ts b/packages/rstack/src/fmt/config.ts index 559eb38..949116a 100644 --- a/packages/rstack/src/fmt/config.ts +++ b/packages/rstack/src/fmt/config.ts @@ -13,6 +13,65 @@ type ResolveFmtConfigOptions = { cwd: string; }; +type PathMatcher = (filePath: string) => boolean; + +const neverMatches: PathMatcher = () => false; + +const compileMatchers = ( + patterns: string[], + excludedPatterns: string | string[] | undefined, + basename: boolean, +): PathMatcher | undefined => { + if (patterns.length === 0) { + return; + } + + const options = { + ignore: excludedPatterns, + basename, + dot: true, + }; + + if (patterns.length === 1) { + return micromatch.matcher(patterns[0], options); + } + + const matchers = patterns.map((pattern) => micromatch.matcher(pattern, options)); + + return (filePath) => { + for (const matches of matchers) { + if (matches(filePath)) { + return true; + } + } + return false; + }; +}; + +const createPathMatcher = ( + patterns: string | string[], + excludedPatterns?: string | string[], +): PathMatcher => { + const pathPatterns: string[] = []; + const basenamePatterns: string[] = []; + + for (const pattern of Array.isArray(patterns) ? patterns : [patterns]) { + if (pattern.includes('/')) { + pathPatterns.push(pattern); + } else { + basenamePatterns.push(pattern); + } + } + + const basenameMatcher = compileMatchers(basenamePatterns, excludedPatterns, true); + const pathMatcher = compileMatchers(pathPatterns, excludedPatterns, false); + + if (!basenameMatcher || !pathMatcher) { + return basenameMatcher ?? pathMatcher ?? neverMatches; + } + return (filePath) => basenameMatcher(filePath) || pathMatcher(filePath); +}; + /** Splits a flat config into project-level formatting options and rules. */ const normalizeFmtConfig = (config: FmtConfig | undefined, rootPath: string): ResolvedFmtConfig => { const { ignorePatterns = [], overrides = [], ...baseOptions } = config ?? {}; @@ -20,47 +79,31 @@ const normalizeFmtConfig = (config: FmtConfig | undefined, rootPath: string): Re return { rootPath, baseOptions, - overrides, + overrides: overrides.map(({ files, excludeFiles, options }) => ({ + matches: createPathMatcher(files, excludeFiles), + options, + })), ignorePatterns, }; }; -const pathMatchesGlobs = ( - filePath: string, - patterns: string | string[], - excludedPatterns?: string | string[], -): boolean => { - const patternList = Array.isArray(patterns) ? patterns : [patterns]; - const withSlashes = patternList.filter((pattern) => pattern.includes('/')); - const withoutSlashes = patternList.filter((pattern) => !pattern.includes('/')); - - return ( - micromatch.isMatch(filePath, withoutSlashes, { - ignore: excludedPatterns, - basename: true, - dot: true, - }) || - micromatch.isMatch(filePath, withSlashes, { - ignore: excludedPatterns, - basename: false, - dot: true, - }) - ); -}; - /** Applies matching overrides to the shared formatter options. */ const resolveFmtOptions = (filePath: string, config: ResolvedFmtConfig): ResolvedFmtOptions => { if (config.overrides.length === 0) { return config.baseOptions; } - const options = { ...config.baseOptions }; + let options = config.baseOptions; const relativeFilePath = relative(config.rootPath, filePath); for (const override of config.overrides) { - if (pathMatchesGlobs(relativeFilePath, override.files, override.excludeFiles)) { - Object.assign(options, override.options); + if (!override.options || !override.matches(relativeFilePath)) { + continue; + } + if (options === config.baseOptions) { + options = { ...options }; } + Object.assign(options, override.options); } return options; diff --git a/packages/rstack/src/fmt/types.ts b/packages/rstack/src/fmt/types.ts index b313207..451f65d 100644 --- a/packages/rstack/src/fmt/types.ts +++ b/packages/rstack/src/fmt/types.ts @@ -33,14 +33,20 @@ interface FmtConfig extends Omit, FmtBu type FmtConfigDefinition = FmtConfig | (() => FmtConfig | Promise); +interface ResolvedFmtOverride { + /** Matches a path relative to the config root. */ + matches: (relativeFilePath: string) => boolean; + options?: ResolvedFmtOptions; +} + /** Internal project config before per-file rules are applied. */ interface ResolvedFmtConfig { /** Root for relative patterns and plugin paths. */ rootPath: string; /** Shared Prettier options before per-file overrides. */ baseOptions: ResolvedFmtOptions; - /** Per-file override rules. */ - overrides: NonNullable; + /** Precompiled per-file override rules. */ + overrides: ResolvedFmtOverride[]; /** Root-relative ignore patterns. */ ignorePatterns: string[]; } diff --git a/packages/rstack/tests/fmt/config.test.ts b/packages/rstack/tests/fmt/config.test.ts new file mode 100644 index 0000000..605b8c5 --- /dev/null +++ b/packages/rstack/tests/fmt/config.test.ts @@ -0,0 +1,49 @@ +import path from 'node:path'; +import { expect, test } from 'rstack/test'; +import { normalizeFmtConfig, resolveFmtOptions } from '../../src/fmt/config.ts'; + +const rootPath = path.join(import.meta.dirname, 'project'); + +test('reuses base options when no override matches', () => { + const config = normalizeFmtConfig( + { + singleQuote: true, + overrides: [{ files: '*.ts', options: { semi: false } }], + }, + rootPath, + ); + + expect(resolveFmtOptions(path.join(rootPath, 'index.js'), config)).toBe(config.baseOptions); +}); + +test('applies basename and path overrides in declaration order', () => { + const config = normalizeFmtConfig( + { + singleQuote: false, + overrides: [ + { + files: '*.ts', + excludeFiles: '*.test.ts', + options: { semi: false }, + }, + { + files: 'src/**/*.{ts,tsx}', + options: { singleQuote: true }, + }, + { + files: 'src/**/index.ts', + options: { semi: true, tabWidth: 4 }, + }, + ], + }, + rootPath, + ); + + const options = resolveFmtOptions(path.join(rootPath, 'src/index.ts'), config); + const testOptions = resolveFmtOptions(path.join(rootPath, 'src/index.test.ts'), config); + + expect(options).not.toBe(config.baseOptions); + expect(options).toEqual({ semi: true, singleQuote: true, tabWidth: 4 }); + expect(testOptions).toEqual({ singleQuote: true }); + expect(config.baseOptions).toEqual({ singleQuote: false }); +});