Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
67.29% covered (warning)
67.29%
72 / 107
50.00% covered (danger)
50.00%
7 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Wikidiff2TextDiffer
67.29% covered (warning)
67.29%
72 / 107
50.00% covered (danger)
50.00%
7 / 14
64.63
0.00% covered (danger)
0.00%
0 / 1
 isInstalled
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 __construct
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
 getName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFormatContext
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getCacheKeys
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getOptionsHash
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 doRenderBatch
68.00% covered (warning)
68.00%
17 / 25
0.00% covered (danger)
0.00%
0 / 1
8.61
 doTableFormat
50.00% covered (danger)
50.00%
6 / 12
0.00% covered (danger)
0.00%
0 / 1
2.50
 doInlineFormat
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFormats
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTablePrefixes
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 localize
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 addLocalizedTitleTooltips
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 getPreferredFormatBatch
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace MediaWiki\Diff\TextDiffer;
4
5use MediaWiki\Html\Html;
6use TextSlotDiffRenderer;
7
8/**
9 * @since 1.41
10 */
11class Wikidiff2TextDiffer extends BaseTextDiffer {
12    /** @var string */
13    private $version;
14    /** @var bool */
15    private $haveMoveSupport;
16    /** @var bool */
17    private $haveMultiFormatSupport;
18    /** @var bool */
19    private $haveCutoffParameter;
20    /** @var bool */
21    private $useMultiFormat;
22    /** @var array */
23    private $defaultOptions;
24    /** @var array[] */
25    private $formatOptions;
26    /** @var string */
27    private $optionsHash;
28
29    private const OPT_NAMES = [
30        'numContextLines',
31        'changeThreshold',
32        'movedLineThreshold',
33        'maxMovedLines',
34        'maxWordLevelDiffComplexity',
35        'maxSplitSize',
36        'initialSplitThreshold',
37        'finalSplitThreshold',
38    ];
39
40    /**
41     * Fake wikidiff2 extension version for PHPUnit testing
42     * @var string|null
43     */
44    public static $fakeVersionForTesting = null;
45
46    /**
47     * Determine whether the extension is installed (or mocked for testing)
48     *
49     * @return bool
50     */
51    public static function isInstalled() {
52        return self::$fakeVersionForTesting !== null
53            || function_exists( 'wikidiff2_do_diff' );
54    }
55
56    /**
57     * @param array $options
58     */
59    public function __construct( $options ) {
60        $this->version = self::$fakeVersionForTesting ?? phpversion( 'wikidiff2' );
61        $this->haveMoveSupport = version_compare( $this->version, '1.5.0', '>=' );
62        $this->haveMultiFormatSupport = version_compare( $this->version, '1.14.0', '>=' );
63        $this->haveCutoffParameter = $this->haveMoveSupport
64            && version_compare( $this->version, '1.8.0', '<' );
65
66        $this->useMultiFormat = $this->haveMultiFormatSupport && !empty( $options['useMultiFormat'] );
67        $validOpts = array_fill_keys( self::OPT_NAMES, true );
68        $this->defaultOptions = array_intersect_key( $options, $validOpts );
69        $this->formatOptions = [];
70        foreach ( $options['formatOptions'] ?? [] as $format => $formatOptions ) {
71            $this->formatOptions[$format] = array_intersect_key( $formatOptions, $validOpts );
72        }
73    }
74
75    public function getName(): string {
76        return 'wikidiff2';
77    }
78
79    public function getFormatContext( string $format ) {
80        return $format === 'inline' ? self::CONTEXT_PLAIN : self::CONTEXT_ROW;
81    }
82
83    public function getCacheKeys( array $formats ): array {
84        return [
85            '20-wikidiff2-version' => $this->version,
86            '21-wikidiff2-options' => $this->getOptionsHash(),
87        ];
88    }
89
90    /**
91     * Get a hash of the cache-varying constructor options
92     *
93     * @return string
94     */
95    private function getOptionsHash() {
96        if ( $this->optionsHash === null ) {
97            $json = json_encode(
98                [
99                    $this->useMultiFormat,
100                    $this->defaultOptions,
101                    $this->formatOptions,
102                ],
103                JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
104            );
105            $this->optionsHash = substr( md5( $json ), 0, 8 );
106        }
107        return $this->optionsHash;
108    }
109
110    public function doRenderBatch( string $oldText, string $newText, array $formats ): array {
111        if ( $this->useMultiFormat ) {
112            if ( !$this->formatOptions ) {
113                /** @var array $result */
114                $result = wikidiff2_multi_format_diff(
115                    $oldText,
116                    $newText,
117                    [ 'formats' => $formats ] + $this->defaultOptions
118                );
119            } else {
120                $result = [];
121                foreach ( $formats as $format ) {
122                    $result[$format] = wikidiff2_multi_format_diff(
123                        $oldText,
124                        $newText,
125                        [ 'formats' => $formats ]
126                        + ( $this->formatOptions[$format] ?? [] )
127                        + $this->defaultOptions
128                    );
129                }
130            }
131        } else {
132            $result = [];
133            foreach ( $formats as $format ) {
134                switch ( $format ) {
135                    case 'table':
136                        $result['table'] = $this->doTableFormat( $oldText, $newText );
137                        break;
138
139                    case 'inline':
140                        $result['inline'] = $this->doInlineFormat( $oldText, $newText );
141                        break;
142                }
143            }
144        }
145        return $result;
146    }
147
148    /**
149     * Do a table format diff
150     *
151     * @param string $old
152     * @param string $new
153     * @return string
154     */
155    private function doTableFormat( $old, $new ) {
156        if ( $this->haveCutoffParameter ) {
157            return wikidiff2_do_diff(
158                $old,
159                $new,
160                2,
161                0
162            );
163        } else {
164            // Don't pass the 4th parameter introduced in version 1.5.0 and removed in version 1.8.0
165            return wikidiff2_do_diff(
166                $old,
167                $new,
168                2
169            );
170        }
171    }
172
173    /**
174     * Do an inline format diff
175     *
176     * @param string $oldText
177     * @param string $newText
178     * @return string
179     */
180    private function doInlineFormat( $oldText, $newText ) {
181        return wikidiff2_inline_diff( $oldText, $newText, 2 );
182    }
183
184    public function getFormats(): array {
185        return [ 'table', 'inline' ];
186    }
187
188    public function getTablePrefixes( string $format ): array {
189        $localizer = $this->getLocalizer();
190        $ins = Html::element( 'span',
191            [ 'class' => 'mw-diff-inline-legend-ins' ],
192            $localizer->msg( 'diff-inline-tooltip-ins' )->plain()
193        );
194        $del = Html::element( 'span',
195            [ 'class' => 'mw-diff-inline-legend-del' ],
196            $localizer->msg( 'diff-inline-tooltip-del' )->plain()
197        );
198        $hideDiffClass = $format === 'inline' ? '' : 'oo-ui-element-hidden';
199        $legend = Html::rawElement( 'div',
200            [ 'class' => 'mw-diff-inline-legend ' . $hideDiffClass ], "$del $ins"
201        );
202        return [ TextSlotDiffRenderer::INLINE_LEGEND_KEY => $legend ];
203    }
204
205    public function localize( string $format, string $diff, array $options = [] ): string {
206        $diff = $this->localizeLineNumbers( $diff,
207            $options['reducedLineNumbers'] ?? false
208        );
209        if ( $this->haveMoveSupport ) {
210            $diff = $this->addLocalizedTitleTooltips( $format, $diff );
211        }
212        return $diff;
213    }
214
215    /**
216     * Add title attributes for tooltips on various diff elements
217     *
218     * @param string $format
219     * @param string $text
220     * @return string
221     */
222    private function addLocalizedTitleTooltips( $format, $text ) {
223        // Moved paragraph indicators.
224        $localizer = $this->getLocalizer();
225        $replacements = [
226            'class="mw-diff-movedpara-right"' =>
227                'class="mw-diff-movedpara-right" title="' .
228                $localizer->msg( 'diff-paragraph-moved-toold' )->escaped() . '"',
229            'class="mw-diff-movedpara-left"' =>
230                'class="mw-diff-movedpara-left" title="' .
231                $localizer->msg( 'diff-paragraph-moved-tonew' )->escaped() . '"',
232        ];
233        // For inline diffs, add tooltips to `<ins>` and `<del>`.
234        if ( $format == 'inline' ) {
235            $replacements['<ins>'] = Html::openElement( 'ins',
236                [ 'title' => $localizer->msg( 'diff-inline-tooltip-ins' )->plain() ] );
237            $replacements['<del>'] = Html::openElement( 'del',
238                [ 'title' => $localizer->msg( 'diff-inline-tooltip-del' )->plain() ] );
239        }
240        return strtr( $text, $replacements );
241    }
242
243    public function getPreferredFormatBatch( string $format ): array {
244        if ( $this->formatOptions ) {
245            return [ $format ];
246        } else {
247            return [ 'table', 'inline' ];
248        }
249    }
250}