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