Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 143
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
EditCheckFileParser
0.00% covered (danger)
0.00%
0 / 143
0.00% covered (danger)
0.00%
0 / 9
3192
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 extractStaticValue
0.00% covered (danger)
0.00%
0 / 6
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
 parseMessage
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
72
 extractStaticAssignmentExpression
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 findTopLevelDelimiter
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
462
 getMessageExpressionPattern
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 convertSingleQuotedToDoubleQuoted
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
156
1<?php
2/**
3 * Parser for reading metadata from edit check JavaScript source files.
4 */
5
6namespace MediaWiki\Extension\VisualEditor;
7
8use MediaWiki\Language\MessageLocalizer;
9
10/**
11 * Extracts structured metadata from edit check JavaScript source files.
12 *
13 * Handles these kinds of value expression that appear in the JS:
14 *  - string literals ('foo' / "foo")
15 *  - structured arrays / objects (decoded via tryJsonDecodeObjectString)
16 *  - message-function calls (ve.msg, mw.msg, OO.ui.deferMsg, …) inside arrays / objects
17 */
18class EditCheckFileParser {
19
20    public function __construct(
21        private readonly MessageLocalizer $localizer
22    ) {
23    }
24
25    /**
26     * Extract the value of a static property from a JS source file.
27     *
28     * Supports string literals, and arrays or objects which tryJsonDecodeObjectString can decode.
29     *
30     * @param string $src Source code
31     * @param string $prop Property name (e.g. 'name', 'allowedContentLanguages')
32     * @return mixed|null Value, or null if not found or not decodable
33     */
34    public function extractStaticValue( string $src, string $prop ) {
35        $expr = $this->extractStaticAssignmentExpression( $src, $prop );
36        if ( $expr === null ) {
37            return null;
38        }
39
40        if ( preg_match( '/^([\"\\\'])(.*?)\1$/', $expr, $mm ) ) {
41            return $mm[2];
42        }
43
44        return $this->tryJsonDecodeObjectString( $expr );
45    }
46
47    /**
48     * Extract the defaultConfig object literal from the source code.
49     *
50     * @param string $src Source code
51     * @return string Raw JS object literal, or empty string if not found
52     */
53    public function extractDefaultConfig( string $src ): string {
54        // Capture object literal used as overrides in ve.extendObject(..., {...}) or a direct object.
55        if (
56            preg_match(
57                '/static\s*\.\s*defaultConfig\s*=' .
58                '\s*ve\.extendObject\s*\(\s*\{.*?\}\s*,\s*[^,]+,\s*(\{[\s\S]*?\})\s*\)\s*;/',
59                $src, $m )
60        ) {
61            return $m[ 1 ];
62        }
63        if ( preg_match( '/static\s*\.\s*defaultConfig\s*=\s*(\{[\s\S]*?\})\s*;/', $src, $m ) ) {
64            return $m[ 1 ];
65        }
66        return '';
67    }
68
69    /**
70     * Attempt to decode a JS object/array literal as PHP data.
71     *
72     * Handles JS-specific syntax: single-quoted strings, unquoted keys, trailing commas,
73     * `undefined`, inline comments, and message-function calls.
74     *
75     * @param string $js JS object literal
76     * @return mixed|null PHP value, or null on parse failure
77     */
78    public function tryJsonDecodeObjectString( string $js ) {
79        $src = trim( $js );
80        // Remove comments
81        $src = preg_replace( '/\/\/.*?(?=\n|$)/', '', $src );
82        $src = preg_replace( '/\/\*[\s\S]*?\*\//', '', $src );
83        // Remove trailing commas before } or ]
84        $src = preg_replace( '/,\s*(\}|\])/', '$1', $src );
85        // Replace undefined with null
86        $src = preg_replace( '/\bundefined\b/', 'null', $src );
87        // Quote unquoted keys: { key: ... } or , key: ...
88        $src = preg_replace( '/([\{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:/', '$1"$2":', $src );
89        // Message expressions, e.g. ve.msg('...'), OO.ui.deferMsg('...'), mw.msg('...')
90        $messageExprPattern = $this->getMessageExpressionPattern( false );
91        $src = preg_replace_callback(
92            $messageExprPattern,
93            function ( $mm ) {
94                return json_encode( (string)$this->parseMessage( $mm[0] ) );
95            },
96            $src
97        );
98
99        $src = $this->convertSingleQuotedToDoubleQuoted( $src );
100
101        // Try decode
102        $data = json_decode( $src, true );
103        if ( json_last_error() !== JSON_ERROR_NONE ) {
104            return null;
105        }
106        return $data;
107    }
108
109    /**
110     * Parse a JS message expression and return the rendered message.
111     *
112     * Supported call forms:
113     *  - ve.msg(...)
114     *  - ve.htmlMsg(...)
115     *  - ve.deferHtmlMsg(...)
116     *  - ve.deferJQueryMsg(...)
117     *  - mw.msg(...)
118     *  - OO.ui.deferMsg(...)
119     *
120     * @param string $expr JS expression
121     * @return string|\OOUI\HtmlSnippet Empty string if the expression doesn't match.
122     */
123    public function parseMessage( string $expr ) {
124        if ( preg_match( $this->getMessageExpressionPattern( true ), $expr, $mm ) ) {
125            $argsStr = $mm[4];
126            $args = [];
127            if ( preg_match_all( '/,\s*([\"\\\'])(.*?)\1/', $argsStr, $am, PREG_SET_ORDER ) ) {
128                foreach ( $am as $a ) {
129                    $args[] = $a[2];
130                }
131            }
132            $msg = $this->localizer->msg( $mm[3], ...$args );
133            switch ( $mm[1] ) {
134                case 've.htmlMsg':
135                case 've.deferHtmlMsg':
136                case 've.deferJQueryMsg':
137                    return new \OOUI\HtmlSnippet( $msg->parse() );
138                default:
139                    return $msg->text();
140            }
141        }
142
143        return '';
144    }
145
146    /**
147     * Extract the full RHS expression of a `static.<prop> = ...;` assignment.
148     *
149     * @param string $src Source code
150     * @param string $prop Property name
151     * @return string|null RHS expression, or null if not found
152     */
153    public function extractStaticAssignmentExpression( string $src, string $prop ): ?string {
154        $pattern = '/static\s*\.\s*' . preg_quote( $prop, '/' ) . '\s*=\s*/';
155        if ( !preg_match( $pattern, $src, $m, PREG_OFFSET_CAPTURE ) ) {
156            return null;
157        }
158        $start = $m[ 0 ][ 1 ] + strlen( $m[ 0 ][ 0 ] );
159        $end = $this->findTopLevelDelimiter( $src, $start, [ ';' ] );
160        return trim( $end === null ?
161            substr( $src, $start ) :
162            substr( $src, $start, $end - $start )
163        );
164    }
165
166    /**
167     * Find the offset of the first character from $delimiters that appears at the
168     * top level of a JS expression (not inside a string or nested brackets/parens/braces).
169     *
170     * @param string $src Source string
171     * @param int $start Start offset
172     * @param string[] $delimiters Single-character stop characters
173     * @return int|null Offset of the delimiter, or null if not found before end of string
174     */
175    private function findTopLevelDelimiter( string $src, int $start, array $delimiters ): ?int {
176        $len = strlen( $src );
177        $depthParen = 0;
178        $depthBracket = 0;
179        $depthBrace = 0;
180        $inSingle = false;
181        $inDouble = false;
182        $escape = false;
183
184        for ( $i = $start; $i < $len; $i++ ) {
185            $ch = $src[$i];
186            if ( $escape ) {
187                $escape = false;
188                continue;
189            }
190            if ( $ch === '\\' ) {
191                $escape = true;
192                continue;
193            }
194            if ( !$inDouble && $ch === '\'' ) {
195                $inSingle = !$inSingle;
196                continue;
197            }
198            if ( !$inSingle && $ch === '"' ) {
199                $inDouble = !$inDouble;
200                continue;
201            }
202            if ( $inSingle || $inDouble ) {
203                continue;
204            }
205            switch ( $ch ) {
206                case '(':
207                    $depthParen++;
208                    break;
209                case ')':
210                    $depthParen = max( 0, $depthParen - 1 );
211                    break;
212                case '[':
213                    $depthBracket++;
214                    break;
215                case ']':
216                    $depthBracket = max( 0, $depthBracket - 1 );
217                    break;
218                case '{':
219                    $depthBrace++;
220                    break;
221                case '}':
222                    $depthBrace = max( 0, $depthBrace - 1 );
223                    break;
224                default:
225                    if ( $depthParen === 0 && $depthBracket === 0 && $depthBrace === 0
226                        && in_array( $ch, $delimiters, true )
227                    ) {
228                        return $i;
229                    }
230            }
231        }
232        return null;
233    }
234
235    /**
236     * Build a regex for matching supported message-call expressions.
237     *
238     * @param bool $anchored Whether to anchor the pattern to the whole string
239     * @return string
240     */
241    private function getMessageExpressionPattern( bool $anchored ): string {
242        $start = $anchored ? '^' : '\b';
243        $end = $anchored ? '$' : '';
244        $pattern =
245            '/' .
246            $start .
247            '(ve\.msg|ve\.htmlMsg|ve\.deferHtmlMsg|ve\.deferJQueryMsg|mw\.msg|OO\.ui\.deferMsg)' .
248            "\\s*\\(\\s*(\"|')" .
249            "([^\"']+)" .
250            "\\2(.*?)\\)" .
251            $end .
252            '/s';
253        return $pattern;
254    }
255
256    /**
257     * Convert all JS single-quoted string literals to double-quoted, handling escapes.
258     *
259     * @param string $code JS source
260     * @return string
261     */
262    private function convertSingleQuotedToDoubleQuoted( string $code ): string {
263        $out = '';
264        $len = strlen( $code );
265        $inSingle = false;
266        $inDouble = false;
267        $escape = false;
268        for ( $i = 0; $i < $len; $i++ ) {
269            $ch = $code[$i];
270            if ( $escape ) {
271                // Preserve escaped char, but if we are in a single-quoted string
272                // and the escaped char is a single quote, drop the escape
273                if ( $inSingle && $ch === '\'' ) {
274                    $out .= '\'';
275                } else {
276                    $out .= '\\' . $ch;
277                }
278                $escape = false;
279                continue;
280            }
281            if ( $ch === '\\' ) {
282                $escape = true;
283                continue;
284            }
285            if ( !$inDouble && $ch === '\'' ) {
286                // Toggle single-quoted string; replace quote with double quote
287                $inSingle = !$inSingle;
288                $out .= '"';
289                continue;
290            }
291            if ( !$inSingle && $ch === '"' ) {
292                // Track double quotes to avoid interfering while inside
293                $inDouble = !$inDouble;
294                $out .= '"';
295                continue;
296            }
297            // Inside single-quoted string: ensure double quotes are escaped
298            if ( $inSingle && $ch === '"' ) {
299                $out .= '\\"';
300                continue;
301            }
302            $out .= $ch;
303        }
304        return $out;
305    }
306}