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