execa
Version:
Process execution for humans
184 lines (152 loc) • 6.78 kB
JavaScript
import {ChildProcess} from 'node:child_process';
import isPlainObject from 'is-plain-obj';
import {ASTRAL_START} from '../arguments/escape.js';
import {isUint8Array, uint8ArrayToString} from '../utils/uint-array.js';
import {isExecaError} from '../return/final-error.js';
import {getTypeName} from '../utils/type-name.js';
// Check whether the template string syntax is being used
export const isTemplateString = templates => Array.isArray(templates) && Array.isArray(templates.raw);
// Convert execa`file ...commandArguments` to execa(file, commandArguments)
export const parseTemplates = (templates, expressions) => {
let tokens = [];
// Whether whitespaces separate the last token from the next one
let isSeparated = false;
for (const [index, template] of templates.entries()) {
({tokens, isSeparated} = parseTemplate({
templates,
expressions,
tokens,
isSeparated,
index,
template,
}));
}
if (tokens.length === 0) {
throw new TypeError('Template script must not be empty');
}
const [file, ...commandArguments] = tokens;
return [file, commandArguments, {}];
};
const parseTemplate = ({templates, expressions, tokens, isSeparated, index, template}) => {
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, isSeparated || leadingWhitespaces);
// When the template has no tokens, the whitespaces before it still separate the next token, e.g. after an empty array expression
const isTemplateSeparated = trailingWhitespaces || (nextTokens.length === 0 && isSeparated);
if (index === expressions.length) {
return {tokens: newTokens, isSeparated: isTemplateSeparated};
}
const expression = expressions[index];
const expressionTokens = Array.isArray(expression)
? expression.map(expression => parseExpression(expression))
: [parseExpression(expression)];
return {
tokens: concatTokens(newTokens, expressionTokens, isTemplateSeparated),
isSeparated: expressionTokens.length === 0 && isTemplateSeparated,
};
};
// 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) => {
// The template is empty either between two `${expression}`, or when it only contains escaped newlines.
// Neither holds any whitespace, so neither separates tokens.
if (template.length === 0) {
return {nextTokens: [], leadingWhitespaces: false, trailingWhitespaces: false};
}
const nextTokens = [];
let templateStart = 0;
let isLeadingWhitespaces = false;
for (
let templateIndex = 0, rawIndex = 0;
templateIndex < template.length;
templateIndex += 1, rawIndex += 1
) {
const rawCharacter = rawTemplate[rawIndex];
if (DELIMITERS.has(rawCharacter)) {
// Escaped newlines are ignored, so a whitespace after them is still a leading one
if (templateIndex === 0) {
isLeadingWhitespaces = true;
}
if (templateStart !== templateIndex) {
nextTokens.push(template.slice(templateStart, templateIndex));
}
templateStart = templateIndex + 1;
} else if (rawCharacter === '\\') {
const nextRawCharacter = rawTemplate[rawIndex + 1];
if (nextRawCharacter === '\n') {
// Handles escaped newlines in templates
templateIndex -= 1;
rawIndex += 1;
} else if (nextRawCharacter === 'u' && rawTemplate[rawIndex + 2] === '{') {
const closingBraceIndex = rawTemplate.indexOf('}', rawIndex + 3);
// A code point above the BMP is a surrogate pair, i.e. two characters, in the template string
if (isAstralEscape(rawTemplate.slice(rawIndex + 3, closingBraceIndex))) {
templateIndex += 1;
}
rawIndex = closingBraceIndex;
} else {
rawIndex += ESCAPE_LENGTH[nextRawCharacter] ?? 1;
}
}
}
const isTrailingWhitespaces = templateStart === template.length;
if (!isTrailingWhitespaces) {
nextTokens.push(template.slice(templateStart));
}
return {nextTokens, leadingWhitespaces: isLeadingWhitespaces, trailingWhitespaces: isTrailingWhitespaces};
};
const isAstralEscape = codePointHex => Number.parseInt(codePointHex, 16) > ASTRAL_START;
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, isSeparated) => isSeparated
|| tokens.length === 0
|| nextTokens.length === 0
? [...tokens, ...nextTokens]
: [
...tokens.slice(0, -1),
`${tokens.at(-1)}${nextTokens[0]}`,
...nextTokens.slice(1),
];
// Handle `${expression}` inside the template string syntax
const parseExpression = expression => {
const typeOfExpression = getTypeName(expression);
if (typeOfExpression === 'string') {
return expression;
}
if (typeOfExpression === 'number') {
return String(expression);
}
// Only own properties are checked: a polluted `Object.prototype.stdout` must not turn any plain object into a subprocess result.
// A failed subprocess result is an error instance instead.
if ((isPlainObject(expression) || isExecaError(expression)) && (Object.hasOwn(expression, 'stdout') || Object.hasOwn(expression, 'isMaxBuffer'))) {
return getSubprocessResult(expression);
}
if (expression instanceof ChildProcess || Object.prototype.toString.call(expression) === '[object Promise]') {
// eslint-disable-next-line no-template-curly-in-string
throw new TypeError('Unexpected subprocess in template expression. Please use ${await subprocess} instead of ${subprocess}.');
}
throw new TypeError(`Unexpected "${typeOfExpression}" in template expression`);
};
const getSubprocessResult = result => {
// Read the own property only: results with `stdout: 'ignore'` have no `stdout` property, which must not be read from a polluted prototype
const stdout = Object.hasOwn(result, 'stdout') ? result.stdout : undefined;
if (typeof stdout === 'string') {
return stdout;
}
if (isUint8Array(stdout)) {
return uint8ArrayToString(stdout);
}
if (stdout === undefined) {
throw new TypeError('Missing result.stdout in template expression. This is probably due to the previous subprocess\' "stdout" option.');
}
throw new TypeError(`Unexpected "${getTypeName(stdout)}" stdout in template expression`);
};