Skip to content

Commit 581eef9

Browse files
committed
✨ New MessageCollector utility class
This _internal-use only_ `PHP_CodeSniffer\Util\MessageCollector` class can be used to collect various type of "error" messages, including notices, warnings and deprecations for display at a later point in time. This class is intended to be used by various steps in the PHPCS process flow to improve the error handling and prevent the "one error hiding another" pattern. Design choices: * This class does not have a constructor and has no awareness of the applicable `Config` or other aspects of a PHPCS run. If notices should be displayed conditionally, like only when not in `-q` quite mode, this should be handled by the class _using_ the `MessageCollector`. * While duplicate error messages are not a great user experience, this class allows for them. It is the responsibility of the class _using_ the `MessageCollector` to ensure that the messages cached are informative for the end-user. * The class provides a number of (`public`) class constants to indicate the error level when adding messages. It is _strongly_ recommended to only ever use these class constants for the error levels. The default error level for messages will be `NOTICE`. Note: yes, these class constants should basically be an Enum, however, enums are a PHP 8.1+ feature, so can't be used at this time. * The class provides two (`public`) "orderby" class constants to indicate the display order of the messages. It is _strongly_ recommended to only ever use these class constants for the display order. By default, messages will be ordered by severity (with "order received" being the secondary ordering for messages of the same severity). Note: again, yes, these class constants should basically be an Enum. * When display of the messages is requested and the message cache includes messages with severity `ERROR`, the class will throw a RuntimeException, which will cause the PHPCS run to exit with a non-zero exit code (currently `3`). * In all other cases, the messages will be displayed, but the PHPCS run will continue without affecting the exit code. * It was suggested by fredden to consider implementing [PSR-3: Logger Interface](https://www.php-fig.org/psr/psr-3/) for the `MessageCollector`. While we discussed this, I don't consider this necessary at this time, though as the class has no BC-promise, this can be reconsidered in the future. Arguments against implementing PSR-3 at this time: - Placeholder handling is required in the PSR-3 implementation to allow for logging to different contexts and using appropriate variable escaping based on the context. For PHPCS, messages will only ever be displayed on the command-line, so there is no need to take escaping for different contexts into account. - PSR-3 expects handling of eight different severity levels - emergency, alert, critical, error, warning, notice, info, debug -. For PHPCS, the `emergency`, `alert` and `critical` levels, as defined in PSR-3, are redundant as these will never occur. Along the same line, PHPCS does not log `info`. However, we do want to be able to display deprecation notices, but that is a severity level not supported under PSR-3. The new functionality is fully covered by tests. Potential future scope: * Add a constructor and inject the `Config` class to allow for colorizing the messages/message prefixes if color support is enabled. This would be most effective after issue 448 has been addressed first. * Add a new `CRITICAL` level for errors which prohibit the current task from finishing. When an error with such level would be received, it would cause the class to display all messages collected up to that point straight away via an exception. * If the conditional displaying of the messages, like only when not in `-q` mode, would lead to undue duplicate code, potentially move display conditions handling into the `MessageCollector` class. This would also need the aforementioned constructor with `Config` injection.
1 parent 08a864f commit 581eef9

File tree

2 files changed

+849
-0
lines changed

2 files changed

+849
-0
lines changed

