diff --git a/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.spec.ts b/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.spec.ts index b2b8d987a6..c40bae9830 100644 --- a/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.spec.ts +++ b/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.spec.ts @@ -80,6 +80,9 @@ describe('TargetProjectLocator', () => { '@proj/proj1234-child/*': ['libs/proj1234-child/*'], '#hash-path': ['libs/hash-project/src/index.ts'], 'parent-path/*': ['libs/parent-path/*'], + '@proj/feature-*': ['libs/features/*'], + '@proj/*/utils': ['libs/scope/*/utils'], + '@proj/*-util': ['libs/utils/*'], }, }, }; @@ -227,6 +230,27 @@ describe('TargetProjectLocator', () => { }, }, }, + users: { + name: 'users', + type: 'lib', + data: { + root: 'libs/features/users', + }, + }, + admin: { + name: 'admin', + type: 'lib', + data: { + root: 'libs/scope/admin', + }, + }, + 'file-system': { + name: 'file-system', + type: 'lib', + data: { + root: 'libs/utils/file-system', + }, + }, }; npmProjects = { 'npm:@ng/core': { @@ -413,19 +437,33 @@ describe('TargetProjectLocator', () => { }); it('should be able to resolve wildcard paths', () => { - const parentProject = targetProjectLocator.findProjectFromImport( - 'parent-path', - 'libs/proj1/index.ts' - ); - - expect(parentProject).toEqual('parent-project'); - + // 'parent-path/*': ['libs/parent-path/*'] => 'libs/parent-path/child-path' const childProject = targetProjectLocator.findProjectFromImport( 'parent-path/child-path', 'libs/proj1/index.ts' ); - expect(childProject).toEqual('child-project'); + + // '@proj/feature-*': ['libs/features/*'] => 'libs/features/users' + const usersProject = targetProjectLocator.findProjectFromImport( + '@proj/feature-users', + 'libs/proj1/index.ts' + ); + expect(usersProject).toEqual('users'); + + // '@proj/*/utils': ['libs/scope/*/utils'] => 'libs/scope/admin/utils' + const adminProject = targetProjectLocator.findProjectFromImport( + '@proj/admin/utils', + 'libs/proj1/index.ts' + ); + expect(adminProject).toEqual('admin'); + + // '@proj/*-util': ['libs/utils/*'] => 'libs/utils/file-system' + const fileSystemProject = targetProjectLocator.findProjectFromImport( + '@proj/file-system-util', + 'libs/proj1/index.ts' + ); + expect(fileSystemProject).toEqual('file-system'); }); it('should be able to resolve paths that start with a #', () => { diff --git a/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.ts b/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.ts index d97f3714bf..78bb14febb 100644 --- a/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.ts +++ b/packages/nx/src/plugins/js/project-graph/build-dependencies/target-project-locator.ts @@ -30,6 +30,16 @@ import { */ type NpmResolutionCache = Map; +type PathPattern = { + pattern: string; + prefix: string; + suffix: string; +}; +type ParsedPatterns = { + matchableStrings: Set | undefined; + patterns: PathPattern[] | undefined; +}; + /** * Use a shared cache to avoid repeated npm package resolution work within the TargetProjectLocator. */ @@ -47,6 +57,7 @@ export class TargetProjectLocator { private npmProjects: Record; private tsConfig = this.getRootTsConfig(); private paths = this.tsConfig.config?.compilerOptions?.paths; + private parsedPathPatterns: ParsedPatterns | undefined; private typescriptResolutionCache = new Map(); private packagesMetadata: { entryPointsToProjectMap: Record; @@ -81,6 +92,10 @@ export class TargetProjectLocator { } return acc; }, {} as Record); + + if (this.tsConfig.config?.compilerOptions?.paths) { + this.parsePaths(this.tsConfig.config.compilerOptions.paths); + } } /** @@ -97,14 +112,19 @@ export class TargetProjectLocator { } // find project using tsconfig paths - const results = this.findPaths(importExpr); + const results = this.findMatchingPaths(importExpr); if (results) { const [path, paths] = results; + const matchedStar = + typeof path === 'string' + ? undefined + : importExpr.substring( + path.prefix.length, + importExpr.length - path.suffix.length + ); for (let p of paths) { - const r = p.endsWith('/*') - ? join(dirname(p), relative(path.replace(/\*$/, ''), importExpr)) - : p; - const maybeResolvedProject = this.findProjectOfResolvedModule(r); + const path = matchedStar ? p.replace('*', matchedStar) : p; + const maybeResolvedProject = this.findProjectOfResolvedModule(path); if (maybeResolvedProject) { return maybeResolvedProject; } @@ -237,7 +257,7 @@ export class TargetProjectLocator { /** * Return file paths matching the import relative to the repo root * @param normalizedImportExpr - * @returns + * @deprecated Use `findMatchingPaths` instead. It will be removed in Nx v22. */ findPaths(normalizedImportExpr: string): string[] | undefined { if (!this.paths) { @@ -258,6 +278,37 @@ export class TargetProjectLocator { return undefined; } + findMatchingPaths( + importExpr: string + ): [pattern: string | PathPattern, paths: string[]] | undefined { + if (!this.parsedPathPatterns) { + return undefined; + } + + const { matchableStrings, patterns } = this.parsedPathPatterns; + if (matchableStrings.has(importExpr)) { + return [importExpr, this.paths[importExpr]]; + } + + // https://github.com/microsoft/TypeScript/blob/29e6d6689dfb422e4f1395546c1917d07e1f664d/src/compiler/core.ts#L2410 + let matchedValue: PathPattern | undefined; + let longestMatchPrefixLength = -1; + for (let i = 0; i < patterns.length; i++) { + const pattern = patterns[i]; + if ( + pattern.prefix.length > longestMatchPrefixLength && + this.isPatternMatch(pattern, importExpr) + ) { + longestMatchPrefixLength = pattern.prefix.length; + matchedValue = pattern; + } + } + + return matchedValue + ? [matchedValue, this.paths[matchedValue.pattern]] + : undefined; + } + findImportInWorkspaceProjects(importPath: string): string | null { this.packagesMetadata ??= getWorkspacePackagesMetadata(this.nodes); @@ -279,6 +330,40 @@ export class TargetProjectLocator { return this.packagesMetadata.packageToProjectMap[dep]?.name; } + private isPatternMatch( + { prefix, suffix }: PathPattern, + candidate: string + ): boolean { + return ( + candidate.length >= prefix.length + suffix.length && + candidate.startsWith(prefix) && + candidate.endsWith(suffix) + ); + } + + private parsePaths(paths: Record): void { + this.parsedPathPatterns = { + matchableStrings: new Set(), + patterns: [], + }; + + for (const key of Object.keys(paths)) { + const parts = key.split('*'); + if (parts.length > 2) { + continue; + } + if (parts.length === 1) { + this.parsedPathPatterns.matchableStrings.add(key); + continue; + } + this.parsedPathPatterns.patterns.push({ + pattern: key, + prefix: parts[0], + suffix: parts[1], + }); + } + } + private resolveImportWithTypescript( normalizedImportExpr: string, filePath: string