The "diff" is done in the diff() function (only 25 LoC - although some might call this "too compact"); the rest is for presentation purpose (plain-text or HTML, side-by-side = horizontal, or diff lines above eachother = vertical):
<?php
namespace Rsi;
class Diff{
const TYPE_EQUAL = 'e'; //!< A and B are equal.
const TYPE_DIFF = 'x'; //!< A and B differ.
const TYPE_SPACE = 's'; //!< A and B differ only in leading and trailing white-space.
const TYPE_DIFF_A = 'a'; //!< A is not present in B.
const TYPE_DIFF_B = 'b'; //!< B is not present in A.
const HTML_MODE_VERTICAL = 'vertical'; //!< Show A above B in HTML view (when different).
const HTML_MODE_HORIZONTAL = 'horizontal'; //!< Show A left and B right in HTML view.
public $defaultHead = ['A','B']; //!< Default header for the HTML table.
public $extra = 2; //!< Extra, unchanged lines to show around changes.
public $extraAllThreshold = 2; //!< Show all code when there is max this number of lines to be hidden.
public $styleFile = __DIR__ . '/diff.css'; //!< Stylsheet filename.
public $htmlMode = self::HTML_MODE_VERTICAL; //!< See HTML_MODE_* constants.
public $whiteSpaceThreshold = 0.2; //!< Only show "white-space only" changes if they are more then this part of the (maximum)
// number of lines in the block.
public $lineDiffMaxLines = 2; //!< Only show detailed diff info if the difference in line count is equal or below this amount.
public $lineDiffThreshold = 0.6; //!< Only show detailed diff info if more than this part of the block stayed the same.
public $skipTemplate = '* lines'; //!< Template for skipped lines (asterisk is replaced with number of lines).
protected $_a = []; //!< Lines for side A.
protected $_b = []; //!< Lines for side B.
protected $_diff = []; //!< Diff results (array of blocks with keys: t = type, sa = start of block in A, ca = line count for
// block in A, sb = start of block in B, cb = line count for block in B).
/**
* Create diff.
* @param string $a Text for A.
* @param string $b Text for B.
* @param bool Set to true if A and B are both local filenames. Besides loading the files, this will also set the default
* head to the filenames, combined with the modification date (and time, when changed within 24 hours of eachother).
*/
public function __construct($a,$b,$files = false){
if($files){
$ta = filemtime($a);
$tb = filemtime($b);
$format = 'Y-m-d' . (($diff = abs($ta - $tb)) < 86400 ? ' H:i' . ($diff < 60 ? ':s' : null) : null);
$this->defaultHead = [$a . ' @ ' . date($format,$ta),$b . ' @ ' . date($format,$tb)];
$a = file_get_contents($a);
$b = file_get_contents($b);
}
$this->_diff = $this->diff($this->_a = $this->lines($a),$this->_b = $this->lines($b));
}
protected function lines($str){
return $str === null ? [] : explode("\n",$str);
}
/**
* Calculate the difference between two blocks.
* @param array $a Lines of block A.
* @param array $b Lines of block B.
* @param int $oa Offset of block A in relation to the comlete file.
* @param int $ob Offset of block B in relation to the comlete file.
* @return array Array with a record per comparison block ('t' = block type - see TYPE_* constants, 'sa' = start of block in
* A, 'ca' = number of lines in A, 'sb' = start of block in B, 'cb' = number of lines in B).
*/
protected function diff($a,$b,$oa = 0,$ob = 0){
//prefixes: o = offset, c = count, s = start, i = index
$ca = count($a);
$cb = count($b);
$sa = $sb = $count = 0;
$cache = [];
for($ia = 0; $ia < $ca - $count; $ia++)
foreach(($cache[$a[$ia]] ?? ($cache[$a[$ia]] = array_keys($b,$a[$ia],true))) as $ib) if($ib < $cb - $count){
for($c = 1; $c < $count; $c++) if($a[$ia + $c] !== $b[$ib + $c]) continue 2;
while(($ia + $c < $ca) && ($ib + $c < $cb) && ($a[$ia + $c] === $b[$ib + $c])) $c++;
$count = $c;
$sa = $ia;
$sb = $ib;
}
return $count
? array_merge(
$this->diff(array_slice($a,0,$sa),array_slice($b,0,$sb),$oa,$ob),
[['t' => self::TYPE_EQUAL,'sa' => $oa + $sa,'ca' => $count,'sb' => $ob + $sb,'cb' => $count]],
$this->diff(array_slice($a,$sa + $count),array_slice($b,$sb + $count),$oa + $sa + $count,$ob + $sb + $count)
)
: ($a || $b ? [[
't' => ($a && $b) ? self::TYPE_DIFF : ($a ? self::TYPE_DIFF_A : self::TYPE_DIFF_B),
'sa' => $oa,'ca' => $ca,
'sb' => $ob,'cb' => $cb
]] : []);
}
/**
* Count the number of blocks of a certain type.
* @param string $type Diff type (see DIFF_* constants). Set to null to count all "not equal" blocks.
* @return int
*/
public function count($type = null){
$count = 0;
foreach($this->_diff as $block) if($type === null ? $block['t'] != self::TYPE_EQUAL : $block['t'] == $type) $count++;
return $count;
}
/**
* Maximum line number.
* @param bool $chars Return the number of characters for the largest line number.
* @return int Largest line number (or characters).
*/
public function maxLine($chars = false){
$lines = max(count($this->_a),count($this->_b));
return $chars ? ceil(log($lines,10)) : $lines;
}
protected function textLine($type,$sa,$ca,$sb,$cb){
//prefixes: s = start, c = count
$text = null;
switch($type){
case self::TYPE_EQUAL:
for($i = 0; $i < $ca; $i++) $text .= " " . $this->_a[$sa + $i] . "\n";
break;
default:
for($i = 0; $i < $ca; $i++) $text .= "-" . $this->_a[$sa + $i] . "\n";
for($i = 0; $i < $cb; $i++) $text .= "+" . $this->_b[$sb + $i] . "\n";
}
return $text;
}
/**
* Classic, textual diff output (A above B).
* @param mixed $head Header for the text (string). Set to true for a default head, or provide an array with the A and B
* description.
* @return string
*/
public function asText($head = null){
//prefixes: c = count, p = prefix, q = suffix (unchanged lines before and after the diff)
if($head === true) $head = $this->defaultHead;
if(is_array($head)) $head = "+++ " . array_shift($head) . "\n--- " . array_shift($head);
$text = $head ? "$head\n" : null;
$index = 0;
$count = count($this->_diff);
while($index < $count) if($this->_diff[$i = $index]['t'] != self::TYPE_EQUAL){
$blocks = [];
while(($i < $count) && (
($this->_diff[$i]['t'] != self::TYPE_EQUAL) ||
($this->_diff[$i]['ca'] <= $this->extra * 2 + $this->extraAllThreshold) //relativly small equal block
)) $blocks[] = $this->_diff[$i++];
$pa = min($this->extra,$sa = $blocks[0]['sa']);
$qa = min($this->extra,count($this->_a) - ($sa + ($ca = array_sum(array_column($blocks,'ca')))));
$pb = min($this->extra,$sb = $blocks[0]['sb']);
$qb = min($this->extra,count($this->_b) - ($sb + ($cb = array_sum(array_column($blocks,'cb')))));
$text .=
"@@ -" . ($sa - $pa + 1) . "," . ($ca + $pa + $qa) . " +" . ($sb - $pb + 1) . "," . ($cb + $pb + $qb) . " @@\n" .
$this->textLine($this->_diff[$index - 1]['t'] ?? self::TYPE_EQUAL,$sa - $pa,$pa,$sb - $pb,$pb);
foreach($blocks as $block) if(extract($block)) $text .= $this->textLine($t,$sa,$ca,$sb,$cb);
$text .= $this->textLine($this->_diff[$i]['t'] ?? self::TYPE_EQUAL,$sa + $ca,$qa,$sb + $cb,$qb);
$index = $i;
}
else $index++;
return $text;
}
/**
* Style block for the HTML diff.
* @return string
*/
public function htmlStyle(){
return "<style>" . str_replace('var(--diff-max-line)',$this->maxLine(true),file_get_contents($this->styleFile)) . "</style>";
}
protected function htmlLineBlock($type,$chars,$start,$count){
if(!$count) return null;
$prefix = $type == self::TYPE_EQUAL ? null : "<span class='text-$type'>";
$suffix = $type == self::TYPE_EQUAL ? null : "</span>";
return $prefix . str_replace("\n","$suffix\n$prefix",htmlentities(implode(array_slice($chars,$start,$count)))) . $suffix;
}
protected function htmlLineSpace($chars){
$html = null;
foreach($chars as $char){
$type = 'other';
switch($char){
case ' ': $type = 'space'; break;
case "\t": $type = 'tab'; break;
case "\r": $type = 'cr'; break;
}
$html .= "<span class='space type-$type'>$char</span>";
}
return $html;
}
protected function htmlLine($index,$type,$sa,$ca,$sb,$cb){
//prefixes: h = HTML for code, s = start, c = count, y = char array, p = prefix chars, q = suffix chars
$ha = $hb = null;
$a = array_slice($this->_a,$sa,$ca);
$b = array_slice($this->_b,$sb,$cb);
switch($type){
case self::TYPE_DIFF:
if(abs($ca - $cb) <= $this->lineDiffMaxLines){
$diff = $this->diff($ya = str_split(implode("\n",$a)),$yb = str_split(implode("\n",$b)));
$same = 0;
foreach($diff as $block) if($block['t'] == self::TYPE_EQUAL) $same += $block['ca'];
if($same / max(count($ya),count($yb)) >= $this->lineDiffThreshold) foreach($diff as $block){ //show char diff
$ha .= $this->htmlLineBlock($block['t'],$ya,$block['sa'],$block['ca']);
$hb .= $this->htmlLineBlock($block['t'],$yb,$block['sb'],$block['cb']);
}
}
break;
case self::TYPE_SPACE:
for($i = 0; $i < $ca; $i++){
$common = trim($a[$i]);
list($pa,$qa) = array_map('str_split',explode($common,$a[$i],2));
list($pb,$qb) = array_map('str_split',explode($common,$b[$i],2));
while($pa && $pb && (end($pa) == end($pb)) && array_pop($pa)) $common = array_pop($pb) . $common;
while($qa && $qb && ($qa[0] == $qb[0]) && array_shift($qa)) $common .= array_shift($qb);
$ha .= ($i ? "\n" : null) . $this->htmlLineSpace($pa) . htmlentities($common) . $this->htmlLineSpace($qa);
$hb .= ($i ? "\n" : null) . $this->htmlLineSpace($pb) . htmlentities($common) . $this->htmlLineSpace($qb);
}
break;
}
//prefixes: l = line number column, c = code column
$tr = "<tr class='block-$index type-$type'>";
$la = "<td class='line'>" . ($ca ? implode("\n",range($sa + 1,$sa + $ca)) : null) . "</td>";
$ca = "<td class='code side-a'>" . ($ha ?: htmlentities(implode("\n",$a))) . "\n</td>";
$lb = "<td class='line'>" . ($cb ? implode("\n",range($sb + 1,$sb + $cb)) : null) . "</td>";
$cb = "<td class='code side-b'>" . ($hb ?: htmlentities(implode("\n",$b))) . "\n</td>";
$td = "<td class='line'></td>";
switch($this->htmlMode){
case self::HTML_MODE_VERTICAL: return $type == self::TYPE_EQUAL
? "$tr$la$lb$ca</tr>"
: ($a ? "$tr$la$td$ca</tr>" : null) . ($b ? "$tr$td$lb$cb</tr>" : null);
default: return "$tr$la$ca$lb$cb</tr>";
}
}
/**
* HTML diff with markup.
* @param mixed $head Table head (string; table row with 4 columns: line A, code A, line B, code B). Set to true for a
* default head, or provide an array with the column headers (2 = only for code columns, or 4 = all columns).
* @return string HTML table.
*/
public function asHtml($head = null){
if($head === true) $head = $this->defaultHead;
if(is_array($head)){
if(count($head = array_values($head)) < 4) $head = ['#',array_shift($head),'#',array_shift($head)];
$la = "<th class='line'>{$head[0]}</th>";
$ca = "<th class='code'>{$head[1]}</th>";
$lb = "<th class='line'>{$head[2]}</th>";
$cb = "<th class='code'>{$head[3]}</th>";
$th = "<th class='line'>";
switch($this->htmlMode){
case self::HTML_MODE_VERTICAL: $head = "<tr>$la$th</th>$ca</tr><tr>$th$lb$cb</tr>"; break;
default: $head = "<tr>$la$ca$lb$cb</tr>";
}
}
$cols = [self::HTML_MODE_VERTICAL => 3][$this->htmlMode] ?? 4;
$skip = "<tr class='skip'><td colspan='$cols'>{$this->skipTemplate}</td></tr>\n";
$last = count($this->_diff) - 1;
$body = null;
foreach($this->_diff as $index => ['t' => $t,'sa' => $sa,'ca' => $ca,'sb' => $sb,'cb' => $cb]) switch($t){
case self::TYPE_EQUAL:
$body .= ($ca <= ($x = $this->extra) * 2 + $this->extraAllThreshold)
? $this->htmlLine($index,$t,$sa,$ca,$sb,$cb) //show complete equal block since it is relativly small
: ($index ? $this->htmlLine($index,$t,$sa,$x,$sb,$x) : null) .
str_replace('*', $ca - ($index ? $x : 0) - ($index < $last ? $x : 0), $skip) .
($index < $last ? $this->htmlLine($index,$t,$sa + $ca - $x,$x,$sb + $cb - $x,$x) : null);
break;
case self::TYPE_DIFF: //check for white-space-only diffs inside this block
$count = 0;
foreach(($diff = $this->diff(
array_map('trim',array_slice($this->_a,$sa,$ca)),
array_map('trim',array_slice($this->_b,$sb,$cb)),
$sa,$sb
)) as $block) if($block['t'] == self::TYPE_EQUAL) $count += $block['ca'];
if($count / max($ca,$cb) > $this->whiteSpaceThreshold){ //show white-space blocks differently
foreach($diff as $sub => ['t' => $t,'sa' => $sa,'ca' => $ca,'sb' => $sb,'cb' => $cb])
$body .= $this->htmlLine($index . '-' . $sub,$t == self::TYPE_EQUAL ? self::TYPE_SPACE : $t,$sa,$ca,$sb,$cb);
break;
} //else
default:
$body .= $this->htmlLine($index,$t,$sa,$ca,$sb,$cb);
}
return "<table class='diff {$this->htmlMode}'><thead>$head</thead><tbody>$body</tbody></table>";
}
public function __toString(){
return $this->asText() ?: '';
}
}