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