Skip to content

Commit

Permalink
Add parseCommandString() (#1054)
Browse files Browse the repository at this point in the history
  • Loading branch information
ehmicky committed May 14, 2024
1 parent 62d02af commit b9474c3
Show file tree
Hide file tree
Showing 11 changed files with 203 additions and 60 deletions.
11 changes: 3 additions & 8 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,17 +83,12 @@ This is the preferred method when executing Node.js files.

[More info.](node.md)

### execaCommand(command, options?)
### parseCommandString(command)

`command`: `string`\
`options`: [`Options`](#options)\
_Returns_: [`ResultPromise`](#return-value)

Executes a command. `command` is a string that includes both the `file` and its `arguments`.

This is only intended for very specific cases, such as a [REPL](https://en.wikipedia.org/wiki/Read%E2%80%93eval%E2%80%93print_loop). This should be avoided otherwise.
_Returns_: `string[]`

Just like `execa()`, this can [bind options](execution.md#globalshared-options). It can also be [run synchronously](#execasyncfile-arguments-options) using `execaCommandSync()`.
Split a `command` string into an array. For example, `'npm run build'` returns `['npm', 'run', 'build']` and `'argument otherArgument'` returns `['argument', 'otherArgument']`.

[More info.](escaping.md#user-defined-input)

Expand Down
2 changes: 1 addition & 1 deletion docs/debugging.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

[`error.escapedCommand`](api.md#resultescapedcommand) is the same, except control characters are escaped. This makes it safe to either print or copy and paste in a terminal, for debugging purposes.

Since the escaping is fairly basic, neither `error.command` nor `error.escapedCommand` should be executed directly, including using [`execa()`](api.md#execafile-arguments-options) or [`execaCommand()`](api.md#execacommandcommand-options).
Since the escaping is fairly basic, neither `error.command` nor `error.escapedCommand` should be executed directly, including using [`execa()`](api.md#execafile-arguments-options) or [`parseCommandString()`](api.md#parsecommandstringcommand).

```js
import {execa} from 'execa';
Expand Down
29 changes: 15 additions & 14 deletions docs/escaping.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,31 +29,32 @@ await execa`npm run ${'task with space'}`;
The above syntaxes allow the file and its arguments to be user-defined by passing a variable.

```js
const command = 'npm';
import {execa} from 'execa';

const file = 'npm';
const commandArguments = ['run', 'task with space'];
await execa`${file} ${commandArguments}`;

await execa(command, commandArguments);
await execa`${command} ${commandArguments}`;
await execa(file, commandArguments);
```

However, [`execaCommand()`](api.md#execacommandcommand-options) must be used instead if:
- _Both_ the file and its arguments are user-defined
- _And_ those are supplied as a single string

This is only intended for very specific cases, such as a [REPL](https://en.wikipedia.org/wiki/Read%E2%80%93eval%E2%80%93print_loop). This should be avoided otherwise.
If the file and/or multiple arguments are supplied as a single string, [`parseCommandString()`](api.md#parsecommandstringcommand) can split it into an array.

```js
import {execaCommand} from 'execa';
import {execa, parseCommandString} from 'execa';

const commandString = 'npm run task';
const commandArray = parseCommandString(commandString);
await execa`${commandArray}`;

for await (const commandAndArguments of getReplLine()) {
await execaCommand(commandAndArguments);
}
const [file, ...commandArguments] = commandArray;
await execa(file, commandArguments);
```

Arguments passed to `execaCommand()` are automatically escaped. They can contain any character (except [null bytes](https://en.wikipedia.org/wiki/Null_character)), but spaces must be escaped with a backslash.
Spaces are used as delimiters. They can be escaped with a backslash.

```js
await execaCommand('npm run task\\ with\\ space');
await execa`${parseCommandString('npm run task\\ with\\ space')}`;
```

## Shells
Expand Down
2 changes: 1 addition & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@ export {ExecaError, ExecaSyncError} from './types/return/final-error.js';
export type {TemplateExpression} from './types/methods/template.js';
export {execa} from './types/methods/main-async.js';
export {execaSync} from './types/methods/main-sync.js';
export {execaCommand, execaCommandSync} from './types/methods/command.js';
export {execaCommand, execaCommandSync, parseCommandString} from './types/methods/command.js';
export {$} from './types/methods/script.js';
export {execaNode} from './types/methods/node.js';
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {mapCommandAsync, mapCommandSync} from './lib/methods/command.js';
import {mapNode} from './lib/methods/node.js';
import {mapScriptAsync, setScriptSync, deepScriptOptions} from './lib/methods/script.js';

export {parseCommandString} from './lib/methods/command.js';
export {ExecaError, ExecaSyncError} from './lib/return/final-error.js';

export const execa = createExeca(() => ({}));
Expand Down
20 changes: 17 additions & 3 deletions lib/methods/command.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,23 @@ const parseCommand = (command, unusedArguments) => {
throw new TypeError(`The command and its arguments must be passed as a single string: ${command} ${unusedArguments}.`);
}

const [file, ...commandArguments] = parseCommandString(command);
return {file, commandArguments};
};

// Convert `command` string into an array of file or arguments to pass to $`${...fileOrCommandArguments}`
export const parseCommandString = command => {
if (typeof command !== 'string') {
throw new TypeError(`The command must be a string: ${String(command)}.`);
}

const trimmedCommand = command.trim();
if (trimmedCommand === '') {
return [];
}

const tokens = [];
for (const token of command.trim().split(SPACES_REGEXP)) {
for (const token of trimmedCommand.split(SPACES_REGEXP)) {
// Allow spaces to be escaped by a backslash if not meant as a delimiter
const previousToken = tokens.at(-1);
if (previousToken && previousToken.endsWith('\\')) {
Expand All @@ -22,8 +37,7 @@ const parseCommand = (command, unusedArguments) => {
}
}

const [file, ...commandArguments] = tokens;
return {file, commandArguments};
return tokens;
};

const SPACES_REGEXP = / +/g;
2 changes: 1 addition & 1 deletion lib/methods/main-async.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {addConvertedStreams} from '../convert/add.js';
import {createDeferred} from '../utils/deferred.js';
import {mergePromise} from './promise.js';

// Main shared logic for all async methods: `execa()`, `execaCommand()`, `$`, `execaNode()`
// Main shared logic for all async methods: `execa()`, `$`, `execaNode()`
export const execaCoreAsync = (rawFile, rawArguments, rawOptions, createNested) => {
const {file, commandArguments, command, escapedCommand, startTime, verboseInfo, options, fileDescriptors} = handleAsyncArguments(rawFile, rawArguments, rawOptions);
const {subprocess, promise} = spawnSubprocessAsync({
Expand Down
2 changes: 1 addition & 1 deletion lib/methods/main-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {logEarlyResult} from '../verbose/complete.js';
import {getAllSync} from '../resolve/all-sync.js';
import {getExitResultSync} from '../resolve/exit-sync.js';

// Main shared logic for all sync methods: `execaSync()`, `execaCommandSync()`, `$.sync()`
// Main shared logic for all sync methods: `execaSync()`, `$.sync()`
export const execaCoreSync = (rawFile, rawArguments, rawOptions) => {
const {file, commandArguments, command, escapedCommand, startTime, verboseInfo, options, fileDescriptors} = handleSyncArguments(rawFile, rawArguments, rawOptions);
const result = spawnSubprocessSync({
Expand Down
22 changes: 22 additions & 0 deletions test-d/methods/command.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import {expectType, expectError, expectAssignable} from 'tsd';
import {
execa,
execaSync,
$,
execaNode,
execaCommand,
execaCommandSync,
parseCommandString,
type Result,
type ResultPromise,
type SyncResult,
Expand All @@ -10,6 +15,23 @@ import {
const fileUrl = new URL('file:///test');
const stringArray = ['foo', 'bar'] as const;

expectError(parseCommandString());
expectError(parseCommandString(true));
expectError(parseCommandString(['unicorns', 'arg']));

expectType<string[]>(parseCommandString(''));
expectType<string[]>(parseCommandString('unicorns foo bar'));

expectType<Result<{}>>(await execa`${parseCommandString('unicorns foo bar')}`);
expectType<SyncResult<{}>>(execaSync`${parseCommandString('unicorns foo bar')}`);
expectType<Result<{}>>(await $`${parseCommandString('unicorns foo bar')}`);
expectType<SyncResult<{}>>($.sync`${parseCommandString('unicorns foo bar')}`);
expectType<Result<{}>>(await execaNode`${parseCommandString('foo bar')}`);

expectType<Result<{}>>(await execa`unicorns ${parseCommandString('foo bar')}`);
expectType<Result<{}>>(await execa('unicorns', parseCommandString('foo bar')));
expectType<Result<{}>>(await execa('unicorns', ['foo', ...parseCommandString('bar')]));

expectError(execaCommand());
expectError(execaCommand(true));
expectError(execaCommand(['unicorns', 'arg']));
Expand Down
152 changes: 121 additions & 31 deletions test/methods/command.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,102 @@
import {join} from 'node:path';
import test from 'ava';
import {execaCommand, execaCommandSync} from '../../index.js';
import {setFixtureDirectory, FIXTURES_DIRECTORY} from '../helpers/fixtures-directory.js';
import {
execa,
execaSync,
$,
execaNode,
execaCommand,
execaCommandSync,
parseCommandString,
} from '../../index.js';
import {
setFixtureDirectory,
FIXTURES_DIRECTORY,
FIXTURES_DIRECTORY_URL,
} from '../helpers/fixtures-directory.js';
import {QUOTE} from '../helpers/verbose.js';

setFixtureDirectory();
const STDIN_FIXTURE = join(FIXTURES_DIRECTORY, 'stdin.js');
const ECHO_FIXTURE_URL = new URL('echo.js', FIXTURES_DIRECTORY_URL);

const parseAndRunCommand = command => execa`${parseCommandString(command)}`;

test('execaCommand()', async t => {
const {stdout} = await execaCommand('echo.js foo bar');
t.is(stdout, 'foo\nbar');
});

test('parseCommandString() + execa()', async t => {
const {stdout} = await execa('echo.js', parseCommandString('foo bar'));
t.is(stdout, 'foo\nbar');
});

test('execaCommandSync()', t => {
const {stdout} = execaCommandSync('echo.js foo bar');
t.is(stdout, 'foo\nbar');
});

test('parseCommandString() + execaSync()', t => {
const {stdout} = execaSync('echo.js', parseCommandString('foo bar'));
t.is(stdout, 'foo\nbar');
});

test('execaCommand`...`', async t => {
const {stdout} = await execaCommand`${'echo.js foo bar'}`;
t.is(stdout, 'foo\nbar');
});

test('parseCommandString() + execa`...`', async t => {
const {stdout} = await execa`${parseCommandString('echo.js foo bar')}`;
t.is(stdout, 'foo\nbar');
});

test('parseCommandString() + execa`...`, only arguments', async t => {
const {stdout} = await execa`echo.js ${parseCommandString('foo bar')}`;
t.is(stdout, 'foo\nbar');
});

test('parseCommandString() + execa`...`, only some arguments', async t => {
const {stdout} = await execa`echo.js ${'foo bar'} ${parseCommandString('foo bar')}`;
t.is(stdout, 'foo bar\nfoo\nbar');
});

test('execaCommandSync`...`', t => {
const {stdout} = execaCommandSync`${'echo.js foo bar'}`;
t.is(stdout, 'foo\nbar');
});

test('parseCommandString() + execaSync`...`', t => {
const {stdout} = execaSync`${parseCommandString('echo.js foo bar')}`;
t.is(stdout, 'foo\nbar');
});

test('parseCommandString() + execaSync`...`, only arguments', t => {
const {stdout} = execaSync`echo.js ${parseCommandString('foo bar')}`;
t.is(stdout, 'foo\nbar');
});

test('parseCommandString() + execaSync`...`, only some arguments', t => {
const {stdout} = execaSync`echo.js ${'foo bar'} ${parseCommandString('foo bar')}`;
t.is(stdout, 'foo bar\nfoo\nbar');
});

test('parseCommandString() + $', async t => {
const {stdout} = await $`${parseCommandString('echo.js foo bar')}`;
t.is(stdout, 'foo\nbar');
});

test('parseCommandString() + $.sync', t => {
const {stdout} = $.sync`${parseCommandString('echo.js foo bar')}`;
t.is(stdout, 'foo\nbar');
});

test('parseCommandString() + execaNode', async t => {
const {stdout} = await execaNode(ECHO_FIXTURE_URL, parseCommandString('foo bar'));
t.is(stdout, 'foo\nbar');
});

test('execaCommand(options)`...`', async t => {
const {stdout} = await execaCommand({stripFinalNewline: false})`${'echo.js foo bar'}`;
t.is(stdout, 'foo\nbar\n');
Expand Down Expand Up @@ -67,43 +137,63 @@ test('execaCommandSync() bound options have lower priority', t => {
t.is(stdout, 'foo\nbar');
});

test('execaCommand() allows escaping spaces in commands', async t => {
const {stdout} = await execaCommand('command\\ with\\ space.js foo bar');
t.is(stdout, 'foo\nbar');
});

test('execaCommand() trims', async t => {
const {stdout} = await execaCommand(' echo.js foo bar ');
t.is(stdout, 'foo\nbar');
});

const testExecaCommandOutput = async (t, commandArguments, expectedOutput) => {
const {stdout} = await execaCommand(`echo.js ${commandArguments}`);
t.is(stdout, expectedOutput);
};

test('execaCommand() ignores consecutive spaces', testExecaCommandOutput, 'foo bar', 'foo\nbar');
test('execaCommand() escapes other whitespaces', testExecaCommandOutput, 'foo\tbar', 'foo\tbar');
test('execaCommand() allows escaping spaces', testExecaCommandOutput, 'foo\\ bar', 'foo bar');
test('execaCommand() allows escaping backslashes before spaces', testExecaCommandOutput, 'foo\\\\ bar', 'foo\\ bar');
test('execaCommand() allows escaping multiple backslashes before spaces', testExecaCommandOutput, 'foo\\\\\\\\ bar', 'foo\\\\\\ bar');
test('execaCommand() allows escaping backslashes not before spaces', testExecaCommandOutput, 'foo\\bar baz', 'foo\\bar\nbaz');

const testInvalidArgumentsArray = (t, execaMethod) => {
t.throws(() => {
execaMethod('echo', ['foo']);
}, {message: /The command and its arguments must be passed as a single string/});
t.throws(() => execaMethod('echo', ['foo']), {
message: /The command and its arguments must be passed as a single string/,
});
};

test('execaCommand() must not pass an array of arguments', testInvalidArgumentsArray, execaCommand);
test('execaCommandSync() must not pass an array of arguments', testInvalidArgumentsArray, execaCommandSync);

const testInvalidArgumentsTemplate = (t, execaMethod) => {
t.throws(() => {
// eslint-disable-next-line no-unused-expressions
execaMethod`echo foo`;
}, {message: /The command and its arguments must be passed as a single string/});
t.throws(() => execaMethod`echo foo`, {
message: /The command and its arguments must be passed as a single string/,
});
};

test('execaCommand() must not pass an array of arguments with a template string', testInvalidArgumentsTemplate, execaCommand);
test('execaCommandSync() must not pass an array of arguments with a template string', testInvalidArgumentsTemplate, execaCommandSync);

const testInvalidArgumentsParse = (t, command) => {
t.throws(() => parseCommandString(command), {
message: /The command must be a string/,
});
};

test('execaCommand() must not pass a number', testInvalidArgumentsParse, 0);
test('execaCommand() must not pass undefined', testInvalidArgumentsParse, undefined);
test('execaCommand() must not pass null', testInvalidArgumentsParse, null);
test('execaCommand() must not pass a symbol', testInvalidArgumentsParse, Symbol('test'));
test('execaCommand() must not pass an object', testInvalidArgumentsParse, {});
test('execaCommand() must not pass an array', testInvalidArgumentsParse, []);

const testExecaCommandOutput = async (t, command, expectedOutput, execaMethod) => {
const {stdout} = await execaMethod(command);
t.is(stdout, expectedOutput);
};

test('execaCommand() allows escaping spaces in commands', testExecaCommandOutput, 'command\\ with\\ space.js foo bar', 'foo\nbar', execaCommand);
test('execaCommand() trims', testExecaCommandOutput, ' echo.js foo bar ', 'foo\nbar', execaCommand);
test('execaCommand() ignores consecutive spaces', testExecaCommandOutput, 'echo.js foo bar', 'foo\nbar', execaCommand);
test('execaCommand() escapes other whitespaces', testExecaCommandOutput, 'echo.js foo\tbar', 'foo\tbar', execaCommand);
test('execaCommand() allows escaping spaces', testExecaCommandOutput, 'echo.js foo\\ bar', 'foo bar', execaCommand);
test('execaCommand() allows escaping backslashes before spaces', testExecaCommandOutput, 'echo.js foo\\\\ bar', 'foo\\ bar', execaCommand);
test('execaCommand() allows escaping multiple backslashes before spaces', testExecaCommandOutput, 'echo.js foo\\\\\\\\ bar', 'foo\\\\\\ bar', execaCommand);
test('execaCommand() allows escaping backslashes not before spaces', testExecaCommandOutput, 'echo.js foo\\bar baz', 'foo\\bar\nbaz', execaCommand);
test('parseCommandString() allows escaping spaces in commands', testExecaCommandOutput, 'command\\ with\\ space.js foo bar', 'foo\nbar', parseAndRunCommand);
test('parseCommandString() trims', testExecaCommandOutput, ' echo.js foo bar ', 'foo\nbar', parseAndRunCommand);
test('parseCommandString() ignores consecutive spaces', testExecaCommandOutput, 'echo.js foo bar', 'foo\nbar', parseAndRunCommand);
test('parseCommandString() escapes other whitespaces', testExecaCommandOutput, 'echo.js foo\tbar', 'foo\tbar', parseAndRunCommand);
test('parseCommandString() allows escaping spaces', testExecaCommandOutput, 'echo.js foo\\ bar', 'foo bar', parseAndRunCommand);
test('parseCommandString() allows escaping backslashes before spaces', testExecaCommandOutput, 'echo.js foo\\\\ bar', 'foo\\ bar', parseAndRunCommand);
test('parseCommandString() allows escaping multiple backslashes before spaces', testExecaCommandOutput, 'echo.js foo\\\\\\\\ bar', 'foo\\\\\\ bar', parseAndRunCommand);
test('parseCommandString() allows escaping backslashes not before spaces', testExecaCommandOutput, 'echo.js foo\\bar baz', 'foo\\bar\nbaz', parseAndRunCommand);

test('parseCommandString() can get empty strings', t => {
t.deepEqual(parseCommandString(''), []);
});

test('parseCommandString() can get only whitespaces', t => {
t.deepEqual(parseCommandString(' '), []);
});
Loading

0 comments on commit b9474c3

Please sign in to comment.