diff --git a/docs/generated/cli/run-many.md b/docs/generated/cli/run-many.md index 623e65370a8d9..91620395d8bac 100644 --- a/docs/generated/cli/run-many.md +++ b/docs/generated/cli/run-many.md @@ -44,7 +44,13 @@ Test proj1 and proj2 in sequence: Test all projects ending with `*-app` except `excluded-app`. Note: your shell may require you to escape the `*` like this: `\*`: ```shell - nx run-many --target=test --projects=*-app --exclude excluded-app + nx run-many --target=test --projects=*-app --exclude=excluded-app +``` + +Test all projects with tags starting with `api-`. Note: your shell may require you to escape the `*` like this: `\*`: + +```shell + nx run-many --target=test --projects=tag:api-* ``` Run lint, test, and build targets for all projects. Requires Nx v15.4+: diff --git a/docs/generated/packages/nx/documents/run-many.md b/docs/generated/packages/nx/documents/run-many.md index 623e65370a8d9..91620395d8bac 100644 --- a/docs/generated/packages/nx/documents/run-many.md +++ b/docs/generated/packages/nx/documents/run-many.md @@ -44,7 +44,13 @@ Test proj1 and proj2 in sequence: Test all projects ending with `*-app` except `excluded-app`. Note: your shell may require you to escape the `*` like this: `\*`: ```shell - nx run-many --target=test --projects=*-app --exclude excluded-app + nx run-many --target=test --projects=*-app --exclude=excluded-app +``` + +Test all projects with tags starting with `api-`. Note: your shell may require you to escape the `*` like this: `\*`: + +```shell + nx run-many --target=test --projects=tag:api-* ``` Run lint, test, and build targets for all projects. Requires Nx v15.4+: diff --git a/e2e/nx-run/src/affected-graph.test.ts b/e2e/nx-run/src/affected-graph.test.ts index bb123ad6140a7..a48383d34d2b4 100644 --- a/e2e/nx-run/src/affected-graph.test.ts +++ b/e2e/nx-run/src/affected-graph.test.ts @@ -38,7 +38,7 @@ describe('Nx Affected and Graph Tests', () => { runCLI(`generate @nrwl/js:lib ${mylib}`); runCLI(`generate @nrwl/js:lib ${mylib2}`); runCLI( - `generate @nrwl/js:lib ${mypublishablelib} --publishable --importPath=@${proj}/${mypublishablelib}` + `generate @nrwl/js:lib ${mypublishablelib} --publishable --importPath=@${proj}/${mypublishablelib} --tags=ui` ); updateFile( @@ -135,11 +135,19 @@ describe('Nx Affected and Graph Tests', () => { expect(build).toContain('Successfully ran target build'); const buildExcluded = runCLI( - `affected:build --files="libs/${mylib}/src/index.ts" --exclude ${myapp}` + `affected:build --files="libs/${mylib}/src/index.ts" --exclude=${myapp}` ); expect(buildExcluded).toContain(`Running target build for 2 projects:`); expect(buildExcluded).toContain(`- ${mypublishablelib}`); + const buildExcludedByTag = runCLI( + `affected:build --files="libs/${mylib}/src/index.ts" --exclude=tag:ui` + ); + expect(buildExcludedByTag).toContain( + `Running target build for 2 projects:` + ); + expect(buildExcludedByTag).not.toContain(`- ${mypublishablelib}`); + // test updateFile( `apps/${myapp}/src/app/app.element.spec.ts`, diff --git a/e2e/nx-run/src/run.test.ts b/e2e/nx-run/src/run.test.ts index d069c83c1cd14..ffb46fd4eaca2 100644 --- a/e2e/nx-run/src/run.test.ts +++ b/e2e/nx-run/src/run.test.ts @@ -440,9 +440,13 @@ describe('Nx Running Tests', () => { runCLI(`generate @nrwl/web:app ${appA}`); runCLI(`generate @nrwl/js:lib ${libA} --bundler=tsc --defaults`); - runCLI(`generate @nrwl/js:lib ${libB} --bundler=tsc --defaults`); - runCLI(`generate @nrwl/js:lib ${libC} --bundler=tsc --defaults`); - runCLI(`generate @nrwl/node:lib ${libD} --defaults`); + runCLI( + `generate @nrwl/js:lib ${libB} --bundler=tsc --defaults --tags=ui-a` + ); + runCLI( + `generate @nrwl/js:lib ${libC} --bundler=tsc --defaults --tags=ui-b,shared` + ); + runCLI(`generate @nrwl/node:lib ${libD} --defaults --tags=api`); // libA depends on libC updateFile( @@ -462,6 +466,7 @@ describe('Nx Running Tests', () => { `run-many --target=build --projects="${libC},${libB}"` ); expect(buildParallel).toContain(`Running target build for 2 projects:`); + expect(buildParallel).not.toContain(`- ${appA}`); expect(buildParallel).not.toContain(`- ${libA}`); expect(buildParallel).toContain(`- ${libB}`); expect(buildParallel).toContain(`- ${libC}`); @@ -480,6 +485,36 @@ describe('Nx Running Tests', () => { expect(buildAllParallel).not.toContain(`- ${libD}`); expect(buildAllParallel).toContain('Successfully ran target build'); + // testing run many by tags + const buildByTagParallel = runCLI( + `run-many --target=build --projects="tag:ui*"` + ); + expect(buildByTagParallel).toContain( + `Running target build for 2 projects:` + ); + expect(buildByTagParallel).not.toContain(`- ${appA}`); + expect(buildByTagParallel).not.toContain(`- ${libA}`); + expect(buildByTagParallel).toContain(`- ${libB}`); + expect(buildByTagParallel).toContain(`- ${libC}`); + expect(buildByTagParallel).not.toContain(`- ${libD}`); + expect(buildByTagParallel).toContain('Successfully ran target build'); + + // testing run many with exclude + const buildWithExcludeParallel = runCLI( + `run-many --target=build --exclude="${libD},tag:ui*"` + ); + expect(buildWithExcludeParallel).toContain( + `Running target build for 2 projects and 1 task they depend on:` + ); + expect(buildWithExcludeParallel).toContain(`- ${appA}`); + expect(buildWithExcludeParallel).toContain(`- ${libA}`); + expect(buildWithExcludeParallel).not.toContain(`- ${libB}`); + expect(buildWithExcludeParallel).toContain(`${libC}`); // should still include libC as dependency despite exclude + expect(buildWithExcludeParallel).not.toContain(`- ${libD}`); + expect(buildWithExcludeParallel).toContain( + 'Successfully ran target build' + ); + // testing run many when project depends on other projects const buildWithDeps = runCLI( `run-many --target=build --projects="${libA}"` @@ -487,6 +522,7 @@ describe('Nx Running Tests', () => { expect(buildWithDeps).toContain( `Running target build for project ${libA} and 1 task it depends on:` ); + expect(buildWithDeps).not.toContain(`- ${appA}`); expect(buildWithDeps).toContain(`- ${libA}`); expect(buildWithDeps).toContain(`${libC}`); // build should include libC as dependency expect(buildWithDeps).not.toContain(`- ${libB}`); diff --git a/packages/nx/src/command-line/affected.ts b/packages/nx/src/command-line/affected.ts index c493d7f97a3b7..e4102a0a05485 100644 --- a/packages/nx/src/command-line/affected.ts +++ b/packages/nx/src/command-line/affected.ts @@ -17,6 +17,7 @@ import { filterAffected } from '../project-graph/affected/affected-project-graph import { TargetDependencyConfig } from '../config/workspace-json-project-json'; import { readNxJson } from '../config/configuration'; import { workspaceConfigurationCheck } from '../utils/workspace-configuration-check'; +import { findMatchingProjects } from '../utils/find-matching-projects'; export async function affected( command: 'apps' | 'libs' | 'graph' | 'print-affected' | 'affected', @@ -168,13 +169,16 @@ async function projectsToRun( ); if (nxArgs.exclude) { - const excludedProjects = new Set(nxArgs.exclude); + const excludedProjects = new Set( + findMatchingProjects(nxArgs.exclude, affectedGraph.nodes) + ); + return Object.entries(affectedGraph.nodes) .filter(([projectName]) => !excludedProjects.has(projectName)) .map(([, project]) => project); } - return Object.values(affectedGraph.nodes) as ProjectGraphProjectNode[]; + return Object.values(affectedGraph.nodes); } function allProjectsWithTarget( diff --git a/packages/nx/src/command-line/examples.ts b/packages/nx/src/command-line/examples.ts index 76a4226f3a25f..68977fc89d460 100644 --- a/packages/nx/src/command-line/examples.ts +++ b/packages/nx/src/command-line/examples.ts @@ -305,10 +305,15 @@ export const examples: Record = { description: 'Test proj1 and proj2 in sequence', }, { - command: 'run-many --target=test --projects=*-app --exclude excluded-app', + command: 'run-many --target=test --projects=*-app --exclude=excluded-app', description: 'Test all projects ending with `*-app` except `excluded-app`. Note: your shell may require you to escape the `*` like this: `\\*`', }, + { + command: 'run-many --target=test --projects=tag:api-*', + description: + 'Test all projects with tags starting with `api-`. Note: your shell may require you to escape the `*` like this: `\\*`', + }, { command: 'run-many --targets=lint,test,build --all', description: diff --git a/packages/nx/src/command-line/nx-commands.ts b/packages/nx/src/command-line/nx-commands.ts index b996b1e18b4dc..4c2f3b6a32c3d 100644 --- a/packages/nx/src/command-line/nx-commands.ts +++ b/packages/nx/src/command-line/nx-commands.ts @@ -481,7 +481,6 @@ function withExcludeOption(yargs: yargs.Argv): yargs.Argv { describe: 'Exclude certain projects from being processed', type: 'string', coerce: parseCSV, - default: '', }); } diff --git a/packages/nx/src/command-line/run-many.spec.ts b/packages/nx/src/command-line/run-many.spec.ts index dcca4bfa7c6fd..7abc2013e1020 100644 --- a/packages/nx/src/command-line/run-many.spec.ts +++ b/packages/nx/src/command-line/run-many.spec.ts @@ -13,6 +13,7 @@ describe('run-many', () => { type: 'lib', data: { root: 'proj1', + tags: ['api', 'theme1'], targets: { build: {}, test: {}, @@ -24,6 +25,7 @@ describe('run-many', () => { type: 'lib', data: { root: 'proj2', + tags: ['ui', 'theme2'], targets: { test: {}, }, @@ -71,6 +73,50 @@ describe('run-many', () => { expect(projects).toContain('proj2'); }); + it('should filter projects by tag', () => { + const projects = projectsToRun( + { + targets: ['test'], + projects: ['tag:api'], + }, + projectGraph + ).map(({ name }) => name); + expect(projects).toContain('proj1'); + expect(projects).not.toContain('proj2'); + }); + + it('should filter projects by tag pattern', () => { + const projects = projectsToRun( + { + targets: ['test'], + projects: ['tag:theme*'], + }, + projectGraph + ).map(({ name }) => name); + expect(projects).toContain('proj1'); + expect(projects).toContain('proj2'); + }); + + it('should filter projects by name and tag', () => { + let projects = projectsToRun( + { + targets: ['test'], + projects: ['proj1', 'tag:ui'], + }, + projectGraph + ).map(({ name }) => name); + expect(projects).toContain('proj1'); + expect(projects).toContain('proj2'); + projects = projectsToRun( + { + targets: ['test'], + projects: ['proj1', 'tag:a*'], + }, + projectGraph + ).map(({ name }) => name); + expect(projects).toContain('proj1'); + }); + it('should exclude projects', () => { const projects = projectsToRun( { @@ -99,6 +145,34 @@ describe('run-many', () => { expect(projects).not.toContain('proj2'); }); + it('should exclude projects by tag', () => { + const projects = projectsToRun( + { + all: true, + targets: ['test'], + projects: [], + exclude: ['tag:ui'], + }, + projectGraph + ).map(({ name }) => name); + expect(projects).toContain('proj1'); + expect(projects).not.toContain('proj2'); + }); + + it('should exclude projects with a tag pattern', () => { + const projects = projectsToRun( + { + all: true, + targets: ['test'], + projects: [], + exclude: ['tag:theme*'], + }, + projectGraph + ).map(({ name }) => name); + expect(projects).not.toContain('proj1'); + expect(projects).not.toContain('proj2'); + }); + describe('perf testing', () => { beforeEach(() => { for (let i = 0; i < 1000000; i++) { diff --git a/packages/nx/src/command-line/run-many.ts b/packages/nx/src/command-line/run-many.ts index c0a3379ff0ebd..62afb4c5916a0 100644 --- a/packages/nx/src/command-line/run-many.ts +++ b/packages/nx/src/command-line/run-many.ts @@ -83,11 +83,9 @@ export function projectsToRun( selectedProjects.set(projectName, projectGraph.nodes[projectName]); } } else { - const allProjectNames = Object.keys(projectGraph.nodes); const matchingProjects = findMatchingProjects( nxArgs.projects, - allProjectNames, - new Set(allProjectNames) + projectGraph.nodes ); for (const project of matchingProjects) { if (!validProjects.has(project)) { @@ -108,9 +106,8 @@ export function projectsToRun( } const excludedProjects = findMatchingProjects( - nxArgs.exclude ?? [], - Array.from(selectedProjects.keys()), - new Set(selectedProjects.keys()) + nxArgs.exclude, + selectedProjects ); for (const excludedProject of excludedProjects) { diff --git a/packages/nx/src/project-graph/build-dependencies/implicit-project-dependencies.ts b/packages/nx/src/project-graph/build-dependencies/implicit-project-dependencies.ts index 97e6409d725e5..371189c68f4e0 100644 --- a/packages/nx/src/project-graph/build-dependencies/implicit-project-dependencies.ts +++ b/packages/nx/src/project-graph/build-dependencies/implicit-project-dependencies.ts @@ -5,8 +5,8 @@ export function buildImplicitProjectDependencies( ctx: ProjectGraphProcessorContext, builder: ProjectGraphBuilder ) { - Object.keys(ctx.workspace.projects).forEach((source) => { - const p = ctx.workspace.projects[source]; + Object.keys(ctx.projectsConfigurations.projects).forEach((source) => { + const p = ctx.projectsConfigurations.projects[source]; if (p.implicitDependencies && p.implicitDependencies.length > 0) { p.implicitDependencies.forEach((target) => { if (target.startsWith('!')) { diff --git a/packages/nx/src/project-graph/build-dependencies/implict-project-dependencies.spec.ts b/packages/nx/src/project-graph/build-dependencies/implict-project-dependencies.spec.ts index a63f2a9aa8cfa..c93cb5166a85e 100644 --- a/packages/nx/src/project-graph/build-dependencies/implict-project-dependencies.spec.ts +++ b/packages/nx/src/project-graph/build-dependencies/implict-project-dependencies.spec.ts @@ -8,8 +8,6 @@ jest.mock('nx/src/utils/workspace-root', () => ({ })); describe('explicit project dependencies', () => { - let ctx: ProjectGraphProcessorContext; - it(`should add implicit deps`, () => { const builder = new ProjectGraphBuilder(); builder.addNode({ @@ -25,12 +23,13 @@ describe('explicit project dependencies', () => { { filesToProcess: {}, fileMap: {}, - workspace: { + projectsConfigurations: { + version: 2, projects: { - proj1: { implicitDependencies: ['proj2'] }, + proj1: { root: '', implicitDependencies: ['proj2'] }, }, }, - } as any, + } as Partial as ProjectGraphProcessorContext, builder ); @@ -59,12 +58,13 @@ describe('explicit project dependencies', () => { { filesToProcess: {}, fileMap: {}, - workspace: { + projectsConfigurations: { + version: 2, projects: { - proj1: { implicitDependencies: ['!proj2'] }, + proj1: { root: '', implicitDependencies: ['!proj2'] }, }, }, - } as any, + } as Partial as ProjectGraphProcessorContext, builder ); diff --git a/packages/nx/src/project-graph/build-nodes/workspace-projects.spec.ts b/packages/nx/src/project-graph/build-nodes/workspace-projects.spec.ts index a01c9f0003ff4..1e8a6d469b772 100644 --- a/packages/nx/src/project-graph/build-nodes/workspace-projects.spec.ts +++ b/packages/nx/src/project-graph/build-nodes/workspace-projects.spec.ts @@ -1,45 +1,83 @@ +import { ProjectGraphProjectNode } from 'nx/src/config/project-graph'; import { normalizeImplicitDependencies } from './workspace-projects'; describe('workspace-projects', () => { - let projectsSet: Set; - - beforeEach(() => { - projectsSet = new Set(['test-project', 'a', 'b', 'c']); - }); + let projectGraph: Record = { + 'test-project': { + name: 'test-project', + type: 'lib', + data: { + root: 'lib/test-project', + files: [], + tags: ['api', 'theme1'], + }, + }, + a: { + name: 'a', + type: 'lib', + data: { + root: 'lib/a', + files: [], + tags: ['api', 'theme2'], + }, + }, + b: { + name: 'b', + type: 'lib', + data: { + root: 'lib/b', + files: [], + tags: ['ui'], + }, + }, + c: { + name: 'c', + type: 'lib', + data: { + root: 'lib/c', + files: [], + tags: ['api'], + }, + }, + }; describe('normalizeImplicitDependencies', () => { it('should expand "*" implicit dependencies', () => { expect( - normalizeImplicitDependencies( - 'test-project', - ['*'], - Array.from(projectsSet), - projectsSet - ) + normalizeImplicitDependencies('test-project', ['*'], projectGraph) ).toEqual(['a', 'b', 'c']); }); it('should return [] for null implicit dependencies', () => { expect( - normalizeImplicitDependencies( - 'test-project', - null, - Array.from(projectsSet), - projectsSet - ) + normalizeImplicitDependencies('test-project', null, projectGraph) ).toEqual([]); }); it('should expand glob based implicit dependencies', () => { - projectsSet.add('b-1'); - projectsSet.add('b-2'); + const projectGraphMod: typeof projectGraph = { + ...projectGraph, + 'b-1': { + name: 'b-1', + type: 'lib', + data: { + root: 'lib/b-1', + files: [], + tags: [], + }, + }, + 'b-2': { + name: 'b-2', + type: 'lib', + data: { + root: 'lib/b-2', + files: [], + tags: [], + }, + }, + }; expect( - normalizeImplicitDependencies( - 'test-project', - ['b*'], - Array.from(projectsSet), - projectsSet - ) + normalizeImplicitDependencies('test-project', ['b*'], projectGraphMod) ).toEqual(['b', 'b-1', 'b-2']); }); }); diff --git a/packages/nx/src/project-graph/build-nodes/workspace-projects.ts b/packages/nx/src/project-graph/build-nodes/workspace-projects.ts index 8a9ebf219b780..20aea0afa65f5 100644 --- a/packages/nx/src/project-graph/build-nodes/workspace-projects.ts +++ b/packages/nx/src/project-graph/build-nodes/workspace-projects.ts @@ -5,7 +5,10 @@ import { loadNxPlugins, mergePluginTargetsWithNxTargets, } from '../../utils/nx-plugin'; -import { ProjectGraphProcessorContext } from '../../config/project-graph'; +import { + ProjectGraphProcessorContext, + ProjectGraphProjectNode, +} from '../../config/project-graph'; import { mergeNpmScriptsWithTargets } from '../../utils/project-graph-utils'; import { ProjectGraphBuilder } from '../project-graph-builder'; import { PackageJson } from '../../utils/package-json'; @@ -25,11 +28,24 @@ export async function buildWorkspaceProjectNodes( nxJson: NxJsonConfiguration ) { const toAdd = []; - const projects = Object.keys(ctx.workspace.projects); - const projectsSet = new Set(projects); + const projects = Object.keys(ctx.projectsConfigurations.projects); + + // Used for expanding implicit dependencies (e.g. `@proj/*` or `tag:foo`) + const partialProjectGraphNodes = projects.reduce((graph, project) => { + const projectConfiguration = ctx.projectsConfigurations.projects[project]; + graph[project] = { + name: project, + type: projectConfiguration.projectType === 'library' ? 'lib' : 'app', // missing fallback to `e2e` + data: { + ...projectConfiguration, + files: [], // missing files + }, + }; + return graph; + }, {} as Record); for (const key of projects) { - const p = ctx.workspace.projects[key]; + const p = ctx.projectsConfigurations.projects[key]; const projectRoot = join(workspaceRoot, p.root); if (existsSync(join(projectRoot, 'package.json'))) { @@ -59,14 +75,13 @@ export async function buildWorkspaceProjectNodes( p.implicitDependencies = normalizeImplicitDependencies( key, p.implicitDependencies, - projects, - projectsSet + partialProjectGraphNodes ); p.targets = mergePluginTargetsWithNxTargets( p.root, p.targets, - await loadNxPlugins(ctx.workspace.plugins) + await loadNxPlugins(ctx.nxJsonConfiguration.plugins) ); p.targets = normalizeProjectTargets(p, nxJson.targetDefaults, key); @@ -78,7 +93,7 @@ export async function buildWorkspaceProjectNodes( ? 'e2e' : 'app' : 'lib'; - const tags = ctx.workspace.projects?.[key]?.tags || []; + const tags = ctx.projectsConfigurations.projects?.[key]?.tags || []; toAdd.push({ name: key, @@ -157,17 +172,12 @@ function normalizeProjectTargets( export function normalizeImplicitDependencies( source: string, implicitDependencies: ProjectConfiguration['implicitDependencies'], - projectNames: string[], - projectsSet: Set + projects: Record ) { if (!implicitDependencies?.length) { return implicitDependencies ?? []; } - const matches = findMatchingProjects( - implicitDependencies, - projectNames, - projectsSet - ); + const matches = findMatchingProjects(implicitDependencies, projects); return ( matches .filter((x) => x !== source) diff --git a/packages/nx/src/utils/assert-workspace-validity.ts b/packages/nx/src/utils/assert-workspace-validity.ts index 1bfff266d0219..cb57461a015ea 100644 --- a/packages/nx/src/utils/assert-workspace-validity.ts +++ b/packages/nx/src/utils/assert-workspace-validity.ts @@ -2,13 +2,25 @@ import { ProjectsConfigurations } from '../config/workspace-json-project-json'; import { NxJsonConfiguration } from '../config/nx-json'; import { findMatchingProjects } from './find-matching-projects'; import { output } from './output'; +import { ProjectGraphProjectNode } from '../config/project-graph'; export function assertWorkspaceValidity( - projectsConfigurations, + projectsConfigurations: ProjectsConfigurations, nxJson: NxJsonConfiguration ) { const projectNames = Object.keys(projectsConfigurations.projects); - const projectNameSet = new Set(projectNames); + const projectGraphNodes = projectNames.reduce((graph, project) => { + const projectConfiguration = projectsConfigurations.projects[project]; + graph[project] = { + name: project, + type: projectConfiguration.projectType === 'library' ? 'lib' : 'app', // missing fallback to `e2e` + data: { + ...projectConfiguration, + files: [], // missing files + }, + }; + return graph; + }, {} as Record); const projects = { ...projectsConfigurations.projects, @@ -39,8 +51,7 @@ export function assertWorkspaceValidity( projectName, project.implicitDependencies, projects, - projectNames, - projectNameSet + projectGraphNodes ); return map; }, invalidImplicitDependencies); @@ -66,8 +77,7 @@ function detectAndSetInvalidProjectGlobValues( sourceName: string, desiredImplicitDeps: string[], projectConfigurations: ProjectsConfigurations['projects'], - projectNames: string[], - projectNameSet: Set + projects: Record ) { const invalidProjectsOrGlobs = desiredImplicitDeps.filter((implicit) => { const projectName = implicit.startsWith('!') @@ -76,7 +86,7 @@ function detectAndSetInvalidProjectGlobValues( return !( projectConfigurations[projectName] || - findMatchingProjects([implicit], projectNames, projectNameSet).length + findMatchingProjects([implicit], projects).length ); }); diff --git a/packages/nx/src/utils/command-line-utils.ts b/packages/nx/src/utils/command-line-utils.ts index 9d3a946bceb31..b7dce0df0374f 100644 --- a/packages/nx/src/utils/command-line-utils.ts +++ b/packages/nx/src/utils/command-line-utils.ts @@ -173,6 +173,10 @@ export function splitArgsIntoNxArgsAndOverrides( } } + if (typeof args.exclude === 'string') { + nxArgs.exclude = args.exclude.split(','); + } + if (!nxArgs.skipNxCache) { nxArgs.skipNxCache = process.env.NX_SKIP_NX_CACHE === 'true'; } diff --git a/packages/nx/src/utils/find-matching-projects.spec.ts b/packages/nx/src/utils/find-matching-projects.spec.ts index e9092bac9801d..ebfeb7af2c878 100644 --- a/packages/nx/src/utils/find-matching-projects.spec.ts +++ b/packages/nx/src/utils/find-matching-projects.spec.ts @@ -1,30 +1,127 @@ import { findMatchingProjects } from './find-matching-projects'; +import type { ProjectGraphProjectNode } from '../config/project-graph'; describe('findMatchingProjects', () => { - let projectsSet: Set; + let projectGraph: Record = { + 'test-project': { + name: 'test-project', + type: 'lib', + data: { + root: 'lib/test-project', + files: [], + tags: ['api', 'theme1'], + }, + }, + a: { + name: 'a', + type: 'lib', + data: { + root: 'lib/a', + files: [], + tags: ['api', 'theme2'], + }, + }, + b: { + name: 'b', + type: 'lib', + data: { + root: 'lib/b', + files: [], + tags: ['ui'], + }, + }, + c: { + name: 'c', + type: 'lib', + data: { + root: 'lib/c', + files: [], + tags: ['api'], + }, + }, + }; - beforeEach(() => { - projectsSet = new Set(['test-project', 'a', 'b', 'c']); + it('should expand "*"', () => { + expect(findMatchingProjects(['*'], projectGraph)).toEqual([ + 'test-project', + 'a', + 'b', + 'c', + ]); }); - it('should expand "*"', () => { - expect( - findMatchingProjects(['*'], Array.from(projectsSet), projectsSet) - ).toEqual(['test-project', 'a', 'b', 'c']); + it('should support negation "!"', () => { + expect(findMatchingProjects(['*', '!a'], projectGraph)).toEqual([ + 'test-project', + 'b', + 'c', + ]); + expect(findMatchingProjects(['!*', 'a'], projectGraph)).toEqual([]); }); it('should expand generic glob patterns', () => { - projectsSet.add('b-1'); - projectsSet.add('b-2'); + const projectGraphMod: typeof projectGraph = { + ...projectGraph, + 'b-1': { + name: 'b-1', + type: 'lib', + data: { + root: 'lib/b-1', + files: [], + tags: [], + }, + }, + 'b-2': { + name: 'b-2', + type: 'lib', + data: { + root: 'lib/b-2', + files: [], + tags: [], + }, + }, + }; - expect( - findMatchingProjects(['b*'], Array.from(projectsSet), projectsSet) - ).toEqual(['b', 'b-1', 'b-2']); + expect(findMatchingProjects(['b*'], projectGraphMod)).toEqual([ + 'b', + 'b-1', + 'b-2', + ]); }); it('should support projectNames', () => { + expect(findMatchingProjects(['a', 'b'], projectGraph)).toEqual(['a', 'b']); + }); + + it('should expand "*" for tags', () => { + expect(findMatchingProjects(['tag:*'], projectGraph)).toEqual([ + 'test-project', + 'a', + 'b', + 'c', + ]); + }); + + it('should support negation "!" for tags', () => { + expect(findMatchingProjects(['*', '!tag:api'], projectGraph)).toEqual([ + 'b', + ]); + }); + + it('should expand generic glob patterns for tags', () => { + expect(findMatchingProjects(['tag:theme*'], projectGraph)).toEqual([ + 'test-project', + 'a', + ]); + }); + + it('should support mixed projectNames and tags', () => { + expect(findMatchingProjects(['a', 'tag:ui'], projectGraph)).toEqual([ + 'a', + 'b', + ]); expect( - findMatchingProjects(['a', 'b'], Array.from(projectsSet), projectsSet) - ).toEqual(['a', 'b']); + findMatchingProjects(['tag:api', '!tag:theme2'], projectGraph) + ).toEqual(['test-project', 'c']); }); }); diff --git a/packages/nx/src/utils/find-matching-projects.ts b/packages/nx/src/utils/find-matching-projects.ts index 7162874e58e67..686762fb24aa4 100644 --- a/packages/nx/src/utils/find-matching-projects.ts +++ b/packages/nx/src/utils/find-matching-projects.ts @@ -1,46 +1,102 @@ import minimatch = require('minimatch'); +import type { ProjectGraphProjectNode } from '../config/project-graph'; const globCharacters = ['*', '|', '{', '}', '(', ')']; +const validPatternTypes = [ + 'name', // Pattern is based on the project's name + 'tag', // Pattern is based on the project's tags +] as const; +type ProjectPatternType = typeof validPatternTypes[number]; + +interface ProjectPattern { + // If true, the pattern is an exclude pattern + exclude: boolean; + // The type of pattern to match against + type: ProjectPatternType; + // The pattern to match against + value: string; +} + /** * Find matching project names given a list of potential project names or globs. * * @param patterns A list of project names or globs to match against. - * @param projectNames An array containing the list of project names. - * @param projectNamesSet A set containing the list of project names. + * @param projects A map of {@link ProjectGraphProjectNode} by project name. * @returns */ export function findMatchingProjects( - patterns: string[], - projectNames: string[], - projectNamesSet: Set + patterns: string[] = [], + projects: + | Record + | Map ): string[] { + const projectNames = keys(projects); + + const patternObjects: ProjectPattern[] = patterns.map((p) => + parseStringPattern(p, projects) + ); + const selectedProjects: Set = new Set(); const excludedProjects: Set = new Set(); - for (const nameOrGlob of patterns) { - if (projectNamesSet.has(nameOrGlob)) { - selectedProjects.add(nameOrGlob); + for (const pattern of patternObjects) { + // Handle wildcard with short-circuit, as its a common case with potentially + // large project sets and we can avoid the more expensive glob matching. + if (pattern.value === '*') { + for (const projectName of projectNames) { + if (pattern.exclude) { + excludedProjects.add(projectName); + } else { + selectedProjects.add(projectName); + } + } continue; } - if (!globCharacters.some((c) => nameOrGlob.includes(c))) { - continue; - } + if (pattern.type === 'tag') { + for (const projectName of projectNames) { + const tags = + getItemInMapOrRecord(projects, projectName).data.tags || []; - const exclude = nameOrGlob.startsWith('!'); - const pattern = exclude ? nameOrGlob.substring(1) : nameOrGlob; + if (tags.includes(pattern.value)) { + (pattern.exclude ? excludedProjects : selectedProjects).add( + projectName + ); + continue; + } - const matchedProjectNames = - pattern === '*' ? projectNames : minimatch.match(projectNames, pattern); + if (!globCharacters.some((c) => pattern.value.includes(c))) { + continue; + } - matchedProjectNames.forEach((matchedProjectName) => { - if (exclude) { - excludedProjects.add(matchedProjectName); - } else { - selectedProjects.add(matchedProjectName); + if (minimatch.match(tags, pattern.value).length) + (pattern.exclude ? excludedProjects : selectedProjects).add( + projectName + ); } - }); + continue; + } else if (pattern.type === 'name') { + if (hasKey(projects, pattern.value)) { + (pattern.exclude ? excludedProjects : selectedProjects).add( + pattern.value + ); + continue; + } + + if (!globCharacters.some((c) => pattern.value.includes(c))) { + continue; + } + + const matchedProjectNames = minimatch.match(projectNames, pattern.value); + for (const projectName of matchedProjectNames) { + if (pattern.exclude) { + excludedProjects.add(projectName); + } else { + selectedProjects.add(projectName); + } + } + } } for (const project of excludedProjects) { @@ -49,3 +105,60 @@ export function findMatchingProjects( return Array.from(selectedProjects); } + +function keys( + object: Record | Map +): string[] { + return object instanceof Map ? [...object.keys()] : Object.keys(object); +} + +function hasKey( + object: Record | Map, + key: string +) { + return object instanceof Map ? object.has(key) : key in object; +} + +function getItemInMapOrRecord( + object: Record | Map, + key: string +): T { + return object instanceof Map ? object.get(key) : object[key]; +} + +function parseStringPattern( + pattern: string, + projects: + | Map + | Record +): ProjectPattern { + let type: ProjectPatternType; + let value: string; + const isExclude = pattern.startsWith('!'); + + // Support for things like: `!{type}:value` + if (isExclude) { + pattern = pattern.substring(1); + } + + const indexOfFirstPotentialSeparator = pattern.indexOf(':'); + if (indexOfFirstPotentialSeparator === -1 || hasKey(projects, pattern)) { + type = 'name'; + value = pattern; + } else { + const potentialType = pattern.substring(0, indexOfFirstPotentialSeparator); + if (isValidPatternType(potentialType)) { + type = potentialType; + value = pattern.substring(indexOfFirstPotentialSeparator + 1); + } else { + type = 'name'; + value = pattern; + } + } + + return { type, value, exclude: isExclude }; +} + +function isValidPatternType(type: string): type is ProjectPatternType { + return validPatternTypes.includes(type as ProjectPatternType); +}