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