Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
42.19% covered (danger)
42.19%
208 / 493
32.76% covered (danger)
32.76%
19 / 58
CRAP
0.00% covered (danger)
0.00%
0 / 1
HTMLFormField
42.28% covered (danger)
42.28%
208 / 492
32.76% covered (danger)
32.76%
19 / 58
10137.86
0.00% covered (danger)
0.00%
0 / 1
 getInputHTML
n/a
0 / 0
n/a
0 / 0
0
 getInputOOUI
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getInputCodex
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 canDisplayErrors
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 msg
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 hasVisibleOutput
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNearestField
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 getNearestFieldValue
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 getNearestFieldByName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validateCondState
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
15
 checkStateRecurse
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
12
 parseCondState
62.50% covered (warning)
62.50%
10 / 16
0.00% covered (danger)
0.00%
0 / 1
13.27
 parseCondStateForClient
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 isHidden
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 isDisabled
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 cancelSubmit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validate
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
11.06
 filter
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 needsLabel
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setShowEmptyLabel
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isSubmitAttempt
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 loadDataFromRequest
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 __construct
68.18% covered (warning)
68.18%
30 / 44
0.00% covered (danger)
0.00%
0 / 1
40.04
 getTableRow
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
20
 getDiv
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
12
 getOOUI
62.79% covered (warning)
62.79%
27 / 43
0.00% covered (danger)
0.00%
0 / 1
29.19
 getCodex
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
110
 getClassName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getLabelAlignOOUI
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFieldLayoutOOUI
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 shouldInfuseOOUI
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getOOUIModules
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRaw
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getVForm
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getInline
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getHelpTextHtmlTable
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 getHelpTextHtmlDiv
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 getHelpTextHtmlRaw
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHelpMessages
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
5.26
 getHelpText
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
11.10
 isHelpInline
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getErrorsAndErrorClass
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getErrorsRaw
33.33% covered (danger)
33.33%
3 / 9
0.00% covered (danger)
0.00%
0 / 1
16.67
 getLabel
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLabelHtml
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
90
 getDefault
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTooltipAndAccessKey
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getTooltipAndAccessKeyOOUI
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
3.19
 getAttributes
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 lookupOptionsKeys
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 forceToStringRecursive
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getOptions
42.86% covered (danger)
42.86%
6 / 14
0.00% covered (danger)
0.00%
0 / 1
12.72
 getOptionsOOUI
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 flattenOptions
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 formatErrors
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
56
 getMessage
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 skipLoadData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 needsJSForHtml5FormValidation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\HTMLForm;
4
5use FormatJson;
6use HtmlArmor;
7use HTMLCheckField;
8use HTMLFormFieldCloner;
9use InvalidArgumentException;
10use MediaWiki\Context\RequestContext;
11use MediaWiki\Html\Html;
12use MediaWiki\Linker\Linker;
13use MediaWiki\Logger\LoggerFactory;
14use MediaWiki\Message\Message;
15use MediaWiki\Request\WebRequest;
16use MediaWiki\Status\Status;
17use MessageSpecifier;
18use StatusValue;
19
20/**
21 * The parent class to generate form fields.  Any field type should
22 * be a subclass of this.
23 *
24 * @stable to extend
25 */
26abstract class HTMLFormField {
27    /** @var array|array[] */
28    public $mParams;
29
30    /** @var callable(mixed,array,HTMLForm):(Status|string|bool|Message) */
31    protected $mValidationCallback;
32    protected $mFilterCallback;
33    protected $mName;
34    protected $mDir;
35    protected $mLabel; # String label, as HTML. Set on construction.
36    protected $mID;
37    protected $mClass = '';
38    protected $mVFormClass = '';
39    protected $mHelpClass = false;
40    protected $mDefault;
41    private $mNotices;
42
43    /**
44     * @var array|null|false
45     */
46    protected $mOptions = false;
47    protected $mOptionsLabelsNotFromMessage = false;
48    /**
49     * @var array Array to hold params for 'hide-if' or 'disable-if' statements
50     */
51    protected $mCondState = [];
52    protected $mCondStateClass = [];
53
54    /**
55     * @var bool If true will generate an empty div element with no label
56     * @since 1.22
57     */
58    protected $mShowEmptyLabels = true;
59
60    /**
61     * @var HTMLForm|null
62     */
63    public $mParent;
64
65    /**
66     * This function must be implemented to return the HTML to generate
67     * the input object itself.  It should not implement the surrounding
68     * table cells/rows, or labels/help messages.
69     *
70     * @param mixed $value The value to set the input to; eg a default
71     *     text for a text input.
72     *
73     * @return string Valid HTML.
74     */
75    abstract public function getInputHTML( $value );
76
77    /**
78     * Same as getInputHTML, but returns an OOUI object.
79     * Defaults to false, which getOOUI will interpret as "use the HTML version"
80     * @stable to override
81     *
82     * @param string $value
83     * @return \OOUI\Widget|string|false
84     */
85    public function getInputOOUI( $value ) {
86        return false;
87    }
88
89    /**
90     * Same as getInputHTML, but for Codex. This is called by CodexHTMLForm.
91     *
92     * If not overridden, falls back to getInputHTML.
93     *
94     * @param string $value The value to set the input to
95     * @param bool $hasErrors Whether there are validation errors. If set to true, this method
96     *   should apply a CSS class for the error status (e.g. cdx-text-input--status-error)
97     *   if the component used supports that.
98     * @return string HTML
99     */
100    public function getInputCodex( $value, $hasErrors ) {
101        // If not overridden, fall back to getInputHTML()
102        return $this->getInputHTML( $value );
103    }
104
105    /**
106     * True if this field type is able to display errors; false if validation errors need to be
107     * displayed in the main HTMLForm error area.
108     * @stable to override
109     * @return bool
110     */
111    public function canDisplayErrors() {
112        return $this->hasVisibleOutput();
113    }
114
115    /**
116     * Get a translated interface message
117     *
118     * This is a wrapper around $this->mParent->msg() if $this->mParent is set
119     * and wfMessage() otherwise.
120     *
121     * Parameters are the same as wfMessage().
122     *
123     * @param string|string[]|MessageSpecifier $key
124     * @param mixed ...$params
125     * @return Message
126     */
127    public function msg( $key, ...$params ) {
128        if ( $this->mParent ) {
129            return $this->mParent->msg( $key, ...$params );
130        }
131        return wfMessage( $key, ...$params );
132    }
133
134    /**
135     * If this field has a user-visible output or not. If not,
136     * it will not be rendered
137     * @stable to override
138     *
139     * @return bool
140     */
141    public function hasVisibleOutput() {
142        return true;
143    }
144
145    /**
146     * Get the field name that will be used for submission.
147     *
148     * @since 1.38
149     * @return string
150     */
151    public function getName() {
152        return $this->mName;
153    }
154
155    /**
156     * Get the closest field matching a given name.
157     *
158     * It can handle array fields like the user would expect. The general
159     * algorithm is to look for $name as a sibling of $this, then a sibling
160     * of $this's parent, and so on.
161     *
162     * @param string $name
163     * @param bool $backCompat Whether to try striping the 'wp' prefix.
164     * @return HTMLFormField
165     */
166    protected function getNearestField( $name, $backCompat = false ) {
167        // When the field is belong to a HTMLFormFieldCloner
168        $cloner = $this->mParams['cloner'] ?? null;
169        if ( $cloner instanceof HTMLFormFieldCloner ) {
170            $field = $cloner->findNearestField( $this, $name );
171            if ( $field ) {
172                return $field;
173            }
174        }
175
176        if ( $backCompat && str_starts_with( $name, 'wp' ) &&
177            !$this->mParent->hasField( $name )
178        ) {
179            // Don't break the existed use cases.
180            return $this->mParent->getField( substr( $name, 2 ) );
181        }
182        return $this->mParent->getField( $name );
183    }
184
185    /**
186     * Fetch a field value from $alldata for the closest field matching a given
187     * name.
188     *
189     * @param array $alldata
190     * @param string $name
191     * @param bool $asDisplay Whether the reverting logic of HTMLCheckField
192     *     should be ignored.
193     * @param bool $backCompat Whether to try striping the 'wp' prefix.
194     * @return mixed
195     */
196    protected function getNearestFieldValue( $alldata, $name, $asDisplay = false, $backCompat = false ) {
197        $field = $this->getNearestField( $name, $backCompat );
198        // When the field belongs to a HTMLFormFieldCloner
199        $cloner = $field->mParams['cloner'] ?? null;
200        if ( $cloner instanceof HTMLFormFieldCloner ) {
201            $value = $cloner->extractFieldData( $field, $alldata );
202        } else {
203            // Note $alldata is an empty array when first rendering a form with a formIdentifier.
204            // In that case, $alldata[$field->mParams['fieldname']] is unset and we use the
205            // field's default value
206            $value = $alldata[$field->mParams['fieldname']] ?? $field->getDefault();
207        }
208
209        // Check invert state for HTMLCheckField
210        if ( $asDisplay && $field instanceof HTMLCheckField && ( $field->mParams['invert'] ?? false ) ) {
211            $value = !$value;
212        }
213
214        return $value;
215    }
216
217    /**
218     * Fetch a field value from $alldata for the closest field matching a given
219     * name.
220     *
221     * @deprecated since 1.38 Use getNearestFieldValue() instead.
222     * @param array $alldata
223     * @param string $name
224     * @param bool $asDisplay
225     * @return string
226     */
227    protected function getNearestFieldByName( $alldata, $name, $asDisplay = false ) {
228        return (string)$this->getNearestFieldValue( $alldata, $name, $asDisplay );
229    }
230
231    /**
232     * Validate the cond-state params, the existence check of fields should
233     * be done later.
234     *
235     * @param array $params
236     */
237    protected function validateCondState( $params ) {
238        $origParams = $params;
239        $op = array_shift( $params );
240
241        $makeException = function ( string $details ) use ( $origParams ): InvalidArgumentException {
242            return new InvalidArgumentException(
243                "Invalid hide-if or disable-if specification for $this->mName" .
244                $details . " in " . var_export( $origParams, true )
245            );
246        };
247
248        switch ( $op ) {
249            case 'NOT':
250                if ( count( $params ) !== 1 ) {
251                    throw $makeException( "NOT takes exactly one parameter" );
252                }
253                // Fall-through intentionally
254
255            case 'AND':
256            case 'OR':
257            case 'NAND':
258            case 'NOR':
259                foreach ( $params as $i => $p ) {
260                    if ( !is_array( $p ) ) {
261                        $type = gettype( $p );
262                        throw $makeException( "Expected array, found $type at index $i" );
263                    }
264                    $this->validateCondState( $p );
265                }
266                break;
267
268            case '===':
269            case '!==':
270                if ( count( $params ) !== 2 ) {
271                    throw $makeException( "$op takes exactly two parameters" );
272                }
273                [ $name, $value ] = $params;
274                if ( !is_string( $name ) || !is_string( $value ) ) {
275                    throw $makeException( "Parameters for $op must be strings" );
276                }
277                break;
278
279            default:
280                throw $makeException( "Unknown operation" );
281        }
282    }
283
284    /**
285     * Helper function for isHidden and isDisabled to handle recursive data structures.
286     *
287     * @param array $alldata
288     * @param array $params
289     * @return bool
290     */
291    protected function checkStateRecurse( array $alldata, array $params ) {
292        $op = array_shift( $params );
293        $valueChk = [ 'AND' => false, 'OR' => true, 'NAND' => false, 'NOR' => true ];
294        $valueRet = [ 'AND' => true, 'OR' => false, 'NAND' => false, 'NOR' => true ];
295
296        switch ( $op ) {
297            case 'AND':
298            case 'OR':
299            case 'NAND':
300            case 'NOR':
301                foreach ( $params as $p ) {
302                    if ( $valueChk[$op] === $this->checkStateRecurse( $alldata, $p ) ) {
303                        return !$valueRet[$op];
304                    }
305                }
306                return $valueRet[$op];
307
308            case 'NOT':
309                return !$this->checkStateRecurse( $alldata, $params[0] );
310
311            case '===':
312            case '!==':
313                [ $field, $value ] = $params;
314                $testValue = (string)$this->getNearestFieldValue( $alldata, $field, true, true );
315                switch ( $op ) {
316                    case '===':
317                        return ( $value === $testValue );
318                    case '!==':
319                        return ( $value !== $testValue );
320                }
321        }
322    }
323
324    /**
325     * Parse the cond-state array to use the field name for submission, since
326     * the key in the form descriptor is never known in HTML. Also check for
327     * field existence here.
328     *
329     * @param array $params
330     * @return mixed[]
331     */
332    protected function parseCondState( $params ) {
333        $op = array_shift( $params );
334
335        switch ( $op ) {
336            case 'AND':
337            case 'OR':
338            case 'NAND':
339            case 'NOR':
340                $ret = [ $op ];
341                foreach ( $params as $p ) {
342                    $ret[] = $this->parseCondState( $p );
343                }
344                return $ret;
345
346            case 'NOT':
347                return [ 'NOT', $this->parseCondState( $params[0] ) ];
348
349            case '===':
350            case '!==':
351                [ $name, $value ] = $params;
352                $field = $this->getNearestField( $name, true );
353                return [ $op, $field->getName(), $value ];
354        }
355    }
356
357    /**
358     * Parse the cond-state array for client-side.
359     *
360     * @return array[]
361     */
362    protected function parseCondStateForClient() {
363        $parsed = [];
364        foreach ( $this->mCondState as $type => $params ) {
365            $parsed[$type] = $this->parseCondState( $params );
366        }
367        return $parsed;
368    }
369
370    /**
371     * Test whether this field is supposed to be hidden, based on the values of
372     * the other form fields.
373     *
374     * @since 1.23
375     * @param array $alldata The data collected from the form
376     * @return bool
377     */
378    public function isHidden( $alldata ) {
379        return isset( $this->mCondState['hide'] ) &&
380            $this->checkStateRecurse( $alldata, $this->mCondState['hide'] );
381    }
382
383    /**
384     * Test whether this field is supposed to be disabled, based on the values of
385     * the other form fields.
386     *
387     * @since 1.38
388     * @param array $alldata The data collected from the form
389     * @return bool
390     */
391    public function isDisabled( $alldata ) {
392        return ( $this->mParams['disabled'] ?? false ) ||
393            $this->isHidden( $alldata ) ||
394            ( isset( $this->mCondState['disable'] )
395                && $this->checkStateRecurse( $alldata, $this->mCondState['disable'] ) );
396    }
397
398    /**
399     * Override this function if the control can somehow trigger a form
400     * submission that shouldn't actually submit the HTMLForm.
401     *
402     * @stable to override
403     * @since 1.23
404     * @param string|array $value The value the field was submitted with
405     * @param array $alldata The data collected from the form
406     *
407     * @return bool True to cancel the submission
408     */
409    public function cancelSubmit( $value, $alldata ) {
410        return false;
411    }
412
413    /**
414     * Override this function to add specific validation checks on the
415     * field input.  Don't forget to call parent::validate() to ensure
416     * that the user-defined callback mValidationCallback is still run
417     * @stable to override
418     *
419     * @param mixed $value The value the field was submitted with
420     * @param array $alldata The data collected from the form
421     *
422     * @return bool|string|Message True on success, or String/Message error to display, or
423     *   false to fail validation without displaying an error.
424     */
425    public function validate( $value, $alldata ) {
426        if ( $this->isHidden( $alldata ) ) {
427            return true;
428        }
429
430        if ( isset( $this->mParams['required'] )
431            && $this->mParams['required'] !== false
432            && ( $value === '' || $value === false || $value === null )
433        ) {
434            return $this->msg( 'htmlform-required' );
435        }
436
437        if ( !isset( $this->mValidationCallback ) ) {
438            return true;
439        }
440
441        $p = ( $this->mValidationCallback )( $value, $alldata, $this->mParent );
442
443        if ( $p instanceof StatusValue ) {
444            $language = $this->mParent ? $this->mParent->getLanguage() : RequestContext::getMain()->getLanguage();
445
446            return $p->isGood() ? true : Status::wrap( $p )->getHTML( false, false, $language );
447        }
448
449        return $p;
450    }
451
452    /**
453     * @stable to override
454     *
455     * @param mixed $value
456     * @param mixed[] $alldata
457     *
458     * @return mixed
459     */
460    public function filter( $value, $alldata ) {
461        if ( isset( $this->mFilterCallback ) ) {
462            $value = ( $this->mFilterCallback )( $value, $alldata, $this->mParent );
463        }
464
465        return $value;
466    }
467
468    /**
469     * Should this field have a label, or is there no input element with the
470     * appropriate id for the label to point to?
471     * @stable to override
472     *
473     * @return bool True to output a label, false to suppress
474     */
475    protected function needsLabel() {
476        return true;
477    }
478
479    /**
480     * Tell the field whether to generate a separate label element if its label
481     * is blank.
482     *
483     * @since 1.22
484     *
485     * @param bool $show Set to false to not generate a label.
486     * @return void
487     */
488    public function setShowEmptyLabel( $show ) {
489        $this->mShowEmptyLabels = $show;
490    }
491
492    /**
493     * Can we assume that the request is an attempt to submit a HTMLForm, as opposed to an attempt to
494     * just view it? This can't normally be distinguished for e.g. checkboxes.
495     *
496     * Returns true if the request was posted and has a field for a CSRF token (wpEditToken), or
497     * has a form identifier (wpFormIdentifier).
498     *
499     * @todo Consider moving this to HTMLForm?
500     * @param WebRequest $request
501     * @return bool
502     */
503    protected function isSubmitAttempt( WebRequest $request ) {
504        // HTMLForm would add a hidden field of edit token for forms that require to be posted.
505        return ( $request->wasPosted() && $request->getCheck( 'wpEditToken' ) )
506            // The identifier matching or not has been checked in HTMLForm::prepareForm()
507            || $request->getCheck( 'wpFormIdentifier' );
508    }
509
510    /**
511     * Get the value that this input has been set to from a posted form,
512     * or the input's default value if it has not been set.
513     * @stable to override
514     *
515     * @param WebRequest $request
516     * @return mixed The value
517     */
518    public function loadDataFromRequest( $request ) {
519        if ( $request->getCheck( $this->mName ) ) {
520            return $request->getText( $this->mName );
521        } else {
522            return $this->getDefault();
523        }
524    }
525
526    /**
527     * Initialise the object
528     *
529     * @stable to call
530     * @param array $params Associative Array. See HTMLForm doc for syntax.
531     *
532     * @since 1.22 The 'label' attribute no longer accepts raw HTML, use 'label-raw' instead
533     */
534    public function __construct( $params ) {
535        $this->mParams = $params;
536
537        if ( isset( $params['parent'] ) && $params['parent'] instanceof HTMLForm ) {
538            $this->mParent = $params['parent'];
539        } else {
540            // Normally parent is added automatically by HTMLForm::factory.
541            // Several field types already assume unconditionally this is always set,
542            // so deprecate manually creating an HTMLFormField without a parent form set.
543            wfDeprecatedMsg(
544                __METHOD__ . ": Constructing an HTMLFormField without a 'parent' parameter",
545                "1.40"
546            );
547        }
548
549        # Generate the label from a message, if possible
550        if ( isset( $params['label-message'] ) ) {
551            $this->mLabel = $this->getMessage( $params['label-message'] )->parse();
552        } elseif ( isset( $params['label'] ) ) {
553            if ( $params['label'] === '&#160;' || $params['label'] === "\u{00A0}" ) {
554                // Apparently some things set &nbsp directly and in an odd format
555                $this->mLabel = "\u{00A0}";
556            } else {
557                $this->mLabel = htmlspecialchars( $params['label'] );
558            }
559        } elseif ( isset( $params['label-raw'] ) ) {
560            $this->mLabel = $params['label-raw'];
561        }
562
563        $this->mName = $params['name'] ?? 'wp' . $params['fieldname'];
564
565        if ( isset( $params['dir'] ) ) {
566            $this->mDir = $params['dir'];
567        }
568
569        $this->mID = "mw-input-{$this->mName}";
570
571        if ( isset( $params['default'] ) ) {
572            $this->mDefault = $params['default'];
573        }
574
575        if ( isset( $params['id'] ) ) {
576            $this->mID = $params['id'];
577        }
578
579        if ( isset( $params['cssclass'] ) ) {
580            $this->mClass = $params['cssclass'];
581        }
582
583        if ( isset( $params['csshelpclass'] ) ) {
584            $this->mHelpClass = $params['csshelpclass'];
585        }
586
587        if ( isset( $params['validation-callback'] ) ) {
588            $this->mValidationCallback = $params['validation-callback'];
589        }
590
591        if ( isset( $params['filter-callback'] ) ) {
592            $this->mFilterCallback = $params['filter-callback'];
593        }
594
595        if ( isset( $params['hidelabel'] ) ) {
596            $this->mShowEmptyLabels = false;
597        }
598        if ( isset( $params['notices'] ) ) {
599            $this->mNotices = $params['notices'];
600        }
601
602        if ( isset( $params['hide-if'] ) && $params['hide-if'] ) {
603            $this->validateCondState( $params['hide-if'] );
604            $this->mCondState['hide'] = $params['hide-if'];
605            $this->mCondStateClass[] = 'mw-htmlform-hide-if';
606        }
607        if ( !( isset( $params['disabled'] ) && $params['disabled'] ) &&
608            isset( $params['disable-if'] ) && $params['disable-if']
609        ) {
610            $this->validateCondState( $params['disable-if'] );
611            $this->mCondState['disable'] = $params['disable-if'];
612            $this->mCondStateClass[] = 'mw-htmlform-disable-if';
613        }
614    }
615
616    /**
617     * Get the complete table row for the input, including help text,
618     * labels, and whatever.
619     * @stable to override
620     *
621     * @param string $value The value to set the input to.
622     *
623     * @return string Complete HTML table row.
624     */
625    public function getTableRow( $value ) {
626        [ $errors, $errorClass ] = $this->getErrorsAndErrorClass( $value );
627        $inputHtml = $this->getInputHTML( $value );
628        $fieldType = $this->getClassName();
629        $helptext = $this->getHelpTextHtmlTable( $this->getHelpText() );
630        $cellAttributes = [];
631        $rowAttributes = [];
632        $rowClasses = '';
633
634        if ( !empty( $this->mParams['vertical-label'] ) ) {
635            $cellAttributes['colspan'] = 2;
636            $verticalLabel = true;
637        } else {
638            $verticalLabel = false;
639        }
640
641        $label = $this->getLabelHtml( $cellAttributes );
642
643        $field = Html::rawElement(
644            'td',
645            [ 'class' => 'mw-input' ] + $cellAttributes,
646            $inputHtml . "\n$errors"
647        );
648
649        if ( $this->mCondState ) {
650            $rowAttributes['data-cond-state'] = FormatJson::encode( $this->parseCondStateForClient() );
651            $rowClasses .= implode( ' ', $this->mCondStateClass );
652        }
653
654        if ( $verticalLabel ) {
655            $html = Html::rawElement( 'tr',
656                $rowAttributes + [ 'class' => "mw-htmlform-vertical-label $rowClasses" ], $label );
657            $html .= Html::rawElement( 'tr',
658                $rowAttributes + [
659                    'class' => "mw-htmlform-field-$fieldType {$this->mClass} $errorClass $rowClasses"
660                ],
661                $field );
662        } else {
663            $html = Html::rawElement( 'tr',
664                $rowAttributes + [
665                    'class' => "mw-htmlform-field-$fieldType {$this->mClass} $errorClass $rowClasses"
666                ],
667                $label . $field );
668        }
669
670        return $html . $helptext;
671    }
672
673    /**
674     * Get the complete div for the input, including help text,
675     * labels, and whatever.
676     * @stable to override
677     * @since 1.20
678     *
679     * @param string $value The value to set the input to.
680     *
681     * @return string Complete HTML table row.
682     */
683    public function getDiv( $value ) {
684        [ $errors, $errorClass ] = $this->getErrorsAndErrorClass( $value );
685        $inputHtml = $this->getInputHTML( $value );
686        $fieldType = $this->getClassName();
687        $helptext = $this->getHelpTextHtmlDiv( $this->getHelpText() );
688        $cellAttributes = [];
689        $label = $this->getLabelHtml( $cellAttributes );
690
691        $outerDivClass = [
692            'mw-input',
693            'mw-htmlform-nolabel' => ( $label === '' )
694        ];
695
696        $horizontalLabel = $this->mParams['horizontal-label'] ?? false;
697
698        if ( $horizontalLabel ) {
699            $field = "\u{00A0}" . $inputHtml . "\n$errors";
700        } else {
701            $field = Html::rawElement(
702                'div',
703                // @phan-suppress-next-line PhanUselessBinaryAddRight
704                [ 'class' => $outerDivClass ] + $cellAttributes,
705                $inputHtml . "\n$errors"
706            );
707        }
708
709        $wrapperAttributes = [ 'class' => [
710            "mw-htmlform-field-$fieldType",
711            $this->mClass,
712            $this->mVFormClass,
713            $errorClass,
714        ] ];
715        if ( $this->mCondState ) {
716            $wrapperAttributes['data-cond-state'] = FormatJson::encode( $this->parseCondStateForClient() );
717            $wrapperAttributes['class'] = array_merge( $wrapperAttributes['class'], $this->mCondStateClass );
718        }
719        return Html::rawElement( 'div', $wrapperAttributes, $label . $field ) .
720            $helptext;
721    }
722
723    /**
724     * Get the OOUI version of the div. Falls back to getDiv by default.
725     * @stable to override
726     * @since 1.26
727     *
728     * @param string $value The value to set the input to.
729     *
730     * @return \OOUI\FieldLayout
731     */
732    public function getOOUI( $value ) {
733        $inputField = $this->getInputOOUI( $value );
734
735        if ( !$inputField ) {
736            // This field doesn't have an OOUI implementation yet at all. Fall back to getDiv() to
737            // generate the whole field, label and errors and all, then wrap it in a Widget.
738            // It might look weird, but it'll work OK.
739            return $this->getFieldLayoutOOUI(
740                new \OOUI\Widget( [ 'content' => new \OOUI\HtmlSnippet( $this->getDiv( $value ) ) ] ),
741                [ 'align' => 'top' ]
742            );
743        }
744
745        $infusable = true;
746        if ( is_string( $inputField ) ) {
747            // We have an OOUI implementation, but it's not proper, and we got a load of HTML.
748            // Cheat a little and wrap it in a widget. It won't be infusable, though, since client-side
749            // JavaScript doesn't know how to rebuilt the contents.
750            $inputField = new \OOUI\Widget( [ 'content' => new \OOUI\HtmlSnippet( $inputField ) ] );
751            $infusable = false;
752        }
753
754        $fieldType = $this->getClassName();
755        $help = $this->getHelpText();
756        $errors = $this->getErrorsRaw( $value );
757        foreach ( $errors as &$error ) {
758            $error = new \OOUI\HtmlSnippet( $error );
759        }
760
761        $config = [
762            'classes' => [ "mw-htmlform-field-$fieldType" ],
763            'align' => $this->getLabelAlignOOUI(),
764            'help' => ( $help !== null && $help !== '' ) ? new \OOUI\HtmlSnippet( $help ) : null,
765            'errors' => $errors,
766            'infusable' => $infusable,
767            'helpInline' => $this->isHelpInline(),
768            'notices' => $this->mNotices ?: [],
769        ];
770        if ( $this->mClass !== '' ) {
771            $config['classes'][] = $this->mClass;
772        }
773
774        $preloadModules = false;
775
776        if ( $infusable && $this->shouldInfuseOOUI() ) {
777            $preloadModules = true;
778            $config['classes'][] = 'mw-htmlform-autoinfuse';
779        }
780        if ( $this->mCondState ) {
781            $config['classes'] = array_merge( $config['classes'], $this->mCondStateClass );
782        }
783
784        // the element could specify, that the label doesn't need to be added
785        $label = $this->getLabel();
786        if ( $label && $label !== "\u{00A0}" && $label !== '&#160;' ) {
787            $config['label'] = new \OOUI\HtmlSnippet( $label );
788        }
789
790        if ( $this->mCondState ) {
791            $preloadModules = true;
792            $config['condState'] = $this->parseCondStateForClient();
793        }
794
795        $config['modules'] = $this->getOOUIModules();
796
797        if ( $preloadModules ) {
798            $this->mParent->getOutput()->addModules( 'mediawiki.htmlform.ooui' );
799            $this->mParent->getOutput()->addModules( $this->getOOUIModules() );
800        }
801
802        return $this->getFieldLayoutOOUI( $inputField, $config );
803    }
804
805    /**
806     * Get the Codex version of the div.
807     * @since 1.42
808     *
809     * @param string $value The value to set the input to.
810     * @return string HTML
811     */
812    public function getCodex( $value ) {
813        $isDisabled = ( $this->mParams['disabled'] ?? false );
814
815        // Label
816        $labelDiv = '';
817        $labelValue = trim( $this->getLabel() );
818        // For weird historical reasons, a non-breaking space is treated as an empty label
819        // Check for both a literal nbsp ("\u{00A0}") and the HTML-encoded version
820        if ( $labelValue !== '' && $labelValue !== "\u{00A0}" && $labelValue !== '&#160;' ) {
821            $labelFor = $this->needsLabel() ? [ 'for' => $this->mID ] : [];
822            $labelClasses = [ 'cdx-label' ];
823            if ( $isDisabled ) {
824                $labelClasses[] = 'cdx-label--disabled';
825            }
826            // <div class="cdx-label">
827            $labelDiv = Html::rawElement( 'div', [ 'class' => $labelClasses ],
828                // <label class="cdx-label__label" for="ID">
829                Html::rawElement( 'label', [ 'class' => 'cdx-label__label' ] + $labelFor,
830                    // <span class="cdx-label__label__text">
831                    Html::rawElement( 'span', [ 'class' => 'cdx-label__label__text' ],
832                        $labelValue
833                    )
834                )
835            );
836        }
837
838        // Help text
839        // <div class="cdx-field__help-text">
840        $helptext = $this->getHelpTextHtmlDiv( $this->getHelpText(), [ 'cdx-field__help-text' ] );
841
842        // Validation message
843        // <div class="cdx-field__validation-message">
844        // $errors is a <div class="cdx-message">
845        // FIXME right now this generates a block message (cdx-message--block), we want an inline message instead
846        $validationMessage = '';
847        [ $errors, $errorClass ] = $this->getErrorsAndErrorClass( $value );
848        if ( $errors !== '' ) {
849            $validationMessage = Html::rawElement( 'div', [ 'class' => 'cdx-field__validation-message' ],
850                $errors
851            );
852        }
853
854        // Control
855        $inputHtml = $this->getInputCodex( $value, $errors !== '' );
856        // <div class="cdx-field__control cdx-field__control--has-help-text">
857        $controlClasses = [ 'cdx-field__control' ];
858        if ( $helptext ) {
859            $controlClasses[] = 'cdx-field__control--has-help-text';
860        }
861        $control = Html::rawElement( 'div', [ 'class' => $controlClasses ], $inputHtml );
862
863        // <div class="cdx-field">
864        $fieldClasses = [
865            "mw-htmlform-field-{$this->getClassName()}",
866            $this->mClass,
867            $errorClass,
868            'cdx-field'
869        ];
870        if ( $isDisabled ) {
871            $fieldClasses[] = 'cdx-field--disabled';
872        }
873        $fieldAttributes = [];
874        // Set data attribute and CSS class for client side handling of hide-if / disable-if
875        if ( $this->mCondState ) {
876            $fieldAttributes['data-cond-state'] = FormatJson::encode( $this->parseCondStateForClient() );
877            $fieldClasses = array_merge( $fieldClasses, $this->mCondStateClass );
878        }
879
880        return Html::rawElement( 'div', [ 'class' => $fieldClasses ] + $fieldAttributes,
881            $labelDiv . $control . $helptext . $validationMessage
882        );
883    }
884
885    /**
886     * Gets the non namespaced class name
887     *
888     * @since 1.36
889     *
890     * @return string
891     */
892    protected function getClassName() {
893        $name = explode( '\\', static::class );
894        return end( $name );
895    }
896
897    /**
898     * Get label alignment when generating field for OOUI.
899     * @stable to override
900     * @return string 'left', 'right', 'top' or 'inline'
901     */
902    protected function getLabelAlignOOUI() {
903        return 'top';
904    }
905
906    /**
907     * Get a FieldLayout (or subclass thereof) to wrap this field in when using OOUI output.
908     * @param \OOUI\Widget $inputField
909     * @param array $config
910     * @return \OOUI\FieldLayout
911     */
912    protected function getFieldLayoutOOUI( $inputField, $config ) {
913        return new HTMLFormFieldLayout( $inputField, $config );
914    }
915
916    /**
917     * Whether the field should be automatically infused. Note that all OOUI HTMLForm fields are
918     * infusable (you can call OO.ui.infuse() on them), but not all are infused by default, since
919     * there is no benefit in doing it e.g. for buttons and it's a small performance hit on page load.
920     * @stable to override
921     *
922     * @return bool
923     */
924    protected function shouldInfuseOOUI() {
925        // Always infuse fields with popup help text, since the interface for it is nicer with JS
926        return !$this->isHelpInline() && $this->getHelpMessages();
927    }
928
929    /**
930     * Get the list of extra ResourceLoader modules which must be loaded client-side before it's
931     * possible to infuse this field's OOUI widget.
932     * @stable to override
933     *
934     * @return string[]
935     */
936    protected function getOOUIModules() {
937        return [];
938    }
939
940    /**
941     * Get the complete raw fields for the input, including help text,
942     * labels, and whatever.
943     * @stable to override
944     * @since 1.20
945     *
946     * @param string $value The value to set the input to.
947     *
948     * @return string Complete HTML table row.
949     */
950    public function getRaw( $value ) {
951        [ $errors, ] = $this->getErrorsAndErrorClass( $value );
952        return "\n" . $errors .
953            $this->getLabelHtml() .
954            $this->getInputHTML( $value ) .
955            $this->getHelpTextHtmlRaw( $this->getHelpText() );
956    }
957
958    /**
959     * Get the complete field for the input, including help text,
960     * labels, and whatever. Fall back from 'vform' to 'div' when not overridden.
961     *
962     * @stable to override
963     * @since 1.25
964     * @param string $value The value to set the input to.
965     * @return string Complete HTML field.
966     */
967    public function getVForm( $value ) {
968        // Ewwww
969        $this->mVFormClass = ' mw-ui-vform-field';
970        return $this->getDiv( $value );
971    }
972
973    /**
974     * Get the complete field as an inline element.
975     * @stable to override
976     * @since 1.25
977     * @param string $value The value to set the input to.
978     * @return string Complete HTML inline element
979     */
980    public function getInline( $value ) {
981        [ $errors, ] = $this->getErrorsAndErrorClass( $value );
982        return "\n" . $errors .
983            $this->getLabelHtml() .
984            "\u{00A0}" .
985            $this->getInputHTML( $value ) .
986            $this->getHelpTextHtmlDiv( $this->getHelpText() );
987    }
988
989    /**
990     * Generate help text HTML in table format
991     * @since 1.20
992     *
993     * @param string|null $helptext
994     * @return string
995     */
996    public function getHelpTextHtmlTable( $helptext ) {
997        if ( $helptext === null ) {
998            return '';
999        }
1000
1001        $rowAttributes = [];
1002        if ( $this->mCondState ) {
1003            $rowAttributes['data-cond-state'] = FormatJson::encode( $this->parseCondStateForClient() );
1004            $rowAttributes['class'] = $this->mCondStateClass;
1005        }
1006
1007        $tdClasses = [ 'htmlform-tip' ];
1008        if ( $this->mHelpClass !== false ) {
1009            $tdClasses[] = $this->mHelpClass;
1010        }
1011        return Html::rawElement( 'tr', $rowAttributes,
1012            Html::rawElement( 'td', [ 'colspan' => 2, 'class' => $tdClasses ], $helptext )
1013        );
1014    }
1015
1016    /**
1017     * Generate help text HTML in div format
1018     * @since 1.20
1019     *
1020     * @param string|null $helptext
1021     * @param string[] $cssClasses
1022     *
1023     * @return string
1024     */
1025    public function getHelpTextHtmlDiv( $helptext, $cssClasses = [] ) {
1026        if ( $helptext === null ) {
1027            return '';
1028        }
1029
1030        $wrapperAttributes = [
1031            'class' => array_merge( $cssClasses, [ 'htmlform-tip' ] ),
1032        ];
1033        if ( $this->mHelpClass !== false ) {
1034            $wrapperAttributes['class'][] = $this->mHelpClass;
1035        }
1036        if ( $this->mCondState ) {
1037            $wrapperAttributes['data-cond-state'] = FormatJson::encode( $this->parseCondStateForClient() );
1038            $wrapperAttributes['class'] = array_merge( $wrapperAttributes['class'], $this->mCondStateClass );
1039        }
1040        return Html::rawElement( 'div', $wrapperAttributes, $helptext );
1041    }
1042
1043    /**
1044     * Generate help text HTML formatted for raw output
1045     * @since 1.20
1046     *
1047     * @param string|null $helptext
1048     * @return string
1049     */
1050    public function getHelpTextHtmlRaw( $helptext ) {
1051        return $this->getHelpTextHtmlDiv( $helptext );
1052    }
1053
1054    private function getHelpMessages(): array {
1055        if ( isset( $this->mParams['help-message'] ) ) {
1056            return [ $this->mParams['help-message'] ];
1057        } elseif ( isset( $this->mParams['help-messages'] ) ) {
1058            return $this->mParams['help-messages'];
1059        } elseif ( isset( $this->mParams['help'] ) ) {
1060            return [ new HtmlArmor( $this->mParams['help'] ) ];
1061        }
1062
1063        return [];
1064    }
1065
1066    /**
1067     * Determine the help text to display
1068     * @stable to override
1069     * @since 1.20
1070     * @return string|null HTML
1071     */
1072    public function getHelpText() {
1073        $html = [];
1074
1075        foreach ( $this->getHelpMessages() as $msg ) {
1076            if ( $msg instanceof HtmlArmor ) {
1077                $html[] = HtmlArmor::getHtml( $msg );
1078            } else {
1079                $msg = $this->getMessage( $msg );
1080                if ( $msg->exists() ) {
1081                    $html[] = $msg->parse();
1082                }
1083            }
1084        }
1085
1086        return $html ? implode( $this->msg( 'word-separator' )->escaped(), $html ) : null;
1087    }
1088
1089    /**
1090     * Determine if the help text should be displayed inline.
1091     *
1092     * Only applies to OOUI forms.
1093     *
1094     * @since 1.31
1095     * @return bool
1096     */
1097    public function isHelpInline() {
1098        return $this->mParams['help-inline'] ?? true;
1099    }
1100
1101    /**
1102     * Determine form errors to display and their classes
1103     * @since 1.20
1104     *
1105     * phan-taint-check gets confused with returning both classes
1106     * and errors and thinks double escaping is happening, so specify
1107     * that return value has no taint.
1108     *
1109     * @param string $value The value of the input
1110     * @return array [ $errors, $errorClass ]
1111     * @return-taint none
1112     */
1113    public function getErrorsAndErrorClass( $value ) {
1114        $errors = $this->validate( $value, $this->mParent->mFieldData );
1115
1116        if ( is_bool( $errors ) || !$this->mParent->wasSubmitted() ) {
1117            return [ '', '' ];
1118        }
1119
1120        return [ self::formatErrors( $errors ), 'mw-htmlform-invalid-input' ];
1121    }
1122
1123    /**
1124     * Determine form errors to display, returning them in an array.
1125     *
1126     * @since 1.26
1127     * @param string $value The value of the input
1128     * @return string[] Array of error HTML strings
1129     */
1130    public function getErrorsRaw( $value ) {
1131        $errors = $this->validate( $value, $this->mParent->mFieldData );
1132
1133        if ( is_bool( $errors ) || !$this->mParent->wasSubmitted() ) {
1134            return [];
1135        }
1136
1137        if ( !is_array( $errors ) ) {
1138            $errors = [ $errors ];
1139        }
1140        foreach ( $errors as &$error ) {
1141            if ( $error instanceof Message ) {
1142                $error = $error->parse();
1143            }
1144        }
1145
1146        return $errors;
1147    }
1148
1149    /**
1150     * @stable to override
1151     * @return string HTML
1152     */
1153    public function getLabel() {
1154        return $this->mLabel ?? '';
1155    }
1156
1157    /**
1158     * @stable to override
1159     * @param array $cellAttributes
1160     *
1161     * @return string
1162     */
1163    public function getLabelHtml( $cellAttributes = [] ) {
1164        # Don't output a for= attribute for labels with no associated input.
1165        # Kind of hacky here, possibly we don't want these to be <label>s at all.
1166        $for = $this->needsLabel() ? [ 'for' => $this->mID ] : [];
1167
1168        $labelValue = trim( $this->getLabel() );
1169        $hasLabel = $labelValue !== '' && $labelValue !== "\u{00A0}" && $labelValue !== '&#160;';
1170
1171        $displayFormat = $this->mParent->getDisplayFormat();
1172        $horizontalLabel = $this->mParams['horizontal-label'] ?? false;
1173
1174        if ( $displayFormat === 'table' ) {
1175            return Html::rawElement( 'td',
1176                    [ 'class' => 'mw-label' ] + $cellAttributes,
1177                    Html::rawElement( 'label', $for, $labelValue ) );
1178        } elseif ( $hasLabel || $this->mShowEmptyLabels ) {
1179            if ( $displayFormat === 'div' && !$horizontalLabel ) {
1180                return Html::rawElement( 'div',
1181                        [ 'class' => 'mw-label' ] + $cellAttributes,
1182                        Html::rawElement( 'label', $for, $labelValue ) );
1183            } else {
1184                return Html::rawElement( 'label', $for, $labelValue );
1185            }
1186        }
1187
1188        return '';
1189    }
1190
1191    /**
1192     * @stable to override
1193     * @return mixed
1194     */
1195    public function getDefault() {
1196        return $this->mDefault ?? null;
1197    }
1198
1199    /**
1200     * Returns the attributes required for the tooltip and accesskey, for Html::element() etc.
1201     *
1202     * @return array Attributes
1203     */
1204    public function getTooltipAndAccessKey() {
1205        if ( empty( $this->mParams['tooltip'] ) ) {
1206            return [];
1207        }
1208
1209        return Linker::tooltipAndAccesskeyAttribs( $this->mParams['tooltip'] );
1210    }
1211
1212    /**
1213     * Returns the attributes required for the tooltip and accesskey, for OOUI widgets' config.
1214     *
1215     * @return array Attributes
1216     */
1217    public function getTooltipAndAccessKeyOOUI() {
1218        if ( empty( $this->mParams['tooltip'] ) ) {
1219            return [];
1220        }
1221
1222        return [
1223            'title' => Linker::titleAttrib( $this->mParams['tooltip'] ),
1224            'accessKey' => Linker::accesskey( $this->mParams['tooltip'] ),
1225        ];
1226    }
1227
1228    /**
1229     * Returns the given attributes from the parameters
1230     * @stable to override
1231     *
1232     * @param array $list List of attributes to get
1233     * @return array Attributes
1234     */
1235    public function getAttributes( array $list ) {
1236        static $boolAttribs = [ 'disabled', 'required', 'autofocus', 'multiple', 'readonly' ];
1237
1238        $ret = [];
1239        foreach ( $list as $key ) {
1240            if ( in_array( $key, $boolAttribs ) ) {
1241                if ( !empty( $this->mParams[$key] ) ) {
1242                    $ret[$key] = '';
1243                }
1244            } elseif ( isset( $this->mParams[$key] ) ) {
1245                $ret[$key] = $this->mParams[$key];
1246            }
1247        }
1248
1249        return $ret;
1250    }
1251
1252    /**
1253     * Given an array of msg-key => value mappings, returns an array with keys
1254     * being the message texts. It also forces values to strings.
1255     *
1256     * @param array $options
1257     * @param bool $needsParse
1258     * @return array
1259     * @return-taint tainted
1260     */
1261    private function lookupOptionsKeys( $options, $needsParse ) {
1262        $ret = [];
1263        foreach ( $options as $key => $value ) {
1264            $msg = $this->msg( $key );
1265            $msgAsText = $needsParse ? $msg->parse() : $msg->plain();
1266            if ( array_key_exists( $msgAsText, $ret ) ) {
1267                LoggerFactory::getInstance( 'error' )->error(
1268                    'The option that uses the message key {msg_key_one} has the same translation as ' .
1269                    'another option in {lang}. This means that {msg_key_one} will not be used as an option.',
1270                    [
1271                        'msg_key_one' => $key,
1272                        'lang' => $this->mParent ?
1273                            $this->mParent->getLanguageCode()->toBcp47Code() :
1274                            RequestContext::getMain()->getLanguageCode()->toBcp47Code(),
1275                    ]
1276                );
1277                continue;
1278            }
1279            $ret[$msgAsText] = is_array( $value )
1280                ? $this->lookupOptionsKeys( $value, $needsParse )
1281                : strval( $value );
1282        }
1283        return $ret;
1284    }
1285
1286    /**
1287     * Recursively forces values in an array to strings, because issues arise
1288     * with integer 0 as a value.
1289     *
1290     * @param array|string $array
1291     * @return array|string
1292     */
1293    public static function forceToStringRecursive( $array ) {
1294        if ( is_array( $array ) ) {
1295            return array_map( [ __CLASS__, 'forceToStringRecursive' ], $array );
1296        } else {
1297            return strval( $array );
1298        }
1299    }
1300
1301    /**
1302     * Fetch the array of options from the field's parameters. In order, this
1303     * checks 'options-messages', 'options', then 'options-message'.
1304     *
1305     * @return array|null
1306     */
1307    public function getOptions() {
1308        if ( $this->mOptions === false ) {
1309            if ( array_key_exists( 'options-messages', $this->mParams ) ) {
1310                $needsParse = $this->mParams['options-messages-parse'] ?? false;
1311                if ( $needsParse ) {
1312                    $this->mOptionsLabelsNotFromMessage = true;
1313                }
1314                $this->mOptions = $this->lookupOptionsKeys( $this->mParams['options-messages'], $needsParse );
1315            } elseif ( array_key_exists( 'options', $this->mParams ) ) {
1316                $this->mOptionsLabelsNotFromMessage = true;
1317                $this->mOptions = self::forceToStringRecursive( $this->mParams['options'] );
1318            } elseif ( array_key_exists( 'options-message', $this->mParams ) ) {
1319                $message = $this->getMessage( $this->mParams['options-message'] )->inContentLanguage()->plain();
1320                $this->mOptions = Html::listDropdownOptions( $message );
1321            } else {
1322                $this->mOptions = null;
1323            }
1324        }
1325
1326        return $this->mOptions;
1327    }
1328
1329    /**
1330     * Get options and make them into arrays suitable for OOUI.
1331     * @stable to override
1332     * @return array|null Options for inclusion in a select or whatever.
1333     */
1334    public function getOptionsOOUI() {
1335        $oldoptions = $this->getOptions();
1336
1337        if ( $oldoptions === null ) {
1338            return null;
1339        }
1340
1341        return Html::listDropdownOptionsOoui( $oldoptions );
1342    }
1343
1344    /**
1345     * flatten an array of options to a single array, for instance,
1346     * a set of "<options>" inside "<optgroups>".
1347     *
1348     * @param array $options Associative Array with values either Strings or Arrays
1349     * @return array Flattened input
1350     */
1351    public static function flattenOptions( $options ) {
1352        $flatOpts = [];
1353
1354        foreach ( $options as $value ) {
1355            if ( is_array( $value ) ) {
1356                $flatOpts = array_merge( $flatOpts, self::flattenOptions( $value ) );
1357            } else {
1358                $flatOpts[] = $value;
1359            }
1360        }
1361
1362        return $flatOpts;
1363    }
1364
1365    /**
1366     * Formats one or more errors as accepted by field validation-callback.
1367     *
1368     * @param string|Message|array $errors Array of strings or Message instances
1369     * To work around limitations in phan-taint-check the calling
1370     * class has taintedness disabled. So instead we pretend that
1371     * this method outputs html, since the result is eventually
1372     * outputted anyways without escaping and this allows us to verify
1373     * stuff is safe even though the caller has taintedness cleared.
1374     * @param-taint $errors exec_html
1375     * @return string HTML
1376     * @since 1.18
1377     */
1378    protected static function formatErrors( $errors ) {
1379        if ( is_array( $errors ) && count( $errors ) === 1 ) {
1380            $errors = array_shift( $errors );
1381        }
1382
1383        if ( is_array( $errors ) ) {
1384            foreach ( $errors as &$error ) {
1385                $error = Html::rawElement( 'li', [],
1386                    $error instanceof Message ? $error->parse() : $error
1387                );
1388            }
1389            $errors = Html::rawElement( 'ul', [], implode( "\n", $errors ) );
1390        } elseif ( $errors instanceof Message ) {
1391            $errors = $errors->parse();
1392        }
1393
1394        return Html::errorBox( $errors );
1395    }
1396
1397    /**
1398     * Turns a *-message parameter (which could be a MessageSpecifier, or a message name, or a
1399     * name + parameters array) into a Message.
1400     * @param mixed $value
1401     * @return Message
1402     */
1403    protected function getMessage( $value ) {
1404        $message = Message::newFromSpecifier( $value );
1405
1406        if ( $this->mParent ) {
1407            $message->setContext( $this->mParent );
1408        }
1409
1410        return $message;
1411    }
1412
1413    /**
1414     * Skip this field when collecting data.
1415     * @stable to override
1416     * @param WebRequest $request
1417     * @return bool
1418     * @since 1.27
1419     */
1420    public function skipLoadData( $request ) {
1421        return !empty( $this->mParams['nodata'] );
1422    }
1423
1424    /**
1425     * Whether this field requires the user agent to have JavaScript enabled for the client-side HTML5
1426     * form validation to work correctly.
1427     *
1428     * @return bool
1429     * @since 1.29
1430     */
1431    public function needsJSForHtml5FormValidation() {
1432        // This is probably more restrictive than it needs to be, but better safe than sorry
1433        return (bool)$this->mCondState;
1434    }
1435}
1436
1437/** @deprecated class alias since 1.42 */
1438class_alias( HTMLFormField::class, 'HTMLFormField' );