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