diff --git a/bin/concurrently.ts b/bin/concurrently.ts index 08f65145..c79034c4 100755 --- a/bin/concurrently.ts +++ b/bin/concurrently.ts @@ -93,6 +93,13 @@ const program = yargs(hideBin(process.argv)) type: 'boolean', default: defaults.timings, }, + matrix: { + describe: + 'Run many commands as a matrix using space-separated parameters. ' + + 'E.g. concurrently --matrix "a b c" --matrix "1 2 3" "echo {1}{2}"', + type: 'string', + array: true, + }, 'passthrough-arguments': { alias: 'P', describe: @@ -253,6 +260,7 @@ concurrently( timestampFormat: args.timestampFormat, timings: args.timings, teardown: args.teardown, + matrices: args.matrix?.map((matrix) => matrix.split(' ')), additionalArguments: args.passthroughArguments ? additionalArguments : undefined, }, ).result.then( diff --git a/src/command-parser/expand-matrices.spec.ts b/src/command-parser/expand-matrices.spec.ts new file mode 100644 index 00000000..9202ae1a --- /dev/null +++ b/src/command-parser/expand-matrices.spec.ts @@ -0,0 +1,116 @@ +import { CommandInfo } from '../command'; +import { combinations, ExpandMatrices } from './expand-matrices'; + +const createCommandInfo = (command: string): CommandInfo => ({ + command, + name: '', +}); + +describe('ExpandMatrices', () => { + it('should replace placeholders with matrix values', () => { + const matrices = [ + ['a', 'b'], + ['1', '2'], + ]; + const expandMatrices = new ExpandMatrices(matrices); + const commandInfo = createCommandInfo('echo {1} and {2}'); + + const result = expandMatrices.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 matrices = [['a', 'b']]; + const expandMatrices = new ExpandMatrices(matrices); + const commandInfo = createCommandInfo('echo \\{1} and {1}'); + + const result = expandMatrices.parse(commandInfo); + + expect(result).toEqual([ + { command: 'echo {1} and a', name: '' }, + { command: 'echo {1} and b', name: '' }, + ]); + }); + + it('should replace placeholders with empty string if index is out of bounds', () => { + const matrices = [['a']]; + const expandMatrices = new ExpandMatrices(matrices); + const commandInfo = createCommandInfo('echo {2}'); + + const result = expandMatrices.parse(commandInfo); + + expect(result).toEqual([{ command: 'echo ', name: '' }]); + }); +}); + +describe('combinations', () => { + it('should return all possible combinations of the given dimensions', () => { + const dimensions = [ + ['a', 'b'], + ['1', '2'], + ]; + + const result = combinations(dimensions); + + expect(result).toEqual([ + ['a', '1'], + ['a', '2'], + ['b', '1'], + ['b', '2'], + ]); + }); + + it('should handle single dimension', () => { + const dimensions = [['a', 'b']]; + + const result = combinations(dimensions); + + expect(result).toEqual([['a'], ['b']]); + }); + + it('should handle empty dimensions', () => { + const dimensions: string[][] = []; + + const result = combinations(dimensions); + + expect(result).toEqual([[]]); + }); + + it('should handle dimensions with empty arrays', () => { + const dimensions = [['a', 'b'], []]; + + const result = combinations(dimensions); + + expect(result).toEqual([]); + }); + + it('should handle dimensions with multiple empty arrays', () => { + const dimensions = [[], []]; + + const result = combinations(dimensions); + + expect(result).toEqual([]); + }); + + it('should handle dimensions with some empty arrays', () => { + const dimensions = [['a', 'b'], [], ['x', 'y']]; + + const result = combinations(dimensions); + + expect(result).toEqual([]); + }); + + it('should handle dimensions with all empty arrays', () => { + const dimensions = [[], [], []]; + + const result = combinations(dimensions); + + expect(result).toEqual([]); + }); +}); diff --git a/src/command-parser/expand-matrices.ts b/src/command-parser/expand-matrices.ts new file mode 100644 index 00000000..36714df4 --- /dev/null +++ b/src/command-parser/expand-matrices.ts @@ -0,0 +1,62 @@ +import { quote } from 'shell-quote'; + +import { CommandInfo } from '../command'; +import { CommandParser } from './command-parser'; + +/** + * Replace placeholders with new commands for each combination of matrices. + */ +export class ExpandMatrices implements CommandParser { + private _bindings: string[][]; + + constructor(private readonly matrices: readonly string[][]) { + this.matrices = matrices; + this._bindings = combinations(matrices); + } + + parse(commandInfo: CommandInfo) { + return this._bindings.map((binding) => this.replacePlaceholders(commandInfo, binding)); + } + + private replacePlaceholders(commandInfo: CommandInfo, binding: string[]): CommandInfo { + const command = commandInfo.command.replace( + /\\?\{([0-9]*)?\}/g, + (match, placeholderTarget) => { + // Don't replace the placeholder if it is escaped by a backslash. + if (match.startsWith('\\')) { + return match.slice(1); + } + + let index = 0; + if (placeholderTarget && !isNaN(placeholderTarget)) { + index = parseInt(placeholderTarget, 10) - 1; + } + + // Replace numeric placeholder if value exists in additional arguments. + if (index < binding.length) { + return quote([binding[index]]); + } + + // Replace placeholder with empty string + // if value doesn't exist in additional arguments. + return ''; + }, + ); + + return { ...commandInfo, command }; + } +} + +/** + * Returns all possible combinations of the given dimensions. + */ +export function combinations(dimensions: readonly string[][]): string[][] { + return dimensions.reduce( + (acc, dimension) => { + return acc.flatMap((accItem) => + dimension.map((dimensionItem) => accItem.concat(dimensionItem)), + ); + }, + [[]] as string[][], + ); +} diff --git a/src/concurrently.ts b/src/concurrently.ts index 30a9fbfe..346bd377 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 { ExpandMatrices } from './command-parser/expand-matrices'; import { ExpandShortcut } from './command-parser/expand-shortcut'; import { ExpandWildcard } from './command-parser/expand-wildcard'; import { StripQuotes } from './command-parser/strip-quotes'; @@ -147,6 +148,11 @@ export type ConcurrentlyOptions = { */ killSignal?: string; + /** + * Specify variables which will spawn multiple commands. + */ + matrices?: readonly string[][]; + /** * List of additional arguments passed that will get replaced in each command. * If not defined, no argument replacing will happen. @@ -179,6 +185,10 @@ export function concurrently( new ExpandWildcard(), ]; + if (options.matrices?.length) { + commandParsers.push(new ExpandMatrices(options.matrices)); + } + if (options.additionalArguments) { commandParsers.push(new ExpandArguments(options.additionalArguments)); } diff --git a/src/index.ts b/src/index.ts index 22f03b13..835024b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -103,6 +103,11 @@ export type ConcurrentlyOptions = Omit