diff options
Diffstat (limited to 'webapp/codereview/engine.py')
-rw-r--r-- | webapp/codereview/engine.py | 709 |
1 files changed, 709 insertions, 0 deletions
diff --git a/webapp/codereview/engine.py b/webapp/codereview/engine.py new file mode 100644 index 0000000000..a1f0777cc9 --- /dev/null +++ b/webapp/codereview/engine.py @@ -0,0 +1,709 @@ +# Copyright 2008 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Diff rendering in HTML for Gerrit.""" + +# Python imports +import re +import cgi +import difflib +import logging +import urlparse + +# AppEngine imports +from google.appengine.api import urlfetch +from google.appengine.api import users +from google.appengine.ext import db + +# Django imports +from django.template import loader + +# Local imports +import library +import models +import patching +import intra_region_diff + + +# NOTE: this function is duplicated in upload.py, keep them in sync. +def SplitPatch(data): + """Splits a patch into separate pieces for each file. + + Args: + data: A string containing the output of svn diff. + + Returns: + A list of 2-tuple (filename, text) where text is the svn diff output + pertaining to filename. + """ + patches = [] + filename = None + diff = [] + for line in data.splitlines(True): + new_filename = None + if line.startswith('Index:'): + unused, new_filename = line.split(':', 1) + new_filename = new_filename.strip() + elif line.startswith('Property changes on:'): + unused, temp_filename = line.split(':', 1) + # When a file is modified, paths use '/' between directories, however + # when a property is modified '\' is used on Windows. Make them the same + # otherwise the file shows up twice. + temp_filename = temp_filename.strip().replace('\\', '/') + if temp_filename != filename: + # File has property changes but no modifications, create a new diff. + new_filename = temp_filename + if new_filename: + if filename and diff: + patches.append((filename, ''.join(diff))) + filename = new_filename + diff = [line] + continue + if diff is not None: + diff.append(line) + if filename and diff: + patches.append((filename, ''.join(diff))) + return patches + + + +def RenderDiffTableRows(request, old_lines, chunks, patch, + colwidth=80, + debug=False, + context=models.DEFAULT_CONTEXT): + """Render the HTML table rows for a side-by-side diff for a patch. + + Args: + request: Django Request object. + old_lines: List of lines representing the original file. + chunks: List of chunks as returned by patching.ParsePatchToChunks(). + patch: A models.Patch instance. + colwidth: Optional column width (default 80). + debug: Optional debugging flag (default False). + context: Maximum number of rows surrounding a change (default CONTEXT). + + Yields: + Strings, each of which represents the text rendering one complete + pair of lines of the side-by-side diff, possibly including comments. + Each yielded string may consist of several <tr> elements. + """ + rows = _RenderDiffTableRows(request, old_lines, chunks, patch, + colwidth, debug) + return _CleanupTableRowsGenerator(rows, context) + + +def RenderDiff2TableRows(request, old_lines, old_patch, new_lines, new_patch, + colwidth=80, + debug=False, + context=models.DEFAULT_CONTEXT): + """Render the HTML table rows for a side-by-side diff between two patches. + + Args: + request: Django Request object. + old_lines: List of lines representing the patched file on the left. + old_patch: The models.Patch instance corresponding to old_lines. + new_lines: List of lines representing the patched file on the right. + new_patch: The models.Patch instance corresponding to new_lines. + colwidth: Optional column width (default 80). + debug: Optional debugging flag (default False). + context: Maximum number of visible context lines (default models.DEFAULT_CONTEXT). + + Yields: + Strings, each of which represents the text rendering one complete + pair of lines of the side-by-side diff, possibly including comments. + Each yielded string may consist of several <tr> elements. + """ + rows = _RenderDiff2TableRows(request, old_lines, old_patch, + new_lines, new_patch, colwidth, debug) + return _CleanupTableRowsGenerator(rows, context) + + +def _CleanupTableRowsGenerator(rows, context): + """Cleanup rows returned by _TableRowGenerator for output. + + Args: + rows: List of tuples (tag, text) + context: Maximum number of visible context lines. + + Yields: + Rows marked as 'equal' are possibly contracted using _ShortenBuffer(). + Stops on rows marked as 'error'. + """ + buffer = [] + for tag, text in rows: + if tag == 'equal': + buffer.append(text) + continue + else: + for t in _ShortenBuffer(buffer, context): + yield t + buffer = [] + yield text + if tag == 'error': + yield None + break + if buffer: + for t in _ShortenBuffer(buffer, context): + yield t + + +def _ShortenBuffer(buffer, context): + """Render a possibly contracted series of HTML table rows. + + Args: + buffer: a list of strings representing HTML table rows. + context: Maximum number of visible context lines. + + Yields: + If the buffer has fewer than 3 times context items, yield all + the items. Otherwise, yield the first context items, a single + table row representing the contraction, and the last context + items. + """ + if len(buffer) < 3*context: + for t in buffer: + yield t + else: + last_id = None + for t in buffer[:context]: + m = re.match('^<tr( name="hook")? id="pair-(?P<rowcount>\d+)">', t) + if m: + last_id = int(m.groupdict().get("rowcount")) + yield t + skip = len(buffer) - 2*context + if skip <= 10: + expand_link = ('<a href="javascript:M_expandSkipped(%(before)d, ' + '%(after)d, \'b\', %(skip)d)">Show</a>') + else: + expand_link = ('<a href="javascript:M_expandSkipped(%(before)d, ' + '%(after)d, \'t\', %(skip)d)">Show 10 above</a> ' + '<a href="javascript:M_expandSkipped(%(before)d, ' + '%(after)d, \'b\', %(skip)d)">Show 10 below</a> ') + expand_link = expand_link % {'before': last_id+1, + 'after': last_id+skip, + 'skip': last_id} + yield ('<tr id="skip-%d"><td colspan="2" align="center" ' + 'style="background:lightblue">' + '(...skipping <span id="skipcount-%d">%d</span> matching lines...) ' + '<span id="skiplinks-%d">%s</span>' + '</td></tr>\n' % (last_id, last_id, skip, + last_id, expand_link)) + for t in buffer[-context:]: + yield t + + +def _RenderDiff2TableRows(request, old_lines, old_patch, new_lines, new_patch, + colwidth=80, debug=False): + """Internal version of RenderDiff2TableRows(). + + Args: + The same as for RenderDiff2TableRows. + + Yields: + Tuples (tag, row) where tag is an indication of the row type. + """ + old_dict = {} + new_dict = {} + for patch, dct in [(old_patch, old_dict), (new_patch, new_dict)]: + # XXX GQL doesn't support OR yet... Otherwise we'd be using that. + for comment in models.Comment.gql( + 'WHERE patch = :1 AND left = FALSE ORDER BY date', patch): + if comment.draft and comment.author != request.user: + continue # Only show your own drafts + comment.complete(patch) + lst = dct.setdefault(comment.lineno, []) + lst.append(comment) + library.prefetch_names([comment.author]) + return _TableRowGenerator(old_patch, old_dict, len(old_lines)+1, 'new', + new_patch, new_dict, len(new_lines)+1, 'new', + _GenerateTriples(old_lines, new_lines), + colwidth, debug) + + +def _GenerateTriples(old_lines, new_lines): + """Helper for _RenderDiff2TableRows yielding input for _TableRowGenerator. + + Args: + old_lines: List of lines representing the patched file on the left. + new_lines: List of lines representing the patched file on the right. + + Yields: + Tuples (tag, old_slice, new_slice) where tag is a tag as returned by + difflib.SequenceMatchser.get_opcodes(), and old_slice and new_slice + are lists of lines taken from old_lines and new_lines. + """ + sm = difflib.SequenceMatcher(None, old_lines, new_lines) + for tag, i1, i2, j1, j2 in sm.get_opcodes(): + yield tag, old_lines[i1:i2], new_lines[j1:j2] + + +def _GetComments(request): + """Helper that returns comments for a patch. + + Args: + request: Django Request object. + + Returns: + A 2-tuple of (old, new) where old/new are dictionaries that holds comments + for that file, mapping from line number to a Comment entity. + """ + old_dict = {} + new_dict = {} + # XXX GQL doesn't support OR yet... Otherwise we'd be using + # .gql('WHERE patch = :1 AND (draft = FALSE OR author = :2) ORDER BY data', + # patch, request.user) + for comment in models.Comment.gql('WHERE patch = :1 ORDER BY date', + request.patch): + if comment.draft and comment.author != request.user: + continue # Only show your own drafts + comment.complete(request.patch) + if comment.left: + dct = old_dict + else: + dct = new_dict + dct.setdefault(comment.lineno, []).append(comment) + library.prefetch_names([comment.author]) + return old_dict, new_dict + + +def _RenderDiffTableRows(request, old_lines, chunks, patch, + colwidth=80, debug=False): + """Internal version of RenderDiffTableRows(). + + Args: + The same as for RenderDiffTableRows. + + Yields: + Tuples (tag, row) where tag is an indication of the row type. + """ + old_dict = {} + new_dict = {} + if patch: + old_dict, new_dict = _GetComments(request) + old_max, new_max = _ComputeLineCounts(old_lines, chunks) + return _TableRowGenerator(patch, old_dict, old_max, 'old', + patch, new_dict, new_max, 'new', + patching.PatchChunks(old_lines, chunks), + colwidth, debug) + + +def _TableRowGenerator(old_patch, old_dict, old_max, old_snapshot, + new_patch, new_dict, new_max, new_snapshot, + triple_iterator, colwidth=80, debug=False): + """Helper function to render side-by-side table rows. + + Args: + old_patch: First models.Patch instance. + old_dict: Dictionary with line numbers as keys and comments as values (left) + old_max: Line count of the patch on the left. + old_snapshot: A tag used in the comments form. + new_patch: Second models.Patch instance. + new_dict: Same as old_dict, but for the right side. + new_max: Line count of the patch on the right. + new_snapshot: A tag used in the comments form. + triple_iterator: Iterator that yields (tag, old, new) triples. + colwidth: Optional column width (default 80). + debug: Optional debugging flag (default False). + + Yields: + Tuples (tag, row) where tag is an indication of the row type and + row is an HTML fragment representing one or more <td> elements. + """ + diff_params = intra_region_diff.GetDiffParams(dbg=debug) + ndigits = 1 + max(len(str(old_max)), len(str(new_max))) + indent = 1 + ndigits + old_offset = new_offset = 0 + row_count = 0 + + # Render a row with a message if a side is empty or both sides are equal. + if old_patch == new_patch and (old_max == 0 or new_max == 0): + if old_max == 0: + msg_old = '(Empty)' + else: + msg_old = '' + if new_max == 0: + msg_new = '(Empty)' + else: + msg_new = '' + yield '', ('<tr><td class="info">%s</td>' + '<td class="info">%s</td></tr>' % (msg_old, msg_new)) + # TODO(sop) + #elif old_patch == new_patch: + # old_patch.patch_hash == new_patch.patch_hash + # yield '', ('<tr><td class="info" colspan="2">' + # '(Both sides are equal)</td></tr>') + + for tag, old, new in triple_iterator: + if tag.startswith('error'): + yield 'error', '<tr><td><h3>%s</h3></td></tr>\n' % cgi.escape(tag) + return + old1 = old_offset + old_offset = old2 = old1 + len(old) + new1 = new_offset + new_offset = new2 = new1 + len(new) + old_buff = [] + new_buff = [] + frag_list = [] + do_ir_diff = tag == 'replace' and intra_region_diff.CanDoIRDiff(old, new) + + for i in xrange(max(len(old), len(new))): + row_count += 1 + old_lineno = old1 + i + 1 + new_lineno = new1 + i + 1 + old_valid = old1+i < old2 + new_valid = new1+i < new2 + + # Start rendering the first row + frags = [] + if i == 0 and tag != 'equal': + # Mark the first row of each non-equal chunk as a 'hook'. + frags.append('<tr name="hook"') + else: + frags.append('<tr') + frags.append(' id="pair-%d">' % row_count) + + old_intra_diff = '' + new_intra_diff = '' + if old_valid: + old_intra_diff = old[i] + if new_valid: + new_intra_diff = new[i] + + frag_list.append(frags) + if do_ir_diff: + # Don't render yet. Keep saving state necessary to render the whole + # region until we have encountered all the lines in the region. + old_buff.append([old_valid, old_lineno, old_intra_diff]) + new_buff.append([new_valid, new_lineno, new_intra_diff]) + else: + # We render line by line as usual if do_ir_diff is false + old_intra_diff = intra_region_diff.Fold( + old_intra_diff, colwidth + indent, indent, indent) + new_intra_diff = intra_region_diff.Fold( + new_intra_diff, colwidth + indent, indent, indent) + old_buff_out = [[old_valid, old_lineno, + (old_intra_diff, True, None)]] + new_buff_out = [[new_valid, new_lineno, + (new_intra_diff, True, None)]] + for tg, frag in _RenderDiffInternal(old_buff_out, new_buff_out, + ndigits, tag, frag_list, + do_ir_diff, + old_dict, new_dict, + old_patch, new_patch, + old_snapshot, new_snapshot, + colwidth, debug): + yield tg, frag + frag_list = [] + + if do_ir_diff: + # So this was a replace block which means that the whole region still + # needs to be rendered. + old_lines = [b[2] for b in old_buff] + new_lines = [b[2] for b in new_buff] + ret = intra_region_diff.IntraRegionDiff(old_lines, new_lines, + diff_params) + old_chunks, new_chunks, ratio = ret + old_tag = 'old' + new_tag = 'new' + + old_diff_out = intra_region_diff.RenderIntraRegionDiff( + old_lines, old_chunks, old_tag, ratio, + limit=colwidth, indent=indent, + dbg=debug) + new_diff_out = intra_region_diff.RenderIntraRegionDiff( + new_lines, new_chunks, new_tag, ratio, + limit=colwidth, indent=indent, + dbg=debug) + for (i, b) in enumerate(old_buff): + b[2] = old_diff_out[i] + for (i, b) in enumerate(new_buff): + b[2] = new_diff_out[i] + + for tg, frag in _RenderDiffInternal(old_buff, new_buff, + ndigits, tag, frag_list, + do_ir_diff, + old_dict, new_dict, + old_patch, new_patch, + old_snapshot, new_snapshot, + colwidth, debug): + yield tg, frag + old_buff = [] + new_buff = [] + + +def _CleanupTableRows(rows): + """Cleanup rows returned by _TableRowGenerator. + + Args: + rows: Sequence of (tag, text) tuples. + + Yields: + Rows marked as 'equal' are possibly contracted using _ShortenBuffer(). + Stops on rows marked as 'error'. + """ + buffer = [] + for tag, text in rows: + if tag == 'equal': + buffer.append(text) + continue + else: + for t in _ShortenBuffer(buffer): + yield t + buffer = [] + yield text + if tag == 'error': + yield None + break + if buffer: + for t in _ShortenBuffer(buffer): + yield t + + +def _RenderDiffInternal(old_buff, new_buff, ndigits, tag, frag_list, + do_ir_diff, old_dict, new_dict, + old_patch, new_patch, + old_snapshot, new_snapshot, + colwidth, debug): + """Helper for _TableRowGenerator().""" + obegin = (intra_region_diff.BEGIN_TAG % + intra_region_diff.COLOR_SCHEME['old']['match']) + nbegin = (intra_region_diff.BEGIN_TAG % + intra_region_diff.COLOR_SCHEME['new']['match']) + oend = intra_region_diff.END_TAG + nend = oend + user = users.get_current_user() + + for i in xrange(len(old_buff)): + tg = tag + old_valid, old_lineno, old_out = old_buff[i] + new_valid, new_lineno, new_out = new_buff[i] + old_intra_diff, old_has_newline, old_debug_info = old_out + new_intra_diff, new_has_newline, new_debug_info = new_out + + frags = frag_list[i] + # Render left text column + frags.append(_RenderDiffColumn(old_patch, old_valid, tag, ndigits, + old_lineno, obegin, oend, old_intra_diff, + do_ir_diff, old_has_newline, 'old')) + + # Render right text column + frags.append(_RenderDiffColumn(new_patch, new_valid, tag, ndigits, + new_lineno, nbegin, nend, new_intra_diff, + do_ir_diff, new_has_newline, 'new')) + + # End rendering the first row + frags.append('</tr>\n') + + if debug: + frags.append('<tr>') + if old_debug_info: + frags.append('<td class="debug-info">%s</td>' % + old_debug_info.replace('\n', '<br>')) + else: + frags.append('<td></td>') + if new_debug_info: + frags.append('<td class="debug-info">%s</td>' % + new_debug_info.replace('\n', '<br>')) + else: + frags.append('<td></td>') + frags.append('</tr>\n') + + if old_patch or new_patch: + # Start rendering the second row + if ((old_valid and old_lineno in old_dict) or + (new_valid and new_lineno in new_dict)): + tg += '_comment' + frags.append('<tr class="inline-comments" name="hook">') + else: + frags.append('<tr class="inline-comments">') + + # Render left inline comments + frags.append(_RenderInlineComments(old_valid, old_lineno, old_dict, + user, old_patch, old_snapshot, 'old')) + + # Render right inline comments + frags.append(_RenderInlineComments(new_valid, new_lineno, new_dict, + user, new_patch, new_snapshot, 'new')) + + # End rendering the second row + frags.append('</tr>\n') + + # Yield the combined fragments + yield tg, ''.join(frags) + + +def _RenderDiffColumn(patch, line_valid, tag, ndigits, lineno, begin, end, + intra_diff, do_ir_diff, has_newline, prefix): + """Helper function for _RenderDiffInternal(). + + Returns: + A rendered column. + """ + if line_valid: + cls_attr = '%s%s' % (prefix, tag) + if tag == 'equal': + lno = '%*d' % (ndigits, lineno) + else: + lno = _MarkupNumber(ndigits, lineno, 'u') + if tag == 'replace': + col_content = ('%s%s %s%s' % (begin, lno, end, intra_diff)) + # If IR diff has been turned off or there is no matching new line at + # the end then switch to dark background CSS style. + if not do_ir_diff or not has_newline: + cls_attr = cls_attr + '1' + else: + col_content = '%s %s' % (lno, intra_diff) + return '<td class="%s" id="%scode%d">%s</td>' % (cls_attr, prefix, + lineno, col_content) + else: + return '<td class="%sblank"></td>' % prefix + + +def _RenderInlineComments(line_valid, lineno, data, user, + patch, snapshot, prefix): + """Helper function for _RenderDiffInternal(). + + Returns: + Rendered comments. + """ + comments = [] + if line_valid: + comments.append('<td id="%s-line-%s">' % (prefix, lineno)) + if lineno in data: + comments.append( + _ExpandTemplate('inline_comment.html', + inline_draft_url='/inline_draft', + user=user, + patch=patch, + patchset=patch.patchset, + change=patch.patchset.change, + snapshot=snapshot, + side='a' if prefix == 'old' else 'b', + comments=data[lineno], + lineno=lineno, + )) + comments.append('</td>') + else: + comments.append('<td></td>') + return ''.join(comments) + + +def RenderUnifiedTableRows(request, parsed_lines): + """Render the HTML table rows for a unified diff for a patch. + + Args: + request: Django Request object. + parsed_lines: List of tuples for each line that contain the line number, + if they exist, for the old and new file. + + Returns: + A list of html table rows. + """ + old_dict, new_dict = _GetComments(request) + + rows = [] + for old_line_no, new_line_no, line_text in parsed_lines: + row1_id = row2_id = '' + # When a line is unchanged (i.e. both old_line_no and new_line_no aren't 0) + # pick the old column line numbers when adding a comment. + if old_line_no: + row1_id = 'id="oldcode%d"' % old_line_no + row2_id = 'id="old-line-%d"' % old_line_no + elif new_line_no: + row1_id = 'id="newcode%d"' % new_line_no + row2_id = 'id="new-line-%d"' % new_line_no + rows.append('<tr><td class="udiff" %s>%s</td></tr>' % + (row1_id, cgi.escape(line_text))) + + frags = [] + if old_line_no in old_dict or new_line_no in new_dict: + frags.append('<tr class="inline-comments" name="hook">') + if old_line_no in old_dict: + dct = old_dict + line_no = old_line_no + snapshot = 'old' + else: + dct = new_dict + line_no = new_line_no + snapshot = 'new' + frags.append(_RenderInlineComments(True, line_no, dct, request.user, + request.patch, snapshot, snapshot)) + else: + frags.append('<tr class="inline-comments">') + frags.append('<td ' + row2_id +'></td>') + frags.append('</tr>') + rows.append(''.join(frags)) + return rows + + +def _ComputeLineCounts(old_lines, chunks): + """Compute the length of the old and new sides of a diff. + + Args: + old_lines: List of lines representing the original file. + chunks: List of chunks as returned by patching.ParsePatchToChunks(). + + Returns: + A tuple (old_len, new_len) representing len(old_lines) and + len(new_lines), where new_lines is the list representing the + result of applying the patch chunks to old_lines, however, without + actually computing new_lines. + """ + old_len = len(old_lines) + new_len = old_len + if chunks: + (old_a, old_b), (new_a, new_b), old_lines, new_lines = chunks[-1] + new_len += new_b - old_b + return old_len, new_len + + +def _MarkupNumber(ndigits, number, tag): + """Format a number in HTML in a given width with extra markup. + + Args: + ndigits: the total width available for formatting + number: the number to be formatted + tag: HTML tag name, e.g. 'u' + + Returns: + An HTML string that displays as ndigits wide, with the + number right-aligned and surrounded by an HTML tag; for example, + _MarkupNumber(42, 4, 'u') returns ' <u>42</u>'. + """ + formatted_number = str(number) + space_prefix = ' ' * (ndigits - len(formatted_number)) + return '%s<%s>%s</%s>' % (space_prefix, tag, formatted_number, tag) + + +def _ExpandTemplate(name, **params): + """Wrapper around django.template.loader.render_to_string(). + + For convenience, this takes keyword arguments instead of a dict. + """ + return loader.render_to_string(name, params) + + +def ToText(text): + """Helper to turn a string into a db.Text instance. + + Args: + text: a string. + + Returns: + A db.Text instance. + """ + try: + return db.Text(text, encoding='utf-8') + except UnicodeDecodeError: + return db.Text(text, encoding='latin-1') |