packages/sql-faker/src/Generation/Output/ReverseLexemeGenerator.php

1<?php
2
3declare(strict_types=1);
4
5namespace SqlFaker\Generation\Output;
6
7use Closure;
8use SqlFaker\Generation\Exception\LexicalException;
9use SqlFaker\Generation\Lexeme\LexemeGenerator;
10use SqlFaker\Generation\Lexeme\LexemeInput;
11use SqlFaker\Generation\Lexeme\LexemeSequence;
12use SqlFaker\Generation\Lexeme\ResolvedOutput;
13use SqlFaker\Generation\Lexeme\SpacingConstraint;
14use SqlFaker\Generation\Plan\GenerationPlan;
15use SqlFaker\Generation\Token\TerminalSequence;
16use SqlFaker\Generation\Value\ValueChoices;
17
18/**
19 * Chooses complete compatible candidates while traversing terminals from right to left.
20 */
21final class ReverseLexemeGenerator
22{
23    /**
24     * Binds the candidate definitions, boundary resolver and exact release for diagnostics.
25     */
26    public function __construct(
27        private readonly LexemeGenerator $lexemes,
28        private readonly CandidateResolver $resolver,
29        private readonly string $version,
30        private readonly string $dialect = 'SQL',
31    ) {
32    }
33
34    /**
35     * @throws LexicalException When a candidate is missing, incompatible or unstable
36     * @param GenerationPlan<bool>|null $plan
37     * @param (Closure(positive-int): ?int)|null $valueChoice Explicit plan-time value decisions
38     * @param Closure(int): int $choose Chooses once after each applicable candidate set has been evaluated
39     */
40    public function generate(TerminalSequence $sequence, ?GenerationPlan $plan, Closure $choose, ?Closure $valueChoice = null): ResolvedOutput
41    {
42        $occurrences = [];
43        $requested = [];
44        $keys = [];
45        $original = [];
46        foreach ($sequence->original as $terminal) {
47            $occurrence = $occurrences[$terminal->name] ?? 0;
48            $occurrences[$terminal->name] = $occurrence + 1;
49            $original[$terminal->id] = $plan?->lexemeAt($terminal->name, $occurrence);
50        }
51        $occurrences = [];
52        foreach ($sequence->terminals as $index => $terminal) {
53            $occurrence = $occurrences[$terminal->name] ?? 0;
54            $occurrences[$terminal->name] = $occurrence + 1;
55            $keys[$index] = $plan?->candidateKeyAt($terminal->name, $occurrence);
56            $requested[$index] = $plan?->lexemeAt($terminal->name, $occurrence) ?? $original[$terminal->id] ?? null;
57        }
58        $values = $valueChoice === null ? null : new ValueChoices($valueChoice);
59        $completion = new BoundaryCompletion($this->lexemes, $this->resolver, $requested, $keys, $values);
60        $right = new ResolvedOutput();
61        for ($index = count($sequence->terminals) - 1; $index >= 0; --$index) {
62            $right = $this->select(
63                new LexemeInput($sequence, $index, $right, $requested[$index], $values),
64                $choose,
65                $keys[$index],
66                static fn (ResolvedOutput $suffix): bool => $completion->accepts($sequence, $index - 1, $suffix)
67            );
68        }
69        if ($right->left !== null && $right->left->allowed !== SpacingConstraint::EITHER) {
70            throw new LexicalException('Unresolved left boundary: ' . implode(', ', $right->left->rules));
71        }
72        return $right;
73    }
74
75    /**
76     * @throws LexicalException When a candidate is missing, incompatible or unstable
77     * @param Closure(int): int $choose
78     * @param (Closure(ResolvedOutput): bool)|null $canComplete Checks outstanding left-boundary obligations
79     */
80    public function select(LexemeInput $input, Closure $choose, ?string $key = null, ?Closure $canComplete = null): ResolvedOutput
81    {
82        $candidates = $this->lexemes->generate($input);
83        if ($candidates === null) {
84            throw LexicalException::unsupportedTerminal($this->dialect, $this->version, $input->terminal()->name);
85        }
86        $eligible = 0;
87        $contradictions = [];
88        $rejections = [];
89        foreach ($candidates->sequences() as $candidate) {
90            if (!$this->matchesRequest($candidate, $input, $key)) {
91                continue;
92            }
93            $resolved = $this->resolver->resolve($candidate, $input);
94            if ($resolved instanceof SpacingConstraint) {
95                $contradictions[] = $candidate->id . ': ' . implode(', ', $resolved->rules);
96                $rejections[] = ['index' => $input->index, 'candidate' => $candidate->id, 'rules' => $resolved->rules];
97            } elseif ($canComplete !== null && !$canComplete($resolved)) {
98                $contradictions[] = $candidate->id . ': uncompletable-left-boundary';
99                $rejections[] = ['index' => $input->index, 'candidate' => $candidate->id, 'rules' => ['uncompletable-left-boundary']];
100            } else {
101                ++$eligible;
102            }
103        }
104        if ($eligible === 0) {
105            throw new LexicalException('No compatible lexeme for ' . $input->terminal()->name . ' at ' . $input->index
106                . ' in ' . $this->version . ' before ' . ($input->right->parts[0]->lexeme->text ?? '<end>')
107                . '; ' . implode('; ', $contradictions));
108        }
109        $selected = $eligible === 1 ? 0 : $choose($eligible);
110        if ($selected < 0 || $selected >= $eligible) {
111            throw new LexicalException('Candidate selector returned an out-of-range index for ' . $input->terminal()->name);
112        }
113        foreach ($candidates->sequences() as $candidate) {
114            if (!$this->matchesRequest($candidate, $input, $key)) {
115                continue;
116            }
117            $resolved = $this->resolver->resolve($candidate, $input);
118            if ($resolved instanceof ResolvedOutput && ($canComplete === null || $canComplete($resolved)) && $selected-- === 0) {
119                return new ResolvedOutput($resolved->parts, $resolved->left, $resolved->candidates, [...$resolved->rejections, ...$rejections]);
120            }
121        }
122        throw new LexicalException('Lexeme candidates changed during selection for ' . $input->terminal()->name);
123    }
124
125    /**
126     * Compares a requested spelling with the candidate's complete output, before boundary selection.
127     */
128    public function matchesRequest(LexemeSequence $candidate, LexemeInput $input, ?string $key = null): bool
129    {
130        return ($key === null || $candidate->key() === $key) && ($input->requested === null
131            || implode(' ', array_map(static fn ($lexeme): string => $lexeme->text, $candidate->lexemes)) === $input->requested);
132    }
133}
134