diff --git a/README.md b/README.md index 5cbc6f8..3ad115e 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,8 @@ $options = [ 'ignoreWhitespace' => true, 'ignoreCase' => true, 'context' => 2, - 'cliColor' => true // for cli output + 'cliColor' => true, // for cli output + 'ignoreLines' => Diff::DIFF_IGNORE_LINE_BLANK, ]; // Initialize the diff class. @@ -140,7 +141,6 @@ at [jQuery-Merge-for-php-diff](https://github.com/Xiphe/jQuery-Merge-for-php-dif ## Todo -* Ability to ignore blank line changes * 3 way diff support ## Contributors diff --git a/example/dark-theme.css b/example/dark-theme.css index 175b075..7fa9840 100644 --- a/example/dark-theme.css +++ b/example/dark-theme.css @@ -97,6 +97,19 @@ a, a:visited { background: #EEBB00; } +.DifferencesSideBySide .ChangeIgnore .Left, +.DifferencesSideBySide .ChangeIgnore .Right { + background: #FBF2BF; +} + +.DifferencesSideBySide .ChangeIgnore .Left.Ignore { + background: #4B4C57; +} + +.DifferencesSideBySide .ChangeIgnore .Right.Ignore { + background: #4B4C57; +} + /* * HTML Unified Diff */ @@ -127,6 +140,19 @@ a, a:visited { color: #272822; } +.DifferencesUnified .ChangeIgnore .Left, +.DifferencesUnified .ChangeIgnore .Right { + background: #FBF2BF; +} + +.DifferencesUnified .ChangeIgnore .Left.Ignore { + background: #4B4C57; +} + +.DifferencesUnified .ChangeIgnore .Right.Ignore { + background: #4B4C57; +} + /* * HTML Merged Diff */ diff --git a/example/example.php b/example/example.php index fe2cb0c..e4d7ed0 100644 --- a/example/example.php +++ b/example/example.php @@ -20,6 +20,7 @@ 'trimEqual' => false, 'ignoreWhitespace' => true, 'ignoreCase' => true, + 'ignoreLines' => Diff::DIFF_IGNORE_LINE_EMPTY, ]; // Choose one of the initializations. diff --git a/example/styles.css b/example/styles.css index 5dd6abe..23c6839 100644 --- a/example/styles.css +++ b/example/styles.css @@ -78,6 +78,19 @@ pre { background: #FFDD88; } +.DifferencesSideBySide .ChangeIgnore .Left, +.DifferencesSideBySide .ChangeIgnore .Right { + background: #FBF2BF; +} + +.DifferencesSideBySide .ChangeIgnore .Left.Ignore { + background: #F7F7F7; +} + +.DifferencesSideBySide .ChangeIgnore .Right.Ignore { + background: #F7F7F7; +} + .Differences ins, .Differences del { text-decoration: none; @@ -109,6 +122,19 @@ pre { background: #EE9999; } +.DifferencesUnified .ChangeIgnore .Left, +.DifferencesUnified .ChangeIgnore .Right { + background: #FBF2BF; +} + +.DifferencesUnified .ChangeIgnore .Left.Ignore { + background: #F7F7F7; +} + +.DifferencesUnified .ChangeIgnore .Right.Ignore { + background: #F7F7F7; +} + /* * HTML Merged Diff */ diff --git a/lib/jblond/Diff.php b/lib/jblond/Diff.php index 3bb3ce0..4038056 100644 --- a/lib/jblond/Diff.php +++ b/lib/jblond/Diff.php @@ -5,6 +5,7 @@ namespace jblond; use InvalidArgumentException; +use jblond\Diff\ConstantsInterface; use jblond\Diff\SequenceMatcher; use jblond\Diff\Similarity; use OutOfRangeException; @@ -26,7 +27,7 @@ * @version 2.3.3 * @link https://github.com/JBlond/php-diff */ -class Diff +class Diff implements ConstantsInterface { /** * @var array The first version to compare. @@ -46,21 +47,22 @@ class Diff private $groupedCodes; /** - * @var array Associative array containing the default options available - * for the diff class and their default value. + * @var array Associative array containing the default options available for the diff class and their default value. * - * - context The amount of lines to include around blocks that differ. - * - trimEqual Strip blocks of equal lines from the start and end of the text. - * - ignoreWhitespace When true, tabs and spaces are ignored while comparing. - * The spacing of version1 is leading. - * - ignoreCase When true, character casing is ignored while comparing. - * The casing of version1 is leading. + * - context The amount of lines to include around blocks that differ. + * - trimEqual Strip blocks of equal lines from the start and end of the text. + * - ignoreWhitespace True to ignore differences in tabs and spaces. + * - ignoreCase True to ignore differences in character casing. + * - ignoreLines 0: None. + * 1: Ignore empty lines. + * 2: Ignore blank lines. */ private $defaultOptions = [ 'context' => 3, 'trimEqual' => true, 'ignoreWhitespace' => false, 'ignoreCase' => false, + 'ignoreLines' => self::DIFF_IGNORE_LINE_NONE, ]; /** diff --git a/lib/jblond/Diff/ConstantsInterface.php b/lib/jblond/Diff/ConstantsInterface.php new file mode 100644 index 0000000..4166a60 --- /dev/null +++ b/lib/jblond/Diff/ConstantsInterface.php @@ -0,0 +1,33 @@ + + * @copyright (c) 2020 Mario Brandt + * @license New BSD License http://www.opensource.org/licenses/bsd-license.php + * @version 2.3.0 + * @link https://github.com/JBlond/php-diff + */ +interface ConstantsInterface +{ + /** + * Flag to disable ignore of successive empty/blank lines. + */ + public const DIFF_IGNORE_LINE_NONE = 0; + /** + * Flag to ignore empty lines. + */ + public const DIFF_IGNORE_LINE_EMPTY = 1; + /** + * Flag to ignore blank lines. (Lines which contain no or only non printable characters.) + */ + public const DIFF_IGNORE_LINE_BLANK = 2; +} diff --git a/lib/jblond/Diff/Renderer/Html/SideBySide.php b/lib/jblond/Diff/Renderer/Html/SideBySide.php index 72a23e2..db7ccf7 100644 --- a/lib/jblond/Diff/Renderer/Html/SideBySide.php +++ b/lib/jblond/Diff/Renderer/Html/SideBySide.php @@ -282,4 +282,70 @@ public function generateDiffFooter(): string { return ''; } + + /** + * @inheritDoc + * + * @return string Html code representing table rows showing ignored text. + */ + public function generateLinesIgnore(array $changes): string + { + $html = ''; + + // Is below comparison result ever false? + if (count($changes['base']['lines']) >= count($changes['changed']['lines'])) { + foreach ($changes['base']['lines'] as $lineNo => $line) { + $fromLine = $changes['base']['offset'] + $lineNo + 1; + $toLine = ' '; + $changedLine = ' '; + if (isset($changes['changed']['lines'][$lineNo])) { + $toLine = $changes['changed']['offset'] + $lineNo + 1; + $changedLine = $changes['changed']['lines'][$lineNo]; + } + + $html .= << + $fromLine + + $line + + $toLine + + $changedLine + + +HTML; + } + + return $html; + } + + foreach ($changes['changed']['lines'] as $lineNo => $changedLine) { + $toLine = $changes['changed']['offset'] + $lineNo + 1; + $fromLine = ' '; + $line = ' '; + if (isset($changes['base']['lines'][$lineNo])) { + $fromLine = $changes['base']['offset'] + $lineNo + 1; + $line = $changes['base']['lines'][$lineNo]; + } + + $line = str_replace(["\0", "\1"], $this->options['deleteMarkers'], $line); + $changedLine = str_replace(["\0", "\1"], $this->options['insertMarkers'], $changedLine); + + $html .= << + $fromLine + + $line + + $toLine + + $changedLine + + +HTML; + } + + return $html; + } } diff --git a/lib/jblond/Diff/Renderer/Html/Unified.php b/lib/jblond/Diff/Renderer/Html/Unified.php index cd8cd00..1f9faf3 100644 --- a/lib/jblond/Diff/Renderer/Html/Unified.php +++ b/lib/jblond/Diff/Renderer/Html/Unified.php @@ -219,6 +219,44 @@ public function generateLinesReplace(array $changes): string return $html; } + /** + * @inheritDoc + * + * @return string Html code representing table rows showing modified text. + */ + public function generateLinesIgnore(array $changes): string + { + $html = ''; + + foreach ($changes['base']['lines'] as $lineNo => $line) { + $fromLine = $changes['base']['offset'] + $lineNo + 1; + $html .= << + $fromLine + + + $line + + +HTML; + } + + foreach ($changes['changed']['lines'] as $lineNo => $line) { + $toLine = $changes['changed']['offset'] + $lineNo + 1; + $html .= << + + $toLine + + $line + + +HTML; + } + + return $html; + } + /** * @inheritDoc * diff --git a/lib/jblond/Diff/Renderer/MainRenderer.php b/lib/jblond/Diff/Renderer/MainRenderer.php index 91a6330..21e9a97 100644 --- a/lib/jblond/Diff/Renderer/MainRenderer.php +++ b/lib/jblond/Diff/Renderer/MainRenderer.php @@ -65,7 +65,23 @@ public function renderOutput(array $changes, object $subRenderer) strlen($this->options['equalityMarkers'][1]) ); + $deprecationTriggered = false; foreach ($blocks as $change) { + if ( + $subRenderer instanceof MainRenderer && + !method_exists($subRenderer, 'generateLinesIgnore') && + $change['tag'] == 'ignore' + ) { + if (!$deprecationTriggered) { + trigger_error( + 'The use of a subRenderer without method generateLinesIgnore() is deprecated!', + E_USER_DEPRECATED + ); + $deprecationTriggered = true; + } + $change['tag'] = + (count($change['base']['lines']) > count($change['changed']['lines'])) ? 'delete' : 'insert'; + } $output .= $subRenderer->generateBlockHeader($change); switch ($change['tag']) { case 'equal': @@ -80,6 +96,10 @@ public function renderOutput(array $changes, object $subRenderer) case 'replace': $output .= $subRenderer->generateLinesReplace($change); break; + case 'ignore': + // TODO: Keep backward compatible with renderers? + $output .= $subRenderer->generateLinesIgnore($change); + break; } $output .= $subRenderer->generateBlockFooter($change); @@ -124,12 +144,14 @@ protected function renderSequences(): array * 4 - The end line in the second sequence. * * The different types of tags include: - * replace - The string from $startOld to $endOld in $oldText should be replaced by + * replace - The string in $oldText from $startOld to $endOld, should be replaced by * the string in $newText from $startNew to $endNew. * delete - The string in $oldText from $startOld to $endNew should be deleted. * insert - The string in $newText from $startNew to $endNew should be inserted at $startOld in * $oldText. * equal - The two strings with the specified ranges are equal. + * ignore - The string in $oldText from $startOld to $endOld and + * the string in $newText from $startNew to $endNew are different, but considered to be equal. */ $blockSizeOld = $endOld - $startOld; @@ -146,23 +168,23 @@ protected function renderSequences(): array $oldBlock = $this->formatLines(array_slice($oldText, $startOld, $blockSizeOld)); $newBlock = $this->formatLines(array_slice($newText, $startNew, $blockSizeNew)); - if ($tag == 'equal') { - // Old block equals New block + if ($tag != 'delete' && $tag != 'insert') { + // Old block "equals" New block or is replaced. $blocks[$lastBlock]['base']['lines'] += $oldBlock; $blocks[$lastBlock]['changed']['lines'] += $newBlock; continue; } - if ($tag == 'replace' || $tag == 'delete') { - // Inline differences or old block doesn't exist in the new text. + if ($tag == 'delete') { + // Block of version1 doesn't exist in version2. $blocks[$lastBlock]['base']['lines'] += $oldBlock; + continue; } - if ($tag == 'replace' || $tag == 'insert') { - // Inline differences or the new block doesn't exist in the old text. - $blocks[$lastBlock]['changed']['lines'] += $newBlock; - } + // Block of version2 doesn't exist in version1. + $blocks[$lastBlock]['changed']['lines'] += $newBlock; } + $changes[] = $blocks; } @@ -291,7 +313,7 @@ public function sequenceToArray(string $pattern, string $sequence): array * E.g. *
      *         1234567
-     * OLd => "abcdefg" Start marker inserted at position 3
+     * Old => "abcdefg" Start marker inserted at position 3
      * New => "ab123fg"   End marker inserted at position 6
      * 
* diff --git a/lib/jblond/Diff/Renderer/SubRendererInterface.php b/lib/jblond/Diff/Renderer/SubRendererInterface.php index 4f3ad3d..8feffbe 100644 --- a/lib/jblond/Diff/Renderer/SubRendererInterface.php +++ b/lib/jblond/Diff/Renderer/SubRendererInterface.php @@ -41,13 +41,6 @@ public function generateDiffHeader(): string; */ public function generateBlockHeader(array $changes): string; - /** - * Generate a string representation of lines that are skipped in the diff view. - * - * @return string Representation of skipped lines. - */ - public function generateSkippedLines(): string; - /** * Generate a string representation of lines without differences between both versions. * @@ -69,6 +62,23 @@ public function generateLinesEqual(array $changes): string; */ public function generateLinesInsert(array $changes): string; + /** + * Generate a string representation of lines that are skipped in the diff view. + * + * @return string Representation of skipped lines. + */ + public function generateSkippedLines(): string; + + /** + * Generate a string representation of lines with ignored differences between both versions. + * + * @param array $changes Contains the op-codes about the changes between two blocks of text. + * + * @return string Text with no difference. + * @todo: Uncomment once deprecation period is over. + */ + // public function generateLinesIgnore(array $changes): string; + /** * Generate a string representation of lines that are removed from the 2nd version. * diff --git a/lib/jblond/Diff/Renderer/Text/Context.php b/lib/jblond/Diff/Renderer/Text/Context.php index d6f04cc..30b11c4 100644 --- a/lib/jblond/Diff/Renderer/Text/Context.php +++ b/lib/jblond/Diff/Renderer/Text/Context.php @@ -65,6 +65,7 @@ public function render() // Line differences between versions or lines of version 1 are removed from version 2. // Add all operations to diff-view of version 1, except for insert. $filteredGroups = $this->filterGroups($group, 'insert'); + $filteredGroups = $this->filterGroups($filteredGroups, 'ignore'); foreach ($filteredGroups as [$tag, $start1, $end1, $start2, $end2]) { $diff .= $this->tagMap[$tag] . ' ' . implode( @@ -81,6 +82,7 @@ public function render() // Line differences between versions or lines are inserted into version 2. // Add all operations to diff-view of version 2, except for delete. $filteredGroups = $this->filterGroups($group, 'delete'); + $filteredGroups = $this->filterGroups($filteredGroups, 'ignore'); foreach ($filteredGroups as [$tag, $start1, $end1, $start2, $end2]) { $diff .= $this->tagMap[$tag] . ' ' . implode( diff --git a/lib/jblond/Diff/SequenceMatcher.php b/lib/jblond/Diff/SequenceMatcher.php index 32af5d4..5c7859e 100644 --- a/lib/jblond/Diff/SequenceMatcher.php +++ b/lib/jblond/Diff/SequenceMatcher.php @@ -20,7 +20,7 @@ * @version 2.3.3 * @link https://github.com/JBlond/php-diff */ -class SequenceMatcher +class SequenceMatcher implements ConstantsInterface { /** * @var array The first sequence to compare against. @@ -30,6 +30,13 @@ class SequenceMatcher * @var array The second sequence. */ protected $new; + /** + * @var array Associative array containing the options that will be applied for generating the diff. + * The key-value pairs are set at the constructor of this class. + * + * @see SequenceMatcher::setOptions() + */ + protected $options = []; /** * @var string|array Either a string or an array containing a callback function to determine * if a line is "junk" or not. @@ -39,36 +46,37 @@ class SequenceMatcher * @var array Array of characters that are considered junk from the second sequence. Characters are the array key. */ private $junkDict = []; - /** * @var array Array of indices that do not contain junk elements. */ private $b2j = []; - - /** - * @var array - */ - private $options = []; - /** - * @var null|array + * @var array A list of all of the op-codes for the differences between the compared strings. */ private $opCodes; /** - * @var null|array + * @var array A nested set of arrays for all of the matching sub-sequences the compared strings. */ private $matchingBlocks; /** - * @var array + * @var array Associative array containing the default options available for the diff class and their default value. + * + * - context The amount of lines to include around blocks that differ. + * - trimEqual Strip blocks of equal lines from the start and end of the text. + * - ignoreWhitespace True to ignore differences in tabs and spaces. + * - ignoreCase True to ignore differences in character casing. + * - ignoreLines 0: None. + * 1: Ignore empty lines. + * 2: Ignore blank lines. */ private $defaultOptions = [ 'context' => 3, 'trimEqual' => true, 'ignoreWhitespace' => false, 'ignoreCase' => false, - 'ignoreNewLines' => false, + 'ignoreLines' => self::DIFF_IGNORE_LINE_NONE, ]; /** @@ -298,8 +306,7 @@ public function getGroupedOpCodes(): array * Return a list of all the op codes for the differences between the * two strings. * - * The nested array returned contains an array describing the op code - * which includes: + * The nested array returned contains an array describing the op code which includes: * 0 - The type of tag (as described below) for the op code. * 1 - The beginning line in the first sequence. * 2 - The end line in the first sequence. @@ -338,6 +345,34 @@ public function getOpCodes(): array $tag = 'insert'; } + if ($this->options['ignoreLines']) { + $slice1 = array_slice($this->old, $i, $ai - $i); + $slice2 = array_slice($this->new, $j, $bj - $j); + + if ($this->options['ignoreLines'] == 2) { + array_walk( + $slice1, + function (&$line) { + $line = trim($line); + } + ); + array_walk( + $slice2, + function (&$line) { + $line = trim($line); + } + ); + unset($line); + } + + if ( + ($tag == 'delete' && implode('', $slice1) == '') || + ($tag == 'insert' && implode('', $slice2) == '') + ) { + $tag = 'ignore'; + } + } + if ($tag) { $this->opCodes[] = [ $tag, @@ -369,9 +404,8 @@ public function getOpCodes(): array * Return a nested set of arrays for all the matching sub-sequences * in the strings $a and $b. * - * Each block contains the lower constraint of the block in $a, the lower - * constraint of the block in $b and finally the number of lines that the - * block continues for. + * Each block contains the lower constraint of the block in $a, the lower constraint of the block in $b and finally + * the number of lines that the block continues for. * * @return array Nested array of the matching blocks, as described by the function. */ diff --git a/lib/jblond/Diff/Similarity.php b/lib/jblond/Diff/Similarity.php index ea7b46e..fc9c961 100644 --- a/lib/jblond/Diff/Similarity.php +++ b/lib/jblond/Diff/Similarity.php @@ -37,6 +37,11 @@ class Similarity extends SequenceMatcher * @var array Count of each unique sequence at version 2. */ private $uniqueCount2; + /** + * @var array Contains the indexes of lines which are stripped from the sequences by Similarity::stripLines(). + * @see Similarity::stripLines() + */ + private $stripped = ['old' => [], 'new' => []]; /** @@ -65,12 +70,22 @@ public function setSeq2($version2) */ public function getSimilarity(int $type = self::CALC_DEFAULT): float { + if ($this->options['ignoreLines']) { + // Backup original sequences and filter non blank lines. + $this->stripLines(); + } + switch ($type) { case self::CALC_FAST: - return $this->getRatioFast(); + $ratio = $this->getRatioFast(); + $this->restoreLines(); + break; case self::CALC_FASTEST: - return $this->getRatioFastest(); + $ratio = $this->getRatioFastest(); + $this->restoreLines(); + break; default: + $this->setSequences($this->old, $this->new); $matches = array_reduce( $this->getMatchingBlocks(), function ($carry, $item) { @@ -79,7 +94,44 @@ function ($carry, $item) { 0 ); - return $this->calculateRatio($matches, count($this->old) + count($this->new)); + $ratio = $this->calculateRatio($matches, count($this->old) + count($this->new)); + $this->restoreLines(); + $this->setSequences($this->old, $this->new); + } + + return $ratio; + } + + /** + * Strip empty or blank lines from the sequences to compare. + * + */ + private function stripLines(): void + { + foreach (['old', 'new'] as $version) { + // Remove empty lines. + $this->$version = array_filter( + $this->$version, + function ($line, $index) use ($version) { + $sanitizedLine = $line; + if ($this->options['ignoreLines'] == self::DIFF_IGNORE_LINE_BLANK) { + $sanitizedLine = trim($line); + } + + if ($sanitizedLine == '') { + // Store line to be able to restore later. + $this->stripped[$version][$index] = $line; + + return false; + } + + return true; + }, + ARRAY_FILTER_USE_BOTH + ); + + // Re-index sequence. + $this->$version = array_values($this->$version); } } @@ -93,6 +145,7 @@ function ($carry, $item) { private function getRatioFast(): float { if ($this->uniqueCount2 === null) { + // Build unless cached. $this->uniqueCount2 = []; $bLength = count($this->new); for ($iterator = 0; $iterator < $bLength; ++$iterator) { @@ -136,6 +189,15 @@ private function calculateRatio(int $matches, int $length = 0): float return $returnValue; } + private function restoreLines() + { + foreach (['old', 'new'] as $version) { + foreach ($this->stripped[$version] as $index => $line) { + array_splice($this->$version, $index, 0, $line); + } + } + } + /** * Return an upper bound ratio really quickly for the similarity of the strings. * @@ -151,7 +213,6 @@ private function getRatioFastest(): float return $this->calculateRatio(min($aLength, $bLength), $aLength + $bLength); } - /** * Helper function to calculate the number of matches for Ratio(). * diff --git a/tests/Diff/SequenceMatcherTest.php b/tests/Diff/SequenceMatcherTest.php index 16f8d3d..af8f200 100644 --- a/tests/Diff/SequenceMatcherTest.php +++ b/tests/Diff/SequenceMatcherTest.php @@ -84,7 +84,7 @@ public function testGetGroupedOpCodesIgnoreWhitespaceTrue() } /** - *T est the opCodes of the differences between version1 and version2 with option ignoreCase enabled. + * Test the opCodes of the differences between version1 and version2 with option ignoreCase enabled. */ public function testGetGroupedOpCodesIgnoreCaseTrue() { @@ -97,4 +97,52 @@ public function testGetGroupedOpCodesIgnoreCaseTrue() $this->assertEquals([], $sequenceMatcher->getGroupedOpCodes()); } + + /** + * Test the opCodes of the differences between version1 and version2 with option ignoreLines set to empty. + */ + public function testGetGroupedOpCodesIgnoreLinesEmpty() + { + // Test with ignoreCase enabled. Both sequences are considered to be the same. + $sequenceMatcher = new SequenceMatcher( + [0, 1, 2, 3], + [0, 1, '', 2, 3], + ['ignoreLines' => SequenceMatcher::DIFF_IGNORE_LINE_EMPTY] + ); + + $this->assertEquals( + [ + [ + ['equal', 0, 2, 0, 2], + ['ignore', 2, 2, 2, 3], + ['equal', 2, 4, 3, 5], + ], + ], + $sequenceMatcher->getGroupedOpCodes() + ); + } + + /** + * Test the opCodes of the differences between version1 and version2 with option ignoreLines set to blank. + */ + public function testGetGroupedOpCodesIgnoreLinesBlank() + { + // Test with ignoreCase enabled. Both sequences are considered to be the same. + $sequenceMatcher = new SequenceMatcher( + [0, 1, 2, 3], + [0, 1, "\t", 2, 3], + ['ignoreLines' => SequenceMatcher::DIFF_IGNORE_LINE_BLANK] + ); + + $this->assertEquals( + [ + [ + ['equal', 0, 2, 0, 2], + ['ignore', 2, 2, 2, 3], + ['equal', 2, 4, 3, 5], + ], + ], + $sequenceMatcher->getGroupedOpCodes() + ); + } }