diff --git a/docs/docs/configuration.md b/docs/docs/configuration.md index 45ea1e88..bb9dfef2 100644 --- a/docs/docs/configuration.md +++ b/docs/docs/configuration.md @@ -85,9 +85,31 @@ These options have sensible defaults and are typically only customized for speci #### `barrelFileName` {#barrelfilename} -- **Type**: `string` +- **Type**: `string | string[]` - **Default**: `'index.ts'` -- **Description**: Name of the barrel file that exports public APIs from a module. +- **Description**: Name of the barrel file that exports public APIs from a module. Supports glob patterns and arrays to allow multiple entry files per module. + +**Glob wildcards:** +- `*` matches zero or more characters +- `?` matches exactly one character + +**Examples:** + +```typescript +// Single filename (default behavior) +barrelFileName: 'index.ts' + +// Glob pattern for multiple entry files +barrelFileName: 'index.*.ts' + +// Explicit list of entry files +barrelFileName: ['index.ts', 'index.routing.ts', 'index.state.ts'] + +// Combine exact names and glob patterns +barrelFileName: ['index.ts', 'index.*.ts'] +``` + +> **Use case**: In Angular monorepos, a single barrel file can cause inefficient tree shaking. By splitting entry files (e.g., `index.ts`, `index.routing.ts`, `index.state.ts`), you can keep module boundaries enforced while allowing multiple public access points. #### `ignoreFileExtensions` {#ignorefileextensions} diff --git a/packages/core/src/lib/checks/has-encapsulation-violations.ts b/packages/core/src/lib/checks/has-encapsulation-violations.ts index 9b7ffe54..72cc2ad6 100644 --- a/packages/core/src/lib/checks/has-encapsulation-violations.ts +++ b/packages/core/src/lib/checks/has-encapsulation-violations.ts @@ -71,7 +71,7 @@ function accessesBarrelFileForBarrelModules(fileInfo: FileInfo) { return false; } - return fileInfo.moduleInfo.barrelPath === fileInfo.path; + return fileInfo.moduleInfo.isBarrelFile(fileInfo.path); } function isExcludedRootModule( diff --git a/packages/core/src/lib/checks/tests/encapsulation-multiple-barrel-files.spec.ts b/packages/core/src/lib/checks/tests/encapsulation-multiple-barrel-files.spec.ts new file mode 100644 index 00000000..e561893c --- /dev/null +++ b/packages/core/src/lib/checks/tests/encapsulation-multiple-barrel-files.spec.ts @@ -0,0 +1,302 @@ +import { describe, expect, it } from 'vitest'; +import { hasEncapsulationViolations } from '../has-encapsulation-violations'; +import { toFsPath } from '../../file-info/fs-path'; +import { testInit } from '../../test/test-init'; +import { tsConfig } from '../../test/fixtures/ts-config'; +import { sheriffConfig } from '../../test/project-configurator'; + +describe('encapsulation with multiple barrel files', () => { + it('should allow imports of all barrel files when using an array', () => { + const projectInfo = testInit('src/main.ts', { + 'tsconfig.json': tsConfig(), + 'sheriff.config.ts': sheriffConfig({ + barrelFileName: ['index.ts', 'index.routing.ts'], + depRules: {}, + }), + src: { + 'main.ts': [ + './app/orders/index', + './app/orders/index.routing', + ], + app: { + orders: { + 'index.ts': ['./order-list.component'], + 'index.routing.ts': ['./order-routes'], + 'order-list.component.ts': [], + 'order-routes.ts': [], + }, + }, + }, + }); + + const violations = hasEncapsulationViolations( + toFsPath('/project/src/main.ts'), + projectInfo, + ); + expect(Object.keys(violations)).toEqual([]); + }); + + it('should block deep imports even with multiple barrel files', () => { + const projectInfo = testInit('src/main.ts', { + 'tsconfig.json': tsConfig(), + 'sheriff.config.ts': sheriffConfig({ + barrelFileName: ['index.ts', 'index.routing.ts'], + depRules: {}, + }), + src: { + 'main.ts': [ + './app/orders/index', + './app/orders/order-list.component', + ], + app: { + orders: { + 'index.ts': ['./order-list.component'], + 'index.routing.ts': ['./order-routes'], + 'order-list.component.ts': [], + 'order-routes.ts': [], + }, + }, + }, + }); + + const violations = hasEncapsulationViolations( + toFsPath('/project/src/main.ts'), + projectInfo, + ); + expect(Object.keys(violations)).toEqual([ + './app/orders/order-list.component', + ]); + }); + + it('should allow importing barrel files of nested modules', () => { + const projectInfo = testInit('src/main.ts', { + 'tsconfig.json': tsConfig(), + 'sheriff.config.ts': sheriffConfig({ + barrelFileName: ['index.ts'], + depRules: {}, + }), + src: { + 'main.ts': ['./app/orders/internal/index'], + app: { + orders: { + 'index.ts': ['./order-list.component'], + 'order-list.component.ts': [], + internal: { + 'index.ts': ['./details'], + 'details.ts': [], + }, + }, + }, + }, + }); + + const violations = hasEncapsulationViolations( + toFsPath('/project/src/main.ts'), + projectInfo, + ); + expect(Object.keys(violations)).toEqual([]); + }); + + it('should block deep imports into nested modules', () => { + const projectInfo = testInit('src/main.ts', { + 'tsconfig.json': tsConfig(), + 'sheriff.config.ts': sheriffConfig({ + barrelFileName: ['index.ts'], + depRules: {}, + }), + src: { + 'main.ts': ['./app/orders/internal/details'], + app: { + orders: { + 'index.ts': ['./order-list.component'], + 'order-list.component.ts': [], + internal: { + 'index.ts': ['./details'], + 'details.ts': [], + }, + }, + }, + }, + }); + + const violations = hasEncapsulationViolations( + toFsPath('/project/src/main.ts'), + projectInfo, + ); + expect(Object.keys(violations)).toEqual([ + './app/orders/internal/details', + ]); + }); + + it('should allow imports of barrel files matching a glob pattern', () => { + const projectInfo = testInit('src/main.ts', { + 'tsconfig.json': tsConfig(), + 'sheriff.config.ts': sheriffConfig({ + barrelFileName: ['index.ts', 'index.*.ts'], + depRules: {}, + }), + src: { + 'main.ts': [ + './app/orders/index', + './app/orders/index.state', + './app/orders/index.routing', + ], + app: { + orders: { + 'index.ts': ['./order-list.component'], + 'index.state.ts': ['./state'], + 'index.routing.ts': ['./order-routes'], + 'order-list.component.ts': [], + 'state.ts': [], + 'order-routes.ts': [], + }, + }, + }, + }); + + const violations = hasEncapsulationViolations( + toFsPath('/project/src/main.ts'), + projectInfo, + ); + expect(Object.keys(violations)).toEqual([]); + }); + + it('should block deep imports when using glob barrel pattern', () => { + const projectInfo = testInit('src/main.ts', { + 'tsconfig.json': tsConfig(), + 'sheriff.config.ts': sheriffConfig({ + barrelFileName: ['index.ts', 'index.*.ts'], + depRules: {}, + }), + src: { + 'main.ts': [ + './app/orders/index', + './app/orders/state', + ], + app: { + orders: { + 'index.ts': ['./order-list.component'], + 'index.state.ts': ['./state'], + 'order-list.component.ts': [], + 'state.ts': [], + }, + }, + }, + }); + + const violations = hasEncapsulationViolations( + toFsPath('/project/src/main.ts'), + projectInfo, + ); + expect(Object.keys(violations)).toEqual(['./app/orders/state']); + }); + + it('should allow barrel imports of nested modules with glob patterns', () => { + const projectInfo = testInit('src/main.ts', { + 'tsconfig.json': tsConfig(), + 'sheriff.config.ts': sheriffConfig({ + barrelFileName: ['index.ts', 'index.*.ts'], + depRules: {}, + }), + src: { + 'main.ts': ['./app/orders/feature/index.routing'], + app: { + orders: { + 'index.ts': ['./order-list.component'], + 'index.routing.ts': ['./order-routes'], + 'order-list.component.ts': [], + 'order-routes.ts': [], + feature: { + 'index.routing.ts': ['./feature-routes'], + 'feature-routes.ts': [], + }, + }, + }, + }, + }); + + const violations = hasEncapsulationViolations( + toFsPath('/project/src/main.ts'), + projectInfo, + ); + expect(Object.keys(violations)).toEqual([]); + }); + + it('should block non-barrel imports into nested modules with glob patterns', () => { + const projectInfo = testInit('src/main.ts', { + 'tsconfig.json': tsConfig(), + 'sheriff.config.ts': sheriffConfig({ + barrelFileName: ['index.ts', 'index.*.ts'], + depRules: {}, + }), + src: { + 'main.ts': ['./app/orders/feature/feature-routes'], + app: { + orders: { + 'index.ts': ['./order-list.component'], + 'index.routing.ts': ['./order-routes'], + 'order-list.component.ts': [], + 'order-routes.ts': [], + feature: { + 'index.routing.ts': ['./feature-routes'], + 'feature-routes.ts': [], + }, + }, + }, + }, + }); + + const violations = hasEncapsulationViolations( + toFsPath('/project/src/main.ts'), + projectInfo, + ); + expect(Object.keys(violations)).toEqual([ + './app/orders/feature/feature-routes', + ]); + }); + + it('should work across multiple modules with glob barrel files', () => { + const projectInfo = testInit('src/main.ts', { + 'tsconfig.json': tsConfig(), + 'sheriff.config.ts': sheriffConfig({ + barrelFileName: ['index.ts', 'index.*.ts'], + depRules: {}, + }), + src: { + 'main.ts': [ + './app/orders/index', + './app/orders/index.routing', + './app/customers/index', + ], + app: { + orders: { + 'index.ts': ['./order-list.component'], + 'index.routing.ts': ['./order-routes'], + 'order-list.component.ts': [], + 'order-routes.ts': [], + }, + customers: { + 'index.ts': ['./customer.component'], + 'customer.component.ts': [], + }, + }, + }, + }); + + for (const [filename, expectedViolations] of [ + ['src/main.ts', []], + ['src/app/orders/index.ts', []], + ['src/app/orders/index.routing.ts', []], + ['src/app/customers/index.ts', []], + ] as [string, string[]][]) { + const violations = hasEncapsulationViolations( + toFsPath(`/project/${filename}`), + projectInfo, + ); + expect( + Object.keys(violations), + `encapsulation check failed for ${filename}`, + ).toEqual(expectedViolations); + } + }); +}); diff --git a/packages/core/src/lib/config/configuration.ts b/packages/core/src/lib/config/configuration.ts index adb94069..c4f57553 100644 --- a/packages/core/src/lib/config/configuration.ts +++ b/packages/core/src/lib/config/configuration.ts @@ -7,6 +7,7 @@ export type Configuration = Required< | 'showWarningOnBarrelCollision' | 'encapsulatedFolderNameForBarrelLess' | 'entryPoints' + | 'barrelFileName' > > & { // dependency rules will skip if `isConfigFileMissing` is true @@ -18,4 +19,6 @@ export type Configuration = Required< entryPoints?: Record; // ignoreFileExtensions is always present (either user-specified or default) ignoreFileExtensions: string[]; + // barrelFileName is always normalized to an array internally + barrelFileName: string[]; }; diff --git a/packages/core/src/lib/config/default-config.ts b/packages/core/src/lib/config/default-config.ts index 7db8434a..f38a9ff8 100644 --- a/packages/core/src/lib/config/default-config.ts +++ b/packages/core/src/lib/config/default-config.ts @@ -12,7 +12,7 @@ export const defaultConfig: Configuration = { log: false, entryFile: '', isConfigFileMissing: false, - barrelFileName: 'index.ts', + barrelFileName: ['index.ts'], entryPoints: undefined, ignoreFileExtensions: defaultIgnoreFileExtensions, }; diff --git a/packages/core/src/lib/config/parse-config.ts b/packages/core/src/lib/config/parse-config.ts index 339d06c9..941528d8 100644 --- a/packages/core/src/lib/config/parse-config.ts +++ b/packages/core/src/lib/config/parse-config.ts @@ -63,12 +63,15 @@ export const parseConfig = (configFile: FsPath): Configuration => { } const mergedConfig = { ...defaultConfig, ...rest }; + const barrelFileName = getBarrelFileName(mergedConfig.barrelFileName); + const ignoreFileExtensions = getIgnoreFileExtensions( mergedConfig.ignoreFileExtensions, ); return { ...mergedConfig, + barrelFileName, ignoreFileExtensions, }; }; @@ -82,3 +85,9 @@ function getIgnoreFileExtensions( : ignoreFileExtensions; return Array.from(new Set(extensions.map((ext) => ext.toLowerCase()))); } + +function getBarrelFileName( + barrelFileName: string | string[], +): string[] { + return Array.isArray(barrelFileName) ? barrelFileName : [barrelFileName]; +} diff --git a/packages/core/src/lib/config/tests/parse-config.spec.ts b/packages/core/src/lib/config/tests/parse-config.spec.ts index 5374b96f..020efb01 100644 --- a/packages/core/src/lib/config/tests/parse-config.spec.ts +++ b/packages/core/src/lib/config/tests/parse-config.spec.ts @@ -82,7 +82,7 @@ export const config: SheriffConfig = { log: false, isConfigFileMissing: false, entryFile: '', - barrelFileName: 'index.ts', + barrelFileName: ['index.ts'], entryPoints: undefined, ignoreFileExtensions: defaultIgnoreFileExtensions, }); @@ -367,4 +367,81 @@ export const config: SheriffConfig = { ); }); }); + + describe('barrelFileName', () => { + it('should normalize a string barrelFileName to an array', () => { + getFs().writeFile( + 'sheriff.config.ts', + ` +import { SheriffConfig } from '@softarc/sheriff-core'; + +export const config: SheriffConfig = { + barrelFileName: 'barrel.ts', + depRules: { root: 'noTag', noTag: 'noTag' } +}; + `, + ); + const config = parseConfig( + toFsPath(getFs().cwd() + '/sheriff.config.ts'), + ); + expect(config.barrelFileName).toEqual(['barrel.ts']); + }); + + it('should accept an array of barrel file names', () => { + getFs().writeFile( + 'sheriff.config.ts', + ` +import { SheriffConfig } from '@softarc/sheriff-core'; + +export const config: SheriffConfig = { + barrelFileName: ['index.ts', 'index.routing.ts', 'index.state.ts'], + depRules: { root: 'noTag', noTag: 'noTag' } +}; + `, + ); + const config = parseConfig( + toFsPath(getFs().cwd() + '/sheriff.config.ts'), + ); + expect(config.barrelFileName).toEqual([ + 'index.ts', + 'index.routing.ts', + 'index.state.ts', + ]); + }); + + it('should accept a glob pattern as barrel file name', () => { + getFs().writeFile( + 'sheriff.config.ts', + ` +import { SheriffConfig } from '@softarc/sheriff-core'; + +export const config: SheriffConfig = { + barrelFileName: 'index.*.ts', + depRules: { root: 'noTag', noTag: 'noTag' } +}; + `, + ); + const config = parseConfig( + toFsPath(getFs().cwd() + '/sheriff.config.ts'), + ); + expect(config.barrelFileName).toEqual(['index.*.ts']); + }); + + it('should default to [index.ts] when not specified', () => { + getFs().writeFile( + 'sheriff.config.ts', + ` +import { SheriffConfig } from '@softarc/sheriff-core'; + +export const config: SheriffConfig = { + depRules: { root: 'noTag', noTag: 'noTag' } +}; + `, + ); + const config = parseConfig( + toFsPath(getFs().cwd() + '/sheriff.config.ts'), + ); + expect(config.barrelFileName).toEqual(['index.ts']); + }); + }); }); diff --git a/packages/core/src/lib/config/user-sheriff-config.ts b/packages/core/src/lib/config/user-sheriff-config.ts index 4bc596c7..1c3c8c87 100644 --- a/packages/core/src/lib/config/user-sheriff-config.ts +++ b/packages/core/src/lib/config/user-sheriff-config.ts @@ -162,8 +162,27 @@ export interface UserSheriffConfig { /** * The barrel file is usually the `index.ts` and exports * those files which are available outside the module. + * + * Supports a single filename, a glob pattern, or an array of + * filenames/glob patterns. + * + * Supported glob wildcards: + * - `*` matches zero or more characters + * - `?` matches exactly one character + * + * @example + * ```typescript + * // single filename (default) + * barrelFileName: 'index.ts' + * + * // glob pattern for multiple entry files + * barrelFileName: 'index.*.ts' + * + * // explicit list of entry files + * barrelFileName: ['index.ts', 'index.routing.ts', 'index.state.ts'] + * ``` */ - barrelFileName?: string; + barrelFileName?: string | string[]; /** * The barrel-less approach means that the module diff --git a/packages/core/src/lib/fs/default-fs.ts b/packages/core/src/lib/fs/default-fs.ts index bdb3904b..74835c6f 100644 --- a/packages/core/src/lib/fs/default-fs.ts +++ b/packages/core/src/lib/fs/default-fs.ts @@ -3,6 +3,7 @@ import * as os from 'os'; import * as path from 'path'; import { Fs } from './fs'; import { FsPath, toFsPath } from '../file-info/fs-path'; +import { matchGlob } from '../util/match-glob'; export class DefaultFs extends Fs { override appendFile(filename: string, contents: string): void { @@ -60,7 +61,7 @@ export class DefaultFs extends Fs { for (const file of files) { const filePath = toFsPath(path.join(directory, file)); const info = fs.lstatSync(filePath); - if (info.isFile() && file.toLowerCase() === filename.toLowerCase()) { + if (info.isFile() && matchGlob(filename, file)) { found.push(toFsPath(filePath)); } if (info.isDirectory()) { diff --git a/packages/core/src/lib/fs/virtual-fs.ts b/packages/core/src/lib/fs/virtual-fs.ts index 3f727296..1b2fbbc8 100644 --- a/packages/core/src/lib/fs/virtual-fs.ts +++ b/packages/core/src/lib/fs/virtual-fs.ts @@ -2,6 +2,7 @@ import { Fs } from './fs'; import throwIfNull from '../util/throw-if-null'; import { FsPath, toFsPath } from '../file-info/fs-path'; import { EOL } from 'os'; +import { matchGlob } from '../util/match-glob'; type FsNodeType = 'file' | 'directory'; @@ -247,7 +248,7 @@ export class VirtualFs extends Fs { for (const childNode of node.children.values()) { if ( childNode.type === 'file' && - childNode.name === filename.toLowerCase() + matchGlob(filename, childNode.name) ) { found.push(this.#absolutePath(childNode)); } diff --git a/packages/core/src/lib/main/parse-project.ts b/packages/core/src/lib/main/parse-project.ts index b17b3901..a6d61f9b 100644 --- a/packages/core/src/lib/main/parse-project.ts +++ b/packages/core/src/lib/main/parse-project.ts @@ -9,6 +9,7 @@ import { TsData } from '../file-info/ts-data'; import { Module } from '../modules/module'; import { Configuration } from '../config/configuration'; import { findModulePaths } from '../modules/find-module-paths'; +import { createGlobMatcher } from '../util/match-glob'; export type ParsedResult = { fileInfo: FileInfo; @@ -39,12 +40,18 @@ export const parseProject = ( const getFileInfo = (path: FsPath) => throwIfNull(fileInfoMap.get(path), `cannot find FileInfo for ${path}`); - const modulePaths = findModulePaths(projectDirs, rootDir, config); + const isBarrelMatch = createGlobMatcher(config.barrelFileName); + const modulePaths = findModulePaths( + projectDirs, + rootDir, + config, + isBarrelMatch, + ); const modules = createModules(modulePaths, fileInfoMap, getFileInfo, { entryFileInfo: unassignedFileInfo, rootDir, - barrelFile: config.barrelFileName, + isBarrelMatch, }); fillFileInfoMap(fileInfoMap, modules); diff --git a/packages/core/src/lib/modules/create-modules.ts b/packages/core/src/lib/modules/create-modules.ts index 768b8ccb..a6de13b3 100644 --- a/packages/core/src/lib/modules/create-modules.ts +++ b/packages/core/src/lib/modules/create-modules.ts @@ -11,18 +11,19 @@ import { keys, values, } from '../util/typed-object-functions'; +import { GlobMatcher } from '../util/match-glob'; interface CreateModulesContext { entryFileInfo: UnassignedFileInfo; rootDir: FsPath; - barrelFile: string; + isBarrelMatch: GlobMatcher; } export function createModules( modulePathMap: ModulePathMap, fileInfoMap: Map, getFileInfo: (path: FsPath) => FileInfo, - { entryFileInfo, rootDir, barrelFile }: CreateModulesContext, + { entryFileInfo, rootDir, isBarrelMatch }: CreateModulesContext, ): Module[] { const moduleMap = fromEntries( entries(modulePathMap).map(([path, hasBarrel]) => [ @@ -33,7 +34,7 @@ export function createModules( getFileInfo, false, hasBarrel, - barrelFile, + isBarrelMatch, ), ]), ); @@ -44,7 +45,7 @@ export function createModules( getFileInfo, true, false, - barrelFile, + isBarrelMatch, ); const modulePaths = keys(moduleMap); diff --git a/packages/core/src/lib/modules/find-module-paths.ts b/packages/core/src/lib/modules/find-module-paths.ts index 2cdb4424..ab4eca0c 100644 --- a/packages/core/src/lib/modules/find-module-paths.ts +++ b/packages/core/src/lib/modules/find-module-paths.ts @@ -2,6 +2,7 @@ import { FsPath } from '../file-info/fs-path'; import { findModulePathsWithBarrel } from './internal/find-module-paths-with-barrel'; import { findModulePathsWithoutBarrel } from './internal/find-module-paths-without-barrel'; import { Configuration } from '../config/configuration'; +import { GlobMatcher } from '../util/match-glob'; export type ModulePathMap = Record; @@ -15,14 +16,11 @@ export function findModulePaths( projectDirs: FsPath[], rootDir: FsPath, sheriffConfig: Configuration, + isBarrelMatch: GlobMatcher, ): ModulePathMap { - const { - modules, - enableBarrelLess, - barrelFileName - } = sheriffConfig; + const { modules, enableBarrelLess, barrelFileName } = sheriffConfig; const modulesWithoutBarrel = enableBarrelLess - ? findModulePathsWithoutBarrel(modules, rootDir, barrelFileName) + ? findModulePathsWithoutBarrel(modules, rootDir, isBarrelMatch) : []; const modulesWithBarrel = findModulePathsWithBarrel( projectDirs, diff --git a/packages/core/src/lib/modules/internal/find-module-paths-with-barrel.ts b/packages/core/src/lib/modules/internal/find-module-paths-with-barrel.ts index cee926f9..8a0a1b4d 100644 --- a/packages/core/src/lib/modules/internal/find-module-paths-with-barrel.ts +++ b/packages/core/src/lib/modules/internal/find-module-paths-with-barrel.ts @@ -3,12 +3,18 @@ import getFs from '../../fs/getFs'; export function findModulePathsWithBarrel( projectDirs: FsPath[], - barrelFileName: string, + barrelFileNames: string[], ): FsPath[] { - return projectDirs.flatMap((projectDir) => - getFs() - .findFiles(projectDir, barrelFileName) - .map((path) => path.slice(0, -(barrelFileName.length + 1))) - .map(toFsPath), - ); + const modulePaths = new Set(); + + for (const barrelFileName of barrelFileNames) { + for (const projectDir of projectDirs) { + const found = getFs().findFiles(projectDir, barrelFileName); + for (const filePath of found) { + modulePaths.add(getFs().getParent(filePath)); + } + } + } + + return Array.from(modulePaths).map(toFsPath); } diff --git a/packages/core/src/lib/modules/internal/find-module-paths-without-barrel.ts b/packages/core/src/lib/modules/internal/find-module-paths-without-barrel.ts index cdac9b62..5e0c064f 100644 --- a/packages/core/src/lib/modules/internal/find-module-paths-without-barrel.ts +++ b/packages/core/src/lib/modules/internal/find-module-paths-without-barrel.ts @@ -4,9 +4,11 @@ import { createModulePathPatternsTree, ModulePathPatternsTree, } from './create-module-path-patterns-tree'; +import * as path from 'path'; import getFs from '../../fs/getFs'; import { FOLDER_CHARACTERS_REGEX_STRING } from '../../tags/calc-tags-for-module'; import { flattenModules } from './flatten-modules'; +import { GlobMatcher } from '../../util/match-glob'; /** * The current criterion for finding modules is via @@ -18,11 +20,15 @@ import { flattenModules } from './flatten-modules'; export function findModulePathsWithoutBarrel( moduleConfig: ModuleConfig, rootDir: FsPath, - barrelFileName: string + isBarrelMatch: GlobMatcher, ): Set { const paths = flattenModules(moduleConfig, ''); const modulePathsPatternTree = createModulePathPatternsTree(paths); - const modules = traverseAndMatch(modulePathsPatternTree, rootDir, barrelFileName); + const modules = traverseAndMatch( + modulePathsPatternTree, + rootDir, + isBarrelMatch, + ); return new Set(modules); } @@ -32,14 +38,14 @@ export function findModulePathsWithoutBarrel( function traverseAndMatch( groupedPatterns: ModulePathPatternsTree, basePath: FsPath, - barrelFileName: string + isBarrelMatch: GlobMatcher, ): FsPath[] { const fs = getFs(); const matchedDirectories: FsPath[] = []; // Check if the current directory should be matched if ('' in groupedPatterns) { - addAsModuleIfWithoutBarrel(matchedDirectories, basePath, barrelFileName); + addAsModuleIfWithoutBarrel(matchedDirectories, basePath, isBarrelMatch); } const subDirectories = fs.readDirectory(basePath, 'directory'); @@ -53,11 +59,23 @@ function traverseAndMatch( if (matchingPattern) { if (Object.keys(groupedPatterns[matchingPattern]).length === 0) { - addAsModuleIfWithoutBarrel(matchedDirectories, subDirectory, barrelFileName); + addAsModuleIfWithoutBarrel( + matchedDirectories, + subDirectory, + isBarrelMatch, + ); } else { - const newDirectories = traverseAndMatch(groupedPatterns[matchingPattern], subDirectory, barrelFileName); + const newDirectories = traverseAndMatch( + groupedPatterns[matchingPattern], + subDirectory, + isBarrelMatch, + ); for (const newDirectory of newDirectories) { - addAsModuleIfWithoutBarrel(matchedDirectories, newDirectory, barrelFileName); + addAsModuleIfWithoutBarrel( + matchedDirectories, + newDirectory, + isBarrelMatch, + ); } } } @@ -89,13 +107,22 @@ function matchPattern(pattern: string, pathSegment: string): boolean { function addAsModuleIfWithoutBarrel( modulePaths: FsPath[], directory: FsPath, - barrelFileName: string, + isBarrelMatch: GlobMatcher, ) { - const fs = getFs(); - - if (fs.exists(fs.join(directory, barrelFileName))) { + if (hasBarrelFile(directory, isBarrelMatch)) { return; } modulePaths.push(directory); } + +function hasBarrelFile(directory: FsPath, isBarrelMatch: GlobMatcher): boolean { + const fs = getFs(); + const children = fs.readDirectory(directory); + return children.some((child) => { + if (fs.isFile(child)) { + return isBarrelMatch(path.basename(child)); + } + return false; + }); +} diff --git a/packages/core/src/lib/modules/module.ts b/packages/core/src/lib/modules/module.ts index 8b7e86c0..3aeaa225 100644 --- a/packages/core/src/lib/modules/module.ts +++ b/packages/core/src/lib/modules/module.ts @@ -1,7 +1,9 @@ import { UnassignedFileInfo } from '../file-info/unassigned-file-info'; import { FileInfo } from './file.info'; -import { FsPath, toFsPath } from '../file-info/fs-path'; +import * as path from 'path'; +import { FsPath } from '../file-info/fs-path'; import getFs from '../fs/getFs'; +import { GlobMatcher } from '../util/match-glob'; /** * Since modules are constructed incrementally with in-place @@ -17,7 +19,7 @@ export class Module { private readonly getFileInfo: (fsPath: FsPath) => FileInfo, public readonly isRoot: boolean, public readonly hasBarrel: boolean, - private readonly barrelFile: string, + private readonly isBarrelMatch: GlobMatcher, ) { } @@ -27,7 +29,22 @@ export class Module { this.fileInfos.push(fileInfo); } - get barrelPath(): FsPath { - return toFsPath(getFs().join(this.path, this.barrelFile)); + /** + * Checks whether the given file path is a barrel (entry) file + * of this module by matching its filename against the configured + * barrel file patterns. + * + * Only files directly inside the module root directory qualify; + * nested files with barrel-like names (e.g. `internal/index.ts`) + * are not treated as barrel entries. + */ + isBarrelFile(filePath: FsPath): boolean { + const fileDir = getFs().getParent(filePath); + + if (fileDir !== this.path) { + return false; + } + + return this.isBarrelMatch(path.basename(filePath)); } } diff --git a/packages/core/src/lib/modules/tests/create-module.spec.ts b/packages/core/src/lib/modules/tests/create-module.spec.ts index 7f59a191..94b918a8 100644 --- a/packages/core/src/lib/modules/tests/create-module.spec.ts +++ b/packages/core/src/lib/modules/tests/create-module.spec.ts @@ -16,6 +16,7 @@ import { FileInfo } from '../file.info'; import { buildFileInfo } from '../../test/build-file-info'; import { fromEntries } from '../../util/typed-object-functions'; import { ModulePathMap } from '../find-module-paths'; +import { createGlobMatcher } from '../../util/match-glob'; interface TestParameter { fileInfo: UnassignedFileInfo; @@ -226,12 +227,11 @@ function assertModule(createTestParams: () => TestParameter) { const modulePathMap: ModulePathMap = fromEntries( barrelFiles.map((path) => [toFsPath(path.replace('/index.ts', '')), true]), ); + const isBarrelMatch = createGlobMatcher(['index.ts']); const modules = createModules(modulePathMap, fileInfoMap, getFileInfo, { entryFileInfo: fileInfo, rootDir: toFsPath('/'), - barrelFile: 'index.ts', - encapsulatedFolderName: 'internals', - showWarningOnBarrelFileLessCollision: true, + isBarrelMatch, }); const expectedModules = testParams.expectedModules.map((mi) => { @@ -247,8 +247,7 @@ function assertModule(createTestParams: () => TestParameter) { getFileInfo, mi.path === '/', mi.path !== '/', - 'index.ts', - 'internals', + isBarrelMatch, ); for (const fi of fileInfos) { module.addFileInfo(fi); diff --git a/packages/core/src/lib/modules/tests/find-module-paths-with-barrel.spec.ts b/packages/core/src/lib/modules/tests/find-module-paths-with-barrel.spec.ts index 04211bbd..c534b325 100644 --- a/packages/core/src/lib/modules/tests/find-module-paths-with-barrel.spec.ts +++ b/packages/core/src/lib/modules/tests/find-module-paths-with-barrel.spec.ts @@ -4,7 +4,7 @@ import { tsConfig } from "../../test/fixtures/ts-config"; import { findModulePathsWithBarrel } from "../internal/find-module-paths-with-barrel"; import { toFsPath } from "../../file-info/fs-path"; -describe('should find two modules', () => { +describe('findModulePathsWithBarrel', () => { it('should find two modules', () => { createProject({ 'tsconfig.json': tsConfig(), @@ -20,11 +20,106 @@ describe('should find two modules', () => { 'index.ts': '', }}}); - const modulePaths = findModulePathsWithBarrel([toFsPath('/project')], 'index.ts'); + const modulePaths = findModulePathsWithBarrel([toFsPath('/project')], ['index.ts']); expect(modulePaths).toEqual([ '/project/src/app/customers', '/project/src/app/holidays', ]); }); + + it('should find modules with glob barrel file pattern', () => { + createProject({ + 'tsconfig.json': tsConfig(), + 'sheriff.config.ts': '', + 'src/app': { + 'app.component.ts': '', + orders: { + 'order.component.ts': '', + 'index.ts': '', + 'index.state.ts': '', + 'index.routing.ts': '', + }, + }, + }); + + const modulePaths = findModulePathsWithBarrel( + [toFsPath('/project')], + ['index.*.ts'], + ); + + expect(modulePaths).toEqual(['/project/src/app/orders']); + }); + + it('should find modules with array of barrel file patterns', () => { + createProject({ + 'tsconfig.json': tsConfig(), + 'sheriff.config.ts': '', + 'src/app': { + 'app.component.ts': '', + customers: { + 'customer.component.ts': '', + 'index.ts': '', + }, + orders: { + 'order.component.ts': '', + 'index.routing.ts': '', + }, + }, + }); + + const modulePaths = findModulePathsWithBarrel( + [toFsPath('/project')], + ['index.ts', 'index.routing.ts'], + ); + + expect(modulePaths).toEqual( + expect.arrayContaining([ + '/project/src/app/customers', + '/project/src/app/orders', + ]), + ); + expect(modulePaths).toHaveLength(2); + }); + + it('should deduplicate module paths when multiple patterns match same directory', () => { + createProject({ + 'tsconfig.json': tsConfig(), + 'sheriff.config.ts': '', + 'src/app': { + orders: { + 'order.component.ts': '', + 'index.ts': '', + 'index.routing.ts': '', + }, + }, + }); + + const modulePaths = findModulePathsWithBarrel( + [toFsPath('/project')], + ['index.ts', 'index.routing.ts'], + ); + + expect(modulePaths).toEqual(['/project/src/app/orders']); + }); + + it('should not match non-barrel files with glob', () => { + createProject({ + 'tsconfig.json': tsConfig(), + 'sheriff.config.ts': '', + 'src/app': { + orders: { + 'main.ts': '', + 'order.component.ts': '', + }, + }, + }); + + const modulePaths = findModulePathsWithBarrel( + [toFsPath('/project')], + ['index.*.ts'], + ); + + expect(modulePaths).toEqual([]); + }); }); diff --git a/packages/core/src/lib/modules/tests/find-module-paths-without-barrel.spec.ts b/packages/core/src/lib/modules/tests/find-module-paths-without-barrel.spec.ts index c5ed606b..9ecccd95 100644 --- a/packages/core/src/lib/modules/tests/find-module-paths-without-barrel.spec.ts +++ b/packages/core/src/lib/modules/tests/find-module-paths-without-barrel.spec.ts @@ -5,6 +5,7 @@ import { createProject } from '../../test/project-creator'; import { findModulePathsWithoutBarrel } from '../internal/find-module-paths-without-barrel'; import { useVirtualFs } from '../../fs/getFs'; import { toFsPath } from '../../file-info/fs-path'; +import { createGlobMatcher } from '../../util/match-glob'; function assertProject(fileTree: FileTree) { return { @@ -18,7 +19,7 @@ function assertProject(fileTree: FileTree) { const actualModulePaths = findModulePathsWithoutBarrel( moduleConfig, toFsPath('/project'), - 'index.ts', + createGlobMatcher(['index.ts']), ); expect(Array.from(actualModulePaths)).toEqual(absoluteModulePaths); }, diff --git a/packages/core/src/lib/modules/tests/module-is-barrel-file.spec.ts b/packages/core/src/lib/modules/tests/module-is-barrel-file.spec.ts new file mode 100644 index 00000000..f98b7a31 --- /dev/null +++ b/packages/core/src/lib/modules/tests/module-is-barrel-file.spec.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { Module } from '../module'; +import { FsPath, toFsPath } from '../../file-info/fs-path'; +import { FileInfo } from '../file.info'; +import getFs, { useVirtualFs } from '../../fs/getFs'; +import { createGlobMatcher } from '../../util/match-glob'; + +describe('Module.isBarrelFile', () => { + beforeAll(() => { + useVirtualFs(); + }); + + afterEach(() => { + getFs().reset(); + }); + + function writePaths(...paths: string[]) { + const fs = getFs(); + for (const p of paths) { + fs.writeFile(p, ''); + } + } + + function createModule( + modulePath: string, + barrelFilePatterns: string[], + ): Module { + const fileInfoMap = new Map(); + const getFileInfo = (path: FsPath) => { + throw new Error(`not needed for this test: ${path}`); + }; + return new Module( + toFsPath(modulePath), + fileInfoMap, + getFileInfo, + false, + true, + createGlobMatcher(barrelFilePatterns), + ); + } + + it('should match a barrel file at the module root', () => { + writePaths('/project/src/app/orders/index.ts'); + const module = createModule('/project/src/app/orders', ['index.ts']); + expect( + module.isBarrelFile(toFsPath('/project/src/app/orders/index.ts')), + ).toBe(true); + }); + + it('should reject a nested file with a barrel-like name', () => { + writePaths( + '/project/src/app/orders/index.ts', + '/project/src/app/orders/internal/index.ts', + ); + const module = createModule('/project/src/app/orders', ['index.ts']); + expect( + module.isBarrelFile( + toFsPath('/project/src/app/orders/internal/index.ts'), + ), + ).toBe(false); + }); + + it('should reject a deeply nested file with a barrel-like name', () => { + writePaths( + '/project/src/app/orders/index.ts', + '/project/src/app/orders/feature/sub/index.ts', + ); + const module = createModule('/project/src/app/orders', ['index.ts']); + expect( + module.isBarrelFile( + toFsPath('/project/src/app/orders/feature/sub/index.ts'), + ), + ).toBe(false); + }); + + it('should match glob barrel patterns at the module root', () => { + writePaths( + '/project/src/app/orders/index.ts', + '/project/src/app/orders/index.routing.ts', + ); + const module = createModule('/project/src/app/orders', [ + 'index.ts', + 'index.*.ts', + ]); + expect( + module.isBarrelFile( + toFsPath('/project/src/app/orders/index.routing.ts'), + ), + ).toBe(true); + }); + + it('should reject nested files matching glob barrel patterns', () => { + writePaths( + '/project/src/app/orders/index.ts', + '/project/src/app/orders/feature/index.routing.ts', + ); + const module = createModule('/project/src/app/orders', [ + 'index.ts', + 'index.*.ts', + ]); + expect( + module.isBarrelFile( + toFsPath('/project/src/app/orders/feature/index.routing.ts'), + ), + ).toBe(false); + }); + + it('should reject non-barrel files at the module root', () => { + writePaths( + '/project/src/app/orders/index.ts', + '/project/src/app/orders/order-list.component.ts', + ); + const module = createModule('/project/src/app/orders', ['index.ts']); + expect( + module.isBarrelFile( + toFsPath('/project/src/app/orders/order-list.component.ts'), + ), + ).toBe(false); + }); + + it('should match multiple explicit barrel patterns', () => { + writePaths( + '/project/src/app/orders/index.ts', + '/project/src/app/orders/index.routing.ts', + '/project/src/app/orders/index.state.ts', + ); + const module = createModule('/project/src/app/orders', [ + 'index.ts', + 'index.routing.ts', + 'index.state.ts', + ]); + expect( + module.isBarrelFile(toFsPath('/project/src/app/orders/index.ts')), + ).toBe(true); + expect( + module.isBarrelFile( + toFsPath('/project/src/app/orders/index.routing.ts'), + ), + ).toBe(true); + expect( + module.isBarrelFile(toFsPath('/project/src/app/orders/index.state.ts')), + ).toBe(true); + }); + + it('should reject barrel files from a different module', () => { + writePaths( + '/project/src/app/orders/index.ts', + '/project/src/app/customers/index.ts', + ); + const module = createModule('/project/src/app/orders', ['index.ts']); + expect( + module.isBarrelFile(toFsPath('/project/src/app/customers/index.ts')), + ).toBe(false); + }); +}); diff --git a/packages/core/src/lib/test/project-creator.ts b/packages/core/src/lib/test/project-creator.ts index 5229693b..8b7a4ec2 100644 --- a/packages/core/src/lib/test/project-creator.ts +++ b/packages/core/src/lib/test/project-creator.ts @@ -78,8 +78,13 @@ function serializeDepRules(config: UserSheriffConfig): Configuration { ? mergedConfig.ignoreFileExtensions(defaultConfig.ignoreFileExtensions) : mergedConfig.ignoreFileExtensions; + const barrelFileName = Array.isArray(mergedConfig.barrelFileName) + ? mergedConfig.barrelFileName + : [mergedConfig.barrelFileName]; + return { ...mergedConfig, + barrelFileName, depRules: Object.entries(mergedConfig.depRules).reduce( (current, [from, tos]) => ({ ...current, diff --git a/packages/core/src/lib/util/match-glob.spec.ts b/packages/core/src/lib/util/match-glob.spec.ts new file mode 100644 index 00000000..f66e22ac --- /dev/null +++ b/packages/core/src/lib/util/match-glob.spec.ts @@ -0,0 +1,334 @@ +import { describe, it, expect } from 'vitest'; +import { matchGlob } from './match-glob'; + +describe('matchGlob', () => { + describe('exact matching', () => { + it('should match identical strings', () => { + expect(matchGlob('index.ts', 'index.ts')).toBe(true); + }); + + it('should not match different strings', () => { + expect(matchGlob('index.ts', 'main.ts')).toBe(false); + }); + + it('should match empty pattern against empty text', () => { + expect(matchGlob('', '')).toBe(true); + }); + + it('should not match empty pattern against non-empty text', () => { + expect(matchGlob('', 'index.ts')).toBe(false); + }); + + it('should not match non-empty pattern against empty text', () => { + expect(matchGlob('index.ts', '')).toBe(false); + }); + }); + + describe('case insensitivity', () => { + it('should match case-insensitively', () => { + expect(matchGlob('Index.ts', 'index.ts')).toBe(true); + }); + + it('should match uppercase pattern against lowercase text', () => { + expect(matchGlob('INDEX.TS', 'index.ts')).toBe(true); + }); + + it('should match lowercase pattern against uppercase text', () => { + expect(matchGlob('index.ts', 'INDEX.TS')).toBe(true); + }); + }); + + describe('star wildcard (*)', () => { + it('should match any characters with *', () => { + expect(matchGlob('index.*.ts', 'index.routing.ts')).toBe(true); + }); + + it('should match multiple characters with *', () => { + expect(matchGlob('index.*.ts', 'index.state.ts')).toBe(true); + }); + + it('should match zero characters with *', () => { + expect(matchGlob('index*.ts', 'index.ts')).toBe(true); + }); + + it('should match a leading *', () => { + expect(matchGlob('*.ts', 'index.ts')).toBe(true); + }); + + it('should match a trailing *', () => { + expect(matchGlob('index.*', 'index.ts')).toBe(true); + }); + + it('should match pattern with only *', () => { + expect(matchGlob('*', 'anything')).toBe(true); + }); + + it('should match empty text against a single *', () => { + expect(matchGlob('*', '')).toBe(true); + }); + + it('should match with multiple * wildcards', () => { + expect(matchGlob('*index*ts*', 'my-index-file-ts-backup')).toBe(true); + }); + + it('should match dots with *', () => { + expect(matchGlob('index.*.*', 'index.routing.ts')).toBe(true); + }); + + it('should not match when surrounding text does not match', () => { + expect(matchGlob('index.*.ts', 'main.routing.ts')).toBe(false); + }); + + it('should not match when trailing text does not match', () => { + expect(matchGlob('index.*.ts', 'index.routing.js')).toBe(false); + }); + }); + + describe('question mark wildcard (?)', () => { + it('should match exactly one character with ?', () => { + expect(matchGlob('index?.ts', 'index1.ts')).toBe(true); + }); + + it('should not match zero characters with ?', () => { + expect(matchGlob('index?.ts', 'index.ts')).toBe(false); + }); + + it('should not match multiple characters with single ?', () => { + expect(matchGlob('index?.ts', 'index12.ts')).toBe(false); + }); + + it('should match with multiple ? wildcards', () => { + expect(matchGlob('index??.ts', 'index12.ts')).toBe(true); + }); + + it('should match any character including dots', () => { + expect(matchGlob('index?ts', 'index.ts')).toBe(true); + }); + }); + + describe('combined wildcards', () => { + it('should handle * and ? together', () => { + expect(matchGlob('index.?*.ts', 'index.routing.ts')).toBe(true); + }); + + it('should require ? to match at least one char in combo', () => { + expect(matchGlob('index.?*.ts', 'index.a.ts')).toBe(true); + }); + + it('should not match when ? cannot be satisfied in combo', () => { + expect(matchGlob('index.?*.ts', 'index..ts')).toBe(false); + }); + + it('should not match when ? part is missing', () => { + expect(matchGlob('index.?*.ts', 'index.ts')).toBe(false); + }); + }); + + describe('special regex characters', () => { + it('should handle dots literally', () => { + expect(matchGlob('index.ts', 'indexXts')).toBe(false); + }); + + it('should handle parentheses literally', () => { + expect(matchGlob('file(1).ts', 'file(1).ts')).toBe(true); + }); + + it('should handle square brackets literally', () => { + expect(matchGlob('file[0].ts', 'file[0].ts')).toBe(true); + }); + + it('should handle curly braces literally', () => { + expect(matchGlob('file{a}.ts', 'file{a}.ts')).toBe(true); + }); + + it('should handle plus sign literally', () => { + expect(matchGlob('file+.ts', 'file+.ts')).toBe(true); + }); + + it('should handle caret literally', () => { + expect(matchGlob('^file.ts', '^file.ts')).toBe(true); + }); + + it('should handle dollar sign literally', () => { + expect(matchGlob('file$.ts', 'file$.ts')).toBe(true); + }); + + it('should handle pipe literally', () => { + expect(matchGlob('file|other.ts', 'file|other.ts')).toBe(true); + }); + + it('should handle backslash literally', () => { + expect(matchGlob('file\\.ts', 'file\\.ts')).toBe(true); + }); + }); + + describe('real-world barrel file patterns', () => { + it('should match index.ts with exact name', () => { + expect(matchGlob('index.ts', 'index.ts')).toBe(true); + }); + + it('should match index.routing.ts with glob', () => { + expect(matchGlob('index.*.ts', 'index.routing.ts')).toBe(true); + }); + + it('should match index.state.ts with glob', () => { + expect(matchGlob('index.*.ts', 'index.state.ts')).toBe(true); + }); + + it('should not match non-index files with index glob', () => { + expect(matchGlob('index.*.ts', 'main.routing.ts')).toBe(false); + }); + + it('should not match index.ts with index.*.ts (requires at least one char after dot)', () => { + expect(matchGlob('index.*.ts', 'index..ts')).toBe(true); + }); + + it('should match public-api.ts with public-*.ts', () => { + expect(matchGlob('public-*.ts', 'public-api.ts')).toBe(true); + }); + + it('should match barrel.ts exactly', () => { + expect(matchGlob('barrel.ts', 'barrel.ts')).toBe(true); + }); + + it('should not match non-ts file with ts glob', () => { + expect(matchGlob('*.ts', 'file.js')).toBe(false); + }); + }); + + describe('consecutive and adjacent wildcards', () => { + it('should handle ** same as *', () => { + expect(matchGlob('index.**.ts', 'index.routing.ts')).toBe(true); + }); + + it('should handle ** matching empty text segment', () => { + expect(matchGlob('index.**.ts', 'index..ts')).toBe(true); + }); + + it('should handle *? requiring at least one char', () => { + expect(matchGlob('*?.ts', 'a.ts')).toBe(true); + }); + + it('should not match *? when no char precedes dot', () => { + expect(matchGlob('*?.ts', '.ts')).toBe(false); + }); + + it('should handle ?* matching one or more chars', () => { + expect(matchGlob('?*.ts', 'ab.ts')).toBe(true); + }); + + it('should handle ?* matching exactly one char', () => { + expect(matchGlob('?*.ts', 'a.ts')).toBe(true); + }); + + it('should not match ?* against zero chars', () => { + expect(matchGlob('?*.ts', '.ts')).toBe(false); + }); + }); + + describe('greedy star matching', () => { + it('should match with multiple possible split points', () => { + expect(matchGlob('*.*.ts', 'a.b.ts')).toBe(true); + }); + + it('should handle greedy * with three dot segments', () => { + expect(matchGlob('*.*.ts', 'a.b.c.ts')).toBe(true); + }); + + it('should handle * greedily consuming dots', () => { + expect(matchGlob('*.*', 'index.routing.ts')).toBe(true); + }); + + it('should match when * must split across dots', () => { + expect(matchGlob('index.*.*.ts', 'index.routing.lazy.ts')).toBe(true); + }); + + it('should not match when insufficient segments for multiple *', () => { + expect(matchGlob('index.*.*.ts', 'index.routing.ts')).toBe(false); + }); + }); + + describe('single-character pattern edge cases', () => { + it('should match single ? against single char', () => { + expect(matchGlob('?', 'a')).toBe(true); + }); + + it('should not match single ? against empty string', () => { + expect(matchGlob('?', '')).toBe(false); + }); + + it('should not match single ? against two chars', () => { + expect(matchGlob('?', 'ab')).toBe(false); + }); + + it('should match ?? against exactly two chars', () => { + expect(matchGlob('??', 'ab')).toBe(true); + }); + + it('should not match ?? against one char', () => { + expect(matchGlob('??', 'a')).toBe(false); + }); + + it('should not match ?? against three chars', () => { + expect(matchGlob('??', 'abc')).toBe(false); + }); + }); + + describe('boundary and length edge cases', () => { + it('should not match when pattern is longer than text', () => { + expect(matchGlob('index.routing.ts', 'index.ts')).toBe(false); + }); + + it('should not match when text is longer than pattern', () => { + expect(matchGlob('index.ts', 'index.routing.ts')).toBe(false); + }); + + it('should match pattern with wildcard at both ends', () => { + expect(matchGlob('*index*', 'my-index-file')).toBe(true); + }); + + it('should match long filename with simple glob', () => { + expect( + matchGlob('index.*.ts', 'index.very-long-feature-name.ts'), + ).toBe(true); + }); + + it('should handle text that is a prefix of the pattern', () => { + expect(matchGlob('index.routing.ts', 'index.routing')).toBe(false); + }); + + it('should handle text that is a suffix of the pattern', () => { + expect(matchGlob('routing.ts', 'index.routing.ts')).toBe(false); + }); + }); + + describe('encapsulationPattern reuse scenarios', () => { + it('should match path-like patterns with *', () => { + expect(matchGlob('public-*.ts', 'public-api.ts')).toBe(true); + }); + + it('should match exposed file pattern', () => { + expect(matchGlob('*.component.ts', 'user.component.ts')).toBe(true); + }); + + it('should not match private file against public pattern', () => { + expect(matchGlob('*.component.ts', 'user.service.ts')).toBe(false); + }); + + it('should match with leading underscore convention', () => { + expect(matchGlob('_*.ts', '_internal.ts')).toBe(true); + }); + + it('should not match non-underscore file against underscore pattern', () => { + expect(matchGlob('_*.ts', 'internal.ts')).toBe(false); + }); + + it('should match feature module barrel with question mark', () => { + expect(matchGlob('index.?.ts', 'index.a.ts')).toBe(true); + }); + + it('should not match multi-char segment against single ?', () => { + expect(matchGlob('index.?.ts', 'index.routing.ts')).toBe(false); + }); + }); +}); diff --git a/packages/core/src/lib/util/match-glob.ts b/packages/core/src/lib/util/match-glob.ts new file mode 100644 index 00000000..54811c69 --- /dev/null +++ b/packages/core/src/lib/util/match-glob.ts @@ -0,0 +1,74 @@ +export type GlobMatcher = (text: string) => boolean; + +/** + * Creates a pre-compiled matcher function for the given glob patterns. + * + * Supported wildcards: + * - `*` matches zero or more characters + * - `?` matches exactly one character + * + * All other characters are matched literally. + * The matching is **case-insensitive**. + * + * @param patterns - The glob patterns to match against. + * @returns A function that returns `true` if the text matches any pattern. + */ +export function createGlobMatcher(patterns: string[]): GlobMatcher { + const literals = patterns + .filter((p) => !p.includes('*') && !p.includes('?')) + .map((p) => p.toLowerCase()); + + const regexes = patterns + .filter((p) => p.includes('*') || p.includes('?')) + .map((p) => new RegExp(`^${globToRegexString(p)}$`, 'i')); + + return (text: string): boolean => { + const lower = text.toLowerCase(); + return literals.includes(lower) || regexes.some((re) => re.test(text)); + }; +} + +/** + * Matches a text string against a glob pattern. + * + * Supported wildcards: + * - `*` matches zero or more characters + * - `?` matches exactly one character + * + * All other characters are matched literally. + * The matching is **case-insensitive**. + * + * @param pattern - The glob pattern to match against. + * @param text - The text to test. + * @returns `true` if the text matches the pattern. + */ +export function matchGlob(pattern: string, text: string): boolean { + const regexString = globToRegexString(pattern); + const regex = new RegExp(`^${regexString}$`, 'i'); + return regex.test(text); +} + +function globToRegexString(pattern: string): string { + let result = ''; + + for (let i = 0; i < pattern.length; i++) { + const char = pattern[i]; + + if (char === '*') { + result += '.*'; + } else if (char === '?') { + result += '.'; + } else { + result += escapeRegexChar(char); + } + } + + return result; +} + +function escapeRegexChar(char: string): string { + if ('.+^${}()|[]\\'.includes(char)) { + return `\\${char}`; + } + return char; +}