Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
17.17% covered (danger)
17.17%
17 / 99
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
CodeDiffHighlighter
17.17% covered (danger)
17.17%
17 / 99
0.00% covered (danger)
0.00%
0 / 13
690.89
0.00% covered (danger)
0.00%
0 / 1
 render
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 splitLines
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 parseLine
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
110
 formatLine
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
56
 handleLineDeletion
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 handleLineAddition
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 handleChunkDelimiter
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 handleUnchanged
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 handleLineFile
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getLineIdAttr
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 colorLine
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 tagForLine
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 parseChunkDelimiter
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
5.03
1<?php
2
3namespace MediaWiki\Extension\CodeReview\Backend;
4
5use Exception;
6use Html;
7use Xml;
8
9/**
10 * Highlight a SVN diff for easier readibility
11 */
12class CodeDiffHighlighter {
13    /** @var int chunk line count for the original file */
14    protected $left = 0;
15    /** @var int chunk line count for the changed file */
16    protected $right = 0;
17    /** @var int number of chunks */
18    protected $chunk = 0;
19    /** @var int line number inside patch */
20    protected $lineNumber = 0;
21
22    /**
23     * Main entry point. Given a diff text, highlight it
24     * and wrap it in a div
25     *
26     * @param string $text Text to highlight
27     * @return string
28     */
29    public function render( $text ) {
30        return '<table class="mw-codereview-diff">' .
31            $this->splitLines( $text ) .
32            "</table>\n";
33    }
34
35    /**
36     * Given a bunch of text, split it into individual
37     * lines, color them, then put it back into one big
38     * string
39     * @param string $text Text to split and highlight
40     * @return string
41     */
42    public function splitLines( $text ) {
43        return implode( "\n",
44            array_map( [ $this, 'parseLine' ],
45                explode( "\n", $text ) ) );
46    }
47
48    /**
49     * Internal dispatcher to a handler depending on line
50     * Handles lines beginning with '-' '+' '@' and ' '
51     * @param string $line Diff line to parse
52     * @return string HTML table line (with <tr></tr>)
53     */
54    public function parseLine( $line ) {
55        $this->lineNumber++;
56
57        if ( $line === '' ) {
58            // do not create bogus lines
59            return '';
60        }
61
62        # Dispatch diff lines to the proper handler
63        switch ( substr( $line, 0, 1 ) ) {
64        case '-':
65            if ( substr( $line, 0, 3 ) === '---' ) {
66                return '';
67            }
68            $r = $this->handleLineDeletion( $line );
69            break;
70        case '+':
71            if ( substr( $line, 0, 3 ) === '+++' ) {
72                return '';
73            }
74            $r = $this->handleLineAddition( $line );
75            break;
76        case '@':
77            $r = $this->handleChunkDelimiter( $line );
78            break;
79        case ' ':
80            $r = $this->handleUnchanged( $line );
81            break;
82
83        # Patch lines that will be skipped:
84        case '=':
85            return '';
86
87        # Remaining case should be the file name
88        default:
89            $r = $this->handleLineFile( $line );
90        }
91
92        # Return HTML generated by one of the handler
93        return $r;
94    }
95
96    /**
97     * @param string $content
98     * @param string|null $class
99     * @return string
100     */
101    public function formatLine( $content, $class = null ) {
102        if ( $class === null ) {
103            return Html::rawElement( 'tr', $this->getLineIdAttr(),
104                Html::element( 'td', [ 'class' => 'linenumbers' ], $this->left ) .
105                Html::element( 'td', [ 'class' => 'linenumbers' ], $this->right ) .
106                Html::rawElement( 'td', [], Html::element( 'span', [], $content ) )
107            );
108        }
109
110        # Skip line number when they do not apply
111        // non-breaking space
112        $left = $right = ' ';
113        $inlineWrapEl = 'span';
114
115        switch ( $class ) {
116        case 'chunkdelimiter':
117            // &mdash;
118            $left = $right = '—';
119            break;
120        case 'unchanged':
121            $left  = $this->left;
122            $right = $this->right;
123            break;
124        case 'del':
125            $left  = $this->left;
126            $inlineWrapEl = 'del';
127            break;
128        case 'ins':
129            $right = $this->right;
130            $inlineWrapEl = 'ins';
131            break;
132
133        default:
134            # Rely on $left, $right initialization above
135        }
136
137        $classAttr = [ 'class' => $class ];
138        return Html::rawElement( 'tr', $this->getLineIdAttr(),
139            Html::element( 'td', [ 'class' => 'linenumbers' ], $left )
140            . Html::element( 'td', [ 'class' => 'linenumbers' ], $right )
141            . Html::rawElement( 'td', $classAttr, Html::element( $inlineWrapEl, [], $content ) )
142        );
143    }
144
145    public function handleLineDeletion( $line ) {
146        $this->left++;
147        return $this->formatLine( $line, 'del' );
148    }
149
150    public function handleLineAddition( $line ) {
151        $this->right++;
152        return $this->formatLine( $line, 'ins' );
153    }
154
155    public function handleChunkDelimiter( $line ) {
156        $this->chunk++;
157
158        list(
159            $this->left,
160            # unused
161            $leftChanged,
162            $this->right,
163            # unused
164            $rightChanged
165        ) = self::parseChunkDelimiter( $line );
166
167        return $this->formatLine( $line, 'chunkdelimiter' );
168    }
169
170    public function handleUnchanged( $line ) {
171        $this->left++;
172        $this->right++;
173        return $this->formatLine( $line, 'unchanged' );
174    }
175
176    public function handleLineFile( $line ) {
177        $this->chunk = 0;
178        return Html::rawElement( 'tr',
179            array_merge( $this->getLineIdAttr(), [ 'class' => 'patchedfile' ] ),
180            Html::Element( 'td', [ 'colspan' => 3 ], $line )
181        );
182    }
183
184    public function getLineIdAttr() {
185        return [ 'id' => $this->lineNumber ];
186    }
187
188    /**
189     * Turn a diff line into a properly formatted string suitable
190     * for output
191     * @param string $line Line from a diff
192     * @return string
193     */
194    public function colorLine( $line ) {
195        if ( $line == '' ) {
196            // don't create bogus spans
197            return '';
198        }
199        list( $element, $attribs ) = $this->tagForLine( $line );
200        return '<tr>' . Xml::element( $element, $attribs, $line ) . '</tr>';
201    }
202
203    /**
204     * Take a line of a diff and apply the appropriate stylings
205     * @param string $line Line to check
206     * @return array
207     */
208    public function tagForLine( $line ) {
209        static $default = [ 'td', [] ];
210        static $tags = [
211            '-' => [ 'td', [ 'class' => 'del' ] ],
212            '+' => [ 'td', [ 'class' => 'ins' ] ],
213            '@' => [ 'td', [ 'class' => 'meta' ] ],
214            ' ' => [ 'td', [] ],
215        ];
216        $first = substr( $line, 0, 1 );
217        if ( isset( $tags[$first] ) ) {
218            return $tags[$first];
219        } else {
220            return $default;
221        }
222    }
223
224    /**
225     * Parse unified diff change chunk header.
226     *
227     * The format represents two ranges for the left (prefixed with -) and right
228     * file (prefixed with +).
229     * The format looks like:
230     * @@ -l,s +l,s @@
231     *
232     * Where:
233     *  - l is the starting line number
234     *  - s is the number of lines the change hunk applies to
235     *
236     * 's', for the number of lines, is optional and default to 1.
237     * When omitted, the previous comma will be skipped as well. So all
238     * following lines are valid too:
239     *
240     * @@ -l,s +l @@
241     * @@ -l +l,s @@
242     * @@ -l +l @@
243     *
244     * NOTE: visibility is 'public' since the function covered by tests.
245     *
246     * @param string $chunkHeader a one line chunk as described above
247     * @throws Exception
248     * @return array with the four values above as an array
249     */
250    public static function parseChunkDelimiter( $chunkHeader ) {
251        $chunkHeader = rtrim( $chunkHeader );
252
253        # regex snippet to capture a number
254        $n = "(\d+)";
255        $s = "(?:,(\d+))";
256        $matches = preg_match( "/^@@ -$n$s \+$n$s @@$/", $chunkHeader, $m );
257        if ( $matches === 1 ) {
258            array_shift( $m );
259            return $m;
260        }
261
262        $s_default_value = 1;
263
264        $matches = preg_match( "/^@@ -$n$s \+$n @@$/", $chunkHeader, $m );
265        if ( $matches === 1 ) {
266            return [ $m[1], $m[2], $m[3], $s_default_value ];
267        }
268
269        $matches = preg_match( "/^@@ -$n \+$n$s @@$/", $chunkHeader, $m );
270        if ( $matches === 1 ) {
271            return [ $m[1], $s_default_value, $m[2], $m[3] ];
272        }
273
274        $matches = preg_match( "/^@@ -$n \+$n @@$/", $chunkHeader, $m );
275        if ( $matches === 1 ) {
276            return [ $m[1], $s_default_value, $m[2], $s_default_value ];
277        }
278
279        # We really really should have matched something!
280        throw new Exception(
281            __METHOD__ . " given an invalid chunk header: '$chunkHeader'\n"
282        );
283    }
284}