From f4e4284fc1805a3cc9d9eb6634cce7e21587a1ae Mon Sep 17 00:00:00 2001 From: Jeff Welch Date: Thu, 7 Feb 2013 00:50:01 -0500 Subject: [PATCH] Added diffing. --- src/PHP_Exporter/Diff.php | 325 ++++++++++++++++++++++++++++++++ src/PHP_Exporter/Exporter.php | 16 +- tests/PHP_Exporter/DiffTest.php | 277 +++++++++++++++++++++++++++ 3 files changed, 616 insertions(+), 2 deletions(-) create mode 100644 src/PHP_Exporter/Diff.php create mode 100644 tests/PHP_Exporter/DiffTest.php diff --git a/src/PHP_Exporter/Diff.php b/src/PHP_Exporter/Diff.php new file mode 100644 index 0000000..ff1792a --- /dev/null +++ b/src/PHP_Exporter/Diff.php @@ -0,0 +1,325 @@ +. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Sebastian Bergmann nor the names of his + * contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + * @package PHP_Exporter + * @author Sebastian Bergmann + * @author Kore Nordmann + * @copyright 2001-2013 Sebastian Bergmann + * @license http://www.opensource.org/licenses/BSD-3-Clause The BSD 3-Clause License + * @link http://www.github.com/whatthejeff/php-exporter + */ + +namespace PHP_Exporter; + +/** + * Diff implementation. + * + * @package PHP_Exporter + * @author Sebastian Bergmann + * @author Kore Nordmann + * @copyright 2001-2013 Sebastian Bergmann + * @license http://www.opensource.org/licenses/BSD-3-Clause The BSD 3-Clause License + * @link http://www.github.com/whatthejeff/php-exporter + */ +class Diff +{ + /** + * The original string + * + * @var string + */ + private $from; + + /** + * The new string + * + * @var string + */ + private $to; + + /** + * The diff header + * + * @var string + */ + private $header = "--- Original\n+++ New\n"; + + /** + * Constructs a new diff for two given strings + * + * @param mixed $from The original string + * @param mixed $to The new string + * @param mixed $header The diff header + */ + public function __construct($from, $to, $header = '') + { + $this->from = $from; + $this->to = $to; + + if($header) { + $this->header = $header; + } + } + + /** + * Exports a value into a string. + * + * @return string + * @see PHP_Exporter\Diff::diff + */ + public function __toString() + { + return $this->diff(); + } + + /** + * Returns the diff between two strings as a string. + * + * @return string + */ + public function diff() + { + if($this->from === $this->to) { + return ''; + } + + $buffer = $this->header; + $diff = $this->toArray(); + + $inOld = FALSE; + $i = 0; + $old = array(); + + foreach ($diff as $line) { + if ($line[1] === 0 /* OLD */) { + if ($inOld === FALSE) { + $inOld = $i; + } + } + + else if ($inOld !== FALSE) { + if (($i - $inOld) > 5) { + $old[$inOld] = $i - 1; + } + + $inOld = FALSE; + } + + ++$i; + } + + $start = isset($old[0]) ? $old[0] : 0; + $end = count($diff); + + if ($tmp = array_search($end, $old)) { + $end = $tmp; + } + + $newChunk = TRUE; + + for ($i = $start; $i < $end; $i++) { + if (isset($old[$i])) { + $buffer .= "\n"; + $newChunk = TRUE; + $i = $old[$i]; + } + + if ($newChunk) { + $buffer .= "@@ @@\n"; + $newChunk = FALSE; + } + + if ($diff[$i][1] === 1 /* ADDED */) { + $buffer .= '+' . $diff[$i][0] . "\n"; + } + + else if ($diff[$i][1] === 2 /* REMOVED */) { + $buffer .= '-' . $diff[$i][0] . "\n"; + } + + else { + $buffer .= ' ' . $diff[$i][0] . "\n"; + } + } + + return $buffer; + } + + /** + * Returns the diff between two strings as an array. + * + * every array-entry containts two elements: + * - [0] => string $token + * - [1] => 2|1|0 + * + * - 2: REMOVED: $token was removed from $from + * - 1: ADDED: $token was added to $from + * - 0: OLD: $token is not changed in $to + * + * @return array + */ + public function toArray() + { + if($this->from === $this->to) { + return array(); + } + + $from = preg_split('(\r\n|\r|\n)', $this->from); + $to = preg_split('(\r\n|\r|\n)', $this->to); + + $start = array(); + $end = array(); + $fromLength = count($from); + $toLength = count($to); + $length = min($fromLength, $toLength); + + for ($i = 0; $i < $length; ++$i) { + if ($from[$i] === $to[$i]) { + $start[] = $from[$i]; + unset($from[$i], $to[$i]); + } else { + break; + } + } + + $length -= $i; + + for ($i = 1; $i < $length; ++$i) { + if ($from[$fromLength - $i] === $to[$toLength - $i]) { + array_unshift($end, $from[$fromLength - $i]); + unset($from[$fromLength - $i], $to[$toLength - $i]); + } else { + break; + } + } + + $common = self::longestCommonSubsequence( + array_values($from), array_values($to) + ); + + $diff = array(); + + foreach ($start as $token) { + $diff[] = array($token, 0 /* OLD */); + } + + reset($from); + reset($to); + + foreach ($common as $token) { + while ((($fromToken = reset($from)) !== $token)) { + $diff[] = array(array_shift($from), 2 /* REMOVED */); + } + + while ((($toToken = reset($to)) !== $token)) { + $diff[] = array(array_shift($to), 1 /* ADDED */); + } + + $diff[] = array($token, 0 /* OLD */); + + array_shift($from); + array_shift($to); + } + + while (($token = array_shift($from)) !== NULL) { + $diff[] = array($token, 2 /* REMOVED */); + } + + while (($token = array_shift($to)) !== NULL) { + $diff[] = array($token, 1 /* ADDED */); + } + + foreach ($end as $token) { + $diff[] = array($token, 0 /* OLD */); + } + + return $diff; + } + + /** + * Calculates the longest common subsequence of two arrays. + * + * @param array $from + * @param array $to + * @return array + */ + protected static function longestCommonSubsequence(array $from, array $to) + { + $common = array(); + $matrix = array(); + $fromLength = count($from); + $toLength = count($to); + + for ($i = 0; $i <= $fromLength; ++$i) { + $matrix[$i][0] = 0; + } + + for ($j = 0; $j <= $toLength; ++$j) { + $matrix[0][$j] = 0; + } + + for ($i = 1; $i <= $fromLength; ++$i) { + for ($j = 1; $j <= $toLength; ++$j) { + $matrix[$i][$j] = max( + $matrix[$i-1][$j], + $matrix[$i][$j-1], + $from[$i-1] === $to[$j-1] ? $matrix[$i-1][$j-1] + 1 : 0 + ); + } + } + + $i = $fromLength; + $j = $toLength; + + while ($i > 0 && $j > 0) { + if ($from[$i-1] === $to[$j-1]) { + array_unshift($common, $from[$i-1]); + --$i; + --$j; + } + + else if ($matrix[$i][$j-1] > $matrix[$i-1][$j]) { + --$j; + } + + else { + --$i; + } + } + + return $common; + } +} diff --git a/src/PHP_Exporter/Exporter.php b/src/PHP_Exporter/Exporter.php index 122447e..abbf82d 100644 --- a/src/PHP_Exporter/Exporter.php +++ b/src/PHP_Exporter/Exporter.php @@ -72,7 +72,7 @@ class Exporter } /** - * Exports a value into a string + * Exports a value as a string * * The output of this method is similar to the output of print_r(), but * improved in various aspects: @@ -93,7 +93,7 @@ class Exporter } /** - * Exports a value into a string. + * Exports a value as a string. * * @return string * @see PHP_Exporter\Exporter::export @@ -257,6 +257,18 @@ class Exporter return $this->export(); } + /** + * Gets a diff of the current value and a given value. + * + * @param mixed $value The value to diff with the current value + * @param mixed $header The diff header + * @return PHP_Exporter\Diff + */ + public function diff($value, $header = '') + { + return new Diff((string)$this, (string)new self($value), $header); + } + /** * Converts an object to an array containing all of its private, protected * and public properties. diff --git a/tests/PHP_Exporter/DiffTest.php b/tests/PHP_Exporter/DiffTest.php new file mode 100644 index 0000000..e6bcecb --- /dev/null +++ b/tests/PHP_Exporter/DiffTest.php @@ -0,0 +1,277 @@ +. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in + * the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Sebastian Bergmann nor the names of his + * contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS + * FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + * + * @package PHP_Exporter + * @author Sebastian Bergmann + * @copyright 2001-2013 Sebastian Bergmann + * @license http://www.opensource.org/licenses/BSD-3-Clause The BSD 3-Clause License + * @link http://www.github.com/whatthejeff/php-exporter + */ + +use PHP_Exporter\Diff; + +/** + * + * + * @package PHPUnit + * @author Sebastian Bergmann + * @copyright 2001-2013 Sebastian Bergmann + * @license http://www.opensource.org/licenses/BSD-3-Clause The BSD 3-Clause License + * @link http://www.github.com/whatthejeff/php-exporter + */ +class DiffTest extends PHPUnit_Framework_TestCase +{ + const REMOVED = 2; + const ADDED = 1; + const OLD = 0; + + /** + * @covers PHP_Exporter\Diff::diff + */ + public function testComparisonErrorMessage() + { + $this->assertEquals( + "--- Original\n+++ New\n@@ @@\n-a\n+b\n", + new Diff('a', 'b') + ); + } + + /** + * @covers PHP_Exporter\Diff::toArray + */ + public function testComparisonErrorMessage_toArray() + { + $expected = array(); + $expected[] = array('a', self::REMOVED); + $expected[] = array('b', self::ADDED); + + $diff = new Diff('a', 'b'); + $this->assertEquals($expected, $diff->toArray()); + } + + /** + * @covers PHP_Exporter\Diff::diff + */ + public function testComparisonErrorStartSame() + { + $this->assertEquals( + "--- Original\n+++ New\n@@ @@\n-ba\n+bc\n", + new Diff('ba', 'bc') + ); + } + + /** + * @covers PHP_Exporter\Diff::toArray + */ + public function testComparisonErrorStartSame_toArray() + { + $expected = array(); + $expected[] = array('ba', self::REMOVED); + $expected[] = array('bc', self::ADDED); + + $diff = new Diff('ba', 'bc'); + $this->assertEquals($expected, $diff->toArray()); + } + + /** + * @covers PHP_Exporter\Diff::diff + */ + public function testComparisonErrorEndSame() + { + $this->assertEquals( + "--- Original\n+++ New\n@@ @@\n-ab\n+cb\n", + new Diff('ab', 'cb') + ); + } + + /** + * @covers PHP_Exporter\Diff::toArray + */ + public function testComparisonErrorEndSame_toArray() + { + $expected = array(); + $expected[] = array('ab', self::REMOVED); + $expected[] = array('cb', self::ADDED); + + $diff = new Diff('ab', 'cb'); + $this->assertEquals($expected, $diff->toArray()); + } + + /** + * @covers PHP_Exporter\Diff::diff + */ + public function testComparisonErrorStartAndEndSame() + { + $this->assertEquals( + "--- Original\n+++ New\n@@ @@\n-abc\n+adc\n", + new Diff('abc', 'adc') + ); + } + + /** + * @covers PHP_Exporter\Diff::toArray + */ + public function testComparisonErrorStartAndEndSame_toArray() + { + $expected = array(); + $expected[] = array('abc', self::REMOVED); + $expected[] = array('adc', self::ADDED); + + $diff = new Diff('abc', 'adc'); + $this->assertEquals($expected, $diff->toArray()); + } + + /** + * @covers PHP_Exporter\Diff::diff + */ + public function testComparisonErrorStartSameComplete() + { + $this->assertEquals( + "--- Original\n+++ New\n@@ @@\n-ab\n+abc\n", + new Diff('ab', 'abc') + ); + } + + /** + * @covers PHP_Exporter\Diff::toArray + */ + public function testComparisonErrorStartSameComplete_toArray() + { + $expected = array(); + $expected[] = array('ab', self::REMOVED); + $expected[] = array('abc', self::ADDED); + + $diff = new Diff('ab', 'abc'); + $this->assertEquals($expected, $diff->toArray()); + } + + /** + * @covers PHP_Exporter\Diff::diff + */ + public function testComparisonErrorEndSameComplete() + { + $this->assertEquals( + "--- Original\n+++ New\n@@ @@\n-bc\n+abc\n", + new Diff('bc', 'abc') + ); + } + + /** + * @covers PHP_Exporter\Diff::toArray + */ + public function testComparisonErrorEndSameComplete_toArray() + { + $expected = array(); + $expected[] = array('bc', self::REMOVED); + $expected[] = array('abc', self::ADDED); + + $diff = new Diff('bc', 'abc'); + $this->assertEquals($expected, $diff->toArray()); + } + + /** + * @covers PHP_Exporter\Diff::diff + */ + public function testComparisonErrorOverlapingMatches() + { + $this->assertEquals( + "--- Original\n+++ New\n@@ @@\n-abc\n+abbc\n", + new Diff('abc', 'abbc') + ); + } + + /** + * @covers PHP_Exporter\Diff::toArray + */ + public function testComparisonErrorOverlapingMatches_toArray() + { + $expected = array(); + $expected[] = array('abc', self::REMOVED); + $expected[] = array('abbc', self::ADDED); + + $diff = new Diff('abc', 'abbc'); + $this->assertEquals($expected, $diff->toArray()); + } + + /** + * @covers PHP_Exporter\Diff::diff + */ + public function testComparisonErrorOverlapingMatches2() + { + $this->assertEquals( + "--- Original\n+++ New\n@@ @@\n-abcdde\n+abcde\n", + new Diff('abcdde', 'abcde') + ); + } + + /** + * @covers PHP_Exporter\Diff::toArray + */ + public function testComparisonErrorOverlapingMatches2_toArray() + { + $expected = array(); + $expected[] = array('abcdde', self::REMOVED); + $expected[] = array('abcde', self::ADDED); + + $diff = new Diff('abcdde', 'abcde'); + $this->assertEquals($expected, $diff->toArray()); + } + + /** + * @covers PHP_Exporter\Diff::diff + */ + public function testEmptyDiff() + { + $this->assertEquals('', new Diff('abc', 'abc')); + } + + /** + * @covers PHP_Exporter\Diff::toArray + */ + public function testEmptyDiff_toArray() + { + $diff = new Diff('abc', 'abc'); + $this->assertEquals(array(), $diff->toArray()); + } + + public function testCustomHeader() + { + $this->assertEquals( + "CUSTOM HEADER@@ @@\n-a\n+b\n", + new Diff('a', 'b', 'CUSTOM HEADER') + ); + } +}