<commit_before><?php

final class DifferentialChangesetParser {

  protected $visible      = array();
  protected $new          = array();
  protected $old          = array();
  protected $intra        = array();
  protected $newRender    = null;
  protected $oldRender    = null;

  protected $filename     = null;
  protected $missingOld   = array();
  protected $missingNew   = array();

  protected $comments     = array();
  protected $specialAttributes = array();

  protected $changeset;
  protected $whitespaceMode = null;

  protected $subparser;

  protected $renderCacheKey = null;

  private $handles = array();
  private $user;

  private $leftSideChangesetID;
  private $leftSideAttachesToNewFile;

  private $rightSideChangesetID;
  private $rightSideAttachesToNewFile;

  private $originalLeft;
  private $originalRight;

  private $renderingReference;
  private $isSubparser;

  private $isTopLevel;
  private $coverage;
  private $markupEngine;
  private $highlightErrors;

  const CACHE_VERSION = 8;
  const CACHE_MAX_SIZE = 8e6;

  const ATTR_GENERATED  = 'attr:generated';
  const ATTR_DELETED    = 'attr:deleted';
  const ATTR_UNCHANGED  = 'attr:unchanged';
  const ATTR_WHITELINES = 'attr:white';

  const LINES_CONTEXT = 8;

  const WHITESPACE_SHOW_ALL         = 'show-all';
  const WHITESPACE_IGNORE_TRAILING  = 'ignore-trailing';

  // TODO: This is now "Ignore Most" in the UI.
  const WHITESPACE_IGNORE_ALL       = 'ignore-all';

  const WHITESPACE_IGNORE_FORCE     = 'ignore-force';

  /**
   * Configure which Changeset comments added to the right side of the visible
   * diff will be attached to. The ID must be the ID of a real Differential
   * Changeset.
   *
   * The complexity here is that we may show an arbitrary side of an arbitrary
   * changeset as either the left or right part of a diff. This method allows
   * the left and right halves of the displayed diff to be correctly mapped to
   * storage changesets.
   *
   * @param id    The Differential Changeset ID that comments added to the right
   *              side of the visible diff should be attached to.
   * @param bool  If true, attach new comments to the right side of the storage
   *              changeset. Note that this may be false, if the left side of
   *              some storage changeset is being shown as the right side of
   *              a display diff.
   * @return this
   */
  public function setRightSideCommentMapping($id, $is_new) {
    $this->rightSideChangesetID = $id;
    $this->rightSideAttachesToNewFile = $is_new;
    return $this;
  }

  /**
   * See setRightSideCommentMapping(), but this sets information for the left
   * side of the display diff.
   */
  public function setLeftSideCommentMapping($id, $is_new) {
    $this->leftSideChangesetID = $id;
    $this->leftSideAttachesToNewFile = $is_new;
    return $this;
  }

  public function setOriginals(
    DifferentialChangeset $left,
    DifferentialChangeset $right) {

    $this->originalLeft = $left;
    $this->originalRight = $right;
  }

  public function diffOriginals() {
    $engine = new PhabricatorDifferenceEngine();
    $changeset = $engine->generateChangesetFromFileContent(
      implode('', mpull($this->originalLeft->getHunks(), 'getChanges')),
      implode('', mpull($this->originalRight->getHunks(), 'getChanges')));

    // Put changes side by side.
    $olds = array();
    $news = array();
    foreach ($changeset->getHunks() as $hunk) {
      $n_old = $hunk->getOldOffset();
      $n_new = $hunk->getNewOffset();
      $changes = phutil_split_lines($hunk->getChanges());
      foreach ($changes as $line) {
        $diff_type = $line[0]; // Change type in diff of diffs.
        $orig_type = $line[1]; // Change type in the original diff.
        if ($diff_type == ' ') {
          // Use the same key for lines that are next to each other.
          $key = max(last_key($olds), last_key($news)) + 1;
          $olds[$key] = null;
          $news[$key] = null;
        } else if ($diff_type == '-') {
          $olds[] = array($n_old, $orig_type);
        } else if ($diff_type == '+') {
          $news[] = array($n_new, $orig_type);
        }
        if (($diff_type == '-' || $diff_type == ' ') && $orig_type != '-') {
          $n_old++;
        }
        if (($diff_type == '+' || $diff_type == ' ') && $orig_type != '-') {
          $n_new++;
        }
      }
    }

    $offsets_old = $this->originalLeft->computeOffsets();
    $offsets_new = $this->originalRight->computeOffsets();

    // Highlight lines that were added on each side or removed on the other
    // side.
    $highlight_old = array();
    $highlight_new = array();
    $last = max(last_key($olds), last_key($news));
    for ($i = 0; $i <= $last; $i++) {
      if (isset($olds[$i])) {
        list($n, $type) = $olds[$i];
        if ($type == '+' ||
            ($type == ' ' && isset($news[$i]) && $news[$i][1] != ' ')) {
          $highlight_old[] = $offsets_old[$n];
        }
      }
      if (isset($news[$i])) {
        list($n, $type) = $news[$i];
        if ($type == '+' ||
            ($type == ' ' && isset($olds[$i]) && $olds[$i][1] != ' ')) {
          $highlight_new[] = $offsets_new[$n];
        }
      }
    }

    return array($highlight_old, $highlight_new);
  }

  /**
   * Set a key for identifying this changeset in the render cache. If set, the
   * parser will attempt to use the changeset render cache, which can improve
   * performance for frequently-viewed changesets.
   *
   * By default, there is no render cache key and parsers do not use the cache.
   * This is appropriate for rarely-viewed changesets.
   *
   * NOTE: Currently, this key must be a valid Differential Changeset ID.
   *
   * @param   string  Key for identifying this changeset in the render cache.
   * @return  this
   */
  public function setRenderCacheKey($key) {
    $this->renderCacheKey = $key;
    return $this;
  }

  private function getRenderCacheKey() {
    return $this->renderCacheKey;
  }

  public function setChangeset(DifferentialChangeset $changeset) {
    $this->changeset = $changeset;

    $this->setFilename($changeset->getFilename());

    return $this;
  }

  public function setWhitespaceMode($whitespace_mode) {
    $this->whitespaceMode = $whitespace_mode;
    return $this;
  }

  public function setRenderingReference($ref) {
    $this->renderingReference = $ref;
    return $this;
  }

  private function getRenderingReference() {
    return $this->renderingReference;
  }

  public function getChangeset() {
    return $this->changeset;
  }

  public function setFilename($filename) {
    $this->filename = $filename;
    return $this;
  }

  public function setHandles(array $handles) {
    assert_instances_of($handles, 'PhabricatorObjectHandle');
    $this->handles = $handles;
    return $this;
  }

  public function setMarkupEngine(PhabricatorMarkupEngine $engine) {
    $this->markupEngine = $engine;
    return $this;
  }

  public function setUser(PhabricatorUser $user) {
    $this->user = $user;
    return $this;
  }

  public function setCoverage($coverage) {
    $this->coverage = $coverage;
    return $this;
  }

