// Which CRITERIA_MATCH strings real GSF refuses to compile, and the text it // refuses them with. // // GSF parses a criteria as Groovy before anything else // (CriteriaFilterBuilder.validateAndParseCriteriaOptions: // AstBuilder().buildFromString(criteria), GSF v8.15.29, Groovy 3.0.25), and a // parse failure becomes INVALID_CRITERIA with the compiler's message. This // engine's criteria grammar (criteria.ts) is much smaller than Groovy, so its // own parse failures can't be taken for Groovy's: `ENABLED`, `NAME in ['A']` // or arithmetic are valid Groovy it doesn't model. This check answers only // for the mistakes it can pin down as Groovy does — a dangling operator, an // unclosed or unopened parenthesis, an operator where an operand must start, // an unterminated string — and gives up (undefined) on anything else it // doesn't understand, so a criteria is refused only when Groovy would refuse // it too. The messages and positions follow what Groovy 3.0.25's AstBuilder // reports for those cases (checked by running it; test/groovySyntax.test.ts pins // them), though ANTLR names a different token in a few of them. export interface GroovySyntaxError { // "Unexpected input: ''", "Unexpected character: '\''", ... message: string; line: number; column: number; } type TokenKind = 'name' | 'literal' | 'operator' | 'open' | 'close' | 'comma' | 'semicolon'; export interface Token { kind: TokenKind; text: string; offset: number; } // Thrown when the criteria is outside what this check understands: no verdict. class GiveUp extends Error {} // Thrown for a syntax error Groovy reports the same way. class Refused extends Error { readonly text: string; readonly offset: number; readonly kind: 'input' | 'character'; constructor(text: string, offset: number, kind: 'input' | 'character' = 'input') { super(text); this.text = text; this.offset = offset; this.kind = kind; } } // Groovy's operators, longest first so the scanner takes `===` before `==`. const OPERATORS = [ '>>>=', '===', '!==', '<=>', '==~', '**=', '<<=', '>>=', '>>>', '..<', '?.', '?:', '*.', '.&', '.@', '..', '::', '->', '==', '!=', '<=', '>=', '&&', '||', '=~', '++', '--', '+=', '-=', '*=', '%=', '&=', '|=', '^=', '**', '<<', '>>', '<>', '=', '<', '>', '!', '~', '+', '-', '*', '%', '&', '|', '^', '?', ':', '.', ]; // Operators that end a postfix chain with a member name. const MEMBER_OPERATORS = new Set(['.', '?.', '*.', '.&', '.@', '::']); // Operators an operand may start with. const PREFIX_OPERATORS = new Set(['!', '~', '+', '-', '++', '--']); // Operators that can never start an operand: one of these where an operand // must start is the error Groovy reports at that operator (recorded for ==, // &&, ||, > and &). const NEVER_LEADING = new Set([ '==', '!=', '===', '!==', '<=', '>=', '<', '>', '&&', '||', '=', '&', '|', '^', '=~', '==~', '<=>', ]); // Binary operators; this engine's AND / OR keywords count too, so a criteria // in its own dialect gets no syntax verdict here: criteriaDialect.ts gives // GSF's. const BINARY_KEYWORDS = new Set(['in', 'instanceof', 'as']); const DIALECT_BINARY = new Set(['AND', 'OR']); const NAME_START = /[A-Za-z_$]/; const NAME_PART = /[A-Za-z0-9_$]/; // Groovy's number literals: hex, binary, decimal (octal too), digits split by // underscores, a fraction, an exponent and a type suffix. const DIGITS = '\\d(?:[\\d_]*\\d)?'; const NUMBER = new RegExp( `^(?:0[xX][\\da-fA-F](?:[\\da-fA-F_]*[\\da-fA-F])?|0[bB][01](?:[01_]*[01])?|${DIGITS}(?:\\.${DIGITS})?(?:[eE][+-]?${DIGITS})?)[GgLlDdFfIi]?`, ); // Characters Groovy can't start any token with. const NEVER_TOKENS = new Set(['#', '`']); const TRIPLE_QUOTE = 3; function scan(source: string): Token[] { const tokens: Token[] = []; let at = 0; while (at < source.length) { const char = source[at]; if (char === ' ' || char === '\t') { at += 1; continue; } // A line break can end a statement in Groovy: leave those to Groovy. if (char === '\n' || char === '\r') throw new GiveUp(); if (char === "'" || char === '"') { // Triple-quoted strings and interpolated GStrings: not modelled. if (source.startsWith(char.repeat(TRIPLE_QUOTE), at)) throw new GiveUp(); let end = at + 1; while (end < source.length && source[end] !== char) end += source[end] === '\\' ? 2 : 1; if (end >= source.length) throw new Refused(char, at, 'character'); const text = source.slice(at, end + 1); if (char === '"' && text.includes('${')) throw new GiveUp(); tokens.push({ kind: 'literal', text, offset: at }); at = end + 1; continue; } const number = NUMBER.exec(source.slice(at)); if (number) { tokens.push({ kind: 'literal', text: number[0], offset: at }); at += number[0].length; continue; } // A slash may open a slashy string (/O'Brien/) or a comment, inside // which nothing above applies: no verdict. if (char === '/') throw new GiveUp(); if (NAME_START.test(char)) { let end = at + 1; while (end < source.length && NAME_PART.test(source[end])) end += 1; tokens.push({ kind: 'name', text: source.slice(at, end), offset: at }); at = end; continue; } if ('([{'.includes(char)) { tokens.push({ kind: 'open', text: char, offset: at }); at += 1; continue; } if (')]}'.includes(char)) { tokens.push({ kind: 'close', text: char, offset: at }); at += 1; continue; } if (char === ',' || char === ';') { tokens.push({ kind: char === ',' ? 'comma' : 'semicolon', text: char, offset: at }); at += 1; continue; } const operator = OPERATORS.find((candidate) => source.startsWith(candidate, at)); if (operator) { tokens.push({ kind: 'operator', text: operator, offset: at }); at += operator.length; continue; } if (NEVER_TOKENS.has(char)) throw new Refused(char, at, 'character'); throw new GiveUp(); } return tokens; } class Checker { private at = 0; private readonly tokens: Token[]; // The offset of the end of the input, where Groovy reports ''. private readonly end: number; constructor(tokens: Token[], end: number) { this.tokens = tokens; this.end = end; } private peek(ahead = 0): Token | undefined { return this.tokens[this.at + ahead]; } private next(): Token | undefined { const token = this.tokens[this.at]; this.at += 1; return token; } private isOperator(token: Token | undefined, ...texts: string[]): boolean { return token?.kind === 'operator' && texts.includes(token.text); } private startsOperand(token: Token | undefined): boolean { if (!token) return false; if (token.kind === 'name' || token.kind === 'literal') return true; if (token.kind === 'open') return true; return token.kind === 'operator' && PREFIX_OPERATORS.has(token.text); } private isBinary(token: Token | undefined): boolean { if (!token) return false; if (token.kind === 'name') { return BINARY_KEYWORDS.has(token.text) || DIALECT_BINARY.has(token.text.toUpperCase()); } return ( token.kind === 'operator' && !MEMBER_OPERATORS.has(token.text) && !['!', '~', '++', '--', '?', '?:', ':', '->'].includes(token.text) ); } check(): void { for (;;) { this.expression(); const token = this.next(); if (!token) return; if (token.kind === 'semicolon') { if (!this.peek()) return; continue; } // A parenthesis closing nothing (recorded: `NAME == 'x')`). if (token.kind === 'close' && token.text === ')') throw new Refused(')', token.offset); throw new GiveUp(); } } private expression(): void { this.binary(); const token = this.peek(); if (this.isOperator(token, '?')) { this.next(); this.expression(); const colon = this.next(); if (!colon) throw new Refused('', this.end); if (!this.isOperator(colon, ':')) throw new GiveUp(); this.expression(); } else if (this.isOperator(token, '?:')) { this.next(); this.expression(); } } private binary(): void { this.unary(); while (this.isBinary(this.peek())) { const operator = this.next()!; // A trailing + or - (which could also start an operand) is reported at // itself (recorded: `QUANTITY > 1 +`); any other at the end of input. if (!this.peek() && PREFIX_OPERATORS.has(operator.text)) { throw new Refused(operator.text, operator.offset); } this.unary(); } } private unary(): void { for (;;) { const token = this.peek(); if (token?.kind === 'operator' && PREFIX_OPERATORS.has(token.text)) { this.next(); continue; } // This engine's NOT keyword, when an operand follows it. if ( token?.kind === 'name' && token.text.toUpperCase() === 'NOT' && this.startsOperand(this.peek(1)) ) { this.next(); continue; } break; } this.postfix(); } private postfix(): void { this.primary(); for (;;) { const token = this.peek(); if (token?.kind === 'operator' && MEMBER_OPERATORS.has(token.text)) { this.next(); const member = this.next(); if (!member) throw new Refused('', this.end); // A name, or a quoted one (obj.'name'); not a number (1.5.5). const quoted = member.kind === 'literal' && /^['"]/.test(member.text); if (member.kind !== 'name' && !quoted) throw new GiveUp(); continue; } if (this.isOperator(token, '++', '--')) { this.next(); continue; } if (token?.kind === 'open' && token.text === '(') { this.next(); this.callArguments(token); continue; } if (token?.kind === 'open' && (token.text === '[' || token.text === '{')) { this.next(); this.bracketed(token); continue; } return; } } private primary(): void { const token = this.next(); if (!token) throw new Refused('', this.end); if (token.kind === 'name' || token.kind === 'literal') return; if (token.kind === 'open' && token.text === '(') { const following = this.peek(); if (!following) throw new Refused('(', this.end); // recorded: `NAME == 'x' && (` // `()` is no expression; recorded only at the end of the input. if (following.kind === 'close' && following.text === ')') { this.next(); if (!this.peek()) throw new Refused('', this.end); throw new GiveUp(); } this.expression(); const close = this.next(); if (!close) throw new Refused('', this.end); if (close.kind !== 'close' || close.text !== ')') throw new GiveUp(); return; } if (token.kind === 'open') { this.bracketed(token); return; } if (token.kind === 'close' && token.text === ')') throw new Refused(')', token.offset); if (token.kind === 'operator' && NEVER_LEADING.has(token.text)) { throw new Refused(token.text, token.offset); } throw new GiveUp(); } // A call's arguments, after its '('. Running out of input inside them is // reported at that '(' (recorded: `Expr.contains(NAME, 'x'`). private callArguments(open: Token): void { try { this.list(')'); } catch (error) { if (error instanceof Refused && error.offset === this.end && error.kind === 'input') { throw new Refused('(', open.offset); } throw error; } } // A list literal or index after '[', or a closure after '{'. Closures // aren't modelled: their contents only have to balance. private bracketed(open: Token): void { if (open.text === '{') { let depth = 1; while (depth > 0) { const token = this.next(); if (!token) throw new GiveUp(); if (token.kind === 'open') depth += 1; if (token.kind === 'close') depth -= 1; } return; } this.list(']'); } // Comma-separated expressions up to `close`; a trailing comma is allowed. private list(close: string): void { for (;;) { const token = this.peek(); if (token?.kind === 'close' && token.text === close) { this.next(); return; } if (token?.kind === 'comma') throw new GiveUp(); this.expression(); const after = this.next(); if (!after) { if (close === ')') throw new Refused('', this.end); throw new GiveUp(); } if (after.kind === 'close' && after.text === close) return; if (after.kind !== 'comma') throw new GiveUp(); } } } // The criteria's Groovy tokens, or undefined where this scanner gives up (a // line break, a slash, a triple-quoted or interpolated string) or Groovy's // lexer refuses it. export function groovyTokens(criteria: string): Token[] | undefined { try { return scan(criteria); } catch (error) { if (error instanceof GiveUp || error instanceof Refused) return undefined; throw error; } } // The syntax error Groovy would report for this criteria, or undefined when // it has none or this check can't tell (see the header). export function groovySyntaxError(criteria: string): GroovySyntaxError | undefined { const verdict = checkGroovySyntax(criteria); return verdict.kind === 'refused' ? verdict.error : undefined; } // The whole verdict: 'valid' when the criteria parsed as Groovy this check // understands, 'refused' with Groovy's error, 'unknown' when it gave up (a // construct it doesn't model, or nesting too deep for its recursion). export type GroovyVerdict = | { kind: 'valid' } | { kind: 'refused'; error: GroovySyntaxError } | { kind: 'unknown' }; export function checkGroovySyntax(criteria: string): GroovyVerdict { if (criteria.trim() === '') return { kind: 'valid' }; try { new Checker(scan(criteria), criteria.length).check(); return { kind: 'valid' }; } catch (error) { if (error instanceof GiveUp || error instanceof RangeError) return { kind: 'unknown' }; if (!(error instanceof Refused)) throw error; const quoted = error.text === "'" ? "'\\''" : `'${error.text}'`; return { kind: 'refused', error: { message: `Unexpected ${error.kind}: ${quoted}`, line: 1, column: error.offset + 1 }, }; } } let scripts = 0; // The INVALID_CRITERIA TEXT GSF sends for it: the MultipleCompilationErrors // message, prefixed with the exception's simple name // (GenericCriteriaValidationException("${e.javaClass.simpleName} : // ${e.message}")), naming a generated script class. // `ownLine`: the position goes on a line of its own, as Groovy prints a // semantic error ("unable to resolve class NOT\n @ line 1, column 5.") // rather than a syntax one. export function groovyCompileErrorText( criteria: string, error: GroovySyntaxError, { ownLine = false }: { ownLine?: boolean } = {}, ): string { scripts += 1; const script = `Script${Date.now()}${scripts}.groovy`; const sourceLine = criteria.split('\n')[error.line - 1] ?? ''; return ( 'MultipleCompilationErrorsException : startup failed:\n' + `${script}: ${error.line}: ${error.message}${ownLine ? '\n' : ''} @ line ${error.line}, column ${error.column}.\n` + ` ${sourceLine}\n` + ` ${' '.repeat(error.column - 1)}^\n` + '\n1 error\n' ); }