*/ private array $tokens = []; private int $pos = 0; /** * @return array{sources:list, where:array} */ public function parse(string $input): array { $lexer = new DslLexer(); $this->tokens = $lexer->lex($input); $this->pos = 0; $sources = $this->parseSourceExpr(); $where = []; if ($this->matchKw('where')) { $where = $this->parsePredicateExpr(); } $this->expect('EOF'); return [ 'sources' => $sources, 'where' => $where, ]; } /** @return list */ private function parseSourceExpr(): array { $sources = []; $sources[] = $this->parseSourceRef(); while ($this->matchKw('and')) { $sources[] = $this->parseSourceRef(); } return array_values(array_unique($sources)); } private function parseSourceRef(): string { $left = $this->expect('IDENT')->value; if ($this->match('.')) { $right = $this->expect('IDENT')->value; return $left . '.' . $right; } return strtolower($left); } /** @return array */ private function parsePredicateExpr(): array { $predicates = []; $predicates[] = $this->parsePredicateTerm(); while ($this->matchKw('and')) { $predicates[] = $this->parsePredicateTerm(); } return $predicates; } /** @return array */ private function parsePredicateTerm(): array { $negated = $this->matchKw('not'); if ($this->match('(')) { $inner = $this->parsePredicateExpr(); $this->expect(')'); return ['type' => 'group', 'not' => $negated, 'items' => $inner]; } $ident = $this->expect('IDENT')->value; if ($this->isBareFilter($ident)) { return ['type' => 'filter', 'not' => $negated, 'name' => strtolower($ident), 'args' => []]; } if ($this->match('(')) { $args = []; if (!$this->match(')')) { $args = $this->parseValueList(); $this->expect(')'); } return ['type' => 'filter', 'not' => $negated, 'name' => strtolower($ident), 'args' => $args]; } $lhs = $this->parseFieldRefFromIdent($ident); if ($this->matchKw('in')) { $this->expect('('); if ($this->peek()->type === 'IDENT' && $this->peek(1)->type === '.') { $rhs = $this->parseFieldRef(); $this->expect(')'); return ['type' => 'set_ref', 'not' => $negated, 'lhs' => $lhs, 'rhs' => $rhs, 'op' => 'in']; } $rhsValues = $this->parseValueList(); $this->expect(')'); return ['type' => 'set_values', 'not' => $negated, 'lhs' => $lhs, 'values' => $rhsValues, 'op' => 'in']; } if ($this->matchKw('not')) { $this->expectKw('in'); $this->expect('('); if ($this->peek()->type === 'IDENT' && $this->peek(1)->type === '.') { $rhs = $this->parseFieldRef(); $this->expect(')'); return ['type' => 'set_ref', 'not' => $negated, 'lhs' => $lhs, 'rhs' => $rhs, 'op' => 'not in']; } $rhsValues = $this->parseValueList(); $this->expect(')'); return ['type' => 'set_values', 'not' => $negated, 'lhs' => $lhs, 'values' => $rhsValues, 'op' => 'not in']; } if ($this->matchKw('blank')) { return ['type' => 'blank', 'not' => $negated, 'lhs' => $lhs]; } $opToken = $this->peek(); $op = ''; if ($opToken->type === 'OP') { $op = $this->next()->value; } elseif ($opToken->type === 'KW' && in_array($opToken->value, ['contains', 'starts-with', 'ends-with'], true)) { $op = $this->next()->value; } else { throw new AppError('dsl_parse', 'Expected comparison operator', ['offset' => $opToken->offset]); } if ($this->peek()->type === 'IDENT' && $this->peek(1)->type === '.') { $rhsField = $this->parseFieldRef(); return ['type' => 'comparison_field', 'not' => $negated, 'lhs' => $lhs, 'rhs' => $rhsField, 'op' => $op]; } $value = $this->parseValue(); return ['type' => 'comparison_value', 'not' => $negated, 'lhs' => $lhs, 'rhs' => $value, 'op' => $op]; } /** @return array{source:string, field:string} */ private function parseFieldRefFromIdent(string $ident): array { $this->expect('.'); $field = $this->expect('IDENT')->value; if ($this->match('.')) { $ident .= '.' . $field; $field = $this->expect('IDENT')->value; } else { $ident = strtolower($ident); } return ['source' => $ident, 'field' => $field]; } /** @return array{source:string, field:string} */ private function parseFieldRef(): array { $src = $this->expect('IDENT')->value; $this->expect('.'); $field = $this->expect('IDENT')->value; if ($this->match('.')) { $src .= '.' . $field; $field = $this->expect('IDENT')->value; } else { $src = strtolower($src); } return ['source' => $src, 'field' => $field]; } /** @return list */ private function parseValueList(): array { $values = [$this->parseValue()]; while ($this->match(',')) { $values[] = $this->parseValue(); } return $values; } /** @return mixed */ private function parseValue() { $token = $this->next(); if ($token->type === 'STRING') { return $token->value; } if ($token->type === 'NUMBER') { return (int) $token->value; } if ($token->type === 'KW' && ($token->value === 'true' || $token->value === 'false')) { return $token->value === 'true'; } if ($token->type === 'KW' && $token->value === 'blank') { return ['type' => 'blank_literal']; } throw new AppError('dsl_parse', 'Expected literal value', ['offset' => $token->offset]); } private function match(string $type): bool { if ($this->peek()->type === $type) { $this->pos++; return true; } return false; } private function matchKw(string $value): bool { $token = $this->peek(); if ($token->type === 'KW' && $token->value === $value) { $this->pos++; return true; } return false; } private function expect(string $type): DslToken { $token = $this->next(); if ($token->type !== $type) { throw new AppError('dsl_parse', 'Unexpected token', ['expected' => $type, 'actual' => $token->type, 'offset' => $token->offset]); } return $token; } private function expectKw(string $value): DslToken { $token = $this->next(); if ($token->type !== 'KW' || $token->value !== $value) { throw new AppError('dsl_parse', 'Expected keyword', ['expected' => $value, 'actual' => $token->value, 'offset' => $token->offset]); } return $token; } private function next(): DslToken { $token = $this->tokens[$this->pos] ?? new DslToken('EOF', '', 0); $this->pos++; return $token; } private function peek(int $offset = 0): DslToken { return $this->tokens[$this->pos + $offset] ?? new DslToken('EOF', '', 0); } private function isBareFilter(string $ident): bool { $filters = [ 'selected-renewal', 'pending-renewal', 'selected', 'primary-contact', 'fen1-contact', 'pending-invoice', 'selected-invoice', 'member-or-affiliate-or-parish-council', ]; $next = $this->peek(); $blockedNext = ($next->type === '.') || ($next->type === 'OP') || ($next->type === 'KW' && in_array($next->value, ['in', 'not', 'contains', 'starts-with', 'ends-with'], true)); return in_array(strtolower($ident), $filters, true) && !$blockedNext; } }