template.js 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. import {ChildProcess} from 'node:child_process';
  2. import isPlainObject from 'is-plain-obj';
  3. import {isUint8Array, uint8ArrayToString} from '../utils/uint-array.js';
  4. // Check whether the template string syntax is being used
  5. export const isTemplateString = templates => Array.isArray(templates) && Array.isArray(templates.raw);
  6. // Convert execa`file ...commandArguments` to execa(file, commandArguments)
  7. export const parseTemplates = (templates, expressions) => {
  8. let tokens = [];
  9. for (const [index, template] of templates.entries()) {
  10. tokens = parseTemplate({
  11. templates,
  12. expressions,
  13. tokens,
  14. index,
  15. template,
  16. });
  17. }
  18. if (tokens.length === 0) {
  19. throw new TypeError('Template script must not be empty');
  20. }
  21. const [file, ...commandArguments] = tokens;
  22. return [file, commandArguments, {}];
  23. };
  24. const parseTemplate = ({templates, expressions, tokens, index, template}) => {
  25. if (template === undefined) {
  26. throw new TypeError(`Invalid backslash sequence: ${templates.raw[index]}`);
  27. }
  28. const {nextTokens, leadingWhitespaces, trailingWhitespaces} = splitByWhitespaces(template, templates.raw[index]);
  29. const newTokens = concatTokens(tokens, nextTokens, leadingWhitespaces);
  30. if (index === expressions.length) {
  31. return newTokens;
  32. }
  33. const expression = expressions[index];
  34. const expressionTokens = Array.isArray(expression)
  35. ? expression.map(expression => parseExpression(expression))
  36. : [parseExpression(expression)];
  37. return concatTokens(newTokens, expressionTokens, trailingWhitespaces);
  38. };
  39. // Like `string.split(/[ \t\r\n]+/)` except newlines and tabs are:
  40. // - ignored when input as a backslash sequence like: `echo foo\n bar`
  41. // - not ignored when input directly
  42. // The only way to distinguish those in JavaScript is to use a tagged template and compare:
  43. // - the first array argument, which does not escape backslash sequences
  44. // - its `raw` property, which escapes them
  45. const splitByWhitespaces = (template, rawTemplate) => {
  46. if (rawTemplate.length === 0) {
  47. return {nextTokens: [], leadingWhitespaces: false, trailingWhitespaces: false};
  48. }
  49. const nextTokens = [];
  50. let templateStart = 0;
  51. const leadingWhitespaces = DELIMITERS.has(rawTemplate[0]);
  52. for (
  53. let templateIndex = 0, rawIndex = 0;
  54. templateIndex < template.length;
  55. templateIndex += 1, rawIndex += 1
  56. ) {
  57. const rawCharacter = rawTemplate[rawIndex];
  58. if (DELIMITERS.has(rawCharacter)) {
  59. if (templateStart !== templateIndex) {
  60. nextTokens.push(template.slice(templateStart, templateIndex));
  61. }
  62. templateStart = templateIndex + 1;
  63. } else if (rawCharacter === '\\') {
  64. const nextRawCharacter = rawTemplate[rawIndex + 1];
  65. if (nextRawCharacter === '\n') {
  66. // Handles escaped newlines in templates
  67. templateIndex -= 1;
  68. rawIndex += 1;
  69. } else if (nextRawCharacter === 'u' && rawTemplate[rawIndex + 2] === '{') {
  70. rawIndex = rawTemplate.indexOf('}', rawIndex + 3);
  71. } else {
  72. rawIndex += ESCAPE_LENGTH[nextRawCharacter] ?? 1;
  73. }
  74. }
  75. }
  76. const trailingWhitespaces = templateStart === template.length;
  77. if (!trailingWhitespaces) {
  78. nextTokens.push(template.slice(templateStart));
  79. }
  80. return {nextTokens, leadingWhitespaces, trailingWhitespaces};
  81. };
  82. const DELIMITERS = new Set([' ', '\t', '\r', '\n']);
  83. // Number of characters in backslash escape sequences: \0 \xXX or \uXXXX
  84. // \cX is allowed in RegExps but not in strings
  85. // Octal sequences are not allowed in strict mode
  86. const ESCAPE_LENGTH = {x: 3, u: 5};
  87. const concatTokens = (tokens, nextTokens, isSeparated) => isSeparated
  88. || tokens.length === 0
  89. || nextTokens.length === 0
  90. ? [...tokens, ...nextTokens]
  91. : [
  92. ...tokens.slice(0, -1),
  93. `${tokens.at(-1)}${nextTokens[0]}`,
  94. ...nextTokens.slice(1),
  95. ];
  96. // Handle `${expression}` inside the template string syntax
  97. const parseExpression = expression => {
  98. const typeOfExpression = typeof expression;
  99. if (typeOfExpression === 'string') {
  100. return expression;
  101. }
  102. if (typeOfExpression === 'number') {
  103. return String(expression);
  104. }
  105. if (isPlainObject(expression) && ('stdout' in expression || 'isMaxBuffer' in expression)) {
  106. return getSubprocessResult(expression);
  107. }
  108. if (expression instanceof ChildProcess || Object.prototype.toString.call(expression) === '[object Promise]') {
  109. // eslint-disable-next-line no-template-curly-in-string
  110. throw new TypeError('Unexpected subprocess in template expression. Please use ${await subprocess} instead of ${subprocess}.');
  111. }
  112. throw new TypeError(`Unexpected "${typeOfExpression}" in template expression`);
  113. };
  114. const getSubprocessResult = ({stdout}) => {
  115. if (typeof stdout === 'string') {
  116. return stdout;
  117. }
  118. if (isUint8Array(stdout)) {
  119. return uint8ArrayToString(stdout);
  120. }
  121. if (stdout === undefined) {
  122. throw new TypeError('Missing result.stdout in template expression. This is probably due to the previous subprocess\' "stdout" option.');
  123. }
  124. throw new TypeError(`Unexpected "${typeof stdout}" stdout in template expression`);
  125. };