Skip to content

Commit

Permalink
Allow newlines in $ (#843)
Browse files Browse the repository at this point in the history
  • Loading branch information
ehmicky committed Feb 25, 2024
1 parent bd547e4 commit 1315486
Show file tree
Hide file tree
Showing 4 changed files with 405 additions and 237 deletions.
24 changes: 24 additions & 0 deletions docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,30 @@ await $`echo example`;
await $`echo example`;
```

### Multiline commands

```sh
# Bash
npm run build \
--example-flag-one \
--example-flag-two
```

```js
// zx
await $`npm run build ${[
'--example-flag-one',
'--example-flag-two',
]}`;
```

```js
// Execa
await $`npm run build
--example-flag-one
--example-flag-two`
```

### Subcommands

```sh
Expand Down
79 changes: 65 additions & 14 deletions lib/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,17 +73,20 @@ const parseTemplates = (templates, expressions) => {
tokens = parseTemplate({templates, expressions, tokens, index, template});
}

if (tokens.length === 0) {
throw new TypeError('Template script must not be empty');
}

return tokens;
};

const parseTemplate = ({templates, expressions, tokens, index, template}) => {
const templateString = template ?? templates.raw[index];
const templateTokens = templateString.split(SPACES_REGEXP).filter(Boolean);
const newTokens = concatTokens(
tokens,
templateTokens,
templateString.startsWith(' '),
);
if (template === undefined) {
throw new TypeError(`Invalid backslash sequence: ${templates.raw[index]}`);
}

const {nextTokens, leadingWhitespaces, trailingWhitespaces} = splitByWhitespaces(template, templates.raw[index]);
const newTokens = concatTokens(tokens, nextTokens, leadingWhitespaces);

if (index === expressions.length) {
return newTokens;
Expand All @@ -93,16 +96,64 @@ const parseTemplate = ({templates, expressions, tokens, index, template}) => {
const expressionTokens = Array.isArray(expression)
? expression.map(expression => parseExpression(expression))
: [parseExpression(expression)];
return concatTokens(
newTokens,
expressionTokens,
templateString.endsWith(' '),
);
return concatTokens(newTokens, expressionTokens, trailingWhitespaces);
};

// Like `string.split(/[ \t\r\n]+/)` except newlines and tabs are:
// - ignored when input as a backslash sequence like: `echo foo\n bar`
// - not ignored when input directly
// The only way to distinguish those in JavaScript is to use a tagged template and compare:
// - the first array argument, which does not escape backslash sequences
// - its `raw` property, which escapes them
const splitByWhitespaces = (template, rawTemplate) => {
if (rawTemplate.length === 0) {
return {nextTokens: [], leadingWhitespaces: false, trailingWhitespaces: false};
}

const nextTokens = [];
let templateStart = 0;
const leadingWhitespaces = DELIMITERS.has(rawTemplate[0]);

for (
let templateIndex = 0, rawIndex = 0;
templateIndex < template.length;
templateIndex += 1, rawIndex += 1
) {
const rawCharacter = rawTemplate[rawIndex];
if (DELIMITERS.has(rawCharacter)) {
if (templateStart !== templateIndex) {
nextTokens.push(template.slice(templateStart, templateIndex));
}

templateStart = templateIndex + 1;
} else if (rawCharacter === '\\') {
const nextRawCharacter = rawTemplate[rawIndex + 1];
if (nextRawCharacter === 'u' && rawTemplate[rawIndex + 2] === '{') {
rawIndex = rawTemplate.indexOf('}', rawIndex + 3);
} else {
rawIndex += ESCAPE_LENGTH[nextRawCharacter] ?? 1;
}
}
}

const trailingWhitespaces = templateStart === template.length;
if (!trailingWhitespaces) {
nextTokens.push(template.slice(templateStart));
}

return {nextTokens, leadingWhitespaces, trailingWhitespaces};
};

const SPACES_REGEXP = / +/g;
const DELIMITERS = new Set([' ', '\t', '\r', '\n']);

// Number of characters in backslash escape sequences: \0 \xXX or \uXXXX
// \cX is allowed in RegExps but not in strings
// Octal sequences are not allowed in strict mode
const ESCAPE_LENGTH = {x: 3, u: 5};

const concatTokens = (tokens, nextTokens, isNew) => isNew || tokens.length === 0 || nextTokens.length === 0
const concatTokens = (tokens, nextTokens, isSeparated) => isSeparated
|| tokens.length === 0
|| nextTokens.length === 0
? [...tokens, ...nextTokens]
: [
...tokens.slice(0, -1),
Expand Down
2 changes: 2 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,8 @@ This is the preferred method when executing multiple commands in a script file.

The `command` string can inject any `${value}` with the following types: string, number, [`childProcess`](#childprocess) or an array of those types. For example: `` $`echo one ${'two'} ${3} ${['four', 'five']}` ``. For `${childProcess}`, the process's `stdout` is used.

The `command` string can use [multiple lines and indentation](docs/scripts.md#multiline-commands).

For more information, please see [this section](#scripts-interface) and [this page](docs/scripts.md).

#### $(options)
Expand Down

0 comments on commit 1315486

Please sign in to comment.