From ecdae503f244f2ecd2ca3f88be5f447470204c83 Mon Sep 17 00:00:00 2001 From: Drini Cami Date: Wed, 15 Jan 2025 20:49:05 -0500 Subject: [PATCH] Add new --matrix option to multiply commands --- bin/concurrently.ts | 10 +- src/command-parser/expand-matrices.spec.ts | 116 +++++++++++++++++++++ src/command-parser/expand-matrices.ts | 62 +++++++++++ src/concurrently.ts | 12 ++- src/index.ts | 6 ++ 5 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 src/command-parser/expand-matrices.spec.ts create mode 100644 src/command-parser/expand-matrices.ts diff --git a/bin/concurrently.ts b/bin/concurrently.ts index 08f65145..483258bc 100755 --- a/bin/concurrently.ts +++ b/bin/concurrently.ts @@ -93,6 +93,11 @@ const program = yargs(hideBin(process.argv)) type: 'boolean', default: defaults.timings, }, + matrix: { + describe: 'Run multiple commands in a matrix style.', + type: 'string', + array: true, + }, 'passthrough-arguments': { alias: 'P', describe: @@ -235,8 +240,8 @@ concurrently( killOthers: args.killOthers ? ['success', 'failure'] : args.killOthersOnFail - ? ['failure'] - : [], + ? ['failure'] + : [], killSignal: args.killSignal, maxProcesses: args.maxProcesses, raw: args.raw, @@ -253,6 +258,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..c7189c14 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; + /** + * TODO + */ + 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)); } @@ -197,7 +207,7 @@ export function concurrently( }, getSpawnOpts({ ipc: command.ipc, - stdio: hidden ? 'hidden' : command.raw ?? options.raw ? 'raw' : 'normal', + stdio: hidden ? 'hidden' : (command.raw ?? options.raw) ? 'raw' : 'normal', env: command.env, cwd: command.cwd || options.cwd, }), 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