mirror of
https://github.com/serverless/serverless.git
synced 2025-12-08 19:46:03 +00:00
501 lines
16 KiB
JavaScript
501 lines
16 KiB
JavaScript
// Variables syntax parser, for a string input returns AST-like meta object
|
|
|
|
'use strict';
|
|
|
|
const ServerlessError = require('../../serverless-error');
|
|
|
|
const alphaChars = new Set('abcdefghijklmnopqrstuvwxyz');
|
|
const alphaNumericChars = new Set(
|
|
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-'
|
|
);
|
|
const isType = RegExp.prototype.test.bind(/^[a-z][a-zA-Z0-9]*$/);
|
|
|
|
let value;
|
|
let index;
|
|
let char;
|
|
|
|
let state;
|
|
let stringContext;
|
|
let variables;
|
|
let sources;
|
|
let contexts;
|
|
let currentSource;
|
|
|
|
let contextStart;
|
|
let contextVariables;
|
|
let escapeVarStart;
|
|
let variableStart;
|
|
let sourceStart;
|
|
let paramStart;
|
|
let addressStart;
|
|
|
|
const finalizeSource = () => {
|
|
if (!sources) sources = [];
|
|
sources.push(currentSource);
|
|
currentSource = null;
|
|
state = 'maybeNextSourceStart';
|
|
};
|
|
|
|
const finalizeVariable = () => {
|
|
finalizeSource();
|
|
if (!variables) variables = [];
|
|
variables.push({ start: variableStart - contextStart, end: index + 1 - contextStart, sources });
|
|
sources = null;
|
|
releaseContext();
|
|
};
|
|
|
|
const registerVarEscape = () => {
|
|
if (!variables) variables = [];
|
|
variables.push({
|
|
start: escapeVarStart - contextStart,
|
|
end: index - contextStart,
|
|
value: '\\'.repeat(Math.ceil((index - 1 - escapeVarStart - 1) / 2)) + value[index - 1],
|
|
});
|
|
escapeVarStart = null;
|
|
};
|
|
|
|
const registerSlashEscape = () => {
|
|
if (!variables) variables = [];
|
|
variables.push({
|
|
start: escapeVarStart - contextStart,
|
|
end: index - 1 - contextStart,
|
|
value: '\\'.repeat(Math.ceil((index - 1 - escapeVarStart - 1) / 2)),
|
|
});
|
|
escapeVarStart = null;
|
|
};
|
|
|
|
const registerContext = (newContextStart = contextStart) => {
|
|
if (!contexts) contexts = [];
|
|
if (!variables) variables = [];
|
|
if (newContextStart && !contextVariables) contextVariables = [];
|
|
contexts.push({
|
|
state,
|
|
variableStart,
|
|
contextStart,
|
|
variables,
|
|
contextVariables,
|
|
currentSource,
|
|
sources,
|
|
});
|
|
|
|
state = 'maybeVariableOpen';
|
|
variableStart = index;
|
|
sources = null;
|
|
if (newContextStart) {
|
|
contextStart = newContextStart;
|
|
variables = contextVariables;
|
|
}
|
|
};
|
|
|
|
const releaseContext = () => {
|
|
const context = contexts && contexts.pop();
|
|
if (!context) {
|
|
state = 'out';
|
|
return;
|
|
}
|
|
if (!contexts.length) contexts = null;
|
|
({ state, variableStart, variables, contextVariables, currentSource, sources } = context);
|
|
if (state === 'param') paramStart = contextStart;
|
|
if (state === 'address') addressStart = contextStart;
|
|
({ contextStart } = context);
|
|
};
|
|
|
|
const normalizeMetaVariables = (metaValue, metaVariables) => {
|
|
switch (metaVariables.length) {
|
|
case 0:
|
|
return null;
|
|
case 1: {
|
|
const [variable] = metaVariables;
|
|
if (!variable.start && variable.end === metaValue.length) {
|
|
delete variable.start;
|
|
delete variable.end;
|
|
}
|
|
}
|
|
// fallthrough
|
|
default:
|
|
return metaVariables;
|
|
}
|
|
};
|
|
|
|
const generateMeta = (metaValue, metaVariables) => {
|
|
const meta = { value: metaValue || null };
|
|
if (!metaVariables || !metaVariables.length) return meta;
|
|
meta.variables = normalizeMetaVariables(metaValue, metaVariables);
|
|
return meta;
|
|
};
|
|
|
|
const parseString = (start) => {
|
|
if (value[start] === "'") return value.slice(start, index).trim().slice(1, -1);
|
|
try {
|
|
return JSON.parse(value.slice(start, index));
|
|
} catch (error) {
|
|
throw new ServerlessError(
|
|
`Invalid string literal at index ${start} in "${value}": ${error.message}`,
|
|
'INVALID_VARIABLE_STRING_LITERAL'
|
|
);
|
|
}
|
|
};
|
|
|
|
const parseLiteralSource = (literal, shouldFallbackToInput = false) => {
|
|
switch (literal) {
|
|
case 'true':
|
|
return true;
|
|
case 'false':
|
|
return false;
|
|
case 'null':
|
|
return null;
|
|
default: {
|
|
const numberValue = Number(literal);
|
|
if (!isNaN(literal)) return numberValue;
|
|
if (shouldFallbackToInput) return literal;
|
|
throw new ServerlessError(
|
|
`Invalid literal source "${literal}" at index ${sourceStart} in "${value}"`,
|
|
'INVALID_VARIABLE_LITERAL_SOURCE'
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
module.exports = (inputValue) => {
|
|
// State machine design pattern
|
|
try {
|
|
value = inputValue;
|
|
state = 'out';
|
|
contextStart = 0;
|
|
contextVariables = null;
|
|
|
|
for (index = 0; (char = value[index]); ++index) {
|
|
switch (state) {
|
|
case 'out':
|
|
if (char === '\\') {
|
|
if (escapeVarStart == null) escapeVarStart = index;
|
|
state = 'escapeVar';
|
|
} else if (char === '$') {
|
|
variableStart = index;
|
|
state = 'maybeVariableOpen';
|
|
} else {
|
|
escapeVarStart = null;
|
|
}
|
|
break;
|
|
case 'escapeVar':
|
|
if (char === '$') {
|
|
state = 'escapeVarDollar';
|
|
} else {
|
|
if (char !== '\\') escapeVarStart = null;
|
|
state = 'out';
|
|
}
|
|
break;
|
|
case 'escapeVarDollar':
|
|
if (char === '{') {
|
|
registerVarEscape();
|
|
state = 'out';
|
|
} else {
|
|
escapeVarStart = null;
|
|
if (char === '\\') {
|
|
escapeVarStart = index;
|
|
state = 'escapeVar';
|
|
} else if (char === '$') {
|
|
variableStart = index;
|
|
state = 'maybeVariableOpen';
|
|
} else {
|
|
state = 'out';
|
|
}
|
|
}
|
|
break;
|
|
case 'maybeVariableOpen':
|
|
if (char === '{') {
|
|
if (escapeVarStart != null) registerSlashEscape();
|
|
state = 'maybeTypeStart';
|
|
} else {
|
|
escapeVarStart = null;
|
|
if (char !== '$') releaseContext();
|
|
}
|
|
break;
|
|
case 'maybeTypeStart':
|
|
if (alphaChars.has(char)) {
|
|
sourceStart = index;
|
|
state = 'maybeType';
|
|
} else if (contexts && contexts.some((context) => context.state !== 'foreign')) {
|
|
throw new ServerlessError(
|
|
`Invalid variable type at index ${index} in "${value}"`,
|
|
'INVALID_VARIABLE_TYPE'
|
|
);
|
|
} else if (char === '}') {
|
|
releaseContext();
|
|
} else {
|
|
state = 'foreign';
|
|
if (char === '$') registerContext();
|
|
}
|
|
break;
|
|
case 'maybeNextSourceStart':
|
|
if (char === ' ') continue;
|
|
sourceStart = index;
|
|
if (alphaNumericChars.has(char)) {
|
|
state = 'typeOrSourceLiteral';
|
|
} else if (char === '"') {
|
|
stringContext = 'afterSourceLiteralString';
|
|
state = 'doubleQuotedString';
|
|
} else if (char === "'") {
|
|
stringContext = 'afterSourceLiteralString';
|
|
state = 'singleQuotedString';
|
|
} else {
|
|
throw new ServerlessError(
|
|
`Invalid variable source at index ${index} in "${value}"`,
|
|
'INVALID_VARIABLE_SOURCE'
|
|
);
|
|
}
|
|
break;
|
|
case 'maybeType':
|
|
if (char === ':') {
|
|
currentSource = { type: value.slice(sourceStart, index) };
|
|
state = 'maybeAddressStart';
|
|
} else if (char === '(') {
|
|
currentSource = { type: value.slice(sourceStart, index), params: [] };
|
|
state = 'paramStart';
|
|
} else if (!alphaNumericChars.has(char)) {
|
|
if (contexts && contexts.some((context) => context.state !== 'foreign')) {
|
|
throw new ServerlessError(
|
|
`Invalid variable type at index ${sourceStart} in "${value}"`,
|
|
'INVALID_VARIABLE_TYPE'
|
|
);
|
|
}
|
|
if (char === '}') {
|
|
releaseContext();
|
|
} else {
|
|
state = 'foreign';
|
|
if (char === '$') registerContext();
|
|
}
|
|
}
|
|
break;
|
|
case 'typeOrSourceLiteral':
|
|
if (char === ' ') continue;
|
|
if (char === ':') {
|
|
const type = value.slice(sourceStart, index);
|
|
if (!isType(type)) {
|
|
throw new ServerlessError(
|
|
`Invalid variable source at index ${sourceStart} in "${value}"`,
|
|
'INVALID_VARIABLE_SOURCE'
|
|
);
|
|
}
|
|
currentSource = { type };
|
|
state = 'maybeAddressStart';
|
|
} else if (char === '(') {
|
|
const type = value.slice(sourceStart, index);
|
|
if (!isType(type)) {
|
|
throw new ServerlessError(
|
|
`Invalid variable source at index ${sourceStart} in "${value}"`,
|
|
'INVALID_VARIABLE_SOURCE'
|
|
);
|
|
}
|
|
currentSource = { type, params: [] };
|
|
state = 'paramStart';
|
|
} else if (char === '}') {
|
|
currentSource = { value: parseLiteralSource(value.slice(sourceStart, index).trim()) };
|
|
finalizeVariable();
|
|
} else if (!alphaNumericChars.has(char)) {
|
|
throw new ServerlessError(
|
|
`Invalid variable source at index ${sourceStart} in "${value}"`,
|
|
'INVALID_VARIABLE_SOURCE'
|
|
);
|
|
}
|
|
break;
|
|
case 'paramStart':
|
|
if (char === ' ') continue;
|
|
if (char === ')') {
|
|
state = 'maybeAddressOpen';
|
|
} else if (char === ',') {
|
|
currentSource.params.push({ value: null });
|
|
} else if (char === '}') {
|
|
throw new ServerlessError(
|
|
`Invalid variable param at index ${index} in "${value}"`,
|
|
'INVALID_VARIABLE_PARAM'
|
|
);
|
|
} else {
|
|
paramStart = index;
|
|
if (char === '"') {
|
|
stringContext = 'afterParamString';
|
|
state = 'doubleQuotedString';
|
|
} else if (char === "'") {
|
|
stringContext = 'afterParamString';
|
|
state = 'singleQuotedString';
|
|
} else {
|
|
state = 'param';
|
|
contextVariables = null;
|
|
if (char === '$') registerContext(paramStart);
|
|
}
|
|
}
|
|
break;
|
|
case 'param':
|
|
if (char === ')') {
|
|
let paramValue = value.slice(paramStart, index).trim();
|
|
if (paramValue) {
|
|
if (!contextVariables) paramValue = parseLiteralSource(paramValue, true);
|
|
currentSource.params.push(generateMeta(paramValue, contextVariables));
|
|
}
|
|
state = 'maybeAddressOpen';
|
|
} else if (char === ',') {
|
|
let paramValue = value.slice(paramStart, index).trim();
|
|
if (!contextVariables) paramValue = parseLiteralSource(paramValue, true);
|
|
currentSource.params.push(generateMeta(paramValue, contextVariables));
|
|
state = 'paramStart';
|
|
} else if (char === '}') {
|
|
throw new ServerlessError(
|
|
`Invalid variable param at index ${paramStart} in "${value}"`,
|
|
'INVALID_VARIABLE_PARAM'
|
|
);
|
|
} else if (char === '$') {
|
|
registerContext(paramStart);
|
|
}
|
|
break;
|
|
case 'afterParamString':
|
|
if (char === ' ') continue;
|
|
if (char === ')') {
|
|
currentSource.params.push({ value: parseString(paramStart) });
|
|
state = 'maybeAddressOpen';
|
|
} else if (char === ',') {
|
|
currentSource.params.push({ value: parseString(paramStart) });
|
|
state = 'paramStart';
|
|
} else {
|
|
throw new ServerlessError(
|
|
`Invalid variable param at index ${paramStart} in "${value}"`,
|
|
'INVALID_VARIABLE_PARAM'
|
|
);
|
|
}
|
|
break;
|
|
case 'maybeAddressOpen':
|
|
if (char === ' ') continue;
|
|
if (char === ':') {
|
|
state = 'maybeAddressStart';
|
|
} else if (char === '}') {
|
|
finalizeVariable();
|
|
} else if (char === ',') {
|
|
finalizeSource();
|
|
} else {
|
|
throw new ServerlessError(
|
|
`Invalid variable address at index ${index} in "${value}"`,
|
|
'INVALID_VARIABLE_ADDRESS'
|
|
);
|
|
}
|
|
break;
|
|
case 'maybeAddressStart':
|
|
if (char === ' ') continue;
|
|
if (char === '}') {
|
|
finalizeVariable();
|
|
} else if (char === ',') {
|
|
finalizeSource();
|
|
} else {
|
|
addressStart = index;
|
|
if (char === ':') {
|
|
if (
|
|
!currentSource.params &&
|
|
(!contexts || contexts.every((context) => context.state === 'foreign'))
|
|
) {
|
|
currentSource = null;
|
|
state = 'foreign';
|
|
} else {
|
|
throw new ServerlessError(
|
|
`Invalid variable address at index ${addressStart} in "${value}"`,
|
|
'INVALID_VARIABLE_ADDRESS'
|
|
);
|
|
}
|
|
} else if (char === '"') {
|
|
stringContext = 'afterAddressString';
|
|
state = 'doubleQuotedString';
|
|
} else if (char === "'") {
|
|
stringContext = 'afterAddressString';
|
|
state = 'singleQuotedString';
|
|
} else {
|
|
state = 'address';
|
|
contextVariables = null;
|
|
if (char === '$') registerContext(addressStart);
|
|
}
|
|
}
|
|
break;
|
|
case 'address':
|
|
if (char === '}') {
|
|
currentSource.address = generateMeta(
|
|
value.slice(addressStart, index).trim(),
|
|
contextVariables
|
|
);
|
|
finalizeVariable();
|
|
} else if (char === '$') {
|
|
registerContext(addressStart);
|
|
} else if (char === ',') {
|
|
currentSource.address = generateMeta(
|
|
value.slice(addressStart, index).trim(),
|
|
contextVariables
|
|
);
|
|
finalizeSource();
|
|
}
|
|
break;
|
|
case 'afterAddressString':
|
|
if (char === ' ') continue;
|
|
if (char === '}') {
|
|
currentSource.address = { value: parseString(addressStart) };
|
|
finalizeVariable();
|
|
} else if (char === ',') {
|
|
currentSource.address = { value: parseString(addressStart) };
|
|
finalizeSource();
|
|
} else {
|
|
throw new ServerlessError(
|
|
`Invalid variable address at index ${addressStart} in "${value}"`,
|
|
'INVALID_VARIABLE_ADDRESS'
|
|
);
|
|
}
|
|
break;
|
|
case 'afterSourceLiteralString':
|
|
if (char === ' ') continue;
|
|
if (char === '}') {
|
|
currentSource = { value: parseString(sourceStart) };
|
|
finalizeVariable();
|
|
} else {
|
|
throw new ServerlessError(
|
|
`Invalid variable source at index ${sourceStart} in "${value}"`,
|
|
'INVALID_VARIABLE_SOURCE'
|
|
);
|
|
}
|
|
break;
|
|
case 'foreign':
|
|
if (char === '}') {
|
|
releaseContext();
|
|
} else if (char === '$') {
|
|
registerContext();
|
|
}
|
|
break;
|
|
case 'escapeString':
|
|
state = 'doubleQuotedString';
|
|
break;
|
|
case 'doubleQuotedString':
|
|
if (char === '"') {
|
|
state = stringContext;
|
|
} else if (char === '\\') {
|
|
state = 'escapeString';
|
|
}
|
|
break;
|
|
case 'singleQuotedString':
|
|
if (char === "'") state = stringContext;
|
|
break;
|
|
/* istanbul ignore next */
|
|
default:
|
|
throw new Error(`Unexpected state ${state}`);
|
|
}
|
|
}
|
|
|
|
if (currentSource || sources) {
|
|
throw new ServerlessError(
|
|
`Missing variable closing bracket in ${value}`,
|
|
'UNTERMINATED_VARIABLE'
|
|
);
|
|
}
|
|
|
|
if (!variables) return null;
|
|
return normalizeMetaVariables(value, variables);
|
|
} finally {
|
|
escapeVarStart = null;
|
|
variables = null;
|
|
sources = null;
|
|
contexts = null;
|
|
currentSource = null;
|
|
}
|
|
};
|