Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions bin/concurrently.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
130 changes: 130 additions & 0 deletions src/command-parser/expand-matrix.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>[];

expect(result).toEqual(expected);
});

it('should handle empty dimensions', () => {
const dimensions: Record<string, string[]> = {};

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' },
]);
});
});
108 changes: 108 additions & 0 deletions src/command-parser/expand-matrix.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]>;

/**
* All combinations of the matrix dimensions.
*/
private readonly bindings: Record<string, string>[];

constructor(matrix: Record<string, string[]>) {
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<string, string>,
): 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<string, string[]>,
): Generator<Record<string, string>> {
const buildCurBinding = (): Record<string, string> => {
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++;
}
}
}
12 changes: 12 additions & 0 deletions src/concurrently.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, string[]>;

/**
* List of additional arguments passed that will get replaced in each command.
* If not defined, no argument replacing will happen.
Expand Down Expand Up @@ -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));
}
Expand Down
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,13 @@ export type ConcurrentlyOptions = Omit<BaseConcurrentlyOptions, 'abortSignal' |
* If not defined, no argument replacing will happen.
*/
additionalArguments?: string[];

/**
* 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<string, string[]>;
};

export function concurrently(
Expand Down Expand Up @@ -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,
});
}
Expand Down