Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.14% covered (success)
92.14%
129 / 140
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Builder
92.14% covered (success)
92.14%
129 / 140
75.00% covered (warning)
75.00%
6 / 8
75.58
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 build
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 titleWeight
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 schema
84.62% covered (warning)
84.62%
55 / 65
0.00% covered (danger)
0.00%
0 / 1
30.85
 fields
97.37% covered (success)
97.37%
37 / 38
0.00% covered (danger)
0.00%
0 / 1
18
 effective
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 richtext
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
11
 append
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Fulltext;
6
7use Cosray\Block\Registry;
8use Cosray\Contract\Block;
9use Cosray\Contract\Title;
10use Cosray\Exception\RuntimeException;
11use Cosray\Field;
12use Cosray\Field\Condition;
13use Cosray\Field\Definitions;
14use Cosray\Field\Schema\FulltextHandler;
15use Cosray\Locale;
16use Cosray\Node\Types;
17use Cosray\Richtext\Envelope;
18use Cosray\Richtext\Spec;
19use Cosray\Schema\Allows;
20use Cosray\Schema\Fulltext;
21use Cosray\Schema\FulltextWeight;
22use Cosray\Schema\Translate;
23use Cosray\Schema\TranslateMode;
24use Cosray\Schema\When;
25use Cosray\Title\Resolver;
26use ReflectionClass;
27use ReflectionMethod;
28
29final class Builder
30{
31    private readonly Resolver $titles;
32    /** @var array<string, array<string, array<string, mixed>>> */
33    private array $schemas = [];
34
35    public function __construct(
36        Types $types,
37        private readonly Registry $blocks,
38    ) {
39        $this->titles = new Resolver($types);
40    }
41
42    /**
43     * @param class-string $class
44     * @param array<mixed> $content
45     * @param array<string, mixed> $titles
46     */
47    public function build(string $class, array $content, array $titles, Locale $locale): Document
48    {
49        $parts = [];
50        $weight = $this->titleWeight($class);
51        $title = $weight instanceof FulltextWeight ? $this->titles->stored($titles, $locale) : null;
52
53        if ($title !== null) {
54            $this->append($parts, 'title()', $weight, $title);
55        }
56
57        $this->fields($class, $content, $locale, null, '', $parts);
58
59        return new Document($parts, $weight instanceof FulltextWeight && $title === null);
60    }
61
62    /** @param class-string $class */
63    private function titleWeight(string $class): FulltextWeight|false|null
64    {
65        $descriptor = $this->titles->descriptor($class);
66
67        if ($descriptor['kind'] !== Resolver::KIND_DYNAMIC) {
68            return null;
69        }
70
71        $provider = isset($descriptor['embedded'])
72            ? Definitions::for($class)->embed($descriptor['embedded'])->type
73            ?? throw new RuntimeException("Unknown embedded title provider '{$descriptor['embedded']}' in {$class}")
74            : $class;
75        $attributes = new ReflectionMethod($provider, 'title')->getAttributes(Fulltext::class);
76
77        return $attributes === [] ? null : $attributes[0]->newInstance()->fulltextWeight;
78    }
79
80    /**
81     * @param class-string $class
82     * @return array<string, array<string, mixed>>
83     */
84    private function schema(string $class): array
85    {
86        if (isset($this->schemas[$class])) {
87            return $this->schemas[$class];
88        }
89
90        $definitions = Definitions::for($class);
91        foreach ([$class, ...array_column($definitions->embedded(), 'type')] as $owner) {
92            $reflection = new ReflectionClass($owner);
93            foreach ($reflection->getMethods() as $method) {
94                if ($method->getAttributes(Fulltext::class) === []) {
95                    continue;
96                }
97                if ($method->getName() !== 'title' || !is_a($owner, Title::class, true)) {
98                    throw new RuntimeException(
99                        "Fulltext on '{$owner}::{$method->getName()}()' requires the Title contract's title() implementation.",
100                    );
101                }
102            }
103            foreach ($reflection->getProperties() as $property) {
104                if (
105                    $property->getAttributes(Fulltext::class) !== []
106                    && $definitions->field($property->getName()) === null
107                ) {
108                    throw new RuntimeException(
109                        "Fulltext on '{$owner}::\${$property->getName()}' requires a supported field.",
110                    );
111                }
112            }
113        }
114
115        $schema = [];
116        foreach ($definitions->fields() as $name => $field) {
117            $attributes = $field->property->getAttributes(Fulltext::class);
118            $weight = $attributes === [] ? null : $attributes[0]->newInstance()->fulltextWeight;
119            if ($weight === false) {
120                continue;
121            }
122            if (!FulltextHandler::supports($field->type)) {
123                if ($weight !== null) {
124                    throw new RuntimeException(
125                        "Fulltext does not support '{$class}::\${$name}' of type '{$field->type}'.",
126                    );
127                }
128                continue;
129            }
130            $entry = [
131                'type' => $field->type,
132                'weight' => $weight,
133                'conditions' => array_map(
134                    static fn($attribute): array => $attribute->newInstance()->condition(),
135                    $field->property->getAttributes(When::class),
136                ),
137            ];
138            if (in_array($field->type, [Field\Blocks::class, Field\Entries::class], true)) {
139                $allows = $field->property->getAttributes(Allows::class);
140                $entry['types'] = $field->type === Field\Blocks::class ? $this->blocks->all() : [];
141                if ($allows !== []) {
142                    $entry['types'] = $allows[0]->newInstance()->types;
143                }
144                if ($entry['types'] === []) {
145                    throw new RuntimeException("Fulltext field '{$class}::\${$name}' requires allowed row types.");
146                }
147                foreach ($entry['types'] as $type) {
148                    if (
149                        !class_exists($type)
150                        || $field->type === Field\Blocks::class
151                        && !is_a($type, Block::class, true)
152                    ) {
153                        throw new RuntimeException(
154                            "Invalid row type '{$type}' in fulltext field '{$class}::\${$name}'.",
155                        );
156                    }
157                    foreach (Definitions::for($type)->fields() as $child) {
158                        if (
159                            is_a($child->type, Field\Blocks::class, true)
160                            || is_a($child->type, Field\Entries::class, true)
161                        ) {
162                            throw new RuntimeException(
163                                "Fulltext field '{$class}::\${$name}' cannot contain nested repeaters in '{$type}'.",
164                            );
165                        }
166                    }
167                }
168                $translation = $field->property->getAttributes(Translate::class);
169                $entry['asymmetric'] =
170                    $field->type === Field\Blocks::class
171                    && $translation !== []
172                    && $translation[0]->newInstance()->mode === TranslateMode::Asymmetric;
173            }
174            $schema[$name] = $entry;
175        }
176
177        return $this->schemas[$class] = $schema;
178    }
179
180    /**
181     * @param class-string $class
182     * @param array<mixed> $content
183     * @param list<array{field: string, weight: string, text: string}> $parts
184     */
185    private function fields(
186        string $class,
187        array $content,
188        Locale $locale,
189        ?FulltextWeight $inherited,
190        string $path,
191        array &$parts,
192    ): void {
193        foreach ($this->schema($class) as $name => $field) {
194            foreach ($field['conditions'] as $condition) {
195                if (!Condition::active($condition, $content)) {
196                    continue 2;
197                }
198            }
199            $weight = $field['weight'] ?? $inherited;
200            $data = $content[$name] ?? [];
201            $map = is_array($data) && is_array($data['value'] ?? null) ? $data['value'] : [];
202            $where = $path . $name;
203            if (isset($field['types'])) {
204                $rows = $field['asymmetric']
205                    ? $this->effective($map, $locale)
206                    : $map[Field\Field::NEUTRAL_LOCALE] ?? [];
207                foreach (is_array($rows) ? $rows : [] as $index => $row) {
208                    if (!is_array($row) || !in_array($row['type'] ?? null, $field['types'], true)) {
209                        continue;
210                    }
211                    $this->fields(
212                        $row['type'],
213                        is_array($row['fields'] ?? null) ? $row['fields'] : [],
214                        $locale,
215                        $weight,
216                        "{$where}[{$index}].",
217                        $parts,
218                    );
219                }
220                continue;
221            }
222            if ($weight === null) {
223                continue;
224            }
225            $value = $this->effective($map, $locale);
226            if ($value === null) {
227                continue;
228            }
229            if ($field['type'] === Field\RichText::class) {
230                if (!Envelope::isStructured($data)) {
231                    continue;
232                }
233                $value = $this->richtext($value);
234            }
235            if (!is_string($value)) {
236                throw new RuntimeException(
237                    "Fulltext field '{$where}' must contain text, not " . get_debug_type($value) . '.',
238                );
239            }
240            $this->append($parts, $where, $weight, $value);
241        }
242    }
243
244    /** @param array<mixed> $map */
245    private function effective(array $map, Locale $locale): mixed
246    {
247        foreach ([$locale->id, ...$locale->fallbacks(), Field\Field::NEUTRAL_LOCALE] as $id) {
248            $value = $map[$id] ?? null;
249            if ($value !== null && $value !== '' && $value !== []) {
250                return $value;
251            }
252        }
253        return null;
254    }
255
256    // Reader-tolerant like Richtext\Renderer: unknown types are skipped with their subtree.
257    private function richtext(mixed $node): string
258    {
259        $type = is_array($node) ? $node['type'] ?? null : null;
260        if (!is_string($type) || !Spec::isNode($type)) {
261            return '';
262        }
263        if ($type === 'text') {
264            return is_string($node['text'] ?? null) ? $node['text'] : '';
265        }
266        $boundary = $type === 'hardBreak' || !Spec::isInline($type) ? "\n" : '';
267        if (Spec::isLeaf($type)) {
268            return $boundary;
269        }
270        $text = '';
271        foreach (is_array($node['content'] ?? null) ? $node['content'] : [] as $child) {
272            $text .= $this->richtext($child);
273        }
274        return $text . $boundary;
275    }
276
277    /** @param list<array{field: string, weight: string, text: string}> $parts */
278    private function append(array &$parts, string $field, FulltextWeight $weight, string $text): void
279    {
280        // Headline markers must never occur in original source text.
281        $text = trim(str_replace(["\0", "\x01", "\x02"], '', $text));
282        if ($text !== '') {
283            $parts[] = ['field' => $field, 'weight' => $weight->name, 'text' => $text];
284        }
285    }
286}