// What the criteria evaluators (criteria.ts, legacyCriteria.ts) and the // dialect check (criteriaDialect.ts) share: the token grammar, its errors, // and the lenient date reading this engine has always had. // A criteria this grammar can't parse: unbalanced parentheses, a missing // operand, text it can't tokenize. GSF fails to compile those too. export class CriteriaSyntaxError extends Error { override name = 'CriteriaSyntaxError'; } // A well-formed call to a function this engine doesn't have. It may well be // valid Groovy, so callers that refuse malformed criteria still fail open on // this one. export class UnsupportedCriteriaError extends Error { override name = 'UnsupportedCriteriaError'; } const TOKEN_RE = /\s*(&&|\|\||<=|>=|!=|<>|==|=|<|>|!|\(|\)|,|'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?|[A-Za-z_][A-Za-z0-9_.]*)/g; // One token of this grammar, and where it starts in the criteria. export interface CriteriaToken { text: string; offset: number; } // The criteria's tokens as this grammar reads them: a quoted string is one // token, and so is a name (NOTES is never NOT). Throws CriteriaSyntaxError // for text it can't tokenize. export function tokenizeCriteria(input: string): CriteriaToken[] { const tokens: CriteriaToken[] = []; let match: RegExpExecArray | null; let lastIndex = 0; TOKEN_RE.lastIndex = 0; while ((match = TOKEN_RE.exec(input))) { if (match.index !== lastIndex) { throw new CriteriaSyntaxError(`Unable to tokenize criteria near: ${input.slice(lastIndex)}`); } tokens.push({ text: match[1], offset: TOKEN_RE.lastIndex - match[1].length }); lastIndex = TOKEN_RE.lastIndex; } if (lastIndex !== input.length) { throw new CriteriaSyntaxError(`Unable to tokenize criteria near: ${input.slice(lastIndex)}`); } return tokens; } // Groovy's character escapes; any other escaped character stands for itself // (\\, \', \", \$). const ESCAPES: Record = { b: '\b', t: '\t', n: '\n', f: '\f', r: '\r' }; const HEX = 16; // The value of a quoted string token: its quotes off and its escapes decoded // as Groovy decodes them, so '\n' is a line break and '\u0041' is A. export function decodeStringLiteral(token: string): string { return token .slice(1, -1) .replace(/\\(?:u([0-9a-fA-F]{4})|([\s\S]))/g, (_, unicode: string | undefined, char: string) => unicode ? String.fromCharCode(parseInt(unicode, HEX)) : (ESCAPES[char] ?? char), ); } const MS_DIGITS = 3; // '.5' means 500ms, not 5ms — right-pad to full precision // Genesis date/datetime values serialize as 'YYYYMMDD-HH:MM[:SS[.mmm]]'. Row // values may already be epoch millis (number) or an ISO string; anything // else Date.parse reads is taken too. Returns null if unparseable. (A // REQUEST's DATE fields are read with it, and fidelity: 'legacy' criteria.) export function parseGenesisDateTime(value: unknown): number | null { if (typeof value === 'number') return value; if (typeof value !== 'string') return null; const match = value.match(/^(\d{4})(\d{2})(\d{2})-(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/); if (match) { const [, y, mo, d, h, mi, s, ms] = match; return Date.UTC( Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s ?? '0'), Number((ms ?? '0').padEnd(MS_DIGITS, '0')), ); } const parsed = Date.parse(value); return Number.isNaN(parsed) ? null : parsed; }