UNPKG

execa

Version:
173 lines (141 loc) • 6.07 kB
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'; // 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 = []; for (const [index, template] of templates.entries()) { tokens = parseTemplate({ templates, expressions, tokens, 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, 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, leadingWhitespaces); if (index === expressions.length) { return newTokens; } const expression = expressions[index]; const expressionTokens = Array.isArray(expression) ? expression.map(expression => parseExpression(expression)) : [parseExpression(expression)]; 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) => { // 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 = typeof 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 if (isPlainObject(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 "${typeof stdout}" stdout in template expression`); };