diff --git a/docs/cli/shortcuts.md b/docs/cli/shortcuts.md
index e3e70ff6..1444b41a 100644
--- a/docs/cli/shortcuts.md
+++ b/docs/cli/shortcuts.md
@@ -67,6 +67,12 @@ $ concurrently 'yarn:lint:*(!fix)'
$ concurrently -n js,ts 'yarn run lint:js' 'yarn run lint:ts'
```
+Wildcard expansion replaces the script pattern and preserves surrounding commands and arguments. The full command runs once per matching script:
+
+```bash
+$ concurrently 'npm run lint:*(!fix) && echo done'
+```
+
> [!NOTE]
> If you use this syntax with double quotes (`"`), bash and other shells might fail
> parsing it. You'll need to escape the `!`, or use single quote (`'`) instead.
diff --git a/lib/command-parser/expand-wildcard-parser.spec.ts b/lib/command-parser/expand-wildcard-parser.spec.ts
new file mode 100644
index 00000000..954565e3
--- /dev/null
+++ b/lib/command-parser/expand-wildcard-parser.spec.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from 'vitest';
+
+import { ExpandWildcard } from './expand-wildcard.js';
+
+const createParser = (scripts: Record = { 'build:app': '', 'build:lib': '' }) =>
+ new ExpandWildcard(
+ () => ({}),
+ () => ({ scripts }),
+ );
+
+describe('parser-based wildcard expansion', () => {
+ it.each([
+ ['npm run build:*; echo done', 'npm run build:app; echo done'],
+ ['npm run build:* | cat', 'npm run build:app | cat'],
+ [' \tnpm run build:*', ' \tnpm run build:app'],
+ ["npm run build:* -- don't", "npm run build:app -- don't"],
+ ['npm run build:* ; Write-Output `"done`"', 'npm run build:app ; Write-Output `"done`"'],
+ ])('keeps the source around %s', (command, expected) => {
+ expect(createParser({ 'build:app': '' }).parse({ command, name: '' })).toEqual([
+ { name: 'app', command: expected },
+ ]);
+ });
+
+ it('expands only the first eligible runner', () => {
+ expect(
+ createParser().parse({ command: 'npm run build:* && npm run test:*', name: '' }),
+ ).toEqual([
+ { name: 'app', command: 'npm run build:app && npm run test:*' },
+ { name: 'lib', command: 'npm run build:lib && npm run test:*' },
+ ]);
+ });
+
+ it('quotes special characters in matched script names', () => {
+ const parser = createParser({
+ 'build:with space': '',
+ "build:it's": '',
+ 'build:$HOME': '',
+ "build:it's!": '',
+ });
+ expect(parser.parse({ command: 'npm run "build:*"', name: '' })).toEqual([
+ { name: 'with space', command: "npm run 'build:with space'" },
+ { name: "it's", command: "npm run 'build:it'\\''s'" },
+ { name: '$HOME', command: "npm run 'build:$HOME'" },
+ { name: "it's!", command: "npm run 'build:it'\\''s!'" },
+ ]);
+ });
+
+ it('rejects NUL in a matched script name', () => {
+ expect(() =>
+ createParser({ 'build:\0': '' }).parse({ command: 'npm run build:*', name: '' }),
+ ).toThrow(new TypeError('Arguments cannot contain NUL'));
+ });
+
+ it.each([
+ 'test:*-unit(!slow|integration)',
+ 'test:*(!slow|integration)-unit',
+ '"test:*-unit(!slow|integration)"',
+ ])('preserves a chain around omission pattern %s', (pattern) => {
+ const parser = createParser({
+ 'test:fast-unit': '',
+ 'test:slow-unit': '',
+ 'test:integration-unit': '',
+ });
+ expect(
+ parser.parse({ command: `cd app && npm run ${pattern} && echo done`, name: '' }),
+ ).toEqual([{ name: 'fast', command: 'cd app && npm run test:fast-unit && echo done' }]);
+ });
+
+ it.each(["'test:*-unit(!slow\\.case)'", 'test:*-unit(!slow\\.case)'])(
+ 'preserves regex escapes in %s',
+ (pattern) => {
+ const parser = createParser({ 'test:slow.case-unit': '', 'test:slowXcase-unit': '' });
+ expect(parser.parse({ command: `npm run ${pattern} && echo done`, name: '' })).toEqual([
+ { name: 'slowXcase', command: 'npm run test:slowXcase-unit && echo done' },
+ ]);
+ },
+ );
+
+ it.each([
+ 'build() { npm run build:*; }',
+ 'echo $(npm run build:*)',
+ 'echo `npm run build:*`',
+ '(npm run build:*)',
+ ])('does not traverse a nested invocation: %s', (command) => {
+ const input = { command, name: '' };
+ expect(createParser().parse(input)).toBe(input);
+ });
+});
diff --git a/lib/command-parser/expand-wildcard-preservation.spec.ts b/lib/command-parser/expand-wildcard-preservation.spec.ts
new file mode 100644
index 00000000..7d02af63
--- /dev/null
+++ b/lib/command-parser/expand-wildcard-preservation.spec.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest';
+
+import { ExpandWildcard } from './expand-wildcard.js';
+
+const createParser = (scripts: Record = { 'build:app': '' }) =>
+ new ExpandWildcard(
+ () => ({}),
+ () => ({ scripts }),
+ );
+
+describe('preserving commands around wildcard scripts', () => {
+ it.each([
+ ['npm run build:* && echo done', 'npm run build:app && echo done'],
+ ['cd app && npm run build:*', 'cd app && npm run build:app'],
+ ['npm run build:* -- --grep "a & b"', 'npm run build:app -- --grep "a & b"'],
+ ['cross-env NODE_ENV=test npm run build:*', 'cross-env NODE_ENV=test npm run build:app'],
+ ['npx pnpm run build:*', 'npx pnpm run build:app'],
+ ['npm run clean && npm run build:*', 'npm run clean && npm run build:app'],
+ ['npm run "build:*"', 'npm run build:app'],
+ ['npm run build:* && echo "unfinished', 'npm run build:app && echo "unfinished'],
+ ['npm run build:* && && echo done', 'npm run build:app && && echo done'],
+ ])('replaces just the script in %s', (command, expected) => {
+ expect(createParser().parse({ command, name: '' })).toEqual([
+ { command: expected, name: 'app' },
+ ]);
+ });
+
+ it('does not expand a runner inside one quoted argument', () => {
+ const input = { command: 'echo "npm run build:*"', name: '' };
+ expect(createParser().parse(input)).toBe(input);
+ });
+
+ it('preserves chained commands with the existing omission syntax', () => {
+ const parser = createParser({ 'lint:js': '', 'lint:fix:js': '' });
+ expect(parser.parse({ command: 'npm run lint:*(!fix) && echo done', name: '' })).toEqual([
+ { command: 'npm run lint:js && echo done', name: 'js' },
+ ]);
+ });
+
+ it('does not repair an unterminated script word', () => {
+ const input = { command: 'npm run "build:*', name: '' };
+ expect(createParser().parse(input)).toBe(input);
+ });
+});
diff --git a/lib/command-parser/expand-wildcard.ts b/lib/command-parser/expand-wildcard.ts
index 4aa37ecc..cdeecb08 100644
--- a/lib/command-parser/expand-wildcard.ts
+++ b/lib/command-parser/expand-wildcard.ts
@@ -1,5 +1,7 @@
import fs from 'node:fs';
+import { type Node, parse as parseShell, type Script, type Word } from 'unbash';
+
import { CommandInfo } from '../command.js';
import JSONC from '../jsonc.js';
import { escapeRegExp } from '../utils.js';
@@ -8,6 +10,90 @@ import { CommandParser } from './command-parser.js';
// Matches a negative filter surrounded by '(!' and ')'.
const OMISSION = /\(!([^)]+)\)/;
+const RUN_SUBCOMMANDS: Record = {
+ npm: 'run',
+ yarn: 'run',
+ pnpm: 'run',
+ bun: 'run',
+ node: '--run',
+ deno: 'task',
+};
+
+type WildcardCommand = {
+ command: string;
+ scriptGlob: string;
+ replace: (script: string) => string;
+};
+
+function findRunner(node: Node | Script): { command: string; glob: Word } | undefined {
+ switch (node.type) {
+ case 'Command': {
+ const words = [node.name, ...node.suffix];
+ for (let index = 0; index + 2 < words.length; index++) {
+ const name = words[index]?.value;
+ const subcommand = words[index + 1];
+ const glob = words[index + 2];
+ if (
+ name &&
+ glob &&
+ subcommand?.value === RUN_SUBCOMMANDS[name] &&
+ glob.value.includes('*')
+ ) {
+ return {
+ command: `${name} ${subcommand.value}`,
+ glob,
+ };
+ }
+ }
+ return undefined;
+ }
+ case 'Statement':
+ return findRunner(node.command);
+ case 'Script':
+ case 'Pipeline':
+ case 'AndOr':
+ for (const command of node.commands) {
+ const runner = findRunner(command);
+ if (runner) {
+ return runner;
+ }
+ }
+ return undefined;
+ default:
+ return undefined;
+ }
+}
+
+function quoteScript(script: string): string {
+ if (script.includes('\0')) {
+ throw new TypeError('Arguments cannot contain NUL');
+ }
+ return /^[\p{L}\p{N}_:./+-]+$/u.test(script)
+ ? script
+ : "'" + script.split("'").join("'\\''") + "'";
+}
+
+function parseCommand(commandLine: string): WildcardCommand | undefined {
+ const parsed = parseShell(commandLine);
+ const runner = findRunner(parsed);
+ if (!runner) {
+ return undefined;
+ }
+ const { command, glob } = runner;
+ if (parsed.errors?.some((error) => error.pos >= glob.pos && error.pos < glob.end)) {
+ return undefined;
+ }
+ // A suffix omission can sit outside the Bash word, e.g. test:*-unit(!slow).
+ const omission = OMISSION.exec(commandLine.slice(glob.end));
+ const end = omission?.index === 0 ? glob.end + omission[0].length : glob.end;
+ return {
+ command,
+ scriptGlob: glob.value + commandLine.slice(glob.end, end),
+ replace: (script) =>
+ commandLine.slice(0, glob.pos) + quoteScript(script) + commandLine.slice(end),
+ };
+}
+
/**
* Finds wildcards in 'npm/yarn/pnpm/bun run', 'node --run' and 'deno task'
* commands and replaces them with all matching scripts in the NodeJS and Deno
@@ -70,19 +156,12 @@ export class ExpandWildcard implements CommandParser {
}
parse(commandInfo: CommandInfo) {
- // We expect one of the following patterns:
- // - run