diff --git a/bin/concurrently.ts b/bin/concurrently.ts index 61e7b7c3..64b404f0 100755 --- a/bin/concurrently.ts +++ b/bin/concurrently.ts @@ -94,6 +94,17 @@ const program = yargs(hideBin(process.argv)) type: 'boolean', default: defaults.timings, }, + matrix: { + describe: + 'Run the commands multiple times, once for every combination of matrix variables. ' + + 'Each --matrix defines a new variable, with format "name:val1 val2 ...". ' + + 'You can reference the values in commands using the placeholder {M:name}.\n\n' + + 'E.g. concurrently --matrix "os:windows linux" --matrix "env:dev staging" "echo {M:os}-{M:env}" ' + + 'will run the command 4 times, once for each combination of os and env.', + alias: 'M', + type: 'string', + array: true, + }, 'passthrough-arguments': { alias: 'P', describe: @@ -264,6 +275,25 @@ concurrently( timestampFormat: args.timestampFormat, timings: args.timings, teardown: args.teardown, + matrix: Object.fromEntries( + args.matrix?.map((matrix) => { + if (!matrix.includes(':')) { + throw new SyntaxError( + `Invalid matrix format '${matrix}'. ` + + 'Matrix must be in the format "name:val1 val2 ...".', + ); + } + + const [name] = matrix.split(':', 1); + return [ + name, + matrix + .slice(name.length + 1) + .trim() + .split(/\s+/), + ]; + }) ?? [], + ), additionalArguments: args.passthroughArguments ? additionalArguments : undefined, }, ).result.then( diff --git a/src/command-parser/expand-matrix.spec.ts b/src/command-parser/expand-matrix.spec.ts new file mode 100644 index 00000000..32c24ae5 --- /dev/null +++ b/src/command-parser/expand-matrix.spec.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; + +import { CommandInfo } from '../command'; +import { combinations, ExpandMatrix } from './expand-matrix'; + +const createCommandInfo = (command: string): CommandInfo => ({ + command, + name: '', +}); + +describe('ExpandMatrix', () => { + it('should replace placeholders with matrix values', () => { + const matrix = { + X: ['a', 'b'], + Y: ['1', '2'], + }; + const expandMatrix = new ExpandMatrix(matrix); + const commandInfo = createCommandInfo('echo {M:X} and {M:Y}'); + + const result = expandMatrix.parse(commandInfo); + + expect(result).toEqual([ + { command: 'echo a and 1', name: '' }, + { command: 'echo a and 2', name: '' }, + { command: 'echo b and 1', name: '' }, + { command: 'echo b and 2', name: '' }, + ]); + }); + + it('should handle escaped placeholders', () => { + const matrix = { X: ['a', 'b'] }; + const expandMatrix = new ExpandMatrix(matrix); + const commandInfo = createCommandInfo('echo \\{M:X} and {M:X}'); + + const result = expandMatrix.parse(commandInfo); + + expect(result).toEqual([ + { command: 'echo {M:X} and a', name: '' }, + { command: 'echo {M:X} and b', name: '' }, + ]); + }); + + it('throws SyntaxError if matrix name is invalid', () => { + const matrix = { X: ['a'] }; + const expandMatrix = new ExpandMatrix(matrix); + const commandInfo = createCommandInfo('echo {M:INVALID}'); + + expect(() => expandMatrix.parse(commandInfo)).toThrowError( + "[concurrently] Matrix placeholder '{M:INVALID}' does not match any defined matrix.", + ); + }); +}); + +describe('combinations', () => { + it('should return all possible combinations of the given dimensions', () => { + const dimensions = { + X: ['a', 'b'], + Y: ['1', '2'], + }; + + const result = Array.from(combinations(dimensions)); + + expect(result).toEqual([ + { X: 'a', Y: '1' }, + { X: 'a', Y: '2' }, + { X: 'b', Y: '1' }, + { X: 'b', Y: '2' }, + ]); + }); + + it('should handle single dimension', () => { + const dimensions = { X: ['a', 'b'] }; + + const result = Array.from(combinations(dimensions)); + const expected = [{ X: 'a' }, { X: 'b' }] as Record[]; + + expect(result).toEqual(expected); + }); + + it('should handle empty dimensions', () => { + const dimensions: Record = {}; + + const result = Array.from(combinations(dimensions)); + + expect(result).toEqual([]); + }); + + it('should handle dimensions with empty arrays', () => { + const dimensions = { X: ['a', 'b'], Y: [] }; + + const result = Array.from(combinations(dimensions)); + + expect(result).toEqual([]); + }); + + it('should handle dimensions with multiple empty arrays', () => { + const dimensions = { X: [], Y: [] }; + + const result = Array.from(combinations(dimensions)); + + expect(result).toEqual([]); + }); + + it('should handle dimensions with all empty arrays', () => { + const dimensions = { X: [], Y: [], Z: [] }; + + const result = Array.from(combinations(dimensions)); + + expect(result).toEqual([]); + }); + + it('should handle uneven dimensions', () => { + const dimensions = { + A: ['x'], + B: ['1', '2', '3'], + C: ['foo', 'bar'], + }; + + const result = Array.from(combinations(dimensions)); + + expect(result).toEqual([ + { A: 'x', B: '1', C: 'foo' }, + { A: 'x', B: '1', C: 'bar' }, + { A: 'x', B: '2', C: 'foo' }, + { A: 'x', B: '2', C: 'bar' }, + { A: 'x', B: '3', C: 'foo' }, + { A: 'x', B: '3', C: 'bar' }, + ]); + }); +}); diff --git a/src/command-parser/expand-matrix.ts b/src/command-parser/expand-matrix.ts new file mode 100644 index 00000000..4e9c996f --- /dev/null +++ b/src/command-parser/expand-matrix.ts @@ -0,0 +1,108 @@ +import { quote } from 'shell-quote'; + +import { CommandInfo } from '../command'; +import { CommandParser } from './command-parser'; + +/** + * Replace placeholders with new commands for each binding in the matrix expansion. + */ +export class ExpandMatrix implements CommandParser { + /** + * The matrix as defined by a mapping of dimension names to their possible values. + */ + private readonly matrix: Record; + + /** + * All combinations of the matrix dimensions. + */ + private readonly bindings: Record[]; + + constructor(matrix: Record) { + this.matrix = matrix; + this.bindings = Array.from(combinations(matrix)); + } + + parse(commandInfo: CommandInfo) { + return this.bindings.map((binding) => this.replacePlaceholders(commandInfo, binding)); + } + + private replacePlaceholders( + commandInfo: CommandInfo, + binding: Record, + ): CommandInfo { + const command = commandInfo.command.replace( + /\\?\{M:([^}]+)\}/g, + (match, placeholderTarget) => { + // Don't replace the placeholder if it is escaped by a backslash. + if (match.startsWith('\\')) { + return match.slice(1); + } + + if (placeholderTarget && !(placeholderTarget in this.matrix)) { + throw new Error( + `[concurrently] Matrix placeholder '{M:${placeholderTarget}}' does not match any defined matrix.`, + ); + } + + // Replace dimension name with binding value + return quote([binding[placeholderTarget]]); + }, + ); + + return { ...commandInfo, command }; + } +} + +/** + * Returns all possible combinations of the given dimensions. + * + * @param dimensions An object where keys are dimension names and values are arrays of possible values. + * eg `{os: ['windows', 'linux'], env: ['dev', 'staging']}` + */ +export function* combinations( + dimensions: Record, +): Generator> { + const buildCurBinding = (): Record => { + return Object.fromEntries( + Object.entries(dimensions).map(([dimName, dimValues], i) => [ + dimName, + dimValues[curBindingIndices[i]], + ]), + ); + }; + + const totalDimensions = Object.keys(dimensions).length; + const curBindingIndices = Object.values(dimensions).map(() => 0); + const dimensionSizes = Object.values(dimensions).map((dimValues) => dimValues.length); + + // If any dimension is empty, there are no combinations. + if (totalDimensions === 0 || dimensionSizes.some((size) => size === 0)) { + return; + } + + let curDimension = 0; + while (curDimension >= 0) { + if (curDimension === totalDimensions - 1) { + yield buildCurBinding(); + + // Exhausted last dimension, backtrack + while ( + curDimension >= 0 && + curBindingIndices[curDimension] === dimensionSizes[curDimension] - 1 + ) { + curBindingIndices[curDimension] = 0; + curDimension--; + } + + // All dimensions exhausted, done + if (curDimension < 0) { + break; + } + + // Move to next value in current dimension + curBindingIndices[curDimension]++; + } else { + curDimension++; + } + } +} diff --git a/src/concurrently.ts b/src/concurrently.ts index 0bc4152a..bb2d0af4 100644 --- a/src/concurrently.ts +++ b/src/concurrently.ts @@ -14,6 +14,7 @@ import { } from './command'; import { CommandParser } from './command-parser/command-parser'; import { ExpandArguments } from './command-parser/expand-arguments'; +import { ExpandMatrix } from './command-parser/expand-matrix'; import { ExpandShortcut } from './command-parser/expand-shortcut'; import { ExpandWildcard } from './command-parser/expand-wildcard'; import { StripQuotes } from './command-parser/strip-quotes'; @@ -143,6 +144,13 @@ export type ConcurrentlyOptions = { */ kill: KillProcess; + /** + * Every command will be run multiple times, for all combinations of the given arrays. + * Each dimension is a mapping of a dimension name to its possible values. + * Eg. `{ X: ['a', 'b'], Y: ['1', '2'] }` will run the commands 4 times. + */ + matrix?: Record; + /** * List of additional arguments passed that will get replaced in each command. * If not defined, no argument replacing will happen. @@ -175,6 +183,10 @@ export function concurrently( new ExpandWildcard(), ]; + if (options.matrix && Object.keys(options.matrix).length > 0) { + commandParsers.push(new ExpandMatrix(options.matrix)); + } + if (options.additionalArguments) { commandParsers.push(new ExpandArguments(options.additionalArguments)); } diff --git a/src/index.ts b/src/index.ts index 4cd90d63..721247b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -119,6 +119,13 @@ export type ConcurrentlyOptions = Omit; }; export function concurrently( @@ -192,6 +199,7 @@ export function concurrently( new Teardown({ logger, spawn: options.spawn, commands: options.teardown || [] }), ], prefixColors: options.prefixColors || [], + matrix: options.matrix, additionalArguments: options.additionalArguments, }); }