  public function parseHunk(DifferentialHunk $hunk) {
    $lines = $hunk->getChanges();
    $lines = phutil_split_lines($lines);

    $types = array();
    foreach ($lines as $line_index => $line) {
      if (isset($line[0])) {
        $char = $line[0];
        if ($char == ' ') {
          $types[$line_index] = null;
        } else {
          $types[$line_index] = $char;
        }
      } else {
        $types[$line_index] = null;
      }
    }

    $old_line = $hunk->getOldOffset();
    $new_line = $hunk->getNewOffset();
    $num_lines = count($lines);

    if ($old_line > 1) {
      $this->missingOld[$old_line] = true;
    } else if ($new_line > 1) {
      $this->missingNew[$new_line] = true;
    }

    for ($cursor = 0; $cursor < $num_lines; $cursor++) {
      $type = $types[$cursor];
      $data = array(
        'type'  => $type,
        'text'  => (string)substr($lines[$cursor], 1),
        'line'  => $new_line,
      );
      if ($type == '\\') {
        $type = $types[$cursor - 1];
        $data['text'] = ltrim($data['text']);
      }
      switch ($type) {
        case '+':
          $this->new[] = $data;
          ++$new_line;
          break;
        case '-':
          $data['line'] = $old_line;
          $this->old[] = $data;
          ++$old_line;
          break;
        default:
          $this->new[] = $data;
          $data['line'] = $old_line;
          $this->old[] = $data;
          ++$new_line;
          ++$old_line;
          break;
      }
    }
  }

  public function parseInlineComment(
    PhabricatorInlineCommentInterface $comment) {

    // Parse only comments which are actually visible.
    if ($this->isCommentVisibleOnRenderedDiff($comment)) {
      $this->comments[] = $comment;
    }
    return $this;
  }

  public function process() {

    $old = array();
    $new = array();

    $this->old = array_reverse($this->old);
    $this->new = array_reverse($this->new);

    $whitelines = false;
    $changed = false;

    $skip_intra = array();
    while (count($this->old) || count($this->new)) {
      $o_desc = array_pop($this->old);
      $n_desc = array_pop($this->new);

      if ($o_desc) {
        $o_type = $o_desc['type'];
      } else {
        $o_type = null;
      }

      if ($n_desc) {
        $n_type = $n_desc['type'];
      } else {
        $n_type = null;
      }

      if (($o_type != null) && ($n_type == null)) {
        $old[] = $o_desc;
        $new[] = null;
        if ($n_desc) {
          array_push($this->new, $n_desc);
        }
        $changed = true;
        continue;
      }

      if (($n_type != null) && ($o_type == null)) {
        $old[] = null;
        $new[] = $n_desc;
        if ($o_desc) {
          array_push($this->old, $o_desc);
        }
        $changed = true;
        continue;
      }

      if ($this->whitespaceMode != self::WHITESPACE_SHOW_ALL) {
        $similar = false;
        switch ($this->whitespaceMode) {
          case self::WHITESPACE_IGNORE_TRAILING:
            if (rtrim($o_desc['text']) == rtrim($n_desc['text'])) {
              if ($o_desc['type']) {
                // If we're converting this into an unchanged line because of
                // a trailing whitespace difference, mark it as a whitespace
                // change so we can show "This file was modified only by
                // adding or removing trailing whitespace." instead of
                // "This file was not modified.".
                $whitelines = true;
              }
              $similar = true;
            }
            break;
          default:
            // In this case, the lines are similar if there is no change type
            // (that is, just trust the diff algorithm).
            if (!$o_desc['type']) {
              $similar = true;
            }
            break;
        }
        if ($similar) {
          if ($o_desc['type'] == '\\') {
            // These are similar because they're "No newline at end of file"
            // comments.
          } else {
            $o_desc['type'] = null;
            $n_desc['type'] = null;
            $skip_intra[count($old)] = true;
          }
        } else {
          $changed = true;
        }
      } else {
        $changed = true;
      }

      $old[] = $o_desc;
      $new[] = $n_desc;
    }

    $this->old = $old;
    $this->new = $new;

    $unchanged = false;
    if ($this->subparser) {
      $unchanged = $this->subparser->isUnchanged();
      $whitelines = $this->subparser->isWhitespaceOnly();
    } else if (!$changed) {
      $filetype = $this->changeset->getFileType();
      if ($filetype == DifferentialChangeType::FILE_TEXT ||
          $filetype == DifferentialChangeType::FILE_SYMLINK) {
        $unchanged = true;
      }
    }
    $changetype = $this->changeset->getChangeType();
    if ($changetype == DifferentialChangeType::TYPE_MOVE_AWAY) {
      // sometimes we show moved files as unchanged, sometimes deleted,
      // and sometimes inconsistent with what actually happened at the
      // destination of the move.  Rather than make a false claim,
      // omit the 'not changed' notice if this is the source of a move
      $unchanged = false;
    }

    $this->specialAttributes = array(
      self::ATTR_UNCHANGED  => $unchanged,
      self::ATTR_DELETED    => array_filter($this->old) &&
                               !array_filter($this->new),
      self::ATTR_WHITELINES => $whitelines
    );

    if ($this->isSubparser) {
      // The rest of this function deals with formatting the diff for display;
      // we can exit early if we're a subparser and avoid doing extra work.
      return;
    }

    if ($this->subparser) {

      // Use this parser's side-by-side line information -- notably, the
      // change types -- but replace all the line text with the subparser's.
      // This lets us render whitespace-only changes without marking them as
      // different.

      $old = $this->old;
      $new = $this->new;
      $old_text = ipull($this->subparser->old, 'text', 'line');
      $new_text = ipull($this->subparser->new, 'text', 'line');

      foreach ($old as $k => $desc) {
        if (empty($desc)) {
          continue;
        }
        $old[$k]['text'] = idx($old_text, $desc['line']);
      }
      foreach ($new as $k => $desc) {
        if (empty($desc)) {
          continue;
        }
        $new[$k]['text'] = idx($new_text, $desc['line']);

        if ($this->whitespaceMode == self::WHITESPACE_IGNORE_FORCE) {
          // Under forced ignore mode, ignore even internal whitespace
          // changes.
          continue;
        }

        // If there's a corresponding "old" text and the line is marked as
        // unchanged, test if there are internal whitespace changes between
        // non-whitespace characters, e.g. spaces added to a string or spaces
        // added around operators. If we find internal spaces, mark the line
        // as changed.
        //
        // We only need to do this for "new" lines because any line that is
        // missing either "old" or "new" text certainly can not have internal
        // whitespace changes without also having non-whitespace changes,
        // because characters had to be either added or removed to create the
        // possibility of internal whitespace.
        if (isset($old[$k]['text']) && empty($new[$k]['type'])) {
          if (trim($old[$k]['text']) != trim($new[$k]['text'])) {
            // The strings aren't the same when trimmed, so there are internal
            // whitespace changes. Mark this line changed.
            $old[$k]['type'] = '-';
            $new[$k]['type'] = '+';

            // Re-mark this line for intraline diffing.
            unset($skip_intra[$k]);
          }
        }
      }

      $this->old = $old;
      $this->new = $new;
    }

    $min_length = min(count($this->old), count($this->new));
    for ($ii = 0; $ii < $min_length; $ii++) {
      if ($this->old[$ii] || $this->new[$ii]) {
        if (isset($this->old[$ii]['text'])) {
          $otext = $this->old[$ii]['text'];
        } else {
          $otext = '';
        }
        if (isset($this->new[$ii]['text'])) {
          $ntext = $this->new[$ii]['text'];
        } else {
          $ntext = '';
        }
        if ($otext != $ntext && empty($skip_intra[$ii])) {
          $this->intra[$ii] = ArcanistDiffUtils::generateIntralineDiff(
            $otext,
            $ntext);
        }
      }
    }

    $lines_context = self::LINES_CONTEXT;
    $max_length = max(count($this->old), count($this->new));
    $old = $this->old;
    $new = $this->new;
    $visible = false;
    $last = 0;
    for ($cursor = -$lines_context; $cursor < $max_length; $cursor++) {
      $offset = $cursor + $lines_context;
      if ((isset($old[$offset]) && $old[$offset]['type']) ||
          (isset($new[$offset]) && $new[$offset]['type'])) {
        $visible = true;
        $last = $offset;
      } else if ($cursor > $last + $lines_context) {
        $visible = false;
      }
      if ($visible && $cursor > 0) {
        $this->visible[$cursor] = 1;
      }
    }

    $old_corpus = array();
    foreach ($this->old as $o) {
      if ($o['type'] != '\\') {
        if ($o['text'] === null) {
          // There's no text on this side of the diff, but insert a placeholder
          // newline so the highlighted line numbers match up.
          $old_corpus[] = "\n";
        } else {
          $old_corpus[] = $o['text'];
        }
      }
    }
    $old_corpus_block = implode('', $old_corpus);

    $new_corpus = array();
    foreach ($this->new as $n) {
      if ($n['type'] != '\\') {
        if ($n['text'] === null) {
          $new_corpus[] = "\n";
        } else {
          $new_corpus[] = $n['text'];
        }
      }
    }
    $new_corpus_block = implode('', $new_corpus);

    $this->markGenerated($new_corpus_block);

    if ($this->isTopLevel && !$this->comments &&
        ($this->isGenerated() || $this->isUnchanged() || $this->isDeleted())) {
      return;
    }

    $old_future = $this->getHighlightFuture($old_corpus_block);
    $new_future = $this->getHighlightFuture($new_corpus_block);
    $futures = array(
      'old' => $old_future,
      'new' => $new_future,
    );
    $corpus_blocks = array(
      'old' => $old_corpus_block,
      'new' => $new_corpus_block,
    );

    $this->highlightErrors = false;
    foreach (Futures($futures) as $key => $future) {
      try {
        try {
          $highlighted = $future->resolve();
        } catch (PhutilSyntaxHighlighterException $ex) {
          $this->highlightErrors = true;
          $highlighted = id(new PhutilDefaultSyntaxHighlighter())
            ->getHighlightFuture($corpus_blocks[$key])
            ->resolve();
        }
        switch ($key) {
          case 'old':
            $this->oldRender = $this->processHighlightedSource(
              $this->old,
              $highlighted);
            break;
          case 'new':
            $this->newRender = $this->processHighlightedSource(
              $this->new,
              $highlighted);
            break;
        }
      } catch (Exception $ex) {
        phlog($ex);
        throw $ex;
      }
    }

    $this->applyIntraline(
      $this->oldRender,
      ipull($this->intra, 0),
      $old_corpus);
    $this->applyIntraline(
      $this->newRender,
      ipull($this->intra, 1),
      $new_corpus);
  }

