<?php /** * Diffs two arbitrary files in the depot. * * @copyright 2011 Perforce Software. All rights reserved. * @license Please see LICENSE.txt in top-level folder of this distribution. * @version <release>/<patch> */ namespace P4\File; use P4\File\File; use P4\Filter\Utf8 as Utf8Filter; use P4\Model\Connected\ConnectedAbstract; class Diff extends ConnectedAbstract { const MAX_FILESIZE = 1048576; // 1 MB const IGNORE_WS = 'ignoreWs'; const UTF8_CONVERT = 'convert'; const UTF8_SANITIZE = 'sanitize'; /** * Compare left/right files. * * @param File $right optional - right-hand file * @param File $left optional - left-hand file * @param array $options optional - influence diff behavior * IGNORE_WS - ignore whitespace and line-ending * changes (defaults to false) * UTF8_CONVERT - attempt to covert non UTF-8 to UTF-8 * UTF8_SANITIZE - replace invalid UTF-8 sequences with � * @return array array with three elements: * lines - added/deleted and contextual (common) lines * isCut - true if lines exceed max filesize (>1MB) * isSame - true if left and right file contents are equal * @throws \InvalidArgumentException if no right-hand file is given */ public function diff(File $right = null, File $left = null, array $options = array()) { $options = $options + array( static::IGNORE_WS => false, static::UTF8_CONVERT => false, static::UTF8_SANITIZE => false ); if (!$right && !$left) { throw new \InvalidArgumentException( "Cannot diff. Must specify at least one file to diff." ); } $diff = array( 'lines' => array(), 'isCut' => false, 'isSame' => false ); // only examine contents if both sides are non-binary and at least one has content $leftIsBinary = $left && $left->isBinary(); $leftHasContent = $left && !$left->isDeletedOrPurged(); $rightIsBinary = $right && $right->isBinary(); $rightHasContent = $right && !$right->isDeletedOrPurged(); if (!$leftIsBinary && !$rightIsBinary && ($leftHasContent || $rightHasContent)) { // if only one file given or either file was deleted/purged, // can't use diff2, must print the file contents instead. if (!$left || !$right || $left->isDeletedOrPurged() || $right->isDeletedOrPurged()) { $diff = $this->diffAddDelete($diff, $right, $left, $options); } else { $diff = $this->diffEdit($diff, $right, $left, $options); } } // compare digests if we have no diff lines (need both sides) if (!$diff['lines'] && $left && $right) { $leftDigest = $left->hasStatusField('digest') ? $left->getStatus('digest') : null; $rightDigest = $right->hasStatusField('digest') ? $right->getStatus('digest') : null; $diff['isSame'] = $leftDigest === $rightDigest; } return $diff; } /** * Run p4 diff2 against left/right files and parse output into array. * * @param array $diff diff result array we are building. * @param File $right right-hand file. * @param File $left left-hand file. * @param array $options influences diff behavior. * @return array diff result with lines added. */ protected function diffEdit(array $diff, File $right, File $left, array $options) { $mode = $options[static::IGNORE_WS] ? '-dwu5' : '-du5'; $flags = array($mode, $left->getFilespec(), $right->getFilespec()); $data = $this->getConnection()->run('diff2', $flags, null, false)->getData(); // diff output puts a file header in the first data block // (which we skip) and the diffs in one or more following blocks. $diffs = ""; for ($i = 1; $i < count($data); $i++) { $diffs .= $data[$i]; } // if we are requested to convert or replace; do so prior to split if ($options[static::UTF8_CONVERT] || $options[static::UTF8_SANITIZE]) { $filter = new Utf8Filter; $diffs = $filter->setConvertEncoding($options[static::UTF8_CONVERT]) ->setReplaceInvalid($options[static::UTF8_SANITIZE]) ->filter($diffs); } // parse diff block into lines // capture line-ending so we can detect line-end changes. $types = array('@' => 'meta', ' ' => 'same', '-' => 'delete', '+' => 'add'); $lines = preg_split("/(\r\n|\n|\r)/", $diffs, null, PREG_SPLIT_DELIM_CAPTURE); for ($i = 0; $i < count($lines); $i = $i+2) { $line = $lines[$i]; $end = isset($lines[$i+1]) ? $lines[$i+1] : ''; // skip empty or unexpected output if (!strlen($line) || !isset($types[$line[0]])) { continue; } $type = $types[$line[0]]; // extract starting left/right line numbers from meta block // meta block has the format of "@@ -133,29 +133,27 @@" if ($type === 'meta') { preg_match('/@@ \-([0-9]+),[0-9]+ \+([0-9]+),[0-9]+ @@/', $line, $matches); $leftLine = $matches[1]; $rightLine = $matches[2]; } $diff['lines'][] = array( 'value' => $line, 'type' => $type, 'lineEnd' => $end, 'leftLine' => ($type === 'same' || $type === 'delete') ? $leftLine++ : null, 'rightLine' => ($type === 'same' || $type === 'add') ? $rightLine++ : null ); } return $diff; } /** * Get file contents of added/deleted files. * * @param array $diff diff result array we are building. * @param File $right optional - right-hand file. * @param File $left optional - left-hand file. * @param array|null $options influences diff behavior. * @return array diff result with lines added. */ protected function diffAddDelete(array $diff, File $right = null, File $left = null, $options = null) { // contents must come from the side we have, or the side that is not deleted/purged // contents from right imply add, contents from left imply delete $file = $right && !$right->isDeletedOrPurged() ? $right : $left; $isAdd = $file == $right; // get file contents truncated to max filesize to avoid consuming too much memory. $options += array(File::MAX_FILESIZE => static::MAX_FILESIZE); $content = $file->getDepotContents($options, $cropped); $lines = preg_split("/\r\n|\n|\r/", $content); $count = count($lines); $meta = '@@ ' . ($isAdd ? '-1,0 +1,' . $count : '-1,' . $count . '+1,0') . ' @@'; $diff['isCut'] = $cropped ? static::MAX_FILESIZE : false; $diff['lines'][] = array( 'value' => $meta, 'type' => 'meta', 'leftLine' => null, 'rightLine' => null ); foreach ($lines as $i => $line) { $diff['lines'][] = array( 'value' => $isAdd ? '+' . $line : '-' . $line, 'type' => $isAdd ? 'add' : 'delete', 'leftLine' => $isAdd ? null : $i+1, 'rightLine' => $isAdd ? $i+1 : null ); } return $diff; } }