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