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    private function extractBufferedOutput(OutputInterface $output): mixed
73    {
74        if (! $output instanceof BufferedOutput) {
75            return null;
76        }
77
78        $content = $output->fetch();
79
80        return $this->decodeStructuredOutput($content);
81    }
82
83    /**
84     * Decodes a buffered output string when it contains JSON content.
85     *
86     * @param string $content the buffered output contents
87     *
88     * @return mixed the decoded JSON payload or the original string
89     */
90    private function decodeStructuredOutput(string $content): mixed
91    {
92        $trimmedContent = trim($content);
93
94        if ('' === $trimmedContent) {
95            return $content;
96        }
97
98        try {
99            return $this->normalizeStructuredPayload(json_decode($trimmedContent, true));
100        } catch (JsonException) {
101        }
102
103        $decodedDocuments = $this->decodeJsonDocumentStream($trimmedContent);
104
105        if (null !== $decodedDocuments) {
106            return $decodedDocuments;
107        }
108
109        $decodedStructuredOutput = $this->decodeStructuredOutputAfterTextPreamble($content);
110
111        if (null !== $decodedStructuredOutput) {
112            return $decodedStructuredOutput;
113        }
114
115        return $content;
116    }
117
118    /**
119     * Decodes structured output that is preceded by plain-text warnings or banners.
120     *
121     * Some tooling emits advisory text before a valid JSON payload even when a
122     * machine-readable format is requested. When the suffix starting at the
123     * first valid JSON token is fully decodable, the textual preamble SHALL be
124     * ignored so parent command envelopes remain parseable.
125     *
126     * @param string $content the buffered output contents
127     *
128     * @return mixed the decoded JSON payload when a valid structured suffix exists
129     */
130    private function decodeStructuredOutputAfterTextPreamble(string $content): mixed
131    {
132        $offset = 0;
133
134        while (null !== ($offset = $this->findNextJsonDocumentOffset($content, $offset))) {
135            $structuredSuffix = trim(substr($content, $offset));
136
137            if ('' === $structuredSuffix) {
138                return null;
139            }
140
141            try {
142                return $this->normalizeStructuredPayload(json_decode($structuredSuffix, true));
143            } catch (JsonException) {
144            }
145
146            $decodedDocuments = $this->decodeJsonDocumentStream($structuredSuffix);
147
148            if (null !== $decodedDocuments) {
149                return $decodedDocuments;
150            }
151
152            ++$offset;
153        }
154
155        return null;
156    }
157
158    /**
159     * Decodes a stream that contains multiple JSON documents separated by whitespace.
160     *
161     * @param string $content the buffered output contents
162     *
163     * @return ?list<mixed> the decoded JSON documents when the stream is valid
164     */
165    private function decodeJsonDocumentStream(string $content): ?array
166    {
167        $decodedDocuments = [];
168        $offset = 0;
169        $length = \strlen($content);
170
171        while ($offset < $length) {
172            while ($offset < $length && ctype_space($content[$offset])) {
173                ++$offset;
174            }
175
176            if ($offset >= $length) {
177                break;
178            }
179
180            $document = $this->consumeJsonDocument($content, $offset);
181
182            if (null === $document) {
183                return null;
184            }
185
186            try {
187                $decodedDocuments[] = $this->normalizeStructuredPayload(json_decode($document, true));
188            } catch (JsonException) {
189                return null;
190            }
191        }
192
193        return \count($decodedDocuments) > 1 ? $decodedDocuments : null;
194    }
195
196    /**
197     * Consumes a single top-level JSON document from a multi-document stream.
198     *
199     * @param string $content the buffered output contents
200     * @param int $offset the current stream offset, advanced past the document on success
201     *
202     * @return ?string the extracted JSON document
203     */
204    private function consumeJsonDocument(string $content, int &$offset): ?string
205    {
206        $length = \strlen($content);
207        $start = $offset;
208        $openingToken = $content[$offset];
209
210        if ('{' !== $openingToken && '[' !== $openingToken) {
211            return null;
212        }
213
214        $depth = 0;
215        $inString = false;
216        $escaping = false;
217
218        for (; $offset < $length; ++$offset) {
219            $character = $content[$offset];
220
221            if ($inString) {
222                if ($escaping) {
223                    $escaping = false;
224
225                    continue;
226                }
227
228                if ('\\' === $character) {
229                    $escaping = true;
230
231                    continue;
232                }
233
234                if ('"' === $character) {
235                    $inString = false;
236                }
237
238                continue;
239            }
240
241            if ('"' === $character) {
242                $inString = true;
243
244                continue;
245            }
246
247            if ('{' === $character || '[' === $character) {
248                ++$depth;
249
250                continue;
251            }
252
253            if ('}' === $character || ']' === $character) {
254                --$depth;
255
256                if (0 === $depth) {
257                    ++$offset;
258
259                    return substr($content, $start, $offset - $start);
260                }
261            }
262        }
263
264        return null;
265    }
266
267    /**
268     * Finds the offset of the next possible JSON document opening token.
269     *
270     * @param string $content the buffered output contents
271     * @param int $offset the offset from which scanning SHALL start
272     *
273     * @return ?int the offset of the next "{" or "[" token
274     */
275    private function findNextJsonDocumentOffset(string $content, int $offset): ?int
276    {
277        $length = \strlen($content);
278
279        for (; $offset < $length; ++$offset) {
280            if ('{' === $content[$offset] || '[' === $content[$offset]) {
281                return $offset;
282            }
283        }
284
285        return null;
286    }
287
288    /**
289     * Normalizes decoded structured payloads produced by wrapped tooling.
290     *
291     * @param mixed $payload the decoded payload
292     *
293     * @return mixed the normalized payload
294     */
295    private function normalizeStructuredPayload(mixed $payload): mixed
296    {
297        if (! \is_array($payload)) {
298            return $payload;
299        }
300
301        if (! isset($payload['totals']) || ! \is_array($payload['totals'])) {
302            return $payload;
303        }
304
305        $changedFilesTotal = $payload['totals']['changed_files'] ?? null;
306
307        if (! \is_int($changedFilesTotal)) {
308            return $payload;
309        }
310
311        if (0 === $changedFilesTotal) {
312            $payload['changed_files'] = [];
313
314            return $payload;
315        }
316
317        if (! isset($payload['file_diffs']) || ! \is_array($payload['file_diffs'])) {
318            return $payload;
319        }
320
321        $changedFiles = [];
322
323        foreach ($payload['file_diffs'] as $fileDiff) {
324            if (! \is_array($fileDiff)) {
325                continue;
326            }
327
328            if (! isset($fileDiff['file'])) {
329                continue;
330            }
331
332            if (! \is_string($fileDiff['file'])) {
333                continue;
334            }
335
336            $changedFiles[$fileDiff['file']] = $fileDiff['file'];
337        }
338
339        $payload['changed_files'] = array_values($changedFiles);
340
341        return $payload;
342    }
343}