Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
56 / 56
100.00% covered (success)
100.00%
12 / 12
CRAP
100.00% covered (success)
100.00%
1 / 1
Sections
100.00% covered (success)
100.00%
56 / 56
100.00% covered (success)
100.00%
12 / 12
25
100.00% covered (success)
100.00%
1 / 1
 begin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 append
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prepend
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 end
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 get
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOr
100.00% covered (success)
100.00%
6 / 6
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
 checkpoint
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 assertClosed
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 open
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 at
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 name
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 Celemas\Boiler;
6
7use Celemas\Boiler\Exception\LogicException;
8
9/** @internal */
10final class Sections
11{
12    /** @var array<string, Section> */
13    private array $sections = [];
14    /** @var list<string> */
15    private array $capture = [];
16    private SectionMode $sectionMode = SectionMode::Closed;
17    private ?int $captureLevel = null;
18    private ?Location $captureLocation = null;
19
20    public function begin(string $name, ?Location $location = null): void
21    {
22        $this->open($name, SectionMode::Assign, $location);
23    }
24
25    public function append(string $name, ?Location $location = null): void
26    {
27        $this->open($name, SectionMode::Append, $location);
28    }
29
30    public function prepend(string $name, ?Location $location = null): void
31    {
32        $this->open($name, SectionMode::Prepend, $location);
33    }
34
35    public function end(): void
36    {
37        if ($this->sectionMode === SectionMode::Closed) {
38            throw new LogicException('No section started');
39        }
40
41        $name = $this->name();
42
43        if ($this->captureLevel !== ob_get_level()) {
44            throw new LogicException("Section capture block `{$name}` is not the active output buffer");
45        }
46
47        $content = (string) ob_get_clean();
48        array_pop($this->capture);
49
50        $this->sections[$name] = match ($this->sectionMode) {
51            SectionMode::Assign => new Section($content),
52            SectionMode::Append => ($this->sections[$name] ?? new Section(''))->append($content),
53            SectionMode::Prepend => ($this->sections[$name] ?? new Section(''))->prepend($content),
54        };
55
56        $this->sectionMode = SectionMode::Closed;
57        $this->captureLevel = null;
58        $this->captureLocation = null;
59    }
60
61    public function get(string $name): string
62    {
63        return $this->sections[$name]->get();
64    }
65
66    public function getOr(string $name, string $default): string
67    {
68        $section = $this->sections[$name] ?? null;
69
70        if ($section === null) {
71            return $default;
72        }
73
74        if ($section->empty()) {
75            $section->setValue($default);
76        }
77
78        return $section->get();
79    }
80
81    public function has(string $name): bool
82    {
83        return isset($this->sections[$name]);
84    }
85
86    /** @return array{mode: SectionMode, name: string|null, level: int|null, location: Location|null} */
87    public function checkpoint(): array
88    {
89        return [
90            'mode' => $this->sectionMode,
91            'name' => $this->sectionMode === SectionMode::Closed ? null : $this->name(),
92            'level' => $this->captureLevel,
93            'location' => $this->captureLocation,
94        ];
95    }
96
97    /** @param array{mode: SectionMode, name: string|null, level: int|null, location: Location|null} $checkpoint */
98    public function assertClosed(array $checkpoint): void
99    {
100        if ($this->checkpoint() === $checkpoint) {
101            return;
102        }
103
104        if ($this->sectionMode === SectionMode::Closed) {
105            $name = $checkpoint['name'] ?? 'unknown';
106            $location = $checkpoint['location'] ?? null;
107
108            throw new LogicException(
109                "Section capture block `{$name}` was closed unexpectedly" . $this->at($location),
110                location: $location,
111            );
112        }
113
114        throw new LogicException(
115            "Unclosed section capture block `{$this->name()}`" . $this->at($this->captureLocation),
116            location: $this->captureLocation,
117        );
118    }
119
120    private function open(string $name, SectionMode $mode, ?Location $location): void
121    {
122        if ($this->sectionMode !== SectionMode::Closed) {
123            throw new LogicException('Nested sections are not allowed');
124        }
125
126        $this->sectionMode = $mode;
127        $this->capture[] = $name;
128        $this->captureLocation = $location;
129        ob_start();
130        $this->captureLevel = ob_get_level();
131    }
132
133    private function at(?Location $location): string
134    {
135        return $location === null ? '' : " at {$location}";
136    }
137
138    private function name(): string
139    {
140        $last = array_key_last($this->capture);
141
142        if ($last === null) {
143            // This check is mostly necessary to satisfy Psalm.
144            //
145            // It serves as a defensive guard against corrupted internal state,
146            // which should never occur when Sections is used internally;
147            // public API calls cannot reach this branch.
148            // @codeCoverageIgnoreStart
149            throw new LogicException('No section started');
150
151            // @codeCoverageIgnoreEnd
152        }
153
154        return $this->capture[$last];
155    }
156}