Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
72 / 72
100.00% covered (success)
100.00%
13 / 13
CRAP
100.00% covered (success)
100.00%
1 / 1
Sections
100.00% covered (success)
100.00%
72 / 72
100.00% covered (success)
100.00%
13 / 13
27
100.00% covered (success)
100.00%
1 / 1
 capture
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 append
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 prepend
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 rewrite
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 writing
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 nest
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 enterLayout
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOr
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 has
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 assign
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 write
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 assertWritable
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 position
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Boiler;
6
7use Celema\Boiler\Exception\LogicException;
8use Closure;
9
10/**
11 * The sections captured during a render, shared by the rendered template,
12 * its includes, and its layouts.
13 *
14 * @internal
15 */
16final class Sections
17{
18    /** @var array<string, Section> */
19    private array $sections = [];
20
21    /**
22     * The position of the include that is rendering, as the layout level and
23     * call number of each enclosing include; empty for the rendered template.
24     *
25     * @var list<int>
26     */
27    private array $include = [];
28
29    /**
30     * How many layouts deep the rendering template is, counted on from the
31     * level of the template that included it.
32     */
33    private int $level = 0;
34
35    /** Numbers appends and includes in call order. */
36    private int $calls = 0;
37
38    /**
39     * Where and at which position the main content of each section was
40     * captured.
41     *
42     * @var array<string, array{Location, list<int>}>
43     */
44    private array $captured = [];
45
46    /**
47     * How many discarded defaults are open. While one is, nothing reaches the
48     * sections, so a default that is not used adds nothing, not even through
49     * the templates it includes.
50     */
51    private int $muted = 0;
52
53    /**
54     * The sections that open `section()`, `append()`, and `prepend()` blocks
55     * write, innermost last. Such a section cannot be printed before they
56     * close, as its content is not complete yet.
57     *
58     * @var list<string>
59     */
60    private array $writing = [];
61
62    /**
63     * The sections that open `rewrite()` blocks rewrite, innermost last. Such
64     * a section cannot be written before they close, as the rewrite replaces
65     * what it read.
66     *
67     * @var list<string>
68     */
69    private array $rewriting = [];
70
71    /**
72     * Opens a capture of the main content and returns what closes it with the
73     * captured output and where `section()` opened it.
74     *
75     * A layout's capture is a default: a template it wraps renders first, so
76     * when one of those captured the section already, the layout's capture is
77     * discarded. Any other second capture fails instead of replacing the
78     * first.
79     *
80     * @return Closure(string, Location): void
81     */
82    public function capture(string $name): Closure
83    {
84        if ($this->muted > 0) {
85            return $this->write($name);
86        }
87
88        $position = $this->position();
89        $captured = $this->captured[$name] ?? null;
90
91        if ($captured !== null && Section::wraps($position, $captured[1])) {
92            $close = $this->write($name, function (): void {
93                $this->muted--;
94            });
95            $this->muted++;
96
97            return $close;
98        }
99
100        return $this->write($name, function (string $content, Location $location) use ($name, $position): void {
101            $this->assign($name, $content, $location, $position);
102        });
103    }
104
105    /** @return Closure(string, Location): void */
106    public function append(string $name): Closure
107    {
108        return $this->write($name, function (string $content) use ($name): void {
109            if ($this->muted === 0) {
110                ($this->sections[$name] ??= new Section())->append($content, $this->position());
111            }
112        });
113    }
114
115    /** @return Closure(string, Location): void */
116    public function prepend(string $name): Closure
117    {
118        return $this->write($name, function (string $content) use ($name): void {
119            if ($this->muted === 0) {
120                ($this->sections[$name] ??= new Section())->prepend($content);
121            }
122        });
123    }
124
125    /**
126     * Opens a rewrite, inside which `yield()` returns the section's content so
127     * far, and returns what closes it. The output replaces that content,
128     * appended and prepended content included, and counts as a capture at the
129     * rewriting template. When both are blank, the section stays as it was,
130     * so a default passed to `yield()` still applies.
131     *
132     * @return Closure(string, Location): void
133     */
134    public function rewrite(string $name): Closure
135    {
136        $this->assertWritable($name);
137        $this->rewriting[] = $name;
138
139        return function (string $content, Location $location) use ($name): void {
140            array_pop($this->rewriting);
141            $current = isset($this->sections[$name]) ? $this->sections[$name]->get() : '';
142
143            if ($this->muted > 0 || trim($content) === '' && trim($current) === '') {
144                return;
145            }
146
147            $section = new Section();
148            $section->setValue($content);
149            $this->sections[$name] = $section;
150            $this->captured[$name] = [$location, $this->position()];
151        };
152    }
153
154    /** Whether an open `section()`, `append()`, or `prepend()` block writes the section. */
155    public function writing(string $name): bool
156    {
157        return in_array($name, $this->writing, true);
158    }
159
160    /**
161     * Renders an included template. Its appends and those of its own layouts
162     * stay together at the place of the include among the caller's.
163     *
164     * @param Closure(): string $render
165     */
166    public function nest(Closure $render): string
167    {
168        $include = $this->include;
169        $level = $this->level;
170        $muted = $this->muted;
171        $writing = $this->writing;
172        $rewriting = $this->rewriting;
173        $this->include = [...$include, $level, ++$this->calls];
174
175        try {
176            return $render();
177        } finally {
178            $this->include = $include;
179            $this->level = $level;
180            // A failed include that the caller catches may leave blocks open.
181            $this->muted = $muted;
182            $this->writing = $writing;
183            $this->rewriting = $rewriting;
184        }
185    }
186
187    /** Moves on to the next layout of the rendering template. */
188    public function enterLayout(): void
189    {
190        $this->level++;
191    }
192
193    /**
194     * The default stands in for main content that was never captured. A
195     * closure runs only then, and before the additions are collected, so
196     * those it makes to this section count.
197     *
198     * @param string|Closure(): string $default
199     */
200    public function getOr(string $name, string|Closure $default): string
201    {
202        if (isset($this->captured[$name])) {
203            return $this->sections[$name]->get();
204        }
205
206        $default = is_string($default) ? $default : $default();
207
208        return ($this->sections[$name] ?? null)?->get($default) ?? $default;
209    }
210
211    public function has(string $name): bool
212    {
213        return isset($this->sections[$name]);
214    }
215
216    /**
217     * Sets only the main content; prepended and appended content stays.
218     *
219     * @param list<int> $position
220     */
221    private function assign(string $name, string $content, Location $location, array $position): void
222    {
223        if (isset($this->captured[$name])) {
224            throw new LogicException(
225                "Section `{$name}` was already captured at {$this->captured[$name][0]}; "
226                    . 'add to it with append() or prepend(), or capture defaults in a layout',
227                location: $location,
228            );
229        }
230
231        $this->captured[$name] = [$location, $position];
232        ($this->sections[$name] ??= new Section())->setValue($content);
233    }
234
235    /**
236     * Registers an open block that writes the section and returns what closes
237     * it, which runs $close once the block is no longer open.
238     *
239     * @param ?Closure(string, Location): void $close
240     *
241     * @return Closure(string, Location): void
242     */
243    private function write(string $name, ?Closure $close = null): Closure
244    {
245        $this->assertWritable($name);
246        $this->writing[] = $name;
247
248        return function (string $content, Location $location) use ($close): void {
249            array_pop($this->writing);
250
251            if ($close !== null) {
252                $close($content, $location);
253            }
254        };
255    }
256
257    private function assertWritable(string $name): void
258    {
259        if (in_array($name, $this->rewriting, true)) {
260            throw new LogicException(
261                "Section `{$name}` is being rewritten; add to it before the rewrite() block or print the content inside it",
262            );
263        }
264    }
265
266    /** @return list<int> */
267    private function position(): array
268    {
269        return [...$this->include, $this->level, ++$this->calls];
270    }
271}