# 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 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 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('^', t) if m: last_id = int(m.groupdict().get("rowcount")) yield t skip = len(buffer) - 2*context if skip <= 10: expand_link = ('Show') else: expand_link = ('Show 10 above ' 'Show 10 below ') expand_link = expand_link % {'before': last_id+1, 'after': last_id+skip, 'skip': last_id} yield ('' '(...skipping %d matching lines...) ' '%s' '\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 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 '', ('%s' '%s' % (msg_old, msg_new)) # TODO(sop) #elif old_patch == new_patch: # old_patch.patch_hash == new_patch.patch_hash # yield '', ('' # '(Both sides are equal)') for tag, old, new in triple_iterator: if tag.startswith('error'): yield 'error', '

%s

\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('' % 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('\n') if debug: frags.append('') if old_debug_info: frags.append('%s' % old_debug_info.replace('\n', '
')) else: frags.append('') if new_debug_info: frags.append('%s' % new_debug_info.replace('\n', '
')) else: frags.append('') frags.append('\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('') else: frags.append('') # 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('\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 '%s' % (cls_attr, prefix, lineno, col_content) else: return '' % prefix def _RenderInlineComments(line_valid, lineno, data, user, patch, snapshot, prefix): """Helper function for _RenderDiffInternal(). Returns: Rendered comments. """ comments = [] if line_valid: comments.append('' % (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('') else: comments.append('') 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('%s' % (row1_id, cgi.escape(line_text))) frags = [] if old_line_no in old_dict or new_line_no in new_dict: frags.append('') 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('') frags.append('') frags.append('') 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 ' 42'. """ formatted_number = str(number) space_prefix = ' ' * (ndigits - len(formatted_number)) return '%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')