Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.07% covered (warning)
81.07%
257 / 317
68.97% covered (warning)
68.97%
40 / 58
CRAP
0.00% covered (danger)
0.00%
0 / 1
SiteConfig
81.07% covered (warning)
81.07%
257 / 317
68.97% covered (warning)
68.97%
40 / 58
189.56
0.00% covered (danger)
0.00%
0 / 1
 __construct
53.33% covered (warning)
53.33%
8 / 15
0.00% covered (danger)
0.00%
0 / 1
5.63
 reset
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 combineRegexArrays
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 addNamespace
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 detectFeatures
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 hasVideoInfo
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 loadSiteData
97.67% covered (success)
97.67%
84 / 86
0.00% covered (danger)
0.00%
0 / 1
21
 galleryOptions
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 allowedExternalImagePrefixes
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 determineArticlePath
84.21% covered (warning)
84.21%
16 / 19
0.00% covered (danger)
0.00%
0 / 1
4.06
 baseURI
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 relativeLinkPrefix
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 canonicalNamespaceId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 namespaceId
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 namespaceName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 namespaceHasSubpages
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 namespaceCase
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 specialPageLocalName
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 magicLinkEnabled
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 interwikiMagic
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 interwikiMap
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 iwp
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 legalTitleChars
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 linkPrefixRegex
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 linkTrail
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 langBcp47
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 mainpage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 mainPageLinkTarget
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getMWConfigValue
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 rtl
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 langConverterEnabledBcp47
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 script
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 scriptpath
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 server
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 exportMetadataToHeadBcp47
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 redirectRegexp
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 categoryRegexp
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 bswRegexp
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 timezoneOffset
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 variantsFor
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 widthOption
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getVariableIDs
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 haveComputedFunctionSynonyms
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 updateFunctionSynonym
96.55% covered (success)
96.55%
28 / 29
0.00% covered (danger)
0.00%
0 / 1
6
 getMagicWords
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getMagicWordMatcher
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getParameterizedAliasMatcher
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 ensureExtensionTag
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getNonNativeExtensionTags
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getMaxTemplateDepth
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSpecialNSAliases
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getSpecialPageAliases
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getProtocols
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 metrics
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 incrementCounter
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 observeTiming
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getNoFollowConfig
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getExternalLinkTarget
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3declare( strict_types = 1 );
4
5namespace Wikimedia\Parsoid\Config\Api;
6
7use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
8use Wikimedia\Bcp47Code\Bcp47Code;
9use Wikimedia\Parsoid\Config\SiteConfig as ISiteConfig;
10use Wikimedia\Parsoid\Config\StubMetadataCollector;
11use Wikimedia\Parsoid\Core\ContentMetadataCollector;
12use Wikimedia\Parsoid\DOM\Document;
13use Wikimedia\Parsoid\Mocks\MockMetrics;
14use Wikimedia\Parsoid\Utils\ConfigUtils;
15use Wikimedia\Parsoid\Utils\PHPUtils;
16use Wikimedia\Parsoid\Utils\Title;
17use Wikimedia\Parsoid\Utils\UrlUtils;
18use Wikimedia\Parsoid\Utils\Utils;
19
20/**
21 * SiteConfig via MediaWiki's Action API
22 *
23 * Note this is intended for testing, not performance.
24 */
25class SiteConfig extends ISiteConfig {
26
27    /** @var ApiHelper */
28    private $api;
29
30    /** @var array|null */
31    private $siteData;
32
33    /** @var array|null */
34    private $protocols;
35
36    /** @var string|null */
37    private $baseUri;
38
39    /** @var string|null */
40    private $relativeLinkPrefix;
41
42    /** @var string */
43    private $savedCategoryRegexp;
44
45    /** @var string */
46    private $savedRedirectRegexp;
47
48    /** @var string */
49    private $savedBswRegexp;
50
51    /** @var array<int,string> */
52    protected $nsNames = [];
53
54    /** @var array<int,string> */
55    protected $nsCase = [];
56
57    /** @var array<string,int> */
58    protected $nsIds = [];
59
60    /** @var array<string,int> */
61    protected $nsCanon = [];
62
63    /** @var array<int,bool> */
64    protected $nsWithSubpages = [];
65
66    /** @var array<string,string> */
67    private $specialPageNames = [];
68
69    /** @var array */
70    private $specialPageAliases = [];
71
72    /** @var array|null */
73    private $interwikiMap;
74
75    /** @var array<string,array>|null Keys are stored as lowercased BCP-47 code strings */
76    private $variants;
77
78    /** @var array<string,bool>|null Keys are stored as lowercased BCP-47 code strings */
79    private $langConverterEnabled;
80
81    /** @var array|null */
82    private $apiMagicWords;
83
84    /** @var array|null */
85    private $paramMWs;
86
87    /** @var array|null */
88    private $apiVariables;
89
90    /** @var array|null */
91    private $apiFunctionHooks;
92
93    /** @var array|null */
94    private $allMWs;
95
96    /** @var array|null */
97    private $extensionTags;
98
99    /** @var int|null */
100    private $widthOption;
101
102    /** @var int */
103    private $maxDepth = 40;
104
105    private $featureDetectionDone = false;
106    private $hasVideoInfo = false;
107
108    /** @var string[] Base parameters for a siteinfo query */
109    public const SITE_CONFIG_QUERY_PARAMS = [
110        'action' => 'query',
111        'meta' => 'siteinfo',
112        'siprop' => 'general|protocols|namespaces|namespacealiases|magicwords|interwikimap|'
113            . 'languagevariants|defaultoptions|specialpagealiases|extensiontags|'
114            . 'functionhooks|variables',
115    ];
116
117    public function __construct( ApiHelper $api, array $opts ) {
118        parent::__construct();
119
120        $this->api = $api;
121
122        $this->linterEnabled = (bool)( $opts['linting'] ?? false );
123        $this->addHTMLTemplateParameters = (bool)( $opts['addHTMLTemplateParameters'] ?? false );
124
125        if ( isset( $opts['maxDepth'] ) ) {
126            $this->maxDepth = (int)$opts['maxDepth'];
127        }
128
129        $this->setLogger( $opts['logger'] ?? self::createLogger() );
130
131        if ( isset( $opts['wt2htmlLimits'] ) ) {
132            $this->wt2htmlLimits = array_merge(
133                $this->wt2htmlLimits, $opts['wt2htmlLimits']
134            );
135        }
136        if ( isset( $opts['html2wtLimits'] ) ) {
137            $this->html2wtLimits = array_merge(
138                $this->html2wtLimits, $opts['html2wtLimits']
139            );
140        }
141    }
142
143    protected function reset() {
144        $this->siteData = null;
145        $this->baseUri = null;
146        $this->relativeLinkPrefix = null;
147        // Superclass value reset since parsertests reuse SiteConfig objects
148        $this->linkTrailRegex = false;
149        $this->mwAliases = null;
150        $this->interwikiMapNoNamespaces = null;
151        $this->iwMatcher = null;
152    }
153
154    /**
155     * Combine sets of regex fragments
156     * @param string[][] $res
157     *  - $regexes[0] are case-insensitive regex fragments. Must not be empty.
158     *  - $regexes[1] are case-sensitive regex fragments. Must not be empty.
159     * @return string Combined regex fragment. May be an alternation. Assumes
160     *  the outer environment is case-sensitive.
161     */
162    private function combineRegexArrays( array $res ): string {
163        if ( $res ) {
164            if ( isset( $res[0] ) ) {
165                $res[0] = '(?i:' . implode( '|', $res[0] ) . ')';
166            }
167            if ( isset( $res[1] ) ) {
168                $res[1] = '(?:' . implode( '|', $res[1] ) . ')';
169            }
170            return implode( '|', $res );
171        }
172        // None? Return a failing regex
173        return '(?!)';
174    }
175
176    /**
177     * Add a new namespace to the config
178     *
179     * Protected access to let mocks and parser tests versions
180     * add new namespaces as required.
181     *
182     * @param array $ns Namespace info
183     */
184    protected function addNamespace( array $ns ): void {
185        $id = (int)$ns['id'];
186        $this->nsNames[$id] = $ns['name'];
187        $this->nsIds[Utils::normalizeNamespaceName( $ns['name'] )] = $id;
188        $this->nsCanon[Utils::normalizeNamespaceName( $ns['canonical'] ?? $ns['name'] )] = $id;
189        if ( $ns['subpages'] ) {
190            $this->nsWithSubpages[$id] = true;
191        }
192        $this->nsCase[$id] = (string)$ns['case'];
193    }
194
195    private function detectFeatures(): void {
196        if ( !$this->featureDetectionDone ) {
197            $this->featureDetectionDone = true;
198            $data = $this->api->makeRequest( [ 'action' => 'paraminfo', 'modules' => 'query' ] );
199            $props = $data["paraminfo"]["modules"][0]["parameters"]["0"]["type"] ?? [];
200            $this->hasVideoInfo = in_array( 'videoinfo', $props, true );
201        }
202    }
203
204    public function hasVideoInfo(): bool {
205        $this->detectFeatures();
206        return $this->hasVideoInfo;
207    }
208
209    /**
210     * Load site data from the Action API, if necessary
211     */
212    private function loadSiteData(): void {
213        if ( $this->siteData !== null ) {
214            return;
215        }
216
217        $data = $this->api->makeRequest( self::SITE_CONFIG_QUERY_PARAMS )['query'];
218
219        $this->siteData = $data['general'];
220        $this->widthOption = $data['general']['thumblimits'][$data['defaultoptions']['thumbsize']];
221        $this->protocols = $data['protocols'];
222        $this->apiVariables = $data['variables'];
223        $this->apiFunctionHooks = PHPUtils::makeSet( $data['functionhooks'] );
224
225        // Process namespace data from API
226        $this->nsNames = [];
227        $this->nsCase = [];
228        $this->nsIds = [];
229        $this->nsCanon = [];
230        $this->nsWithSubpages = [];
231        foreach ( $data['namespaces'] as $ns ) {
232            $this->addNamespace( $ns );
233        }
234        foreach ( $data['namespacealiases'] as $ns ) {
235            $this->nsIds[Utils::normalizeNamespaceName( $ns['alias'] )] = $ns['id'];
236        }
237
238        // Process magic word data from API
239        $bsws = [];
240        $this->paramMWs = [];
241        $this->allMWs = [];
242
243        // Recast the API results in the format that core MediaWiki returns internally
244        // This enables us to use the Production SiteConfig without changes and add the
245        // extra overhead to this developer API usage.
246        $this->apiMagicWords = [];
247        foreach ( $data['magicwords'] as $mw ) {
248            $cs = (int)$mw['case-sensitive'];
249            $mwName = $mw['name'];
250            $this->apiMagicWords[$mwName][] = $cs;
251            $pmws = [];
252            $allMWs = [];
253            foreach ( $mw['aliases'] as $alias ) {
254                $this->apiMagicWords[$mwName][] = $alias;
255                // Aliases for double underscore mws include the underscores
256                if ( substr( $alias, 0, 2 ) === '__' && substr( $alias, -2 ) === '__' ) {
257                    $bsws[$cs][] = preg_quote( substr( $alias, 2, -2 ), '@' );
258                }
259                if ( strpos( $alias, '$1' ) !== false ) {
260                    $pmws[$cs][] = strtr( preg_quote( $alias, '/' ), [ '\\$1' => "(.*?)" ] );
261                }
262                $allMWs[$cs][] = preg_quote( $alias, '/' );
263            }
264
265            if ( $pmws ) {
266                $this->paramMWs[$mwName] = '/^(?:' . $this->combineRegexArrays( $pmws ) . ')$/uDS';
267            }
268            $this->allMWs[$mwName] = '/^(?:' . $this->combineRegexArrays( $allMWs ) . ')$/D';
269        }
270
271        $bswRegexp = $this->combineRegexArrays( $bsws );
272
273        // Parse interwiki map data from the API
274        $this->interwikiMap = ConfigUtils::computeInterwikiMap( $data['interwikimap'] );
275
276        // Parse variant data from the API
277        # T320662: API should return these in BCP-47 forms
278        $this->langConverterEnabled = [];
279        $this->variants = [];
280        foreach ( $data['languagevariants'] as $base => $variants ) {
281            $baseBcp47 = Utils::mwCodeToBcp47( $base );
282            if ( $this->siteData['langconversion'] ) {
283                $baseKey = strtolower( $baseBcp47->toBcp47Code() );
284                $this->langConverterEnabled[$baseKey] = true;
285                foreach ( $variants as $code => $vdata ) {
286                    $variantKey = strtolower( Utils::mwCodeToBcp47( $code )->toBcp47Code() );
287                    $this->variants[$variantKey] = [
288                        'base' => $baseBcp47,
289                        'fallbacks' => array_map(
290                            [ Utils::class, 'mwCodeToBcp47' ],
291                            $vdata['fallbacks']
292                        ),
293                    ];
294                }
295            }
296        }
297
298        // Parse extension tag data from the API
299        $this->extensionTags = [];
300        foreach ( $data['extensiontags'] as $tag ) {
301            $tag = preg_replace( '/^<|>$/D', '', $tag );
302            $this->ensureExtensionTag( $tag );
303        }
304
305        $this->specialPageAliases = $data['specialpagealiases'];
306        $this->specialPageNames = [];
307        foreach ( $this->specialPageAliases as $special ) {
308            $alias = strtr( mb_strtoupper( $special['realname'] ), ' ', '_' );
309            $this->specialPageNames[$alias] = $special['aliases'][0];
310            foreach ( $special['aliases'] as $alias ) {
311                $alias = strtr( mb_strtoupper( $alias ), ' ', '_' );
312                $this->specialPageNames[$alias] = $special['aliases'][0];
313            }
314        }
315
316        $redirect = '(?i:\#REDIRECT)';
317        $quote = static function ( $s ) {
318            $q = preg_quote( $s, '@' );
319            # Note that PHP < 7.3 doesn't escape # in preg_quote.  That means
320            # that the $redirect regexp will fail if used with the `x` flag.
321            # Manually hack around this for PHP 7.2; can remove this workaround
322            # once minimum PHP version >= 7.3
323            if ( preg_quote( '#' ) === '#' ) {
324                $q = str_replace( '#', '\\#', $q );
325            }
326            return $q;
327        };
328        foreach ( $data['magicwords'] as $mw ) {
329            if ( $mw['name'] === 'redirect' ) {
330                $redirect = implode( '|', array_map( $quote, $mw['aliases'] ) );
331                if ( !$mw['case-sensitive'] ) {
332                    $redirect = '(?i:' . $redirect . ')';
333                }
334                break;
335            }
336        }
337        // `$this->nsNames[14]` is set earlier by the calls to `$this->addNamespace( $ns )`
338        // @phan-suppress-next-line PhanCoalescingAlwaysNull
339        $category = $this->quoteTitleRe( $this->nsNames[14] ?? 'Category', '@' );
340        if ( $category !== 'Category' ) {
341            $category = "(?:$category|Category)";
342        }
343
344        $this->savedCategoryRegexp = "@{$category}@";
345        $this->savedRedirectRegexp = "@{$redirect}@";
346        $this->savedBswRegexp = "@{$bswRegexp}@";
347    }
348
349    public function galleryOptions(): array {
350        $this->loadSiteData();
351        return $this->siteData['galleryoptions'];
352    }
353
354    public function allowedExternalImagePrefixes(): array {
355        $this->loadSiteData();
356        return $this->siteData['externalimages'] ?? [];
357    }
358
359    /**
360     * Determine the article base URI and relative prefix
361     */
362    private function determineArticlePath(): void {
363        $this->loadSiteData();
364
365        $url = $this->siteData['server'] . $this->siteData['articlepath'];
366
367        if ( substr( $url, -2 ) !== '$1' ) {
368            throw new \UnexpectedValueException( "Article path '$url' does not have '$1' at the end" );
369        }
370        $url = substr( $url, 0, -2 );
371
372        $bits = UrlUtils::parseUrl( $url );
373        if ( !$bits ) {
374            throw new \UnexpectedValueException( "Failed to parse article path '$url'" );
375        }
376
377        if ( empty( $bits['path'] ) ) {
378            $path = '/';
379        } else {
380            $path = UrlUtils::removeDotSegments( $bits['path'] );
381        }
382
383        $relParts = [ 'query' => true, 'fragment' => true ];
384        $base = array_diff_key( $bits, $relParts );
385        $rel = array_intersect_key( $bits, $relParts );
386
387        $i = strrpos( $path, '/' );
388        $base['path'] = substr( $path, 0, $i + 1 );
389        $rel['path'] = '.' . substr( $path, $i );
390
391        $this->baseUri = UrlUtils::assembleUrl( $base );
392        $this->relativeLinkPrefix = UrlUtils::assembleUrl( $rel );
393    }
394
395    public function baseURI(): string {
396        if ( $this->baseUri === null ) {
397            $this->determineArticlePath();
398        }
399        return $this->baseUri;
400    }
401
402    public function relativeLinkPrefix(): string {
403        if ( $this->relativeLinkPrefix === null ) {
404            $this->determineArticlePath();
405        }
406        return $this->relativeLinkPrefix;
407    }
408
409    /** @inheritDoc */
410    public function canonicalNamespaceId( string $name ): ?int {
411        $this->loadSiteData();
412        return $this->nsCanon[Utils::normalizeNamespaceName( $name )] ?? null;
413    }
414
415    /** @inheritDoc */
416    public function namespaceId( string $name ): ?int {
417        $this->loadSiteData();
418        $name = Utils::normalizeNamespaceName( $name );
419        return $this->nsCanon[$name] ?? $this->nsIds[$name] ?? null;
420    }
421
422    /** @inheritDoc */
423    public function namespaceName( int $ns ): ?string {
424        $this->loadSiteData();
425        return $this->nsNames[$ns] ?? null;
426    }
427
428    /** @inheritDoc */
429    public function namespaceHasSubpages( int $ns ): bool {
430        $this->loadSiteData();
431        return $this->nsWithSubpages[$ns] ?? false;
432    }
433
434    /** @inheritDoc */
435    public function namespaceCase( int $ns ): string {
436        $this->loadSiteData();
437        return $this->nsCase[$ns] ?? 'first-letter';
438    }
439
440    /** @inheritDoc */
441    public function specialPageLocalName( string $alias ): ?string {
442        $this->loadSiteData();
443        $alias = strtr( mb_strtoupper( $alias ), ' ', '_' );
444        return $this->specialPageNames[$alias] ?? null;
445    }
446
447    /** @inheritDoc */
448    public function magicLinkEnabled( string $which ): bool {
449        $this->loadSiteData();
450        $magic = $this->siteData['magiclinks'] ?? [];
451        // Default to true, as wikis too old to export the 'magiclinks'
452        // property always had magic links enabled.
453        return $magic[$which] ?? true;
454    }
455
456    public function interwikiMagic(): bool {
457        $this->loadSiteData();
458        return $this->siteData['interwikimagic'];
459    }
460
461    public function interwikiMap(): array {
462        $this->loadSiteData();
463        return $this->interwikiMap;
464    }
465
466    public function iwp(): string {
467        $this->loadSiteData();
468        return $this->siteData['wikiid'];
469    }
470
471    public function legalTitleChars(): string {
472        $this->loadSiteData();
473        return $this->siteData['legaltitlechars'];
474    }
475
476    public function linkPrefixRegex(): ?string {
477        $this->loadSiteData();
478
479        if ( !empty( $this->siteData['linkprefixcharset'] ) ) {
480            return '/[' . $this->siteData['linkprefixcharset'] . ']+$/Du';
481        } else {
482            // We don't care about super-old MediaWiki, so don't try to parse 'linkprefix'.
483            return null;
484        }
485    }
486
487    /** @inheritDoc */
488    protected function linkTrail(): string {
489        $this->loadSiteData();
490        return $this->siteData['linktrail'];
491    }
492
493    public function langBcp47(): Bcp47Code {
494        $this->loadSiteData();
495        return Utils::mwCodeToBcp47( $this->siteData['lang'] );
496    }
497
498    public function mainpage(): string {
499        $this->loadSiteData();
500        return $this->siteData['mainpage'];
501    }
502
503    public function mainPageLinkTarget(): Title {
504        $this->loadSiteData();
505        return Title::newFromText( $this->siteData['mainpage'], $this );
506    }
507
508    /** @inheritDoc */
509    public function getMWConfigValue( string $key ) {
510        $this->loadSiteData();
511        switch ( $key ) {
512            // Hardcoded values for these 2 keys
513            case 'CiteResponsiveReferences':
514                return $this->siteData['citeresponsivereferences'] ?? false;
515
516            case 'CiteResponsiveReferencesThreshold':
517                return 10;
518
519            // We can add more hardcoded keys based on testing needs
520            // but null is the default for keys unsupported in this mode.
521            default:
522                return null;
523        }
524    }
525
526    public function rtl(): bool {
527        $this->loadSiteData();
528        return $this->siteData['rtl'];
529    }
530
531    /** @inheritDoc */
532    public function langConverterEnabledBcp47( Bcp47Code $lang ): bool {
533        $this->loadSiteData();
534        return $this->langConverterEnabled[strtolower( $lang->toBcp47Code() )] ?? false;
535    }
536
537    public function script(): string {
538        $this->loadSiteData();
539        return $this->siteData['script'];
540    }
541
542    public function scriptpath(): string {
543        $this->loadSiteData();
544        return $this->siteData['scriptpath'];
545    }
546
547    public function server(): string {
548        $this->loadSiteData();
549        return $this->siteData['server'];
550    }
551
552    /**
553     * @inheritDoc
554     */
555    public function exportMetadataToHeadBcp47(
556        Document $document,
557        ContentMetadataCollector $metadata,
558        string $defaultTitle,
559        Bcp47Code $lang
560    ): void {
561        '@phan-var StubMetadataCollector $metadata'; // @var StubMetadataCollector $metadata
562        $moduleLoadURI = $this->server() . $this->scriptpath() . '/load.php';
563        // Parsoid/JS always made this protocol-relative, so match
564        // that (for now at least)
565        $moduleLoadURI = preg_replace( '#^https?://#', '//', $moduleLoadURI );
566        // Look for a displaytitle.
567        $displayTitle = $metadata->getPageProperty( 'displaytitle' ) ??
568            // Use the default title, properly escaped
569            Utils::escapeHtml( $defaultTitle );
570        $this->exportMetadataHelper(
571            $document,
572            $moduleLoadURI,
573            $metadata->getModules(),
574            $metadata->getModuleStyles(),
575            $metadata->getJsConfigVars(),
576            $displayTitle,
577            $lang
578        );
579    }
580
581    public function redirectRegexp(): string {
582        $this->loadSiteData();
583        return $this->savedRedirectRegexp;
584    }
585
586    public function categoryRegexp(): string {
587        $this->loadSiteData();
588        return $this->savedCategoryRegexp;
589    }
590
591    public function bswRegexp(): string {
592        $this->loadSiteData();
593        return $this->savedBswRegexp;
594    }
595
596    public function timezoneOffset(): int {
597        $this->loadSiteData();
598        return $this->siteData['timeoffset'];
599    }
600
601    /** @inheritDoc */
602    public function variantsFor( Bcp47Code $lang ): ?array {
603        $this->loadSiteData();
604        return $this->variants[strtolower( $lang->toBcp47Code() )] ?? null;
605    }
606
607    public function widthOption(): int {
608        $this->loadSiteData();
609        return $this->widthOption;
610    }
611
612    /** @inheritDoc */
613    protected function getVariableIDs(): array {
614        $this->loadSiteData();
615        return $this->apiVariables;
616    }
617
618    /** @inheritDoc */
619    protected function haveComputedFunctionSynonyms(): bool {
620        return false;
621    }
622
623    private static $noHashFunctions = null;
624
625    /** @inheritDoc */
626    protected function updateFunctionSynonym( string $func, string $magicword, bool $caseSensitive ): void {
627        if ( !$this->apiFunctionHooks ) {
628            $this->loadSiteData();
629        }
630        if ( isset( $this->apiFunctionHooks[$magicword] ) ) {
631            if ( !self::$noHashFunctions ) {
632                // FIXME: This is an approximation only computed in non-integrated mode for
633                // commandline and developer testing. This set is probably not up to date
634                // and also doesn't reflect no-hash functions registered by extensions
635                // via setFunctionHook calls. As such, you might run into GOTCHAs during
636                // debugging of production issues in standalone / API config mode.
637                self::$noHashFunctions = PHPUtils::makeSet( [
638                    'ns', 'nse', 'urlencode', 'lcfirst', 'ucfirst', 'lc', 'uc',
639                    'localurl', 'localurle', 'fullurl', 'fullurle', 'canonicalurl',
640                    'canonicalurle', 'formatnum', 'grammar', 'gender', 'plural', 'bidi',
641                    'numberofpages', 'numberofusers', 'numberofactiveusers',
642                    'numberofarticles', 'numberoffiles', 'numberofadmins',
643                    'numberingroup', 'numberofedits', 'language',
644                    'padleft', 'padright', 'anchorencode', 'defaultsort', 'filepath',
645                    'pagesincategory', 'pagesize', 'protectionlevel', 'protectionexpiry',
646                    'namespacee', 'namespacenumber', 'talkspace', 'talkspacee',
647                    'subjectspace', 'subjectspacee', 'pagename', 'pagenamee',
648                    'fullpagename', 'fullpagenamee', 'rootpagename', 'rootpagenamee',
649                    'basepagename', 'basepagenamee', 'subpagename', 'subpagenamee',
650                    'talkpagename', 'talkpagenamee', 'subjectpagename',
651                    'subjectpagenamee', 'pageid', 'revisionid', 'revisionday',
652                    'revisionday2', 'revisionmonth', 'revisionmonth1', 'revisionyear',
653                    'revisiontimestamp', 'revisionuser', 'cascadingsources',
654                    // Special callbacks in core
655                    'namespace', 'int', 'displaytitle', 'pagesinnamespace',
656                ] );
657            }
658
659            $syn = $func;
660            if ( substr( $syn, -1 ) === ':' ) {
661                $syn = substr( $syn, 0, -1 );
662            }
663            if ( !isset( self::$noHashFunctions[$magicword] ) ) {
664                $syn = '#' . $syn;
665            }
666            $this->functionSynonyms[intval( $caseSensitive )][$syn] = $magicword;
667        }
668    }
669
670    /** @inheritDoc */
671    protected function getMagicWords(): array {
672        $this->loadSiteData();
673        return $this->apiMagicWords;
674    }
675
676    /** @inheritDoc */
677    public function getMagicWordMatcher( string $id ): string {
678        $this->loadSiteData();
679        return $this->allMWs[$id] ?? '/^(?!)$/';
680    }
681
682    /** @inheritDoc */
683    public function getParameterizedAliasMatcher( array $words ): callable {
684        $this->loadSiteData();
685        $regexes = array_intersect_key( $this->paramMWs, array_flip( $words ) );
686        return static function ( $text ) use ( $regexes ) {
687            /**
688             * $name is the canonical magic word name
689             * $re has patterns for matching aliases
690             */
691            foreach ( $regexes as $name => $re ) {
692                if ( preg_match( $re, $text, $m ) ) {
693                    unset( $m[0] );
694
695                    // Ex. regexp here is, /^(?:(?:|vinculo\=(.*?)|enlace\=(.*?)|link\=(.*?)))$/uS
696                    // Check all the capture groups for a value, if not, it's safe to return an
697                    // empty string since we did get a match.
698                    foreach ( $m as $v ) {
699                        if ( $v !== '' ) {
700                            return [ 'k' => $name, 'v' => $v ];
701                        }
702                    }
703                    return [ 'k' => $name, 'v' => '' ];
704                }
705            }
706            return null;
707        };
708    }
709
710    /**
711     * This function is public so it can be used to synchronize env for
712     * hybrid parserTests.  The parserTests setup includes the definition
713     * of a number of non-standard extension tags, whose names are passed
714     * over from the JS side in hybrid testing.
715     * @param string $tag Name of an extension tag assumed to be present
716     */
717    public function ensureExtensionTag( string $tag ): void {
718        $this->loadSiteData();
719        $this->extensionTags[mb_strtolower( $tag )] = true;
720    }
721
722    /** @inheritDoc */
723    protected function getNonNativeExtensionTags(): array {
724        $this->loadSiteData();
725        return $this->extensionTags;
726    }
727
728    /** @inheritDoc */
729    public function getMaxTemplateDepth(): int {
730        // Not in the API result
731        return $this->maxDepth;
732    }
733
734    /** @inheritDoc */
735    protected function getSpecialNSAliases(): array {
736        $nsAliases = [
737            'Special',
738        ];
739        foreach ( $this->nsIds as $name => $id ) {
740            if ( $id === -1 ) {
741                $nsAliases[] = $this->quoteTitleRe( $name, '!' );
742            }
743        }
744        return $nsAliases;
745    }
746
747    /** @inheritDoc */
748    protected function getSpecialPageAliases( string $specialPage ): array {
749        $spAliases = [ $specialPage ];
750        foreach ( $this->specialPageAliases as $special ) {
751            if ( $special['realname'] === $specialPage ) {
752                $spAliases = array_merge( $spAliases, $special['aliases'] );
753                break;
754            }
755        }
756        return $spAliases;
757    }
758
759    /** @inheritDoc */
760    protected function getProtocols(): array {
761        $this->loadSiteData();
762        return $this->protocols;
763    }
764
765    /** @var ?MockMetrics */
766    private $metrics;
767
768    /** @inheritDoc */
769    public function metrics(): ?StatsdDataFactoryInterface {
770        if ( $this->metrics === null ) {
771            $this->metrics = new MockMetrics();
772        }
773        return $this->metrics;
774    }
775
776    /**
777     * Increment a counter metric
778     * @param string $name
779     * @param array $labels
780     * @param float $amount
781     * @return void
782     */
783    public function incrementCounter( string $name, array $labels, float $amount = 1 ): void {
784        // We don't use the labels for now, using MockMetrics instead
785        $this->metrics->increment( $name );
786    }
787
788    /**
789     * Record a timing metric
790     * @param string $name
791     * @param float $value
792     * @param array $labels
793     * @return void
794     */
795    public function observeTiming( string $name, float $value, array $labels ): void {
796        // We don't use the labels for now, using MockMetrics instead
797        $this->metrics->timing( $name, $value );
798    }
799
800    /** @inheritDoc */
801    public function getNoFollowConfig(): array {
802        $this->loadSiteData();
803        return [
804            'nofollow' => $this->siteData['nofollowlinks'] ?? true,
805            'nsexceptions' => $this->siteData['nofollownsexceptions'] ?? [],
806            'domainexceptions' => $this->siteData['nofollowdomainexceptions'] ?? [ 'mediawiki.org' ]
807        ];
808    }
809
810    /** @inheritDoc */
811    public function getExternalLinkTarget() {
812        $this->loadSiteData();
813        return $this->siteData['externallinktarget'] ?? false;
814    }
815}