  public function loadCache() {
    $render_cache_key = $this->getRenderCacheKey();
    if (!$render_cache_key) {
      return false;
    }

    $data = null;

    $changeset = new DifferentialChangeset();
    $conn_r = $changeset->establishConnection('r');
    $data = queryfx_one(
      $conn_r,
      'SELECT * FROM %T WHERE id = %d',
      $changeset->getTableName().'_parse_cache',
      $render_cache_key);

    if (!$data) {
      return false;
    }

    $data = json_decode($data['cache'], true);
    if (!is_array($data) || !$data) {
      return false;
    }

    foreach (self::getCacheableProperties() as $cache_key) {
      if (!array_key_exists($cache_key, $data)) {
        // If we're missing a cache key, assume we're looking at an old cache
        // and ignore it.
        return false;
      }
    }

    if ($data['cacheVersion'] !== self::CACHE_VERSION) {
      return false;
    }

    // Someone displays contents of a partially cached shielded file.
    if (!isset($data['newRender']) && (!$this->isTopLevel || $this->comments)) {
      return false;
    }

    unset($data['cacheVersion'], $data['cacheHost']);
    $cache_prop = array_select_keys($data, self::getCacheableProperties());
    foreach ($cache_prop as $cache_key => $v) {
      $this->$cache_key = $v;
    }

    return true;
  }

  protected static function getCacheableProperties() {
    return array(
      'visible',
      'new',
      'old',
      'intra',
      'newRender',
      'oldRender',
      'specialAttributes',
      'missingOld',
      'missingNew',
      'cacheVersion',
      'cacheHost',
    );
  }

  public function saveCache() {
    if ($this->highlightErrors) {
      return false;
    }

    $render_cache_key = $this->getRenderCacheKey();
    if (!$render_cache_key) {
      return false;
    }

    $cache = array();
    foreach (self::getCacheableProperties() as $cache_key) {
      switch ($cache_key) {
        case 'cacheVersion':
          $cache[$cache_key] = self::CACHE_VERSION;
          break;
        case 'cacheHost':
          $cache[$cache_key] = php_uname('n');
          break;
        default:
          $cache[$cache_key] = $this->$cache_key;
          break;
      }
    }
    $cache = json_encode($cache);

    // We don't want to waste too much space by a single changeset.
    if (strlen($cache) > self::CACHE_MAX_SIZE) {
      return;
    }

    try {
      $changeset = new DifferentialChangeset();
      $conn_w = $changeset->establishConnection('w');

      $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
      queryfx(
        $conn_w,
        'INSERT INTO %T (id, cache, dateCreated) VALUES (%d, %s, %d)
          ON DUPLICATE KEY UPDATE cache = VALUES(cache)',
        DifferentialChangeset::TABLE_CACHE,
        $render_cache_key,
        $cache,
        time());
    } catch (AphrontQueryException $ex) {
      // TODO: uhoh
    }
  }

  private function markGenerated($new_corpus_block = '') {
    $generated_guess = (strpos($new_corpus_block, '@'.'generated') !== false);

    if (!$generated_guess) {
      $config_key = 'differential.generated-paths';
      $generated_path_regexps = PhabricatorEnv::getEnvConfig($config_key);
      foreach ($generated_path_regexps as $regexp) {
        if (preg_match($regexp, $this->changeset->getFilename())) {
          $generated_guess = true;
          break;
        }
      }
    }

    $event = new PhabricatorEvent(
      PhabricatorEventType::TYPE_DIFFERENTIAL_WILLMARKGENERATED,
      array(
        'corpus' => $new_corpus_block,
        'is_generated' => $generated_guess,
      )
    );
    PhutilEventEngine::dispatchEvent($event);

    $generated = $event->getValue('is_generated');
    $this->specialAttributes[self::ATTR_GENERATED] = $generated;
  }

  public function isGenerated() {
    return idx($this->specialAttributes, self::ATTR_GENERATED, false);
  }

  public function isDeleted() {
    return idx($this->specialAttributes, self::ATTR_DELETED, false);
  }

  public function isUnchanged() {
    return idx($this->specialAttributes, self::ATTR_UNCHANGED, false);
  }

  public function isWhitespaceOnly() {
    return idx($this->specialAttributes, self::ATTR_WHITELINES, false);
  }

  protected function applyIntraline(&$render, $intra, $corpus) {

    foreach ($render as $key => $text) {
      if (isset($intra[$key])) {
        $render[$key] = ArcanistDiffUtils::applyIntralineDiff(
          $text,
          $intra[$key]);
      }
    }
  }

