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