// What GSF answers a CRITERIA_MATCH with two operands side by side — most // often a double quote left unescaped inside a double-quoted value // (`NAME.equals("say "hi"")` is `"say "`, `hi`, `""`). Groovy reads operands // side by side as a command expression (`a b c d e` is `this.a(b).c(d).e`) // where one may start, and as a syntax error everywhere else, so GSF never // runs such a criteria. Which of the two, and the text, depend on where the // operands stand (Groovy 3.0.25 and genesis-db 8.15.29, captured by running // them; test/criteriaJuxtaposition.test.ts pins them): // - the only argument of a call, with no binary operator before the call: // a command expression, refused by the validator (groovyValidator.ts); // - the only argument of a call after a binary operator: a syntax error // naming the statement up to the method; // - a later argument of a call: a syntax error at the call's '('; // - after a binary operator outside a call: a syntax error naming the // statement up to the operand before them. // A type beside a name is no such pair: a cast `(int) X`, a declaration // `String x = ...` or a closure parameter `{ String s -> ... }` Groovy parses, // and groovyValidator.ts gives GSF's text for them first. Any other shape is // still refused, as GSF refuses it, but with an approximate text: GSF's may // be another syntax error (the text follows the whole statement, where this // one looks at the first pair only) or a validator error (`TRADER "x"` is the // call `this.TRADER(x)`). import { groovyCompileErrorText, groovyTokens, type Token } from './groovySyntax.ts'; // Names that are no operand: Groovy's keyword operators, `new`, and this // engine's AND / OR / NOT dialect (criteriaDialect.ts answers those), in any // case; and Groovy's other keywords, which start or shape a statement // (`return QTY > 1` is valid Groovy), as written. const NOT_OPERANDS = new Set(['in', 'instanceof', 'as', 'new', 'and', 'or', 'not']); const KEYWORDS = new Set( ( 'abstract assert boolean break byte case catch char class const continue def default do ' + 'double else enum extends final finally float for goto if implements import int interface ' + 'long native non-sealed package permits private protected public record return sealed ' + 'short static strictfp switch synchronized threadsafe throw throws trait transient try var ' + 'void volatile while yield' ).split(' '), ); // Operators that end a postfix chain or start an operand: no binary operator. const NOT_BINARY = new Set(['.', '?.', '*.', '.&', '.@', '::', '!', '~', '++', '--', '->']); const startsOperand = (token: Token | undefined) => !!token && (token.kind === 'literal' || (token.kind === 'name' && !NOT_OPERANDS.has(token.text.toLowerCase()) && !KEYWORDS.has(token.text))); const endsOperand = (token: Token | undefined) => !!token && (startsOperand(token) || (token.kind === 'close' && token.text !== '}')); const isBinary = (token: Token) => (token.kind === 'operator' && !NOT_BINARY.has(token.text)) || (token.kind === 'name' && ['in', 'instanceof', 'as'].includes(token.text)); // The index of the first operand that directly follows another, or -1. export function juxtaposedIndex(tokens: Token[]): number { return tokens.findIndex( (token, index) => index > 0 && startsOperand(token) && endsOperand(tokens[index - 1]), ); } // The INVALID_CRITERIA TEXT GSF sends for a criteria with operands side by // side, and where the second one is; undefined when it has none. The // command expression groovyValidator.ts refuses gets its exact text there // first; here it gets the approximate one only when that parser gives up. export function juxtaposition(criteria: string): { text: string; at: number } | undefined { const tokens = groovyTokens(criteria); if (!tokens) return undefined; const second = juxtaposedIndex(tokens); if (second < 0) return undefined; const at = tokens[second].offset; const syntaxError = (input: string, offset: number) => ({ text: groovyCompileErrorText(criteria, { message: `Unexpected input: '${input}'`, line: 1, column: offset + 1, }), at, }); const span = (from: Token, to: Token) => criteria.slice(from.offset, to.offset + to.text.length); // The parentheses still open at the second operand, and where its statement starts. const open: number[] = []; let statement = 0; for (let index = 0; index < second; index += 1) { const token = tokens[index]; if (token.kind === 'open') open.push(index); else if (token.kind === 'close') open.pop(); else if (token.kind === 'semicolon' && open.length === 0) statement = index + 1; } const groupStart = (depth: number) => (depth > 0 ? open[depth - 1] + 1 : statement); const binaryIn = (from: number, to: number) => tokens.slice(from, to).some(isBinary); const paren = open.at(-1); const callee = paren === undefined ? undefined : tokens[paren - 1]; const inCall = paren !== undefined && tokens[paren].text === '(' && callee?.kind === 'name' && !NOT_OPERANDS.has(callee.text.toLowerCase()); if (inCall) { // A comma of this call's own list before the operands: a later argument. let depth = 0; let later = false; for (let index = paren + 1; index < second; index += 1) { const token = tokens[index]; if (token.kind === 'open') depth += 1; else if (token.kind === 'close') depth -= 1; else if (token.kind === 'comma' && depth === 0) later = true; } if (later) return syntaxError('(', tokens[paren].offset); let end = second; while (startsOperand(tokens[end + 1])) end += 1; const after = tokens[end + 1]; if (after?.kind === 'close' && after.text === ')') { // The call's only argument: after a binary operator, a syntax error // naming the statement up to the method. const from = groupStart(open.length - 1); if (binaryIn(from, paren - 1)) return syntaxError(span(tokens[from], callee), tokens[from].offset); } } else { const from = groupStart(open.length); if (binaryIn(from, second)) { return syntaxError(span(tokens[from], tokens[second - 1]), tokens[from].offset); } } return syntaxError(tokens[second].text, at); }