  protected function getHighlightFuture($corpus) {
    if (preg_match('/\r(?!\n)/', $corpus)) {
      // TODO: Pygments converts "\r" newlines into "\n" newlines, so we can't
      // use it on files with "\r" newlines. If we have "\r" not followed by
      // "\n" in the file, skip highlighting.
      $result = phutil_escape_html($corpus);
      return new ImmediateFuture($result);
    }

    return $this->highlightEngine->getHighlightFuture(
      $this->highlightEngine->getLanguageFromFilename($this->filename),
      $corpus);
  }

  protected function processHighlightedSource($data, $result) {

    $result_lines = phutil_split_lines($result);
    foreach ($data as $key => $info) {
      if (!$info) {
        unset($result_lines[$key]);
      }
    }
    return $result_lines;
  }

  private function tryCacheStuff() {
    $whitespace_mode = $this->whitespaceMode;
    switch ($whitespace_mode) {
      case self::WHITESPACE_SHOW_ALL:
      case self::WHITESPACE_IGNORE_TRAILING:
      case self::WHITESPACE_IGNORE_FORCE:
        break;
      default:
        $whitespace_mode = self::WHITESPACE_IGNORE_ALL;
        break;
    }

    $skip_cache = ($whitespace_mode != self::WHITESPACE_IGNORE_ALL);
    $this->whitespaceMode = $whitespace_mode;

    $changeset = $this->changeset;

    if ($changeset->getFileType() != DifferentialChangeType::FILE_TEXT &&
        $changeset->getFileType() != DifferentialChangeType::FILE_SYMLINK) {
      $this->markGenerated();

    } else {
      if ($skip_cache || !$this->loadCache()) {

        $ignore_all = (($whitespace_mode == self::WHITESPACE_IGNORE_ALL) ||
                       ($whitespace_mode == self::WHITESPACE_IGNORE_FORCE));

        $force_ignore = ($whitespace_mode == self::WHITESPACE_IGNORE_FORCE);

        if (!$force_ignore) {
          if ($ignore_all && $changeset->getWhitespaceMatters()) {
            $ignore_all = false;
          }
        }

        // The "ignore all whitespace" algorithm depends on rediffing the
        // files, and we currently need complete representations of both
        // files to do anything reasonable. If we only have parts of the files,
        // don't use the "ignore all" algorithm.
        if ($ignore_all) {
          $hunks = $changeset->getHunks();
          if (count($hunks) !== 1) {
            $ignore_all = false;
          } else {
            $first_hunk = reset($hunks);
            if ($first_hunk->getOldOffset() != 1 ||
                $first_hunk->getNewOffset() != 1) {
              $ignore_all = false;
            }
          }
        }

        if ($ignore_all) {
          $old_file = $changeset->makeOldFile();
          $new_file = $changeset->makeNewFile();
          if ($old_file == $new_file) {
            // If the old and new files are exactly identical, the synthetic
            // diff below will give us nonsense and whitespace modes are
            // irrelevant anyway. This occurs when you, e.g., copy a file onto
            // itself in Subversion (see T271).
            $ignore_all = false;
          }
        }

        if ($ignore_all) {

          // Huge mess. Generate a "-bw" (ignore all whitespace changes) diff,
          // parse it out, and then play a shell game with the parsed format
          // in process() so we highlight only changed lines but render
          // whitespace differences. If we don't do this, we either fail to
          // render whitespace changes (which is incredibly confusing,
          // especially for python) or often produce a much larger set of
          // differences than necessary.

          $engine = new PhabricatorDifferenceEngine();
          $engine->setIgnoreWhitespace(true);
          $no_whitespace_changeset = $engine->generateChangesetFromFileContent(
            $old_file,
            $new_file);

          // subparser takes over the current non-whitespace-ignoring changeset
          $subparser = new DifferentialChangesetParser();
          $subparser->isSubparser = true;
          $subparser->setChangeset($changeset);
          foreach ($changeset->getHunks() as $hunk) {
            $subparser->parseHunk($hunk);
          }
          // We need to call process() so that the subparser's values for
          // metadata (like 'unchanged') is correct.
          $subparser->process();

          $this->subparser = $subparser;

          // While we aren't updating $this->changeset (since it has a bunch
          // of metadata we need to preserve, so that headers like "this file
          // was moved" render correctly), we're overwriting the local
          // $changeset so that the block below will choose the synthetic
          // hunks we've built instead of the original hunks.
          $changeset = $no_whitespace_changeset;
        }

        // This either uses the real hunks, or synthetic hunks we built above.
        foreach ($changeset->getHunks() as $hunk) {
          $this->parseHunk($hunk);
        }
        $this->process();
        if (!$skip_cache) {
          $this->saveCache();
        }
      }
    }
  }

  private function shouldRenderPropertyChangeHeader($changeset) {
    if (!$this->isTopLevel) {
      // We render properties only at top level; otherwise we get multiple
      // copies of them when a user clicks "Show More".
      return false;
    }

    $old = $changeset->getOldProperties();
    $new = $changeset->getNewProperties();

    if ($old === $new) {
      return false;
    }

    if ($changeset->getChangeType() == DifferentialChangeType::TYPE_ADD &&
        $new == array('unix:filemode' => '100644')) {
      return false;
    }

    if ($changeset->getChangeType() == DifferentialChangeType::TYPE_DELETE &&
        $old == array('unix:filemode' => '100644')) {
      return false;
     }

    return true;
  }

