diff --git a/src/applications/differential/controller/DifferentialChangesetViewController.php b/src/applications/differential/controller/DifferentialChangesetViewController.php index fe6163feb9..f2558ffa1d 100644 --- a/src/applications/differential/controller/DifferentialChangesetViewController.php +++ b/src/applications/differential/controller/DifferentialChangesetViewController.php @@ -1,464 +1,465 @@ <?php final class DifferentialChangesetViewController extends DifferentialController { public function shouldAllowPublic() { return true; } public function handleRequest(AphrontRequest $request) { $viewer = $this->getViewer(); $rendering_reference = $request->getStr('ref'); $parts = explode('/', $rendering_reference); if (count($parts) == 2) { list($id, $vs) = $parts; } else { $id = $parts[0]; $vs = 0; } $id = (int)$id; $vs = (int)$vs; $load_ids = array($id); if ($vs && ($vs != -1)) { $load_ids[] = $vs; } $changesets = id(new DifferentialChangesetQuery()) ->setViewer($viewer) ->withIDs($load_ids) ->needHunks(true) ->execute(); $changesets = mpull($changesets, null, 'getID'); $changeset = idx($changesets, $id); if (!$changeset) { return new Aphront404Response(); } $vs_changeset = null; if ($vs && ($vs != -1)) { $vs_changeset = idx($changesets, $vs); if (!$vs_changeset) { return new Aphront404Response(); } } $view = $request->getStr('view'); if ($view) { $phid = idx($changeset->getMetadata(), "$view:binary-phid"); if ($phid) { return id(new AphrontRedirectResponse())->setURI("/file/info/$phid/"); } switch ($view) { case 'new': return $this->buildRawFileResponse($changeset, $is_new = true); case 'old': if ($vs_changeset) { return $this->buildRawFileResponse($vs_changeset, $is_new = true); } return $this->buildRawFileResponse($changeset, $is_new = false); default: return new Aphront400Response(); } } $old = array(); $new = array(); if (!$vs) { $right = $changeset; $left = null; $right_source = $right->getID(); $right_new = true; $left_source = $right->getID(); $left_new = false; $render_cache_key = $right->getID(); $old[] = $changeset; $new[] = $changeset; } else if ($vs == -1) { $right = null; $left = $changeset; $right_source = $left->getID(); $right_new = false; $left_source = $left->getID(); $left_new = true; $render_cache_key = null; $old[] = $changeset; $new[] = $changeset; } else { $right = $changeset; $left = $vs_changeset; $right_source = $right->getID(); $right_new = true; $left_source = $left->getID(); $left_new = true; $render_cache_key = null; $new[] = $left; $new[] = $right; } if ($left) { $left_data = $left->makeNewFile(); $left_properties = $left->getNewProperties(); if ($right) { $right_data = $right->makeNewFile(); $right_properties = $right->getNewProperties(); } else { $right_data = $left->makeOldFile(); $right_properties = $left->getOldProperties(); } $engine = new PhabricatorDifferenceEngine(); $synthetic = $engine->generateChangesetFromFileContent( $left_data, $right_data); $choice = clone nonempty($left, $right); $choice->attachHunks($synthetic->getHunks()); $choice->setOldProperties($left_properties); $choice->setNewProperties($right_properties); $changeset = $choice; } if ($left_new || $right_new) { $diff_map = array(); if ($left) { $diff_map[] = $left->getDiff(); } if ($right) { $diff_map[] = $right->getDiff(); } $diff_map = mpull($diff_map, null, 'getPHID'); $buildables = id(new HarbormasterBuildableQuery()) ->setViewer($viewer) ->withBuildablePHIDs(array_keys($diff_map)) ->withManualBuildables(false) ->needBuilds(true) ->needTargets(true) ->execute(); $buildables = mpull($buildables, null, 'getBuildablePHID'); foreach ($diff_map as $diff_phid => $changeset_diff) { $changeset_diff->attachBuildable(idx($buildables, $diff_phid)); } } $coverage = null; if ($right_new) { $coverage = $this->loadCoverage($right); } $spec = $request->getStr('range'); list($range_s, $range_e, $mask) = DifferentialChangesetParser::parseRangeSpecification($spec); $parser = id(new DifferentialChangesetParser()) + ->setViewer($viewer) ->setCoverage($coverage) ->setChangeset($changeset) ->setRenderingReference($rendering_reference) ->setRenderCacheKey($render_cache_key) ->setRightSideCommentMapping($right_source, $right_new) ->setLeftSideCommentMapping($left_source, $left_new); $parser->readParametersFromRequest($request); if ($left && $right) { $parser->setOriginals($left, $right); } $diff = $changeset->getDiff(); $revision_id = $diff->getRevisionID(); $can_mark = false; $object_owner_phid = null; $revision = null; if ($revision_id) { $revision = id(new DifferentialRevisionQuery()) ->setViewer($viewer) ->withIDs(array($revision_id)) ->executeOne(); if ($revision) { $can_mark = ($revision->getAuthorPHID() == $viewer->getPHID()); $object_owner_phid = $revision->getAuthorPHID(); } } // Load both left-side and right-side inline comments. if ($revision) { $query = id(new DifferentialInlineCommentQuery()) ->setViewer($viewer) ->needHidden(true) ->withRevisionPHIDs(array($revision->getPHID())); $inlines = $query->execute(); $inlines = $query->adjustInlinesForChangesets( $inlines, $old, $new, $revision); } else { $inlines = array(); } if ($left_new) { $inlines = array_merge( $inlines, $this->buildLintInlineComments($left)); } if ($right_new) { $inlines = array_merge( $inlines, $this->buildLintInlineComments($right)); } $phids = array(); foreach ($inlines as $inline) { $parser->parseInlineComment($inline); if ($inline->getAuthorPHID()) { $phids[$inline->getAuthorPHID()] = true; } } $phids = array_keys($phids); $handles = $this->loadViewerHandles($phids); $parser->setHandles($handles); $engine = new PhabricatorMarkupEngine(); $engine->setViewer($viewer); foreach ($inlines as $inline) { $engine->addObject( $inline, PhabricatorInlineCommentInterface::MARKUP_FIELD_BODY); } $engine->process(); $parser ->setUser($viewer) ->setMarkupEngine($engine) ->setShowEditAndReplyLinks(true) ->setCanMarkDone($can_mark) ->setObjectOwnerPHID($object_owner_phid) ->setRange($range_s, $range_e) ->setMask($mask); if ($request->isAjax()) { // NOTE: We must render the changeset before we render coverage // information, since it builds some caches. $rendered_changeset = $parser->renderChangeset(); $mcov = $parser->renderModifiedCoverage(); $coverage_data = array( 'differential-mcoverage-'.md5($changeset->getFilename()) => $mcov, ); return id(new PhabricatorChangesetResponse()) ->setRenderedChangeset($rendered_changeset) ->setCoverage($coverage_data) ->setUndoTemplates($parser->getRenderer()->renderUndoTemplates()); } $detail = id(new DifferentialChangesetListView()) ->setUser($this->getViewer()) ->setChangesets(array($changeset)) ->setVisibleChangesets(array($changeset)) ->setRenderingReferences(array($rendering_reference)) ->setRenderURI('/differential/changeset/') ->setDiff($diff) ->setTitle(pht('Standalone View')) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setIsStandalone(true) ->setParser($parser); if ($revision_id) { $detail->setInlineCommentControllerURI( '/differential/comment/inline/edit/'.$revision_id.'/'); } $crumbs = $this->buildApplicationCrumbs(); if ($revision_id) { $crumbs->addTextCrumb('D'.$revision_id, '/D'.$revision_id); } $diff_id = $diff->getID(); if ($diff_id) { $crumbs->addTextCrumb( pht('Diff %d', $diff_id), $this->getApplicationURI('diff/'.$diff_id)); } $crumbs->addTextCrumb($changeset->getDisplayFilename()); $crumbs->setBorder(true); $header = id(new PHUIHeaderView()) ->setHeader(pht('Changeset View')) ->setHeaderIcon('fa-gear'); $view = id(new PHUITwoColumnView()) ->setHeader($header) ->setFooter($detail); return $this->newPage() ->setTitle(pht('Changeset View')) ->setCrumbs($crumbs) ->appendChild($view); } private function buildRawFileResponse( DifferentialChangeset $changeset, $is_new) { $viewer = $this->getViewer(); if ($is_new) { $key = 'raw:new:phid'; } else { $key = 'raw:old:phid'; } $metadata = $changeset->getMetadata(); $file = null; $phid = idx($metadata, $key); if ($phid) { $file = id(new PhabricatorFileQuery()) ->setViewer($viewer) ->withPHIDs(array($phid)) ->execute(); if ($file) { $file = head($file); } } if (!$file) { // This is just building a cache of the changeset content in the file // tool, and is safe to run on a read pathway. $unguard = AphrontWriteGuard::beginScopedUnguardedWrites(); if ($is_new) { $data = $changeset->makeNewFile(); } else { $data = $changeset->makeOldFile(); } $diff = $changeset->getDiff(); $file = PhabricatorFile::newFromFileData( $data, array( 'name' => $changeset->getFilename(), 'mime-type' => 'text/plain', 'ttl.relative' => phutil_units('24 hours in seconds'), 'viewPolicy' => PhabricatorPolicies::POLICY_NOONE, )); $file->attachToObject($diff->getPHID()); $metadata[$key] = $file->getPHID(); $changeset->setMetadata($metadata); $changeset->save(); unset($unguard); } return $file->getRedirectResponse(); } private function buildLintInlineComments($changeset) { $diff = $changeset->getDiff(); $target_phids = $diff->getBuildTargetPHIDs(); if (!$target_phids) { return array(); } $messages = id(new HarbormasterBuildLintMessage())->loadAllWhere( 'buildTargetPHID IN (%Ls) AND path = %s', $target_phids, $changeset->getFilename()); if (!$messages) { return array(); } $change_type = $changeset->getChangeType(); if (DifferentialChangeType::isDeleteChangeType($change_type)) { // If this is a lint message on a deleted file, show it on the left // side of the UI because there are no source code lines on the right // side of the UI so inlines don't have anywhere to render. See PHI416. $is_new = 0; } else { $is_new = 1; } $template = id(new DifferentialInlineComment()) ->setChangesetID($changeset->getID()) ->setIsNewFile($is_new) ->setLineLength(0); $inlines = array(); foreach ($messages as $message) { $description = $message->getProperty('description'); $inlines[] = id(clone $template) ->setSyntheticAuthor(pht('Lint: %s', $message->getName())) ->setLineNumber($message->getLine()) ->setContent($description); } return $inlines; } private function loadCoverage(DifferentialChangeset $changeset) { $viewer = $this->getViewer(); $target_phids = $changeset->getDiff()->getBuildTargetPHIDs(); if (!$target_phids) { return null; } $unit = id(new HarbormasterBuildUnitMessageQuery()) ->setViewer($viewer) ->withBuildTargetPHIDs($target_phids) ->execute(); if (!$unit) { return null; } $coverage = array(); foreach ($unit as $message) { $test_coverage = $message->getProperty('coverage'); if ($test_coverage === null) { continue; } $coverage_data = idx($test_coverage, $changeset->getFileName()); if (!strlen($coverage_data)) { continue; } $coverage[] = $coverage_data; } if (!$coverage) { return null; } return ArcanistUnitTestResult::mergeCoverage($coverage); } } diff --git a/src/applications/differential/parser/DifferentialChangesetParser.php b/src/applications/differential/parser/DifferentialChangesetParser.php index 9843ff3d5d..058fc9c766 100644 --- a/src/applications/differential/parser/DifferentialChangesetParser.php +++ b/src/applications/differential/parser/DifferentialChangesetParser.php @@ -1,1678 +1,1746 @@ <?php final class DifferentialChangesetParser extends Phobject { const HIGHLIGHT_BYTE_LIMIT = 262144; protected $visible = array(); protected $new = array(); protected $old = array(); protected $intra = array(); protected $depthOnlyLines = array(); protected $newRender = null; protected $oldRender = null; protected $filename = null; protected $hunkStartLines = array(); protected $comments = array(); protected $specialAttributes = array(); protected $changeset; 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; private $disableCache; private $renderer; private $characterEncoding; private $highlightAs; private $highlightingDisabled; private $showEditAndReplyLinks = true; private $canMarkDone; private $objectOwnerPHID; private $offsetMode; private $rangeStart; private $rangeEnd; private $mask; private $linesOfContext = 8; private $highlightEngine; + private $viewer; public function setRange($start, $end) { $this->rangeStart = $start; $this->rangeEnd = $end; return $this; } public function setMask(array $mask) { $this->mask = $mask; return $this; } public function renderChangeset() { return $this->render($this->rangeStart, $this->rangeEnd, $this->mask); } public function setShowEditAndReplyLinks($bool) { $this->showEditAndReplyLinks = $bool; return $this; } public function getShowEditAndReplyLinks() { return $this->showEditAndReplyLinks; } public function setHighlightAs($highlight_as) { $this->highlightAs = $highlight_as; return $this; } public function getHighlightAs() { return $this->highlightAs; } public function setCharacterEncoding($character_encoding) { $this->characterEncoding = $character_encoding; return $this; } public function getCharacterEncoding() { return $this->characterEncoding; } public function setRenderer(DifferentialChangesetRenderer $renderer) { $this->renderer = $renderer; return $this; } public function getRenderer() { if (!$this->renderer) { return new DifferentialChangesetTwoUpRenderer(); } return $this->renderer; } public function setDisableCache($disable_cache) { $this->disableCache = $disable_cache; return $this; } public function getDisableCache() { return $this->disableCache; } public function setCanMarkDone($can_mark_done) { $this->canMarkDone = $can_mark_done; return $this; } public function getCanMarkDone() { return $this->canMarkDone; } public function setObjectOwnerPHID($phid) { $this->objectOwnerPHID = $phid; return $this; } public function getObjectOwnerPHID() { return $this->objectOwnerPHID; } public function setOffsetMode($offset_mode) { $this->offsetMode = $offset_mode; return $this; } public function getOffsetMode() { return $this->offsetMode; } + public function setViewer(PhabricatorUser $viewer) { + $this->viewer = $viewer; + return $this; + } + + public function getViewer() { + return $this->viewer; + } + public static function getDefaultRendererForViewer(PhabricatorUser $viewer) { $is_unified = $viewer->compareUserSetting( PhabricatorUnifiedDiffsSetting::SETTINGKEY, PhabricatorUnifiedDiffsSetting::VALUE_ALWAYS_UNIFIED); if ($is_unified) { return '1up'; } return null; } public function readParametersFromRequest(AphrontRequest $request) { $this->setCharacterEncoding($request->getStr('encoding')); $this->setHighlightAs($request->getStr('highlight')); $renderer = null; // If the viewer prefers unified diffs, always set the renderer to unified. // Otherwise, we leave it unspecified and the client will choose a // renderer based on the screen size. if ($request->getStr('renderer')) { $renderer = $request->getStr('renderer'); } else { $renderer = self::getDefaultRendererForViewer($request->getViewer()); } switch ($renderer) { case '1up': $this->setRenderer(new DifferentialChangesetOneUpRenderer()); break; default: $this->setRenderer(new DifferentialChangesetTwoUpRenderer()); break; } return $this; } const CACHE_VERSION = 14; const CACHE_MAX_SIZE = 8e6; const ATTR_GENERATED = 'attr:generated'; const ATTR_DELETED = 'attr:deleted'; const ATTR_UNCHANGED = 'attr:unchanged'; const ATTR_MOVEAWAY = 'attr:moveaway'; public function setOldLines(array $lines) { $this->old = $lines; return $this; } public function setNewLines(array $lines) { $this->new = $lines; return $this; } public function setSpecialAttributes(array $attributes) { $this->specialAttributes = $attributes; return $this; } public function setIntraLineDiffs(array $diffs) { $this->intra = $diffs; return $this; } public function setDepthOnlyLines(array $lines) { $this->depthOnlyLines = $lines; return $this; } public function getDepthOnlyLines() { return $this->depthOnlyLines; } public function setVisibileLinesMask(array $mask) { $this->visible = $mask; return $this; } public function setLinesOfContext($lines_of_context) { $this->linesOfContext = $lines_of_context; return $this; } public function getLinesOfContext() { return $this->linesOfContext; } /** * 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; return $this; } public function diffOriginals() { $engine = new PhabricatorDifferenceEngine(); $changeset = $engine->generateChangesetFromFileContent( implode('', mpull($this->originalLeft->getHunks(), 'getChanges')), implode('', mpull($this->originalRight->getHunks(), 'getChanges'))); $parser = new DifferentialHunkParser(); return $parser->parseHunksForHighlightMasks( $changeset->getHunks(), $this->originalLeft->getHunks(), $this->originalRight->getHunks()); } /** * 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 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 getUser() { return $this->user; } public function setCoverage($coverage) { $this->coverage = $coverage; return $this; } private function getCoverage() { return $this->coverage; } public function parseInlineComment( PhabricatorInlineCommentInterface $comment) { // Parse only comments which are actually visible. if ($this->isCommentVisibleOnRenderedDiff($comment)) { $this->comments[] = $comment; } return $this; } private 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; } if ($data['cache'][0] == '{') { // This is likely an old-style JSON cache which we will not be able to // deserialize. return false; } $data = unserialize($data['cache']); 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', 'depthOnlyLines', 'newRender', 'oldRender', 'specialAttributes', 'hunkStartLines', 'cacheVersion', 'cacheHost', 'highlightingDisabled', ); } public function saveCache() { if (PhabricatorEnv::isReadOnly()) { return false; } 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 = serialize($cache); // We don't want to waste too much space by a single changeset. if (strlen($cache) > self::CACHE_MAX_SIZE) { return; } $changeset = new DifferentialChangeset(); $conn_w = $changeset->establishConnection('w'); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); try { queryfx( $conn_w, 'INSERT INTO %T (id, cache, dateCreated) VALUES (%d, %B, %d) ON DUPLICATE KEY UPDATE cache = VALUES(cache)', DifferentialChangeset::TABLE_CACHE, $render_cache_key, $cache, time()); } catch (AphrontQueryException $ex) { // Ignore these exceptions. A common cause is that the cache is // larger than 'max_allowed_packet', in which case we're better off // not writing it. // TODO: It would be nice to tailor this more narrowly. } unset($unguarded); } private function markGenerated($new_corpus_block = '') { $generated_guess = (strpos($new_corpus_block, '@'.'generated') !== false); if (!$generated_guess) { $generated_path_regexps = PhabricatorEnv::getEnvConfig( 'differential.generated-paths'); 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'); $attribute = $this->changeset->isGeneratedChangeset(); if ($attribute) { $generated = true; } $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 isMoveAway() { return idx($this->specialAttributes, self::ATTR_MOVEAWAY, false); } private function applyIntraline(&$render, $intra, $corpus) { foreach ($render as $key => $text) { $result = $text; if (isset($intra[$key])) { $result = ArcanistDiffUtils::applyIntralineDiff( $result, $intra[$key]); } $result = $this->adjustRenderedLineForDisplay($result); $render[$key] = $result; } } private function getHighlightFuture($corpus) { $language = $this->highlightAs; if (!$language) { $language = $this->highlightEngine->getLanguageFromFilename( $this->filename); if (($language != 'txt') && (strlen($corpus) > self::HIGHLIGHT_BYTE_LIMIT)) { $this->highlightingDisabled = true; $language = 'txt'; } } return $this->highlightEngine->getHighlightFuture( $language, $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() { $skip_cache = false; if ($this->disableCache) { $skip_cache = true; } if ($this->characterEncoding) { $skip_cache = true; } if ($this->highlightAs) { $skip_cache = true; } $changeset = $this->changeset; if ($changeset->getFileType() != DifferentialChangeType::FILE_TEXT && $changeset->getFileType() != DifferentialChangeType::FILE_SYMLINK) { $this->markGenerated(); } else { if ($skip_cache || !$this->loadCache()) { $this->process(); if (!$skip_cache) { $this->saveCache(); } } } } private function process() { $changeset = $this->changeset; $hunk_parser = new DifferentialHunkParser(); $hunk_parser->parseHunksForLineData($changeset->getHunks()); $this->realignDiff($changeset, $hunk_parser); $hunk_parser->reparseHunksForSpecialAttributes(); $unchanged = false; if (!$hunk_parser->getHasAnyChanges()) { $filetype = $this->changeset->getFileType(); if ($filetype == DifferentialChangeType::FILE_TEXT || $filetype == DifferentialChangeType::FILE_SYMLINK) { $unchanged = true; } } $moveaway = false; $changetype = $this->changeset->getChangeType(); if ($changetype == DifferentialChangeType::TYPE_MOVE_AWAY) { $moveaway = true; } $this->setSpecialAttributes(array( self::ATTR_UNCHANGED => $unchanged, self::ATTR_DELETED => $hunk_parser->getIsDeleted(), self::ATTR_MOVEAWAY => $moveaway, )); $lines_context = $this->getLinesOfContext(); $hunk_parser->generateIntraLineDiffs(); $hunk_parser->generateVisibileLinesMask($lines_context); $this->setOldLines($hunk_parser->getOldLines()); $this->setNewLines($hunk_parser->getNewLines()); $this->setIntraLineDiffs($hunk_parser->getIntraLineDiffs()); $this->setDepthOnlyLines($hunk_parser->getDepthOnlyLines()); $this->setVisibileLinesMask($hunk_parser->getVisibleLinesMask()); $this->hunkStartLines = $hunk_parser->getHunkStartLines( $changeset->getHunks()); $new_corpus = $hunk_parser->getNewCorpus(); $new_corpus_block = implode('', $new_corpus); $this->markGenerated($new_corpus_block); if ($this->isTopLevel && !$this->comments && ($this->isGenerated() || $this->isUnchanged() || $this->isDeleted())) { return; } $old_corpus = $hunk_parser->getOldCorpus(); $old_corpus_block = implode('', $old_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 (new FutureIterator($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); } 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; } 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(); $encoding = null; if ($this->characterEncoding) { // We are forcing this changeset to be interpreted with a specific // character encoding, so force all the hunks into that encoding and // propagate it to the renderer. $encoding = $this->characterEncoding; foreach ($this->changeset->getHunks() as $hunk) { $hunk->forceEncoding($this->characterEncoding); } } else { // We're just using the default, so tell the renderer what that is // (by reading the encoding from the first hunk). foreach ($this->changeset->getHunks() as $hunk) { $encoding = $hunk->getDataEncoding(); break; } } $this->tryCacheStuff(); // If we're rendering in an offset mode, treat the range numbers as line // numbers instead of rendering offsets. $offset_mode = $this->getOffsetMode(); if ($offset_mode) { if ($offset_mode == 'new') { $offset_map = $this->new; } else { $offset_map = $this->old; } // NOTE: Inline comments use zero-based lengths. For example, a comment // that starts and ends on line 123 has length 0. Rendering considers // this range to have length 1. Probably both should agree, but that // ship likely sailed long ago. Tweak things here to get the two systems // to agree. See PHI985, where this affected mail rendering of inline // comments left on the final line of a file. $range_end = $this->getOffset($offset_map, $range_start + $range_len); $range_start = $this->getOffset($offset_map, $range_start); $range_len = ($range_end - $range_start) + 1; } $render_pch = $this->shouldRenderPropertyChangeHeader($this->changeset); $rows = max( count($this->old), count($this->new)); $renderer = $this->getRenderer() ->setUser($this->getUser()) ->setChangeset($this->changeset) ->setRenderPropertyChangeHeader($render_pch) ->setIsTopLevel($this->isTopLevel) ->setOldRender($this->oldRender) ->setNewRender($this->newRender) ->setHunkStartLines($this->hunkStartLines) ->setOldChangesetID($this->leftSideChangesetID) ->setNewChangesetID($this->rightSideChangesetID) ->setOldAttachesToNewFile($this->leftSideAttachesToNewFile) ->setNewAttachesToNewFile($this->rightSideAttachesToNewFile) ->setCodeCoverage($this->getCoverage()) ->setRenderingReference($this->getRenderingReference()) ->setMarkupEngine($this->markupEngine) ->setHandles($this->handles) ->setOldLines($this->old) ->setNewLines($this->new) ->setOriginalCharacterEncoding($encoding) ->setShowEditAndReplyLinks($this->getShowEditAndReplyLinks()) ->setCanMarkDone($this->getCanMarkDone()) ->setObjectOwnerPHID($this->getObjectOwnerPHID()) ->setHighlightingDisabled($this->highlightingDisabled) ->setDepthOnlyLines($this->getDepthOnlyLines()); $shield = null; if ($this->isTopLevel && !$this->comments) { if ($this->isGenerated()) { $shield = $renderer->renderShield( pht( 'This file contains generated code, which does not normally '. 'need to be reviewed.')); } else if ($this->isMoveAway()) { // We put an empty shield on these files. Normally, they do not have // any diff content anyway. However, if they come through `arc`, they // may have content. We don't want to show it (it's not useful) and // we bailed out of fully processing it earlier anyway. // We could show a message like "this file was moved", but we show // that as a change header anyway, so it would be redundant. Instead, // just render an empty shield to skip rendering the diff body. $shield = ''; } else if ($this->isUnchanged()) { $type = 'text'; if (!$rows) { // NOTE: Normally, diffs which don't change files do not include // file content (for example, if you "chmod +x" a file and then // run "git show", the file content is not available). Similarly, // if you move a file from A to B without changing it, diffs normally // do not show the file content. In some cases `arc` is able to // synthetically generate content for these diffs, but for raw diffs // we'll never have it so we need to be prepared to not render a link. $type = 'none'; } $type_add = DifferentialChangeType::TYPE_ADD; if ($this->changeset->getChangeType() == $type_add) { // Although the generic message is sort of accurate in a technical // sense, this more-tailored message is less confusing. $shield = $renderer->renderShield( pht('This is an empty file.'), $type); } else { $shield = $renderer->renderShield( pht('The contents of this file were not changed.'), $type); } } else if ($this->isDeleted()) { $shield = $renderer->renderShield( pht('This file was completely deleted.')); } else if ($this->changeset->getAffectedLineCount() > 2500) { $shield = $renderer->renderShield( pht( 'This file has a very large number of changes (%s lines).', new PhutilNumber($this->changeset->getAffectedLineCount()))); } } if ($shield !== null) { return $renderer->renderChangesetTable($shield); } // This request should render the "undershield" headers if it's a top-level // request which made it this far (indicating the changeset has no shield) // or it's a request with no mask information (indicating it's the request // that removes the rendering shield). Possibly, this second class of // request might need to be made more explicit. $is_undershield = (empty($mask_force) || $this->isTopLevel); $renderer->setIsUndershield($is_undershield); $old_comments = array(); $new_comments = array(); $old_mask = array(); $new_mask = array(); $feedback_mask = array(); $lines_context = $this->getLinesOfContext(); if ($this->comments) { // If there are any comments which appear in sections of the file which // we don't have, we're going to move them backwards to the closest // earlier line. Two cases where this may happen are: // // - Porting ghost comments forward into a file which was mostly // deleted. // - Porting ghost comments forward from a full-context diff to a // partial-context diff. list($old_backmap, $new_backmap) = $this->buildLineBackmaps(); foreach ($this->comments as $comment) { $new_side = $this->isCommentOnRightSideWhenDisplayed($comment); $line = $comment->getLineNumber(); if ($new_side) { $back_line = $new_backmap[$line]; } else { $back_line = $old_backmap[$line]; } if ($back_line != $line) { // TODO: This should probably be cleaner, but just be simple and // obvious for now. $ghost = $comment->getIsGhost(); if ($ghost) { $moved = pht( 'This comment originally appeared on line %s, but that line '. 'does not exist in this version of the diff. It has been '. 'moved backward to the nearest line.', new PhutilNumber($line)); $ghost['reason'] = $ghost['reason']."\n\n".$moved; $comment->setIsGhost($ghost); } $comment->setLineNumber($back_line); $comment->setLineLength(0); } $start = max($comment->getLineNumber() - $lines_context, 0); $end = $comment->getLineNumber() + $comment->getLineLength() + $lines_context; for ($ii = $start; $ii <= $end; $ii++) { if ($new_side) { $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 = id(new PHUIDiffInlineThreader()) ->reorderAndThreadCommments($this->comments); foreach ($this->comments as $comment) { $final = $comment->getLineNumber() + $comment->getLineLength(); $final = max(1, $final); if ($this->isCommentOnRightSideWhenDisplayed($comment)) { $new_comments[$final][] = $comment; } else { $old_comments[$final][] = $comment; } } } $renderer ->setOldComments($old_comments) ->setNewComments($new_comments); + $engine_view = $this->newDocumentEngineChangesetView(); + if ($engine_view !== null) { + return $engine_view; + } + switch ($this->changeset->getFileType()) { case DifferentialChangeType::FILE_IMAGE: $old = null; $new = null; // TODO: Improve the architectural issue as discussed in D955 // https://secure.phabricator.com/D955 $reference = $this->getRenderingReference(); $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); $old_phid = null; $new_phid = null; // TODO: This is spooky, see D6851 if ($vs_changeset) { $vs_metadata = $vs_changeset->getMetadata(); $old_phid = idx($vs_metadata, 'new:binary-phid'); } $changeset = id(new DifferentialChangeset())->load($id); if ($changeset) { $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 PhabricatorFileQuery()) ->setViewer($this->getUser()) ->withPHIDs($file_phids) ->execute(); foreach ($files as $file) { if (empty($file)) { continue; } if ($file->getPHID() == $old_phid) { $old = $file; } else if ($file->getPHID() == $new_phid) { $new = $file; } } } $renderer->attachOldFile($old); $renderer->attachNewFile($new); return $renderer->renderFileChange($old, $new, $id, $vs); case DifferentialChangeType::FILE_DIRECTORY: case DifferentialChangeType::FILE_BINARY: $output = $renderer->renderChangesetTable(null); return $output; } if ($this->originalLeft && $this->originalRight) { list($highlight_old, $highlight_new) = $this->diffOriginals(); $highlight_old = array_flip($highlight_old); $highlight_new = array_flip($highlight_new); $renderer ->setHighlightOld($highlight_old) ->setHighlightNew($highlight_new); } $renderer ->setOriginalOld($this->originalLeft) ->setOriginalNew($this->originalRight); if ($range_start === null) { $range_start = 0; } if ($range_len === null) { $range_len = $rows; } $range_len = min($range_len, $rows - $range_start); list($gaps, $mask) = $this->calculateGapsAndMask( $mask_force, $feedback_mask, $range_start, $range_len); $renderer ->setGaps($gaps) ->setMask($mask); $html = $renderer->renderTextChange( $range_start, $range_len, $rows); return $renderer->renderChangesetTable($html); } /** * This function calculates a lot of stuff we need to know to display * the diff: * * Gaps - compute gaps in the visible display diff, where we will render * "Show more context" spacers. 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. A given $gap * is a tuple of $gap_line_number_start and $gap_length. * * Mask - compute the actual lines that need to be shown (because they * are near changes lines, near inline comments, or the request has * explicitly asked for them, i.e. resulting from the user clicking * "show more"). The $mask returned is a sparsely populated dictionary * of $visible_line_number => true. * * @return array($gaps, $mask) */ private function calculateGapsAndMask( $mask_force, $feedback_mask, $range_start, $range_len) { $lines_context = $this->getLinesOfContext(); $gaps = array(); $gap_start = 0; $in_gap = false; $base_mask = $this->visible + $mask_force + $feedback_mask; $base_mask[$range_start + $range_len] = true; for ($ii = $range_start; $ii <= $range_start + $range_len; $ii++) { if (isset($base_mask[$ii])) { if ($in_gap) { $gap_length = $ii - $gap_start; if ($gap_length <= $lines_context) { for ($jj = $gap_start; $jj <= $gap_start + $gap_length; $jj++) { $base_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); $mask = $base_mask; return array($gaps, $mask); } /** * 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(pht('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; } /** * 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 = phutil_tag('em', array(), '-'); $coverage = $this->getCoverage(); if (!$coverage) { return $na; } $covered = 0; $not_covered = 0; foreach ($this->new as $k => $new) { if (!$new['line']) { continue; } if (!$new['type']) { continue; } if (empty($coverage[$new['line'] - 1])) { continue; } switch ($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))); } /** * Build maps from lines comments appear on to actual lines. */ private function buildLineBackmaps() { $old_back = array(); $new_back = array(); foreach ($this->old as $ii => $old) { $old_back[$old['line']] = $old['line']; } foreach ($this->new as $ii => $new) { $new_back[$new['line']] = $new['line']; } $max_old_line = 0; $max_new_line = 0; foreach ($this->comments as $comment) { if ($this->isCommentOnRightSideWhenDisplayed($comment)) { $max_new_line = max($max_new_line, $comment->getLineNumber()); } else { $max_old_line = max($max_old_line, $comment->getLineNumber()); } } $cursor = 1; for ($ii = 1; $ii <= $max_old_line; $ii++) { if (empty($old_back[$ii])) { $old_back[$ii] = $cursor; } else { $cursor = $old_back[$ii]; } } $cursor = 1; for ($ii = 1; $ii <= $max_new_line; $ii++) { if (empty($new_back[$ii])) { $new_back[$ii] = $cursor; } else { $cursor = $new_back[$ii]; } } return array($old_back, $new_back); } private function getOffset(array $map, $line) { if (!$map) { return null; } $line = (int)$line; foreach ($map as $key => $spec) { if ($spec && isset($spec['line'])) { if ((int)$spec['line'] >= $line) { return $key; } } } return $key; } private function realignDiff( DifferentialChangeset $changeset, DifferentialHunkParser $hunk_parser) { // Normalizing and realigning the diff 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, skip realignment. // We have more than one hunk, so we're definitely missing part of the file. $hunks = $changeset->getHunks(); if (count($hunks) !== 1) { return null; } // The first hunk doesn't start at the beginning of the file, so we're // missing some context. $first_hunk = head($hunks); if ($first_hunk->getOldOffset() != 1 || $first_hunk->getNewOffset() != 1) { return null; } $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). return null; } $engine = id(new PhabricatorDifferenceEngine()) ->setNormalize(true); $normalized_changeset = $engine->generateChangesetFromFileContent( $old_file, $new_file); $type_parser = new DifferentialHunkParser(); $type_parser->parseHunksForLineData($normalized_changeset->getHunks()); $hunk_parser->setNormalized(true); $hunk_parser->setOldLineTypeMap($type_parser->getOldLineTypeMap()); $hunk_parser->setNewLineTypeMap($type_parser->getNewLineTypeMap()); } private function adjustRenderedLineForDisplay($line) { // IMPORTANT: We're using "str_replace()" against raw HTML here, which can // easily become unsafe. The input HTML has already had syntax highlighting // and intraline diff highlighting applied, so it's full of "<span />" tags. static $search; static $replace; if ($search === null) { $rules = $this->newSuspiciousCharacterRules(); $map = array(); foreach ($rules as $key => $spec) { $tag = phutil_tag( 'span', array( 'data-copy-text' => $key, 'class' => $spec['class'], 'title' => $spec['title'], ), $spec['replacement']); $map[$key] = phutil_string_cast($tag); } $search = array_keys($map); $replace = array_values($map); } $is_html = false; if ($line instanceof PhutilSafeHTML) { $is_html = true; $line = hsprintf('%s', $line); } $line = phutil_string_cast($line); // TODO: This should be flexible, eventually. $tab_width = 2; $line = self::replaceTabsWithSpaces($line, $tab_width); $line = str_replace($search, $replace, $line); if ($is_html) { $line = phutil_safe_html($line); } return $line; } private function newSuspiciousCharacterRules() { // The "title" attributes are cached in the database, so they're // intentionally not wrapped in "pht(...)". $rules = array( "\xE2\x80\x8B" => array( 'title' => 'ZWS', 'class' => 'suspicious-character', 'replacement' => '!', ), "\xC2\xA0" => array( 'title' => 'NBSP', 'class' => 'suspicious-character', 'replacement' => '!', ), "\x7F" => array( 'title' => 'DEL (0x7F)', 'class' => 'suspicious-character', 'replacement' => "\xE2\x90\xA1", ), ); // Unicode defines special pictures for the control characters in the // range between "0x00" and "0x1F". $control = array( 'NULL', 'SOH', 'STX', 'ETX', 'EOT', 'ENQ', 'ACK', 'BEL', 'BS', null, // "\t" Tab null, // "\n" New Line 'VT', 'FF', null, // "\r" Carriage Return, 'SO', 'SI', 'DLE', 'DC1', 'DC2', 'DC3', 'DC4', 'NAK', 'SYN', 'ETB', 'CAN', 'EM', 'SUB', 'ESC', 'FS', 'GS', 'RS', 'US', ); foreach ($control as $idx => $label) { if ($label === null) { continue; } $rules[chr($idx)] = array( 'title' => sprintf('%s (0x%02X)', $label, $idx), 'class' => 'suspicious-character', 'replacement' => "\xE2\x90".chr(0x80 + $idx), ); } return $rules; } public static function replaceTabsWithSpaces($line, $tab_width) { static $tags = array(); if (empty($tags[$tab_width])) { for ($ii = 1; $ii <= $tab_width; $ii++) { $tag = phutil_tag( 'span', array( 'data-copy-text' => "\t", ), str_repeat(' ', $ii)); $tag = phutil_string_cast($tag); $tags[$ii] = $tag; } } // Expand all prefix tabs until we encounter any non-tab character. This // is cheap and often immediately produces the correct result with no // further work (and, particularly, no need to handle any unicode cases). $len = strlen($line); $head = 0; for ($head = 0; $head < $len; $head++) { $char = $line[$head]; if ($char !== "\t") { break; } } if ($head) { if (empty($tags[$tab_width * $head])) { $tags[$tab_width * $head] = str_repeat($tags[$tab_width], $head); } $prefix = $tags[$tab_width * $head]; $line = substr($line, $head); } else { $prefix = ''; } // If we have no remaining tabs elsewhere in the string after taking care // of all the prefix tabs, we're done. if (strpos($line, "\t") === false) { return $prefix.$line; } $len = strlen($line); // If the line is particularly long, don't try to do anything special with // it. Use a faster approximation of the correct tabstop expansion instead. // This usually still arrives at the right result. if ($len > 256) { return $prefix.str_replace("\t", $tags[$tab_width], $line); } $in_tag = false; $pos = 0; // See PHI1210. If the line only has single-byte characters, we don't need // to vectorize it and can avoid an expensive UTF8 call. $fast_path = preg_match('/^[\x01-\x7F]*\z/', $line); if ($fast_path) { $replace = array(); for ($ii = 0; $ii < $len; $ii++) { $char = $line[$ii]; if ($char === '>') { $in_tag = false; continue; } if ($in_tag) { continue; } if ($char === '<') { $in_tag = true; continue; } if ($char === "\t") { $count = $tab_width - ($pos % $tab_width); $pos += $count; $replace[$ii] = $tags[$count]; continue; } $pos++; } if ($replace) { // Apply replacements starting at the end of the string so they // don't mess up the offsets for following replacements. $replace = array_reverse($replace, true); foreach ($replace as $replace_pos => $replacement) { $line = substr_replace($line, $replacement, $replace_pos, 1); } } } else { $line = phutil_utf8v_combined($line); foreach ($line as $key => $char) { if ($char === '>') { $in_tag = false; continue; } if ($in_tag) { continue; } if ($char === '<') { $in_tag = true; continue; } if ($char === "\t") { $count = $tab_width - ($pos % $tab_width); $pos += $count; $line[$key] = $tags[$count]; continue; } $pos++; } $line = implode('', $line); } return $prefix.$line; } + private function newDocumentEngineChangesetView() { + $changeset = $this->changeset; + $viewer = $this->getViewer(); + + // TODO: This should probably be made non-optional in the future. + if (!$viewer) { + return null; + } + + $old_data = $this->old; + $old_data = ipull($old_data, 'text'); + $old_data = implode('', $old_data); + + $new_data = $this->new; + $new_data = ipull($new_data, 'text'); + $new_data = implode('', $new_data); + + $old_ref = id(new PhabricatorDocumentRef()) + ->setName($changeset->getOldFile()) + ->setData($old_data); + + $new_ref = id(new PhabricatorDocumentRef()) + ->setName($changeset->getFilename()) + ->setData($new_data); + + $old_engines = PhabricatorDocumentEngine::getEnginesForRef( + $viewer, + $old_ref); + + $new_engines = PhabricatorDocumentEngine::getEnginesForRef( + $viewer, + $new_ref); + + $shared_engines = array_intersect_key($old_engines, $new_engines); + + $document_engine = null; + foreach ($shared_engines as $shared_engine) { + if ($shared_engine->canDiffDocuments($old_ref, $new_ref)) { + $document_engine = $shared_engine; + break; + } + } + + + if ($document_engine) { + return $document_engine->newDiffView( + $old_ref, + $new_ref); + } + + return null; + } + } diff --git a/src/applications/files/document/PhabricatorDocumentEngine.php b/src/applications/files/document/PhabricatorDocumentEngine.php index c869f5f0d7..7e2a0d3b34 100644 --- a/src/applications/files/document/PhabricatorDocumentEngine.php +++ b/src/applications/files/document/PhabricatorDocumentEngine.php @@ -1,248 +1,260 @@ <?php abstract class PhabricatorDocumentEngine extends Phobject { private $viewer; private $highlightedLines = array(); private $encodingConfiguration; private $highlightingConfiguration; private $blameConfiguration = true; final public function setViewer(PhabricatorUser $viewer) { $this->viewer = $viewer; return $this; } final public function getViewer() { return $this->viewer; } final public function setHighlightedLines(array $highlighted_lines) { $this->highlightedLines = $highlighted_lines; return $this; } final public function getHighlightedLines() { return $this->highlightedLines; } final public function canRenderDocument(PhabricatorDocumentRef $ref) { return $this->canRenderDocumentType($ref); } + public function canDiffDocuments( + PhabricatorDocumentRef $uref, + PhabricatorDocumentRef $vref) { + return false; + } + + public function newDiffView( + PhabricatorDocumentRef $uref, + PhabricatorDocumentRef $vref) { + throw new PhutilMethodNotImplementedException(); + } + public function canConfigureEncoding(PhabricatorDocumentRef $ref) { return false; } public function canConfigureHighlighting(PhabricatorDocumentRef $ref) { return false; } public function canBlame(PhabricatorDocumentRef $ref) { return false; } final public function setEncodingConfiguration($config) { $this->encodingConfiguration = $config; return $this; } final public function getEncodingConfiguration() { return $this->encodingConfiguration; } final public function setHighlightingConfiguration($config) { $this->highlightingConfiguration = $config; return $this; } final public function getHighlightingConfiguration() { return $this->highlightingConfiguration; } final public function setBlameConfiguration($blame_configuration) { $this->blameConfiguration = $blame_configuration; return $this; } final public function getBlameConfiguration() { return $this->blameConfiguration; } final protected function getBlameEnabled() { return $this->blameConfiguration; } public function shouldRenderAsync(PhabricatorDocumentRef $ref) { return false; } abstract protected function canRenderDocumentType( PhabricatorDocumentRef $ref); final public function newDocument(PhabricatorDocumentRef $ref) { $can_complete = $this->canRenderCompleteDocument($ref); $can_partial = $this->canRenderPartialDocument($ref); if (!$can_complete && !$can_partial) { return $this->newMessage( pht( 'This document is too large to be rendered inline. (The document '. 'is %s bytes, the limit for this engine is %s bytes.)', new PhutilNumber($ref->getByteLength()), new PhutilNumber($this->getByteLengthLimit()))); } return $this->newDocumentContent($ref); } final public function newDocumentIcon(PhabricatorDocumentRef $ref) { return id(new PHUIIconView()) ->setIcon($this->getDocumentIconIcon($ref)); } abstract protected function newDocumentContent( PhabricatorDocumentRef $ref); protected function getDocumentIconIcon(PhabricatorDocumentRef $ref) { return 'fa-file-o'; } protected function getDocumentRenderingText(PhabricatorDocumentRef $ref) { return pht('Loading...'); } final public function getDocumentEngineKey() { return $this->getPhobjectClassConstant('ENGINEKEY'); } final public static function getAllEngines() { return id(new PhutilClassMapQuery()) ->setAncestorClass(__CLASS__) ->setUniqueMethod('getDocumentEngineKey') ->execute(); } final public function newSortVector(PhabricatorDocumentRef $ref) { $content_score = $this->getContentScore($ref); // Prefer engines which can render the entire file over engines which // can only render a header, and engines which can render a header over // engines which can't render anything. if ($this->canRenderCompleteDocument($ref)) { $limit_score = 0; } else if ($this->canRenderPartialDocument($ref)) { $limit_score = 1; } else { $limit_score = 2; } return id(new PhutilSortVector()) ->addInt($limit_score) ->addInt(-$content_score); } protected function getContentScore(PhabricatorDocumentRef $ref) { return 2000; } abstract public function getViewAsLabel(PhabricatorDocumentRef $ref); public function getViewAsIconIcon(PhabricatorDocumentRef $ref) { $can_complete = $this->canRenderCompleteDocument($ref); $can_partial = $this->canRenderPartialDocument($ref); if (!$can_complete && !$can_partial) { return 'fa-times'; } return $this->getDocumentIconIcon($ref); } public function getViewAsIconColor(PhabricatorDocumentRef $ref) { $can_complete = $this->canRenderCompleteDocument($ref); if (!$can_complete) { return 'grey'; } return null; } final public static function getEnginesForRef( PhabricatorUser $viewer, PhabricatorDocumentRef $ref) { $engines = self::getAllEngines(); foreach ($engines as $key => $engine) { $engine = id(clone $engine) ->setViewer($viewer); if (!$engine->canRenderDocument($ref)) { unset($engines[$key]); continue; } $engines[$key] = $engine; } if (!$engines) { throw new Exception(pht('No content engine can render this document.')); } $vectors = array(); foreach ($engines as $key => $usable_engine) { $vectors[$key] = $usable_engine->newSortVector($ref); } $vectors = msortv($vectors, 'getSelf'); return array_select_keys($engines, array_keys($vectors)); } protected function getByteLengthLimit() { return (1024 * 1024 * 8); } protected function canRenderCompleteDocument(PhabricatorDocumentRef $ref) { $limit = $this->getByteLengthLimit(); if ($limit) { $length = $ref->getByteLength(); if ($length > $limit) { return false; } } return true; } protected function canRenderPartialDocument(PhabricatorDocumentRef $ref) { return false; } protected function newMessage($message) { return phutil_tag( 'div', array( 'class' => 'document-engine-error', ), $message); } final public function newLoadingContent(PhabricatorDocumentRef $ref) { $spinner = id(new PHUIIconView()) ->setIcon('fa-gear') ->addClass('ph-spin'); return phutil_tag( 'div', array( 'class' => 'document-engine-loading', ), array( $spinner, $this->getDocumentRenderingText($ref), )); } } diff --git a/src/applications/files/document/PhabricatorDocumentRef.php b/src/applications/files/document/PhabricatorDocumentRef.php index 036656c00e..12d9f4f2fd 100644 --- a/src/applications/files/document/PhabricatorDocumentRef.php +++ b/src/applications/files/document/PhabricatorDocumentRef.php @@ -1,178 +1,202 @@ <?php final class PhabricatorDocumentRef extends Phobject { private $name; private $mimeType; private $file; private $byteLength; private $snippet; private $symbolMetadata = array(); private $blameURI; private $coverage = array(); + private $data; public function setFile(PhabricatorFile $file) { $this->file = $file; return $this; } public function getFile() { return $this->file; } public function setMimeType($mime_type) { $this->mimeType = $mime_type; return $this; } public function getMimeType() { if ($this->mimeType !== null) { return $this->mimeType; } if ($this->file) { return $this->file->getMimeType(); } return null; } public function setName($name) { $this->name = $name; return $this; } public function getName() { if ($this->name !== null) { return $this->name; } if ($this->file) { return $this->file->getName(); } return null; } public function setByteLength($length) { $this->byteLength = $length; return $this; } public function getByteLength() { if ($this->byteLength !== null) { return $this->byteLength; } + if ($this->data !== null) { + return strlen($this->data); + } + if ($this->file) { return (int)$this->file->getByteSize(); } return null; } + public function setData($data) { + $this->data = $data; + return $this; + } + public function loadData($begin = null, $end = null) { + if ($this->data !== null) { + $data = $this->data; + + if ($begin !== null && $end !== null) { + $data = substr($data, $begin, $end - $begin); + } else if ($begin !== null) { + $data = substr($data, $begin); + } else if ($end !== null) { + $data = substr($data, 0, $end); + } + + return $data; + } + if ($this->file) { $iterator = $this->file->getFileDataIterator($begin, $end); $result = ''; foreach ($iterator as $chunk) { $result .= $chunk; } return $result; } throw new PhutilMethodNotImplementedException(); } public function hasAnyMimeType(array $candidate_types) { $mime_full = $this->getMimeType(); $mime_parts = explode(';', $mime_full); $mime_type = head($mime_parts); $mime_type = $this->normalizeMimeType($mime_type); foreach ($candidate_types as $candidate_type) { if ($this->normalizeMimeType($candidate_type) === $mime_type) { return true; } } return false; } private function normalizeMimeType($mime_type) { $mime_type = trim($mime_type); $mime_type = phutil_utf8_strtolower($mime_type); return $mime_type; } public function isProbablyText() { $snippet = $this->getSnippet(); return (strpos($snippet, "\0") === false); } public function isProbablyJSON() { if (!$this->isProbablyText()) { return false; } $snippet = $this->getSnippet(); // If the file is longer than the snippet, we don't detect the content // as JSON. We could use some kind of heuristic here if we wanted, but // see PHI749 for a false positive. if (strlen($snippet) < $this->getByteLength()) { return false; } // If the snippet is the whole file, just check if the snippet is valid // JSON. Note that `phutil_json_decode()` only accepts arrays and objects // as JSON, so this won't misfire on files with content like "3". try { phutil_json_decode($snippet); return true; } catch (Exception $ex) { return false; } } public function getSnippet() { if ($this->snippet === null) { $this->snippet = $this->loadData(null, (1024 * 1024 * 1)); } return $this->snippet; } public function setSymbolMetadata(array $metadata) { $this->symbolMetadata = $metadata; return $this; } public function getSymbolMetadata() { return $this->symbolMetadata; } public function setBlameURI($blame_uri) { $this->blameURI = $blame_uri; return $this; } public function getBlameURI() { return $this->blameURI; } public function addCoverage($coverage) { $this->coverage[] = array( 'data' => $coverage, ); return $this; } public function getCoverage() { return $this->coverage; } }