Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.44% covered (success)
94.44%
51 / 54
71.43% covered (warning)
71.43%
5 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
OutputFormatLogger
94.44% covered (success)
94.44%
51 / 54
71.43% covered (warning)
71.43%
5 / 7
26.12
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 log
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 emitGithubActionAnnotation
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
6.40
 formatMessage
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 isJsonOutput
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 isPrettyJsonOutput
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 interpolate
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
9.02
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;
21
22use Stringable;
23use DateTimeInterface;
24use FastForward\DevTools\Environment\RuntimeEnvironmentInterface;
25use FastForward\DevTools\Console\Logger\Processor\ContextProcessorInterface;
26use FastForward\DevTools\Console\Output\GithubActionOutput;
27use Psr\Clock\ClockInterface;
28use Psr\Log\LoggerInterface;
29use Psr\Log\LoggerTrait;
30use Psr\Log\LogLevel;
31use Symfony\Component\Console\Input\ArgvInput;
32use Symfony\Component\Console\Output\ConsoleOutputInterface;
33use function Safe\json_encode;
34
35/**
36 * Formats PSR-3 log messages for the DevTools console runtime.
37 *
38 * The logger routes error-related levels to stderr, expands command context
39 * through the configured processor, and can switch between tagged text output
40 * and structured JSON output depending on CLI flags or detected agent
41 * execution.
42 */
43  class OutputFormatLogger implements LoggerInterface
44{
45    use LoggerTrait;
46
47    /**
48     * Lists the log levels that MUST be written to the error output stream.
49     *
50     * @var list<string>
51     */
52    private const array ERROR_LEVELS = [LogLevel::ERROR, LogLevel::CRITICAL, LogLevel::ALERT, LogLevel::EMERGENCY];
53
54    /**
55     * Creates a new console logger instance.
56     *
57     * @param ArgvInput $input the CLI input instance used to inspect runtime options
58     * @param ConsoleOutputInterface $output the console output instance used for writing log messages
59     * @param ClockInterface $clock provides timestamps for rendered log entries
60     * @param RuntimeEnvironmentInterface $runtimeEnvironment resolves runtime-specific output behavior
61     * @param ContextProcessorInterface $contextProcessor expands command input and output metadata
62     * @param GithubActionOutput $githubActionOutput emits GitHub Actions annotations when supported
63     */
64    public function __construct(
65        private ArgvInput $input,
66        private ConsoleOutputInterface $output,
67        private ClockInterface $clock,
68        private RuntimeEnvironmentInterface $runtimeEnvironment,
69        private ContextProcessorInterface $contextProcessor,
70        private GithubActionOutput $githubActionOutput,
71    ) {}
72
73    /**
74     * Logs a message at the specified level.
75     *
76     * This method MUST format the message before writing it to the console.
77     * Error-related levels SHALL be directed to the error output stream.
78     * All other levels SHALL be directed to the standard output stream.
79     *
80     * @param mixed $level the log level identifier
81     * @param string|Stringable $message the log message, optionally containing PSR-3 placeholders
82     * @param array<string, mixed> $context context data used for placeholder interpolation and JSON output
83     */
84    public function log($level, $message, array $context = []): void
85    {
86        $context = $this->contextProcessor->process($context);
87        $formattedMessage = $this->formatMessage((string) $level, (string) $message, $context);
88        $output = $this->output;
89
90        if (\in_array($level, self::ERROR_LEVELS, true)) {
91            $output = $this->output->getErrorOutput();
92        }
93
94        $this->emitGithubActionAnnotation((string) $level, (string) $message, $context);
95        $output->writeln($formattedMessage);
96    }
97
98    /**
99     * Emits GitHub Actions annotations for supported error levels.
100     *
101     * @param string $level the normalized log level
102     * @param string $message the original message template
103     * @param array<string, mixed> $context the processed log context
104     *
105     * @return void
106     */
107    private function emitGithubActionAnnotation(string $level, string $message, array $context): void
108    {
109        if (! \in_array($level, self::ERROR_LEVELS, true)) {
110            return;
111        }
112
113        $file = isset($context['file']) && \is_string($context['file'])
114            ? $context['file']
115            : null;
116        $line = isset($context['line']) && \is_int($context['line'])
117            ? $context['line']
118            : null;
119
120        $this->githubActionOutput->error($this->interpolate($message, $context), $file, $line);
121    }
122
123    /**
124     * Formats a log entry for console output.
125     *
126     * When JSON output is enabled, the logger MUST return a JSON-encoded
127     * representation of the message, level, and context. Otherwise, the
128     * message SHALL be interpolated and wrapped with a console tag that uses
129     * the log level as both the style name and visual prefix.
130     *
131     * @param string $level the normalized log level
132     * @param string $message the message template to format
133     * @param array<string, mixed> $context context values used during formatting
134     *
135     * @return string the formatted message ready to be written to the console
136     */
137    private function formatMessage(string $level, string $message, array $context): string
138    {
139        $timestamp = $this->clock->now()
140            ->format(DateTimeInterface::RFC3339);
141
142        if ($this->isJsonOutput()) {
143            $flags = \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES;
144
145            if ($this->isPrettyJsonOutput()) {
146                $flags |= \JSON_PRETTY_PRINT;
147            }
148
149            return json_encode([
150                'message' => $message,
151                'level' => $level,
152                'context' => $context,
153                'timestamp' => $timestamp,
154            ], $flags);
155        }
156
157        $message = $this->interpolate($message, $context);
158
159        return \sprintf('<%s>%s [%s] %s</%s>', $level, $timestamp, strtoupper($level), $message, $level);
160    }
161
162    /**
163     * Determines whether structured JSON output has been requested.
164     *
165     * The "--json" and "--pretty-json" options MAY be provided by the caller.
166     * When either is present, this method SHALL return true. Otherwise,
167     * detected agent environments SHOULD default to JSON output as well.
168     *
169     * @return bool true when JSON output is enabled; otherwise, false
170     */
171    private function isJsonOutput(): bool
172    {
173        if ($this->isPrettyJsonOutput()) {
174            return true;
175        }
176
177        if ($this->input->hasParameterOption('--json', true)) {
178            return true;
179        }
180
181        return $this->runtimeEnvironment->isAgentPresent() && ! $this->runtimeEnvironment->isComposerTestRun();
182    }
183
184    /**
185     * Determines whether pretty-printed JSON output has been requested.
186     */
187    private function isPrettyJsonOutput(): bool
188    {
189        return $this->input->hasParameterOption('--pretty-json', true);
190    }
191
192    /**
193     * Interpolates context values into PSR-3-style message placeholders.
194     *
195     * Placeholders in the form "{key}" SHALL be replaced when a matching key
196     * exists in the context array and the associated value can be represented
197     * safely as text. Scalar values, null, and stringable objects MUST be
198     * inserted directly. DateTime values SHALL be formatted using RFC3339.
199     * Objects and arrays MUST be converted into descriptive string
200     * representations.
201     *
202     * @param string $message the message containing optional placeholders
203     * @param array<string, mixed> $context the context map used for replacement values
204     *
205     * @return string the interpolated message
206     *
207     * @author PHP Framework Interoperability Group
208     */
209    private function interpolate(string $message, array $context): string
210    {
211        if (! str_contains($message, '{')) {
212            return $message;
213        }
214
215        $replacements = [];
216
217        foreach ($context as $key => $val) {
218            if (null === $val || \is_scalar($val) || $val instanceof Stringable) {
219                $replacements[\sprintf('{%s}', $key)] = $val;
220            } elseif ($val instanceof DateTimeInterface) {
221                $replacements[\sprintf('{%s}', $key)] = $val->format(DateTimeInterface::RFC3339);
222            } elseif (\is_object($val)) {
223                $replacements[\sprintf('{%s}', $key)] = '[object ' . $val::class . ']';
224            } elseif (\is_array($val)) {
225                $replacements[\sprintf('{%s}', $key)] = '[' . json_encode(
226                    $val,
227                    \JSON_UNESCAPED_UNICODE | \JSON_UNESCAPED_SLASHES
228                ) . ']';
229            } else {
230                $replacements[\sprintf('{%s}', $key)] = '[' . \gettype($val) . ']';
231            }
232        }
233
234        return strtr($message, $replacements);
235    }
236}