From f656f5d4c06690f7441a2b8a224241218204d5b0 Mon Sep 17 00:00:00 2001 From: Drini Cami Date: Wed, 15 Jan 2025 20:49:05 -0500 Subject: [PATCH 1/5] Add new --matrix option to multiply commands --- bin/concurrently.ts | 8 ++ src/command-parser/expand-matrices.spec.ts | 116 +++++++++++++++++++++ src/command-parser/expand-matrices.ts | 62 +++++++++++ src/concurrently.ts | 10 ++ src/index.ts | 6 ++ 5 files changed, 202 insertions(+) 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..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 Date: Thu, 28 Aug 2025 15:49:04 +0200 Subject: [PATCH 2/5] Update test to use vitest --- src/command-parser/expand-matrices.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/command-parser/expand-matrices.spec.ts b/src/command-parser/expand-matrices.spec.ts index 9202ae1a..8e802af8 100644 --- a/src/command-parser/expand-matrices.spec.ts +++ b/src/command-parser/expand-matrices.spec.ts @@ -1,3 +1,5 @@ +import { describe, expect, it } from 'vitest'; + import { CommandInfo } from '../command'; import { combinations, ExpandMatrices } from './expand-matrices'; From 661431bf3feb8366a255974a29bf7b361d937e0f Mon Sep 17 00:00:00 2001 From: Drini Cami Date: Mon, 8 Sep 2025 02:48:17 +0200 Subject: [PATCH 3/5] Change --matrix option to require name - also require M: prefix in placeholder - avoids conflicting with pass-through args - avoids having two ways of specifying the matrix with or without ':' - makes the command easier to read --- bin/concurrently.ts | 22 ++++- src/command-parser/expand-matrices.spec.ts | 94 ++++++++++++---------- src/command-parser/expand-matrices.ts | 94 +++++++++++++++++----- src/concurrently.ts | 6 +- src/index.ts | 6 +- 5 files changed, 153 insertions(+), 69 deletions(-) diff --git a/bin/concurrently.ts b/bin/concurrently.ts index abc87fa8..4b93459d 100755 --- a/bin/concurrently.ts +++ b/bin/concurrently.ts @@ -96,8 +96,12 @@ const program = yargs(hideBin(process.argv)) }, 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}"', + '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, }, @@ -271,7 +275,19 @@ concurrently( timestampFormat: args.timestampFormat, timings: args.timings, teardown: args.teardown, - matrices: args.matrix?.map((matrix) => matrix.split(' ')), + matrices: 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, values] = matrix.split(':', 1); + return [name, values.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 index 8e802af8..0e33dfb6 100644 --- a/src/command-parser/expand-matrices.spec.ts +++ b/src/command-parser/expand-matrices.spec.ts @@ -10,12 +10,12 @@ const createCommandInfo = (command: string): CommandInfo => ({ describe('ExpandMatrices', () => { it('should replace placeholders with matrix values', () => { - const matrices = [ - ['a', 'b'], - ['1', '2'], - ]; + const matrices = { + X: ['a', 'b'], + Y: ['1', '2'], + }; const expandMatrices = new ExpandMatrices(matrices); - const commandInfo = createCommandInfo('echo {1} and {2}'); + const commandInfo = createCommandInfo('echo {M:X} and {M:Y}'); const result = expandMatrices.parse(commandInfo); @@ -28,91 +28,103 @@ describe('ExpandMatrices', () => { }); it('should handle escaped placeholders', () => { - const matrices = [['a', 'b']]; + const matrices = { X: ['a', 'b'] }; const expandMatrices = new ExpandMatrices(matrices); - const commandInfo = createCommandInfo('echo \\{1} and {1}'); + const commandInfo = createCommandInfo('echo \\{M:X} and {M:X}'); const result = expandMatrices.parse(commandInfo); expect(result).toEqual([ - { command: 'echo {1} and a', name: '' }, - { command: 'echo {1} and b', name: '' }, + { command: 'echo {M:X} and a', name: '' }, + { command: 'echo {M:X} and b', name: '' }, ]); }); - it('should replace placeholders with empty string if index is out of bounds', () => { - const matrices = [['a']]; + it('throws SyntaxError if matrix name is invalid', () => { + const matrices = { X: ['a'] }; const expandMatrices = new ExpandMatrices(matrices); - const commandInfo = createCommandInfo('echo {2}'); + const commandInfo = createCommandInfo('echo {M:INVALID}'); - const result = expandMatrices.parse(commandInfo); - - expect(result).toEqual([{ command: 'echo ', name: '' }]); + expect(() => expandMatrices.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 = [ - ['a', 'b'], - ['1', '2'], - ]; + const dimensions = { + X: ['a', 'b'], + Y: ['1', '2'], + }; - const result = combinations(dimensions); + const result = Array.from(combinations(dimensions)); expect(result).toEqual([ - ['a', '1'], - ['a', '2'], - ['b', '1'], - ['b', '2'], + { X: 'a', Y: '1' }, + { X: 'a', Y: '2' }, + { X: 'b', Y: '1' }, + { X: 'b', Y: '2' }, ]); }); it('should handle single dimension', () => { - const dimensions = [['a', 'b']]; + const dimensions = { X: ['a', 'b'] }; - const result = combinations(dimensions); + const result = Array.from(combinations(dimensions)); + const expected = [{ X: 'a' }, { X: 'b' }] as Record[]; - expect(result).toEqual([['a'], ['b']]); + expect(result).toEqual(expected); }); it('should handle empty dimensions', () => { - const dimensions: string[][] = []; + const dimensions: Record = {}; - const result = combinations(dimensions); + const result = Array.from(combinations(dimensions)); - expect(result).toEqual([[]]); + expect(result).toEqual([]); }); it('should handle dimensions with empty arrays', () => { - const dimensions = [['a', 'b'], []]; + const dimensions = { X: ['a', 'b'], Y: [] }; - const result = combinations(dimensions); + const result = Array.from(combinations(dimensions)); expect(result).toEqual([]); }); it('should handle dimensions with multiple empty arrays', () => { - const dimensions = [[], []]; + const dimensions = { X: [], Y: [] }; - const result = combinations(dimensions); + const result = Array.from(combinations(dimensions)); expect(result).toEqual([]); }); - it('should handle dimensions with some empty arrays', () => { - const dimensions = [['a', 'b'], [], ['x', 'y']]; + it('should handle dimensions with all empty arrays', () => { + const dimensions = { X: [], Y: [], Z: [] }; - const result = combinations(dimensions); + const result = Array.from(combinations(dimensions)); expect(result).toEqual([]); }); - it('should handle dimensions with all empty arrays', () => { - const dimensions = [[], [], []]; + it('should handle uneven dimensions', () => { + const dimensions = { + A: ['x'], + B: ['1', '2', '3'], + C: ['foo', 'bar'], + }; - const result = combinations(dimensions); + const result = Array.from(combinations(dimensions)); - expect(result).toEqual([]); + 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-matrices.ts b/src/command-parser/expand-matrices.ts index 36714df4..b6ac22bb 100644 --- a/src/command-parser/expand-matrices.ts +++ b/src/command-parser/expand-matrices.ts @@ -7,34 +7,46 @@ import { CommandParser } from './command-parser'; * Replace placeholders with new commands for each combination of matrices. */ export class ExpandMatrices implements CommandParser { - private _bindings: string[][]; + /** + * The dimensions of the matrix, as defined by a mapping of dimension names to their possible values. + */ + private readonly matrices: Record; - constructor(private readonly matrices: readonly string[][]) { + /** + * All combinations of the matrix dimensions. + */ + private readonly bindings: Record[]; + + constructor(matrices: Record) { this.matrices = matrices; - this._bindings = combinations(matrices); + this.bindings = Array.from(combinations(matrices)); } parse(commandInfo: CommandInfo) { - return this._bindings.map((binding) => this.replacePlaceholders(commandInfo, binding)); + return this.bindings.map((binding) => this.replacePlaceholders(commandInfo, binding)); } - private replacePlaceholders(commandInfo: CommandInfo, binding: string[]): CommandInfo { + private replacePlaceholders( + commandInfo: CommandInfo, + binding: Record, + ): CommandInfo { const command = commandInfo.command.replace( - /\\?\{([0-9]*)?\}/g, + /\\?\{M:([^}]+)\}/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; + if (placeholderTarget && !(placeholderTarget in this.matrices)) { + throw new Error( + `[concurrently] Matrix placeholder '{M:${placeholderTarget}}' does not match any defined matrix.`, + ); } - // Replace numeric placeholder if value exists in additional arguments. - if (index < binding.length) { - return quote([binding[index]]); + // Replace dimension name if value exists in additional arguments. + if (placeholderTarget in binding) { + return quote([binding[placeholderTarget]]); } // Replace placeholder with empty string @@ -49,14 +61,54 @@ export class ExpandMatrices implements CommandParser { /** * 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: readonly string[][]): string[][] { - return dimensions.reduce( - (acc, dimension) => { - return acc.flatMap((accItem) => - dimension.map((dimensionItem) => accItem.concat(dimensionItem)), - ); - }, - [[]] as string[][], - ); +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 e5471b19..9a641a6e 100644 --- a/src/concurrently.ts +++ b/src/concurrently.ts @@ -145,9 +145,11 @@ export type ConcurrentlyOptions = { kill: KillProcess; /** - * Specify variables which will spawn multiple commands. + * 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. */ - matrices?: readonly string[][]; + matrices?: Record; /** * List of additional arguments passed that will get replaced in each command. diff --git a/src/index.ts b/src/index.ts index 3d25e53e..097c3d4c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -121,9 +121,11 @@ export type ConcurrentlyOptions = Omit; }; export function concurrently( From 71a33490c9426e59d08469c0bb6b23e5a633df64 Mon Sep 17 00:00:00 2001 From: Drini Cami Date: Mon, 8 Sep 2025 02:57:16 +0200 Subject: [PATCH 4/5] Rename matrices option to matrix I confused the term, we're creating a single matrix, as defined by multiple dimensions/axes. --- bin/concurrently.ts | 2 +- ...matrices.spec.ts => expand-matrix.spec.ts} | 22 +++++++++---------- .../{expand-matrices.ts => expand-matrix.ts} | 16 +++++++------- src/concurrently.ts | 8 +++---- src/index.ts | 4 ++-- 5 files changed, 26 insertions(+), 26 deletions(-) rename src/command-parser/{expand-matrices.spec.ts => expand-matrix.spec.ts} (85%) rename src/command-parser/{expand-matrices.ts => expand-matrix.ts} (87%) diff --git a/bin/concurrently.ts b/bin/concurrently.ts index 4b93459d..f2954bd7 100755 --- a/bin/concurrently.ts +++ b/bin/concurrently.ts @@ -275,7 +275,7 @@ concurrently( timestampFormat: args.timestampFormat, timings: args.timings, teardown: args.teardown, - matrices: Object.fromEntries( + matrix: Object.fromEntries( args.matrix?.map((matrix) => { if (!matrix.includes(':')) { throw new SyntaxError( diff --git a/src/command-parser/expand-matrices.spec.ts b/src/command-parser/expand-matrix.spec.ts similarity index 85% rename from src/command-parser/expand-matrices.spec.ts rename to src/command-parser/expand-matrix.spec.ts index 0e33dfb6..32c24ae5 100644 --- a/src/command-parser/expand-matrices.spec.ts +++ b/src/command-parser/expand-matrix.spec.ts @@ -1,23 +1,23 @@ import { describe, expect, it } from 'vitest'; import { CommandInfo } from '../command'; -import { combinations, ExpandMatrices } from './expand-matrices'; +import { combinations, ExpandMatrix } from './expand-matrix'; const createCommandInfo = (command: string): CommandInfo => ({ command, name: '', }); -describe('ExpandMatrices', () => { +describe('ExpandMatrix', () => { it('should replace placeholders with matrix values', () => { - const matrices = { + const matrix = { X: ['a', 'b'], Y: ['1', '2'], }; - const expandMatrices = new ExpandMatrices(matrices); + const expandMatrix = new ExpandMatrix(matrix); const commandInfo = createCommandInfo('echo {M:X} and {M:Y}'); - const result = expandMatrices.parse(commandInfo); + const result = expandMatrix.parse(commandInfo); expect(result).toEqual([ { command: 'echo a and 1', name: '' }, @@ -28,11 +28,11 @@ describe('ExpandMatrices', () => { }); it('should handle escaped placeholders', () => { - const matrices = { X: ['a', 'b'] }; - const expandMatrices = new ExpandMatrices(matrices); + const matrix = { X: ['a', 'b'] }; + const expandMatrix = new ExpandMatrix(matrix); const commandInfo = createCommandInfo('echo \\{M:X} and {M:X}'); - const result = expandMatrices.parse(commandInfo); + const result = expandMatrix.parse(commandInfo); expect(result).toEqual([ { command: 'echo {M:X} and a', name: '' }, @@ -41,11 +41,11 @@ describe('ExpandMatrices', () => { }); it('throws SyntaxError if matrix name is invalid', () => { - const matrices = { X: ['a'] }; - const expandMatrices = new ExpandMatrices(matrices); + const matrix = { X: ['a'] }; + const expandMatrix = new ExpandMatrix(matrix); const commandInfo = createCommandInfo('echo {M:INVALID}'); - expect(() => expandMatrices.parse(commandInfo)).toThrowError( + expect(() => expandMatrix.parse(commandInfo)).toThrowError( "[concurrently] Matrix placeholder '{M:INVALID}' does not match any defined matrix.", ); }); diff --git a/src/command-parser/expand-matrices.ts b/src/command-parser/expand-matrix.ts similarity index 87% rename from src/command-parser/expand-matrices.ts rename to src/command-parser/expand-matrix.ts index b6ac22bb..99aee536 100644 --- a/src/command-parser/expand-matrices.ts +++ b/src/command-parser/expand-matrix.ts @@ -4,22 +4,22 @@ import { CommandInfo } from '../command'; import { CommandParser } from './command-parser'; /** - * Replace placeholders with new commands for each combination of matrices. + * Replace placeholders with new commands for each binding in the matrix expansion. */ -export class ExpandMatrices implements CommandParser { +export class ExpandMatrix implements CommandParser { /** - * The dimensions of the matrix, as defined by a mapping of dimension names to their possible values. + * The matrix as defined by a mapping of dimension names to their possible values. */ - private readonly matrices: Record; + private readonly matrix: Record; /** * All combinations of the matrix dimensions. */ private readonly bindings: Record[]; - constructor(matrices: Record) { - this.matrices = matrices; - this.bindings = Array.from(combinations(matrices)); + constructor(matrix: Record) { + this.matrix = matrix; + this.bindings = Array.from(combinations(matrix)); } parse(commandInfo: CommandInfo) { @@ -38,7 +38,7 @@ export class ExpandMatrices implements CommandParser { return match.slice(1); } - if (placeholderTarget && !(placeholderTarget in this.matrices)) { + if (placeholderTarget && !(placeholderTarget in this.matrix)) { throw new Error( `[concurrently] Matrix placeholder '{M:${placeholderTarget}}' does not match any defined matrix.`, ); diff --git a/src/concurrently.ts b/src/concurrently.ts index 9a641a6e..408ef31b 100644 --- a/src/concurrently.ts +++ b/src/concurrently.ts @@ -14,7 +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 { 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'; @@ -149,7 +149,7 @@ export type ConcurrentlyOptions = { * 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. */ - matrices?: Record; + matrix?: Record; /** * List of additional arguments passed that will get replaced in each command. @@ -183,8 +183,8 @@ export function concurrently( new ExpandWildcard(), ]; - if (options.matrices?.length) { - commandParsers.push(new ExpandMatrices(options.matrices)); + if (options.matrix?.length) { + commandParsers.push(new ExpandMatrix(options.matrix)); } if (options.additionalArguments) { diff --git a/src/index.ts b/src/index.ts index 097c3d4c..721247b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -125,7 +125,7 @@ export type ConcurrentlyOptions = Omit; + matrix?: Record; }; export function concurrently( @@ -199,7 +199,7 @@ export function concurrently( new Teardown({ logger, spawn: options.spawn, commands: options.teardown || [] }), ], prefixColors: options.prefixColors || [], - matrices: options.matrices, + matrix: options.matrix, additionalArguments: options.additionalArguments, }); } From cbed67a6eda3caa4243f8fd469f032315253a575 Mon Sep 17 00:00:00 2001 From: Drini Cami Date: Mon, 8 Sep 2025 03:37:22 +0200 Subject: [PATCH 5/5] Fix some --matrix edge cases --- bin/concurrently.ts | 10 ++++++++-- src/command-parser/expand-matrix.ts | 10 ++-------- src/concurrently.ts | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/bin/concurrently.ts b/bin/concurrently.ts index f2954bd7..64b404f0 100755 --- a/bin/concurrently.ts +++ b/bin/concurrently.ts @@ -284,8 +284,14 @@ concurrently( ); } - const [name, values] = matrix.split(':', 1); - return [name, values.split(' ')]; + const [name] = matrix.split(':', 1); + return [ + name, + matrix + .slice(name.length + 1) + .trim() + .split(/\s+/), + ]; }) ?? [], ), additionalArguments: args.passthroughArguments ? additionalArguments : undefined, diff --git a/src/command-parser/expand-matrix.ts b/src/command-parser/expand-matrix.ts index 99aee536..4e9c996f 100644 --- a/src/command-parser/expand-matrix.ts +++ b/src/command-parser/expand-matrix.ts @@ -44,14 +44,8 @@ export class ExpandMatrix implements CommandParser { ); } - // Replace dimension name if value exists in additional arguments. - if (placeholderTarget in binding) { - return quote([binding[placeholderTarget]]); - } - - // Replace placeholder with empty string - // if value doesn't exist in additional arguments. - return ''; + // Replace dimension name with binding value + return quote([binding[placeholderTarget]]); }, ); diff --git a/src/concurrently.ts b/src/concurrently.ts index 408ef31b..bb2d0af4 100644 --- a/src/concurrently.ts +++ b/src/concurrently.ts @@ -183,7 +183,7 @@ export function concurrently( new ExpandWildcard(), ]; - if (options.matrix?.length) { + if (options.matrix && Object.keys(options.matrix).length > 0) { commandParsers.push(new ExpandMatrix(options.matrix)); }