Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
189 / 189
100.00% covered (success)
100.00%
24 / 24
CRAP
100.00% covered (success)
100.00%
1 / 1
Renderer
100.00% covered (success)
100.00%
189 / 189
100.00% covered (success)
100.00%
24 / 24
57
100.00% covered (success)
100.00%
1 / 1
 create
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 with
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 render
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 renderToString
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 tpl_state
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 initialize_tree
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 swap_to_new_buffer
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 complete_buffer
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 do_render
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
8
 render_parent
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 render_with_inherit
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getTemplateAsProxy
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getTemplateInstanceAsProxyById
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 renderProxiedTemplate
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 renderError
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setParentForTemplate
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 startRenderingBlock
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 endRenderingBlock
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 renderChildBlock
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 renderChildContent
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 setInsertionPoint
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 startAppendingToInsertionPoint
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 stopAppendingToInsertionPoint
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
1<?php declare(strict_types=1);
2
3namespace Computator\FrameworkUtils\PHPTemplate;
4
5use Computator\FrameworkUtils\PHPTemplate\Exceptions;
6use Computator\FrameworkUtils\PHPTemplate\RenderTree;
7use Computator\FrameworkUtils\PHPTemplate\Templates;
8use Computator\FrameworkUtils\PHPTemplate\UserApi;
9
10use ArrayAccess;
11use SplObjectStorage;
12use Throwable;
13use ValueError;
14
15use function array_key_exists;
16use function in_array;
17use function is_string;
18use function ob_get_level,ob_end_flush;
19
20/**
21 * Manages the rendering process for the template system.
22 *
23 * The user-visible interface to this is documented by the `RenderClient` interface.
24 *
25 * @see UserApi\RenderClient User-visible interface
26 */
27class Renderer implements UserApi\RenderManager, UserApi\RenderClient {
28    protected readonly Templates\Base $root_template;
29    protected readonly TemplateResolver $resolver;
30    /** @var ArrayAccess<Templates\Base,RendererTemplateState> $tpl_state_map */
31    protected SplObjectStorage $tpl_state_map;
32    protected array $tpl_proxy_map = [];
33
34    protected readonly bool $using_tree;
35    protected RenderTree\Tree $rendertree;
36    protected ?RenderTree\Buffer $current_buff = null;
37
38    /** @var array<string,RenderTree\Node> $insert_point_nodes */
39    protected array $insert_point_nodes = [];
40    protected ?string $current_insert = null;
41    protected ?RenderTree\Node $current_insert_prev_node = null;
42    /** @var string[] $insert_points_set */
43    protected array $insert_points_set = [];
44
45    /** @var array<string,mixed> $context */
46    protected array $context = [];
47
48    public static function create(Templates\Base $template, TemplateResolver $resolver = new TemplateResolver()): UserApi\RenderClient {
49        return new static($template, $resolver);
50    }
51
52    final private function __construct(Templates\Base $template, TemplateResolver $resolver) {
53        $this->root_template = $template;
54        $this->resolver = $resolver;
55
56        $this->tpl_state_map = new SplObjectStorage();
57
58        // TODO: make using a tree optional once we can detect if
59        // there is any out of order rendering, or always use the tree
60        // if we can render the tree without any buffering
61        $this->initialize_tree();
62    }
63
64    public function with(mixed ...$context): self {
65        try {
66            $this->context = Utils::transform_context($context);
67        } catch (ValueError $e) {
68            throw new Exceptions\RendererException("'".__FUNCTION__."' called using invalid data format: $e", previous: $e);
69        }
70        return $this;
71    }
72
73    public function render(): void {
74        // use try-finally since we want to send output as well
75        // as throw any errors
76        try {
77            $this->render_with_inherit($this->root_template, $this->context);
78        } finally {
79            if ($this->using_tree)
80                $this->rendertree->render();
81        }
82    }
83
84    public function renderToString(): string {
85        ob_start();
86
87        $this->render_with_inherit($this->root_template, $this->context);
88        if ($this->using_tree)
89            $this->rendertree->render();
90
91        return (string) ob_get_clean();
92    }
93
94    protected function tpl_state(Templates\Base $tpl): RendererTemplateState {
95        return $this->tpl_state_map[$tpl] ??= new RendererTemplateState();
96    }
97
98    protected function initialize_tree(): void {
99        $this->using_tree = true;
100        $this->rendertree = new RenderTree\Tree(
101            RenderTree\Node::withValue(null),
102        );
103    }
104
105    protected function swap_to_new_buffer(): bool {
106        if ($was_buffering = $this->current_buff !== null)
107            $this->current_buff->append((string) ob_get_clean());
108        $this->current_buff = new RenderTree\Buffer();
109        $this->rendertree->addValue($this->current_buff);
110        ob_start();
111        return $was_buffering;
112    }
113
114    protected function complete_buffer(): void {
115        assert($this->current_buff !== null);
116        $this->current_buff->append((string) ob_get_clean());
117        $this->current_buff = null;
118    }
119
120    /** @param array<string,mixed> $context */
121    protected function do_render(Templates\Base $tpl, array $context): void {
122        $was_buffering = false;
123        if ($this->using_tree) {
124            $was_buffering = $this->swap_to_new_buffer();
125        }
126        $start_ob_level = ob_get_level();
127        $error = null;
128
129        try {
130            // TODO: handle execute return values
131            $tpl->execute(
132                $context,
133                renderer: $this,
134                template: $tpl,
135            );
136        } catch (Throwable $t) {
137            $error = new Exceptions\TemplateRenderException(
138                "error while rendering template: {$t->getMessage()}",
139                previous: $t,
140            );
141        }
142
143        if (ob_get_level() > $start_ob_level) {
144            do {
145                ob_end_flush();
146            } while (ob_get_level() > $start_ob_level);
147
148            if (!$error)
149                $error = new Exceptions\TemplateRenderException("template did not close it's output buffers!");
150        }
151
152        if ($this->using_tree) {
153            $this->complete_buffer();
154            if ($was_buffering)
155                $this->swap_to_new_buffer();
156        }
157
158        if ($error)
159            throw $error;
160    }
161
162    protected function render_parent(Templates\Base $child_tpl): void    {
163        $parent_tpl = $this->tpl_state($child_tpl)->parent;
164        assert($parent_tpl !== null);
165        assert($this->tpl_state($parent_tpl)->child === $child_tpl);
166
167        $target = $this->tpl_state($parent_tpl)->parent_render_target;
168        assert($target !== null);
169        $this->rendertree->setCurrentNode($target);
170
171        $this->do_render($parent_tpl, []);
172
173        // if the parent has it's own parent, render that
174        if ($this->tpl_state($parent_tpl)->parent !== null)
175            $this->render_parent($parent_tpl);
176    }
177
178    /** @param array<string,mixed> $context */
179    protected function render_with_inherit(Templates\Base $tpl, array $context): void {
180        $this->do_render($tpl, $context);
181        if ($this->tpl_state($tpl)->parent !== null)
182            $this->render_parent($tpl);
183    }
184
185    public function getTemplateAsProxy(string $template): RenderObjects\TemplateRenderProxy {
186        $tpl = $this->resolver->resolve($template);
187        $proxy = new RenderObjects\TemplateRenderProxy(
188            $this,
189            $tpl,
190        );
191        $this->tpl_proxy_map[$proxy->id] = $tpl;
192        return $proxy;
193    }
194
195    public function getTemplateInstanceAsProxyById(int $orig_proxy_id): ?RenderObjects\TemplateRenderProxy {
196        if (!array_key_exists($orig_proxy_id, $this->tpl_proxy_map))
197            return null;
198        $proxy = new RenderObjects\TemplateRenderProxy(
199            $this,
200            $this->tpl_proxy_map[$orig_proxy_id],
201        );
202        $this->tpl_proxy_map[$proxy->id] = $this->tpl_proxy_map[$orig_proxy_id];
203        return $proxy;
204    }
205
206    /** @param array<string,mixed> $context */
207    public function renderProxiedTemplate(RenderObjects\TemplateRenderProxy $proxy, array $context): void {
208        $prev = $this->rendertree->getCurrentNode();
209        $new_node = $this->rendertree->addNode();
210        $this->rendertree->setCurrentNode($new_node);
211        $this->render_with_inherit($this->tpl_proxy_map[$proxy->id], $context);
212        $this->rendertree->setCurrentNode($prev);
213        // if rendering is nested the final buffer node
214        // needs to be shifted back to the parent node
215        if ($this->current_buff !== null) {
216            $prev->appendChildren($new_node->popChild());
217        }
218    }
219
220    public function renderError(Templates\PHPString|Templates\Text|string $error): void {
221        if (is_string($error))
222            $error = new Templates\Text($error);
223        $this->do_render($error, []);
224    }
225
226    public function setParentForTemplate(Templates\Base $tpl, string $parent_template): void {
227        if (isset($this->tpl_state($tpl)->parent))
228            throw new Exceptions\RendererStateException("template already has an associated parent");
229        $new_parent_tpl = $this->resolver->resolve($parent_template);
230        $this->tpl_state($tpl)->parent = $new_parent_tpl;
231        $this->tpl_state($new_parent_tpl)->child = $tpl;
232        $curr = $this->rendertree->getCurrentNode();
233        $new_child = $curr->isLeaf()
234            ? RenderTree\IgnoredNode::withValue($curr->getValue())
235            : RenderTree\IgnoredNode::withChildren(...$curr);
236        $curr->replaceChildren(
237            $new_child,
238        );
239        $this->rendertree->setCurrentNode($new_child);
240        $this->tpl_state($new_parent_tpl)->parent_render_target = $curr;
241        $this->tpl_state($tpl)->child_render_root = $new_child;
242    }
243
244    public function startRenderingBlock(Templates\Base $tpl, string $block_name): void {
245        if ($block_name == '')
246            throw new ValueError("'\$block_name' can not be empty");
247        if ($this->tpl_state($tpl)->parent === null)
248            throw new Exceptions\RendererStateException("can not define a block until a parent template has been set");
249        if ($this->tpl_state($tpl)->current_block !== null)
250            throw new Exceptions\RendererStateException("current block '{$this->tpl_state($tpl)->current_block}' has not been closed");
251        if (isset($this->tpl_state($tpl)->blocks[$block_name]))
252            throw new Exceptions\RendererStateException("can not redefine block '{$block_name}' for template");
253
254        // swap to new IgnoredNode for block
255        assert($this->tpl_state($tpl)->current_block_prev_node === null);
256        $this->tpl_state($tpl)->current_block_prev_node = $this->rendertree->getCurrentNode();
257        $node = $this->rendertree->addNode(RenderTree\IgnoredNode::withValue(null));
258        $this->rendertree->setCurrentNode($node);
259
260        // store current block
261        $this->tpl_state($tpl)->blocks[$block_name] = $node;
262        $this->tpl_state($tpl)->current_block = $block_name;
263
264        // swap to buffer in new node
265        $was_buffering = $this->swap_to_new_buffer();
266        assert($was_buffering == true);
267    }
268
269    public function endRenderingBlock(Templates\Base $tpl): void {
270        if ($this->tpl_state($tpl)->current_block === null)
271            throw new Exceptions\RendererStateException("not currently rendering a block");
272
273        // complete buffer for block
274        $this->complete_buffer();
275
276        // swap back to the node active before the block
277        $prev_node = $this->tpl_state($tpl)->current_block_prev_node;
278        assert($prev_node !== null);
279        $this->rendertree->setCurrentNode($prev_node);
280        $this->tpl_state($tpl)->current_block_prev_node = null;
281
282        // swap to buffer in original node
283        $this->swap_to_new_buffer();
284
285        $this->tpl_state($tpl)->current_block = null;
286    }
287
288    public function renderChildBlock(Templates\Base $tpl, string $block_name): bool {
289        $child_tpl = $this->tpl_state($tpl)->child;
290        if ($child_tpl === null)
291            throw new Exceptions\RendererStateException("template does not have a child set");
292        if (!array_key_exists($block_name, $this->tpl_state($child_tpl)->blocks))
293            return false;
294        $this->complete_buffer();
295        $this->rendertree->addNode(
296            RenderTree\Node::fromNode($this->tpl_state($child_tpl)->blocks[$block_name]),
297        );
298        $this->swap_to_new_buffer();
299        return true;
300    }
301
302    public function renderChildContent(Templates\Base $tpl): void {
303        $child_tpl = $this->tpl_state($tpl)->child;
304        if ($child_tpl === null)
305            throw new Exceptions\RendererStateException("template does not have a child set");
306        $child_root = $this->tpl_state($child_tpl)->child_render_root;
307        assert($child_root !== null);
308        $this->complete_buffer();
309        $this->rendertree->addNode(
310            RenderTree\Node::fromNode($child_root),
311        );
312        $this->swap_to_new_buffer();
313    }
314
315    public function setInsertionPoint(string $insert_name): void {
316        if ($insert_name == '')
317            throw new ValueError("'\$insert_name' can not be empty");
318        if (in_array($insert_name, $this->insert_points_set))
319            throw new Exceptions\RendererStateException("insertion point has already been set");
320
321        $this->complete_buffer();
322
323        if (array_key_exists($insert_name, $this->insert_point_nodes))
324            $existing_node = RenderTree\Node::fromNode($this->insert_point_nodes[$insert_name]);
325
326        $this->insert_point_nodes[$insert_name] = $this->rendertree->addNode($existing_node ?? null);
327        array_push($this->insert_points_set, $insert_name);
328
329        $this->rendertree->addNode();
330        $this->swap_to_new_buffer();
331    }
332
333    public function startAppendingToInsertionPoint(string $insert_name): void {
334        if ($insert_name == '')
335            throw new ValueError("'\$block_name' can not be empty");
336        if ($this->current_insert !== null)
337            throw new Exceptions\RendererStateException("current insert append '{$this->current_insert}' has not been ended");
338
339        // store node before starting block
340        assert($this->current_insert_prev_node === null);
341        $this->current_insert_prev_node = $this->rendertree->getCurrentNode();
342
343        // if the insertion point node doesn't exist, create it as ignored until it is set later
344        if (!array_key_exists($insert_name, $this->insert_point_nodes)) {
345            $this->insert_point_nodes[$insert_name] = $this->rendertree->addNode(
346                RenderTree\IgnoredNode::withValue(null)
347            );
348        }
349
350        // switch to the insertion point node
351        $this->rendertree->setCurrentNode($this->insert_point_nodes[$insert_name]);
352        $this->current_insert = $insert_name;
353
354        // swap to a buffer in the node
355        $was_buffering = $this->swap_to_new_buffer();
356        assert($was_buffering == true);
357    }
358
359    public function stopAppendingToInsertionPoint(): void {
360        if ($this->current_insert === null)
361            throw new Exceptions\RendererStateException("not currently appending to an insertion point");
362
363        // complete buffer for block
364        $this->complete_buffer();
365
366        // swap back to the node active before the block
367        $prev_node = $this->current_insert_prev_node;
368        assert($prev_node !== null);
369        $this->rendertree->setCurrentNode($prev_node);
370        $this->current_insert_prev_node = null;
371
372        // swap to buffer in original node
373        $this->swap_to_new_buffer();
374
375        $this->current_insert = null;
376    }
377}