Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
66.36% covered (warning)
66.36%
71 / 107
50.00% covered (danger)
50.00%
7 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Wikidiff2TextDiffer
66.36% covered (warning)
66.36%
71 / 107
50.00% covered (danger)
50.00%
7 / 14
67.60
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
64.00% covered (warning)
64.00%
16 / 25
0.00% covered (danger)
0.00%
0 / 1
9.29
 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\Diff\TextSlotDiffRenderer;
6use MediaWiki\Html\Html;
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    /** @inheritDoc */
80    public function getFormatContext( string $format ) {
81        return $format === 'inline' ? self::CONTEXT_PLAIN : self::CONTEXT_ROW;
82    }
83
84    public function getCacheKeys( array $formats ): array {
85        return [
86            '20-wikidiff2-version' => $this->version,
87            '21-wikidiff2-options' => $this->getOptionsHash(),
88        ];
89    }
90
91    /**
92     * Get a hash of the cache-varying constructor options
93     *
94     * @return string
95     */
96    private function getOptionsHash() {
97        if ( $this->optionsHash === null ) {
98            $json = json_encode(
99                [
100                    $this->useMultiFormat,
101                    $this->defaultOptions,
102                    $this->formatOptions,
103                ],
104                JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
105            );
106            $this->optionsHash = substr( md5( $json ), 0, 8 );
107        }
108        return $this->optionsHash;
109    }
110
111    public function doRenderBatch( string $oldText, string $newText, array $formats ): array {
112        if ( $this->useMultiFormat ) {
113            if ( !$this->formatOptions ) {
114                /** @var array $result */
115                $result = wikidiff2_multi_format_diff(
116                    $oldText,
117                    $newText,
118                    [ 'formats' => $formats ] + $this->defaultOptions
119                );
120            } else {
121                $result = [];
122                foreach ( $formats as $format ) {
123                    $result[$format] = wikidiff2_multi_format_diff(
124                        $oldText,
125                        $newText,
126                        [ 'formats' => $formats ]
127                        + ( $this->formatOptions[$format] ?? [] )
128                        + $this->defaultOptions
129                    );
130                }
131            }
132        } else {
133            $result = [];
134            foreach ( $formats as $format ) {
135                switch ( $format ) {
136                    case 'table':
137                        $result['table'] = $this->doTableFormat( $oldText, $newText );
138                        break;
139
140                    case 'inline':
141                        $result['inline'] = $this->doInlineFormat( $oldText, $newText );
142                        break;
143                }
144            }
145        }
146        return $result;
147    }
148
149    /**
150     * Do a table format diff
151     *
152     * @param string $old
153     * @param string $new
154     * @return string
155     * @suppress PhanParamTooManyInternal,UnusedSuppression Spurious Phan warning with wikidiff <1.5.0 or >=1.8.0
156     */
157    private function doTableFormat( $old, $new ) {
158        if ( $this->haveCutoffParameter ) {
159            return wikidiff2_do_diff(
160                $old,
161                $new,
162                2,
163                0
164            );
165        } else {
166            // Don't pass the 4th parameter introduced in version 1.5.0 and removed in version 1.8.0
167            return wikidiff2_do_diff(
168                $old,
169                $new,
170                2
171            );
172        }
173    }
174
175    /**
176     * Do an inline format diff
177     *
178     * @param string $oldText
179     * @param string $newText
180     * @return string
181     */
182    private function doInlineFormat( $oldText, $newText ) {
183        return wikidiff2_inline_diff( $oldText, $newText, 2 );
184    }
185
186    public function getFormats(): array {
187        return [ 'table', 'inline' ];
188    }
189
190    public function getTablePrefixes( string $format ): array {
191        $localizer = $this->getLocalizer();
192        $ins = Html::element( 'span',
193            [ 'class' => 'mw-diff-inline-legend-ins' ],
194            $localizer->msg( 'diff-inline-tooltip-ins' )->plain()
195        );
196        $del = Html::element( 'span',
197            [ 'class' => 'mw-diff-inline-legend-del' ],
198            $localizer->msg( 'diff-inline-tooltip-del' )->plain()
199        );
200        $hideDiffClass = $format === 'inline' ? '' : 'oo-ui-element-hidden';
201        $legend = Html::rawElement( 'div',
202            [ 'class' => 'mw-diff-inline-legend ' . $hideDiffClass ], "$del $ins"
203        );
204        return [ TextSlotDiffRenderer::INLINE_LEGEND_KEY => $legend ];
205    }
206
207    public function localize( string $format, string $diff, array $options = [] ): string {
208        $diff = $this->localizeLineNumbers( $diff,
209            $options['reducedLineNumbers'] ?? false
210        );
211        if ( $this->haveMoveSupport ) {
212            $diff = $this->addLocalizedTitleTooltips( $format, $diff );
213        }
214        return $diff;
215    }
216
217    /**
218     * Add title attributes for tooltips on various diff elements
219     *
220     * @param string $format
221     * @param string $text
222     * @return string
223     */
224    private function addLocalizedTitleTooltips( $format, $text ) {
225        // Moved paragraph indicators.
226        $localizer = $this->getLocalizer();
227        $replacements = [
228            'class="mw-diff-movedpara-right"' =>
229                'class="mw-diff-movedpara-right" title="' .
230                $localizer->msg( 'diff-paragraph-moved-toold' )->escaped() . '"',
231            'class="mw-diff-movedpara-left"' =>
232                'class="mw-diff-movedpara-left" title="' .
233                $localizer->msg( 'diff-paragraph-moved-tonew' )->escaped() . '"',
234        ];
235        // For inline diffs, add tooltips to `<ins>` and `<del>`.
236        if ( $format == 'inline' ) {
237            $replacements['<ins>'] = Html::openElement( 'ins',
238                [ 'title' => $localizer->msg( 'diff-inline-tooltip-ins' )->plain() ] );
239            $replacements['<del>'] = Html::openElement( 'del',
240                [ 'title' => $localizer->msg( 'diff-inline-tooltip-del' )->plain() ] );
241        }
242        return strtr( $text, $replacements );
243    }
244
245    public function getPreferredFormatBatch( string $format ): array {
246        if ( $this->formatOptions ) {
247            return [ $format ];
248        } else {
249            return [ 'table', 'inline' ];
250        }
251    }
252}