@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
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}