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