Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.65% covered (warning)
75.65%
146 / 193
65.79% covered (warning)
65.79%
25 / 38
CRAP
0.00% covered (danger)
0.00%
0 / 1
SettingsBuilder
75.65% covered (warning)
75.65%
146 / 193
65.79% covered (warning)
65.79%
25 / 38
165.86
0.00% covered (danger)
0.00%
0 / 1
 getInstance
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 disableAccessForUnitTests
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 enableAccessAfterUnitTests
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 __construct
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 load
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 loadArray
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadArrayInternal
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 loadFile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fileExists
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 wrapSource
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 makeSource
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 validate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 detectDeprecatedConfig
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 detectObsoleteConfig
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getDefaultConfig
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getConfigSchema
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getDefinedConfigKeys
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 apply
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 loadRecursive
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 updateSettingsConfig
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 assumeDirtyConfig
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 applySchemas
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
6
 applySettings
96.55% covered (success)
96.55%
28 / 29
0.00% covered (danger)
0.00%
0 / 1
13
 putConfigValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 putConfigValues
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 overrideConfigValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 overrideConfigValues
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 registerHookHandlers
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 registerHookHandler
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getConfig
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 reset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 assertNotReadOnly
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 assertStillLoading
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 enterReadOnlyStage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 enterRegistrationStage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getConfigBuilder
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 warning
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getWarnings
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Settings;
4
5use BagOStuff;
6use ExtensionRegistry;
7use MediaWiki\Config\Config;
8use MediaWiki\Config\HashConfig;
9use MediaWiki\Config\IterableConfig;
10use MediaWiki\HookContainer\HookContainer;
11use MediaWiki\MainConfigNames;
12use MediaWiki\Settings\Cache\CacheableSource;
13use MediaWiki\Settings\Cache\CachedSource;
14use MediaWiki\Settings\Config\ConfigBuilder;
15use MediaWiki\Settings\Config\ConfigSchema;
16use MediaWiki\Settings\Config\ConfigSchemaAggregator;
17use MediaWiki\Settings\Config\GlobalConfigBuilder;
18use MediaWiki\Settings\Config\PhpIniSink;
19use MediaWiki\Settings\Source\ArraySource;
20use MediaWiki\Settings\Source\FileSource;
21use MediaWiki\Settings\Source\SettingsFileUtils;
22use MediaWiki\Settings\Source\SettingsIncludeLocator;
23use MediaWiki\Settings\Source\SettingsSource;
24use RuntimeException;
25use StatusValue;
26use function array_key_exists;
27
28/**
29 * Builder class for constructing a Config object from a set of sources
30 * during bootstrap. The SettingsBuilder is used in Setup.php to load
31 * and combine settings files and eventually produce the Config object that
32 * will be used to configure MediaWiki.
33 *
34 * The SettingsBuilder object keeps track of "stages" of initialization that
35 * correspond to sections of Setup.php:
36 *
37 * The initial stage is "loading". In this stage, SettingsSources are added
38 * to the SettingsBuilder using the load* methods. This sets up the config
39 * schema and applies custom configuration values.
40 *
41 * Once all settings sources have been loaded, the SettingsBuilder is moved to the
42 * "registration" stage by calling enterRegistrationStage().
43 * In this stage, config values may still be altered, but no settings sources may
44 * be loaded. During the "registration" stage, dynamic defaults are applied,
45 * extension registration callbacks are executed, and maintenance scripts have an
46 * opportunity to manipulate settings.
47 *
48 * Finally, the SettingsBuilder is moved to the "operation" stage by calling
49 * enterOperationStage(). This renders the SettingsBuilder read only: config values
50 * may no longer be changed. At this point, it becomes safe to use the Config object
51 * returned by getConfig() to initialize the service container.
52 *
53 * @since 1.38
54 */
55class SettingsBuilder {
56
57    /**
58     * @var int The initial stage in which settings can be loaded,
59     * but config values cannot be accessed.
60     */
61    private const STAGE_LOADING = 1;
62
63    /**
64     * @var int The intermediate stage in which settings can no longer be loaded,
65     * but config values can be accessed and manipulated programmatically.
66     */
67    private const STAGE_REGISTRATION = 10;
68
69    /**
70     * @var int The final stage in which config values can be accessed, but can
71     * no longer be changed.
72     */
73    private const STAGE_READ_ONLY = 100;
74
75    /** @var string */
76    private $baseDir;
77
78    /** @var ExtensionRegistry */
79    private $extensionRegistry;
80
81    /** @var BagOStuff */
82    private $cache;
83
84    /** @var ConfigBuilder */
85    private $configSink;
86
87    /** @var array<string,string> */
88    private $obsoleteConfig;
89
90    /** @var Config|null */
91    private $config;
92
93    /** @var SettingsSource[] */
94    private $currentBatch;
95
96    /** @var ConfigSchemaAggregator */
97    private $configSchema;
98
99    /** @var PhpIniSink */
100    private $phpIniSink;
101
102    /**
103     * Configuration that applies to SettingsBuilder itself.
104     * Initialized by the constructor, may be overwritten by regular
105     * config values. Merge strategies are currently not implemented
106     * but can be added if needed.
107     *
108     * @var array
109     */
110    private $settingsConfig;
111
112    /**
113     * The stage of the settings builder. This is used to determine
114     * which settings are allowed to be changed.
115     *
116     * @var int see self::STAGE_*
117     */
118    private $stage = self::STAGE_LOADING;
119
120    /**
121     * Whether we have to apply reverse-merging when applying defaults.
122     * This will initially be false, and become true once any config settings have been
123     * assigned a value.
124     *
125     * This is used as an optimization, to avoid costly merge logic when loading initial
126     * defaults before any config variables have been set.
127     *
128     * @var bool
129     */
130    private $defaultsNeedMerging = false;
131
132    /** @var string[] */
133    private $warnings = [];
134
135    private static bool $accessDisabledForUnitTests = false;
136
137    /**
138     * Accessor for the global SettingsBuilder instance.
139     *
140     * @note It is always preferable to have a SettingsBuilder injected!
141     *       But as long as we can't to this everywhere, this is the preferred way of
142     *       getting the global instance of SettingsBuilder.
143     *
144     * @return SettingsBuilder
145     */
146    public static function getInstance(): self {
147        static $instance = null;
148
149        if ( self::$accessDisabledForUnitTests ) {
150            throw new RuntimeException( 'Access is disabled in unit tests' );
151        }
152
153        if ( !$instance ) {
154            // NOTE: SettingsBuilder is used during bootstrap, before MediaWikiServices
155            //       is available. It has to be, because it is used to construct the
156            //       configuration that is used when constructing services. Because of
157            //       this, we have to instantiate SettingsBuilder directly, we can't
158            //       use service wiring.
159            $instance = new SettingsBuilder(
160                MW_INSTALL_PATH,
161                ExtensionRegistry::getInstance(),
162                new GlobalConfigBuilder( 'wg' ),
163                new PhpIniSink()
164            );
165        }
166
167        return $instance;
168    }
169
170    /**
171     * @internal
172     */
173    public static function disableAccessForUnitTests(): void {
174        if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
175            throw new RuntimeException( 'Can only be called in tests' );
176        }
177        self::$accessDisabledForUnitTests = true;
178    }
179
180    /**
181     * @internal
182     */
183    public static function enableAccessAfterUnitTests(): void {
184        if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
185            throw new RuntimeException( 'Can only be called in tests' );
186        }
187        self::$accessDisabledForUnitTests = false;
188    }
189
190    /**
191     * @param string $baseDir
192     * @param ExtensionRegistry $extensionRegistry
193     * @param ConfigBuilder $configSink
194     * @param PhpIniSink $phpIniSink
195     * @param BagOStuff|null $cache BagOStuff used to cache settings loaded
196     *  from each source. The caller should beware that secrets contained in
197     *  any source passed to {@link load} or {@link loadFile} will be cached as
198     *  well.
199     */
200    public function __construct(
201        string $baseDir,
202        ExtensionRegistry $extensionRegistry,
203        ConfigBuilder $configSink,
204        PhpIniSink $phpIniSink,
205        BagOStuff $cache = null
206    ) {
207        $this->baseDir = $baseDir;
208        $this->extensionRegistry = $extensionRegistry;
209        $this->cache = $cache;
210        $this->configSink = $configSink;
211        $this->obsoleteConfig = [];
212        $this->configSchema = new ConfigSchemaAggregator();
213        $this->phpIniSink = $phpIniSink;
214        $this->settingsConfig = [
215            MainConfigNames::ExtensionDirectory => "$baseDir/extensions",
216            MainConfigNames::StyleDirectory => "$baseDir/skins",
217        ];
218        $this->reset();
219    }
220
221    /**
222     * Load settings from a {@link SettingsSource}.
223     * Only allowed during the "loading" stage.
224     *
225     * @param SettingsSource $source
226     * @return $this
227     */
228    public function load( SettingsSource $source ): self {
229        $this->assertStillLoading( __METHOD__ );
230
231        // XXX: We may want to cache the entire batch instead, see T304493.
232        $this->currentBatch[] = $this->wrapSource( $source );
233
234        return $this;
235    }
236
237    /**
238     * Load settings from an array.
239     *
240     * @param array $newSettings
241     *
242     * @return $this
243     */
244    public function loadArray( array $newSettings ): self {
245        return $this->load( new ArraySource( $newSettings ) );
246    }
247
248    /**
249     * Load settings from an array.
250     * For internal use. Allowed during "loading" and "registration" stage.
251     *
252     * @param array $newSettings
253     * @param string $func
254     *
255     * @return $this
256     */
257    private function loadArrayInternal( array $newSettings, string $func ): self {
258        $this->assertNotReadOnly( $func );
259
260        $source = new ArraySource( $newSettings );
261        $this->currentBatch[] = $this->wrapSource( $source );
262
263        return $this;
264    }
265
266    /**
267     * Load settings from a file.
268     *
269     * @param string $path
270     * @return $this
271     */
272    public function loadFile( string $path ): self {
273        return $this->load( $this->makeSource( $path ) );
274    }
275
276    /**
277     * Checks whether the given file exists relative to the settings builder's
278     * base directory.
279     *
280     * @param string $path
281     * @return bool
282     */
283    public function fileExists( string $path ): bool {
284        $path = SettingsFileUtils::resolveRelativeLocation( $path, $this->baseDir );
285        return file_exists( $path );
286    }
287
288    /**
289     * @param SettingsSource $source
290     *
291     * @return SettingsSource
292     */
293    private function wrapSource( SettingsSource $source ): SettingsSource {
294        if ( $this->cache !== null && $source instanceof CacheableSource ) {
295            $source = new CachedSource( $this->cache, $source );
296        }
297        return $source;
298    }
299
300    /**
301     * @param string $location
302     * @return SettingsSource
303     */
304    private function makeSource( $location ): SettingsSource {
305        // NOTE: Currently, files are the only kind of location, but we could add others.
306        //       The set of supported source locations will be hard-coded here.
307        //       Custom SettingsSource would have to be instantiated directly and passed to load().
308        $path = SettingsFileUtils::resolveRelativeLocation( $location, $this->baseDir );
309
310        return $this->wrapSource( new FileSource( $path ) );
311    }
312
313    /**
314     * Assert that the config loaded so far conforms the schema loaded so far.
315     *
316     * @note this is slow, so you probably don't want to do this on every request.
317     *
318     * @return StatusValue
319     */
320    public function validate(): StatusValue {
321        $config = $this->getConfig();
322        return $this->configSchema->validateConfig( $config );
323    }
324
325    /**
326     * Detect usage of deprecated settings. A setting is counted as used if
327     * it has a value other than the default. Note that deprecated settings are
328     * expected to be supported. Settings that have become non-functional should
329     * be marked as obsolete instead.
330     *
331     * @note this is slow, so you probably don't want to do this on every request.
332     * @note Code that needs to call detectDeprecatedConfig() should probably also
333     *       call detectObsoleteConfig() and getWarnings().
334     *
335     * @return array<string,string> an associative array mapping config keys
336     *         to the deprecation messages from the schema.
337     */
338    public function detectDeprecatedConfig(): array {
339        $config = $this->getConfig();
340        $keys = $this->getDefinedConfigKeys();
341        $deprecated = [];
342
343        foreach ( $keys as $key ) {
344            $sch = $this->configSchema->getSchemaFor( $key );
345            if ( !isset( $sch['deprecated'] ) ) {
346                continue;
347            }
348
349            $default = $sch['default'] ?? null;
350            $value = $config->get( $key );
351
352            if ( $value !== $default ) {
353                $deprecated[$key] = $sch['deprecated'];
354            }
355        }
356
357        return $deprecated;
358    }
359
360    /**
361     * Detect usage of obsolete settings. A setting is counted as used if it is
362     * defined in any way. Note that obsolete settings are non-functional, while
363     * deprecated settings are still supported.
364     *
365     * @note this is slow, so you probably don't want to do this on every request.
366     * @note Code that calls detectObsoleteConfig() may also want to
367     *       call detectDeprecatedConfig() and getWarnings().
368     *
369     * @return array<string,string> an associative array mapping config keys
370     *         to the deprecation messages from the schema.
371     */
372    public function detectObsoleteConfig(): array {
373        $config = $this->getConfig();
374        $obsolete = [];
375
376        foreach ( $this->obsoleteConfig as $key => $msg ) {
377            if ( $config->has( $key ) ) {
378                $obsolete[$key] = $msg;
379            }
380        }
381
382        return $obsolete;
383    }
384
385    /**
386     * Return a Config object with default for all settings from all schemas loaded so far.
387     * If the schema for a setting doesn't specify a default, null is assumed.
388     *
389     * @note This will implicitly call apply()
390     *
391     * @return IterableConfig
392     */
393    public function getDefaultConfig(): IterableConfig {
394        $this->apply();
395        $defaults = $this->configSchema->getDefaults();
396        $nulls = array_fill_keys( $this->configSchema->getDefinedKeys(), null );
397
398        return new HashConfig( array_merge( $nulls, $defaults ) );
399    }
400
401    /**
402     * Return the configuration schema.
403     *
404     * @note This will implicitly call apply()
405     *
406     * @return ConfigSchema
407     */
408    public function getConfigSchema(): ConfigSchema {
409        $this->apply();
410        return $this->configSchema;
411    }
412
413    /**
414     * Returns the names of all defined configuration variables
415     *
416     * @return string[]
417     */
418    public function getDefinedConfigKeys(): array {
419        $this->apply();
420        return $this->configSchema->getDefinedKeys();
421    }
422
423    /**
424     * Apply any settings loaded so far to the runtime environment.
425     *
426     * @note This usually makes all configuration available in global variables.
427     * This may however not be the case in the future.
428     *
429     * @return $this
430     * @throws SettingsBuilderException
431     */
432    public function apply(): self {
433        if ( !$this->currentBatch ) {
434            return $this;
435        }
436
437        $this->assertNotReadOnly( __METHOD__ );
438        $this->config = null;
439
440        // XXX: We may want to cache the entire batch after merging together
441        //      settings from all sources, see T304493.
442        $allSettings = $this->loadRecursive( $this->currentBatch );
443
444        foreach ( $allSettings as $settings ) {
445            $this->applySettings( $settings );
446        }
447        $this->reset();
448        return $this;
449    }
450
451    /**
452     * Loads all sources in the current batch, recursively resolving includes.
453     *
454     * @param SettingsSource[] $batch The batch of sources to load
455     * @param string[] $stack The current stack of includes, for cycle detection
456     *
457     * @return array[] an array of settings arrays
458     */
459    private function loadRecursive( array $batch, array $stack = [] ): array {
460        $allSettings = [];
461
462        // Depth-first traversal of settings sources.
463        foreach ( $batch as $source ) {
464            $sourceName = (string)$source;
465
466            if ( in_array( $sourceName, $stack ) ) {
467                throw new SettingsBuilderException(
468                    'Recursive include chain detected: ' . implode( ', ', $stack )
469                );
470            }
471
472            $settings = $source->load();
473            $settings['source-name'] = $sourceName;
474
475            $allSettings[] = $settings;
476
477            $nextBatch = [];
478            foreach ( $settings['includes'] ?? [] as $location ) {
479                // Try to resolve the include relative to the source,
480                // if the source supports that.
481                if ( $source instanceof SettingsIncludeLocator ) {
482                    $location = $source->locateInclude( $location );
483                }
484
485                $nextBatch[] = $this->makeSource( $location );
486            }
487
488            $nextStack = array_merge( $stack, [ $settings['source-name'] ] );
489            $nextSettings = $this->loadRecursive( $nextBatch, $nextStack );
490            $allSettings = array_merge( $allSettings, $nextSettings );
491        }
492
493        return $allSettings;
494    }
495
496    /**
497     * Updates config settings relevant to the behavior if SettingsBuilder itself.
498     *
499     * @param array $config
500     *
501     * @return string
502     */
503    private function updateSettingsConfig( $config ): string {
504        // No merge strategies are applied, defaults are set in the constructor.
505        foreach ( $this->settingsConfig as $key => $dummy ) {
506            if ( array_key_exists( $key, $config ) ) {
507                $this->settingsConfig[ $key ] = $config[ $key ];
508            }
509        }
510        // @phan-suppress-next-line PhanTypeMismatchReturnNullable,PhanPossiblyUndeclaredVariable Always set
511        return $key;
512    }
513
514    /**
515     * Notify SettingsBuilder that it can no longer assume that is has full knowledge of
516     * all configuration variables that have been set. This would be the case when other code
517     * (such as LocalSettings.php) is manipulating global variables which represent config
518     * values.
519     *
520     * This is used for optimization: up until this method is called, default values can be set
521     * directly for any config values that have not been set yet. This avoids the need to
522     * run merge logic for all default values during initialization.
523     *
524     * @note It is useful to call apply() just before this method, so any settings already queued
525     * will still benefit from assuming that globals are not dirty.
526     *
527     * @return self
528     */
529    public function assumeDirtyConfig(): SettingsBuilder {
530        $this->defaultsNeedMerging = true;
531        return $this;
532    }
533
534    /**
535     * Apply schemas from the settings array.
536     *
537     * This returns the default values to apply, splits into two two categories:
538     * "hard" defaults, which can be applied as config overrides without merging.
539     * And "soft" defaults, which have to be reverse-merged.
540     * Defaults can be considered "hard" if no config value was yet set for them. However,
541     * we can only know that as long as we can be sure that nothing has changed config values
542     * in a way that bypasses SettingsLoader (e.g. by setting global variables in LocalSettings.php).
543     *
544     * @param array $settings A settings structure.
545     */
546    private function applySchemas( array $settings ) {
547        $defaults = [];
548
549        if ( isset( $settings['config-schema-inverse'] ) ) {
550            $defaults = $settings['config-schema-inverse']['default'] ?? [];
551            $this->configSchema->addDefaults(
552                $defaults,
553                $settings['source-name']
554            );
555            $this->configSchema->addMergeStrategies(
556                $settings['config-schema-inverse']['mergeStrategy'] ?? [],
557                $settings['source-name']
558            );
559            $this->configSchema->addTypes(
560                $settings['config-schema-inverse']['type'] ?? [],
561                $settings['source-name']
562            );
563            $this->configSchema->addDynamicDefaults(
564                $settings['config-schema-inverse']['dynamicDefault'] ?? [],
565                $settings['source-name']
566            );
567        }
568
569        if ( isset( $settings['config-schema'] ) ) {
570            foreach ( $settings['config-schema'] as $key => $schema ) {
571                $this->configSchema->addSchema( $key, $schema );
572
573                if ( $this->configSchema->hasDefaultFor( $key ) ) {
574                    $defaults[$key] = $this->configSchema->getDefaultFor( $key );
575                }
576            }
577        }
578
579        if ( $this->defaultsNeedMerging ) {
580            $mergeStrategies = $this->configSchema->getMergeStrategies();
581            $this->configSink->setMultiDefault( $defaults, $mergeStrategies );
582        } else {
583            // Optimization: no merge strategy, just override in one go
584            $this->configSink->setMulti( $defaults );
585        }
586    }
587
588    /**
589     * Apply the settings array.
590     *
591     * @param array $settings
592     */
593    private function applySettings( array $settings ) {
594        // First extract config variables that change the behavior of SettingsBuilder.
595        // No merge strategies are applied, defaults are set in the constructor.
596        if ( isset( $settings['config'] ) ) {
597            $this->updateSettingsConfig( $settings['config'] );
598        }
599        if ( isset( $settings['config-overrides'] ) ) {
600            $this->updateSettingsConfig( $settings['config-overrides'] );
601        }
602
603        $this->applySchemas( $settings );
604
605        if ( isset( $settings['config'] ) ) {
606            $mergeStrategies = $this->configSchema->getMergeStrategies();
607            $this->configSink->setMulti( $settings['config'], $mergeStrategies );
608        }
609
610        if ( isset( $settings['config-overrides'] ) ) {
611            // no merge strategies, just override in one go
612            $this->configSink->setMulti( $settings['config-overrides'] );
613        }
614
615        if ( isset( $settings['obsolete-config'] ) ) {
616            $this->obsoleteConfig = array_merge( $this->obsoleteConfig, $settings['obsolete-config'] );
617        }
618
619        if ( isset( $settings['config'] ) || isset( $settings['config-overrides'] ) ) {
620            // We have set some config variables, we can no longer assume we can blindly set defaults
621            // without merging with existing config variables.
622            // XXX: We could potentially track which config variables have been set, so we can still
623            //      apply defaults for other config vars without merging.
624            $this->defaultsNeedMerging = true;
625        }
626
627        foreach ( $settings['php-ini'] ?? [] as $option => $value ) {
628            $this->phpIniSink->set(
629                $option,
630                $value
631            );
632        }
633
634        // TODO: Closely integrate with ExtensionRegistry. Loading extension.json is basically
635        //       the same as loading settings files. See T297166.
636        //       That would also mean that extensions would actually be loaded here,
637        //       not just queued. We can't do this right now, because we need to preserve
638        //       interoperability with wfLoadExtension() being called from LocalSettings.php.
639
640        if ( isset( $settings['extensions'] ) ) {
641            $extDir = $this->settingsConfig[MainConfigNames::ExtensionDirectory];
642            foreach ( $settings['extensions'] ?? [] as $ext ) {
643                $path = "$extDir/$ext/extension.json"; // see wfLoadExtension
644                $this->extensionRegistry->queue( $path );
645            }
646        }
647
648        if ( isset( $settings['skins'] ) ) {
649            $skinDir = $this->settingsConfig[MainConfigNames::StyleDirectory];
650            foreach ( $settings['skins'] ?? [] as $skin ) {
651                $path = "$skinDir/$skin/skin.json"; // see wfLoadSkin
652                $this->extensionRegistry->queue( $path );
653            }
654        }
655    }
656
657    /**
658     * Puts a value into a config variable.
659     * Depending on the variable's specification, the new value may
660     * be merged with the previous value, or may replace it.
661     * This is a shorthand for putConfigValues( [ $key => $value ] ).
662     *
663     * @see overrideConfigValue
664     *
665     * @param string $key the name of the config setting
666     * @param mixed $value The value to set
667     *
668     * @return $this
669     */
670    public function putConfigValue( string $key, $value ): self {
671        return $this->putConfigValues( [ $key => $value ] );
672    }
673
674    /**
675     * Sets the value of multiple config variables.
676     * Depending on the variables' specification, the new values may
677     * be merged with the previous values, or they may replace them.
678     * This is a shorthand for loadArray( [ 'config' => $values ] ).
679     *
680     * @see overrideConfigValues
681     *
682     * @param array $values An associative array mapping names to values.
683     *
684     * @return $this
685     */
686    public function putConfigValues( array $values ): self {
687        return $this->loadArrayInternal( [ 'config' => $values ], __METHOD__ );
688    }
689
690    /**
691     * Override the value of a config variable.
692     * This ignores any merge strategies and discards any previous value.
693     * This is a shorthand for overrideConfigValues( [ $key => $value ] ).
694     *
695     * @see putConfigValue
696     *
697     * @param string $key the name of the config setting
698     * @param mixed $value The value to set
699     *
700     * @return $this
701     */
702    public function overrideConfigValue( string $key, $value ): self {
703        return $this->overrideConfigValues( [ $key => $value ] );
704    }
705
706    /**
707     * Override the value of multiple config variables.
708     * This ignores any merge strategies and discards any previous value.
709     * This is a shorthand for loadArray( [ 'config-overrides' => $values ] ).
710     *
711     * @see putConfigValues
712     *
713     * @param array $values An associative array mapping names to values.
714     *
715     * @return $this
716     */
717    public function overrideConfigValues( array $values ): self {
718        return $this->loadArrayInternal( [ 'config-overrides' => $values ], __METHOD__ );
719    }
720
721    /**
722     * Register hook handlers.
723     *
724     * @param array<string,mixed> $handlers An associative array using the same structure
725     *        as the Hooks config setting:
726     *        Each value is a list of handler callbacks for the hook.
727     *
728     * @return $this
729     * @see HookContainer::register()
730     */
731    public function registerHookHandlers( array $handlers ): self {
732        // NOTE: Rely on the merge strategy for the Hooks setting.
733        // TODO: Make hook handlers a separate structure in settings files,
734        //       like they are in extension.json.
735        return $this->loadArrayInternal( [ 'config' => [ 'Hooks' => $handlers ] ], __METHOD__ );
736    }
737
738    /**
739     * Register a hook handler.
740     *
741     * @param string $hook
742     * @param mixed $handler
743     *
744     * @return $this
745     * @see HookContainer::register()
746     */
747    public function registerHookHandler( string $hook, $handler ): self {
748        // NOTE: Rely on the merge strategy for the Hooks setting.
749        // TODO: Make hook handlers a separate structure in settings files,
750        //       like they are in extension.json.
751        return $this->loadArray( [ 'config' => [ 'Hooks' => [ $hook => [ $handler ] ] ] ] );
752    }
753
754    /**
755     * Returns the config loaded so far. Implicitly triggers apply() when needed.
756     *
757     * @note This will implicitly call apply()
758     *
759     * @return Config
760     */
761    public function getConfig(): Config {
762        // XXX: Would be nice if we could forbid using this method
763        //   before enterRegistrationStage() is called. But we need
764        //   access to some configuration earlier, e.g. WikiFarmSettingsDirectory.
765
766        if ( $this->config && !$this->currentBatch ) {
767            return $this->config;
768        }
769
770        $this->apply();
771        $this->config = $this->configSink->build();
772
773        return $this->config;
774    }
775
776    private function reset() {
777        $this->currentBatch = [];
778    }
779
780    private function assertNotReadOnly( string $func ): void {
781        if ( $this->stage === self::STAGE_READ_ONLY ) {
782            throw new SettingsBuilderException(
783                "$func not supported in operation stage."
784            );
785        }
786    }
787
788    private function assertStillLoading( string $func ): void {
789        if ( $this->stage !== self::STAGE_LOADING ) {
790            throw new SettingsBuilderException(
791                "$func only supported while still in the loading stage."
792            );
793        }
794    }
795
796    /**
797     * Sets the SettingsBuilder read-only.
798     *
799     * Call this before using the configuration returned by getConfig() to construct services objects
800     * or initialize the service container.
801     *
802     * @internal For use in Setup.php.
803     */
804    public function enterReadOnlyStage(): void {
805        $this->apply();
806        $this->stage = self::STAGE_READ_ONLY;
807    }
808
809    /**
810     * Prevents additional settings from being loaded, but still allows manipulation of config values.
811     *
812     * Call this before applying dynamic defaults and executing extension registration callbacks.
813     *
814     * @internal For use in Setup.php.
815     */
816    public function enterRegistrationStage(): void {
817        $this->apply();
818        $this->stage = self::STAGE_REGISTRATION;
819    }
820
821    /**
822     * @internal For use in Setup.php, pending a better solution.
823     * @return ConfigBuilder
824     */
825    public function getConfigBuilder(): ConfigBuilder {
826        $this->apply();
827        return $this->configSink;
828    }
829
830    /**
831     * Log a settings related warning, such as a deprecated config variable.
832     *
833     * This can be used during bootstrapping, when the regular logger is not yet available.
834     * The warnings will be passed to a regular logger after bootstrapping is complete.
835     * In addition, the updater will fail if it finds any warnings.
836     * This allows us to warn about deprecated settings, and make sure they are
837     * replaced before the update proceeds.
838     *
839     * @param string $msg
840     */
841    public function warning( string $msg ) {
842        $this->assertNotReadOnly( __METHOD__ );
843        $this->warnings[] = trim( $msg );
844    }
845
846    /**
847     * Returns any warnings logged by calling warning().
848     *
849     * @internal
850     * @return string[]
851     */
852    public function getWarnings(): array {
853        return $this->warnings;
854    }
855
856}