Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.30% covered (success)
96.30%
130 / 135
70.00% covered (warning)
70.00%
7 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
StatusFormatter
96.30% covered (success)
96.30%
130 / 135
70.00% covered (warning)
70.00%
7 / 10
48
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
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
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
9
 getMessage
100.00% covered (success)
100.00%
34 / 34
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
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 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 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21namespace MediaWiki\Status;
22
23use ApiMessage;
24use ApiRawMessage;
25use Language;
26use MediaWiki\Language\RawMessage;
27use MediaWiki\Message\Message;
28use MediaWiki\Parser\ParserOutput;
29use MediaWiki\StubObject\StubUserLang;
30use MessageCache;
31use MessageLocalizer;
32use MessageSpecifier;
33use StatusValue;
34use UnexpectedValueException;
35
36/**
37 * Formatter for StatusValue objects.
38 *
39 * @since 1.42
40 *
41 * @see StatusValue
42 */
43class StatusFormatter {
44
45    private MessageLocalizer $messageLocalizer;
46    private MessageCache $messageCache;
47
48    public function __construct( MessageLocalizer $messageLocalizer, MessageCache $messageCache ) {
49        $this->messageLocalizer = $messageLocalizer;
50        $this->messageCache = $messageCache;
51    }
52
53    /**
54     * @param array $params
55     * @param array $options
56     *
57     * @return array
58     */
59    private function cleanParams( array $params, array $options = [] ) {
60        $cleanCallback = $options['cleanCallback'] ?? null;
61
62        if ( !$cleanCallback ) {
63            return $params;
64        }
65        $cleanParams = [];
66        foreach ( $params as $i => $param ) {
67            $cleanParams[$i] = call_user_func( $cleanCallback, $param );
68        }
69        return $cleanParams;
70    }
71
72    /**
73     * Get the error list as a wikitext formatted list
74     *
75     * @param StatusValue $status
76     * @param array $options An array of options, supporting the following keys:
77     * - 'shortContext' (string|false|null) A short enclosing context message name, to
78     *        be used when there is a single error
79     * - 'longContext' (string|false|null) A long enclosing context message name, for a list
80     * - 'lang' (string|Language|StubUserLang|null) Language to use for processing messages
81     * - 'cleanCallback' (callable|null) A callback for sanitizing parameter values
82     *
83     * @return string
84     */
85    public function getWikiText( StatusValue $status, array $options = [] ) {
86        $shortContext = $options['shortContext'] ?? null;
87        $longContext = $options['longContext'] ?? null;
88        $lang = $options['lang'] ?? null;
89
90        $rawErrors = $status->getErrors();
91        if ( count( $rawErrors ) === 0 ) {
92            if ( $status->isOK() ) {
93                $status->fatal(
94                    'internalerror_info',
95                    __METHOD__ . " called for a good result, this is incorrect\n"
96                );
97            } else {
98                $status->fatal(
99                    'internalerror_info',
100                    __METHOD__ . ": Invalid result object: no error text but not OK\n"
101                );
102            }
103            $rawErrors = $status->getErrors(); // just added a fatal
104        }
105
106        if ( count( $rawErrors ) === 1 ) {
107            $s = $this->getErrorMessage( $rawErrors[0], $options )->plain();
108            if ( $shortContext ) {
109                $s = $this->msgInLang( $shortContext, $lang, $s )->plain();
110            } elseif ( $longContext ) {
111                $s = $this->msgInLang( $longContext, $lang, "$s\n" )->plain();
112            }
113        } else {
114            $errors = $this->getErrorMessageArray( $rawErrors, $options );
115            foreach ( $errors as &$error ) {
116                $error = $error->plain();
117            }
118            $s = '* ' . implode( "\n* ", $errors ) . "\n";
119            if ( $longContext ) {
120                $s = $this->msgInLang( $longContext, $lang, $s )->plain();
121            } elseif ( $shortContext ) {
122                $s = $this->msgInLang( $shortContext, $lang, "\n$s\n" )->plain();
123            }
124        }
125        return $s;
126    }
127
128    /**
129     * Get a bullet list of the errors as a Message object.
130     *
131     * $shortContext and $longContext can be used to wrap the error list in some text.
132     * $shortContext will be preferred when there is a single error; $longContext will be
133     * preferred when there are multiple ones. In either case, $1 will be replaced with
134     * the list of errors.
135     *
136     * $shortContext is assumed to use $1 as an inline parameter: if there is a single item,
137     * it will not be made into a list; if there are multiple items, newlines will be inserted
138     * around the list.
139     * $longContext is assumed to use $1 as a standalone parameter; it will always receive a list.
140     *
141     * If both parameters are missing, and there is only one error, no bullet will be added.
142     *
143     * @param StatusValue $status
144     * @param array $options An array of options, supporting the following keys:
145     * - 'shortContext' (string|false|null) A short enclosing context message name, to
146     *        be used when there is a single error
147     * - 'longContext' (string|false|null) A long enclosing context message name, for a list
148     * - 'lang' (string|Language|StubUserLang|null) Language to use for processing messages
149     * - 'cleanCallback' (callable|null) A callback for sanitizing parameter values
150     *
151     * @return Message
152     */
153    public function getMessage( StatusValue $status, array $options = [] ) {
154        $shortContext = $options['shortContext'] ?? null;
155        $longContext = $options['longContext'] ?? null;
156        $lang = $options['lang'] ?? null;
157
158        $rawErrors = $status->getErrors();
159        if ( count( $rawErrors ) === 0 ) {
160            if ( $status->isOK() ) {
161                $status->fatal(
162                    'internalerror_info',
163                    __METHOD__ . " called for a good result, this is incorrect\n"
164                );
165            } else {
166                $status->fatal(
167                    'internalerror_info',
168                    __METHOD__ . ": Invalid result object: no error text but not OK\n"
169                );
170            }
171            $rawErrors = $status->getErrors(); // just added a fatal
172        }
173        if ( count( $rawErrors ) === 1 ) {
174            $s = $this->getErrorMessage( $rawErrors[0], $options );
175            if ( $shortContext ) {
176                $s = $this->msgInLang( $shortContext, $lang, $s );
177            } elseif ( $longContext ) {
178                $wrapper = new RawMessage( "* \$1\n" );
179                $wrapper->params( $s )->parse();
180                $s = $this->msgInLang( $longContext, $lang, $wrapper );
181            }
182        } else {
183            $msgs = $this->getErrorMessageArray( $rawErrors, $options );
184            $msgCount = count( $msgs );
185
186            $s = new RawMessage( '* $' . implode( "\n* \$", range( 1, $msgCount ) ) );
187            $s->params( $msgs )->parse();
188
189            if ( $longContext ) {
190                $s = $this->msgInLang( $longContext, $lang, $s );
191            } elseif ( $shortContext ) {
192                $wrapper = new RawMessage( "\n\$1\n", [ $s ] );
193                $wrapper->parse();
194                $s = $this->msgInLang( $shortContext, $lang, $wrapper );
195            }
196        }
197
198        return $s;
199    }
200
201    /**
202     * Try to convert the status to a PSR-3 friendly format. The output will be similar to
203     * getWikiText( false, false, 'en' ), but message parameters will be extracted into the
204     * context array with parameter names 'parameter1' etc. when possible.
205     *
206     * @return array A pair of (message, context) suitable for passing to a PSR-3 logger.
207     * @phan-return array{0:string,1:(int|float|string)[]}
208     */
209    public function getPsr3MessageAndContext( StatusValue $status ): array {
210        $options = [ 'lang' => 'en' ];
211        $errors = $status->getErrors();
212
213        if ( count( $errors ) === 1 ) {
214            // identical to getMessage( false, false, 'en' ) when there's just one error
215            $message = $this->getErrorMessage( $errors[0], [ 'lang' => 'en' ] );
216
217            $text = null;
218            if ( in_array( get_class( $message ), [ Message::class, ApiMessage::class ], true ) ) {
219                // Fall back to getWikiText for rawmessage, which is just a placeholder for non-translated text.
220                // Turning the entire message into a context parameter wouldn't be useful.
221                if ( $message->getKey() === 'rawmessage' ) {
222                    return [ $this->getWikiText( $status, $options ), [] ];
223                }
224                // $1,$2... will be left as-is when no parameters are provided.
225                $text = $this->msgInLang( $message->getKey(), 'en' )->plain();
226            } elseif ( in_array( get_class( $message ), [ RawMessage::class, ApiRawMessage::class ], true ) ) {
227                $text = $message->getKey();
228            } else {
229                // Unknown Message subclass, we can't be sure how it marks parameters. Fall back to getWikiText.
230                return [ $this->getWikiText( $status, $options ), [] ];
231            }
232
233            $context = [];
234            $i = 1;
235            foreach ( $message->getParams() as $param ) {
236                if ( is_array( $param ) && count( $param ) === 1 ) {
237                    // probably Message::numParam() or similar
238                    $param = reset( $param );
239                }
240                if ( is_int( $param ) || is_float( $param ) || is_string( $param ) ) {
241                    $context["parameter$i"] = $param;
242                } else {
243                    // Parameter is not of a safe type, fall back to getWikiText.
244                    return [ $this->getWikiText( $status, $options ), [] ];
245                }
246
247                $text = str_replace( "\$$i", "{parameter$i}", $text );
248
249                $i++;
250            }
251
252            return [ $text, $context ];
253        }
254        // Parameters cannot be easily extracted, fall back to getWikiText,
255        return [ $this->getWikiText( $status, $options ), [] ];
256    }
257
258    /**
259     * Return the message for a single error
260     *
261     * The code string can be used a message key with per-language versions.
262     * If $error is an array, the "params" field is a list of parameters for the message.
263     *
264     * @param array|string $error Code string or (key: code string, params: string[]) map
265     * @param array $options
266     * @return Message
267     */
268    private function getErrorMessage( $error, array $options = [] ) {
269        $lang = $options['lang'] ?? null;
270
271        if ( is_array( $error ) ) {
272            if ( isset( $error['message'] ) && $error['message'] instanceof Message ) {
273                // Apply context from MessageLocalizer even if we have a Message object already
274                $msg = $this->msg( $error['message'] );
275            } elseif ( isset( $error['message'] ) && isset( $error['params'] ) ) {
276                $msg = $this->msg(
277                    $error['message'],
278                    array_map( static function ( $param ) {
279                        return is_string( $param ) ? wfEscapeWikiText( $param ) : $param;
280                    }, $this->cleanParams( $error['params'], $options ) )
281                );
282            } else {
283                $msgName = array_shift( $error );
284                $msg = $this->msg(
285                    $msgName,
286                    array_map( static function ( $param ) {
287                        return is_string( $param ) ? wfEscapeWikiText( $param ) : $param;
288                    }, $this->cleanParams( $error, $options ) )
289                );
290            }
291        } elseif ( is_string( $error ) ) {
292            $msg = $this->msg( $error );
293        } else {
294            throw new UnexpectedValueException( 'Got ' . get_class( $error ) . ' for key.' );
295        }
296
297        if ( $lang ) {
298            $msg->inLanguage( $lang );
299        }
300        return $msg;
301    }
302
303    /**
304     * Get the error message as HTML. This is done by parsing the wikitext error message
305     *
306     * @param StatusValue $status
307     * @param array $options An array of options, supporting the following keys:
308     * - 'shortContext' (string|false|null) A short enclosing context message name, to
309     *        be used when there is a single error
310     * - 'longContext' (string|false|null) A long enclosing context message name, for a list
311     * - 'lang' (string|Language|StubUserLang|null) Language to use for processing messages
312     * - 'cleanCallback' (callable|null) A callback for sanitizing parameter values
313     *
314     * @return string
315     */
316    public function getHTML( StatusValue $status, array $options = [] ) {
317        $lang = $options['lang'] ?? null;
318
319        $text = $this->getWikiText( $status, $options );
320        $out = $this->messageCache->parse( $text, null, true, true, $lang );
321
322        return $out instanceof ParserOutput
323            ? $out->getText( [ 'enableSectionEditLinks' => false ] )
324            : $out;
325    }
326
327    /**
328     * Return an array with a Message object for each error.
329     *
330     * @param array $errors
331     * @param array $options
332     *
333     * @return Message[]
334     */
335    private function getErrorMessageArray( $errors, array $options = [] ) {
336        return array_map( function ( $e ) use ( $options ) {
337            return $this->getErrorMessage( $e, $options );
338        }, $errors );
339    }
340
341    /**
342     * @param string|MessageSpecifier $key
343     * @param string|string[] ...$params
344     * @return Message
345     */
346    private function msg( $key, ...$params ): Message {
347        return $this->messageLocalizer->msg( $key, ...$params );
348    }
349
350    /**
351     * @param string|MessageSpecifier $key
352     * @param string|Language|StubUserLang|null $lang
353     * @param mixed ...$params
354     * @return Message
355     */
356    private function msgInLang( $key, $lang, ...$params ): Message {
357        $msg = $this->msg( $key, ...$params );
358        if ( $lang ) {
359            $msg->inLanguage( $lang );
360        }
361        return $msg;
362    }
363}