Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.53% covered (warning)
76.53%
75 / 98
61.11% covered (warning)
61.11%
11 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
TextSlotDiffRenderer
76.53% covered (warning)
76.53%
75 / 98
61.11% covered (warning)
61.11%
11 / 18
47.08
0.00% covered (danger)
0.00%
0 / 1
 getExtraCacheKeys
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 diff
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 setStatsdDataFactory
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setStatsFactory
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLanguage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setHookContainer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setContentModel
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setEngine
61.90% covered (warning)
61.90%
13 / 21
0.00% covered (danger)
0.00%
0 / 1
11.54
 setFormat
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setTextDiffer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTextDiffer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setInlineToggleEnabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getContentModel
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDiff
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 localizeDiff
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTablePrefix
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
6
 getTextDiff
70.00% covered (warning)
70.00%
14 / 20
0.00% covered (danger)
0.00%
0 / 1
3.24
 getTextDiffInternal
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Renders a slot diff by doing a text diff on the native representation.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup DifferenceEngine
22 */
23
24use MediaWiki\Content\Content;
25use MediaWiki\Content\TextContent;
26use MediaWiki\Context\IContextSource;
27use MediaWiki\Context\RequestContext;
28use MediaWiki\Diff\TextDiffer\ManifoldTextDiffer;
29use MediaWiki\Diff\TextDiffer\TextDiffer;
30use MediaWiki\HookContainer\HookContainer;
31use MediaWiki\HookContainer\HookRunner;
32use MediaWiki\Html\Html;
33use MediaWiki\Language\Language;
34use MediaWiki\MediaWikiServices;
35use MediaWiki\PoolCounter\PoolCounterWorkViaCallback;
36use MediaWiki\Status\Status;
37use MediaWiki\Title\Title;
38use OOUI\ToggleSwitchWidget;
39use Wikimedia\Stats\IBufferingStatsdDataFactory;
40use Wikimedia\Stats\StatsFactory;
41
42/**
43 * Renders a slot diff by doing a text diff on the native representation.
44 *
45 * If you want to use this without content objects (to call getTextDiff() on some
46 * non-content-related texts), obtain an instance with
47 *     ContentHandler::getForModelID( CONTENT_MODEL_TEXT )
48 *         ->getSlotDiffRenderer( RequestContext::getMain() )
49 *
50 * @ingroup DifferenceEngine
51 */
52class TextSlotDiffRenderer extends SlotDiffRenderer {
53
54    /** Use the PHP diff implementation (DiffEngine). */
55    public const ENGINE_PHP = 'php';
56
57    /** Use the wikidiff2 PHP module. */
58    public const ENGINE_WIKIDIFF2 = 'wikidiff2';
59
60    /** Use the wikidiff2 PHP module. */
61    public const ENGINE_WIKIDIFF2_INLINE = 'wikidiff2inline';
62
63    /** Use an external executable. */
64    public const ENGINE_EXTERNAL = 'external';
65
66    public const INLINE_LEGEND_KEY = '10_mw-diff-inline-legend';
67
68    public const INLINE_SWITCHER_KEY = '60_mw-diff-inline-switch';
69
70    /** @var StatsFactory|null */
71    private $statsFactory;
72
73    /** @var HookRunner|null */
74    private $hookRunner;
75
76    /** @var string|null */
77    private $format;
78
79    /** @var string */
80    private $contentModel;
81
82    /** @var TextDiffer|null */
83    private $textDiffer;
84
85    /** @var bool */
86    private $inlineToggleEnabled = false;
87
88    /** @inheritDoc */
89    public function getExtraCacheKeys() {
90        return $this->textDiffer->getCacheKeys( [ $this->format ] );
91    }
92
93    /**
94     * Convenience helper to use getTextDiff without an instance.
95     * @param string $oldText
96     * @param string $newText
97     * @param array $options
98     * @return string
99     */
100    public static function diff( $oldText, $newText, $options = [] ) {
101        /** @var TextSlotDiffRenderer $slotDiffRenderer */
102        $slotDiffRenderer = MediaWikiServices::getInstance()
103            ->getContentHandlerFactory()
104            ->getContentHandler( CONTENT_MODEL_TEXT )
105            ->getSlotDiffRenderer( RequestContext::getMain(), $options );
106        '@phan-var TextSlotDiffRenderer $slotDiffRenderer';
107        return $slotDiffRenderer->getTextDiff( $oldText, $newText );
108    }
109
110    /**
111     * This has no effect since MW 1.43.
112     *
113     * @internal Use ContentHandler::createTextSlotDiffRenderer instead
114     * @param IBufferingStatsdDataFactory $statsdDataFactory
115     */
116    public function setStatsdDataFactory( IBufferingStatsdDataFactory $statsdDataFactory ) {
117        wfDeprecated( __METHOD__, '1.43' );
118    }
119
120    /**
121     * @internal Use ContentHandler::createTextSlotDiffRenderer instead
122     * @param StatsFactory $statsFactory
123     */
124    public function setStatsFactory( StatsFactory $statsFactory ) {
125        $this->statsFactory = $statsFactory;
126    }
127
128    /**
129     * This has no effect since MW 1.41. The language is now injected via setTextDiffer().
130     *
131     * @param Language $language
132     * @deprecated since 1.41
133     */
134    public function setLanguage( Language $language ) {
135        wfDeprecated( __METHOD__, '1.41' );
136    }
137
138    /**
139     * @internal Use ContentHandler::createTextSlotDiffRenderer instead
140     * @since 1.41
141     * @param HookContainer $hookContainer
142     */
143    public function setHookContainer( HookContainer $hookContainer ): void {
144        $this->hookRunner = new HookRunner( $hookContainer );
145    }
146
147    /**
148     * @param string $contentModel
149     * @since 1.41
150     */
151    public function setContentModel( string $contentModel ) {
152        $this->contentModel = $contentModel;
153    }
154
155    /**
156     * Set which diff engine to use.
157     *
158     * @param string $type One of the ENGINE_* constants.
159     * @param null $executable Must be null since 1.41. Previously a path to execute.
160     */
161    public function setEngine( $type, $executable = null ) {
162        if ( $executable !== null ) {
163            throw new \InvalidArgumentException(
164                'The $executable parameter is no longer supported and must be null'
165            );
166        }
167        switch ( $type ) {
168            case self::ENGINE_PHP:
169                $engine = 'php';
170                $format = 'table';
171                break;
172
173            case self::ENGINE_WIKIDIFF2:
174                $engine = 'wikidiff2';
175                $format = 'table';
176                break;
177
178            case self::ENGINE_EXTERNAL:
179                $engine = 'external';
180                $format = 'external';
181                break;
182
183            case self::ENGINE_WIKIDIFF2_INLINE:
184                $engine = 'wikidiff2';
185                $format = 'inline';
186                break;
187
188            default:
189                throw new \InvalidArgumentException( '$type ' .
190                    'must be one of the TextSlotDiffRenderer::ENGINE_* constants' );
191        }
192        if ( $this->textDiffer instanceof ManifoldTextDiffer ) {
193            $this->textDiffer->setEngine( $engine );
194        }
195        $this->setFormat( $format );
196    }
197
198    /**
199     * Set the TextDiffer format
200     *
201     * @since 1.41
202     * @param string $format
203     */
204    public function setFormat( $format ) {
205        $this->format = $format;
206    }
207
208    /**
209     * @param TextDiffer $textDiffer
210     */
211    public function setTextDiffer( TextDiffer $textDiffer ) {
212        $this->textDiffer = $textDiffer;
213    }
214
215    /**
216     * Get the current TextDiffer, or throw an exception if setTextDiffer() has
217     * not been called.
218     *
219     * @return TextDiffer
220     */
221    private function getTextDiffer(): TextDiffer {
222        return $this->textDiffer;
223    }
224
225    /**
226     * Set a flag indicating whether the inline toggle switch is shown.
227     *
228     * @since 1.41
229     * @param bool $enabled
230     */
231    public function setInlineToggleEnabled( $enabled = true ) {
232        $this->inlineToggleEnabled = $enabled;
233    }
234
235    /**
236     * Get the content model ID that this renderer acts on
237     *
238     * @since 1.41
239     * @return string
240     */
241    public function getContentModel(): string {
242        return $this->contentModel;
243    }
244
245    /** @inheritDoc */
246    public function getDiff( ?Content $oldContent = null, ?Content $newContent = null ) {
247        $this->normalizeContents( $oldContent, $newContent, TextContent::class );
248
249        $oldText = $oldContent->serialize();
250        $newText = $newContent->serialize();
251
252        return $this->getTextDiff( $oldText, $newText );
253    }
254
255    public function localizeDiff( $diff, $options = [] ) {
256        return $this->textDiffer->localize( $this->format, $diff, $options );
257    }
258
259    /**
260     * @inheritDoc
261     */
262    public function getTablePrefix( IContextSource $context, Title $newTitle ): array {
263        $parts = $this->getTextDiffer()->getTablePrefixes( $this->format );
264
265        $showDiffToggleSwitch = $this->inlineToggleEnabled && $this->getTextDiffer()->hasFormat( 'inline' );
266        // If we support the inline type, add a toggle switch
267        if ( $showDiffToggleSwitch ) {
268            $values = $context->getRequest()->getQueryValues();
269            $isInlineDiffType = $this->format === 'inline';
270            $values[ 'diff-type' ] = $isInlineDiffType ? 'table' : 'inline';
271            unset( $values[ 'title' ] );
272            $parts[self::INLINE_SWITCHER_KEY] = Html::rawElement( 'div',
273                [ 'class' => 'mw-diffPage-inlineToggle-container' ],
274                new OOUI\FieldLayout(
275                    new ToggleSwitchWidget( [
276                        'id' => 'mw-diffPage-inline-toggle-switch',
277                        'href' => $newTitle->getLocalURL( $values ),
278                        'value' => $isInlineDiffType,
279                        'title' => $context->msg( 'diff-inline-switch-desc' )->plain()
280                    ] ),
281                    [
282                        'id' => 'mw-diffPage-inline-toggle-switch-layout',
283                        'label' => $context->msg( 'diff-inline-format-label' )->plain(),
284                        'infusable' => true,
285                        'title' => $context->msg( 'diff-inline-switch-desc' )->plain()
286                    ]
287                ),
288            );
289        }
290        // Add an empty placeholder for the legend is added when it's not in
291        // use and other items have been added.
292        $parts += [ self::INLINE_LEGEND_KEY => null, self::INLINE_SWITCHER_KEY => null ];
293
294        // Allow extensions to add other parts to this area (or modify the legend).
295        $this->hookRunner->onTextSlotDiffRendererTablePrefix( $this, $context, $parts );
296        if ( count( $parts ) > 1 && $parts[self::INLINE_LEGEND_KEY] === null ) {
297            $parts[self::INLINE_LEGEND_KEY] = Html::element( 'div' );
298        }
299        return $parts;
300    }
301
302    /**
303     * Diff the text representations of two content objects (or just two pieces of text in general).
304     * @param string $oldText
305     * @param string $newText
306     * @return string HTML. One or more <tr> tags, or an empty string if the inputs are identical.
307     */
308    public function getTextDiff( string $oldText, string $newText ) {
309        $diff = function () use ( $oldText, $newText ) {
310            $time = microtime( true );
311
312            $result = $this->getTextDiffInternal( $oldText, $newText );
313
314            $time = intval( ( microtime( true ) - $time ) * 1000 );
315
316            if ( $this->statsFactory ) {
317                $this->statsFactory->getTiming( 'diff_text_seconds' )
318                    ->copyToStatsdAt( 'diff_time' )
319                    ->observe( $time );
320            }
321
322            return $result;
323        };
324
325        /**
326         * @param Status $status
327         * @throws FatalError
328         * @return never
329         */
330        $error = static function ( $status ) {
331            throw new FatalError( $status->getWikiText() );
332        };
333
334        // Use PoolCounter if the diff looks like it can be expensive
335        if ( strlen( $oldText ) + strlen( $newText ) > 20000 ) {
336            $work = new PoolCounterWorkViaCallback( 'diff',
337                md5( $oldText ) . md5( $newText ),
338                [ 'doWork' => $diff, 'error' => $error ]
339            );
340            return $work->execute();
341        }
342
343        return $diff();
344    }
345
346    /**
347     * Diff the text representations of two content objects (or just two pieces of text in general).
348     * This does the actual diffing, getTextDiff() wraps it with logging and resource limiting.
349     * @param string $oldText
350     * @param string $newText
351     * @return string
352     * @throws Exception
353     */
354    protected function getTextDiffInternal( $oldText, $newText ) {
355        $oldText = str_replace( "\r\n", "\n", $oldText );
356        $newText = str_replace( "\r\n", "\n", $newText );
357
358        if ( $oldText === $newText ) {
359            return '';
360        }
361
362        $textDiffer = $this->getTextDiffer();
363        $diffText = $textDiffer->render( $oldText, $newText, $this->format );
364        return $textDiffer->addRowWrapper( $this->format, $diffText );
365    }
366
367}