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