  public function render(
    $range_start  = null,
    $range_len    = null,
    $mask_force   = array()) {

    // "Top level" renders are initial requests for the whole file, versus
    // requests for a specific range generated by clicking "show more". We
    // generate property changes and "shield" UI elements only for toplevel
    // requests.
    $this->isTopLevel = (($range_start === null) && ($range_len === null));
    $this->highlightEngine = PhabricatorSyntaxHighlighter::newEngine();
    $this->tryCacheStuff();
    $render_pch = $this->shouldRenderPropertyChangeHeader($this->changeset);

    $renderer = id(new DifferentialChangesetTwoUpRenderer())
      ->setChangeset($this->changeset)
      ->setRenderPropertyChangeHeader($render_pch)
      ->setOldLines($this->old)
      ->setNewLines($this->new)
      ->setOldRender($this->oldRender)
      ->setNewRender($this->newRender)
      ->setMissingOldLines($this->missingOld)
      ->setMissingNewLines($this->missingNew)
      ->setVisibleLines($this->visible)
      ->setOldChangesetID($this->leftSideChangesetID)
      ->setNewChangesetID($this->rightSideChangesetID)
      ->setOldAttachesToNewFile($this->leftSideAttachesToNewFile)
      ->setNewAttachesToNewFile($this->rightSideAttachesToNewFile)
      ->setLinesOfContext(self::LINES_CONTEXT)
      ->setCodeCoverage($this->coverage)
      ->setRenderingReference($this->getRenderingReference())
      ->setMarkupEngine($this->markupEngine)
      ->setHandles($this->handles);

    $shield = null;
    if ($this->isTopLevel && !$this->comments) {
      if ($this->isGenerated()) {
        $shield = $renderer->renderShield(
          pht(
            'This file contains generated code, which does not normally '.<commit_msg>Restore "Reply" action to Differential inline comments<commit_after><?php

final class DifferentialChangesetParser {

  protected $visible      = array();
  protected $new          = array();
  protected $old          = array();
  protected $intra        = array();
  protected $newRender    = null;
  protected $oldRender    = null;

  protected $filename     = null;
  protected $missingOld   = array();
  protected $missingNew   = array();

  protected $comments     = array();
  protected $specialAttributes = array();

  protected $changeset;
  protected $whitespaceMode = null;

  protected $subparser;

  protected $renderCacheKey = null;

  private $handles = array();
  private $user;

  private $leftSideChangesetID;
  private $leftSideAttachesToNewFile;

  private $rightSideChangesetID;
  private $rightSideAttachesToNewFile;

  private $originalLeft;
  private $originalRight;

  private $renderingReference;
  private $isSubparser;

  private $isTopLevel;
  private $coverage;
  private $markupEngine;
  private $highlightErrors;

  const CACHE_VERSION = 8;
  const CACHE_MAX_SIZE = 8e6;

  const ATTR_GENERATED  = 'attr:generated';
  const ATTR_DELETED    = 'attr:deleted';
  const ATTR_UNCHANGED  = 'attr:unchanged';
  const ATTR_WHITELINES = 'attr:white';

  const LINES_CONTEXT = 8;

  const WHITESPACE_SHOW_ALL         = 'show-all';
  const WHITESPACE_IGNORE_TRAILING  = 'ignore-trailing';

  // TODO: This is now "Ignore Most" in the UI.
  const WHITESPACE_IGNORE_ALL       = 'ignore-all';

  const WHITESPACE_IGNORE_FORCE     = 'ignore-force';

  /**
   * Configure which Changeset comments added to the right side of the visible
   * diff will be attached to. The ID must be the ID of a real Differential
   * Changeset.
   *
   * The complexity here is that we may show an arbitrary side of an arbitrary
   * changeset as either the left or right part of a diff. This method allows
   * the left and right halves of the displayed diff to be correctly mapped to
   * storage changesets.
   *
   * @param id    The Differential Changeset ID that comments added to the right
   *              side of the visible diff should be attached to.
   * @param bool  If true, attach new comments to the right side of the storage
   *              changeset. Note that this may be false, if the left side of
   *              some storage changeset is being shown as the right side of
   *              a display diff.
   * @return this
   */
  public function setRightSideCommentMapping($id, $is_new) {
    $this->rightSideChangesetID = $id;
    $this->rightSideAttachesToNewFile = $is_new;
    return $this;
  }

  /**
   * See setRightSideCommentMapping(), but this sets information for the left
   * side of the display diff.
   */
  public function setLeftSideCommentMapping($id, $is_new) {
    $this->leftSideChangesetID = $id;
    $this->leftSideAttachesToNewFile = $is_new;
    return $this;
  }

  public function setOriginals(
    DifferentialChangeset $left,
    DifferentialChangeset $right) {

    $this->originalLeft = $left;
    $this->originalRight = $right;
  }

  public function diffOriginals() {
    $engine = new PhabricatorDifferenceEngine();
    $changeset = $engine->generateChangesetFromFileContent(
      implode('', mpull($this->originalLeft->getHunks(), 'getChanges')),
      implode('', mpull($this->originalRight->getHunks(), 'getChanges')));

    // Put changes side by side.
    $olds = array();
    $news = array();
    foreach ($changeset->getHunks() as $hunk) {
      $n_old = $hunk->getOldOffset();
      $n_new = $hunk->getNewOffset();
      $changes = phutil_split_lines($hunk->getChanges());
      foreach ($changes as $line) {
        $diff_type = $line[0]; // Change type in diff of diffs.
        $orig_type = $line[1]; // Change type in the original diff.
        if ($diff_type == ' ') {
          // Use the same key for lines that are next to each other.
          $key = max(last_key($olds), last_key($news)) + 1;
          $olds[$key] = null;
          $news[$key] = null;
        } else if ($diff_type == '-') {
          $olds[] = array($n_old, $orig_type);
        } else if ($diff_type == '+') {
          $news[] = array($n_new, $orig_type);
        }
        if (($diff_type == '-' || $diff_type == ' ') && $orig_type != '-') {
          $n_old++;
        }
        if (($diff_type == '+' || $diff_type == ' ') && $orig_type != '-') {
          $n_new++;
        }
      }
    }

    $offsets_old = $this->originalLeft->computeOffsets();
    $offsets_new = $this->originalRight->computeOffsets();

    // Highlight lines that were added on each side or removed on the other
    // side.
    $highlight_old = array();
    $highlight_new = array();
    $last = max(last_key($olds), last_key($news));
    for ($i = 0; $i <= $last; $i++) {
      if (isset($olds[$i])) {
        list($n, $type) = $olds[$i];
        if ($type == '+' ||
            ($type == ' ' && isset($news[$i]) && $news[$i][1] != ' ')) {
          $highlight_old[] = $offsets_old[$n];
        }
      }
      if (isset($news[$i])) {
        list($n, $type) = $news[$i];
        if ($type == '+' ||
            ($type == ' ' && isset($olds[$i]) && $olds[$i][1] != ' ')) {
          $highlight_new[] = $offsets_new[$n];
        }
      }
    }

    return array($highlight_old, $highlight_new);
  }

  /**
   * Set a key for identifying this changeset in the render cache. If set, the
   * parser will attempt to use the changeset render cache, which can improve
   * performance for frequently-viewed changesets.
   *
   * By default, there is no render cache key and parsers do not use the cache.
   * This is appropriate for rarely-viewed changesets.
   *
   * NOTE: Currently, this key must be a valid Differential Changeset ID.
   *
   * @param   string  Key for identifying this changeset in the render cache.
   * @return  this
   */
  public function setRenderCacheKey($key) {
    $this->renderCacheKey = $key;
    return $this;
  }

  private function getRenderCacheKey() {
    return $this->renderCacheKey;
  }

  public function setChangeset(DifferentialChangeset $changeset) {
    $this->changeset = $changeset;

    $this->setFilename($changeset->getFilename());

    return $this;
  }

  public function setWhitespaceMode($whitespace_mode) {
    $this->whitespaceMode = $whitespace_mode;
    return $this;
  }

  public function setRenderingReference($ref) {
    $this->renderingReference = $ref;
    return $this;
  }

  private function getRenderingReference() {
    return $this->renderingReference;
  }

  public function getChangeset() {
    return $this->changeset;
  }

  public function setFilename($filename) {
    $this->filename = $filename;
    return $this;
  }

  public function setHandles(array $handles) {
    assert_instances_of($handles, 'PhabricatorObjectHandle');
    $this->handles = $handles;
    return $this;
  }

  public function setMarkupEngine(PhabricatorMarkupEngine $engine) {
    $this->markupEngine = $engine;
    return $this;
  }

  public function setUser(PhabricatorUser $user) {
    $this->user = $user;
    return $this;
  }

  public function setCoverage($coverage) {
    $this->coverage = $coverage;
    return $this;
  }

  public function parseHunk(DifferentialHunk $hunk) {
    $lines = $hunk->getChanges();
    $lines = phutil_split_lines($lines);

    $types = array();
    foreach ($lines as $line_index => $line) {
      if (isset($line[0])) {
        $char = $line[0];
        if ($char == ' ') {
          $types[$line_index] = null;
        } else {
          $types[$line_index] = $char;
        }
      } else {
        $types[$line_index] = null;
      }
    }

    $old_line = $hunk->getOldOffset();
    $new_line = $hunk->getNewOffset();
    $num_lines = count($lines);

    if ($old_line > 1) {
      $this->missingOld[$old_line] = true;
    } else if ($new_line > 1) {
      $this->missingNew[$new_line] = true;
    }

    for ($cursor = 0; $cursor < $num_lines; $cursor++) {
      $type = $types[$cursor];
      $data = array(
        'type'  => $type,
        'text'  => (string)substr($lines[$cursor], 1),
        'line'  => $new_line,
      );
      if ($type == '\\') {
        $type = $types[$cursor - 1];
        $data['text'] = ltrim($data['text']);
      }
      switch ($type) {
        case '+':
          $this->new[] = $data;
          ++$new_line;
          break;
        case '-':
          $data['line'] = $old_line;
          $this->old[] = $data;
          ++$old_line;
          break;
        default:
          $this->new[] = $data;
          $data['line'] = $old_line;
          $this->old[] = $data;
          ++$new_line;
          ++$old_line;
          break;
      }
    }
  }

  public function parseInlineComment(
    PhabricatorInlineCommentInterface $comment) {

    // Parse only comments which are actually visible.
    if ($this->isCommentVisibleOnRenderedDiff($comment)) {
      $this->comments[] = $comment;
    }
    return $this;
  }

  public function process() {

    $old = array();
    $new = array();

    $this->old = array_reverse($this->old);
    $this->new = array_reverse($this->new);

    $whitelines = false;
    $changed = false;

    $skip_intra = array();
    while (count($this->old) || count($this->new)) {
      $o_desc = array_pop($this->old);
      $n_desc = array_pop($this->new);

      if ($o_desc) {
        $o_type = $o_desc['type'];
      } else {
        $o_type = null;
      }

      if ($n_desc) {
        $n_type = $n_desc['type'];
      } else {
        $n_type = null;
      }

      if (($o_type != null) && ($n_type == null)) {
        $old[] = $o_desc;
        $new[] = null;
        if ($n_desc) {
          array_push($this->new, $n_desc);
        }
        $changed = true;
        continue;
      }

      if (($n_type != null) && ($o_type == null)) {
        $old[] = null;
        $new[] = $n_desc;
        if ($o_desc) {
          array_push($this->old, $o_desc);
        }
        $changed = true;
        continue;
      }

      if ($this->whitespaceMode != self::WHITESPACE_SHOW_ALL) {
        $similar = false;
        switch ($this->whitespaceMode) {
          case self::WHITESPACE_IGNORE_TRAILING:
            if (rtrim($o_desc['text']) == rtrim($n_desc['text'])) {
              if ($o_desc['type']) {
                // If we're converting this into an unchanged line because of
                // a trailing whitespace difference, mark it as a whitespace
                // change so we can show "This file was modified only by
                // adding or removing trailing whitespace." instead of
                // "This file was not modified.".
                $whitelines = true;
              }
              $similar = true;
            }
            break;
          default:
            // In this case, the lines are similar if there is no change type
            // (that is, just trust the diff algorithm).
            if (!$o_desc['type']) {
              $similar = true;
            }
            break;
        }
        if ($similar) {
          if ($o_desc['type'] == '\\') {
            // These are similar because they're "No newline at end of file"
            // comments.
          } else {
            $o_desc['type'] = null;
            $n_desc['type'] = null;
            $skip_intra[count($old)] = true;
          }
        } else {
          $changed = true;
        }
      } else {
        $changed = true;
      }

      $old[] = $o_desc;
      $new[] = $n_desc;
    }

    $this->old = $old;
    $this->new = $new;

    $unchanged = false;
    if ($this->subparser) {
      $unchanged = $this->subparser->isUnchanged();
      $whitelines = $this->subparser->isWhitespaceOnly();
    } else if (!$changed) {
      $filetype = $this->changeset->getFileType();
      if ($filetype == DifferentialChangeType::FILE_TEXT ||
          $filetype == DifferentialChangeType::FILE_SYMLINK) {
        $unchanged = true;
      }
    }
    $changetype = $this->changeset->getChangeType();
    if ($changetype == DifferentialChangeType::TYPE_MOVE_AWAY) {
      // sometimes we show moved files as unchanged, sometimes deleted,
      // and sometimes inconsistent with what actually happened at the
      // destination of the move.  Rather than make a false claim,
      // omit the 'not changed' notice if this is the source of a move
      $unchanged = false;
    }

    $this->specialAttributes = array(
      self::ATTR_UNCHANGED  => $unchanged,
      self::ATTR_DELETED    => array_filter($this->old) &&
                               !array_filter($this->new),
      self::ATTR_WHITELINES => $whitelines
    );

    if ($this->isSubparser) {
      // The rest of this function deals with formatting the diff for display;
      // we can exit early if we're a subparser and avoid doing extra work.
      return;
    }

    if ($this->subparser) {

      // Use this parser's side-by-side line information -- notably, the
      // change types -- but replace all the line text with the subparser's.
      // This lets us render whitespace-only changes without marking them as
      // different.

      $old = $this->old;
      $new = $this->new;
      $old_text = ipull($this->subparser->old, 'text', 'line');
      $new_text = ipull($this->subparser->new, 'text', 'line');

      foreach ($old as $k => $desc) {
        if (empty($desc)) {
          continue;
        }
        $old[$k]['text'] = idx($old_text, $desc['line']);
      }
      foreach ($new as $k => $desc) {
        if (empty($desc)) {
          continue;
        }
        $new[$k]['text'] = idx($new_text, $desc['line']);

        if ($this->whitespaceMode == self::WHITESPACE_IGNORE_FORCE) {
          // Under forced ignore mode, ignore even internal whitespace
          // changes.
          continue;
        }

        // If there's a corresponding "old" text and the line is marked as
        // unchanged, test if there are internal whitespace changes between
        // non-whitespace characters, e.g. spaces added to a string or spaces
        // added around operators. If we find internal spaces, mark the line
        // as changed.
        //
        // We only need to do this for "new" lines because any line that is
        // missing either "old" or "new" text certainly can not have internal
        // whitespace changes without also having non-whitespace changes,
        // because characters had to be either added or removed to create the
        // possibility of internal whitespace.
        if (isset($old[$k]['text']) && empty($new[$k]['type'])) {
          if (trim($old[$k]['text']) != trim($new[$k]['text'])) {
            // The strings aren't the same when trimmed, so there are internal
            // whitespace changes. Mark this line changed.
            $old[$k]['type'] = '-';
            $new[$k]['type'] = '+';

            // Re-mark this line for intraline diffing.
            unset($skip_intra[$k]);
          }
        }
      }

      $this->old = $old;
      $this->new = $new;
    }

    $min_length = min(count($this->old), count($this->new));
    for ($ii = 0; $ii < $min_length; $ii++) {
      if ($this->old[$ii] || $this->new[$ii]) {
        if (isset($this->old[$ii]['text'])) {
          $otext = $this->old[$ii]['text'];
        } else {
          $otext = '';
        }
        if (isset($this->new[$ii]['text'])) {
          $ntext = $this->new[$ii]['text'];
        } else {
          $ntext = '';
        }
        if ($otext != $ntext && empty($skip_intra[$ii])) {
          $this->intra[$ii] = ArcanistDiffUtils::generateIntralineDiff(
            $otext,
            $ntext);
        }
      }
    }

    $lines_context = self::LINES_CONTEXT;
    $max_length = max(count($this->old), count($this->new));
    $old = $this->old;
    $new = $this->new;
    $visible = false;
    $last = 0;
    for ($cursor = -$lines_context; $cursor < $max_length; $cursor++) {
      $offset = $cursor + $lines_context;
      if ((isset($old[$offset]) && $old[$offset]['type']) ||
          (isset($new[$offset]) && $new[$offset]['type'])) {
        $visible = true;
        $last = $offset;
      } else if ($cursor > $last + $lines_context) {
        $visible = false;
      }
      if ($visible && $cursor > 0) {
        $this->visible[$cursor] = 1;
      }
    }

    $old_corpus = array();
    foreach ($this->old as $o) {
      if ($o['type'] != '\\') {
        if ($o['text'] === null) {
          // There's no text on this side of the diff, but insert a placeholder
          // newline so the highlighted line numbers match up.
          $old_corpus[] = "\n";
        } else {
          $old_corpus[] = $o['text'];
        }
      }
    }
    $old_corpus_block = implode('', $old_corpus);

    $new_corpus = array();
    foreach ($this->new as $n) {
      if ($n['type'] != '\\') {
        if ($n['text'] === null) {
          $new_corpus[] = "\n";
        } else {
          $new_corpus[] = $n['text'];
        }
      }
    }
    $new_corpus_block = implode('', $new_corpus);

    $this->markGenerated($new_corpus_block);

    if ($this->isTopLevel && !$this->comments &&
        ($this->isGenerated() || $this->isUnchanged() || $this->isDeleted())) {
      return;
    }

    $old_future = $this->getHighlightFuture($old_corpus_block);
    $new_future = $this->getHighlightFuture($new_corpus_block);
    $futures = array(
      'old' => $old_future,
      'new' => $new_future,
    );
    $corpus_blocks = array(
      'old' => $old_corpus_block,
      'new' => $new_corpus_block,
    );

    $this->highlightErrors = false;
    foreach (Futures($futures) as $key => $future) {
      try {
        try {
          $highlighted = $future->resolve();
        } catch (PhutilSyntaxHighlighterException $ex) {
          $this->highlightErrors = true;
          $highlighted = id(new PhutilDefaultSyntaxHighlighter())
            ->getHighlightFuture($corpus_blocks[$key])
            ->resolve();
        }
        switch ($key) {
          case 'old':
            $this->oldRender = $this->processHighlightedSource(
              $this->old,
              $highlighted);
            break;
          case 'new':
            $this->newRender = $this->processHighlightedSource(
              $this->new,
              $highlighted);
            break;
        }
      } catch (Exception $ex) {
        phlog($ex);
        throw $ex;
      }
    }

    $this->applyIntraline(
      $this->oldRender,
      ipull($this->intra, 0),
      $old_corpus);
    $this->applyIntraline(
      $this->newRender,
      ipull($this->intra, 1),
      $new_corpus);
  }

  public function loadCache() {
    $render_cache_key = $this->getRenderCacheKey();
    if (!$render_cache_key) {
      return false;
    }

    $data = null;

    $changeset = new DifferentialChangeset();
    $conn_r = $changeset->establishConnection('r');
    $data = queryfx_one(
      $conn_r,
      'SELECT * FROM %T WHERE id = %d',
      $changeset->getTableName().'_parse_cache',
      $render_cache_key);

    if (!$data) {
      return false;
    }

    $data = json_decode($data['cache'], true);
    if (!is_array($data) || !$data) {
      return false;
    }

    foreach (self::getCacheableProperties() as $cache_key) {
      if (!array_key_exists($cache_key, $data)) {
        // If we're missing a cache key, assume we're looking at an old cache
        // and ignore it.
        return false;
      }
    }

    if ($data['cacheVersion'] !== self::CACHE_VERSION) {
      return false;
    }

    // Someone displays contents of a partially cached shielded file.
    if (!isset($data['newRender']) && (!$this->isTopLevel || $this->comments)) {
      return false;
    }

    unset($data['cacheVersion'], $data['cacheHost']);
    $cache_prop = array_select_keys($data, self::getCacheableProperties());
    foreach ($cache_prop as $cache_key => $v) {
      $this->$cache_key = $v;
    }

    return true;
  }

  protected static function getCacheableProperties() {
    return array(
      'visible',
      'new',
      'old',
      'intra',
      'newRender',
      'oldRender',
      'specialAttributes',
      'missingOld',
      'missingNew',
      'cacheVersion',
      'cacheHost',
    );
  }

  public function saveCache() {
    if ($this->highlightErrors) {
      return false;
    }

    $render_cache_key = $this->getRenderCacheKey();
    if (!$render_cache_key) {
      return false;
    }

    $cache = array();
    foreach (self::getCacheableProperties() as $cache_key) {
      switch ($cache_key) {
        case 'cacheVersion':
          $cache[$cache_key] = self::CACHE_VERSION;
          break;
        case 'cacheHost':
          $cache[$cache_key] = php_uname('n');
          break;
        default:
          $cache[$cache_key] = $this->$cache_key;
          break;
      }
    }
    $cache = json_encode($cache);

    // We don't want to waste too much space by a single changeset.
    if (strlen($cache) > self::CACHE_MAX_SIZE) {
      return;
    }

    try {
      $changeset = new DifferentialChangeset();
      $conn_w = $changeset->establishConnection('w');

      $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
      queryfx(
        $conn_w,
        'INSERT INTO %T (id, cache, dateCreated) VALUES (%d, %s, %d)
          ON DUPLICATE KEY UPDATE cache = VALUES(cache)',
        DifferentialChangeset::TABLE_CACHE,
        $render_cache_key,
        $cache,
        time());
    } catch (AphrontQueryException $ex) {
      // TODO: uhoh
    }
  }

  private function markGenerated($new_corpus_block = '') {
    $generated_guess = (strpos($new_corpus_block, '@'.'generated') !== false);

    if (!$generated_guess) {
      $config_key = 'differential.generated-paths';
      $generated_path_regexps = PhabricatorEnv::getEnvConfig($config_key);
      foreach ($generated_path_regexps as $regexp) {
        if (preg_match($regexp, $this->changeset->getFilename())) {
          $generated_guess = true;
          break;
        }
      }
    }

    $event = new PhabricatorEvent(
      PhabricatorEventType::TYPE_DIFFERENTIAL_WILLMARKGENERATED,
      array(
        'corpus' => $new_corpus_block,
        'is_generated' => $generated_guess,
      )
    );
    PhutilEventEngine::dispatchEvent($event);

    $generated = $event->getValue('is_generated');
    $this->specialAttributes[self::ATTR_GENERATED] = $generated;
  }

  public function isGenerated() {
    return idx($this->specialAttributes, self::ATTR_GENERATED, false);
  }

  public function isDeleted() {
    return idx($this->specialAttributes, self::ATTR_DELETED, false);
  }

  public function isUnchanged() {
    return idx($this->specialAttributes, self::ATTR_UNCHANGED, false);
  }

  public function isWhitespaceOnly() {
    return idx($this->specialAttributes, self::ATTR_WHITELINES, false);
  }

  protected function applyIntraline(&$render, $intra, $corpus) {

    foreach ($render as $key => $text) {
      if (isset($intra[$key])) {
        $render[$key] = ArcanistDiffUtils::applyIntralineDiff(
          $text,
          $intra[$key]);
      }
    }
  }

  protected function getHighlightFuture($corpus) {
    if (preg_match('/\r(?!\n)/', $corpus)) {
      // TODO: Pygments converts "\r" newlines into "\n" newlines, so we can't
      // use it on files with "\r" newlines. If we have "\r" not followed by
      // "\n" in the file, skip highlighting.
      $result = phutil_escape_html($corpus);
      return new ImmediateFuture($result);
    }

    return $this->highlightEngine->getHighlightFuture(
      $this->highlightEngine->getLanguageFromFilename($this->filename),
      $corpus);
  }

  protected function processHighlightedSource($data, $result) {

    $result_lines = phutil_split_lines($result);
    foreach ($data as $key => $info) {
      if (!$info) {
        unset($result_lines[$key]);
      }
    }
    return $result_lines;
  }

  private function tryCacheStuff() {
    $whitespace_mode = $this->whitespaceMode;
    switch ($whitespace_mode) {
      case self::WHITESPACE_SHOW_ALL:
      case self::WHITESPACE_IGNORE_TRAILING:
      case self::WHITESPACE_IGNORE_FORCE:
        break;
      default:
        $whitespace_mode = self::WHITESPACE_IGNORE_ALL;
        break;
    }

    $skip_cache = ($whitespace_mode != self::WHITESPACE_IGNORE_ALL);
    $this->whitespaceMode = $whitespace_mode;

    $changeset = $this->changeset;

    if ($changeset->getFileType() != DifferentialChangeType::FILE_TEXT &&
        $changeset->getFileType() != DifferentialChangeType::FILE_SYMLINK) {
      $this->markGenerated();

    } else {
      if ($skip_cache || !$this->loadCache()) {

        $ignore_all = (($whitespace_mode == self::WHITESPACE_IGNORE_ALL) ||
                       ($whitespace_mode == self::WHITESPACE_IGNORE_FORCE));

        $force_ignore = ($whitespace_mode == self::WHITESPACE_IGNORE_FORCE);

        if (!$force_ignore) {
          if ($ignore_all && $changeset->getWhitespaceMatters()) {
            $ignore_all = false;
          }
        }

        // The "ignore all whitespace" algorithm depends on rediffing the
        // files, and we currently need complete representations of both
        // files to do anything reasonable. If we only have parts of the files,
        // don't use the "ignore all" algorithm.
        if ($ignore_all) {
          $hunks = $changeset->getHunks();
          if (count($hunks) !== 1) {
            $ignore_all = false;
          } else {
            $first_hunk = reset($hunks);
            if ($first_hunk->getOldOffset() != 1 ||
                $first_hunk->getNewOffset() != 1) {
              $ignore_all = false;
            }
          }
        }

        if ($ignore_all) {
          $old_file = $changeset->makeOldFile();
          $new_file = $changeset->makeNewFile();
          if ($old_file == $new_file) {
            // If the old and new files are exactly identical, the synthetic
            // diff below will give us nonsense and whitespace modes are
            // irrelevant anyway. This occurs when you, e.g., copy a file onto
            // itself in Subversion (see T271).
            $ignore_all = false;
          }
        }

        if ($ignore_all) {

          // Huge mess. Generate a "-bw" (ignore all whitespace changes) diff,
          // parse it out, and then play a shell game with the parsed format
          // in process() so we highlight only changed lines but render
          // whitespace differences. If we don't do this, we either fail to
          // render whitespace changes (which is incredibly confusing,
          // especially for python) or often produce a much larger set of
          // differences than necessary.

          $engine = new PhabricatorDifferenceEngine();
          $engine->setIgnoreWhitespace(true);
          $no_whitespace_changeset = $engine->generateChangesetFromFileContent(
            $old_file,
            $new_file);

          // subparser takes over the current non-whitespace-ignoring changeset
          $subparser = new DifferentialChangesetParser();
          $subparser->isSubparser = true;
          $subparser->setChangeset($changeset);
          foreach ($changeset->getHunks() as $hunk) {
            $subparser->parseHunk($hunk);
          }
          // We need to call process() so that the subparser's values for
          // metadata (like 'unchanged') is correct.
          $subparser->process();

          $this->subparser = $subparser;

          // While we aren't updating $this->changeset (since it has a bunch
          // of metadata we need to preserve, so that headers like "this file
          // was moved" render correctly), we're overwriting the local
          // $changeset so that the block below will choose the synthetic
          // hunks we've built instead of the original hunks.
          $changeset = $no_whitespace_changeset;
        }

        // This either uses the real hunks, or synthetic hunks we built above.
        foreach ($changeset->getHunks() as $hunk) {
          $this->parseHunk($hunk);
        }
        $this->process();
        if (!$skip_cache) {
          $this->saveCache();
        }
      }
    }
  }

  private function shouldRenderPropertyChangeHeader($changeset) {
    if (!$this->isTopLevel) {
      // We render properties only at top level; otherwise we get multiple
      // copies of them when a user clicks "Show More".
      return false;
    }

    $old = $changeset->getOldProperties();
    $new = $changeset->getNewProperties();

    if ($old === $new) {
      return false;
    }

    if ($changeset->getChangeType() == DifferentialChangeType::TYPE_ADD &&
        $new == array('unix:filemode' => '100644')) {
      return false;
    }

    if ($changeset->getChangeType() == DifferentialChangeType::TYPE_DELETE &&
        $old == array('unix:filemode' => '100644')) {
      return false;
     }

    return true;
  }

  public function render(
    $range_start  = null,
    $range_len    = null,
    $mask_force   = array()) {

    // "Top level" renders are initial requests for the whole file, versus
    // requests for a specific range generated by clicking "show more". We
    // generate property changes and "shield" UI elements only for toplevel
    // requests.
    $this->isTopLevel = (($range_start === null) && ($range_len === null));
    $this->highlightEngine = PhabricatorSyntaxHighlighter::newEngine();
    $this->tryCacheStuff();
    $render_pch = $this->shouldRenderPropertyChangeHeader($this->changeset);

    $renderer = id(new DifferentialChangesetTwoUpRenderer())
      ->setChangeset($this->changeset)
      ->setRenderPropertyChangeHeader($render_pch)
      ->setOldLines($this->old)
      ->setNewLines($this->new)
      ->setOldRender($this->oldRender)
      ->setNewRender($this->newRender)
      ->setMissingOldLines($this->missingOld)
      ->setMissingNewLines($this->missingNew)
      ->setVisibleLines($this->visible)
      ->setOldChangesetID($this->leftSideChangesetID)
      ->setNewChangesetID($this->rightSideChangesetID)
      ->setOldAttachesToNewFile($this->leftSideAttachesToNewFile)
      ->setNewAttachesToNewFile($this->rightSideAttachesToNewFile)
      ->setLinesOfContext(self::LINES_CONTEXT)
      ->setCodeCoverage($this->coverage)
      ->setRenderingReference($this->getRenderingReference())
      ->setMarkupEngine($this->markupEngine)
      ->setHandles($this->handles);

    if ($this->user) {
      $renderer->setUser($this->user);
    }

    $shield = null;
    if ($this->isTopLevel && !$this->comments) {
      if ($this->isGenerated()) {
        $shield = $renderer->renderShield(
          pht(
            'This file contains generated code, which does not normally '.
{'fraction_non_alphanumeric': 0.1416957785105619, 'fraction_numerical': 0.0013386006725652159, 'mean_word_length': 2.314163600952175, 'pattern_counts': {'":': 0, '<': 13, '<?xml version=': 0, '>': 496, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '6203377', 'n_tokens_mistral': 19085, 'n_tokens_neox': 18454, 'n_words': 4553}