src/Util/MessageCollector.php

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
<?php
2+
/**
3+
* Collect messages for display at a later point in the process flow.
4+
*
5+
* If any message with type "error" is passed in, displaying the errors will result in halting the program
6+
* with a non-zero exit code.
7+
* If only messages with a lower severity are passed in, displaying the errors will be non-blocking
8+
* and will not affect the exit code.
9+
*
10+
* ---------------------------------------------------------------------------------------------
11+
* This class is intended for internal use only and is not part of the public API.
12+
* This also means that it has no promise of backward compatibility. Use at your own risk.
13+
* ---------------------------------------------------------------------------------------------
14+
*
15+
* @internal
16+
*
17+
* @author Juliette Reinders Folmer <[email protected]>
18+
* @copyright 2025 PHPCSStandards and contributors
19+
* @license https://github.com/PHPCSStandards/PHP_CodeSniffer/blob/master/licence.txt BSD Licence
20+
*/
21+
22+
namespace PHP_CodeSniffer\Util;
23+
24+
use InvalidArgumentException;
25+
use PHP_CodeSniffer\Exceptions\RuntimeException;
26+
27+
final class MessageCollector
28+
{
29+
30+
/**
31+
* Indicator for a (blocking) error.
32+
*
33+
* @var int
34+
*/
35+
const ERROR = 1;
36+
37+
/**
38+
* Indicator for a warning.
39+
*
40+
* @var int
41+
*/
42+
const WARNING = 2;
43+
44+
/**
45+
* Indicator for a notice.
46+
*
47+
* @var int
48+
*/
49+
const NOTICE = 4;
50+
51+
/**
52+
* Indicator for a deprecation notice.
53+
*
54+
* @var int
55+
*/
56+
const DEPRECATED = 8;
57+
58+
/**
59+
* Indicator for ordering the messages based on severity first, order received second.
60+
*
61+
* @var string
62+
*/
63+
const ORDERBY_SEVERITY = 'severity';
64+
65+
/**
66+
* Indicator for ordering the messages based on the order in which they were received.
67+
*
68+
* @var string
69+
*/
70+
const ORDERBY_RECEIVED = 'received';
71+
72+
/**
73+
* Collected messages.
74+
*
75+
* @var array<array<string, string|int>> The value for each array entry is an associative array
76+
* which holds two keys:
77+
* - 'message' string The message text.
78+
* - 'type' int The type of the message based on the
79+
* above declared error level constants.
80+
*/
81+
private $cache = [];
82+
83+
84+
/**
85+
* Add a new message.
86+
*
87+
* @param string $message The message text.
88+
* @param int $type The type of message. Should be one of the following constants:
89+
* MessageCollector::ERROR, MessageCollector::WARNING, MessageCollector::NOTICE
90+
* or MessageCollector::DEPRECATED.
91+
* Defaults to MessageCollector::NOTICE.
92+
*
93+
* @return void
94+
*
95+
* @throws \InvalidArgumentException If the message text is not a string.
96+
* @throws \InvalidArgumentException If the message type is not one of the accepted types.
97+
*/
98+
public function add($message, $type=self::NOTICE)
99+
{
100+
if (is_string($message) === false) {
101+
throw new InvalidArgumentException('The $message should be of type string. Received: '.gettype($message).'.');
102+
}
103+
104+
if ($type !== self::ERROR
105+
&& $type !== self::WARNING
106+
&& $type !== self::NOTICE
107+
&& $type !== self::DEPRECATED
108+
) {
109+
throw new InvalidArgumentException('The message $type should be one of the predefined MessageCollector constants. Received: '.$type.'.');
110+
}
111+
112+
$this->cache[] = [
113+
'message' => $message,
114+
'type' => $type,
115+
];
116+
117+
}//end add()
118+
119+
120+
/**
121+
* Determine whether or not the currently cached errors include blocking errors.
122+
*
123+
* @return bool
124+
*/
125+
public function containsBlockingErrors()
126+
{
127+
$seenTypes = $this->arrayColumn($this->cache, 'type');
128+
$typeFrequency = array_count_values($seenTypes);
129+
return isset($typeFrequency[self::ERROR]);
130+
131+
}//end containsBlockingErrors()
132+
133+
134+
/**
135+
* Display the cached messages.
136+
*
137+
* Displaying the messages will also clear the message cache.
138+
*
139+
* @param string $order Optional. The order in which to display the messages.
140+
* Should be one of the following constants: MessageCollector::ORDERBY_SEVERITY,
141+
* MessageCollector::ORDERBY_RECEIVED.
142+
* Defaults to MessageCollector::ORDERBY_SEVERITY.
143+
*
144+
* @return void
145+
*
146+
* @throws \PHP_CodeSniffer\Exceptions\RuntimeException When there are blocking errors.
147+
*/
148+
public function display($order=self::ORDERBY_SEVERITY)
149+
{
150+
if ($this->cache === []) {
151+
return;
152+
}
153+
154+
$blocking = $this->containsBlockingErrors();
155+
$messageInfo = $this->prefixAll($this->cache);
156+
$this->clearCache();
157+
158+
if ($order === self::ORDERBY_RECEIVED) {
159+
$messages = $this->arrayColumn($messageInfo, 'message');
160+
} else {
161+
$messages = $this->sortBySeverity($messageInfo);
162+
}
163+
164+
$allMessages = implode(PHP_EOL, $messages).PHP_EOL.PHP_EOL;
165+
166+
if ($blocking === true) {
167+
throw new RuntimeException($allMessages);
168+
} else {
169+
echo $allMessages;
170+
}
171+
172+
}//end display()
173+
174+
175+
/**
176+
* Label all messages based on their type.
177+
*
178+
* @param array<array<string, string|int>> $messages A multi-dimensional array of messages with their severity.
179+
*
180+
* @return array<array<string, string|int>>
181+
*/
182+
private function prefixAll($messages)
183+
{
184+
foreach ($messages as $i => $details) {
185+
$messages[$i]['message'] = $this->prefix($details['message'], $details['type']);
186+
}
187+
188+
return $messages;
189+
190+
}//end prefixAll()
191+
192+
193+
/**
194+
* Add a message type prefix to a message.
195+
*
196+
* @param string $message The message text.
197+
* @param int $type The type of message.
198+
*
199+
* @return string
200+
*/
201+
private function prefix($message, $type)
202+
{
203+
switch ($type) {
204+
case self::ERROR:
205+
$message = 'ERROR: '.$message;
206+
break;
207+
208+
case self::WARNING:
209+
$message = 'WARNING: '.$message;
210+
break;
211+
212+
case self::DEPRECATED:
213+
$message = 'DEPRECATED: '.$message;
214+
break;
215+
216+
default:
217+
$message = 'NOTICE: '.$message;
218+
break;
219+
}
220+
221+
return $message;
222+
223+
}//end prefix()
224+
225+
226+
/**
227+
* Sort an array of messages by severity.
228+
*
229+
* @param array<array<string, string|int>> $messages A multi-dimensional array of messages with their severity.
230+
*
231+
* @return array<string> A single dimensional array of only messages, sorted by severity.
232+
*/
233+
private function sortBySeverity($messages)
234+
{
235+
if (count($messages) === 1) {
236+
return [$messages[0]['message']];
237+
}
238+
239+
$errors = [];
240+
$warnings = [];
241+
$notices = [];
242+
$deprecations = [];
243+
244+
foreach ($messages as $details) {
245+
switch ($details['type']) {
246+
case self::ERROR:
247+
$errors[] = $details['message'];
248+
break;
249+
250+
case self::WARNING:
251+
$warnings[] = $details['message'];
252+
break;
253+
254+
case self::DEPRECATED:
255+
$deprecations[] = $details['message'];
256+
break;
257+
258+
default:
259+
$notices[] = $details['message'];
260+
break;
261+
}
262+
}
263+
264+
return array_merge($errors, $warnings, $notices, $deprecations);
265+
266+
}//end sortBySeverity()
267+
268+
269+
/**
270+
* Clear the message cache.
271+
*
272+
* @return void
273+
*/
274+
private function clearCache()
275+
{
276+
$this->cache = [];
277+
278+
}//end clearCache()
279+
280+
281+
/**
282+
* Return the values from a single column in the input array.
283+
*
284+
* Polyfill for the PHP 5.5+ native array_column() function (for the functionality needed here).
285+
*
286+
* @param array<array<string, string|int>> $input A multi-dimensional array from which to pull a column of values.
287+
* @param string $columnKey The name of the column of values to return.
288+
*
289+
* @link https://www.php.net/function.array-column
290+
*
291+
* @return array<string|int>
292+
*/
293+
private function arrayColumn(array $input, $columnKey)
294+
{
295+
if (function_exists('array_column') === true) {
296+
// PHP 5.5+.
297+
return array_column($input, $columnKey);
298+
}
299+
300+
// PHP 5.4.
301+
$callback = function ($row) use ($columnKey) {
302+
return $row[$columnKey];
303+
};
304+
305+
return array_map($callback, $input);
306+
307+
}//end arrayColumn()
308+
309+
310+
}//end class

0 commit comments

Comments
 (0)