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;
}
};