feca-mailshots-plugin/feca_mailshots_plugin/src/Domain/DslParser.php

276 lines
8.4 KiB
PHP

<?php
declare(strict_types=1);
namespace FecaMailshots\Domain;
final class DslParser
{
/** @var list<DslToken> */
private array $tokens = [];
private int $pos = 0;
/**
* @return array{sources:list<string>, where:array<int, mixed>}
*/
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<string> */
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<int, mixed> */
private function parsePredicateExpr(): array
{
$predicates = [];
$predicates[] = $this->parsePredicateTerm();
while ($this->matchKw('and')) {
$predicates[] = $this->parsePredicateTerm();
}
return $predicates;
}
/** @return array<string, mixed> */
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<mixed> */
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;
}
}