Separates diff processing from diff building

Moves the diff-processing functionality of the gr-diff-builder component
into a new gr-diff-processor component which exposes a promise-based
interface. This is step one of creating an asynchronous (non-blocking)
diff rendering system.

As much as possible, this change is a transfer of code (with tests) from
one component to another, making it easier to verify that functionality
has not changed. Cleanup of the code, and refactoring it into a
more-testable form will come with later changes.

Feature: Issue 3916
Change-Id: I875b03b20bf953b128cbe3c5001ba1f8eba12c61
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
new file mode 100644
index 0000000..0d787c1
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
@@ -0,0 +1,303 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// 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.
+(function() {
+  'use strict';
+
+  var WHOLE_FILE = -1;
+
+  var DiffSide = {
+    LEFT: 'left',
+    RIGHT: 'right',
+  };
+
+  var DiffGroupType = {
+    ADDED: 'b',
+    BOTH: 'ab',
+    REMOVED: 'a',
+  };
+
+  var DiffHighlights = {
+    ADDED: 'edit_b',
+    REMOVED: 'edit_a',
+  };
+
+  Polymer({
+    is: 'gr-diff-processor',
+
+    properties: {
+
+      /**
+       * The amount of context around collapsed groups.
+       */
+      context: Number,
+
+      /**
+       * The array of groups output by the processor.
+       */
+      groups: {
+        type: Array,
+        notify: true,
+      },
+
+      /**
+       * Locations that should not be collapsed, including the locations of
+       * comments.
+       */
+      keyLocations: {
+        type: Object,
+        value: function() { return {left: {}, right: {}}; },
+      },
+
+      _content: Object,
+    },
+
+    process: function(content) {
+      return new Promise(function(resolve) {
+        var groups = [];
+        this._processContent(content, groups, this.context);
+        this.groups = groups;
+        resolve(groups);
+      }.bind(this));
+    },
+
+    _processContent: function(content, groups, context) {
+      this._appendFileComments(groups);
+
+      context = content.length > 1 ? context : WHOLE_FILE;
+
+      var lineNums = {
+        left: 0,
+        right: 0,
+      };
+      content = this._splitCommonGroupsWithComments(content, lineNums);
+      for (var i = 0; i < content.length; i++) {
+        var group = content[i];
+        var lines = [];
+
+        if (group[DiffGroupType.BOTH] !== undefined) {
+          var rows = group[DiffGroupType.BOTH];
+          this._appendCommonLines(rows, lines, lineNums);
+
+          var hiddenRange = [context, rows.length - context];
+          if (i === 0) {
+            hiddenRange[0] = 0;
+          } else if (i === content.length - 1) {
+            hiddenRange[1] = rows.length;
+          }
+
+          if (context !== WHOLE_FILE && hiddenRange[1] - hiddenRange[0] > 0) {
+            this._insertContextGroups(groups, lines, hiddenRange);
+          } else {
+            groups.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, lines));
+          }
+          continue;
+        }
+
+        if (group[DiffGroupType.REMOVED] !== undefined) {
+          var highlights = undefined;
+          if (group[DiffHighlights.REMOVED] !== undefined) {
+            highlights = this._normalizeIntralineHighlights(
+                group[DiffGroupType.REMOVED],
+                group[DiffHighlights.REMOVED]);
+          }
+          this._appendRemovedLines(group[DiffGroupType.REMOVED], lines,
+              lineNums, highlights);
+        }
+
+        if (group[DiffGroupType.ADDED] !== undefined) {
+          var highlights = undefined;
+          if (group[DiffHighlights.ADDED] !== undefined) {
+            highlights = this._normalizeIntralineHighlights(
+              group[DiffGroupType.ADDED],
+              group[DiffHighlights.ADDED]);
+          }
+          this._appendAddedLines(group[DiffGroupType.ADDED], lines,
+              lineNums, highlights);
+        }
+        groups.push(new GrDiffGroup(GrDiffGroup.Type.DELTA, lines));
+      }
+    },
+
+    _appendFileComments: function(groups) {
+      var line = new GrDiffLine(GrDiffLine.Type.BOTH);
+      line.beforeNumber = GrDiffLine.FILE;
+      line.afterNumber = GrDiffLine.FILE;
+      groups.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, [line]));
+    },
+
+    /**
+     * In order to show comments out of the bounds of the selected context,
+     * treat them as separate chunks within the model so that the content (and
+     * context surrounding it) renders correctly.
+     */
+    _splitCommonGroupsWithComments: function(content, lineNums) {
+      var result = [];
+      var leftLineNum = lineNums.left;
+      var rightLineNum = lineNums.right;
+      for (var i = 0; i < content.length; i++) {
+        if (!content[i].ab) {
+          result.push(content[i]);
+          if (content[i].a) {
+            leftLineNum += content[i].a.length;
+          }
+          if (content[i].b) {
+            rightLineNum += content[i].b.length;
+          }
+          continue;
+        }
+        var chunk = content[i].ab;
+        var currentChunk = {ab: []};
+        for (var j = 0; j < chunk.length; j++) {
+          leftLineNum++;
+          rightLineNum++;
+
+          if (this.keyLocations[DiffSide.LEFT][leftLineNum] ||
+              this.keyLocations[DiffSide.RIGHT][rightLineNum]) {
+            if (currentChunk.ab && currentChunk.ab.length > 0) {
+              result.push(currentChunk);
+              currentChunk = {ab: []};
+            }
+            result.push({ab: [chunk[j]]});
+          } else {
+            currentChunk.ab.push(chunk[j]);
+          }
+        }
+        // != instead of !== because we want to cover both undefined and null.
+        if (currentChunk.ab != null && currentChunk.ab.length > 0) {
+          result.push(currentChunk);
+        }
+      }
+      return result;
+    },
+
+    _appendCommonLines: function(rows, lines, lineNums) {
+      for (var i = 0; i < rows.length; i++) {
+        var line = new GrDiffLine(GrDiffLine.Type.BOTH);
+        line.text = rows[i];
+        line.beforeNumber = ++lineNums.left;
+        line.afterNumber = ++lineNums.right;
+        lines.push(line);
+      }
+    },
+
+    _insertContextGroups: function(groups, lines, hiddenRange) {
+      var linesBeforeCtx = lines.slice(0, hiddenRange[0]);
+      var hiddenLines = lines.slice(hiddenRange[0], hiddenRange[1]);
+      var linesAfterCtx = lines.slice(hiddenRange[1]);
+
+      if (linesBeforeCtx.length > 0) {
+        groups.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesBeforeCtx));
+      }
+
+      var ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL);
+      ctxLine.contextGroup =
+          new GrDiffGroup(GrDiffGroup.Type.BOTH, hiddenLines);
+      groups.push(new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL,
+          [ctxLine]));
+
+      if (linesAfterCtx.length > 0) {
+        groups.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesAfterCtx));
+      }
+    },
+
+    /**
+     * The `highlights` array consists of a list of <skip length, mark length>
+     * pairs, where the skip length is the number of characters between the
+     * end of the previous edit and the start of this edit, and the mark
+     * length is the number of edited characters following the skip. The start
+     * of the edits is from the beginning of the related diff content lines.
+     *
+     * Note that the implied newline character at the end of each line is
+     * included in the length calculation, and thus it is possible for the
+     * edits to span newlines.
+     *
+     * A line highlight object consists of three fields:
+     * - contentIndex: The index of the diffChunk `content` field (the line
+     *   being referred to).
+     * - startIndex: Where the highlight should begin.
+     * - endIndex: (optional) Where the highlight should end. If omitted, the
+     *   highlight is meant to be a continuation onto the next line.
+     */
+    _normalizeIntralineHighlights: function(content, highlights) {
+      var contentIndex = 0;
+      var idx = 0;
+      var normalized = [];
+      for (var i = 0; i < highlights.length; i++) {
+        var line = content[contentIndex] + '\n';
+        var hl = highlights[i];
+        var j = 0;
+        while (j < hl[0]) {
+          if (idx === line.length) {
+            idx = 0;
+            line = content[++contentIndex] + '\n';
+            continue;
+          }
+          idx++;
+          j++;
+        }
+        var lineHighlight = {
+          contentIndex: contentIndex,
+          startIndex: idx,
+        };
+
+        j = 0;
+        while (line && j < hl[1]) {
+          if (idx === line.length) {
+            idx = 0;
+            line = content[++contentIndex] + '\n';
+            normalized.push(lineHighlight);
+            lineHighlight = {
+              contentIndex: contentIndex,
+              startIndex: idx,
+            };
+            continue;
+          }
+          idx++;
+          j++;
+        }
+        lineHighlight.endIndex = idx;
+        normalized.push(lineHighlight);
+      }
+      return normalized;
+    },
+
+    _appendRemovedLines: function(rows, lines, lineNums, opt_highlights) {
+      for (var i = 0; i < rows.length; i++) {
+        var line = new GrDiffLine(GrDiffLine.Type.REMOVE);
+        line.text = rows[i];
+        line.beforeNumber = ++lineNums.left;
+        if (opt_highlights) {
+          line.highlights = opt_highlights.filter(function(hl) {
+            return hl.contentIndex === i;
+          });
+        }
+        lines.push(line);
+      }
+    },
+
+    _appendAddedLines: function(rows, lines, lineNums, opt_highlights) {
+      for (var i = 0; i < rows.length; i++) {
+        var line = new GrDiffLine(GrDiffLine.Type.ADD);
+        line.text = rows[i];
+        line.afterNumber = ++lineNums.right;
+        if (opt_highlights) {
+          line.highlights = opt_highlights.filter(function(hl) {
+            return hl.contentIndex === i;
+          });
+        }
+        lines.push(line);
+      }
+    },
+  });
+})();