Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.84% covered (warning)
76.84%
73 / 95
64.71% covered (warning)
64.71%
11 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
TextSlotDiffRenderer
76.84% covered (warning)
76.84%
73 / 95
64.71% covered (warning)
64.71%
11 / 17
44.72
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
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 * 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\Context\IContextSource;
25use MediaWiki\Context\RequestContext;
26use MediaWiki\Diff\TextDiffer\ManifoldTextDiffer;
27use MediaWiki\Diff\TextDiffer\TextDiffer;
28use MediaWiki\HookContainer\HookContainer;
29use MediaWiki\HookContainer\HookRunner;
30use MediaWiki\Html\Html;
31use MediaWiki\MediaWikiServices;
32use MediaWiki\PoolCounter\PoolCounterWorkViaCallback;
33use MediaWiki\Status\Status;
34use MediaWiki\Title\Title;
35use OOUI\ToggleSwitchWidget;
36
37/**
38 * Renders a slot diff by doing a text diff on the native representation.
39 *
40 * If you want to use this without content objects (to call getTextDiff() on some
41 * non-content-related texts), obtain an instance with
42 *     ContentHandler::getForModelID( CONTENT_MODEL_TEXT )
43 *         ->getSlotDiffRenderer( RequestContext::getMain() )
44 *
45 * @ingroup DifferenceEngine
46 */
47class TextSlotDiffRenderer extends SlotDiffRenderer {
48
49    /** Use the PHP diff implementation (DiffEngine). */
50    public const ENGINE_PHP = 'php';
51
52    /** Use the wikidiff2 PHP module. */
53    public const ENGINE_WIKIDIFF2 = 'wikidiff2';
54
55    /** Use the wikidiff2 PHP module. */
56    public const ENGINE_WIKIDIFF2_INLINE = 'wikidiff2inline';
57
58    /** Use an external executable. */
59    public const ENGINE_EXTERNAL = 'external';
60
61    public const INLINE_LEGEND_KEY = '10_mw-diff-inline-legend';
62
63    public const INLINE_SWITCHER_KEY = '60_mw-diff-inline-switch';
64
65    /** @var IBufferingStatsdDataFactory|null */
66    private $statsdDataFactory;
67
68    /** @var HookRunner|null */
69    private $hookRunner;
70
71    /** @var string|null */
72    private $format;
73
74    /** @var string */
75    private $contentModel;
76
77    /** @var TextDiffer|null */
78    private $textDiffer;
79
80    /** @var bool */
81    private $inlineToggleEnabled = false;
82
83    /** @inheritDoc */
84    public function getExtraCacheKeys() {
85        return $this->textDiffer->getCacheKeys( [ $this->format ] );
86    }
87
88    /**
89     * Convenience helper to use getTextDiff without an instance.
90     * @param string $oldText
91     * @param string $newText
92     * @param array $options
93     * @return string
94     */
95    public static function diff( $oldText, $newText, $options = [] ) {
96        /** @var TextSlotDiffRenderer $slotDiffRenderer */
97        $slotDiffRenderer = MediaWikiServices::getInstance()
98            ->getContentHandlerFactory()
99            ->getContentHandler( CONTENT_MODEL_TEXT )
100            ->getSlotDiffRenderer( RequestContext::getMain(), $options );
101        '@phan-var TextSlotDiffRenderer $slotDiffRenderer';
102        return $slotDiffRenderer->getTextDiff( $oldText, $newText );
103    }
104
105    /**
106     * @param IBufferingStatsdDataFactory $statsdDataFactory
107     */
108    public function setStatsdDataFactory( IBufferingStatsdDataFactory $statsdDataFactory ) {
109        $this->statsdDataFactory = $statsdDataFactory;
110    }
111
112    /**
113     * This has no effect since MW 1.41. The language is now injected via setTextDiffer().
114     *
115     * @param Language $language
116     * @deprecated since 1.41
117     */
118    public function setLanguage( Language $language ) {
119        wfDeprecated( __METHOD__, '1.41' );
120    }
121
122    /**
123     * @since 1.41
124     * @param HookContainer $hookContainer
125     */
126    public function setHookContainer( HookContainer $hookContainer ): void {
127        $this->hookRunner = new HookRunner( $hookContainer );
128    }
129
130    /**
131     * @param string $contentModel
132     * @since 1.41
133     */
134    public function setContentModel( string $contentModel ) {
135        $this->contentModel = $contentModel;
136    }
137
138    /**
139     * Set which diff engine to use.
140     *
141     * @param string $type One of the ENGINE_* constants.
142     * @param null $executable Must be null since 1.41. Previously a path to execute.
143     */
144    public function setEngine( $type, $executable = null ) {
145        if ( $executable !== null ) {
146            throw new \InvalidArgumentException(
147                'The $executable parameter is no longer supported and must be null'
148            );
149        }
150        switch ( $type ) {
151            case self::ENGINE_PHP:
152                $engine = 'php';
153                $format = 'table';
154                break;
155
156            case self::ENGINE_WIKIDIFF2:
157                $engine = 'wikidiff2';
158                $format = 'table';
159                break;
160
161            case self::ENGINE_EXTERNAL:
162                $engine = 'external';
163                $format = 'external';
164                break;
165
166            case self::ENGINE_WIKIDIFF2_INLINE:
167                $engine = 'wikidiff2';
168                $format = 'inline';
169                break;
170
171            default:
172                throw new \InvalidArgumentException( '$type ' .
173                    'must be one of the TextSlotDiffRenderer::ENGINE_* constants' );
174        }
175        if ( $this->textDiffer instanceof ManifoldTextDiffer ) {
176            $this->textDiffer->setEngine( $engine );
177        }
178        $this->setFormat( $format );
179    }
180
181    /**
182     * Set the TextDiffer format
183     *
184     * @since 1.41
185     * @param string $format
186     */
187    public function setFormat( $format ) {
188        $this->format = $format;
189    }
190
191    /**
192     * @param TextDiffer $textDiffer
193     */
194    public function setTextDiffer( TextDiffer $textDiffer ) {
195        $this->textDiffer = $textDiffer;
196    }
197
198    /**
199     * Get the current TextDiffer, or throw an exception if setTextDiffer() has
200     * not been called.
201     *
202     * @return TextDiffer
203     */
204    private function getTextDiffer(): TextDiffer {
205        return $this->textDiffer;
206    }
207
208    /**
209     * Set a flag indicating whether the inline toggle switch is shown.
210     *
211     * @since 1.41
212     * @param bool $enabled
213     */
214    public function setInlineToggleEnabled( $enabled = true ) {
215        $this->inlineToggleEnabled = $enabled;
216    }
217
218    /**
219     * Get the content model ID that this renderer acts on
220     *
221     * @since 1.41
222     * @return string
223     */
224    public function getContentModel(): string {
225        return $this->contentModel;
226    }
227
228    /** @inheritDoc */
229    public function getDiff( Content $oldContent = null, Content $newContent = null ) {
230        $this->normalizeContents( $oldContent, $newContent, TextContent::class );
231
232        $oldText = $oldContent->serialize();
233        $newText = $newContent->serialize();
234
235        return $this->getTextDiff( $oldText, $newText );
236    }
237
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()->getValues();
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                ),
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            $time = microtime( true );
294
295            $result = $this->getTextDiffInternal( $oldText, $newText );
296
297            $time = intval( ( microtime( true ) - $time ) * 1000 );
298            if ( $this->statsdDataFactory ) {
299                $this->statsdDataFactory->timing( 'diff_time', $time );
300            }
301
302            return $result;
303        };
304
305        /**
306         * @param Status $status
307         * @throws FatalError
308         * @return never
309         */
310        $error = static function ( $status ) {
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}