Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.35% covered (warning)
84.35%
97 / 115
25.00% covered (danger)
25.00%
2 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
CommandOutputProcessor
84.35% covered (warning)
84.35%
97 / 115
25.00% covered (danger)
25.00%
2 / 8
69.46
0.00% covered (danger)
0.00%
0 / 1
 process
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
7.03
 extractBufferedOutput
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 decodeStructuredOutput
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 decodeStructuredOutputAfterTextPreamble
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
5.12
 decodeJsonDocumentStream
80.00% covered (warning)
80.00%
12 / 15
0.00% covered (danger)
0.00%
0 / 1
8.51
 consumeJsonDocument
84.38% covered (warning)
84.38%
27 / 32
0.00% covered (danger)
0.00%
0 / 1
14.75
 findNextJsonDocumentOffset
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 normalizeStructuredPayload
73.91% covered (warning)
73.91%
17 / 23
0.00% covered (danger)
0.00%
0 / 1
14.56
1<?php
2
3declare(strict_types=1);
4
5/**
6 * Fast Forward Development Tools for PHP projects.
7 *
8 * This file is part of fast-forward/dev-tools project.
9 *
10 * @author   Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
11 * @license  https://opensource.org/licenses/MIT MIT License
12 *
13 * @see      https://github.com/php-fast-forward/
14 * @see      https://github.com/php-fast-forward/dev-tools
15 * @see      https://github.com/php-fast-forward/dev-tools/issues
16 * @see      https://php-fast-forward.github.io/dev-tools/
17 * @see      https://datatracker.ietf.org/doc/html/rfc2119
18 */
19
20namespace FastForward\DevTools\Console\Logger\Processor;
21
22use JsonException;
23use Symfony\Component\Console\Output\BufferedOutput;
24use Symfony\Component\Console\Output\ConsoleOutputInterface;
25use Symfony\Component\Console\Output\OutputInterface;
26
27use function Safe\json_decode;
28
29/**
30 * Converts buffered command output objects into serializable context entries.
31 *
32 * JSON payloads are decoded eagerly so parent command envelopes can expose
33 * nested structured output without re-encoding it as an escaped string.
34 */
35 class CommandOutputProcessor implements ContextProcessorInterface
36{
37    /**
38     * @param array<string, mixed> $context
39     *
40     * @return array<string, mixed>
41     */
42    public function process(array $context): array
43    {
44        foreach ($context as $key => $value) {
45            if (! $value instanceof OutputInterface) {
46                continue;
47            }
48
49            unset($context[$key]);
50
51            $outputContent = $this->extractBufferedOutput($value);
52
53            if (null !== $outputContent) {
54                $context[$key] = $outputContent;
55            }
56
57            if ($value instanceof ConsoleOutputInterface) {
58                $errorOutput = $this->extractBufferedOutput($value->getErrorOutput());
59
60                if (null !== $errorOutput && ! \array_key_exists('error_output', $context)) {
61                    $context['error_output'] = $errorOutput;
62                }
63            }
64        }
65
66        return $context;
67    }
68
69    /**
70     * @param OutputInterface $output
71     *
72     * @return mixed
73     */
74    private function extractBufferedOutput(OutputInterface $output): mixed
75    {
76        if (! $output instanceof BufferedOutput) {
77            return null;
78        }
79
80        $content = $output->fetch();
81
82        return $this->decodeStructuredOutput($content);
83    }
84
85    /**
86     * Decodes a buffered output string when it contains JSON content.
87     *
88     * @param string $content the buffered output contents
89     *
90     * @return mixed the decoded JSON payload or the original string
91     */
92    private function decodeStructuredOutput(string $content): mixed
93    {
94        $trimmedContent = trim($content);
95
96        if ('' === $trimmedContent) {
97            return $content;
98        }
99
100        try {
101            return $this->normalizeStructuredPayload(json_decode($trimmedContent, true));
102        } catch (JsonException) {
103        }
104
105        $decodedDocuments = $this->decodeJsonDocumentStream($trimmedContent);
106
107        if (null !== $decodedDocuments) {
108            return $decodedDocuments;
109        }
110
111        $decodedStructuredOutput = $this->decodeStructuredOutputAfterTextPreamble($content);
112
113        if (null !== $decodedStructuredOutput) {
114            return $decodedStructuredOutput;
115        }
116
117        return $content;
118    }
119
120    /**
121     * Decodes structured output that is preceded by plain-text warnings or banners.
122     *
123     * Some tooling emits advisory text before a valid JSON payload even when a
124     * machine-readable format is requested. When the suffix starting at the
125     * first valid JSON token is fully decodable, the textual preamble SHALL be
126     * ignored so parent command envelopes remain parseable.
127     *
128     * @param string $content the buffered output contents
129     *
130     * @return mixed the decoded JSON payload when a valid structured suffix exists
131     */
132    private function decodeStructuredOutputAfterTextPreamble(string $content): mixed
133    {
134        $offset = 0;
135
136        while (null !== ($offset = $this->findNextJsonDocumentOffset($content, $offset))) {
137            $structuredSuffix = trim(substr($content, $offset));
138
139            if ('' === $structuredSuffix) {
140                return null;
141            }
142
143            try {
144                return $this->normalizeStructuredPayload(json_decode($structuredSuffix, true));
145            } catch (JsonException) {
146            }
147
148            $decodedDocuments = $this->decodeJsonDocumentStream($structuredSuffix);
149
150            if (null !== $decodedDocuments) {
151                return $decodedDocuments;
152            }
153
154            ++$offset;
155        }
156
157        return null;
158    }
159
160    /**
161     * Decodes a stream that contains multiple JSON documents separated by whitespace.
162     *
163     * @param string $content the buffered output contents
164     *
165     * @return ?list<mixed> the decoded JSON documents when the stream is valid
166     */
167    private function decodeJsonDocumentStream(string $content): ?array
168    {
169        $decodedDocuments = [];
170        $offset = 0;
171        $length = \strlen($content);
172
173        while ($offset < $length) {
174            while ($offset < $length && ctype_space($content[$offset])) {
175                ++$offset;
176            }
177
178            if ($offset >= $length) {
179                break;
180            }
181
182            $document = $this->consumeJsonDocument($content, $offset);
183
184            if (null === $document) {
185                return null;
186            }
187
188            try {
189                $decodedDocuments[] = $this->normalizeStructuredPayload(json_decode($document, true));
190            } catch (JsonException) {
191                return null;
192            }
193        }
194
195        return \count($decodedDocuments) > 1 ? $decodedDocuments : null;
196    }
197
198    /**
199     * Consumes a single top-level JSON document from a multi-document stream.
200     *
201     * @param string $content the buffered output contents
202     * @param int $offset the current stream offset, advanced past the document on success
203     *
204     * @return ?string the extracted JSON document
205     */
206    private function consumeJsonDocument(string $content, int &$offset): ?string
207    {
208        $length = \strlen($content);
209        $start = $offset;
210        $openingToken = $content[$offset];
211
212        if ('{' !== $openingToken && '[' !== $openingToken) {
213            return null;
214        }
215
216        $depth = 0;
217        $inString = false;
218        $escaping = false;
219
220        for (; $offset < $length; ++$offset) {
221            $character = $content[$offset];
222
223            if ($inString) {
224                if ($escaping) {
225                    $escaping = false;
226
227                    continue;
228                }
229
230                if ('\\' === $character) {
231                    $escaping = true;
232
233                    continue;
234                }
235
236                if ('"' === $character) {
237                    $inString = false;
238                }
239
240                continue;
241            }
242
243            if ('"' === $character) {
244                $inString = true;
245
246                continue;
247            }
248
249            if ('{' === $character || '[' === $character) {
250                ++$depth;
251
252                continue;
253            }
254
255            if ('}' === $character || ']' === $character) {
256                --$depth;
257
258                if (0 === $depth) {
259                    ++$offset;
260
261                    return substr($content, $start, $offset - $start);
262                }
263            }
264        }
265
266        return null;
267    }
268
269    /**
270     * Finds the offset of the next possible JSON document opening token.
271     *
272     * @param string $content the buffered output contents
273     * @param int $offset the offset from which scanning SHALL start
274     *
275     * @return ?int the offset of the next "{" or "[" token
276     */
277    private function findNextJsonDocumentOffset(string $content, int $offset): ?int
278    {
279        $length = \strlen($content);
280
281        for (; $offset < $length; ++$offset) {
282            if ('{' === $content[$offset] || '[' === $content[$offset]) {
283                return $offset;
284            }
285        }
286
287        return null;
288    }
289
290    /**
291     * Normalizes decoded structured payloads produced by wrapped tooling.
292     *
293     * @param mixed $payload the decoded payload
294     *
295     * @return mixed the normalized payload
296     */
297    private function normalizeStructuredPayload(mixed $payload): mixed
298    {
299        if (! \is_array($payload)) {
300            return $payload;
301        }
302
303        if (! isset($payload['totals']) || ! \is_array($payload['totals'])) {
304            return $payload;
305        }
306
307        $changedFilesTotal = $payload['totals']['changed_files'] ?? null;
308
309        if (! \is_int($changedFilesTotal)) {
310            return $payload;
311        }
312
313        if (0 === $changedFilesTotal) {
314            $payload['changed_files'] = [];
315
316            return $payload;
317        }
318
319        if (! isset($payload['file_diffs']) || ! \is_array($payload['file_diffs'])) {
320            return $payload;
321        }
322
323        $changedFiles = [];
324
325        foreach ($payload['file_diffs'] as $fileDiff) {
326            if (! \is_array($fileDiff)) {
327                continue;
328            }
329
330            if (! isset($fileDiff['file'])) {
331                continue;
332            }
333
334            if (! \is_string($fileDiff['file'])) {
335                continue;
336            }
337
338            $changedFiles[$fileDiff['file']] = $fileDiff['file'];
339        }
340
341        $payload['changed_files'] = array_values($changedFiles);
342
343        return $payload;
344    }
345}