@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
at recaptime-dev/main 1982 lines 56 kB view raw
1<?php 2 3final class DifferentialChangesetParser extends Phobject { 4 5 const HIGHLIGHT_BYTE_LIMIT = 262144; 6 7 protected $visible = array(); 8 protected $new = array(); 9 protected $old = array(); 10 protected $intra = array(); 11 protected $depthOnlyLines = array(); 12 protected $newRender = null; 13 protected $oldRender = null; 14 15 protected $filename = null; 16 protected $hunkStartLines = array(); 17 18 protected $comments = array(); 19 protected $specialAttributes = array(); 20 21 protected $changeset; 22 23 protected $renderCacheKey = null; 24 25 private $handles = array(); 26 private $user; 27 28 private $leftSideChangesetID; 29 private $leftSideAttachesToNewFile; 30 31 private $rightSideChangesetID; 32 private $rightSideAttachesToNewFile; 33 34 private $originalLeft; 35 private $originalRight; 36 37 private $renderingReference; 38 private $isSubparser; 39 40 private $isTopLevel; 41 42 private $coverage; 43 private $markupEngine; 44 private $highlightErrors; 45 private $disableCache; 46 private $renderer; 47 private $highlightingDisabled; 48 private $showEditAndReplyLinks = true; 49 private $canMarkDone; 50 private $objectOwnerPHID; 51 private $offsetMode; 52 53 private $rangeStart; 54 private $rangeEnd; 55 private $mask; 56 private $linesOfContext = 8; 57 58 private $highlightEngine; 59 private $viewer; 60 61 private $viewState; 62 private $availableDocumentEngines; 63 64 public function setRange($start, $end) { 65 $this->rangeStart = $start; 66 $this->rangeEnd = $end; 67 return $this; 68 } 69 70 public function setMask(array $mask) { 71 $this->mask = $mask; 72 return $this; 73 } 74 75 public function renderChangeset() { 76 return $this->render($this->rangeStart, $this->rangeEnd, $this->mask); 77 } 78 79 public function setShowEditAndReplyLinks($bool) { 80 $this->showEditAndReplyLinks = $bool; 81 return $this; 82 } 83 84 public function getShowEditAndReplyLinks() { 85 return $this->showEditAndReplyLinks; 86 } 87 88 public function setViewState(PhabricatorChangesetViewState $view_state) { 89 $this->viewState = $view_state; 90 return $this; 91 } 92 93 public function getViewState() { 94 return $this->viewState; 95 } 96 97 public function setRenderer(DifferentialChangesetRenderer $renderer) { 98 $this->renderer = $renderer; 99 return $this; 100 } 101 102 public function getRenderer() { 103 return $this->renderer; 104 } 105 106 public function setDisableCache($disable_cache) { 107 $this->disableCache = $disable_cache; 108 return $this; 109 } 110 111 public function getDisableCache() { 112 return $this->disableCache; 113 } 114 115 public function setCanMarkDone($can_mark_done) { 116 $this->canMarkDone = $can_mark_done; 117 return $this; 118 } 119 120 public function getCanMarkDone() { 121 return $this->canMarkDone; 122 } 123 124 public function setObjectOwnerPHID($phid) { 125 $this->objectOwnerPHID = $phid; 126 return $this; 127 } 128 129 public function getObjectOwnerPHID() { 130 return $this->objectOwnerPHID; 131 } 132 133 public function setOffsetMode($offset_mode) { 134 $this->offsetMode = $offset_mode; 135 return $this; 136 } 137 138 public function getOffsetMode() { 139 return $this->offsetMode; 140 } 141 142 public function setViewer(PhabricatorUser $viewer) { 143 $this->viewer = $viewer; 144 return $this; 145 } 146 147 public function getViewer() { 148 return $this->viewer; 149 } 150 151 private function newRenderer() { 152 $viewer = $this->getViewer(); 153 $viewstate = $this->getViewstate(); 154 155 $renderer_key = $viewstate->getRendererKey(); 156 157 if ($renderer_key === null) { 158 $is_unified = $viewer->compareUserSetting( 159 PhabricatorUnifiedDiffsSetting::SETTINGKEY, 160 PhabricatorUnifiedDiffsSetting::VALUE_ALWAYS_UNIFIED); 161 162 if ($is_unified) { 163 $renderer_key = '1up'; 164 } else { 165 $renderer_key = $viewstate->getDefaultDeviceRendererKey(); 166 } 167 } 168 169 switch ($renderer_key) { 170 case '1up': 171 $renderer = new DifferentialChangesetOneUpRenderer(); 172 break; 173 default: 174 $renderer = new DifferentialChangesetTwoUpRenderer(); 175 break; 176 } 177 178 return $renderer; 179 } 180 181 const CACHE_VERSION = 14; 182 const CACHE_MAX_SIZE = 8e6; 183 184 const ATTR_GENERATED = 'attr:generated'; 185 const ATTR_DELETED = 'attr:deleted'; 186 const ATTR_UNCHANGED = 'attr:unchanged'; 187 const ATTR_MOVEAWAY = 'attr:moveaway'; 188 189 public function setOldLines(array $lines) { 190 $this->old = $lines; 191 return $this; 192 } 193 194 public function setNewLines(array $lines) { 195 $this->new = $lines; 196 return $this; 197 } 198 199 public function setSpecialAttributes(array $attributes) { 200 $this->specialAttributes = $attributes; 201 return $this; 202 } 203 204 public function setIntraLineDiffs(array $diffs) { 205 $this->intra = $diffs; 206 return $this; 207 } 208 209 public function setDepthOnlyLines(array $lines) { 210 $this->depthOnlyLines = $lines; 211 return $this; 212 } 213 214 public function getDepthOnlyLines() { 215 return $this->depthOnlyLines; 216 } 217 218 public function setVisibleLinesMask(array $mask) { 219 $this->visible = $mask; 220 return $this; 221 } 222 223 public function setLinesOfContext($lines_of_context) { 224 $this->linesOfContext = $lines_of_context; 225 return $this; 226 } 227 228 public function getLinesOfContext() { 229 return $this->linesOfContext; 230 } 231 232 233 /** 234 * Configure which Changeset comments added to the right side of the visible 235 * diff will be attached to. The ID must be the ID of a real Differential 236 * Changeset. 237 * 238 * The complexity here is that we may show an arbitrary side of an arbitrary 239 * changeset as either the left or right part of a diff. This method allows 240 * the left and right halves of the displayed diff to be correctly mapped to 241 * storage changesets. 242 * 243 * @param int $id The Differential Changeset ID that comments added to the 244 * right side of the visible diff should be attached to. 245 * @param bool $is_new If true, attach new comments to the right side of the 246 * storage changeset. Note that this may be false, if the left 247 * side of some storage changeset is being shown as the right 248 * side of a display diff. 249 * @return $this 250 */ 251 public function setRightSideCommentMapping($id, $is_new) { 252 $this->rightSideChangesetID = $id; 253 $this->rightSideAttachesToNewFile = $is_new; 254 return $this; 255 } 256 257 /** 258 * See setRightSideCommentMapping(), but this sets information for the left 259 * side of the display diff. 260 */ 261 public function setLeftSideCommentMapping($id, $is_new) { 262 $this->leftSideChangesetID = $id; 263 $this->leftSideAttachesToNewFile = $is_new; 264 return $this; 265 } 266 267 public function setOriginals( 268 DifferentialChangeset $left, 269 DifferentialChangeset $right) { 270 271 $this->originalLeft = $left; 272 $this->originalRight = $right; 273 return $this; 274 } 275 276 public function diffOriginals() { 277 $engine = new PhabricatorDifferenceEngine(); 278 $changeset = $engine->generateChangesetFromFileContent( 279 implode('', mpull($this->originalLeft->getHunks(), 'getChanges')), 280 implode('', mpull($this->originalRight->getHunks(), 'getChanges'))); 281 282 $parser = new DifferentialHunkParser(); 283 284 return $parser->parseHunksForHighlightMasks( 285 $changeset->getHunks(), 286 $this->originalLeft->getHunks(), 287 $this->originalRight->getHunks()); 288 } 289 290 /** 291 * Set a key for identifying this changeset in the render cache. If set, the 292 * parser will attempt to use the changeset render cache, which can improve 293 * performance for frequently-viewed changesets. 294 * 295 * By default, there is no render cache key and parsers do not use the cache. 296 * This is appropriate for rarely-viewed changesets. 297 * 298 * @param string $key Key for identifying this changeset in the render 299 * cache. 300 * @return $this 301 */ 302 public function setRenderCacheKey($key) { 303 $this->renderCacheKey = $key; 304 return $this; 305 } 306 307 private function getRenderCacheKey() { 308 return $this->renderCacheKey; 309 } 310 311 public function setChangeset(DifferentialChangeset $changeset) { 312 $this->changeset = $changeset; 313 314 $this->setFilename($changeset->getFilename()); 315 316 return $this; 317 } 318 319 public function setRenderingReference($ref) { 320 $this->renderingReference = $ref; 321 return $this; 322 } 323 324 private function getRenderingReference() { 325 return $this->renderingReference; 326 } 327 328 public function getChangeset() { 329 return $this->changeset; 330 } 331 332 public function setFilename($filename) { 333 $this->filename = $filename; 334 return $this; 335 } 336 337 /** 338 * @param array<PhabricatorObjectHandle> $handles 339 */ 340 public function setHandles(array $handles) { 341 assert_instances_of($handles, PhabricatorObjectHandle::class); 342 $this->handles = $handles; 343 return $this; 344 } 345 346 public function setMarkupEngine(PhabricatorMarkupEngine $engine) { 347 $this->markupEngine = $engine; 348 return $this; 349 } 350 351 public function setCoverage($coverage) { 352 $this->coverage = $coverage; 353 return $this; 354 } 355 private function getCoverage() { 356 return $this->coverage; 357 } 358 359 public function parseInlineComment( 360 PhabricatorInlineComment $comment) { 361 362 // Parse only comments which are actually visible. 363 if ($this->isCommentVisibleOnRenderedDiff($comment)) { 364 $this->comments[] = $comment; 365 } 366 return $this; 367 } 368 369 /** 370 * @return bool 371 */ 372 private function loadCache() { 373 $render_cache_key = $this->getRenderCacheKey(); 374 if (!$render_cache_key) { 375 return false; 376 } 377 378 $data = null; 379 380 $changeset = new DifferentialChangeset(); 381 $conn_r = $changeset->establishConnection('r'); 382 $data = queryfx_one( 383 $conn_r, 384 'SELECT * FROM %T WHERE cacheIndex = %s', 385 DifferentialChangeset::TABLE_CACHE, 386 PhabricatorHash::digestForIndex($render_cache_key)); 387 388 if (!$data) { 389 return false; 390 } 391 392 if ($data['cache'][0] == '{') { 393 // This is likely an old-style JSON cache which we will not be able to 394 // deserialize. 395 return false; 396 } 397 398 $data = unserialize($data['cache']); 399 if (!is_array($data) || !$data) { 400 return false; 401 } 402 403 foreach (self::getCacheableProperties() as $cache_key) { 404 if (!array_key_exists($cache_key, $data)) { 405 // If we're missing a cache key, assume we're looking at an old cache 406 // and ignore it. 407 return false; 408 } 409 } 410 411 if ($data['cacheVersion'] !== self::CACHE_VERSION) { 412 return false; 413 } 414 415 // Someone displays contents of a partially cached shielded file. 416 if (!isset($data['newRender']) && (!$this->isTopLevel || $this->comments)) { 417 return false; 418 } 419 420 unset($data['cacheVersion'], $data['cacheHost']); 421 $cache_prop = array_select_keys($data, self::getCacheableProperties()); 422 foreach ($cache_prop as $cache_key => $v) { 423 $this->$cache_key = $v; 424 } 425 426 return true; 427 } 428 429 protected static function getCacheableProperties() { 430 return array( 431 'visible', 432 'new', 433 'old', 434 'intra', 435 'depthOnlyLines', 436 'newRender', 437 'oldRender', 438 'specialAttributes', 439 'hunkStartLines', 440 'cacheVersion', 441 'cacheHost', 442 'highlightingDisabled', 443 ); 444 } 445 446 public function saveCache() { 447 if (PhabricatorEnv::isReadOnly()) { 448 return false; 449 } 450 451 if ($this->highlightErrors) { 452 return false; 453 } 454 455 $render_cache_key = $this->getRenderCacheKey(); 456 if (!$render_cache_key) { 457 return false; 458 } 459 460 $cache = array(); 461 foreach (self::getCacheableProperties() as $cache_key) { 462 switch ($cache_key) { 463 case 'cacheVersion': 464 $cache[$cache_key] = self::CACHE_VERSION; 465 break; 466 case 'cacheHost': 467 $cache[$cache_key] = php_uname('n'); 468 break; 469 default: 470 $cache[$cache_key] = $this->$cache_key; 471 break; 472 } 473 } 474 $cache = serialize($cache); 475 476 // We don't want to waste too much space by a single changeset. 477 if (strlen($cache) > self::CACHE_MAX_SIZE) { 478 return; 479 } 480 481 $changeset = new DifferentialChangeset(); 482 $conn_w = $changeset->establishConnection('w'); 483 484 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 485 try { 486 queryfx( 487 $conn_w, 488 'INSERT INTO %T (cacheIndex, cache, dateCreated) VALUES (%s, %B, %d) 489 ON DUPLICATE KEY UPDATE cache = VALUES(cache)', 490 DifferentialChangeset::TABLE_CACHE, 491 PhabricatorHash::digestForIndex($render_cache_key), 492 $cache, 493 PhabricatorTime::getNow()); 494 } catch (AphrontQueryException $ex) { 495 // Ignore these exceptions. A common cause is that the cache is 496 // larger than 'max_allowed_packet', in which case we're better off 497 // not writing it. 498 499 // TODO: It would be nice to tailor this more narrowly. 500 } 501 unset($unguarded); 502 } 503 504 private function markGenerated($new_corpus_block = '') { 505 $generated_guess = (strpos($new_corpus_block, '@'.'generated') !== false); 506 507 if (!$generated_guess) { 508 $generated_path_regexps = PhabricatorEnv::getEnvConfig( 509 'differential.generated-paths'); 510 foreach ($generated_path_regexps as $regexp) { 511 if (preg_match($regexp, $this->changeset->getFilename())) { 512 $generated_guess = true; 513 break; 514 } 515 } 516 } 517 518 $event = new PhabricatorEvent( 519 PhabricatorEventType::TYPE_DIFFERENTIAL_WILLMARKGENERATED, 520 array( 521 'corpus' => $new_corpus_block, 522 'is_generated' => $generated_guess, 523 ) 524 ); 525 PhutilEventEngine::dispatchEvent($event); 526 527 $generated = $event->getValue('is_generated'); 528 529 $attribute = $this->changeset->isGeneratedChangeset(); 530 if ($attribute) { 531 $generated = true; 532 } 533 534 $this->specialAttributes[self::ATTR_GENERATED] = $generated; 535 } 536 537 public function isGenerated() { 538 return idx($this->specialAttributes, self::ATTR_GENERATED, false); 539 } 540 541 public function isDeleted() { 542 return idx($this->specialAttributes, self::ATTR_DELETED, false); 543 } 544 545 public function isUnchanged() { 546 return idx($this->specialAttributes, self::ATTR_UNCHANGED, false); 547 } 548 549 public function isMoveAway() { 550 return idx($this->specialAttributes, self::ATTR_MOVEAWAY, false); 551 } 552 553 private function applyIntraline(&$render, $intra, $corpus) { 554 555 foreach ($render as $key => $text) { 556 $result = $text; 557 558 if (isset($intra[$key])) { 559 $result = PhabricatorDifferenceEngine::applyIntralineDiff( 560 $result, 561 $intra[$key]); 562 } 563 564 $result = $this->adjustRenderedLineForDisplay($result); 565 566 $render[$key] = $result; 567 } 568 } 569 570 private function getHighlightFuture($corpus) { 571 $language = $this->getViewState()->getHighlightLanguage(); 572 573 if (!$language) { 574 $language = $this->highlightEngine->getLanguageFromFilename( 575 $this->filename); 576 577 if (($language != 'txt') && 578 (strlen($corpus) > self::HIGHLIGHT_BYTE_LIMIT)) { 579 $this->highlightingDisabled = true; 580 $language = 'txt'; 581 } 582 } 583 584 return $this->highlightEngine->getHighlightFuture( 585 $language, 586 $corpus); 587 } 588 589 protected function processHighlightedSource($data, $result) { 590 591 $result_lines = phutil_split_lines($result); 592 foreach ($data as $key => $info) { 593 if (!$info) { 594 unset($result_lines[$key]); 595 } 596 } 597 return $result_lines; 598 } 599 600 private function tryCacheStuff() { 601 $changeset = $this->getChangeset(); 602 if (!$changeset->hasSourceTextBody()) { 603 604 // TODO: This isn't really correct (the change is not "generated"), the 605 // intent is just to not render a text body for Subversion directory 606 // changes, etc. 607 $this->markGenerated(); 608 609 return; 610 } 611 612 $viewstate = $this->getViewState(); 613 614 $skip_cache = false; 615 616 if ($this->disableCache) { 617 $skip_cache = true; 618 } 619 620 $character_encoding = $viewstate->getCharacterEncoding(); 621 if ($character_encoding !== null) { 622 $skip_cache = true; 623 } 624 625 $highlight_language = $viewstate->getHighlightLanguage(); 626 if ($highlight_language !== null) { 627 $skip_cache = true; 628 } 629 630 if ($skip_cache || !$this->loadCache()) { 631 $this->process(); 632 if (!$skip_cache) { 633 $this->saveCache(); 634 } 635 } 636 } 637 638 private function process() { 639 $changeset = $this->changeset; 640 641 $hunk_parser = new DifferentialHunkParser(); 642 $hunk_parser->parseHunksForLineData($changeset->getHunks()); 643 644 $this->realignDiff($changeset, $hunk_parser); 645 646 $hunk_parser->reparseHunksForSpecialAttributes(); 647 648 $unchanged = false; 649 if (!$hunk_parser->getHasAnyChanges()) { 650 $filetype = $this->changeset->getFileType(); 651 if ($filetype == DifferentialChangeType::FILE_TEXT || 652 $filetype == DifferentialChangeType::FILE_SYMLINK) { 653 $unchanged = true; 654 } 655 } 656 657 $moveaway = false; 658 $changetype = $this->changeset->getChangeType(); 659 if ($changetype == DifferentialChangeType::TYPE_MOVE_AWAY) { 660 $moveaway = true; 661 } 662 663 $this->setSpecialAttributes(array( 664 self::ATTR_UNCHANGED => $unchanged, 665 self::ATTR_DELETED => $hunk_parser->getIsDeleted(), 666 self::ATTR_MOVEAWAY => $moveaway, 667 )); 668 669 $lines_context = $this->getLinesOfContext(); 670 671 $hunk_parser->generateIntraLineDiffs(); 672 $hunk_parser->generateVisibleLinesMask($lines_context); 673 674 $this->setOldLines($hunk_parser->getOldLines()); 675 $this->setNewLines($hunk_parser->getNewLines()); 676 $this->setIntraLineDiffs($hunk_parser->getIntraLineDiffs()); 677 $this->setDepthOnlyLines($hunk_parser->getDepthOnlyLines()); 678 $this->setVisibleLinesMask($hunk_parser->getVisibleLinesMask()); 679 $this->hunkStartLines = $hunk_parser->getHunkStartLines( 680 $changeset->getHunks()); 681 682 $new_corpus = $hunk_parser->getNewCorpus(); 683 $new_corpus_block = implode('', $new_corpus); 684 $this->markGenerated($new_corpus_block); 685 686 if ($this->isTopLevel && 687 !$this->comments && 688 ($this->isGenerated() || 689 $this->isUnchanged() || 690 $this->isDeleted())) { 691 return; 692 } 693 694 $old_corpus = $hunk_parser->getOldCorpus(); 695 $old_corpus_block = implode('', $old_corpus); 696 $old_future = $this->getHighlightFuture($old_corpus_block); 697 $new_future = $this->getHighlightFuture($new_corpus_block); 698 $futures = array( 699 'old' => $old_future, 700 'new' => $new_future, 701 ); 702 $corpus_blocks = array( 703 'old' => $old_corpus_block, 704 'new' => $new_corpus_block, 705 ); 706 707 $this->highlightErrors = false; 708 foreach (new FutureIterator($futures) as $key => $future) { 709 try { 710 try { 711 $highlighted = $future->resolve(); 712 } catch (PhutilSyntaxHighlighterException $ex) { 713 $this->highlightErrors = true; 714 $highlighted = id(new PhutilDefaultSyntaxHighlighter()) 715 ->getHighlightFuture($corpus_blocks[$key]) 716 ->resolve(); 717 } 718 switch ($key) { 719 case 'old': 720 $this->oldRender = $this->processHighlightedSource( 721 $this->old, 722 $highlighted); 723 break; 724 case 'new': 725 $this->newRender = $this->processHighlightedSource( 726 $this->new, 727 $highlighted); 728 break; 729 } 730 } catch (Exception $ex) { 731 phlog($ex); 732 throw $ex; 733 } 734 } 735 736 $this->applyIntraline( 737 $this->oldRender, 738 ipull($this->intra, 0), 739 $old_corpus); 740 $this->applyIntraline( 741 $this->newRender, 742 ipull($this->intra, 1), 743 $new_corpus); 744 } 745 746 private function shouldRenderPropertyChangeHeader($changeset) { 747 if (!$this->isTopLevel) { 748 // We render properties only at top level; otherwise we get multiple 749 // copies of them when a user clicks "Show More". 750 return false; 751 } 752 753 return true; 754 } 755 756 public function render( 757 $range_start = null, 758 $range_len = null, 759 $mask_force = array()) { 760 761 $viewer = $this->getViewer(); 762 763 $renderer = $this->getRenderer(); 764 if (!$renderer) { 765 $renderer = $this->newRenderer(); 766 $this->setRenderer($renderer); 767 } 768 769 // "Top level" renders are initial requests for the whole file, versus 770 // requests for a specific range generated by clicking "show more". We 771 // generate property changes and "shield" UI elements only for toplevel 772 // requests. 773 $this->isTopLevel = (($range_start === null) && ($range_len === null)); 774 $this->highlightEngine = PhabricatorSyntaxHighlighter::newEngine(); 775 776 $viewstate = $this->getViewState(); 777 778 $encoding = null; 779 780 $character_encoding = $viewstate->getCharacterEncoding(); 781 if ($character_encoding) { 782 // We are forcing this changeset to be interpreted with a specific 783 // character encoding, so force all the hunks into that encoding and 784 // propagate it to the renderer. 785 $encoding = $character_encoding; 786 foreach ($this->changeset->getHunks() as $hunk) { 787 $hunk->forceEncoding($character_encoding); 788 } 789 } else { 790 // We're just using the default, so tell the renderer what that is 791 // (by reading the encoding from the first hunk). 792 foreach ($this->changeset->getHunks() as $hunk) { 793 $encoding = $hunk->getDataEncoding(); 794 break; 795 } 796 } 797 798 $this->tryCacheStuff(); 799 800 // If we're rendering in an offset mode, treat the range numbers as line 801 // numbers instead of rendering offsets. 802 $offset_mode = $this->getOffsetMode(); 803 if ($offset_mode) { 804 if ($offset_mode == 'new') { 805 $offset_map = $this->new; 806 } else { 807 $offset_map = $this->old; 808 } 809 810 // NOTE: Inline comments use zero-based lengths. For example, a comment 811 // that starts and ends on line 123 has length 0. Rendering considers 812 // this range to have length 1. Probably both should agree, but that 813 // ship likely sailed long ago. Tweak things here to get the two systems 814 // to agree. See PHI985, where this affected mail rendering of inline 815 // comments left on the final line of a file. 816 817 $range_end = $this->getOffset($offset_map, $range_start + $range_len); 818 $range_start = $this->getOffset($offset_map, $range_start); 819 $range_len = ($range_end - $range_start) + 1; 820 } 821 822 $render_pch = $this->shouldRenderPropertyChangeHeader($this->changeset); 823 824 $rows = max( 825 count($this->old), 826 count($this->new)); 827 828 $renderer = $this->getRenderer() 829 ->setUser($this->getViewer()) 830 ->setChangeset($this->changeset) 831 ->setRenderPropertyChangeHeader($render_pch) 832 ->setIsTopLevel($this->isTopLevel) 833 ->setOldRender($this->oldRender) 834 ->setNewRender($this->newRender) 835 ->setHunkStartLines($this->hunkStartLines) 836 ->setOldChangesetID($this->leftSideChangesetID) 837 ->setNewChangesetID($this->rightSideChangesetID) 838 ->setOldAttachesToNewFile($this->leftSideAttachesToNewFile) 839 ->setNewAttachesToNewFile($this->rightSideAttachesToNewFile) 840 ->setCodeCoverage($this->getCoverage()) 841 ->setRenderingReference($this->getRenderingReference()) 842 ->setHandles($this->handles) 843 ->setOldLines($this->old) 844 ->setNewLines($this->new) 845 ->setOriginalCharacterEncoding($encoding) 846 ->setShowEditAndReplyLinks($this->getShowEditAndReplyLinks()) 847 ->setCanMarkDone($this->getCanMarkDone()) 848 ->setObjectOwnerPHID($this->getObjectOwnerPHID()) 849 ->setHighlightingDisabled($this->highlightingDisabled) 850 ->setDepthOnlyLines($this->getDepthOnlyLines()); 851 852 if ($this->markupEngine) { 853 $renderer->setMarkupEngine($this->markupEngine); 854 } 855 856 list($engine, $old_ref, $new_ref) = $this->newDocumentEngine(); 857 if ($engine) { 858 $engine_blocks = $engine->newEngineBlocks( 859 $old_ref, 860 $new_ref); 861 } else { 862 $engine_blocks = null; 863 } 864 865 $has_document_engine = ($engine_blocks !== null); 866 867 // Remove empty comments that don't have any unsaved draft data. 868 PhabricatorInlineComment::loadAndAttachVersionedDrafts( 869 $viewer, 870 $this->comments); 871 foreach ($this->comments as $key => $comment) { 872 if ($comment->isVoidComment($viewer)) { 873 unset($this->comments[$key]); 874 } 875 } 876 877 // See T13515. Sometimes, we collapse file content by default: for 878 // example, if the file is marked as containing generated code. 879 880 // If a file has inline comments, that normally means we never collapse 881 // it. However, if the viewer has already collapsed all of the inlines, 882 // it's fine to collapse the file. 883 884 $expanded_comments = array(); 885 foreach ($this->comments as $comment) { 886 if ($comment->isHidden()) { 887 continue; 888 } 889 $expanded_comments[] = $comment; 890 } 891 892 $collapsed_count = (count($this->comments) - count($expanded_comments)); 893 894 $shield_raw = null; 895 $shield_text = null; 896 $shield_type = null; 897 if ($this->isTopLevel && !$expanded_comments && !$has_document_engine) { 898 if ($this->isGenerated()) { 899 $shield_text = pht( 900 'This file contains generated code, which does not normally '. 901 'need to be reviewed.'); 902 } else if ($this->isMoveAway()) { 903 // We put an empty shield on these files. Normally, they do not have 904 // any diff content anyway. However, if they come through `arc`, they 905 // may have content. We don't want to show it (it's not useful) and 906 // we bailed out of fully processing it earlier anyway. 907 908 // We could show a message like "this file was moved", but we show 909 // that as a change header anyway, so it would be redundant. Instead, 910 // just render an empty shield to skip rendering the diff body. 911 $shield_raw = ''; 912 } else if ($this->isUnchanged()) { 913 $type = 'text'; 914 if (!$rows) { 915 // NOTE: Normally, diffs which don't change files do not include 916 // file content (for example, if you "chmod +x" a file and then 917 // run "git show", the file content is not available). Similarly, 918 // if you move a file from A to B without changing it, diffs normally 919 // do not show the file content. In some cases `arc` is able to 920 // synthetically generate content for these diffs, but for raw diffs 921 // we'll never have it so we need to be prepared to not render a link. 922 $type = 'none'; 923 } 924 925 $shield_type = $type; 926 927 $type_add = DifferentialChangeType::TYPE_ADD; 928 if ($this->changeset->getChangeType() == $type_add) { 929 // Although the generic message is sort of accurate in a technical 930 // sense, this more-tailored message is less confusing. 931 $shield_text = pht('This is an empty file.'); 932 } else { 933 $shield_text = pht('The contents of this file were not changed.'); 934 } 935 } else if ($this->isDeleted()) { 936 $shield_text = pht('This file was completely deleted.'); 937 } else if ($this->changeset->getAffectedLineCount() > 2500) { 938 $shield_text = pht( 939 'This file has a very large number of changes (%s lines).', 940 new PhutilNumber($this->changeset->getAffectedLineCount())); 941 } 942 } 943 944 $shield = null; 945 if ($shield_raw !== null) { 946 $shield = $shield_raw; 947 } else if ($shield_text !== null) { 948 if ($shield_type === null) { 949 $shield_type = 'default'; 950 } 951 952 // If we have inlines and the shield would normally show the whole file, 953 // downgrade it to show only text around the inlines. 954 if ($collapsed_count) { 955 if ($shield_type === 'text') { 956 $shield_type = 'default'; 957 } 958 959 $shield_text = array( 960 $shield_text, 961 ' ', 962 pht( 963 'This file has %s collapsed inline comment(s).', 964 new PhutilNumber($collapsed_count)), 965 ); 966 } 967 968 $shield = $renderer->renderShield($shield_text, $shield_type); 969 } 970 971 if ($shield !== null) { 972 return $renderer->renderChangesetTable($shield); 973 } 974 975 // This request should render the "undershield" headers if it's a top-level 976 // request which made it this far (indicating the changeset has no shield) 977 // or it's a request with no mask information (indicating it's the request 978 // that removes the rendering shield). Possibly, this second class of 979 // request might need to be made more explicit. 980 $is_undershield = (empty($mask_force) || $this->isTopLevel); 981 $renderer->setIsUndershield($is_undershield); 982 983 $old_comments = array(); 984 $new_comments = array(); 985 $old_mask = array(); 986 $new_mask = array(); 987 $feedback_mask = array(); 988 $lines_context = $this->getLinesOfContext(); 989 990 if ($this->comments) { 991 // If there are any comments which appear in sections of the file which 992 // we don't have, we're going to move them backwards to the closest 993 // earlier line. Two cases where this may happen are: 994 // 995 // - Porting ghost comments forward into a file which was mostly 996 // deleted. 997 // - Porting ghost comments forward from a full-context diff to a 998 // partial-context diff. 999 1000 list($old_backmap, $new_backmap) = $this->buildLineBackmaps(); 1001 1002 foreach ($this->comments as $comment) { 1003 $new_side = $this->isCommentOnRightSideWhenDisplayed($comment); 1004 1005 $line = $comment->getLineNumber(); 1006 1007 // See T13524. Lint inlines from Harbormaster may not have a line 1008 // number. 1009 if ($line === null) { 1010 $back_line = null; 1011 } else if ($new_side) { 1012 $back_line = idx($new_backmap, $line); 1013 } else { 1014 $back_line = idx($old_backmap, $line); 1015 } 1016 1017 if ($back_line != $line) { 1018 // TODO: This should probably be cleaner, but just be simple and 1019 // obvious for now. 1020 $ghost = $comment->getIsGhost(); 1021 if ($ghost) { 1022 $moved = pht( 1023 'This comment originally appeared on line %s, but that line '. 1024 'does not exist in this version of the diff. It has been '. 1025 'moved backward to the nearest line.', 1026 new PhutilNumber($line)); 1027 $ghost['reason'] = $ghost['reason']."\n\n".$moved; 1028 $comment->setIsGhost($ghost); 1029 } 1030 1031 $comment->setLineNumber($back_line); 1032 $comment->setLineLength(0); 1033 } 1034 1035 $start = max($comment->getLineNumber() - $lines_context, 0); 1036 $end = $comment->getLineNumber() + 1037 $comment->getLineLength() + 1038 $lines_context; 1039 for ($ii = $start; $ii <= $end; $ii++) { 1040 if ($new_side) { 1041 $new_mask[$ii] = true; 1042 } else { 1043 $old_mask[$ii] = true; 1044 } 1045 } 1046 } 1047 1048 foreach ($this->old as $ii => $old) { 1049 if (isset($old['line']) && isset($old_mask[$old['line']])) { 1050 $feedback_mask[$ii] = true; 1051 } 1052 } 1053 1054 foreach ($this->new as $ii => $new) { 1055 if (isset($new['line']) && isset($new_mask[$new['line']])) { 1056 $feedback_mask[$ii] = true; 1057 } 1058 } 1059 1060 $this->comments = id(new PHUIDiffInlineThreader()) 1061 ->reorderAndThreadCommments($this->comments); 1062 1063 $old_max_display = 1; 1064 foreach ($this->old as $old) { 1065 if (isset($old['line'])) { 1066 $old_max_display = $old['line']; 1067 } 1068 } 1069 1070 $new_max_display = 1; 1071 foreach ($this->new as $new) { 1072 if (isset($new['line'])) { 1073 $new_max_display = $new['line']; 1074 } 1075 } 1076 1077 foreach ($this->comments as $comment) { 1078 $display_line = $comment->getLineNumber() + $comment->getLineLength(); 1079 $display_line = max(1, $display_line); 1080 1081 if ($this->isCommentOnRightSideWhenDisplayed($comment)) { 1082 $display_line = min($new_max_display, $display_line); 1083 $new_comments[$display_line][] = $comment; 1084 } else { 1085 $display_line = min($old_max_display, $display_line); 1086 $old_comments[$display_line][] = $comment; 1087 } 1088 } 1089 } 1090 1091 $renderer 1092 ->setOldComments($old_comments) 1093 ->setNewComments($new_comments); 1094 1095 if ($engine_blocks !== null) { 1096 $reference = $this->getRenderingReference(); 1097 $parts = explode('/', $reference); 1098 if (count($parts) == 2) { 1099 list($id, $vs) = $parts; 1100 } else { 1101 $id = $parts[0]; 1102 $vs = 0; 1103 } 1104 1105 // If we don't have an explicit "vs" changeset, it's the left side of 1106 // the "id" changeset. 1107 if (!$vs) { 1108 $vs = $id; 1109 } 1110 1111 if ($mask_force) { 1112 $engine_blocks->setRevealedIndexes(array_keys($mask_force)); 1113 } 1114 1115 if ($range_start !== null || $range_len !== null) { 1116 $range_min = $range_start; 1117 1118 if ($range_len === null) { 1119 $range_max = null; 1120 } else { 1121 $range_max = (int)$range_start + (int)$range_len; 1122 } 1123 1124 $engine_blocks->setRange($range_min, $range_max); 1125 } 1126 1127 $renderer 1128 ->setDocumentEngine($engine) 1129 ->setDocumentEngineBlocks($engine_blocks); 1130 1131 return $renderer->renderDocumentEngineBlocks( 1132 $engine_blocks, 1133 (string)$id, 1134 (string)$vs); 1135 } 1136 1137 // If we've made it here with a type of file we don't know how to render, 1138 // bail out with a default empty rendering. Normally, we'd expect a 1139 // document engine to catch these changes before we make it this far. 1140 switch ($this->changeset->getFileType()) { 1141 case DifferentialChangeType::FILE_DIRECTORY: 1142 case DifferentialChangeType::FILE_BINARY: 1143 case DifferentialChangeType::FILE_IMAGE: 1144 $output = $renderer->renderChangesetTable(null); 1145 return $output; 1146 } 1147 1148 if ($this->originalLeft && $this->originalRight) { 1149 list($highlight_old, $highlight_new) = $this->diffOriginals(); 1150 $highlight_old = array_flip($highlight_old); 1151 $highlight_new = array_flip($highlight_new); 1152 $renderer 1153 ->setHighlightOld($highlight_old) 1154 ->setHighlightNew($highlight_new); 1155 } 1156 $renderer 1157 ->setOriginalOld($this->originalLeft) 1158 ->setOriginalNew($this->originalRight); 1159 1160 if ($range_start === null) { 1161 $range_start = 0; 1162 } 1163 if ($range_len === null) { 1164 $range_len = $rows; 1165 } 1166 $range_len = min($range_len, $rows - $range_start); 1167 1168 list($gaps, $mask) = $this->calculateGapsAndMask( 1169 $mask_force, 1170 $feedback_mask, 1171 $range_start, 1172 $range_len); 1173 1174 $renderer 1175 ->setGaps($gaps) 1176 ->setMask($mask); 1177 1178 $html = $renderer->renderTextChange( 1179 $range_start, 1180 $range_len, 1181 $rows); 1182 1183 return $renderer->renderChangesetTable($html); 1184 } 1185 1186 /** 1187 * This function calculates a lot of stuff we need to know to display 1188 * the diff: 1189 * 1190 * Gaps - compute gaps in the visible display diff, where we will render 1191 * "Show more context" spacers. If a gap is smaller than the context size, 1192 * we just display it. Otherwise, we record it into $gaps and will render a 1193 * "show more context" element instead of diff text below. A given $gap 1194 * is a tuple of $gap_line_number_start and $gap_length. 1195 * 1196 * Mask - compute the actual lines that need to be shown (because they 1197 * are near changes lines, near inline comments, or the request has 1198 * explicitly asked for them, i.e. resulting from the user clicking 1199 * "show more"). The $mask returned is a sparsely populated dictionary 1200 * of $visible_line_number => true. 1201 * 1202 * @return array Array of <$gaps, $mask> 1203 */ 1204 private function calculateGapsAndMask( 1205 $mask_force, 1206 $feedback_mask, 1207 $range_start, 1208 $range_len) { 1209 1210 $lines_context = $this->getLinesOfContext(); 1211 1212 $gaps = array(); 1213 $gap_start = 0; 1214 $in_gap = false; 1215 $base_mask = $this->visible + $mask_force + $feedback_mask; 1216 $base_mask[$range_start + $range_len] = true; 1217 for ($ii = $range_start; $ii <= $range_start + $range_len; $ii++) { 1218 if (isset($base_mask[$ii])) { 1219 if ($in_gap) { 1220 $gap_length = $ii - $gap_start; 1221 if ($gap_length <= $lines_context) { 1222 for ($jj = $gap_start; $jj <= $gap_start + $gap_length; $jj++) { 1223 $base_mask[$jj] = true; 1224 } 1225 } else { 1226 $gaps[] = array($gap_start, $gap_length); 1227 } 1228 $in_gap = false; 1229 } 1230 } else { 1231 if (!$in_gap) { 1232 $gap_start = $ii; 1233 $in_gap = true; 1234 } 1235 } 1236 } 1237 $gaps = array_reverse($gaps); 1238 $mask = $base_mask; 1239 1240 return array($gaps, $mask); 1241 } 1242 1243 /** 1244 * Determine if an inline comment will appear on the rendered diff, 1245 * taking into consideration which halves of which changesets will actually 1246 * be shown. 1247 * 1248 * @param PhabricatorInlineComment $comment Comment to test for visibility. 1249 * @return bool True if the comment is visible on the rendered diff. 1250 */ 1251 private function isCommentVisibleOnRenderedDiff( 1252 PhabricatorInlineComment $comment) { 1253 1254 $changeset_id = $comment->getChangesetID(); 1255 $is_new = $comment->getIsNewFile(); 1256 1257 if ($changeset_id == $this->rightSideChangesetID && 1258 $is_new == $this->rightSideAttachesToNewFile) { 1259 return true; 1260 } 1261 1262 if ($changeset_id == $this->leftSideChangesetID && 1263 $is_new == $this->leftSideAttachesToNewFile) { 1264 return true; 1265 } 1266 1267 return false; 1268 } 1269 1270 1271 /** 1272 * Determine if a comment will appear on the right side of the display diff. 1273 * Note that the comment must appear somewhere on the rendered changeset, as 1274 * per isCommentVisibleOnRenderedDiff(). 1275 * 1276 * @param PhabricatorInlineComment $comment Comment to test for display 1277 * location. 1278 * @return bool True for right, false for left. 1279 */ 1280 private function isCommentOnRightSideWhenDisplayed( 1281 PhabricatorInlineComment $comment) { 1282 1283 if (!$this->isCommentVisibleOnRenderedDiff($comment)) { 1284 throw new Exception(pht('Comment is not visible on changeset!')); 1285 } 1286 1287 $changeset_id = $comment->getChangesetID(); 1288 $is_new = $comment->getIsNewFile(); 1289 1290 if ($changeset_id == $this->rightSideChangesetID && 1291 $is_new == $this->rightSideAttachesToNewFile) { 1292 return true; 1293 } 1294 1295 return false; 1296 } 1297 1298 /** 1299 * Parse the 'range' specification that this class and the client-side JS 1300 * emit to indicate that a user clicked "Show more..." on a diff. Generally, 1301 * use is something like this: 1302 * 1303 * $spec = $request->getStr('range'); 1304 * $parsed = DifferentialChangesetParser::parseRangeSpecification($spec); 1305 * list($start, $end, $mask) = $parsed; 1306 * $parser->render($start, $end, $mask); 1307 * 1308 * @param string $spec Range specification, indicating the range of the diff 1309 * that should be rendered. 1310 * @return array Tuple of <start, end, mask> suitable for passing to 1311 * @{method:render}. 1312 */ 1313 public static function parseRangeSpecification($spec) { 1314 $range_s = null; 1315 $range_e = null; 1316 $mask = array(); 1317 1318 if ($spec) { 1319 $match = null; 1320 if (preg_match('@^(\d+)-(\d+)(?:/(\d+)-(\d+))?$@', $spec, $match)) { 1321 $range_s = (int)$match[1]; 1322 $range_e = (int)$match[2]; 1323 if (count($match) > 3) { 1324 $start = (int)$match[3]; 1325 $len = (int)$match[4]; 1326 for ($ii = $start; $ii < $start + $len; $ii++) { 1327 $mask[$ii] = true; 1328 } 1329 } 1330 } 1331 } 1332 1333 return array($range_s, $range_e, $mask); 1334 } 1335 1336 /** 1337 * Render "modified coverage" information; test coverage on modified lines. 1338 * This synthesizes diff information with unit test information into a useful 1339 * indicator of how well tested a change is. 1340 */ 1341 public function renderModifiedCoverage() { 1342 $na = phutil_tag('em', array(), '-'); 1343 1344 $coverage = $this->getCoverage(); 1345 if (!$coverage) { 1346 return $na; 1347 } 1348 1349 $covered = 0; 1350 $not_covered = 0; 1351 1352 foreach ($this->new as $k => $new) { 1353 if ($new === null) { 1354 continue; 1355 } 1356 1357 if (!$new['line']) { 1358 continue; 1359 } 1360 1361 if (!$new['type']) { 1362 continue; 1363 } 1364 1365 if (empty($coverage[$new['line'] - 1])) { 1366 continue; 1367 } 1368 1369 switch ($coverage[$new['line'] - 1]) { 1370 case 'C': 1371 $covered++; 1372 break; 1373 case 'U': 1374 $not_covered++; 1375 break; 1376 } 1377 } 1378 1379 if (!$covered && !$not_covered) { 1380 return $na; 1381 } 1382 1383 return sprintf('%d%%', 100 * ($covered / ($covered + $not_covered))); 1384 } 1385 1386 /** 1387 * Build maps from lines comments appear on to actual lines. 1388 */ 1389 private function buildLineBackmaps() { 1390 $old_back = array(); 1391 $new_back = array(); 1392 foreach ($this->old as $ii => $old) { 1393 if ($old === null) { 1394 continue; 1395 } 1396 $old_back[$old['line']] = $old['line']; 1397 } 1398 foreach ($this->new as $ii => $new) { 1399 if ($new === null) { 1400 continue; 1401 } 1402 $new_back[$new['line']] = $new['line']; 1403 } 1404 1405 $max_old_line = 0; 1406 $max_new_line = 0; 1407 foreach ($this->comments as $comment) { 1408 if ($this->isCommentOnRightSideWhenDisplayed($comment)) { 1409 $max_new_line = max($max_new_line, $comment->getLineNumber()); 1410 } else { 1411 $max_old_line = max($max_old_line, $comment->getLineNumber()); 1412 } 1413 } 1414 1415 $cursor = 1; 1416 for ($ii = 1; $ii <= $max_old_line; $ii++) { 1417 if (empty($old_back[$ii])) { 1418 $old_back[$ii] = $cursor; 1419 } else { 1420 $cursor = $old_back[$ii]; 1421 } 1422 } 1423 1424 $cursor = 1; 1425 for ($ii = 1; $ii <= $max_new_line; $ii++) { 1426 if (empty($new_back[$ii])) { 1427 $new_back[$ii] = $cursor; 1428 } else { 1429 $cursor = $new_back[$ii]; 1430 } 1431 } 1432 1433 return array($old_back, $new_back); 1434 } 1435 1436 private function getOffset(array $map, $line) { 1437 if (!$map) { 1438 return null; 1439 } 1440 1441 $line = (int)$line; 1442 foreach ($map as $key => $spec) { 1443 if ($spec && isset($spec['line'])) { 1444 if ((int)$spec['line'] >= $line) { 1445 return $key; 1446 } 1447 } 1448 } 1449 1450 return $key; 1451 } 1452 1453 private function realignDiff( 1454 DifferentialChangeset $changeset, 1455 DifferentialHunkParser $hunk_parser) { 1456 // Normalizing and realigning the diff depends on rediffing the files, and 1457 // we currently need complete representations of both files to do anything 1458 // reasonable. If we only have parts of the files, skip realignment. 1459 1460 // We have more than one hunk, so we're definitely missing part of the file. 1461 $hunks = $changeset->getHunks(); 1462 if (count($hunks) !== 1) { 1463 return null; 1464 } 1465 1466 // The first hunk doesn't start at the beginning of the file, so we're 1467 // missing some context. 1468 $first_hunk = head($hunks); 1469 if ($first_hunk->getOldOffset() != 1 || $first_hunk->getNewOffset() != 1) { 1470 return null; 1471 } 1472 1473 $old_file = $changeset->makeOldFile(); 1474 $new_file = $changeset->makeNewFile(); 1475 if ($old_file === $new_file) { 1476 // If the old and new files are exactly identical, the synthetic 1477 // diff below will give us nonsense and whitespace modes are 1478 // irrelevant anyway. This occurs when you, e.g., copy a file onto 1479 // itself in Subversion (see T271). 1480 return null; 1481 } 1482 1483 1484 $engine = id(new PhabricatorDifferenceEngine()) 1485 ->setNormalize(true); 1486 1487 $normalized_changeset = $engine->generateChangesetFromFileContent( 1488 $old_file, 1489 $new_file); 1490 1491 $type_parser = new DifferentialHunkParser(); 1492 $type_parser->parseHunksForLineData($normalized_changeset->getHunks()); 1493 1494 $hunk_parser->setNormalized(true); 1495 $hunk_parser->setOldLineTypeMap($type_parser->getOldLineTypeMap()); 1496 $hunk_parser->setNewLineTypeMap($type_parser->getNewLineTypeMap()); 1497 } 1498 1499 private function adjustRenderedLineForDisplay($line) { 1500 // IMPORTANT: We're using "str_replace()" against raw HTML here, which can 1501 // easily become unsafe. The input HTML has already had syntax highlighting 1502 // and intraline diff highlighting applied, so it's full of "<span />" tags. 1503 1504 static $search; 1505 static $replace; 1506 if ($search === null) { 1507 $rules = $this->newSuspiciousCharacterRules(); 1508 1509 $map = array(); 1510 foreach ($rules as $key => $spec) { 1511 $tag = phutil_tag( 1512 'span', 1513 array( 1514 'data-copy-text' => $key, 1515 'class' => $spec['class'], 1516 'title' => $spec['title'], 1517 ), 1518 $spec['replacement']); 1519 $map[$key] = phutil_string_cast($tag); 1520 } 1521 1522 $search = array_keys($map); 1523 $replace = array_values($map); 1524 } 1525 1526 $is_html = false; 1527 if ($line instanceof PhutilSafeHTML) { 1528 $is_html = true; 1529 $line = hsprintf('%s', $line); 1530 } 1531 1532 $line = phutil_string_cast($line); 1533 1534 // TODO: This should be flexible, eventually. 1535 $tab_width = 2; 1536 1537 $line = self::replaceTabsWithSpaces($line, $tab_width); 1538 $line = str_replace($search, $replace, $line); 1539 1540 if ($is_html) { 1541 $line = phutil_safe_html($line); 1542 } 1543 1544 return $line; 1545 } 1546 1547 private function newSuspiciousCharacterRules() { 1548 // The "title" attributes are cached in the database, so they're 1549 // intentionally not wrapped in "pht(...)". 1550 1551 $rules = array( 1552 "\xE2\x80\x8B" => array( 1553 'title' => 'ZWS', 1554 'class' => 'suspicious-character', 1555 'replacement' => '!', 1556 ), 1557 "\xC2\xA0" => array( 1558 'title' => 'NBSP', 1559 'class' => 'suspicious-character', 1560 'replacement' => '!', 1561 ), 1562 "\x7F" => array( 1563 'title' => 'DEL (0x7F)', 1564 'class' => 'suspicious-character', 1565 'replacement' => "\xE2\x90\xA1", 1566 ), 1567 ); 1568 1569 // Unicode defines special pictures for the control characters in the 1570 // range between "0x00" and "0x1F". 1571 1572 $control = array( 1573 'NULL', 1574 'SOH', 1575 'STX', 1576 'ETX', 1577 'EOT', 1578 'ENQ', 1579 'ACK', 1580 'BEL', 1581 'BS', 1582 null, // "\t" Tab 1583 null, // "\n" New Line 1584 'VT', 1585 'FF', 1586 null, // "\r" Carriage Return, 1587 'SO', 1588 'SI', 1589 'DLE', 1590 'DC1', 1591 'DC2', 1592 'DC3', 1593 'DC4', 1594 'NAK', 1595 'SYN', 1596 'ETB', 1597 'CAN', 1598 'EM', 1599 'SUB', 1600 'ESC', 1601 'FS', 1602 'GS', 1603 'RS', 1604 'US', 1605 ); 1606 1607 foreach ($control as $idx => $label) { 1608 if ($label === null) { 1609 continue; 1610 } 1611 1612 $rules[chr($idx)] = array( 1613 'title' => sprintf('%s (0x%02X)', $label, $idx), 1614 'class' => 'suspicious-character', 1615 'replacement' => "\xE2\x90".chr(0x80 + $idx), 1616 ); 1617 } 1618 1619 return $rules; 1620 } 1621 1622 public static function replaceTabsWithSpaces($line, $tab_width) { 1623 static $tags = array(); 1624 if (empty($tags[$tab_width])) { 1625 for ($ii = 1; $ii <= $tab_width; $ii++) { 1626 $tag = phutil_tag( 1627 'span', 1628 array( 1629 'data-copy-text' => "\t", 1630 ), 1631 str_repeat(' ', $ii)); 1632 $tag = phutil_string_cast($tag); 1633 $tags[$ii] = $tag; 1634 } 1635 } 1636 1637 // Expand all prefix tabs until we encounter any non-tab character. This 1638 // is cheap and often immediately produces the correct result with no 1639 // further work (and, particularly, no need to handle any unicode cases). 1640 1641 $len = strlen($line); 1642 1643 $head = 0; 1644 for ($head = 0; $head < $len; $head++) { 1645 $char = $line[$head]; 1646 if ($char !== "\t") { 1647 break; 1648 } 1649 } 1650 1651 if ($head) { 1652 if (empty($tags[$tab_width * $head])) { 1653 $tags[$tab_width * $head] = str_repeat($tags[$tab_width], $head); 1654 } 1655 $prefix = $tags[$tab_width * $head]; 1656 $line = substr($line, $head); 1657 } else { 1658 $prefix = ''; 1659 } 1660 1661 // If we have no remaining tabs elsewhere in the string after taking care 1662 // of all the prefix tabs, we're done. 1663 if (strpos($line, "\t") === false) { 1664 return $prefix.$line; 1665 } 1666 1667 $len = strlen($line); 1668 1669 // If the line is particularly long, don't try to do anything special with 1670 // it. Use a faster approximation of the correct tabstop expansion instead. 1671 // This usually still arrives at the right result. 1672 if ($len > 256) { 1673 return $prefix.str_replace("\t", $tags[$tab_width], $line); 1674 } 1675 1676 $in_tag = false; 1677 $pos = 0; 1678 1679 // See PHI1210. If the line only has single-byte characters, we don't need 1680 // to vectorize it and can avoid an expensive UTF8 call. 1681 1682 $fast_path = preg_match('/^[\x01-\x7F]*\z/', $line); 1683 if ($fast_path) { 1684 $replace = array(); 1685 for ($ii = 0; $ii < $len; $ii++) { 1686 $char = $line[$ii]; 1687 if ($char === '>') { 1688 $in_tag = false; 1689 continue; 1690 } 1691 1692 if ($in_tag) { 1693 continue; 1694 } 1695 1696 if ($char === '<') { 1697 $in_tag = true; 1698 continue; 1699 } 1700 1701 if ($char === "\t") { 1702 $count = $tab_width - ($pos % $tab_width); 1703 $pos += $count; 1704 $replace[$ii] = $tags[$count]; 1705 continue; 1706 } 1707 1708 $pos++; 1709 } 1710 1711 if ($replace) { 1712 // Apply replacements starting at the end of the string so they 1713 // don't mess up the offsets for following replacements. 1714 $replace = array_reverse($replace, true); 1715 1716 foreach ($replace as $replace_pos => $replacement) { 1717 $line = substr_replace($line, $replacement, $replace_pos, 1); 1718 } 1719 } 1720 } else { 1721 $line = phutil_utf8v_combined($line); 1722 foreach ($line as $key => $char) { 1723 if ($char === '>') { 1724 $in_tag = false; 1725 continue; 1726 } 1727 1728 if ($in_tag) { 1729 continue; 1730 } 1731 1732 if ($char === '<') { 1733 $in_tag = true; 1734 continue; 1735 } 1736 1737 if ($char === "\t") { 1738 $count = $tab_width - ($pos % $tab_width); 1739 $pos += $count; 1740 $line[$key] = $tags[$count]; 1741 continue; 1742 } 1743 1744 $pos++; 1745 } 1746 1747 $line = implode('', $line); 1748 } 1749 1750 return $prefix.$line; 1751 } 1752 1753 private function newDocumentEngine() { 1754 $changeset = $this->changeset; 1755 $viewer = $this->getViewer(); 1756 1757 list($old_file, $new_file) = $this->loadFileObjectsForChangeset(); 1758 1759 $no_old = !$changeset->hasOldState(); 1760 $no_new = !$changeset->hasNewState(); 1761 1762 if ($no_old) { 1763 $old_ref = null; 1764 } else { 1765 $old_ref = id(new PhabricatorDocumentRef()) 1766 ->setName($changeset->getOldFile()); 1767 if ($old_file) { 1768 $old_ref->setFile($old_file); 1769 } else { 1770 $old_data = $this->getRawDocumentEngineData($this->old); 1771 $old_ref->setData($old_data); 1772 } 1773 } 1774 1775 if ($no_new) { 1776 $new_ref = null; 1777 } else { 1778 $new_ref = id(new PhabricatorDocumentRef()) 1779 ->setName($changeset->getFilename()); 1780 if ($new_file) { 1781 $new_ref->setFile($new_file); 1782 } else { 1783 $new_data = $this->getRawDocumentEngineData($this->new); 1784 $new_ref->setData($new_data); 1785 } 1786 } 1787 1788 $old_engines = null; 1789 if ($old_ref) { 1790 $old_engines = PhabricatorDocumentEngine::getEnginesForRef( 1791 $viewer, 1792 $old_ref); 1793 } 1794 1795 $new_engines = null; 1796 if ($new_ref) { 1797 $new_engines = PhabricatorDocumentEngine::getEnginesForRef( 1798 $viewer, 1799 $new_ref); 1800 } 1801 1802 if ($new_engines !== null && $old_engines !== null) { 1803 $shared_engines = array_intersect_key($new_engines, $old_engines); 1804 $default_engine = head_key($new_engines); 1805 } else if ($new_engines !== null) { 1806 $shared_engines = $new_engines; 1807 $default_engine = head_key($shared_engines); 1808 } else if ($old_engines !== null) { 1809 $shared_engines = $old_engines; 1810 $default_engine = head_key($shared_engines); 1811 } else { 1812 return null; 1813 } 1814 1815 foreach ($shared_engines as $key => $shared_engine) { 1816 if (!$shared_engine->canDiffDocuments($old_ref, $new_ref)) { 1817 unset($shared_engines[$key]); 1818 } 1819 } 1820 1821 $this->availableDocumentEngines = $shared_engines; 1822 1823 $viewstate = $this->getViewState(); 1824 1825 $engine_key = $viewstate->getDocumentEngineKey(); 1826 if (phutil_nonempty_string($engine_key)) { 1827 if (isset($shared_engines[$engine_key])) { 1828 $document_engine = $shared_engines[$engine_key]; 1829 } else { 1830 $document_engine = null; 1831 } 1832 } else { 1833 // If we aren't rendering with a specific engine, only use a default 1834 // engine if the best engine for the new file is a shared engine which 1835 // can diff files. If we're less picky (for example, by accepting any 1836 // shared engine) we can end up with silly behavior (like ".json" files 1837 // rendering as Jupyter documents). 1838 1839 if (isset($shared_engines[$default_engine])) { 1840 $document_engine = $shared_engines[$default_engine]; 1841 } else { 1842 $document_engine = null; 1843 } 1844 } 1845 1846 if ($document_engine) { 1847 return array( 1848 $document_engine, 1849 $old_ref, 1850 $new_ref); 1851 } 1852 1853 return null; 1854 } 1855 1856 private function loadFileObjectsForChangeset() { 1857 $changeset = $this->changeset; 1858 $viewer = $this->getViewer(); 1859 1860 $old_phid = $changeset->getOldFileObjectPHID(); 1861 $new_phid = $changeset->getNewFileObjectPHID(); 1862 1863 $old_file = null; 1864 $new_file = null; 1865 1866 if ($old_phid || $new_phid) { 1867 $file_phids = array(); 1868 if ($old_phid) { 1869 $file_phids[] = $old_phid; 1870 } 1871 if ($new_phid) { 1872 $file_phids[] = $new_phid; 1873 } 1874 1875 $files = id(new PhabricatorFileQuery()) 1876 ->setViewer($viewer) 1877 ->withPHIDs($file_phids) 1878 ->execute(); 1879 $files = mpull($files, null, 'getPHID'); 1880 1881 if ($old_phid) { 1882 $old_file = idx($files, $old_phid); 1883 if (!$old_file) { 1884 throw new Exception( 1885 pht( 1886 'Failed to load file data for changeset ("%s").', 1887 $old_phid)); 1888 } 1889 $changeset->attachOldFileObject($old_file); 1890 } 1891 1892 if ($new_phid) { 1893 $new_file = idx($files, $new_phid); 1894 if (!$new_file) { 1895 throw new Exception( 1896 pht( 1897 'Failed to load file data for changeset ("%s").', 1898 $new_phid)); 1899 } 1900 $changeset->attachNewFileObject($new_file); 1901 } 1902 } 1903 1904 return array($old_file, $new_file); 1905 } 1906 1907 public function newChangesetResponse() { 1908 // NOTE: This has to happen first because it has side effects. Yuck. 1909 $rendered_changeset = $this->renderChangeset(); 1910 1911 $renderer = $this->getRenderer(); 1912 $renderer_key = $renderer->getRendererKey(); 1913 1914 $viewstate = $this->getViewState(); 1915 1916 $undo_templates = $renderer->renderUndoTemplates(); 1917 foreach ($undo_templates as $key => $undo_template) { 1918 $undo_templates[$key] = hsprintf('%s', $undo_template); 1919 } 1920 1921 $document_engine = $renderer->getDocumentEngine(); 1922 if ($document_engine) { 1923 $document_engine_key = $document_engine->getDocumentEngineKey(); 1924 } else { 1925 $document_engine_key = null; 1926 } 1927 1928 $available_keys = array(); 1929 $engines = $this->availableDocumentEngines; 1930 if (!$engines) { 1931 $engines = array(); 1932 } 1933 1934 $available_keys = mpull($engines, 'getDocumentEngineKey'); 1935 1936 // TODO: Always include "source" as a usable engine to default to 1937 // the buitin rendering. This is kind of a hack and does not actually 1938 // use the source engine. The source engine isn't a diff engine, so 1939 // selecting it causes us to fall through and render with builtin 1940 // behavior. For now, overall behavir is reasonable. 1941 1942 $available_keys[] = PhabricatorSourceDocumentEngine::ENGINEKEY; 1943 $available_keys = array_fuse($available_keys); 1944 $available_keys = array_values($available_keys); 1945 1946 $state = array( 1947 'undoTemplates' => $undo_templates, 1948 'rendererKey' => $renderer_key, 1949 'highlight' => $viewstate->getHighlightLanguage(), 1950 'characterEncoding' => $viewstate->getCharacterEncoding(), 1951 'requestDocumentEngineKey' => $viewstate->getDocumentEngineKey(), 1952 'responseDocumentEngineKey' => $document_engine_key, 1953 'availableDocumentEngineKeys' => $available_keys, 1954 'isHidden' => $viewstate->getHidden(), 1955 ); 1956 1957 return id(new PhabricatorChangesetResponse()) 1958 ->setRenderedChangeset($rendered_changeset) 1959 ->setChangesetState($state); 1960 } 1961 1962 private function getRawDocumentEngineData(array $lines) { 1963 $text = array(); 1964 1965 foreach ($lines as $line) { 1966 if ($line === null) { 1967 continue; 1968 } 1969 1970 // If this is a "No newline at end of file." annotation, don't hand it 1971 // off to the DocumentEngine. 1972 if ($line['type'] === '\\') { 1973 continue; 1974 } 1975 1976 $text[] = $line['text']; 1977 } 1978 1979 return implode('', $text); 1980 } 1981 1982}