Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 371
0.00% covered (danger)
0.00%
0 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialEditChecks
0.00% covered (danger)
0.00%
0 / 371
0.00% covered (danger)
0.00%
0 / 19
9702
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 69
0.00% covered (danger)
0.00%
0 / 1
132
 collectChecks
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
90
 buildTableHtml
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
42
 buildRowHtml
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
72
 buildEditCheckActionWidget
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
110
 getConfigValueFromData
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
56
 configDetails
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 matchItemDetails
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 getMessageExpressionPattern
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 parseMessage
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
72
 extractStaticValue
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
56
 extractStaticAssignmentExpression
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 extractDefaultConfig
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 tryJsonDecodeObjectString
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
6
 jsonTableFromObjectString
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 convertSingleQuotedToDoubleQuoted
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
156
 jsonTable
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Special page listing Edit Checks and their configuration.
4 */
5
6namespace MediaWiki\Extension\VisualEditor;
7
8use MediaWiki\Config\Config;
9use MediaWiki\Config\ConfigFactory;
10use MediaWiki\Content\JsonContent;
11use MediaWiki\Extension\VisualEditor\EditCheck\ResourceLoaderData;
12use MediaWiki\Html\Html;
13use MediaWiki\Language\RawMessage;
14use MediaWiki\MediaWikiServices;
15use MediaWiki\SpecialPage\SpecialPage;
16use MediaWiki\Title\Title;
17use OOUI\MessageWidget;
18
19class SpecialEditChecks extends SpecialPage {
20    private readonly Config $config;
21
22    /**
23     * @inheritDoc
24     */
25    public function __construct(
26        private readonly Config $coreConfig,
27        ConfigFactory $configFactory
28    ) {
29        parent::__construct( 'EditChecks' );
30
31        $this->config = $configFactory->makeConfig( 'visualeditor' );
32    }
33
34    /**
35     * @inheritDoc
36     */
37    protected function getGroupName() {
38        return 'wiki';
39    }
40
41    /**
42     * @inheritDoc
43     */
44    public function execute( $par ) {
45        $this->setHeaders();
46        $out = $this->getOutput();
47        $out->enableOOUI();
48        $out->addModuleStyles( [
49            'oojs-ui.styles.icons-interactions',
50            'ext.visualEditor.editCheck.special',
51            'mediawiki.content.json'
52        ] );
53
54        $baseDir = dirname( __DIR__ );
55        $checksDir = $baseDir . '/editcheck/modules/editchecks';
56        $experimentalDir = $checksDir . '/experimental';
57        $abstractClasses = [
58            'BaseEditCheck.js',
59            'AsyncTextCheck.js',
60        ];
61        $onWikiConfig = ResourceLoaderData::getConfig( $this->getContext() );
62
63        $out->addHtml( $this->msg( 'editcheck-specialeditchecks-info' )->parseAsBlock() );
64
65        $abChecks = [];
66        $defaultChecks = $this->collectChecks( $checksDir . '/*.js', $abstractClasses, false, true, $onWikiConfig );
67        $disabledChecks = $this->collectChecks( $checksDir . '/*.js', $abstractClasses, false, false, $onWikiConfig );
68        $abTest = MediaWikiServices::getInstance()->getMainConfig()->get( 'VisualEditorEditCheckABTest' );
69        if ( $abTest !== null ) {
70            // Extract AB test check
71            foreach ( $defaultChecks as $i => $check ) {
72                if ( $check['name'] === (string)$abTest ) {
73                    $abChecks[] = $check;
74                    unset( $defaultChecks[$i] );
75                }
76            }
77            foreach ( $disabledChecks as $i => $check ) {
78                if ( $check['name'] === (string)$abTest ) {
79                    $abChecks[] = $check;
80                    unset( $disabledChecks[$i] );
81                }
82            }
83        }
84
85        $out->addHTML( Html::element( 'h2', [], $this->msg( 'editcheck-specialeditchecks-header-default' )->text() ) );
86        $out->addHTML( $this->buildTableHtml( $defaultChecks, $onWikiConfig ) );
87
88        if ( $abChecks ) {
89            $out->addHTML( Html::element( 'h2', [],
90                $this->msg( 'editcheck-specialeditchecks-header-abtest' )->text() ) );
91            $out->addHTML( $this->buildTableHtml( $abChecks, $onWikiConfig ) );
92        }
93
94        if ( is_dir( $experimentalDir ) ) {
95            $experimentalDisabledChecks = $this->collectChecks(
96                $experimentalDir . '/*.js', [], false, false, $onWikiConfig
97            );
98            $experimentalEnabledChecks = $this->collectChecks(
99                $experimentalDir . '/*.js', [], false, true, $onWikiConfig
100            );
101            $allDisabledChecks = array_merge( $disabledChecks, $experimentalDisabledChecks );
102
103            if ( $this->coreConfig->get( 'VisualEditorEnableEditCheckSuggestionsBeta' ) ) {
104                // Split beta and experimental checks based on if they are enabled by default
105                $out->addHTML( Html::rawElement( 'h2', [],
106                    Html::element( 'a', [
107                        'href' => $this->getTitleFor( 'Preferences' )->getLocalURL() . '#mw-prefsection-betafeatures',
108                    ],
109                    $this->msg( 'editcheck-specialeditchecks-header-betafeatures' )->text() ) )
110                );
111                $out->addHTML( $this->buildTableHtml( $experimentalEnabledChecks, $onWikiConfig, true ) );
112
113                $out->addHTML( Html::element( 'h2', [],
114                    $this->msg( 'editcheck-specialeditchecks-header-experimental' )->text() ) );
115                $out->addHTML( $this->buildTableHtml( $allDisabledChecks, $onWikiConfig, true ) );
116            } else {
117                $allExperimentalChecks = array_merge( $experimentalEnabledChecks, $allDisabledChecks );
118                // Sort checks by 'name' property
119                usort( $allExperimentalChecks, static function ( $a, $b ) {
120                    return strcmp( $a['name'], $b['name'] );
121                } );
122                $out->addHTML( Html::element( 'h2', [],
123                    $this->msg( 'editcheck-specialeditchecks-header-experimental' )->text() ) );
124                $out->addHTML( $this->buildTableHtml( $allExperimentalChecks, $onWikiConfig, true ) );
125            }
126        }
127
128        $baseCheck = $this->collectChecks( $checksDir . '/BaseEditCheck.js', [], true );
129        if ( isset( $baseCheck[0]['defaultConfig'] ) ) {
130            $out->addHTML( Html::element( 'h2', [], $this->msg( 'editcheck-specialeditchecks-header-base' )->text() ) );
131            $out->addHTML( $this->configDetails(
132                $this->jsonTableFromObjectString( $baseCheck[ 0 ]['defaultConfig'] ),
133                isset( $onWikiConfig['*'] ) ? $this->jsonTable( $onWikiConfig['*'] ) : ''
134            ) );
135        }
136    }
137
138    /**
139     * Collect edit checks from the given directory.
140     *
141     * @param string $glob Glob pattern for files
142     * @param array $excludeFiles List of filenames to exclude
143     * @param bool $includeAbstract Whether to include abstract classes
144     * @param bool|null $onlyWithEnabledValue If a boolean, only checks whose 'enabled' value matches this
145     * @return array List of edit checks with metadata
146     */
147    private function collectChecks(
148        string $glob, array $excludeFiles = [], bool $includeAbstract = false,
149        ?bool $onlyWithEnabledValue = null, array $onWikiConfig = []
150    ): array {
151        $checks = [];
152        $files = glob( $glob ) ?: [];
153        foreach ( $files as $file ) {
154            if ( in_array( basename( $file ), $excludeFiles, true ) ) {
155                continue;
156            }
157            $src = file_get_contents( $file );
158            if ( $src === false ) {
159                continue;
160            }
161
162            $name = $this->extractStaticValue( $src, 'name' );
163
164            // Skip abstract classes (those without a name)
165            if ( !$includeAbstract && $name === '' ) {
166                continue;
167            }
168
169            $checkData = [
170                'file' => $file,
171                'name' => $name,
172                'title' => $this->extractStaticValue( $src, 'title' ),
173                'description' => $this->extractStaticValue( $src, 'description' ),
174                'prompt' => $this->extractStaticValue( $src, 'prompt' ),
175                'footer' => $this->extractStaticValue( $src, 'footer' ),
176                'choices' => $this->extractStaticValue( $src, 'choices' ),
177                'defaultConfig' => $this->extractDefaultConfig( $src ),
178            ];
179
180            // Filter by enabled value if requested
181            if ( $onlyWithEnabledValue !== null ) {
182                $enabled = $this->getConfigValueFromData( $checkData, $onWikiConfig, 'enabled' ) ?? true;
183                if ( $enabled !== $onlyWithEnabledValue ) {
184                    continue;
185                }
186            }
187
188            $checks[] = $checkData;
189        }
190        usort( $checks, static function ( $a, $b ) {
191            return strcmp( basename( $a['file'] ), basename( $b['file'] ) );
192        } );
193        return $checks;
194    }
195
196    /**
197     * Build HTML table listing the given edit checks.
198     *
199     * @param array $checks List of edit checks
200     * @param array $onWikiConfig On-wiki configuration overrides
201     * @param bool $suggestions
202     * @return string
203     */
204    private function buildTableHtml(
205        array $checks, array $onWikiConfig, bool $suggestions = false
206    ): string {
207        if ( !$checks ) {
208            return Html::element( 'p', [], $this->msg( 'table_pager_empty' )->text() );
209        }
210        $html = Html::openElement( 'table', [ 'class' => 'wikitable mw-editchecks' ] );
211        $html .= Html::rawElement( 'tr', [],
212            Html::element( 'th', [ 'class' => 'mw-editchecks-name' ],
213                $this->msg( 'editcheck-specialeditchecks-col-name' )->text() ) .
214            Html::element( 'th', [ 'class' => 'mw-editchecks-appearance' ],
215                $this->msg( 'editcheck-specialeditchecks-col-appearance' )->text() ) .
216            Html::element( 'th', [ 'class' => 'mw-editchecks-config' ],
217                $this->msg( 'editcheck-specialeditchecks-config-summary' )->text() )
218        );
219        foreach ( $checks as $checkData ) {
220            $html .= $this->buildRowHtml( $checkData, $onWikiConfig, $suggestions );
221            if ( $checkData['name'] === 'textMatch' ) {
222                $matchItems = $this->getConfigValueFromData( $checkData, $onWikiConfig, 'matchItems' ) ?? [];
223                foreach ( $matchItems as $name => $item ) {
224                    if ( isset( $item['import'] ) ) {
225                        $importTitle = Title::newFromText( $item['import'] );
226                        $item = json_decode( $this->msg( $importTitle->getText() )->inContentLanguage()->text(), true );
227                    }
228                    $mode = $item['mode'] ?? '';
229                    // Filter choices to ones containing the mode if requested
230                    $choices = array_filter(
231                        $checkData['choices'] ?? [],
232                        static function ( $choice ) use ( $mode ) {
233                            return in_array( $mode, $choice['modes'], true );
234                        }
235                    );
236                    $matchCheckData = [
237                        'file' => '',
238                        'name' => $checkData['name'] . " ($name)",
239                        'title' => $item['title'] ?? '',
240                        'description' => new \OOUI\HtmlSnippet( ( new RawMessage( $item['message'] ?? '' ) )->parse() ),
241                        'prompt' => $item['prompt'] ?? '',
242                        'footer' => $item['footer'] ?? '',
243                        'choices' => $choices,
244                        'defaultConfig' => json_encode( $item['config'] ?? '' ),
245                        'matchItem' => $item,
246                    ];
247                    $html .= $this->buildRowHtml( $matchCheckData, $onWikiConfig, $suggestions );
248                }
249            }
250        }
251        $html .= Html::closeElement( 'table' );
252        return $html;
253    }
254
255    /**
256     * Build HTML for a single edit check row.
257     *
258     * @param array $checkData Edit check data
259     * @param array $onWikiConfig On-wiki configuration overrides
260     * @param bool $suggestions
261     * @return string Row HTML or empty string if filtered out
262     */
263    private function buildRowHtml(
264        array $checkData, array $onWikiConfig, bool $suggestions = false
265    ): string {
266        $html = '';
267        $override = '';
268        if ( isset( $onWikiConfig[$checkData['name']] ) ) {
269            $override = $this->jsonTable( $onWikiConfig[$checkData['name']] );
270        }
271        $defaultConfig = '';
272        if ( $checkData['defaultConfig'] ) {
273            $defaultConfig = $this->jsonTableFromObjectString( $checkData['defaultConfig'] );
274        }
275
276        if ( empty( $checkData['title'] ) && empty( $checkData['description'] ) ) {
277            $widget = '';
278        } else {
279            $widget = $this->buildEditCheckActionWidget( $checkData, $suggestions );
280        }
281
282        $html .= Html::rawElement( 'tr', [],
283            Html::rawElement( 'td', [],
284                Html::element( 'strong', [], $checkData['name'] ) .
285                Html::element( 'div', [], basename( $checkData['file'] ) )
286            ) .
287            Html::rawElement( 'td', [],
288                Html::rawElement(
289                    'div',
290                    [ 'class' => 've-ui-editCheckDialog' ],
291                    $widget
292                )
293            ) .
294            Html::rawElement( 'td', [],
295                ( $defaultConfig !== '' || $override !== '' ?
296                    $this->configDetails( $defaultConfig, $override ) : ''
297                ) .
298                ( !empty( $checkData['matchItem'] ) ?
299                    $this->matchItemDetails( $checkData['matchItem'] ) : ''
300                )
301            )
302        );
303        return $html;
304    }
305
306    private function buildEditCheckActionWidget( array $checkData, bool $suggestion ): MessageWidget {
307        $widget = new MessageWidget(
308            [
309                'type' => $suggestion ? 'progressive' : 'warning',
310                'icon' => $suggestion ? 'lightbulb' : null,
311                'label' => $checkData['title'] ?: "\u{00A0}",
312                'classes' => [ 've-ui-editCheckActionWidget' ]
313            ]
314        );
315        if ( $suggestion ) {
316            $widget->clearFlags()->setFlags( [ 'progressive' ] );
317        }
318        if ( $suggestion ) {
319            $widget->addClasses( [ 've-ui-editCheckActionWidget-suggestion' ] );
320        }
321        $actions = new \OOUI\Tag( 'div' );
322        $actions->addClasses( [ 've-ui-editCheckActionWidget-actions' ]    );
323        if ( $checkData['prompt'] ) {
324            $actions
325                ->addClasses( [ 've-ui-editCheckActionWidget-actions-prompted' ] )
326                ->appendContent(
327                    new \OOUI\LabelWidget( [
328                        'label' => $checkData['prompt'],
329                        'classes' => [ 've-ui-editCheckActionWidget-prompt' ]
330                    ] ),
331                );
332        }
333        $body = ( new \OOUI\Tag( 'div' ) )->addClasses( [ 've-ui-editCheckActionWidget-body' ] );
334        $widget->appendContent(
335            $body
336                ->appendContent( new \OOUI\LabelWidget( [ 'label' => $checkData['description'] ] ) )
337                ->appendContent( $actions )
338        );
339        if ( $checkData['footer'] ) {
340            $body->appendContent(
341                new \OOUI\LabelWidget( [
342                    'label' => $checkData['footer'],
343                    'classes' => [ 've-ui-editCheckActionWidget-footer' ]
344                ] ),
345            );
346        }
347
348        if ( !empty( $checkData['choices'] ) ) {
349            foreach ( $checkData['choices'] as $choice ) {
350                $actionButton = new \OOUI\ButtonWidget( [
351                    'label' => $choice[ 'label' ],
352                    'flags' => $choice['flags'] ?? [],
353                    'icon' => $choice['icon'] ?? null,
354                    'classes' => [ 'oo-ui-actionWidget' ],
355                ] );
356                $actions->appendContent( $actionButton );
357            }
358        }
359        return $widget;
360    }
361
362    /**
363     * Get a configuration value for a given check from on-wiki config or default config.
364     *
365     * @param array $checkData Check metadata
366     * @param array $onWikiConfig On-wiki configuration overrides
367     * @param string $key Configuration key to retrieve
368     * @return mixed|null JSON encoded value or null if not found
369     */
370    private function getConfigValueFromData( array $checkData, array $onWikiConfig, string $key ) {
371        // Check on-wiki config first
372        if ( isset( $onWikiConfig[$checkData['name']] ) &&
373            is_array( $onWikiConfig[$checkData['name']] ) &&
374            array_key_exists( $key, $onWikiConfig[$checkData['name']] )
375        ) {
376            return $onWikiConfig[$checkData['name']][$key];
377        } elseif ( $checkData['defaultConfig'] !== '' ) {
378            // Fallback to default config
379            $defaultConfig = $this->tryJsonDecodeObjectString( $checkData['defaultConfig'] );
380            if ( is_array( $defaultConfig ) && array_key_exists( $key, $defaultConfig ) ) {
381                return $defaultConfig[$key];
382            }
383        }
384        return null;
385    }
386
387    /**
388     * Build the details element showing default and on-wiki configuration.
389     *
390     * @param string $defaultConfig Default configuration display
391     * @param string $override On-wiki override display
392     * @return string
393     */
394    private function configDetails( string $defaultConfig, string $override ): string {
395        return ( $defaultConfig !== '' ?
396            Html::element( 'strong', [ 'class' => 'mw-editchecks-config-header' ],
397                $this->msg( 'editcheck-specialeditchecks-config-default' )->text() ) .
398            $defaultConfig
399        : '' ) .
400        ( $override !== '' ?
401            Html::rawElement( 'details', [],
402                Html::rawElement( 'summary', [],
403                    Html::element( 'strong', [ 'class' => 'mw-editchecks-config-header' ],
404                        $this->msg( 'editcheck-specialeditchecks-config-onwiki' )->text() )
405                ) .
406                $override
407            )
408            : ''
409        );
410    }
411
412    /**
413     * Build the details element showing a textMatch matchItem configuration.
414     *
415     * @param array $matchItem Match item data
416     * @return string
417     */
418    private function matchItemDetails( array $matchItem ): string {
419        // Skip already displayed fields
420        $matchItemFiltered = array_filter(
421            $matchItem,
422            static function ( $key ) {
423                return !in_array( $key, [ 'config', 'title', 'message', 'prompt', 'footer' ], true );
424            },
425            ARRAY_FILTER_USE_KEY
426        );
427
428        return Html::rawElement( 'details', [],
429            Html::rawElement( 'summary', [],
430                Html::element( 'strong', [ 'class' => 'mw-editchecks-config-header' ],
431                    $this->msg( 'editcheck-specialeditchecks-config-matchitem' )->text() )
432            ) .
433            $this->jsonTable( $matchItemFiltered )
434        );
435    }
436
437    /**
438     * Build a regex for matching supported message-call expressions.
439     *
440     * @param bool $anchored Whether to anchor the pattern to the whole string
441     * @return string
442     */
443    private function getMessageExpressionPattern( bool $anchored ): string {
444        $start = $anchored ? '^' : '\b';
445        $end = $anchored ? '$' : '';
446        $pattern =
447            '/' .
448            $start .
449            '(ve\.msg|ve\.htmlMsg|ve\.deferHtmlMsg|ve\.deferJQueryMsg|mw\.msg|OO\.ui\.deferMsg)' .
450            "\\s*\\(\\s*(\"|')" .
451            "([^\"']+)" .
452            "\\2(.*?)\\)" .
453            $end .
454            '/s';
455        return $pattern;
456    }
457
458    /**
459     * Parse a JS message expression and return the rendered message.
460     *
461     * @param string $expr
462     * @return string|\OOUI\HtmlSnippet Empty string if the expression doesn't match.
463     */
464    private function parseMessage( string $expr ) {
465        // Message calls:
466        // - ve.msg(...)
467        // - ve.htmlMsg(...)
468        // - ve.deferHtmlMsg(...)
469        // - ve.deferJQueryMsg(...)
470        // - mw.msg(...)
471        // - OO.ui.deferMsg(...)
472        if ( preg_match( $this->getMessageExpressionPattern( true ), $expr, $mm ) ) {
473            $argsStr = $mm[4];
474            $args = [];
475            if ( preg_match_all( '/,\s*([\"\\\'])(.*?)\1/', $argsStr, $am, PREG_SET_ORDER ) ) {
476                foreach ( $am as $a ) {
477                    $args[] = $a[2];
478                }
479            }
480            $msg = $this->getContext()->msg( $mm[3], ...$args );
481            switch ( $mm[1] ) {
482                case 've.htmlMsg':
483                case 've.deferHtmlMsg':
484                case 've.deferJQueryMsg':
485                    return new \OOUI\HtmlSnippet( $msg->parse() );
486                default:
487                    return $msg->text();
488            }
489        }
490
491        return '';
492    }
493
494    /**
495     * Extract a static property value.
496     *
497     * @param string $src Source code
498     * @param string $prop Property name
499     * @return string|\OOUI\HtmlSnippet
500     */
501    private function extractStaticValue( string $src, string $prop ) {
502        $expr = $this->extractStaticAssignmentExpression( $src, $prop );
503        if ( $expr === '' ) {
504            return '';
505        }
506
507        // Literal
508        if ( preg_match( '/^([\"\\\'])(.*?)\1$/', $expr, $mm ) ) {
509            if ( $prop === 'name' ) {
510                return $mm[2];
511            } else {
512                return new \OOUI\HtmlSnippet( $mm[2] );
513            }
514        }
515
516        $message = $this->parseMessage( $expr );
517        if ( $message !== '' ) {
518            return $message;
519        }
520
521        // For non-literal, non-message values, only expose data we explicitly care about.
522        // The common use-case here is extracting multi-line arrays/objects like `static.choices = [ ... ];`.
523        if ( $prop === 'choices' ) {
524            $decoded = $this->tryJsonDecodeObjectString( $expr );
525            if ( $decoded !== null ) {
526                return $decoded;
527            }
528            return '';
529        }
530
531        return '';
532    }
533
534    /**
535     * Extract the full RHS expression of a `static.<prop> = ...;` assignment.
536     *
537     * @param string $src Code
538     * @param string $prop Property name
539     * @return string RHS expression or empty string if not found
540     */
541    private function extractStaticAssignmentExpression( string $src, string $prop ): string {
542        $pattern = '/static\s*\.\s*' . preg_quote( $prop, '/' ) . '\s*=\s*/';
543        if ( !preg_match( $pattern, $src, $m, PREG_OFFSET_CAPTURE ) ) {
544            return '';
545        }
546        $start = $m[ 0 ][ 1 ] + strlen( $m[ 0 ][ 0 ] );
547        $end = strpos( $src, ';', $start );
548        if ( $end === false ) {
549            return trim( substr( $src, $start ) );
550        }
551        return trim( substr( $src, $start, $end - $start ) );
552    }
553
554    /**
555     * Extract the defaultConfig object literal from the source code.
556     */
557    private function extractDefaultConfig( string $src ): string {
558        // Capture object literal used as overrides in ve.extendObject(..., {...}) or a direct object.
559        if (
560            preg_match(
561                '/static\s*\.\s*defaultConfig\s*=' .
562                '\s*ve\.extendObject\s*\(\s*\{.*?\}\s*,\s*[^,]+,\s*(\{[\s\S]*?\})\s*\)\s*;/',
563                $src, $m )
564        ) {
565            return $m[ 1 ];
566        }
567        if ( preg_match( '/static\s*\.\s*defaultConfig\s*=\s*(\{[\s\S]*?\})\s*;/', $src, $m ) ) {
568            return $m[ 1 ];
569        }
570        return '';
571    }
572
573    /**
574     * Attempt to convert a JS object literal to valid JSON for display.
575     * Returns pretty-printed JSON string or null on failure.
576     *
577     * @param string $js JS object literal
578     * @return mixed|null JSON decoded data or null on failure
579     */
580    private function tryJsonDecodeObjectString( string $js ) {
581        $src = trim( $js );
582        // Remove comments
583        $src = preg_replace( '/\/\/.*?(?=\n|$)/', '', $src );
584        $src = preg_replace( '/\/\*[\s\S]*?\*\//', '', $src );
585        // Remove trailing commas before } or ]
586        $src = preg_replace( '/,\s*(\}|\])/', '$1', $src );
587        // Replace undefined with null
588        $src = preg_replace( '/\bundefined\b/', 'null', $src );
589        // Quote unquoted keys: { key: ... } or , key: ...
590        $src = preg_replace( '/([\{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:/', '$1"$2":', $src );
591        // Message expressions, e.g. ve.msg('...'), OO.ui.deferMsg('...'), mw.msg('...')
592        $messageExprPattern = $this->getMessageExpressionPattern( false );
593        $src = preg_replace_callback(
594            $messageExprPattern,
595            function ( $mm ) {
596                return json_encode( (string)$this->parseMessage( $mm[0] ) );
597            },
598            $src
599        );
600
601        $src = $this->convertSingleQuotedToDoubleQuoted( $src );
602
603        // Try decode
604        $data = json_decode( $src, true );
605        if ( json_last_error() !== JSON_ERROR_NONE ) {
606            return null;
607        }
608        return $data;
609    }
610
611    /**
612     * Attempt to convert a JS object literal to valid JSON for display.
613     * Returns pretty-printed JSON string or null on failure.
614     *
615     * @param string $js JS object literal
616     * @return string
617     */
618    private function jsonTableFromObjectString( string $js ): string {
619        $data = $this->tryJsonDecodeObjectString( $js );
620
621        return $data === null ? $js : $this->jsonTable( $data );
622    }
623
624    /**
625     * Convert all JS single-quoted string literals to double-quoted, handling escapes.
626     */
627    private function convertSingleQuotedToDoubleQuoted( string $code ): string {
628        $out = '';
629        $len = strlen( $code );
630        $inSingle = false;
631        $inDouble = false;
632        $escape = false;
633        for ( $i = 0; $i < $len; $i++ ) {
634            $ch = $code[$i];
635            if ( $escape ) {
636                // Preserve escaped char, but if we are in a single-quoted string
637                // and the escaped char is a single quote, drop the escape
638                if ( $inSingle && $ch === '\'' ) {
639                    $out .= '\'';
640                } else {
641                    $out .= '\\' . $ch;
642                }
643                $escape = false;
644                continue;
645            }
646            if ( $ch === '\\' ) {
647                $escape = true;
648                continue;
649            }
650            if ( !$inDouble && $ch === '\'' ) {
651                // Toggle single-quoted string; replace quote with double quote
652                $inSingle = !$inSingle;
653                $out .= '"';
654                continue;
655            }
656            if ( !$inSingle && $ch === '"' ) {
657                // Track double quotes to avoid interfering while inside
658                $inDouble = !$inDouble;
659                $out .= '"';
660                continue;
661            }
662            // Inside single-quoted string: ensure double quotes are escaped
663            if ( $inSingle && $ch === '"' ) {
664                $out .= '\\"';
665                continue;
666            }
667            $out .= $ch;
668        }
669        return $out;
670    }
671
672    /**
673     * Format a JSON value as a table.
674     *
675     * @param mixed $value
676     * @return string
677     */
678    private function jsonTable( $value ): string {
679        $json = json_encode( $value );
680        $content = new JsonContent( $json );
681        return $content->rootValueTable( $content->getData()->getValue() );
682    }
683}