<?php
// WebSVN - Subversion repository viewing via the web using PHP
// Copyright (C) 2004-2006 Tim Armes
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 2 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
//
// --
//
// diff_util.php
//
// help diff_inc.php to make sensible changes from added and deleted diff lines
// These lines are automatically paired and also inline diff is performed to show
// insertions/deletions on one line. diff_inc.php must have all Horde_Text_Diff
// requirements met (include_once statements).
// Interface for diffing function
class LineDiffInterface {
// similarity 1 means that strings are very close to each other
// 0 means totally different
function lineSimilarity($text1, $text2) {
assert(false);
}
// return array($left, $right) annotated with <ins> and <del>
function inlineDiff($text1, $highlighted1, $text2, $highlighted2, $highlighted) {
assert(false);
}
}
// Default line diffing function
class LineDiff extends LineDiffInterface {
private bool $ignoreWhitespace;
function __construct($ignoreWhitespace) {
$this->ignoreWhitespace = $ignoreWhitespace;
}
// {{{ levenshtein2
// levenshtein edit distance, on small strings use php function
// on large strings approximate distance using words
// computed by dynamic programming
function levenshtein2($str1, $str2) {
if (strlen($str1) < 255 && strlen($str2) < 255) {
return levenshtein($str1, $str2);
}
$l1 = explode(' ', $str1);
$l2 = explode(' ', $str2);
$n = count($l1);
$m = count($l2);
$d = array_fill(0, $n + 1, array_fill(0, $m + 1, 0));
for ($i = 1; $i < $n + 1; $i++) {
$d[$i][0] = $i;
}
for ($j = 1; $j < $m + 1; $j++) {
$d[0][$j] = $j;
}
for ($i = 1; $i < $n + 1; $i++) {
for ($j = 1; $j < $m + 1; $j++) {
$c = ($l1[$i - 1] == $l2[$j - 1]) ? 0 : strlen($l1[$i - 1]) + strlen($l2[$j - 1]);
$d[$i][$j] = min($d[$i - 1][$j] + 1, $d[$i][$j - 1] + 1, $d[$i - 1][$j - 1] + $c);
}
}
return $d[$n][$m];
}
// }}}
// {{{ lineSimilarity
function lineSimilarity($text1, $text2) {
$distance = $this->levenshtein2($text1, $text2);
return max(0.0, 1.0 - $distance / (strlen($text1) + strlen($text2) + 4));
}
// }}}
// {{{ tokenize whole line into words
// note that separators are returned as tokens of length 1
// and if $ignoreWhitespace is true, consecutive whitespaces are returned as one token
function tokenize($string, $highlighted, $ignoreWhitespace) {
$html = array('<' => '>', '&' => ';');
$whitespaces = array("\t","\n","\r",' ');
$separators = array('.','-','+','*','/','<','>','?','(',')','&','/','{','}','[',']',':',';');
$data = array();
$segment = '';
$segmentIsWhitespace = true;
$count = strlen($string);
for ($i = 0; $i < $count; $i++) {
$c = $string[$i];
if ($highlighted && array_key_exists($c, $html)) {
if ($segment != '') {
$data[] = $segment;
}
// consider html tags and entities as a single token
$endchar = $html[$c];
$segment = $c;
do {
$i++;
$c = $string[$i];
$segment .= $c;
} while ($c != $endchar && $i < $count - 1);
$data[] = $segment;
$segment = '';
$segmentIsWhitespace = false;
} else if (in_array($c, $separators) || (!$ignoreWhitespace && in_array($c, $whitespaces))) {
// if it is separator or whitespace and we do not consider consecutive whitespaces
if ($segment != '') {
$data[] = $segment;
}
$data[] = $c;
$segment = '';
$segmentIsWhitespace = true;
} else if (in_array($c, $whitespaces)) {
// if it is whitespace and we consider consecutive whitespaces as one token
if (!$segmentIsWhitespace) {
$data[] = $segment;
$segment = '';
$segmentIsWhitespace = true;
}
$segment .= $c;
} else {
// no separator or whitespace
if ($segmentIsWhitespace && $segment != '') {
$data[] = $segment;
$segment = '';
}
$segment .= $c;
$segmentIsWhitespace = false;
}
}
if ($segment != '') {
$data[] = $segment;
}
return $data;
}
// }}}
// {{{ lineDiff
function inlineDiff($text1, $highlighted1, $text2, $highlighted2, $highlighted) {
$whitespaces = array(' ', "\t", "\n", "\r");
$do_diff = true;
if ($text1 == '' || $text2 == '') {
$do_diff = false;
}
if ($this->ignoreWhitespace && (str_replace($whitespaces, array(), $text1) == str_replace($whitespaces, array(), $text2))) {
$do_diff = false;
}
// Exit gracefully if loading of Horde_Text_Diff failed
if (!class_exists('Horde_Text_Diff') || !class_exists('Horde_Text_Diff_Mapped')) {
$do_diff = false;
}
// Return highlighted lines without doing inline diff
if (!$do_diff) {
return array($highlighted1, $highlighted2);
}
$tokens1 = $this->tokenize($highlighted1, $highlighted, $this->ignoreWhitespace);
$tokens2 = $this->tokenize($highlighted2, $highlighted, $this->ignoreWhitespace);
if (!$this->ignoreWhitespace) {
$diff = new Horde_Text_Diff('Native', array($tokens1, $tokens2));
} else {
// we need to create mapped parts for MappedDiff
$mapped1 = array();
foreach ($tokens1 as $token) {
$mapped1[] = str_replace($whitespaces, array(), $token);
}
$mapped2 = array();
foreach ($tokens2 as $token) {
$mapped2[] = str_replace($whitespaces, array(), $token);
}
$diff = new Horde_Text_Diff_Mapped('Native', array($tokens1, $tokens2, $mapped1, $mapped2));
}
// now, get the diff and annotate text
$edits = $diff->getDiff();
$line1 = '';
$line2 = '';
foreach ($edits as $edit) {
if ($edit instanceof Horde_Text_Diff_Op_Copy) {
$line1 .= implode('', $edit->orig);
$line2 .= implode('', $edit->final);
} else if ($edit instanceof Horde_Text_Diff_Op_Delete) {
$line1 .= '<del>'.implode('', $edit->orig).'</del>';
} else if ($edit instanceof Horde_Text_Diff_Op_Add) {
$line2 .= '<ins>'.implode('', $edit->final).'</ins>';
} else if ($edit instanceof Horde_Text_Diff_Op_Change) {
$line1 .= '<del>'.implode('', $edit->orig).'</del>';
$line2 .= '<ins>'.implode('', $edit->final).'</ins>';
} else {
assert(false);
}
}
return array($line1, $line2);
}
// }}}
}
// Class for computing sensibly added/deleted block of lines.
class SensibleLineChanges {
var $_added = array();
var $_deleted = array();
var $_lineDiff = null;
function __construct($lineDiff) {
$this->_lineDiff = $lineDiff;
}
function addDeletedLine($text, $highlighted_text, $lineno) {
$this->_deleted[] = array($text, $highlighted_text, $lineno);
}
function addAddedLine($text, $highlighted_text, $lineno) {
$this->_added[] = array($text, $highlighted_text, $lineno);
}
// this function computes simple match - first min(deleted,added) lines are marked as changed
// it is intended to be run instead of _computeBestMatching if the diff is too big
function _computeFastMatching($n, $m) {
$result = array();
$q = 0;
while ($q < $n && $q < $m) {
$result[] = array($this->_deleted[$q], $this->_added[$q]);
$q++;
}
while ($q < $n) {
$result[] = array($this->_deleted[$q], null);
$q++;
}
while ($q < $m) {
$result[] = array(null, $this->_added[$q]);
$q++;
}
return $result;
}
// {{{ _computeBestMatching
// dynamically compute best matching
// note that this is O(n*m) * O(line similarity)
function _computeBestMatching() {
$n = count($this->_deleted);
$m = count($this->_added);
// if the computation will be slow, just run fast algorithm
if ($n * $m > 10000) {
return $this->_computeFastMatching($n, $m);
}
// dyn[$i][$j] holds best sum of similarities we can obtain if we match
// first $i deleted lines and first $j added lines
$dyn = array_fill(0, $n + 1, array_fill(0, $m + 1, 0.0));
// backlinks, so we can reconstruct best layout easily
$back = array_fill(0, $n + 1, array_fill(0, $m + 1, -1));
// if there is no similarity, prefer adding/deleting lines
$value_del = 0.1;
$value_add = 0.1;
// initialize arrays
for ($i = 1; $i <= $n; $i++) {
$back[$i][0] = 0;
$dyn[$i][0] = $value_del * $i;
}
for ($j = 1; $j <= $m; $j++) {
$back[0][$j] = 1;
$dyn[0][$j] = $value_add * $j;
}
// main dynamic programming
for ($i = 1; $i <= $n; $i++) {
for ($j = 1; $j <= $m; $j++) {
$best = - 1.0;
$b = -1;
if ($dyn[$i - 1][$j] + $value_del >= $best) {
$b = 0;
$best = $dyn[$i - 1][$j] + $value_del;
}
if ($dyn[$i][$j - 1] + $value_add >= $best) {
$b = 1;
$best = $dyn[$i][$j - 1] + $value_add;
}
$sim = $this->_lineDiff->lineSimilarity($this->_deleted[$i - 1][0], $this->_added[$j - 1][0]);
if ($dyn[$i - 1][$j - 1] + $sim >= $best) {
$b = 2;
$best = $dyn[$i - 1][$j - 1] + $sim;
}
$back[$i][$j] = $b;
$dyn[$i][$j] = $best;
}
}
// compute layout for best result
$i = $n;
$j = $m;
$result = array();
while ($i + $j >= 1) {
switch($back[$i][$j]) {
case 2: array_push($result, array($this->_deleted[$i - 1], $this->_added[$j - 1]));
$i--;
$j--;
break;
case 1: array_push($result, array(null, $this->_added[$j - 1]));
$j--;
break;
case 0: array_push($result, array($this->_deleted[$i - 1], null));
$i--;
break;
default:
assert(false);
}
}
return array_reverse($result);
}
// }}}
// {{{ addChangesToListing
// add computed changes to the listing
function addChangesToListing(&$listingHelper, $highlighted) {
$matching = $this->_computeBestMatching();
foreach ($matching as $change) {
if ($change[1] == null) {
// deleted -- preserve original highlighted text
$listingHelper->addDeletedLine($change[0][1], $change[0][2]);
} else if ($change[0] == null) {
// added -- preserve original highlighted text
$listingHelper->addAddedLine($change[1][1], $change[1][2]);
} else {
// this is fully changed line, make inline diff
$diff = $this->_lineDiff->inlineDiff($change[0][0], $change[0][1], $change[1][0], $change[1][1], $highlighted);
$listingHelper->addChangedLine($diff[0], $change[0][2], $diff[1], $change[1][2]);
}
}
$this->clear();
}
// }}}
function clear() {
$this->_added = array();
$this->_deleted = array();
}
}