Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
59.84% covered (warning)
59.84%
76 / 127
41.67% covered (danger)
41.67%
5 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
59.84% covered (warning)
59.84%
76 / 127
41.67% covered (danger)
41.67%
5 / 12
219.44
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 shouldLoadCodeMirror
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
16
 conflictingGadgetsEnabled
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
42
 onEditPage__showEditForm_initial
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 loadCodeMirrorOnEditPage
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 onEditPage__showReadOnlyForm_initial
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 onUploadForm_initial
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 shouldUseV6
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 isBetaFeatureEnabled
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 onResourceLoaderGetConfigVars
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onGetPreferences
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
3
 onGetBetaFeaturePreferences
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace MediaWiki\Extension\CodeMirror;
4
5use InvalidArgumentException;
6use MediaWiki\Config\Config;
7use MediaWiki\EditPage\EditPage;
8use MediaWiki\Extension\BetaFeatures\BetaFeatures;
9use MediaWiki\Extension\Gadgets\GadgetRepo;
10use MediaWiki\Hook\EditPage__showEditForm_initialHook;
11use MediaWiki\Hook\EditPage__showReadOnlyForm_initialHook;
12use MediaWiki\Hook\UploadForm_initialHook;
13use MediaWiki\Output\OutputPage;
14use MediaWiki\Preferences\Hook\GetPreferencesHook;
15use MediaWiki\Registration\ExtensionRegistry;
16use MediaWiki\ResourceLoader\Hook\ResourceLoaderGetConfigVarsHook;
17use Mediawiki\Specials\SpecialUpload;
18use MediaWiki\User\Options\UserOptionsLookup;
19use MediaWiki\User\User;
20
21/**
22 * @phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName
23 */
24class Hooks implements
25    EditPage__showEditForm_initialHook,
26    EditPage__showReadOnlyForm_initialHook,
27    UploadForm_initialHook,
28    ResourceLoaderGetConfigVarsHook,
29    GetPreferencesHook
30{
31
32    private UserOptionsLookup $userOptionsLookup;
33    private array $conflictingGadgets;
34    private bool $useV6;
35    private ?GadgetRepo $gadgetRepo;
36    private string $extensionAssetsPath;
37    private bool $debugMode;
38    private bool $readOnly = false;
39
40    /**
41     * @param UserOptionsLookup $userOptionsLookup
42     * @param Config $config
43     * @param GadgetRepo|null $gadgetRepo
44     */
45    public function __construct(
46        UserOptionsLookup $userOptionsLookup,
47        Config $config,
48        ?GadgetRepo $gadgetRepo
49    ) {
50        $this->userOptionsLookup = $userOptionsLookup;
51        $this->useV6 = $config->get( 'CodeMirrorV6' );
52        $this->conflictingGadgets = $config->get( 'CodeMirrorConflictingGadgets' );
53        $this->gadgetRepo = $gadgetRepo;
54        $this->extensionAssetsPath = $config->get( 'ExtensionAssetsPath' );
55        $this->debugMode = $config->get( 'ShowExceptionDetails' );
56    }
57
58    /**
59     * Checks if any CodeMirror modules should be loaded on this page or not.
60     * Ultimately ::loadCodeMirrorOnEditPage() decides which module(s) get loaded.
61     *
62     * @param OutputPage $out
63     * @param ExtensionRegistry|null $extensionRegistry Overridden in tests.
64     * @param bool $supportWikiEditor
65     * @return bool
66     */
67    public function shouldLoadCodeMirror(
68        OutputPage $out,
69        ?ExtensionRegistry $extensionRegistry = null,
70        bool $supportWikiEditor = true
71    ): bool {
72        // Disable CodeMirror when CodeEditor is active on this page.
73        // Depends on ext.codeEditor being added by \MediaWiki\EditPage\EditPage::showEditForm:initial
74        if ( in_array( 'ext.codeEditor', $out->getModules(), true ) ) {
75            return false;
76        }
77
78        $shouldUseV6 = $this->shouldUseV6( $out );
79        $useCodeMirror = $this->userOptionsLookup->getBoolOption( $out->getUser(), 'usecodemirror' );
80        $useWikiEditor = $supportWikiEditor &&
81            $this->userOptionsLookup->getBoolOption( $out->getUser(), 'usebetatoolbar' );
82        // Disable CodeMirror 5 when the WikiEditor toolbar is not enabled in preferences.
83        if ( !$shouldUseV6 && !$useWikiEditor ) {
84            return false;
85        }
86        // In CodeMirror 6, either WikiEditor or the 'usecodemirror' preference must be enabled.
87        if ( $shouldUseV6 && !$useWikiEditor && !$useCodeMirror ) {
88            return false;
89        }
90
91        $extensionRegistry ??= ExtensionRegistry::getInstance();
92        // Keys are content models, values are the corresponding CodeMirror modes.
93        $contentModels = $extensionRegistry->getAttribute( 'CodeMirrorContentModels' );
94        $contentModel = $out->getTitle()->getContentModel();
95        // b/c: CodeMirrorContentModels extension attribute used to be a flat string array.
96        $isSupportedContentModel = $contentModel && (
97            isset( $contentModels[ $contentModel ] ) ||
98            in_array( $contentModel, $contentModels, true )
99        );
100        $isRTL = $out->getTitle()->getPageLanguage()->isRTL();
101        // Disable CodeMirror if we're on an edit page with a conflicting gadget (T178348)
102        return !$this->conflictingGadgetsEnabled( $extensionRegistry, $out->getUser() ) &&
103            // CodeMirror 5 on any textarea doesn't support RTL (T170001)
104            ( !$isRTL || $shouldUseV6 ) &&
105            // Limit to supported content models. CM5 only supports wikitext.
106            // See https://www.mediawiki.org/wiki/Content_handlers#Extension_content_handlers
107            (
108                ( $shouldUseV6 && $isSupportedContentModel ) ||
109                ( !$shouldUseV6 && $contentModel === CONTENT_MODEL_WIKITEXT )
110            );
111    }
112
113    /**
114     * @param ExtensionRegistry $extensionRegistry
115     * @param User $user
116     * @return bool
117     */
118    private function conflictingGadgetsEnabled( ExtensionRegistry $extensionRegistry, User $user ): bool {
119        if ( !$extensionRegistry->isLoaded( 'Gadgets' ) || !$this->gadgetRepo ) {
120            return false;
121        }
122        $conflictingGadgets = array_intersect( $this->conflictingGadgets, $this->gadgetRepo->getGadgetIds() );
123        foreach ( $conflictingGadgets as $conflictingGadget ) {
124            try {
125                if ( $this->gadgetRepo->getGadget( $conflictingGadget )->isEnabled( $user ) ) {
126                    return true;
127                }
128            } catch ( InvalidArgumentException $e ) {
129                // Safeguard for an invalid gadget ID; treat as gadget not enabled.
130                continue;
131            }
132        }
133        return false;
134    }
135
136    /**
137     * @see https://www.mediawiki.org/wiki/Manual:Hooks/EditPage::showEditForm:initial
138     *
139     * @param EditPage $editor
140     * @param OutputPage $out
141     */
142    public function onEditPage__showEditForm_initial( $editor, $out ): void {
143        if ( !$this->shouldLoadCodeMirror( $out ) ) {
144            return;
145        }
146
147        $useCodeMirror = $this->userOptionsLookup->getBoolOption( $out->getUser(), 'usecodemirror' );
148        $useWikiEditor = $this->userOptionsLookup->getBoolOption( $out->getUser(), 'usebetatoolbar' );
149
150        if ( $this->shouldUseV6( $out ) ) {
151            // Pre-deliver modules for faster loading.
152            $this->loadCodeMirrorOnEditPage( $out );
153        } elseif ( $useWikiEditor ) {
154            // Legacy CM5
155
156            // ext.CodeMirror.WikiEditor adds the toggle button to the toolbar.
157            $out->addModules( 'ext.CodeMirror.WikiEditor' );
158
159            if ( $useCodeMirror ) {
160                // These modules are predelivered for performance when needed
161                // keep these modules in sync with ext.CodeMirror.js
162                $out->addModules( [ 'ext.CodeMirror.lib', 'ext.CodeMirror.mode.mediawiki' ] );
163            }
164        }
165    }
166
167    /**
168     * Set client-side JS variables and pre-deliver modules for optimal performance.
169     * `cmRLModules` is a list of modules that will be lazy-loaded by the client, and,
170     * if the 'usecodemirror' preference is enabled, pre-delivered by ResourceLoader.
171     *
172     * @param OutputPage $out
173     * @param bool $supportWikiEditor
174     */
175    private function loadCodeMirrorOnEditPage( OutputPage $out, bool $supportWikiEditor = true ): void {
176        $useCodeMirror = $this->userOptionsLookup->getBoolOption( $out->getUser(), 'usecodemirror' );
177        $useWikiEditor = $supportWikiEditor &&
178            $this->userOptionsLookup->getBoolOption( $out->getUser(), 'usebetatoolbar' );
179        $modules = [
180            'ext.CodeMirror.v6',
181            ...( $useWikiEditor ? [ 'ext.CodeMirror.v6.WikiEditor' ] : [] ),
182            'ext.CodeMirror.v6.lib',
183            'ext.CodeMirror.v6.init',
184            'ext.CodeMirror.v6.mode.mediawiki'
185        ];
186
187        if ( $useCodeMirror ) {
188            // Pre-deliver modules if we know we're going to need them.
189            $out->addModules( $modules );
190        } elseif ( $useWikiEditor ) {
191            // Load only the init module, which will add the toolbar button
192            // and lazy-load the rest of the modules via the cmRLModules config variable.
193            $out->addModules( 'ext.CodeMirror.v6.init' );
194        }
195
196        $out->addJsConfigVars( [
197            'cmRLModules' => $modules,
198            'cmReadOnly' => $this->readOnly,
199            'cmDebug' => $this->debugMode
200        ] );
201    }
202
203    /**
204     * @see https://www.mediawiki.org/wiki/Manual:Hooks/EditPage::showReadOnlyForm:initial
205     *
206     * @param EditPage $editor
207     * @param OutputPage $out
208     */
209    public function onEditPage__showReadOnlyForm_initial( $editor, $out ): void {
210        if ( $this->shouldUseV6( $out ) && $this->shouldLoadCodeMirror( $out ) ) {
211            $this->readOnly = true;
212            $this->loadCodeMirrorOnEditPage( $out );
213        }
214    }
215
216    /**
217     * @see https://www.mediawiki.org/wiki/Manual:Hooks/UploadForm:initial
218     *
219     * @param SpecialUpload $upload
220     */
221    public function onUploadForm_initial( $upload ): void {
222        if ( $upload->mForReUpload ) {
223            return;
224        }
225        $out = $upload->getOutput();
226        if ( $this->shouldUseV6( $out ) && $this->shouldLoadCodeMirror( $out, null, false ) ) {
227            $this->loadCodeMirrorOnEditPage( $out, false );
228        }
229    }
230
231    /**
232     * @param OutputPage $out
233     * @return bool
234     * @todo Remove check for cm6enable flag after migration is complete
235     */
236    private function shouldUseV6( OutputPage $out ): bool {
237        return $this->useV6 || $out->getRequest()->getBool( 'cm6enable' ) ||
238            $this->isBetaFeatureEnabled( $out->getUser() );
239    }
240
241    /**
242     * @param User $user
243     * @return bool
244     */
245    private function isBetaFeatureEnabled( User $user ): bool {
246        return ExtensionRegistry::getInstance()->isLoaded( 'BetaFeatures' ) &&
247            BetaFeatures::isFeatureEnabled( $user, 'codemirror-beta-feature-enable' );
248    }
249
250    /**
251     * Hook handler for enabling bracket matching.
252     *
253     * TODO: Remove after migration to CodeMirror 6 is complete.
254     *
255     * @param array &$vars Array of variables to be added into the output of the startup module
256     * @param string $skin
257     * @param Config $config
258     * @return void This hook must not abort, it must return no value
259     */
260    public function onResourceLoaderGetConfigVars( array &$vars, $skin, Config $config ): void {
261        $vars['wgCodeMirrorLineNumberingNamespaces'] = $config->get( 'CodeMirrorLineNumberingNamespaces' );
262    }
263
264    /**
265     * GetPreferences hook handler
266     *
267     * @see https://www.mediawiki.org/wiki/Manual:Hooks/GetPreferences
268     *
269     * @param User $user
270     * @param array &$defaultPreferences
271     * @return bool|void True or no return value to continue or false to abort
272     */
273    public function onGetPreferences( $user, &$defaultPreferences ) {
274        if ( !$this->useV6 && !$this->isBetaFeatureEnabled( $user ) ) {
275            $defaultPreferences['usecodemirror'] = [
276                'type' => 'api',
277            ];
278
279            // The following messages are generated upstream by the 'section' value
280            // * prefs-accessibility
281            $defaultPreferences['usecodemirror-colorblind'] = [
282                'type' => 'toggle',
283                'label-message' => 'codemirror-prefs-colorblind',
284                'help-message' => 'codemirror-prefs-colorblind-help',
285                'section' => 'editing/accessibility',
286            ];
287            return;
288        }
289
290        // Show message with a link to the Help page under "Syntax highlighting".
291        // The following messages are generated upstream by the 'section' value:
292        // * prefs-syntax-highlighting
293        $defaultPreferences['usecodemirror-summary'] = [
294            'type' => 'info',
295            'default' => wfMessage( 'codemirror-prefs-summary' )->parse(),
296            'raw' => true,
297            'section' => 'editing/syntax-highlighting'
298        ];
299
300        // CodeMirror is disabled by default for all users. It can enabled for everyone
301        // by default by adding '$wgDefaultUserOptions['usecodemirror'] = 1;' into LocalSettings.php
302        $defaultPreferences['usecodemirror'] = [
303            'type' => 'toggle',
304            'label-message' => 'codemirror-prefs-enable',
305            'section' => 'editing/syntax-highlighting',
306        ];
307
308        $defaultPreferences['usecodemirror-colorblind'] = [
309            'type' => 'toggle',
310            'label-message' => 'codemirror-v6-prefs-colorblind',
311            'section' => 'editing/syntax-highlighting',
312            'disable-if' => [ '!==', 'usecodemirror', '1' ]
313        ];
314
315        $defaultPreferences['codemirror-preferences'] = [
316            'type' => 'api',
317        ];
318    }
319
320    /**
321     * GetBetaFeaturePreferences hook handler
322     *
323     * @param User $user
324     * @param array &$betaPrefs
325     */
326    public function onGetBetaFeaturePreferences( User $user, array &$betaPrefs ): void {
327        if ( $this->useV6 ) {
328            return;
329        }
330        $betaPrefs[ 'codemirror-beta-feature-enable' ] = [
331            'label-message' => 'codemirror-beta-feature-title',
332            'desc-message' => 'codemirror-beta-feature-description',
333            'screenshot' => [
334                'ltr' => $this->extensionAssetsPath . '/CodeMirror/resources/images/codemirror.beta-feature-ltr.svg',
335                'rtl' => $this->extensionAssetsPath . '/CodeMirror/resources/images/codemirror.beta-feature-rtl.svg'
336            ],
337            'info-link' => 'https://www.mediawiki.org/wiki/Special:MyLanguage/Help:Extension:CodeMirror',
338            'discussion-link' => 'https://www.mediawiki.org/wiki/Help_talk:Extension:CodeMirror'
339        ];
340    }
341}