mirror of
https://we.phorge.it/source/phorge.git
synced 2024-11-11 17:32:41 +01:00
752d742085
Auditors: epriestley
1812 lines
53 KiB
PHP
1812 lines
53 KiB
PHP
<?php
|
|
|
|
/*
|
|
* Copyright 2012 Facebook, 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.
|
|
*/
|
|
|
|
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;
|
|
private $user;
|
|
|
|
private $leftSideChangesetID;
|
|
private $leftSideAttachesToNewFile;
|
|
|
|
private $rightSideChangesetID;
|
|
private $rightSideAttachesToNewFile;
|
|
|
|
private $renderingReference;
|
|
private $isSubparser;
|
|
|
|
private $lineWidth = 80;
|
|
private $isTopLevel;
|
|
private $coverage;
|
|
private $markupEngine;
|
|
private $highlightErrors;
|
|
|
|
const CACHE_VERSION = 5;
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Set the character width at which lines will be wrapped. Defaults to 80.
|
|
*
|
|
* @param int Hard-wrap line-width for diff display.
|
|
* @return this
|
|
*/
|
|
public function setLineWidth($width) {
|
|
$this->lineWidth = $width;
|
|
return $this;
|
|
}
|
|
|
|
private function getRenderCacheKey() {
|
|
return $this->renderCacheKey;
|
|
}
|
|
|
|
public function setChangeset($changeset) {
|
|
$this->changeset = $changeset;
|
|
|
|
$this->setFilename($changeset->getFilename());
|
|
$this->setLineWidth($changeset->getWordWrapWidth());
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function setWhitespaceMode($whitespace_mode) {
|
|
$this->whitespaceMode = $whitespace_mode;
|
|
return $this;
|
|
}
|
|
|
|
public function setRenderingReference($ref) {
|
|
$this->renderingReference = $ref;
|
|
return $this;
|
|
}
|
|
|
|
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(PhutilMarkupEngine $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 = str_replace(
|
|
array("\t", "\r\n", "\r"),
|
|
array(' ', "\n", "\n"),
|
|
$lines);
|
|
$lines = explode("\n", $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();
|
|
|
|
$n = 0;
|
|
|
|
$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;
|
|
}
|
|
}
|
|
|
|
$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;
|
|
}
|
|
}
|
|
|
|
// NOTE: Micro-optimize a couple of ipull()s here since it gives us a
|
|
// 10% performance improvement for certain types of large diffs like
|
|
// Phriction changes.
|
|
|
|
$old_corpus = array();
|
|
foreach ($this->old as $o) {
|
|
if ($o['type'] != '\\') {
|
|
$old_corpus[] = $o['text'];
|
|
}
|
|
}
|
|
$old_corpus_block = implode("\n", $old_corpus);
|
|
|
|
$new_corpus = array();
|
|
foreach ($this->new as $n) {
|
|
if ($n['type'] != '\\') {
|
|
$new_corpus[] = $n['text'];
|
|
}
|
|
}
|
|
$new_corpus_block = implode("\n", $new_corpus);
|
|
|
|
$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);
|
|
|
|
$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 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;
|
|
}
|
|
|
|
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);
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
public function getLength() {
|
|
return max(count($this->old), count($this->new));
|
|
}
|
|
|
|
protected function applyIntraline(&$render, $intra, $corpus) {
|
|
|
|
$line_break = "<span class=\"over-the-line\">\xE2\xAC\x85</span><br />";
|
|
|
|
foreach ($render as $key => $text) {
|
|
if (isset($intra[$key])) {
|
|
$render[$key] = ArcanistDiffUtils::applyIntralineDiff(
|
|
$text,
|
|
$intra[$key]);
|
|
}
|
|
if (isset($corpus[$key]) && strlen($corpus[$key]) > $this->lineWidth) {
|
|
$lines = phutil_utf8_hard_wrap_html($render[$key], $this->lineWidth);
|
|
$render[$key] = implode($line_break, $lines);
|
|
}
|
|
}
|
|
}
|
|
|
|
protected function getHighlightFuture($corpus) {
|
|
return $this->highlightEngine->getHighlightFuture(
|
|
$this->highlightEngine->getLanguageFromFilename($this->filename),
|
|
$corpus);
|
|
}
|
|
|
|
protected function processHighlightedSource($data, $result) {
|
|
|
|
$result_lines = explode("\n", $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) {
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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();
|
|
|
|
$feedback_mask = array();
|
|
|
|
switch ($this->changeset->getFileType()) {
|
|
case DifferentialChangeType::FILE_IMAGE:
|
|
$old = null;
|
|
$cur = null;
|
|
// TODO: Improve the architectural issue as discussed in D955
|
|
// https://secure.phabricator.com/D955
|
|
$reference = $this->renderingReference;
|
|
$parts = explode('/', $reference);
|
|
if (count($parts) == 2) {
|
|
list($id, $vs) = $parts;
|
|
} else {
|
|
$id = $parts[0];
|
|
$vs = 0;
|
|
}
|
|
$id = (int)$id;
|
|
$vs = (int)$vs;
|
|
|
|
if (!$vs) {
|
|
$metadata = $this->changeset->getMetadata();
|
|
$data = idx($metadata, 'attachment-data');
|
|
|
|
$old_phid = idx($metadata, 'old:binary-phid');
|
|
$new_phid = idx($metadata, 'new:binary-phid');
|
|
} else {
|
|
$vs_changeset = id(new DifferentialChangeset())->load($vs);
|
|
$vs_metadata = $vs_changeset->getMetadata();
|
|
$old_phid = idx($vs_metadata, 'new:binary-phid');
|
|
|
|
$changeset = id(new DifferentialChangeset())->load($id);
|
|
$metadata = $changeset->getMetadata();
|
|
$new_phid = idx($metadata, 'new:binary-phid');
|
|
}
|
|
|
|
if ($old_phid || $new_phid) {
|
|
|
|
// grab the files, (micro) optimization for 1 query not 2
|
|
$file_phids = array();
|
|
if ($old_phid) {
|
|
$file_phids[] = $old_phid;
|
|
}
|
|
if ($new_phid) {
|
|
$file_phids[] = $new_phid;
|
|
}
|
|
|
|
$files = id(new PhabricatorFile())->loadAllWhere(
|
|
'phid IN (%Ls)',
|
|
$file_phids);
|
|
|
|
foreach ($files as $file) {
|
|
if (empty($file)) {
|
|
continue;
|
|
}
|
|
if ($file->getPHID() == $old_phid) {
|
|
$old = phutil_render_tag(
|
|
'img',
|
|
array(
|
|
'src' => $file->getBestURI(),
|
|
));
|
|
} else {
|
|
$cur = phutil_render_tag(
|
|
'img',
|
|
array(
|
|
'src' => $file->getBestURI(),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->comments = msort($this->comments, 'getID');
|
|
$old_comments = array();
|
|
$new_comments = array();
|
|
foreach ($this->comments as $comment) {
|
|
if ($this->isCommentOnRightSideWhenDisplayed($comment)) {
|
|
$new_comments[] = $comment;
|
|
} else {
|
|
$old_comments[] = $comment;
|
|
}
|
|
}
|
|
|
|
$html_old = array();
|
|
$html_new = array();
|
|
foreach ($old_comments as $comment) {
|
|
$xhp = $this->renderInlineComment($comment);
|
|
$html_old[] =
|
|
'<tr class="inline"><th /><td>'.
|
|
$xhp.
|
|
'</td><th /><td /></tr>';
|
|
}
|
|
foreach ($new_comments as $comment) {
|
|
$xhp = $this->renderInlineComment($comment);
|
|
$html_new[] =
|
|
'<tr class="inline"><th /><td /><th /><td>'.
|
|
$xhp.
|
|
'</td></tr>';
|
|
}
|
|
|
|
if (!$old) {
|
|
$th_old = '<th></th>';
|
|
}
|
|
else {
|
|
$th_old = '<th id="C'.$vs.'OL1">1</th>';
|
|
}
|
|
if (!$cur) {
|
|
$th_new = '<th></th>';
|
|
}
|
|
else {
|
|
$th_new = '<th id="C'.$id.'NL1">1</th>';
|
|
}
|
|
|
|
$output = $this->renderChangesetTable(
|
|
$this->changeset,
|
|
'<tr>'.
|
|
$th_old.
|
|
'<td class="differential-old-image">'.
|
|
'<div class="differential-image-stage">'.
|
|
$old.
|
|
'</div>'.
|
|
'</td>'.
|
|
$th_new.
|
|
'<td class="differential-new-image">'.
|
|
'<div class="differential-image-stage">'.
|
|
$cur.
|
|
'</div>'.
|
|
'</td>'.
|
|
'</tr>'.
|
|
implode('', $html_old).
|
|
implode('', $html_new));
|
|
|
|
return $output;
|
|
case DifferentialChangeType::FILE_DIRECTORY:
|
|
case DifferentialChangeType::FILE_BINARY:
|
|
$output = $this->renderChangesetTable($this->changeset, null);
|
|
return $output;
|
|
}
|
|
|
|
$shield = null;
|
|
if ($this->isTopLevel && !$this->comments) {
|
|
if ($this->isGenerated()) {
|
|
$shield = $this->renderShield(
|
|
"This file contains generated code, which does not normally need ".
|
|
"to be reviewed.",
|
|
true);
|
|
} else if ($this->isUnchanged()) {
|
|
if ($this->isWhitespaceOnly()) {
|
|
$shield = $this->renderShield(
|
|
"This file was changed only by adding or removing trailing ".
|
|
"whitespace.",
|
|
false);
|
|
} else {
|
|
$shield = $this->renderShield(
|
|
"The contents of this file were not changed.",
|
|
false);
|
|
}
|
|
} else if ($this->isDeleted()) {
|
|
$shield = $this->renderShield(
|
|
"This file was completely deleted.",
|
|
true);
|
|
} else if ($this->changeset->getAffectedLineCount() > 2500) {
|
|
$lines = number_format($this->changeset->getAffectedLineCount());
|
|
$shield = $this->renderShield(
|
|
"This file has a very large number of changes ({$lines} lines).",
|
|
true);
|
|
}
|
|
}
|
|
|
|
if ($shield) {
|
|
return $this->renderChangesetTable($this->changeset, $shield);
|
|
}
|
|
|
|
$old_comments = array();
|
|
$new_comments = array();
|
|
|
|
$old_mask = array();
|
|
$new_mask = array();
|
|
$feedback_mask = array();
|
|
|
|
if ($this->comments) {
|
|
foreach ($this->comments as $comment) {
|
|
$start = max($comment->getLineNumber() - self::LINES_CONTEXT, 0);
|
|
$end = $comment->getLineNumber() +
|
|
$comment->getLineLength() +
|
|
self::LINES_CONTEXT;
|
|
$new = $this->isCommentOnRightSideWhenDisplayed($comment);
|
|
for ($ii = $start; $ii <= $end; $ii++) {
|
|
if ($new) {
|
|
$new_mask[$ii] = true;
|
|
} else {
|
|
$old_mask[$ii] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach ($this->old as $ii => $old) {
|
|
if (isset($old['line']) && isset($old_mask[$old['line']])) {
|
|
$feedback_mask[$ii] = true;
|
|
}
|
|
}
|
|
|
|
foreach ($this->new as $ii => $new) {
|
|
if (isset($new['line']) && isset($new_mask[$new['line']])) {
|
|
$feedback_mask[$ii] = true;
|
|
}
|
|
}
|
|
$this->comments = msort($this->comments, 'getID');
|
|
foreach ($this->comments as $comment) {
|
|
$final = $comment->getLineNumber() +
|
|
$comment->getLineLength();
|
|
if ($this->isCommentOnRightSideWhenDisplayed($comment)) {
|
|
$new_comments[$final][] = $comment;
|
|
} else {
|
|
$old_comments[$final][] = $comment;
|
|
}
|
|
}
|
|
}
|
|
|
|
$html = $this->renderTextChange(
|
|
$range_start,
|
|
$range_len,
|
|
$mask_force,
|
|
$feedback_mask,
|
|
$old_comments,
|
|
$new_comments);
|
|
|
|
return $this->renderChangesetTable($this->changeset, $html);
|
|
}
|
|
|
|
/**
|
|
* Determine if an inline comment will appear on the rendered diff,
|
|
* taking into consideration which halves of which changesets will actually
|
|
* be shown.
|
|
*
|
|
* @param PhabricatorInlineCommentInterface Comment to test for visibility.
|
|
* @return bool True if the comment is visible on the rendered diff.
|
|
*/
|
|
private function isCommentVisibleOnRenderedDiff(
|
|
PhabricatorInlineCommentInterface $comment) {
|
|
|
|
$changeset_id = $comment->getChangesetID();
|
|
$is_new = $comment->getIsNewFile();
|
|
|
|
if ($changeset_id == $this->rightSideChangesetID &&
|
|
$is_new == $this->rightSideAttachesToNewFile) {
|
|
return true;
|
|
}
|
|
|
|
if ($changeset_id == $this->leftSideChangesetID &&
|
|
$is_new == $this->leftSideAttachesToNewFile) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
|
|
/**
|
|
* Determine if a comment will appear on the right side of the display diff.
|
|
* Note that the comment must appear somewhere on the rendered changeset, as
|
|
* per isCommentVisibleOnRenderedDiff().
|
|
*
|
|
* @param PhabricatorInlineCommentInterface Comment to test for display
|
|
* location.
|
|
* @return bool True for right, false for left.
|
|
*/
|
|
private function isCommentOnRightSideWhenDisplayed(
|
|
PhabricatorInlineCommentInterface $comment) {
|
|
|
|
if (!$this->isCommentVisibleOnRenderedDiff($comment)) {
|
|
throw new Exception("Comment is not visible on changeset!");
|
|
}
|
|
|
|
$changeset_id = $comment->getChangesetID();
|
|
$is_new = $comment->getIsNewFile();
|
|
|
|
if ($changeset_id == $this->rightSideChangesetID &&
|
|
$is_new == $this->rightSideAttachesToNewFile) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
protected function renderShield($message, $more) {
|
|
|
|
if ($more) {
|
|
$end = $this->getLength();
|
|
$reference = $this->renderingReference;
|
|
$more =
|
|
' '.
|
|
javelin_render_tag(
|
|
'a',
|
|
array(
|
|
'mustcapture' => true,
|
|
'sigil' => 'show-more',
|
|
'class' => 'complete',
|
|
'href' => '#',
|
|
'meta' => array(
|
|
'ref' => $reference,
|
|
'range' => "0-{$end}",
|
|
),
|
|
),
|
|
'Show File Contents');
|
|
} else {
|
|
$more = null;
|
|
}
|
|
|
|
return javelin_render_tag(
|
|
'tr',
|
|
array(
|
|
'sigil' => 'context-target',
|
|
),
|
|
'<td class="differential-shield" colspan="5">'.
|
|
phutil_escape_html($message).
|
|
$more.
|
|
'</td>');
|
|
}
|
|
|
|
protected function renderTextChange(
|
|
$range_start,
|
|
$range_len,
|
|
$mask_force,
|
|
$feedback_mask,
|
|
array $old_comments,
|
|
array $new_comments) {
|
|
foreach (array_merge($old_comments, $new_comments) as $comments) {
|
|
assert_instances_of($comments, 'PhabricatorInlineCommentInterface');
|
|
}
|
|
|
|
$context_not_available = null;
|
|
if ($this->missingOld || $this->missingNew) {
|
|
$context_not_available = javelin_render_tag(
|
|
'tr',
|
|
array(
|
|
'sigil' => 'context-target',
|
|
),
|
|
'<td colspan="5" class="show-more">'.
|
|
'Context not available.'.
|
|
'</td>');
|
|
}
|
|
|
|
$html = array();
|
|
|
|
$rows = max(
|
|
count($this->old),
|
|
count($this->new));
|
|
|
|
if ($range_start === null) {
|
|
$range_start = 0;
|
|
}
|
|
|
|
if ($range_len === null) {
|
|
$range_len = $rows;
|
|
}
|
|
|
|
$range_len = min($range_len, $rows - $range_start);
|
|
|
|
// Gaps - compute gaps in the visible display diff, where we will render
|
|
// "Show more context" spacers. This builds an aggregate $mask of all the
|
|
// lines we must show (because they are near changed lines, near inline
|
|
// comments, or the request has explicitly asked for them, i.e. resulting
|
|
// from the user clicking "show more") and then finds all the gaps between
|
|
// visible lines. If a gap is smaller than the context size, we just
|
|
// display it. Otherwise, we record it into $gaps and will render a
|
|
// "show more context" element instead of diff text below.
|
|
|
|
$gaps = array();
|
|
$gap_start = 0;
|
|
$in_gap = false;
|
|
$mask = $this->visible + $mask_force + $feedback_mask;
|
|
$mask[$range_start + $range_len] = true;
|
|
for ($ii = $range_start; $ii <= $range_start + $range_len; $ii++) {
|
|
if (isset($mask[$ii])) {
|
|
if ($in_gap) {
|
|
$gap_length = $ii - $gap_start;
|
|
if ($gap_length <= self::LINES_CONTEXT) {
|
|
for ($jj = $gap_start; $jj <= $gap_start + $gap_length; $jj++) {
|
|
$mask[$jj] = true;
|
|
}
|
|
} else {
|
|
$gaps[] = array($gap_start, $gap_length);
|
|
}
|
|
$in_gap = false;
|
|
}
|
|
} else {
|
|
if (!$in_gap) {
|
|
$gap_start = $ii;
|
|
$in_gap = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
$gaps = array_reverse($gaps);
|
|
|
|
$reference = $this->renderingReference;
|
|
|
|
$left_id = $this->leftSideChangesetID;
|
|
$right_id = $this->rightSideChangesetID;
|
|
|
|
// "N" stands for 'new' and means the comment should attach to the new file
|
|
// when stored, i.e. DifferentialInlineComment->setIsNewFile().
|
|
// "O" stands for 'old' and means the comment should attach to the old file.
|
|
|
|
$left_char = $this->leftSideAttachesToNewFile
|
|
? 'N'
|
|
: 'O';
|
|
$right_char = $this->rightSideAttachesToNewFile
|
|
? 'N'
|
|
: 'O';
|
|
|
|
for ($ii = $range_start; $ii < $range_start + $range_len; $ii++) {
|
|
if (empty($mask[$ii])) {
|
|
// If we aren't going to show this line, we've just entered a gap.
|
|
// Pop information about the next gap off the $gaps stack and render
|
|
// an appropriate "Show more context" element. This branch eventually
|
|
// increments $ii by the entire size of the gap and then continues
|
|
// the loop.
|
|
$gap = array_pop($gaps);
|
|
$top = $gap[0];
|
|
$len = $gap[1];
|
|
|
|
$end = $top + $len - 20;
|
|
|
|
$contents = array();
|
|
|
|
if ($len > 40) {
|
|
$is_first_block = false;
|
|
if ($ii == 0) {
|
|
$is_first_block = true;
|
|
}
|
|
|
|
$contents[] = javelin_render_tag(
|
|
'a',
|
|
array(
|
|
'href' => '#',
|
|
'mustcapture' => true,
|
|
'sigil' => 'show-more',
|
|
'meta' => array(
|
|
'ref' => $reference,
|
|
'range' => "{$top}-{$len}/{$top}-20",
|
|
),
|
|
),
|
|
$is_first_block
|
|
? "Show First 20 Lines"
|
|
: "\xE2\x96\xB2 Show 20 Lines");
|
|
}
|
|
|
|
$contents[] = javelin_render_tag(
|
|
'a',
|
|
array(
|
|
'href' => '#',
|
|
'mustcapture' => true,
|
|
'sigil' => 'show-more',
|
|
'meta' => array(
|
|
'type' => 'all',
|
|
'ref' => $reference,
|
|
'range' => "{$top}-{$len}/{$top}-{$len}",
|
|
),
|
|
),
|
|
'Show All '.$len.' Lines');
|
|
|
|
if ($len > 40) {
|
|
$is_last_block = false;
|
|
if ($ii + $len >= $rows) {
|
|
$is_last_block = true;
|
|
}
|
|
|
|
$contents[] = javelin_render_tag(
|
|
'a',
|
|
array(
|
|
'href' => '#',
|
|
'mustcapture' => true,
|
|
'sigil' => 'show-more',
|
|
'meta' => array(
|
|
'ref' => $reference,
|
|
'range' => "{$top}-{$len}/{$end}-20",
|
|
),
|
|
),
|
|
$is_last_block
|
|
? "Show Last 20 Lines"
|
|
: "\xE2\x96\xBC Show 20 Lines");
|
|
};
|
|
|
|
$container = javelin_render_tag(
|
|
'tr',
|
|
array(
|
|
'sigil' => 'context-target',
|
|
),
|
|
'<td colspan="5" class="show-more">'.
|
|
implode(' • ', $contents).
|
|
'</td>');
|
|
|
|
$html[] = $container;
|
|
|
|
$ii += ($len - 1);
|
|
continue;
|
|
}
|
|
|
|
if (isset($this->old[$ii])) {
|
|
$o_num = $this->old[$ii]['line'];
|
|
$o_text = isset($this->oldRender[$ii]) ? $this->oldRender[$ii] : null;
|
|
$o_attr = null;
|
|
if ($this->old[$ii]['type']) {
|
|
if ($this->old[$ii]['type'] == '\\') {
|
|
$o_text = $this->old[$ii]['text'];
|
|
$o_attr = ' class="comment"';
|
|
} else if (empty($this->new[$ii])) {
|
|
$o_attr = ' class="old old-full"';
|
|
} else {
|
|
$o_attr = ' class="old"';
|
|
}
|
|
}
|
|
} else {
|
|
$o_num = null;
|
|
$o_text = null;
|
|
$o_attr = null;
|
|
}
|
|
|
|
|
|
if (isset($this->new[$ii])) {
|
|
$n_num = $this->new[$ii]['line'];
|
|
$n_text = isset($this->newRender[$ii]) ? $this->newRender[$ii] : null;
|
|
$n_attr = null;
|
|
|
|
$cov_class = null;
|
|
if ($this->coverage !== null) {
|
|
if (empty($this->coverage[$n_num - 1])) {
|
|
$cov_class = 'N';
|
|
} else {
|
|
$cov_class = $this->coverage[$n_num - 1];
|
|
}
|
|
$cov_class = 'cov-'.$cov_class;
|
|
}
|
|
|
|
$n_cov = '<td class="cov '.$cov_class.'"></td>';
|
|
|
|
if ($this->new[$ii]['type']) {
|
|
if ($this->new[$ii]['type'] == '\\') {
|
|
$n_text = $this->new[$ii]['text'];
|
|
$n_attr = ' class="comment"';
|
|
} else if (empty($this->old[$ii])) {
|
|
$n_attr = ' class="new new-full"';
|
|
} else {
|
|
$n_attr = ' class="new"';
|
|
}
|
|
}
|
|
} else {
|
|
$n_num = null;
|
|
$n_text = null;
|
|
$n_attr = null;
|
|
$n_cov = null;
|
|
}
|
|
|
|
|
|
if (($o_num && !empty($this->missingOld[$o_num])) ||
|
|
($n_num && !empty($this->missingNew[$n_num]))) {
|
|
$html[] = $context_not_available;
|
|
}
|
|
|
|
if ($o_num && $left_id) {
|
|
$o_id = ' id="C'.$left_id.$left_char.'L'.$o_num.'"';
|
|
} else {
|
|
$o_id = null;
|
|
}
|
|
|
|
if ($n_num && $right_id) {
|
|
$n_id = ' id="C'.$right_id.$right_char.'L'.$n_num.'"';
|
|
} else {
|
|
$n_id = null;
|
|
}
|
|
|
|
// NOTE: The Javascript is sensitive to whitespace changes in this
|
|
// block!
|
|
$html[] =
|
|
'<tr>'.
|
|
'<th'.$o_id.'>'.$o_num.'</th>'.
|
|
'<td'.$o_attr.'>'.$o_text.'</td>'.
|
|
'<th'.$n_id.'>'.$n_num.'</th>'.
|
|
// NOTE: This is a unicode zero-width space, which we use as a hint
|
|
// when intercepting 'copy' events to make sure sensible text ends
|
|
// up on the clipboard. See the 'phabricator-oncopy' behavior.
|
|
'<td'.$n_attr.'>'."\xE2\x80\x8B".$n_text.'</td>'.
|
|
$n_cov.
|
|
'</tr>';
|
|
|
|
if ($context_not_available && ($ii == $rows - 1)) {
|
|
$html[] = $context_not_available;
|
|
}
|
|
|
|
if ($o_num && isset($old_comments[$o_num])) {
|
|
foreach ($old_comments[$o_num] as $comment) {
|
|
$xhp = $this->renderInlineComment($comment);
|
|
$new = '';
|
|
if ($n_num && isset($new_comments[$n_num])) {
|
|
foreach ($new_comments[$n_num] as $key => $new_comment) {
|
|
if ($comment->isCompatible($new_comment)) {
|
|
$new = $this->renderInlineComment($new_comment);
|
|
unset($new_comments[$n_num][$key]);
|
|
}
|
|
}
|
|
}
|
|
$html[] =
|
|
'<tr class="inline"><th /><td>'.
|
|
$xhp.
|
|
'</td><th /><td>'.
|
|
$new.
|
|
'</td><td class="cov" /></tr>';
|
|
}
|
|
}
|
|
if ($n_num && isset($new_comments[$n_num])) {
|
|
foreach ($new_comments[$n_num] as $comment) {
|
|
$xhp = $this->renderInlineComment($comment);
|
|
$html[] =
|
|
'<tr class="inline"><th /><td /><th /><td>'.
|
|
$xhp.
|
|
'</td><td class="cov" /></tr>';
|
|
}
|
|
}
|
|
}
|
|
|
|
return implode('', $html);
|
|
}
|
|
|
|
private function renderInlineComment(
|
|
PhabricatorInlineCommentInterface $comment) {
|
|
|
|
$user = $this->user;
|
|
$edit = $user &&
|
|
($comment->getAuthorPHID() == $user->getPHID()) &&
|
|
($comment->isDraft());
|
|
|
|
$on_right = $this->isCommentOnRightSideWhenDisplayed($comment);
|
|
|
|
return id(new DifferentialInlineCommentView())
|
|
->setInlineComment($comment)
|
|
->setOnRight($on_right)
|
|
->setHandles($this->handles)
|
|
->setMarkupEngine($this->markupEngine)
|
|
->setEditable($edit)
|
|
->render();
|
|
}
|
|
|
|
protected function renderPropertyChangeHeader($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 null;
|
|
}
|
|
|
|
$old = $changeset->getOldProperties();
|
|
$new = $changeset->getNewProperties();
|
|
|
|
if ($old === $new) {
|
|
return null;
|
|
}
|
|
|
|
if ($changeset->getChangeType() == DifferentialChangeType::TYPE_ADD &&
|
|
$new == array('unix:filemode' => '100644')) {
|
|
return null;
|
|
}
|
|
|
|
if ($changeset->getChangeType() == DifferentialChangeType::TYPE_DELETE &&
|
|
$old == array('unix:filemode' => '100644')) {
|
|
return null;
|
|
}
|
|
|
|
$keys = array_keys($old + $new);
|
|
sort($keys);
|
|
|
|
$rows = array();
|
|
foreach ($keys as $key) {
|
|
$oval = idx($old, $key);
|
|
$nval = idx($new, $key);
|
|
if ($oval !== $nval) {
|
|
if ($oval === null) {
|
|
$oval = '<em>null</em>';
|
|
} else {
|
|
$oval = phutil_escape_html($oval);
|
|
}
|
|
|
|
if ($nval === null) {
|
|
$nval = '<em>null</em>';
|
|
} else {
|
|
$nval = phutil_escape_html($nval);
|
|
}
|
|
|
|
$rows[] =
|
|
'<tr>'.
|
|
'<th>'.phutil_escape_html($key).'</th>'.
|
|
'<td class="oval">'.$oval.'</td>'.
|
|
'<td class="nval">'.$nval.'</td>'.
|
|
'</tr>';
|
|
}
|
|
}
|
|
|
|
return
|
|
'<table class="differential-property-table">'.
|
|
'<tr class="property-table-header">'.
|
|
'<th>Property Changes</th>'.
|
|
'<td class="oval">Old Value</td>'.
|
|
'<td class="nval">New Value</td>'.
|
|
'</tr>'.
|
|
implode('', $rows).
|
|
'</table>';
|
|
}
|
|
|
|
protected function renderChangesetTable($changeset, $contents) {
|
|
$props = $this->renderPropertyChangeHeader($this->changeset);
|
|
$table = null;
|
|
if ($contents) {
|
|
$table =
|
|
'<table class="differential-diff remarkup-code PhabricatorMonospaced">'.
|
|
$contents.
|
|
'</table>';
|
|
}
|
|
|
|
if (!$table && !$props) {
|
|
$notice = $this->renderChangeTypeHeader($this->changeset, true);
|
|
} else {
|
|
$notice = $this->renderChangeTypeHeader($this->changeset, false);
|
|
}
|
|
|
|
return implode(
|
|
"\n",
|
|
array(
|
|
$notice,
|
|
$props,
|
|
$table,
|
|
));
|
|
}
|
|
|
|
protected function renderChangeTypeHeader($changeset, $force) {
|
|
|
|
static $articles = array(
|
|
DifferentialChangeType::FILE_IMAGE => 'an',
|
|
);
|
|
|
|
static $files = array(
|
|
DifferentialChangeType::FILE_TEXT => 'file',
|
|
DifferentialChangeType::FILE_IMAGE => 'image',
|
|
DifferentialChangeType::FILE_DIRECTORY => 'directory',
|
|
DifferentialChangeType::FILE_BINARY => 'binary file',
|
|
DifferentialChangeType::FILE_SYMLINK => 'symlink',
|
|
DifferentialChangeType::FILE_SUBMODULE => 'submodule',
|
|
);
|
|
|
|
static $changes = array(
|
|
DifferentialChangeType::TYPE_ADD => 'added',
|
|
DifferentialChangeType::TYPE_CHANGE => 'changed',
|
|
DifferentialChangeType::TYPE_DELETE => 'deleted',
|
|
DifferentialChangeType::TYPE_MOVE_HERE => 'moved from',
|
|
DifferentialChangeType::TYPE_COPY_HERE => 'copied from',
|
|
DifferentialChangeType::TYPE_MOVE_AWAY => 'moved to',
|
|
DifferentialChangeType::TYPE_COPY_AWAY => 'copied to',
|
|
DifferentialChangeType::TYPE_MULTICOPY
|
|
=> 'deleted after being copied to',
|
|
);
|
|
|
|
$change = $changeset->getChangeType();
|
|
$file = $changeset->getFileType();
|
|
|
|
$message = null;
|
|
if ($change == DifferentialChangeType::TYPE_CHANGE &&
|
|
$file == DifferentialChangeType::FILE_TEXT) {
|
|
if ($force) {
|
|
// We have to force something to render because there were no changes
|
|
// of other kinds.
|
|
$message = "This {$files[$file]} was not modified.";
|
|
} else {
|
|
// Default case of changes to a text file, no metadata.
|
|
return null;
|
|
}
|
|
} else {
|
|
$verb = idx($changes, $change, 'changed');
|
|
switch ($change) {
|
|
default:
|
|
$message = "This {$files[$file]} was <strong>{$verb}</strong>.";
|
|
break;
|
|
case DifferentialChangeType::TYPE_MOVE_HERE:
|
|
case DifferentialChangeType::TYPE_COPY_HERE:
|
|
$message =
|
|
"This {$files[$file]} was {$verb} ".
|
|
"<strong>".
|
|
phutil_escape_html($changeset->getOldFile()).
|
|
"</strong>.";
|
|
break;
|
|
case DifferentialChangeType::TYPE_MOVE_AWAY:
|
|
case DifferentialChangeType::TYPE_COPY_AWAY:
|
|
case DifferentialChangeType::TYPE_MULTICOPY:
|
|
$paths = $changeset->getAwayPaths();
|
|
if (count($paths) > 1) {
|
|
$message =
|
|
"This {$files[$file]} was {$verb}: ".
|
|
"<strong>".phutil_escape_html(implode(', ', $paths))."</strong>.";
|
|
} else {
|
|
$message =
|
|
"This {$files[$file]} was {$verb} ".
|
|
"<strong>".phutil_escape_html(reset($paths))."</strong>.";
|
|
}
|
|
break;
|
|
case DifferentialChangeType::TYPE_CHANGE:
|
|
$message = "This is ".idx($articles, $file, 'a')." {$files[$file]}.";
|
|
break;
|
|
}
|
|
}
|
|
|
|
return
|
|
'<div class="differential-meta-notice">'.
|
|
$message.
|
|
'</div>';
|
|
}
|
|
|
|
public function renderForEmail() {
|
|
$ret = '';
|
|
|
|
$min = min(count($this->old), count($this->new));
|
|
for ($i = 0; $i < $min; $i++) {
|
|
$o = $this->old[$i];
|
|
$n = $this->new[$i];
|
|
|
|
if (!isset($this->visible[$i])) {
|
|
continue;
|
|
}
|
|
|
|
if ($o['line'] && $n['line']) {
|
|
// It is quite possible there are better ways to achieve this. For
|
|
// example, "white-space: pre;" can do a better job, WERE IT NOT for
|
|
// broken email clients like OWA which use newlines to do weird
|
|
// wrapping. So dont give them newlines.
|
|
if (isset($this->intra[$i])) {
|
|
$ret .= sprintf(
|
|
"<font color=\"red\">- %s</font><br/>",
|
|
str_replace(" ", " ", phutil_escape_html($o['text']))
|
|
);
|
|
$ret .= sprintf(
|
|
"<font color=\"green\">+ %s</font><br/>",
|
|
str_replace(" ", " ", phutil_escape_html($n['text']))
|
|
);
|
|
} else {
|
|
$ret .= sprintf(" %s<br/>",
|
|
str_replace(" ", " ", phutil_escape_html($n['text']))
|
|
);
|
|
}
|
|
} else if ($o['line'] && !$n['line']) {
|
|
$ret .= sprintf(
|
|
"<font color=\"red\">- %s</font><br/>",
|
|
str_replace(" ", " ", phutil_escape_html($o['text']))
|
|
);
|
|
} else {
|
|
$ret .= sprintf(
|
|
"<font color=\"green\">+ %s</font><br/>",
|
|
str_replace(" ", " ", phutil_escape_html($n['text']))
|
|
);
|
|
}
|
|
}
|
|
|
|
return $ret;
|
|
}
|
|
|
|
/**
|
|
* Parse the 'range' specification that this class and the client-side JS
|
|
* emit to indicate that a user clicked "Show more..." on a diff. Generally,
|
|
* use is something like this:
|
|
*
|
|
* $spec = $request->getStr('range');
|
|
* $parsed = DifferentialChangesetParser::parseRangeSpecification($spec);
|
|
* list($start, $end, $mask) = $parsed;
|
|
* $parser->render($start, $end, $mask);
|
|
*
|
|
* @param string Range specification, indicating the range of the diff that
|
|
* should be rendered.
|
|
* @return tuple List of <start, end, mask> suitable for passing to
|
|
* @{method:render}.
|
|
*/
|
|
public static function parseRangeSpecification($spec) {
|
|
$range_s = null;
|
|
$range_e = null;
|
|
$mask = array();
|
|
|
|
if ($spec) {
|
|
$match = null;
|
|
if (preg_match('@^(\d+)-(\d+)(?:/(\d+)-(\d+))?$@', $spec, $match)) {
|
|
$range_s = (int)$match[1];
|
|
$range_e = (int)$match[2];
|
|
if (count($match) > 3) {
|
|
$start = (int)$match[3];
|
|
$len = (int)$match[4];
|
|
for ($ii = $start; $ii < $start + $len; $ii++) {
|
|
$mask[$ii] = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return array($range_s, $range_e, $mask);
|
|
}
|
|
|
|
/**
|
|
* Render "modified coverage" information; test coverage on modified lines.
|
|
* This synthesizes diff information with unit test information into a useful
|
|
* indicator of how well tested a change is.
|
|
*/
|
|
public function renderModifiedCoverage() {
|
|
$na = '<em>-</em>';
|
|
|
|
if (!$this->coverage) {
|
|
return $na;
|
|
}
|
|
|
|
$covered = 0;
|
|
$not_covered = 0;
|
|
|
|
foreach ($this->new as $k => $new) {
|
|
if (!$new['line']) {
|
|
continue;
|
|
}
|
|
|
|
if (!$new['type']) {
|
|
continue;
|
|
}
|
|
|
|
if (empty($this->coverage[$new['line'] - 1])) {
|
|
continue;
|
|
}
|
|
|
|
switch ($this->coverage[$new['line'] - 1]) {
|
|
case 'C':
|
|
$covered++;
|
|
break;
|
|
case 'U':
|
|
$not_covered++;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$covered && !$not_covered) {
|
|
return $na;
|
|
}
|
|
|
|
return sprintf('%d%%', 100 * ($covered / ($covered + $not_covered)));
|
|
}
|
|
|
|
}
|