Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
14.78% covered (danger)
14.78%
17 / 115
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
CodeDiffHighlighter
14.78% covered (danger)
14.78%
17 / 115
0.00% covered (danger)
0.00%
0 / 13
749.39
0.00% covered (danger)
0.00%
0 / 1
 render
0.00% covered (danger)
0.00%
0 / 3
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 / 29
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 / 8
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 / 5
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 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 parseChunkDelimiter
85.00% covered (warning)
85.00%
17 / 20
0.00% covered (danger)
0.00%
0 / 1
5.08
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    /**
146     * @param string $line
147     * @return string
148     */
149    public function handleLineDeletion( $line ) {
150        $this->left++;
151        return $this->formatLine( $line, 'del' );
152    }
153
154    /**
155     * @param string $line
156     * @return string
157     */
158    public function handleLineAddition( $line ) {
159        $this->right++;
160        return $this->formatLine( $line, 'ins' );
161    }
162
163    /**
164     * @param string $line
165     * @return string
166     */
167    public function handleChunkDelimiter( $line ) {
168        $this->chunk++;
169
170        [
171            $this->left,
172            # unused
173            $leftChanged,
174            $this->right,
175            # unused
176            $rightChanged
177        ] = self::parseChunkDelimiter( $line );
178
179        return $this->formatLine( $line, 'chunkdelimiter' );
180    }
181
182    /**
183     * @param string $line
184     * @return string
185     */
186    public function handleUnchanged( $line ) {
187        $this->left++;
188        $this->right++;
189        return $this->formatLine( $line, 'unchanged' );
190    }
191
192    /**
193     * @param string $line
194     * @return string
195     */
196    public function handleLineFile( $line ) {
197        $this->chunk = 0;
198        return Html::rawElement( 'tr',
199            array_merge( $this->getLineIdAttr(), [ 'class' => 'patchedfile' ] ),
200            Html::Element( 'td', [ 'colspan' => 3 ], $line )
201        );
202    }
203
204    /**
205     * @return array
206     */
207    public function getLineIdAttr() {
208        return [ 'id' => $this->lineNumber ];
209    }
210
211    /**
212     * Turn a diff line into a properly formatted string suitable
213     * for output
214     * @param string $line Line from a diff
215     * @return string
216     */
217    public function colorLine( $line ) {
218        if ( $line == '' ) {
219            // don't create bogus spans
220            return '';
221        }
222        [ $element, $attribs ] = $this->tagForLine( $line );
223        return '<tr>' . Xml::element( $element, $attribs, $line ) . '</tr>';
224    }
225
226    /**
227     * Take a line of a diff and apply the appropriate stylings
228     * @param string $line Line to check
229     * @return array
230     */
231    public function tagForLine( $line ) {
232        static $default = [ 'td', [] ];
233        static $tags = [
234            '-' => [ 'td', [ 'class' => 'del' ] ],
235            '+' => [ 'td', [ 'class' => 'ins' ] ],
236            '@' => [ 'td', [ 'class' => 'meta' ] ],
237            ' ' => [ 'td', [] ],
238        ];
239        $first = substr( $line, 0, 1 );
240        if ( isset( $tags[$first] ) ) {
241            return $tags[$first];
242        } else {
243            return $default;
244        }
245    }
246
247    /**
248     * Parse unified diff change chunk header.
249     *
250     * The format represents two ranges for the left (prefixed with -) and right
251     * file (prefixed with +).
252     * The format looks like:
253     * @@ -l,s +l,s @@
254     *
255     * Where:
256     *  - l is the starting line number
257     *  - s is the number of lines the change hunk applies to
258     *
259     * 's', for the number of lines, is optional and default to 1.
260     * When omitted, the previous comma will be skipped as well. So all
261     * following lines are valid too:
262     *
263     * @@ -l,s +l @@
264     * @@ -l +l,s @@
265     * @@ -l +l @@
266     *
267     * NOTE: visibility is 'public' since the function covered by tests.
268     *
269     * @param string $chunkHeader a one line chunk as described above
270     * @throws Exception
271     * @return array with the four values above as an array
272     */
273    public static function parseChunkDelimiter( $chunkHeader ) {
274        $chunkHeader = rtrim( $chunkHeader );
275
276        # regex snippet to capture a number
277        $n = "(\d+)";
278        $s = "(?:,(\d+))";
279        $matches = preg_match( "/^@@ -$n$s \+$n$s @@$/", $chunkHeader, $m );
280        if ( $matches === 1 ) {
281            array_shift( $m );
282            return $m;
283        }
284
285        $s_default_value = 1;
286
287        $matches = preg_match( "/^@@ -$n$s \+$n @@$/", $chunkHeader, $m );
288        if ( $matches === 1 ) {
289            return [ $m[1], $m[2], $m[3], $s_default_value ];
290        }
291
292        $matches = preg_match( "/^@@ -$n \+$n$s @@$/", $chunkHeader, $m );
293        if ( $matches === 1 ) {
294            return [ $m[1], $s_default_value, $m[2], $m[3] ];
295        }
296
297        $matches = preg_match( "/^@@ -$n \+$n @@$/", $chunkHeader, $m );
298        if ( $matches === 1 ) {
299            return [ $m[1], $s_default_value, $m[2], $s_default_value ];
300        }
301
302        # We really really should have matched something!
303        throw new Exception(
304            __METHOD__ . " given an invalid chunk header: '$chunkHeader'\n"
305        );
306    }
307}