Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.38% covered (success)
96.38%
133 / 138
80.00% covered (warning)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
StatusFormatter
96.38% covered (success)
96.38%
133 / 138
80.00% covered (warning)
80.00%
8 / 10
47
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 cleanParams
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getWikiText
93.10% covered (success)
93.10%
27 / 29
0.00% covered (danger)
0.00%
0 / 1
9.03
 getMessage
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
8
 getPsr3MessageAndContext
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
11
 getErrorMessage
87.50% covered (warning)
87.50%
21 / 24
0.00% covered (danger)
0.00%
0 / 1
10.20
 getHTML
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 getErrorMessageArray
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 msg
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 msgInLang
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Status;
8
9use MediaWiki\Api\ApiMessage;
10use MediaWiki\Language\Language;
11use MediaWiki\Language\MessageParser;
12use MediaWiki\Language\RawMessage;
13use MediaWiki\Message\Message;
14use MediaWiki\Page\PageReferenceValue;
15use MediaWiki\StubObject\StubUserLang;
16use MessageLocalizer;
17use Psr\Log\LoggerInterface;
18use RuntimeException;
19use StatusValue;
20use UnexpectedValueException;
21use Wikimedia\Message\MessageParam;
22use Wikimedia\Message\MessageSpecifier;
23
24/**
25 * Formatter for StatusValue objects.
26 *
27 * @since 1.42
28 *
29 * @see StatusValue
30 */
31class StatusFormatter {
32
33    private MessageLocalizer $messageLocalizer;
34    private MessageParser $messageParser;
35    private LoggerInterface $logger;
36
37    public function __construct(
38        MessageLocalizer $messageLocalizer,
39        MessageParser $messageParser,
40        LoggerInterface $logger
41    ) {
42        $this->messageLocalizer = $messageLocalizer;
43        $this->messageParser = $messageParser;
44        $this->logger = $logger;
45    }
46
47    /**
48     * @param array $params
49     * @param array $options
50     *
51     * @return array
52     */
53    private function cleanParams( array $params, array $options = [] ) {
54        $cleanCallback = $options['cleanCallback'] ?? null;
55
56        if ( !$cleanCallback ) {
57            return $params;
58        }
59        $cleanParams = [];
60        foreach ( $params as $i => $param ) {
61            $cleanParams[$i] = $cleanCallback( $param );
62        }
63        return $cleanParams;
64    }
65
66    /**
67     * Get the error list as a wikitext formatted list
68     *
69     * All message parameters that were provided as strings will be escaped with wfEscapeWikiText.
70     * This is mostly a historical accident and often undesirable (T368821).
71     * - To avoid this behavior when producing the Status, pass MessageSpecifier objects to methods
72     *   such as `$status->fatal()`, instead of separate key and params parameters.
73     * - To avoid this behavior when consuming the Status, use the `$status->getMessages()` method
74     *   instead, and display each message separately (or combine then with `Message::listParams()`).
75     *
76     * @param StatusValue $status
77     * @param array $options An array of options, supporting the following keys:
78     * - 'shortContext' (string|false|null) A short enclosing context message name, to
79     *        be used when there is a single error
80     * - 'longContext' (string|false|null) A long enclosing context message name, for a list
81     * - 'lang' (string|Language|StubUserLang|null) Language to use for processing messages
82     * - 'cleanCallback' (callable|null) A callback for sanitizing parameter values
83     *
84     * @return string
85     */
86    public function getWikiText( StatusValue $status, array $options = [] ) {
87        $shortContext = $options['shortContext'] ?? null;
88        $longContext = $options['longContext'] ?? null;
89        $lang = $options['lang'] ?? null;
90
91        $rawErrors = $status->getErrors();
92        if ( count( $rawErrors ) === 0 ) {
93            if ( $status->isOK() ) {
94                $errMsg = __METHOD__ . " called for a good result, this is incorrect\n";
95            } else {
96                $errMsg = __METHOD__ . ": Invalid result object: no error text but not OK\n";
97            }
98
99            $status->fatal(
100                'internalerror_info',
101                $errMsg
102            );
103
104            $this->logger->warning( $errMsg, [ 'exception' => new RuntimeException() ] );
105
106            $rawErrors = $status->getErrors(); // just added a fatal
107        }
108
109        if ( count( $rawErrors ) === 1 ) {
110            $s = $this->getErrorMessage( $rawErrors[0], $options )->plain();
111            if ( $shortContext ) {
112                $s = $this->msgInLang( $shortContext, $lang, $s )->plain();
113            } elseif ( $longContext ) {
114                $s = $this->msgInLang( $longContext, $lang, "$s\n" )->plain();
115            }
116        } else {
117            $errors = $this->getErrorMessageArray( $rawErrors, $options );
118            foreach ( $errors as &$error ) {
119                $error = $error->plain();
120            }
121            $s = "<ul>\n<li>\n" . implode( "\n</li>\n<li>\n", $errors ) . "\n</li>\n</ul>\n";
122            if ( $longContext ) {
123                $s = $this->msgInLang( $longContext, $lang, $s )->plain();
124            } elseif ( $shortContext ) {
125                $s = $this->msgInLang( $shortContext, $lang, "\n$s\n" )->plain();
126            }
127        }
128        return $s;
129    }
130
131    /**
132     * Get a bullet list of the errors as a Message object.
133     *
134     * All message parameters that were provided as strings will be escaped with wfEscapeWikiText.
135     * This is mostly a historical accident and often undesirable (T368821).
136     * - To avoid this behavior when producing the Status, pass MessageSpecifier objects to methods
137     *   such as `$status->fatal()`, instead of separate key and params parameters.
138     * - To avoid this behavior when consuming the Status, use the `$status->getMessages()` method
139     *   instead, and display each message separately (or combine then with `Message::listParams()`).
140     *
141     * $shortContext and $longContext can be used to wrap the error list in some text.
142     * $shortContext will be preferred when there is a single error; $longContext will be
143     * preferred when there are multiple ones. In either case, $1 will be replaced with
144     * the list of errors.
145     *
146     * $shortContext is assumed to use $1 as an inline parameter: if there is a single item,
147     * it will not be made into a list; if there are multiple items, newlines will be inserted
148     * around the list.
149     * $longContext is assumed to use $1 as a standalone parameter; it will always receive a list.
150     *
151     * If both parameters are missing, and there is only one error, no bullet will be added.
152     *
153     * @param StatusValue $status
154     * @param array $options An array of options, supporting the following keys:
155     * - 'shortContext' (string|false|null) A short enclosing context message name, to
156     *        be used when there is a single error
157     * - 'longContext' (string|false|null) A long enclosing context message name, for a list
158     * - 'lang' (string|Language|StubUserLang|null) Language to use for processing messages
159     * - 'cleanCallback' (callable|null) A callback for sanitizing parameter values
160     *
161     * @return Message
162     */
163    public function getMessage( StatusValue $status, array $options = [] ) {
164        $shortContext = $options['shortContext'] ?? null;
165        $longContext = $options['longContext'] ?? null;
166        $lang = $options['lang'] ?? null;
167
168        $rawErrors = $status->getErrors();
169        if ( count( $rawErrors ) === 0 ) {
170            if ( $status->isOK() ) {
171                $errMsg = __METHOD__ . " called for a good result, this is incorrect\n";
172            } else {
173                $errMsg = __METHOD__ . ": Invalid result object: no error text but not OK\n";
174            }
175
176            $status->fatal(
177                'internalerror_info',
178                $errMsg
179            );
180
181            $this->logger->warning( $errMsg, [ 'exception' => new RuntimeException() ] );
182
183            $rawErrors = $status->getErrors(); // just added a fatal
184        }
185        if ( count( $rawErrors ) === 1 ) {
186            $s = $this->getErrorMessage( $rawErrors[0], $options );
187            if ( $shortContext ) {
188                $s = $this->msgInLang( $shortContext, $lang, $s );
189            } elseif ( $longContext ) {
190                $wrapper = new RawMessage( "* \$1\n" );
191                $wrapper->params( $s )->parse();
192                $s = $this->msgInLang( $longContext, $lang, $wrapper );
193            }
194        } else {
195            $msgs = $this->getErrorMessageArray( $rawErrors, $options );
196            $msgCount = count( $msgs );
197
198            $s = new RawMessage( '* $' . implode( "\n* \$", range( 1, $msgCount ) ) );
199            $s->params( $msgs )->parse();
200
201            if ( $longContext ) {
202                $s = $this->msgInLang( $longContext, $lang, $s );
203            } elseif ( $shortContext ) {
204                $wrapper = new RawMessage( "\n\$1\n", [ $s ] );
205                $wrapper->parse();
206                $s = $this->msgInLang( $shortContext, $lang, $wrapper );
207            }
208        }
209
210        return $s;
211    }
212
213    /**
214     * Try to convert the status to a PSR-3 friendly format. The output will be similar to
215     * getWikiText( false, false, 'en' ), but message parameters will be extracted into the
216     * context array with parameter names 'parameter1' etc. when possible.
217     * A predefined context array may be passed for convenience.
218     *
219     * @return array A pair of (message, context) suitable for passing to a PSR-3 logger.
220     * @phan-return array{0:string,1:(int|float|string)[]}
221     */
222    public function getPsr3MessageAndContext( StatusValue $status, array $context = [] ): array {
223        $options = [ 'lang' => 'en' ];
224        $errors = $status->getErrors();
225
226        if ( count( $errors ) === 1 ) {
227            // identical to getMessage( false, false, 'en' ) when there's just one error
228            $message = $this->getErrorMessage( $errors[0], [ 'lang' => 'en' ] );
229
230            if ( $message instanceof RawMessage ) {
231                $text = $message->getTextOfRawMessage();
232                $params = $message->getParamsOfRawMessage();
233            } elseif ( $message instanceof ApiMessage ||
234                // rawmessage is just a placeholder for non-translated text. Turning the entire
235                // message into a context parameter wouldn't be useful.
236                ( get_class( $message ) === Message::class && $message->getKey() !== 'rawmessage' )
237            ) {
238                // $1,$2... will be left as-is when no parameters are provided.
239                $text = $this->msgInLang( $message->getKey(), 'en' )->plain();
240                $params = $message->getParams();
241            } else {
242                // Unknown Message subclass, we can't be sure how it marks parameters. Fall back to getWikiText.
243                return [ $this->getWikiText( $status, $options ), $context ];
244            }
245
246            $contextParams = [];
247            $i = 1;
248            foreach ( $params as $param ) {
249                if ( $param instanceof MessageParam ) {
250                    $param = $param->getValue();
251                }
252                if ( is_int( $param ) || is_float( $param ) || is_string( $param ) ) {
253                    $contextParams["parameter$i"] = $param;
254                } else {
255                    // Parameter is not of a safe type, fall back to getWikiText.
256                    return [ $this->getWikiText( $status, $options ), $context ];
257                }
258
259                $text = str_replace( "\$$i", "{parameter$i}", $text );
260
261                $i++;
262            }
263
264            return [ $text, $context + $contextParams ];
265        }
266        // Parameters cannot be easily extracted, fall back to getWikiText,
267        return [ $this->getWikiText( $status, $options ), $context ];
268    }
269
270    /**
271     * Return the message for a single error
272     *
273     * The code string can be used a message key with per-language versions.
274     * If $error is an array, the "params" field is a list of parameters for the message.
275     *
276     * @param array|string $error Code string or (key: code string, params: string[]) map
277     * @param array $options
278     * @return Message
279     */
280    private function getErrorMessage( $error, array $options = [] ) {
281        $lang = $options['lang'] ?? null;
282
283        if ( is_array( $error ) ) {
284            if ( isset( $error['message'] ) && $error['message'] instanceof Message ) {
285                // Apply context from MessageLocalizer even if we have a Message object already
286                $msg = $this->msg( $error['message'] );
287            } elseif ( isset( $error['message'] ) && isset( $error['params'] ) ) {
288                $msg = $this->msg(
289                    $error['message'],
290                    array_map( static function ( $param ) {
291                        return is_string( $param ) ? wfEscapeWikiText( $param ) : $param;
292                    }, $this->cleanParams( $error['params'], $options ) )
293                );
294            } else {
295                $msgName = array_shift( $error );
296                $msg = $this->msg(
297                    $msgName,
298                    array_map( static function ( $param ) {
299                        return is_string( $param ) ? wfEscapeWikiText( $param ) : $param;
300                    }, $this->cleanParams( $error, $options ) )
301                );
302            }
303        } elseif ( is_string( $error ) ) {
304            $msg = $this->msg( $error );
305        } else {
306            throw new UnexpectedValueException( 'Got ' . get_class( $error ) . ' for key.' );
307        }
308
309        if ( $lang ) {
310            $msg->inLanguage( $lang );
311        }
312        return $msg;
313    }
314
315    /**
316     * Get the error message as HTML. This is done by parsing the wikitext error message
317     *
318     * All message parameters that were provided as strings will be escaped with wfEscapeWikiText.
319     * This is mostly a historical accident and often undesirable (T368821).
320     * - To avoid this behavior when producing the Status, pass MessageSpecifier objects to methods
321     *   such as `$status->fatal()`, instead of separate key and params parameters.
322     * - To avoid this behavior when consuming the Status, use the `$status->getMessages()` method
323     *   instead, and display each message separately (or combine then with `Message::listParams()`).
324     *
325     * @param StatusValue $status
326     * @param array $options An array of options, supporting the following keys:
327     * - 'shortContext' (string|false|null) A short enclosing context message name, to
328     *        be used when there is a single error
329     * - 'longContext' (string|false|null) A long enclosing context message name, for a list
330     * - 'lang' (string|Language|StubUserLang|null) Language to use for processing messages
331     * - 'cleanCallback' (callable|null) A callback for sanitizing parameter values
332     *
333     * @return string
334     */
335    public function getHTML( StatusValue $status, array $options = [] ) {
336        $lang = $options['lang'] ?? null;
337
338        $text = $this->getWikiText( $status, $options );
339        $out = $this->messageParser->parse(
340            $text,
341            PageReferenceValue::localReference( NS_SPECIAL, 'Badtitle/StatusFormatter' ),
342            /*linestart*/ true,
343            /*interface*/ true,
344            $lang
345        );
346
347        return $out->getContentHolderText();
348    }
349
350    /**
351     * Return an array with a Message object for each error.
352     *
353     * @param array $errors
354     * @param array $options
355     *
356     * @return Message[]
357     */
358    private function getErrorMessageArray( $errors, array $options = [] ) {
359        return array_map( function ( $e ) use ( $options ) {
360            return $this->getErrorMessage( $e, $options );
361        }, $errors );
362    }
363
364    /**
365     * @param string|MessageSpecifier $key
366     * @param string|string[] ...$params
367     * @return Message
368     */
369    private function msg( $key, ...$params ): Message {
370        return $this->messageLocalizer->msg( $key, ...$params );
371    }
372
373    /**
374     * @param string|MessageSpecifier $key
375     * @param string|Language|StubUserLang|null $lang
376     * @phpcs:ignore Generic.Files.LineLength
377     * @param MessageParam|MessageSpecifier|string|int|float|list<MessageParam|MessageSpecifier|string|int|float> ...$params
378     *   See Message::params()
379     * @return Message
380     */
381    private function msgInLang( $key, $lang, ...$params ): Message {
382        $msg = $this->msg( $key, ...$params );
383        if ( $lang ) {
384            $msg->inLanguage( $lang );
385        }
386        return $msg;
387    }
388}