Add parseCommandString() method (original) (raw)
Background
Behavior
execaCommand() takes a command string, splits it into an array of arguments (using spaces as delimiters), then forwards that array to execa().
It also trims whitespaces, allows consecutive spaces, and lets users escape spaces with a backslash.
import {execa, execaCommand} from 'execa';
await execaCommand('file one two\ three'); // Is same as: await execa('file', ['one', 'two three']);
Origin
The reason this method was introduced was to discourage the shell option. Many users used that option only to be able to pass the command and its arguments as a single string, as opposed to an array. However, the shell option comes with many pitfalls, including a risk for command injection. execaCommand() provides the single string syntax, but without using a shell.
Recent changes
However, since then, we've introduced the template string syntax. Unless the command or arguments are user-defined, it is actually now better to use the template string syntax instead of execaCommand(). The template string syntax has more features and provides with a simpler way to escape spaces.
import {execa, execaCommand} from 'execa';
await execafile one ${'two three'};
await execaCommand('file one two\ three');
So execaCommand() is now partially redundant, although it still has some use cases (e.g. user-defined input). Taking this new situation into account, this issue is a proposal for evolving execaCommand() to a new purpose.
Proposal
Rename execaCommand() to splitCommand(). It performs the same command/arguments splitting than execaCommand() does. However, it does not execute the command. Instead, it just returns an array with the file and its arguments. The intention is to pass that array to the template string syntax of execa.
Before:
import {execaCommand} from 'execa';
await execaCommand('file one two\ three');
After:
import {execa, splitCommand} from 'execa';
await execa${splitCommand('file one two\\ three')};
For the above case, this results in more verbose syntax. However, this comes with the following pros.
Advantages
Orthogonality
Unlike execaCommand(), splitCommand() can be used with $, i.e. can be used in scripts.
await ‘</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6944em;"></span><span class="mord">‘</span></span></span></span>{splitCommand('file one two\\ three')};
Escaping
execaCommand() uses backslashes to escape spaces. It cannot use ${} to escape spaces like the template string syntax does. On the other hand, splitCommand() allows mixing both.
const variableWithSpaces = 'four five';
await execa${variableWithSpaces} ${splitCommand('one two\\ three')};
Subprocess interpolation
execaCommand() cannot re-use the result from another subprocess (while properly escaping it). splitCommand() does.
const result = await execa...;
await execa${result} ${splitCommand('one two\\ three')};
Partial interpolation
execaCommand() splits the file and all its arguments. splitCommand() can apply to only the arguments (not the file), or to only some of the arguments.
await execaecho four ${splitCommand('one two\\ three')};
Debugging
It is simpler to debug splitCommand(). Users will naturally just print the array being returned. There is less magic involved: users clearly understand what's happening in terms of parsing.
const commandArguments = splitCommand('one two\ three');
console.log(commandArguments); // ['one', 'two three']
await execaecho ${commandArguments};
Less confusion
Explaining the purpose of execaCommand(), as opposed to using the template string syntax, is not easy. I struggled a little when writing the new documentation.
I also saw some examples of execaCommand() being misused. For example, Storybook has a function which takes a command and an array of arguments as input. It joins it into a single string, then passes it to execaCommand(), as opposed to just pass the array of arguments directly to execa(). In other words they are doing something like:
await execaCommand([file, ...args].join(' '));
Instead of:
With the side effect that any argument with spaces is now accidentally split into several arguments.
On the other hand, splitCommand()'s purpose is straightforward.
What do you think @sindresorhus?