primo commit

This commit is contained in:
2024-12-17 17:34:10 +01:00
commit e650f8df99
16435 changed files with 2451012 additions and 0 deletions

View File

@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff;
use Jfcherng\Diff\Factory\RendererFactory;
use Jfcherng\Diff\Renderer\RendererConstant;
final class DiffHelper
{
/**
* The constructor.
*/
private function __construct()
{
}
/**
* Get the absolute path of the project root directory.
*/
public static function getProjectDirectory(): string
{
static $path;
return $path ??= realpath(__DIR__ . '/..');
}
/**
* Get the information about available renderers.
*/
public static function getRenderersInfo(): array
{
static $info;
if (isset($info)) {
return $info;
}
$glob = implode(\DIRECTORY_SEPARATOR, [
self::getProjectDirectory(),
'src',
'Renderer',
'{' . implode(',', RendererConstant::RENDERER_TYPES) . '}',
'*.php',
]);
$fileNames = array_map(
// get basename without file extension
static fn (string $file): string => pathinfo($file, \PATHINFO_FILENAME),
// paths of all Renderer files
glob($glob, \GLOB_BRACE),
);
$renderers = array_filter(
$fileNames,
// only normal class files are wanted
static fn (string $fileName): bool => (
substr($fileName, 0, 8) !== 'Abstract'
&& substr($fileName, -9) !== 'Interface'
&& substr($fileName, -5) !== 'Trait'
),
);
$info = [];
foreach ($renderers as $renderer) {
$info[$renderer] = RendererFactory::resolveRenderer($renderer)::INFO;
}
return $info;
}
/**
* Get the available renderers.
*
* @return string[] the available renderers
*/
public static function getAvailableRenderers(): array
{
return array_keys(self::getRenderersInfo());
}
/**
* Get the content of the CSS style sheet for HTML renderers.
*
* @throws \LogicException path is a directory
* @throws \RuntimeException path cannot be opened
*/
public static function getStyleSheet(): string
{
static $fileContent;
if (isset($fileContent)) {
return $fileContent;
}
$filePath = self::getProjectDirectory() . '/example/diff-table.css';
$file = new \SplFileObject($filePath, 'r');
return $fileContent = $file->fread($file->getSize());
}
/**
* Gets the diff statistics such as inserted and deleted etc...
*
* @return array<string,float> the statistics
*/
public static function getStatistics(): array
{
return Differ::getInstance()->getStatistics();
}
/**
* All-in-one static method to calculate the diff between two strings (or arrays of strings).
*
* @param string|string[] $old the old string (or array of lines)
* @param string|string[] $new the new string (or array of lines)
* @param string $renderer the renderer name
* @param array $differOptions the options for Differ object
* @param array $rendererOptions the options for renderer object
*
* @return string the rendered differences
*/
public static function calculate(
$old,
$new,
string $renderer = 'Unified',
array $differOptions = [],
array $rendererOptions = []
): string {
// always convert into array form
\is_string($old) && ($old = explode("\n", $old));
\is_string($new) && ($new = explode("\n", $new));
return RendererFactory::getInstance($renderer)
->setOptions($rendererOptions)
->render(
Differ::getInstance()
->setOldNew($old, $new)
->setOptions($differOptions),
)
;
}
/**
* All-in-one static method to calculate the diff between two files.
*
* @param string $old the path of the old file
* @param string $new the path of the new file
* @param string $renderer the renderer name
* @param array $differOptions the options for Differ object
* @param array $rendererOptions the options for renderer object
*
* @throws \LogicException path is a directory
* @throws \RuntimeException path cannot be opened
*
* @return string the rendered differences
*/
public static function calculateFiles(
string $old,
string $new,
string $renderer = 'Unified',
array $differOptions = [],
array $rendererOptions = []
): string {
// we want to leave the line-ending problem to static::calculate()
// so do not set SplFileObject::DROP_NEW_LINE flag
// otherwise, we will lose \r if the line-ending is \r\n
$oldFile = new \SplFileObject($old, 'r');
$newFile = new \SplFileObject($new, 'r');
return self::calculate(
// fread() requires the length > 0 hence we plus 1 for empty files
$oldFile->fread($oldFile->getSize() + 1),
$newFile->fread($newFile->getSize() + 1),
$renderer,
$differOptions,
$rendererOptions,
);
}
}

View File

@ -0,0 +1,522 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff;
use Jfcherng\Diff\Utility\Arr;
/**
* A comprehensive library for generating differences between two strings
* in multiple formats (unified, side by side HTML etc).
*
* @author Jack Cherng <jfcherng@gmail.com>
* @author Chris Boulton <chris.boulton@interspire.com>
*
* @see http://github.com/chrisboulton/php-diff
*/
final class Differ
{
/**
* @var int a safe number for indicating showing all contexts
*/
public const CONTEXT_ALL = \PHP_INT_MAX >> 3;
/**
* @var string used to indicate a line has no EOL
*
* Arbitrary chars from the 15-16th Unicode reserved areas
* and hopefully, they won't appear in source texts
*/
public const LINE_NO_EOL = "\u{fcf28}\u{fc231}";
/**
* @var array cached properties and their default values
*/
private const CACHED_PROPERTIES = [
'groupedOpcodes' => [],
'groupedOpcodesGnu' => [],
'oldNoEolAtEofIdx' => -1,
'newNoEolAtEofIdx' => -1,
'oldNewComparison' => 0,
];
/**
* @var array array of the options that have been applied for generating the diff
*/
public array $options = [];
/**
* @var string[] the old sequence
*/
private array $old = [];
/**
* @var string[] the new sequence
*/
private array $new = [];
/**
* @var bool is any of cached properties dirty?
*/
private bool $isCacheDirty = true;
/**
* @var SequenceMatcher the sequence matcher
*/
private SequenceMatcher $sequenceMatcher;
private int $oldSrcLength = 0;
private int $newSrcLength = 0;
/**
* @var int the end index for the old if the old has no EOL at EOF
* `-1` means the old has an EOL at EOF
*/
private int $oldNoEolAtEofIdx = -1;
/**
* @var int the end index for the new if the new has no EOL at EOF
* `-1` means the new has an EOL at EOF
*/
private int $newNoEolAtEofIdx = -1;
/**
* @var int the result of comparing the old and the new with the spaceship operator
* `-1` means `old < new`, `0` means `old == new`, `1` means `old > new`
*/
private int $oldNewComparison = 0;
/**
* @var int[][][] array containing the generated opcodes for the differences between the two items
*/
private array $groupedOpcodes = [];
/**
* @var int[][][] array containing the generated opcodes for the differences between the two items (GNU version)
*/
private array $groupedOpcodesGnu = [];
/**
* @var array associative array of the default options available for the Differ class and their default value
*/
private static array $defaultOptions = [
// show how many neighbor lines
// Differ::CONTEXT_ALL can be used to show the whole file
'context' => 3,
// ignore case difference
'ignoreCase' => false,
// ignore line ending difference
'ignoreLineEnding' => false,
// ignore whitespace difference
'ignoreWhitespace' => false,
// if the input sequence is too long, it will just gives up (especially for char-level diff)
'lengthLimit' => 2000,
// if truthy, when inputs are identical, the whole inputs will be rendered in the output
'fullContextIfIdentical' => false,
];
/**
* The constructor.
*
* @param string[] $old array containing the lines of the old string to compare
* @param string[] $new array containing the lines of the new string to compare
* @param array $options the options
*/
public function __construct(array $old, array $new, array $options = [])
{
$this->sequenceMatcher = new SequenceMatcher([], []);
$this->setOldNew($old, $new)->setOptions($options);
}
/**
* Set old and new.
*
* @param string[] $old the old
* @param string[] $new the new
*/
public function setOldNew(array $old, array $new): self
{
return $this->setOld($old)->setNew($new);
}
/**
* Set old.
*
* @param string[] $old the old
*/
public function setOld(array $old): self
{
if ($this->old !== $old) {
$this->old = $old;
$this->isCacheDirty = true;
}
return $this;
}
/**
* Set new.
*
* @param string[] $new the new
*/
public function setNew(array $new): self
{
if ($this->new !== $new) {
$this->new = $new;
$this->isCacheDirty = true;
}
return $this;
}
/**
* Set the options.
*
* @param array $options the options
*/
public function setOptions(array $options): self
{
$mergedOptions = $options + self::$defaultOptions;
if ($this->options !== $mergedOptions) {
$this->options = $mergedOptions;
$this->isCacheDirty = true;
}
return $this;
}
/**
* Get a range of lines from $start to $end from the old.
*
* @param int $start the starting index (negative = count from backward)
* @param null|int $end the ending index (negative = count from backward)
* if is null, it returns a slice from $start to the end
*
* @return string[] array of all of the lines between the specified range
*/
public function getOld(int $start = 0, ?int $end = null): array
{
return Arr::getPartialByIndex($this->old, $start, $end);
}
/**
* Get a range of lines from $start to $end from the new.
*
* @param int $start the starting index (negative = count from backward)
* @param null|int $end the ending index (negative = count from backward)
* if is null, it returns a slice from $start to the end
*
* @return string[] array of all of the lines between the specified range
*/
public function getNew(int $start = 0, ?int $end = null): array
{
return Arr::getPartialByIndex($this->new, $start, $end);
}
/**
* Get the options.
*
* @return array the options
*/
public function getOptions(): array
{
return $this->options;
}
/**
* Get the old no EOL at EOF index.
*
* @return int the old no EOL at EOF index
*/
public function getOldNoEolAtEofIdx(): int
{
return $this->finalize()->oldNoEolAtEofIdx;
}
/**
* Get the new no EOL at EOF index.
*
* @return int the new no EOL at EOF index
*/
public function getNewNoEolAtEofIdx(): int
{
return $this->finalize()->newNoEolAtEofIdx;
}
/**
* Compare the old and the new with the spaceship operator.
*/
public function getOldNewComparison(): int
{
return $this->finalize()->oldNewComparison;
}
/**
* Get the singleton.
*/
public static function getInstance(): self
{
static $singleton;
return $singleton ??= new self([], []);
}
/**
* Gets the diff statistics such as inserted and deleted etc...
*
* @return array<string,float> the statistics
*/
public function getStatistics(): array
{
$ret = [
'inserted' => 0,
'deleted' => 0,
'unmodified' => 0,
'changedRatio' => 0.0,
];
foreach ($this->getGroupedOpcodes() as $hunk) {
foreach ($hunk as [$op, $i1, $i2, $j1, $j2]) {
if ($op & (SequenceMatcher::OP_INS | SequenceMatcher::OP_REP)) {
$ret['inserted'] += $j2 - $j1;
}
if ($op & (SequenceMatcher::OP_DEL | SequenceMatcher::OP_REP)) {
$ret['deleted'] += $i2 - $i1;
}
}
}
$ret['unmodified'] = $this->oldSrcLength - $ret['deleted'];
$ret['changedRatio'] = 1 - ($ret['unmodified'] / $this->oldSrcLength);
return $ret;
}
/**
* Generate a list of the compiled and grouped opcodes for the differences between the
* two strings. Generally called by the renderer, this class instantiates the sequence
* matcher and performs the actual diff generation and return an array of the opcodes
* for it. Once generated, the results are cached in the Differ class instance.
*
* @return int[][][] array of the grouped opcodes for the generated diff
*/
public function getGroupedOpcodes(): array
{
$this->finalize();
if (!empty($this->groupedOpcodes)) {
return $this->groupedOpcodes;
}
$old = $this->old;
$new = $this->new;
$this->getGroupedOpcodesPre($old, $new);
if ($this->oldNewComparison === 0 && $this->options['fullContextIfIdentical']) {
$opcodes = [
[
[SequenceMatcher::OP_EQ, 0, \count($old), 0, \count($new)],
],
];
} else {
$opcodes = $this->sequenceMatcher
->setSequences($old, $new)
->getGroupedOpcodes($this->options['context'])
;
}
$this->getGroupedOpcodesPost($opcodes);
return $this->groupedOpcodes = $opcodes;
}
/**
* A EOL-at-EOF-sensitive version of getGroupedOpcodes().
*
* @return int[][][] array of the grouped opcodes for the generated diff (GNU version)
*/
public function getGroupedOpcodesGnu(): array
{
$this->finalize();
if (!empty($this->groupedOpcodesGnu)) {
return $this->groupedOpcodesGnu;
}
$old = $this->old;
$new = $this->new;
$this->getGroupedOpcodesGnuPre($old, $new);
if ($this->oldNewComparison === 0 && $this->options['fullContextIfIdentical']) {
$opcodes = [
[
[SequenceMatcher::OP_EQ, 0, \count($old), 0, \count($new)],
],
];
} else {
$opcodes = $this->sequenceMatcher
->setSequences($old, $new)
->getGroupedOpcodes($this->options['context'])
;
}
$this->getGroupedOpcodesGnuPost($opcodes);
return $this->groupedOpcodesGnu = $opcodes;
}
/**
* Triggered before getGroupedOpcodes(). May modify the $old and $new.
*
* @param string[] $old the old
* @param string[] $new the new
*/
private function getGroupedOpcodesPre(array &$old, array &$new): void
{
// append these lines to make sure the last block of the diff result is OP_EQ
static $eolAtEofHelperLines = [
SequenceMatcher::APPENDED_HELPER_LINE,
SequenceMatcher::APPENDED_HELPER_LINE,
SequenceMatcher::APPENDED_HELPER_LINE,
SequenceMatcher::APPENDED_HELPER_LINE,
];
$this->oldSrcLength = \count($old);
array_push($old, ...$eolAtEofHelperLines);
$this->newSrcLength = \count($new);
array_push($new, ...$eolAtEofHelperLines);
}
/**
* Triggered after getGroupedOpcodes(). May modify the $opcodes.
*
* @param int[][][] $opcodes the opcodes
*/
private function getGroupedOpcodesPost(array &$opcodes): void
{
// remove those extra lines cause by adding extra SequenceMatcher::APPENDED_HELPER_LINE lines
foreach ($opcodes as $hunkIdx => &$hunk) {
foreach ($hunk as $blockIdx => &$block) {
// range overflow
if ($block[1] > $this->oldSrcLength) {
$block[1] = $this->oldSrcLength;
}
if ($block[2] > $this->oldSrcLength) {
$block[2] = $this->oldSrcLength;
}
if ($block[3] > $this->newSrcLength) {
$block[3] = $this->newSrcLength;
}
if ($block[4] > $this->newSrcLength) {
$block[4] = $this->newSrcLength;
}
// useless extra block?
/** @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset */
if ($block[1] === $block[2] && $block[3] === $block[4]) {
unset($hunk[$blockIdx]);
}
}
if (empty($hunk)) {
unset($opcodes[$hunkIdx]);
}
}
}
/**
* Triggered before getGroupedOpcodesGnu(). May modify the $old and $new.
*
* @param string[] $old the old
* @param string[] $new the new
*/
private function getGroupedOpcodesGnuPre(array &$old, array &$new): void
{
/**
* Make the lines to be prepared for GNU-style diff.
*
* This method checks whether $lines has no EOL at EOF and append a special
* indicator to the last line.
*
* @param string[] $lines the lines created by simply explode("\n", $string)
*/
$createGnuCompatibleLines = static function (array $lines): array {
// note that the $lines should not be empty at this point
// they have at least one element "" in the array because explode("\n", "") === [""]
$lastLineIdx = \count($lines) - 1;
$lastLine = &$lines[$lastLineIdx];
if ($lastLine === '') {
// remove the last plain "" line since we don't need it anymore
// use array_slice() to also reset the array index
$lines = \array_slice($lines, 0, -1);
} else {
// this means the original source has no EOL at EOF
// we append a special indicator to that line so it no longer matches
$lastLine .= self::LINE_NO_EOL;
}
return $lines;
};
$old = $createGnuCompatibleLines($old);
$new = $createGnuCompatibleLines($new);
$this->getGroupedOpcodesPre($old, $new);
}
/**
* Triggered after getGroupedOpcodesGnu(). May modify the $opcodes.
*
* @param int[][][] $opcodes the opcodes
*/
private function getGroupedOpcodesGnuPost(array &$opcodes): void
{
$this->getGroupedOpcodesPost($opcodes);
}
/**
* Claim this class has settled down and we could calculate cached
* properties by current properties.
*
* This method must be called before accessing cached properties to
* make suer that you will not get a outdated cached value.
*
* @internal
*/
private function finalize(): self
{
if ($this->isCacheDirty) {
$this->resetCachedResults();
$this->oldNoEolAtEofIdx = $this->getOld(-1) === [''] ? -1 : \count($this->old);
$this->newNoEolAtEofIdx = $this->getNew(-1) === [''] ? -1 : \count($this->new);
$this->oldNewComparison = $this->old <=> $this->new;
$this->sequenceMatcher->setOptions($this->options);
}
return $this;
}
/**
* Reset cached results.
*/
private function resetCachedResults(): self
{
foreach (self::CACHED_PROPERTIES as $property => $value) {
$this->{$property} = $value;
}
$this->isCacheDirty = false;
return $this;
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff\Exception;
final class FileNotFoundException extends \Exception
{
public function __construct(string $filepath = '', int $code = 0, ?\Throwable $previous = null)
{
parent::__construct("File not found: {$filepath}", $code, $previous);
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff\Exception;
final class UnsupportedFunctionException extends \Exception
{
public function __construct(string $funcName = '', int $code = 0, ?\Throwable $previous = null)
{
parent::__construct("Unsupported function: {$funcName}", $code, $previous);
}
}

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff\Factory;
use Jfcherng\Diff\Renderer\Html\LineRenderer\AbstractLineRenderer;
use Jfcherng\Diff\Renderer\RendererConstant;
final class LineRendererFactory
{
/**
* Instances of line renderers.
*
* @var AbstractLineRenderer[]
*/
private static array $singletons = [];
/**
* The constructor.
*/
private function __construct()
{
}
/**
* Get the singleton of a line renderer.
*
* @param string $type the type
* @param mixed ...$ctorArgs the constructor arguments
*/
public static function getInstance(string $type, ...$ctorArgs): AbstractLineRenderer
{
return self::$singletons[$type] ??= self::make($type, ...$ctorArgs);
}
/**
* Make a new instance of a line renderer.
*
* @param string $type the type
* @param mixed ...$ctorArgs the constructor arguments
*
* @throws \InvalidArgumentException
*/
public static function make(string $type, ...$ctorArgs): AbstractLineRenderer
{
$className = RendererConstant::RENDERER_NAMESPACE . '\\Html\\LineRenderer\\' . ucfirst($type);
if (!class_exists($className)) {
throw new \InvalidArgumentException("LineRenderer not found: {$type}");
}
return new $className(...$ctorArgs);
}
}

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff\Factory;
use Jfcherng\Diff\Renderer\AbstractRenderer;
use Jfcherng\Diff\Renderer\RendererConstant;
final class RendererFactory
{
/**
* Instances of renderers.
*
* @var AbstractRenderer[]
*/
private static array $singletons = [];
/**
* The constructor.
*/
private function __construct()
{
}
/**
* Get the singleton of a renderer.
*
* @param string $renderer the renderer
* @param mixed ...$ctorArgs the constructor arguments
*/
public static function getInstance(string $renderer, ...$ctorArgs): AbstractRenderer
{
return self::$singletons[$renderer] ??= self::make($renderer, ...$ctorArgs);
}
/**
* Make a new instance of a renderer.
*
* @param string $renderer the renderer
* @param mixed ...$ctorArgs the constructor arguments
*
* @throws \InvalidArgumentException
*/
public static function make(string $renderer, ...$ctorArgs): AbstractRenderer
{
$className = self::resolveRenderer($renderer);
if (!isset($className)) {
throw new \InvalidArgumentException("Renderer not found: {$renderer}");
}
return new $className(...$ctorArgs);
}
/**
* Resolve the renderer name into a FQCN.
*
* @param string $renderer the renderer
*/
public static function resolveRenderer(string $renderer): ?string
{
static $cache = [];
if (isset($cache[$renderer])) {
return $cache[$renderer];
}
foreach (RendererConstant::RENDERER_TYPES as $type) {
$className = RendererConstant::RENDERER_NAMESPACE . "\\{$type}\\{$renderer}";
if (class_exists($className)) {
return $cache[$renderer] = $className;
}
}
return null;
}
}

View File

@ -0,0 +1,241 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff\Renderer;
use Jfcherng\Diff\Differ;
use Jfcherng\Diff\SequenceMatcher;
use Jfcherng\Diff\Utility\Language;
/**
* Base class for diff renderers.
*
* @todo use typed properties (BC breaking for public interface) in v7
*/
abstract class AbstractRenderer implements RendererInterface
{
/**
* @var array information about this renderer
*/
public const INFO = [
'desc' => 'default_desc',
'type' => 'default_type',
];
/**
* @var bool Is this renderer pure text?
*/
public const IS_TEXT_RENDERER = true;
/**
* @var string[] array of the opcodes and their corresponding symbols
*/
public const SYMBOL_MAP = [
SequenceMatcher::OP_DEL => '-',
SequenceMatcher::OP_EQ => ' ',
SequenceMatcher::OP_INS => '+',
SequenceMatcher::OP_REP => '!',
];
/**
* @var Language the language translation object
*/
protected $t;
/**
* If the input "changes" have `<ins>...</ins>` or `<del>...</del>`,
* which means they have been processed, then `false`. Otherwise, `true`.
*
* @var bool
*/
protected $changesAreRaw = true;
/**
* @var array array of the default options that apply to this renderer
*/
protected static $defaultOptions = [
// how detailed the rendered HTML in-line diff is? (none, line, word, char)
'detailLevel' => 'line',
// renderer language: eng, cht, chs, jpn, ...
// or an array which has the same keys with a language file
// check the "Custom Language" section in the readme for more advanced usage
'language' => 'eng',
// show line numbers in HTML renderers
'lineNumbers' => true,
// show a separator between different diff hunks in HTML renderers
'separateBlock' => true,
// show the (table) header
'showHeader' => true,
// convert spaces/tabs into HTML codes like `<span class="ch sp"> </span>`
// and the frontend is responsible for rendering them with CSS.
// when using this, "spacesToNbsp" should be false and "tabSize" is not respected.
'spaceToHtmlTag' => false,
// the frontend HTML could use CSS "white-space: pre;" to visualize consecutive whitespaces
// but if you want to visualize them in the backend with "&nbsp;", you can set this to true
'spacesToNbsp' => false,
// HTML renderer tab width (negative = do not convert into spaces)
'tabSize' => 4,
// this option is currently only for the Combined renderer.
// it determines whether a replace-type block should be merged or not
// depending on the content changed ratio, which values between 0 and 1.
'mergeThreshold' => 0.8,
// this option is currently only for the Unified and the Context renderers.
// RendererConstant::CLI_COLOR_AUTO = colorize the output if possible (default)
// RendererConstant::CLI_COLOR_ENABLE = force to colorize the output
// RendererConstant::CLI_COLOR_DISABLE = force not to colorize the output
'cliColorization' => RendererConstant::CLI_COLOR_AUTO,
// this option is currently only for the Json renderer.
// internally, ops (tags) are all int type but this is not good for human reading.
// set this to "true" to convert them into string form before outputting.
'outputTagAsString' => false,
// this option is currently only for the Json renderer.
// it controls how the output JSON is formatted.
// see available options on https://www.php.net/manual/en/function.json-encode.php
'jsonEncodeFlags' => \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE,
// this option is currently effective when the "detailLevel" is "word"
// characters listed in this array can be used to make diff segments into a whole
// for example, making "<del>good</del>-<del>looking</del>" into "<del>good-looking</del>"
// this should bring better readability but set this to empty array if you do not want it
'wordGlues' => ['-', ' '],
// change this value to a string as the returned diff if the two input strings are identical
'resultForIdenticals' => null,
// extra HTML classes added to the DOM of the diff container
'wrapperClasses' => ['diff-wrapper'],
];
/**
* @var array array containing the user applied and merged default options for the renderer
*/
protected $options = [];
/**
* The constructor. Instantiates the rendering engine and if options are passed,
* sets the options for the renderer.
*
* @param array $options optionally, an array of the options for the renderer
*/
public function __construct(array $options = [])
{
$this->setOptions($options);
}
/**
* Set the options of the renderer to those supplied in the passed in array.
* Options are merged with the default to ensure that there aren't any missing
* options.
*
* @param array $options the options
*
* @return static
*/
public function setOptions(array $options): self
{
$newOptions = $options + static::$defaultOptions;
$this->updateLanguage(
$this->options['language'] ?? '',
$newOptions['language'],
);
$this->options = $newOptions;
return $this;
}
/**
* Get the options.
*
* @return array the options
*/
public function getOptions(): array
{
return $this->options;
}
/**
* @final
*
* @todo mark this method with "final" in the next major release
*
* @throws \InvalidArgumentException
*/
public function getResultForIdenticals(): string
{
$custom = $this->options['resultForIdenticals'];
if (isset($custom) && !\is_string($custom)) {
throw new \InvalidArgumentException('renderer option `resultForIdenticals` must be null or string.');
}
return $custom ?? $this->getResultForIdenticalsDefault();
}
/**
* Get the renderer default result when the old and the new are the same.
*/
abstract public function getResultForIdenticalsDefault(): string;
final public function render(Differ $differ): string
{
$this->changesAreRaw = true;
// the "no difference" situation may happen frequently
return $differ->getOldNewComparison() === 0 && !$differ->options['fullContextIfIdentical']
? $this->getResultForIdenticals()
: $this->renderWorker($differ);
}
final public function renderArray(array $differArray): string
{
$this->changesAreRaw = false;
return $this->renderArrayWorker($differArray);
}
/**
* The real worker for self::render().
*
* @param Differ $differ the differ object
*/
abstract protected function renderWorker(Differ $differ): string;
/**
* The real worker for self::renderArray().
*
* @param array[][] $differArray the differ array
*/
abstract protected function renderArrayWorker(array $differArray): string;
/**
* Update the Language object.
*
* @param string|string[] $old the old language
* @param string|string[] $new the new language
*
* @return static
*/
protected function updateLanguage($old, $new): self
{
if (!isset($this->t) || $old !== $new) {
$this->t = new Language($new);
}
return $this;
}
/**
* A shorthand to do translation.
*
* @param string $text The text
* @param bool $escapeHtml Escape the translated text for HTML?
*
* @return string the translated text
*/
protected function _(string $text, bool $escapeHtml = true): string
{
$text = $this->t->translate($text);
return $escapeHtml ? htmlspecialchars($text) : $text;
}
}

View File

@ -0,0 +1,368 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff\Renderer\Html;
use Jfcherng\Diff\Differ;
use Jfcherng\Diff\Factory\LineRendererFactory;
use Jfcherng\Diff\Renderer\AbstractRenderer;
use Jfcherng\Diff\Renderer\Html\LineRenderer\AbstractLineRenderer;
use Jfcherng\Diff\Renderer\RendererConstant;
use Jfcherng\Diff\SequenceMatcher;
use Jfcherng\Utility\MbString;
/**
* Base renderer for rendering HTML-based diffs.
*/
abstract class AbstractHtml extends AbstractRenderer
{
/**
* @var bool is this renderer pure text?
*/
public const IS_TEXT_RENDERER = false;
/**
* @var string[] array of the different opcodes and how they are mapped to HTML classes
*
* @todo rename to OP_CLASS_MAP in v7
*/
public const TAG_CLASS_MAP = [
SequenceMatcher::OP_DEL => 'del',
SequenceMatcher::OP_EQ => 'eq',
SequenceMatcher::OP_INS => 'ins',
SequenceMatcher::OP_REP => 'rep',
];
/**
* Auto format the content in "changes" to be suitable for HTML output.
*
* This may not be a wanted behavior for some (custom) renderers
* if they want to do this by themselves in a later stage.
*
* @var bool
*/
public const AUTO_FORMAT_CHANGES = true;
public function getResultForIdenticalsDefault(): string
{
return '';
}
/**
* Render and return an array structure suitable for generating HTML
* based differences. Generally called by subclasses that generate a
* HTML based diff and return an array of the changes to show in the diff.
*
* @param Differ $differ the differ object
*
* @return array[][] generated changes, suitable for presentation in HTML
*/
public function getChanges(Differ $differ): array
{
$lineRenderer = LineRendererFactory::make(
$this->options['detailLevel'],
$differ->getOptions(),
$this->options,
);
$old = $differ->getOld();
$new = $differ->getNew();
$changes = [];
foreach ($differ->getGroupedOpcodes() as $hunk) {
$change = [];
foreach ($hunk as [$op, $i1, $i2, $j1, $j2]) {
$change[] = $this->getDefaultBlock($op, $i1, $j1);
$block = &$change[\count($change) - 1];
// if there are same amount of lines replaced
// we can render the inner detailed changes with corresponding lines
// @todo or use LineRenderer to do the job regardless different line counts?
if ($op === SequenceMatcher::OP_REP && $i2 - $i1 === $j2 - $j1) {
for ($k = $i2 - $i1 - 1; $k >= 0; --$k) {
$this->renderChangedExtent($lineRenderer, $old[$i1 + $k], $new[$j1 + $k]);
}
}
$block['old']['lines'] = \array_slice($old, $i1, $i2 - $i1);
$block['new']['lines'] = \array_slice($new, $j1, $j2 - $j1);
}
unset($block);
$changes[] = $change;
}
if (static::AUTO_FORMAT_CHANGES) {
$this->formatChanges($changes);
}
return $changes;
}
protected function renderWorker(Differ $differ): string
{
$rendered = $this->redererChanges($this->getChanges($differ));
return $this->cleanUpDummyHtmlClosures($rendered);
}
protected function renderArrayWorker(array $differArray): string
{
$this->ensureChangesUseIntTag($differArray);
$rendered = $this->redererChanges($differArray);
return $this->cleanUpDummyHtmlClosures($rendered);
}
/**
* Render the array of changes.
*
* @param array[][] $changes the changes
*
* @todo rename typo to renderChanges() in v7
*/
abstract protected function redererChanges(array $changes): string;
/**
* Renderer the changed extent.
*
* @param AbstractLineRenderer $lineRenderer the line renderer
* @param string $old the old line
* @param string $new the new line
*/
protected function renderChangedExtent(AbstractLineRenderer $lineRenderer, string &$old, string &$new): void
{
static $mbOld, $mbNew;
$mbOld ??= new MbString();
$mbNew ??= new MbString();
$mbOld->set($old);
$mbNew->set($new);
$lineRenderer->render($mbOld, $mbNew);
$old = $mbOld->get();
$new = $mbNew->get();
}
/**
* Get the default block.
*
* @param int $op the operation
* @param int $i1 begin index of the diff of the old array
* @param int $j1 begin index of the diff of the new array
*
* @return array the default block
*
* @todo rename tag to op in v7
*/
protected function getDefaultBlock(int $op, int $i1, int $j1): array
{
return [
'tag' => $op,
'old' => [
'offset' => $i1,
'lines' => [],
],
'new' => [
'offset' => $j1,
'lines' => [],
],
];
}
/**
* Make the content in "changes" suitable for HTML output.
*
* @param array[][] $changes the changes
*/
final protected function formatChanges(array &$changes): void
{
foreach ($changes as &$hunk) {
foreach ($hunk as &$block) {
$block['old']['lines'] = $this->formatLines($block['old']['lines']);
$block['new']['lines'] = $this->formatLines($block['new']['lines']);
/** @phan-suppress-next-line PhanTypeInvalidLeftOperandOfBitwiseOp */
if ($block['tag'] & (SequenceMatcher::OP_REP | SequenceMatcher::OP_DEL)) {
$block['old']['lines'] = str_replace(
RendererConstant::HTML_CLOSURES,
RendererConstant::HTML_CLOSURES_DEL,
$block['old']['lines'],
);
}
/** @phan-suppress-next-line PhanTypeInvalidLeftOperandOfBitwiseOp */
if ($block['tag'] & (SequenceMatcher::OP_REP | SequenceMatcher::OP_INS)) {
$block['new']['lines'] = str_replace(
RendererConstant::HTML_CLOSURES,
RendererConstant::HTML_CLOSURES_INS,
$block['new']['lines'],
);
}
}
}
}
/**
* Make a series of lines suitable for outputting in a HTML rendered diff.
*
* @param string[] $lines array of lines to format
*
* @return string[] array of the formatted lines
*/
protected function formatLines(array $lines): array
{
/**
* To prevent from invoking the same function calls for several times,
* we can glue lines into a string and call functions for one time.
* After that, we split the string back into lines.
*/
return explode(
RendererConstant::IMPLODE_DELIMITER,
$this->formatStringFromLines(
implode(
RendererConstant::IMPLODE_DELIMITER,
$lines,
),
),
);
}
/**
* Make a string suitable for outputting in a HTML rendered diff.
*
* This my involve replacing tab characters with spaces, making the HTML safe
* for output, ensuring that double spaces are replaced with &nbsp; etc.
*
* @param string $string the string of imploded lines
*
* @return string the formatted string
*/
protected function formatStringFromLines(string $string): string
{
if (!$this->options['spaceToHtmlTag']) {
$string = $this->expandTabs($string, $this->options['tabSize']);
}
$string = $this->htmlSafe($string);
if ($this->options['spacesToNbsp']) {
$string = $this->htmlFixSpaces($string);
}
if ($this->options['spaceToHtmlTag']) {
$string = $this->htmlReplaceSpacesToHtmlTag($string);
}
return $string;
}
/**
* Replace tabs in a string with a number of spaces.
*
* @param string $string the input string which may contain tabs
* @param int $tabSize one tab = how many spaces, a negative does nothing
* @param bool $onlyLeadingTabs only expand leading tabs
*
* @return string the string with the tabs converted to spaces
*/
protected function expandTabs(string $string, int $tabSize = 4, bool $onlyLeadingTabs = false): string
{
if ($tabSize < 0) {
return $string;
}
if ($onlyLeadingTabs) {
return preg_replace_callback(
"/^[ \t]{1,}/mS", // tabs and spaces may be mixed
static fn (array $matches): string => str_replace("\t", str_repeat(' ', $tabSize), $matches[0]),
$string,
);
}
return str_replace("\t", str_repeat(' ', $tabSize), $string);
}
/**
* Make a string containing HTML safe for output on a page.
*
* @param string $string the string
*
* @return string the string with the HTML characters replaced by entities
*/
protected function htmlSafe(string $string): string
{
return htmlspecialchars($string, \ENT_NOQUOTES, 'UTF-8');
}
/**
* Replace a string containing spaces with a HTML representation having "&nbsp;".
*
* @param string $string the string of spaces
*
* @return string the HTML representation of the string
*/
protected function htmlFixSpaces(string $string): string
{
return str_replace(' ', '&nbsp;', $string);
}
/**
* Replace spaces/tabs with HTML tags, which may be styled in frontend with CSS.
*
* @param string $string the string of spaces
*
* @return string the HTML representation of the string
*/
protected function htmlReplaceSpacesToHtmlTag(string $string): string
{
return strtr($string, [
' ' => '<span class="ch sp"> </span>',
"\t" => "<span class=\"ch tab\">\t</span>",
]);
}
/**
* Make sure the "changes" array uses int "tag".
*
* Internally, we would like always int form for better performance.
*
* @param array[][] $changes the changes
*/
protected function ensureChangesUseIntTag(array &$changes): void
{
// check if the tag is already int type
if (\is_int($changes[0][0]['tag'] ?? null)) {
return;
}
foreach ($changes as &$hunks) {
foreach ($hunks as &$block) {
$block['tag'] = SequenceMatcher::opStrToInt($block['tag']);
}
}
}
/**
* Clean up empty HTML closures in the given string.
*
* @param string $string the string
*/
protected function cleanUpDummyHtmlClosures(string $string): string
{
return str_replace(
[
RendererConstant::HTML_CLOSURES_DEL[0] . RendererConstant::HTML_CLOSURES_DEL[1],
RendererConstant::HTML_CLOSURES_INS[0] . RendererConstant::HTML_CLOSURES_INS[1],
],
'',
$string,
);
}
}

View File

@ -0,0 +1,518 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff\Renderer\Html;
use Jfcherng\Diff\Factory\LineRendererFactory;
use Jfcherng\Diff\Renderer\RendererConstant;
use Jfcherng\Diff\SequenceMatcher;
use Jfcherng\Diff\Utility\ReverseIterator;
use Jfcherng\Utility\MbString;
/**
* Combined HTML diff generator.
*
* Note that this renderer always has no line number.
*/
final class Combined extends AbstractHtml
{
/**
* {@inheritdoc}
*/
public const INFO = [
'desc' => 'Combined',
'type' => 'Html',
];
/**
* {@inheritdoc}
*/
public const AUTO_FORMAT_CHANGES = false;
protected function redererChanges(array $changes): string
{
if (empty($changes)) {
return $this->getResultForIdenticals();
}
$wrapperClasses = [
...$this->options['wrapperClasses'],
'diff', 'diff-html', 'diff-combined',
];
return
'<table class="' . implode(' ', $wrapperClasses) . '">' .
$this->renderTableHeader() .
$this->renderTableHunks($changes) .
'</table>';
}
/**
* Renderer the table header.
*/
protected function renderTableHeader(): string
{
if (!$this->options['showHeader']) {
return '';
}
return
'<thead>' .
'<tr>' .
'<th>' . $this->_('differences') . '</th>' .
'</tr>' .
'</thead>';
}
/**
* Renderer the table separate block.
*/
protected function renderTableSeparateBlock(): string
{
return
'<tbody class="skipped">' .
'<tr>' .
'<td></td>' .
'</tr>' .
'</tbody>';
}
/**
* Renderer table hunks.
*
* @param array[][] $hunks each hunk has many blocks
*/
protected function renderTableHunks(array $hunks): string
{
$ret = '';
foreach ($hunks as $i => $hunk) {
if ($i > 0 && $this->options['separateBlock']) {
$ret .= $this->renderTableSeparateBlock();
}
foreach ($hunk as $block) {
$ret .= $this->renderTableBlock($block);
}
}
return $ret;
}
/**
* Renderer the table block.
*
* @param array $block the block
*/
protected function renderTableBlock(array $block): string
{
switch ($block['tag']) {
case SequenceMatcher::OP_EQ:
$content = $this->renderTableBlockEqual($block);
break;
case SequenceMatcher::OP_INS:
$content = $this->renderTableBlockInsert($block);
break;
case SequenceMatcher::OP_DEL:
$content = $this->renderTableBlockDelete($block);
break;
case SequenceMatcher::OP_REP:
$content = $this->renderTableBlockReplace($block);
break;
default:
$content = '';
}
return '<tbody class="change change-' . self::TAG_CLASS_MAP[$block['tag']] . '">' . $content . '</tbody>';
}
/**
* Renderer the table block: equal.
*
* @param array $block the block
*/
protected function renderTableBlockEqual(array $block): string
{
$block['new']['lines'] = $this->customFormatLines(
$block['new']['lines'],
SequenceMatcher::OP_EQ,
);
$ret = '';
// note that although we are in a OP_EQ situation,
// the old and the new may not be exactly the same
// because of ignoreCase, ignoreWhitespace, etc
foreach ($block['new']['lines'] as $newLine) {
// we could only pick either the old or the new to show
// here we pick the new one to let the user know what it is now
$ret .= $this->renderTableRow('new', SequenceMatcher::OP_EQ, $newLine);
}
return $ret;
}
/**
* Renderer the table block: insert.
*
* @param array $block the block
*/
protected function renderTableBlockInsert(array $block): string
{
$block['new']['lines'] = $this->customFormatLines(
$block['new']['lines'],
SequenceMatcher::OP_INS,
);
$ret = '';
foreach ($block['new']['lines'] as $newLine) {
$ret .= $this->renderTableRow('new', SequenceMatcher::OP_INS, $newLine);
}
return $ret;
}
/**
* Renderer the table block: delete.
*
* @param array $block the block
*/
protected function renderTableBlockDelete(array $block): string
{
$block['old']['lines'] = $this->customFormatLines(
$block['old']['lines'],
SequenceMatcher::OP_DEL,
);
$ret = '';
foreach ($block['old']['lines'] as $oldLine) {
$ret .= $this->renderTableRow('old', SequenceMatcher::OP_DEL, $oldLine);
}
return $ret;
}
/**
* Renderer the table block: replace.
*
* @param array $block the block
*/
protected function renderTableBlockReplace(array $block): string
{
if ($this->options['detailLevel'] === 'none') {
return
$this->renderTableBlockDelete($block) .
$this->renderTableBlockInsert($block);
}
$ret = '';
$oldLines = $block['old']['lines'];
$newLines = $block['new']['lines'];
$oldLinesCount = \count($oldLines);
$newLinesCount = \count($newLines);
// if the line counts changes, we treat the old and the new as
// "a line with \n in it" and then do one-line-to-one-line diff
if ($oldLinesCount !== $newLinesCount) {
[$oldLines, $newLines] = $this->markReplaceBlockDiff($oldLines, $newLines);
$oldLinesCount = $newLinesCount = 1;
}
$oldLines = $this->customFormatLines($oldLines, SequenceMatcher::OP_DEL);
$newLines = $this->customFormatLines($newLines, SequenceMatcher::OP_INS);
// now $oldLines must has the same line counts with $newlines
for ($no = 0; $no < $newLinesCount; ++$no) {
$mergedLine = $this->mergeReplaceLines($oldLines[$no], $newLines[$no]);
// not merge-able, we fall back to separated form
if (!isset($mergedLine)) {
$ret .=
$this->renderTableBlockDelete($block) .
$this->renderTableBlockInsert($block);
break;
}
$ret .= $this->renderTableRow('rep', SequenceMatcher::OP_REP, $mergedLine);
}
return $ret;
}
/**
* Renderer a content row of the output table.
*
* @param string $tdClass the <td> class
* @param int $op the operation
* @param string $line the line
*/
protected function renderTableRow(string $tdClass, int $op, string $line): string
{
return
'<tr data-type="' . self::SYMBOL_MAP[$op] . '">' .
'<td class="' . $tdClass . '">' . $line . '</td>' .
'</tr>';
}
/**
* Merge two "replace"-type lines into a single line.
*
* The implementation concept is that if we remove all closure parts from
* the old and the new, the rest of them (cleaned line) should be the same.
* And then, we add back those removed closure parts in a correct order.
*
* @param string $oldLine the old line
* @param string $newLine the new line
*
* @return null|string string if merge-able, null otherwise
*/
protected function mergeReplaceLines(string $oldLine, string $newLine): ?string
{
$delParts = $this->analyzeClosureParts(
$oldLine,
RendererConstant::HTML_CLOSURES_DEL,
SequenceMatcher::OP_DEL,
);
$insParts = $this->analyzeClosureParts(
$newLine,
RendererConstant::HTML_CLOSURES_INS,
SequenceMatcher::OP_INS,
);
// get the cleaned line by a non-regex way (should be faster)
// i.e., the new line with all "<ins>...</ins>" parts removed
$mergedLine = $newLine;
foreach (ReverseIterator::fromArray($insParts) as $part) {
$mergedLine = substr_replace(
$mergedLine,
'', // deletion
$part['offset'],
\strlen($part['content']),
);
}
// note that $mergedLine is actually a clean line at this point
if (!$this->isLinesMergeable($oldLine, $newLine, $mergedLine)) {
return null;
}
// before building the $mergedParts, we do some adjustments
$this->revisePartsForBoundaryNewlines($delParts, RendererConstant::HTML_CLOSURES_DEL);
$this->revisePartsForBoundaryNewlines($insParts, RendererConstant::HTML_CLOSURES_INS);
// create a sorted merged parts array
$mergedParts = [...$delParts, ...$insParts];
usort(
$mergedParts,
// first sort by "offsetClean", "order" then by "type"
static fn (array $a, array $b): int => (
$a['offsetClean'] <=> $b['offsetClean']
?: $a['order'] <=> $b['order']
?: ($a['type'] === SequenceMatcher::OP_DEL ? -1 : 1)
),
);
// insert merged parts into the cleaned line
foreach (ReverseIterator::fromArray($mergedParts) as $part) {
$mergedLine = substr_replace(
$mergedLine,
$part['content'],
$part['offsetClean'],
0, // insertion
);
}
return str_replace("\n", '<br>', $mergedLine);
}
/**
* Analyze and get the closure parts information of the line.
*
* Such as
* extract information for "<ins>part 1</ins>" and "<ins>part 2</ins>"
* from "Hello <ins>part 1</ins>SOME OTHER TEXT<ins>part 2</ins> World"
*
* @param string $line the line
* @param string[] $closures the closures
* @param int $type the type
*
* @return array[] the closure information
*/
protected function analyzeClosureParts(string $line, array $closures, int $type): array
{
[$ld, $rd] = $closures;
$ldLength = \strlen($ld);
$rdLength = \strlen($rd);
$parts = [];
$partStart = $partEnd = 0;
$partLengthSum = 0;
// find the next left delimiter
while (false !== ($partStart = strpos($line, $ld, $partEnd))) {
// find the corresponding right delimiter
if (false === ($partEnd = strpos($line, $rd, $partStart + $ldLength))) {
break;
}
$partEnd += $rdLength;
$partLength = $partEnd - $partStart;
$parts[] = [
'type' => $type,
// the sorting order used when both "offsetClean" are the same
'order' => 0,
// the offset in the line
'offset' => $partStart,
// the offset in the cleaned line (i.e., the line with closure parts removed)
'offsetClean' => $partStart - $partLengthSum,
// the content of the part
'content' => substr($line, $partStart, $partLength),
];
$partLengthSum += $partLength;
}
return $parts;
}
/**
* Mark differences between two "replace" blocks.
*
* Each of the returned block (lines) is always only one line.
*
* @param string[] $oldBlock The old block
* @param string[] $newBlock The new block
*
* @return string[][] the value of [[$oldLine], [$newLine]]
*/
protected function markReplaceBlockDiff(array $oldBlock, array $newBlock): array
{
static $mbOld, $mbNew, $lineRenderer;
$mbOld ??= new MbString();
$mbNew ??= new MbString();
$lineRenderer ??= LineRendererFactory::make(
$this->options['detailLevel'],
[], /** @todo is it possible to get the differOptions here? */
$this->options,
);
$mbOld->set(implode("\n", $oldBlock));
$mbNew->set(implode("\n", $newBlock));
$lineRenderer->render($mbOld, $mbNew);
return [
[$mbOld->get()], // one-line block for the old
[$mbNew->get()], // one-line block for the new
];
}
/**
* Determine whether the "replace"-type lines are merge-able or not.
*
* @param string $oldLine the old line
* @param string $newLine the new line
* @param string $cleanLine the clean line
*/
protected function isLinesMergeable(string $oldLine, string $newLine, string $cleanLine): bool
{
$oldLine = str_replace(RendererConstant::HTML_CLOSURES_DEL, '', $oldLine);
$newLine = str_replace(RendererConstant::HTML_CLOSURES_INS, '', $newLine);
$sumLength = \strlen($oldLine) + \strlen($newLine);
/** @var float the changed ratio, 0 <= value < 1 */
$changedRatio = ($sumLength - (\strlen($cleanLine) << 1)) / ($sumLength + 1);
return $changedRatio <= $this->options['mergeThreshold'];
}
/**
* Extract boundary newlines from parts into new parts.
*
* @param array[] $parts the parts
* @param string[] $closures the closures
*
* @see https://git.io/JvVXH
*/
protected function revisePartsForBoundaryNewlines(array &$parts, array $closures): void
{
[$ld, $rd] = $closures;
$ldRegex = preg_quote($ld, '/');
$rdRegex = preg_quote($rd, '/');
for ($i = \count($parts) - 1; $i >= 0; --$i) {
$part = &$parts[$i];
// deal with leading newlines
$part['content'] = preg_replace_callback(
"/(?P<closure>{$ldRegex})(?P<nl>[\r\n]++)/u",
static function (array $matches) use (&$parts, $part, $ld, $rd): string {
// add a new part for the extracted newlines
$part['order'] = -1;
$part['content'] = "{$ld}{$matches['nl']}{$rd}";
$parts[] = $part;
return $matches['closure'];
},
$part['content'],
);
// deal with trailing newlines
$part['content'] = preg_replace_callback(
"/(?P<nl>[\r\n]++)(?P<closure>{$rdRegex})/u",
static function (array $matches) use (&$parts, $part, $ld, $rd): string {
// add a new part for the extracted newlines
$part['order'] = 1;
$part['content'] = "{$ld}{$matches['nl']}{$rd}";
$parts[] = $part;
return $matches['closure'];
},
$part['content'],
);
}
}
/**
* Make lines suitable for HTML output.
*
* @param string[] $lines the lines
* @param int $op the operation
*/
protected function customFormatLines(array $lines, int $op): array
{
if (!$this->changesAreRaw) {
return $lines;
}
static $closureMap = [
SequenceMatcher::OP_DEL => RendererConstant::HTML_CLOSURES_DEL,
SequenceMatcher::OP_INS => RendererConstant::HTML_CLOSURES_INS,
];
$lines = $this->formatLines($lines);
$htmlClosures = $closureMap[$op] ?? null;
foreach ($lines as &$line) {
if ($htmlClosures) {
$line = str_replace(RendererConstant::HTML_CLOSURES, $htmlClosures, $line);
}
// fixes https://github.com/jfcherng/php-diff/issues/34
$line = str_replace("\r\n", "\n", $line);
}
return $lines;
}
}

View File

@ -0,0 +1,263 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff\Renderer\Html;
use Jfcherng\Diff\SequenceMatcher;
/**
* Inline HTML diff generator.
*/
final class Inline extends AbstractHtml
{
/**
* {@inheritdoc}
*/
public const INFO = [
'desc' => 'Inline',
'type' => 'Html',
];
protected function redererChanges(array $changes): string
{
if (empty($changes)) {
return $this->getResultForIdenticals();
}
$wrapperClasses = [
...$this->options['wrapperClasses'],
'diff', 'diff-html', 'diff-inline',
];
return
'<table class="' . implode(' ', $wrapperClasses) . '">' .
$this->renderTableHeader() .
$this->renderTableHunks($changes) .
'</table>';
}
/**
* Renderer the table header.
*/
protected function renderTableHeader(): string
{
if (!$this->options['showHeader']) {
return '';
}
$colspan = $this->options['lineNumbers'] ? '' : ' colspan="2"';
return
'<thead>' .
'<tr>' .
(
$this->options['lineNumbers']
?
'<th>' . $this->_('old_version') . '</th>' .
'<th>' . $this->_('new_version') . '</th>' .
'<th></th>' // diff symbol column
:
''
) .
'<th' . $colspan . '>' . $this->_('differences') . '</th>' .
'</tr>' .
'</thead>';
}
/**
* Renderer the table separate block.
*/
protected function renderTableSeparateBlock(): string
{
$colspan = $this->options['lineNumbers'] ? '4' : '2';
return
'<tbody class="skipped">' .
'<tr>' .
'<td colspan="' . $colspan . '"></td>' .
'</tr>' .
'</tbody>';
}
/**
* Renderer table hunks.
*
* @param array[][] $hunks each hunk has many blocks
*/
protected function renderTableHunks(array $hunks): string
{
$ret = '';
foreach ($hunks as $i => $hunk) {
if ($i > 0 && $this->options['separateBlock']) {
$ret .= $this->renderTableSeparateBlock();
}
foreach ($hunk as $block) {
$ret .= $this->renderTableBlock($block);
}
}
return $ret;
}
/**
* Renderer the table block.
*
* @param array $block the block
*/
protected function renderTableBlock(array $block): string
{
switch ($block['tag']) {
case SequenceMatcher::OP_EQ:
$content = $this->renderTableBlockEqual($block);
break;
case SequenceMatcher::OP_INS:
$content = $this->renderTableBlockInsert($block);
break;
case SequenceMatcher::OP_DEL:
$content = $this->renderTableBlockDelete($block);
break;
case SequenceMatcher::OP_REP:
$content = $this->renderTableBlockReplace($block);
break;
default:
$content = '';
}
return '<tbody class="change change-' . self::TAG_CLASS_MAP[$block['tag']] . '">' . $content . '</tbody>';
}
/**
* Renderer the table block: equal.
*
* @param array $block the block
*/
protected function renderTableBlockEqual(array $block): string
{
$ret = '';
// note that although we are in a OP_EQ situation,
// the old and the new may not be exactly the same
// because of ignoreCase, ignoreWhitespace, etc
foreach ($block['new']['lines'] as $no => $newLine) {
// we could only pick either the old or the new to show
// here we pick the new one to let the user know what it is now
$ret .= $this->renderTableRow(
'new',
SequenceMatcher::OP_EQ,
$newLine,
$block['old']['offset'] + $no + 1,
$block['new']['offset'] + $no + 1,
);
}
return $ret;
}
/**
* Renderer the table block: insert.
*
* @param array $block the block
*/
protected function renderTableBlockInsert(array $block): string
{
$ret = '';
foreach ($block['new']['lines'] as $no => $newLine) {
$ret .= $this->renderTableRow(
'new',
SequenceMatcher::OP_INS,
$newLine,
null,
$block['new']['offset'] + $no + 1,
);
}
return $ret;
}
/**
* Renderer the table block: delete.
*
* @param array $block the block
*/
protected function renderTableBlockDelete(array $block): string
{
$ret = '';
foreach ($block['old']['lines'] as $no => $oldLine) {
$ret .= $this->renderTableRow(
'old',
SequenceMatcher::OP_DEL,
$oldLine,
$block['old']['offset'] + $no + 1,
null,
);
}
return $ret;
}
/**
* Renderer the table block: replace.
*
* @param array $block the block
*/
protected function renderTableBlockReplace(array $block): string
{
return
$this->renderTableBlockDelete($block) .
$this->renderTableBlockInsert($block);
}
/**
* Renderer a content row of the output table.
*
* @param string $tdClass the <td> class
* @param int $op the operation
* @param string $line the line
* @param null|int $oldLineNum the old line number
* @param null|int $newLineNum the new line number
*/
protected function renderTableRow(
string $tdClass,
int $op,
string $line,
?int $oldLineNum,
?int $newLineNum
): string {
return
'<tr data-type="' . self::SYMBOL_MAP[$op] . '">' .
(
$this->options['lineNumbers']
? $this->renderLineNumberColumns($oldLineNum, $newLineNum)
: ''
) .
'<th class="sign ' . self::TAG_CLASS_MAP[$op] . '">' . self::SYMBOL_MAP[$op] . '</th>' .
'<td class="' . $tdClass . '">' . $line . '</td>' .
'</tr>';
}
/**
* Renderer the line number columns.
*
* @param null|int $oldLineNum The old line number
* @param null|int $newLineNum The new line number
*/
protected function renderLineNumberColumns(?int $oldLineNum, ?int $newLineNum): string
{
return
(
isset($oldLineNum)
? '<th class="n-old">' . $oldLineNum . '</th>'
: '<th></th>'
) .
(
isset($newLineNum)
? '<th class="n-new">' . $newLineNum . '</th>'
: '<th></th>'
);
}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff\Renderer\Html;
/**
* HTML Json diff generator.
*
* @deprecated 6.8.0 Use the "JsonHtml" renderer instead.
*/
final class Json extends JsonHtml
{
}

View File

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff\Renderer\Html;
use Jfcherng\Diff\SequenceMatcher;
/**
* HTML Json diff generator.
*/
class JsonHtml extends AbstractHtml
{
/**
* {@inheritdoc}
*/
public const INFO = [
'desc' => 'HTML Json',
'type' => 'Html',
];
/**
* {@inheritdoc}
*/
public const IS_TEXT_RENDERER = true;
public function getResultForIdenticalsDefault(): string
{
return '[]';
}
protected function redererChanges(array $changes): string
{
if ($this->options['outputTagAsString']) {
$this->convertTagToString($changes);
}
return json_encode($changes, $this->options['jsonEncodeFlags']);
}
/**
* Convert tags of changes to their string form for better readability.
*
* @param array[][] $changes the changes
*/
protected function convertTagToString(array &$changes): void
{
foreach ($changes as &$hunks) {
foreach ($hunks as &$block) {
$block['tag'] = SequenceMatcher::opIntToStr($block['tag']);
}
}
}
protected function formatStringFromLines(string $string): string
{
return $this->htmlSafe($string);
}
}

View File

@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff\Renderer\Html\LineRenderer;
use Jfcherng\Diff\SequenceMatcher;
/**
* Base renderer for rendering HTML-based line diffs.
*
* @todo use typed properties (BC breaking for public interface) in v7
*/
abstract class AbstractLineRenderer implements LineRendererInterface
{
/**
* @var SequenceMatcher the sequence matcher
*/
protected $sequenceMatcher;
/**
* @var array the differ options
*/
protected $differOptions = [];
/**
* @var array the renderer options
*/
protected $rendererOptions = [];
/**
* The constructor.
*
* @param array $differOptions the differ options
* @param array $rendererOptions the renderer options
*/
public function __construct(array $differOptions, array $rendererOptions)
{
$this->sequenceMatcher = new SequenceMatcher([], []);
$this
->setDifferOptions($differOptions)
->setRendererOptions($rendererOptions)
;
}
/**
* Set the differ options.
*
* @param array $differOptions the differ options
*
* @return static
*/
public function setDifferOptions(array $differOptions): self
{
$this->differOptions = $differOptions;
$this->sequenceMatcher->setOptions($differOptions);
return $this;
}
/**
* Set the renderer options.
*
* @param array $rendererOptions the renderer options
*
* @return static
*/
public function setRendererOptions(array $rendererOptions): self
{
$this->rendererOptions = $rendererOptions;
return $this;
}
/**
* Gets the differ options.
*
* @return array the differ options
*/
public function getDifferOptions(): array
{
return $this->differOptions;
}
/**
* Gets the renderer options.
*
* @return array the renderer options
*/
public function getRendererOptions(): array
{
return $this->rendererOptions;
}
/**
* Get the changed extent segments.
*
* @param string[] $old the old array
* @param string[] $new the new array
*
* @return int[][] the changed extent segments
*/
protected function getChangedExtentSegments(array $old, array $new): array
{
return $this->sequenceMatcher->setSequences($old, $new)->getOpcodes();
}
}

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff\Renderer\Html\LineRenderer;
use Jfcherng\Diff\Renderer\RendererConstant;
use Jfcherng\Diff\SequenceMatcher;
use Jfcherng\Diff\Utility\ReverseIterator;
use Jfcherng\Utility\MbString;
final class Char extends AbstractLineRenderer
{
/**
* @return static
*/
public function render(MbString $mbOld, MbString $mbNew): LineRendererInterface
{
$hunk = $this->getChangedExtentSegments($mbOld->toArray(), $mbNew->toArray());
// reversely iterate hunk
foreach (ReverseIterator::fromArray($hunk) as [$op, $i1, $i2, $j1, $j2]) {
if ($op & (SequenceMatcher::OP_REP | SequenceMatcher::OP_DEL)) {
$mbOld->str_enclose_i(RendererConstant::HTML_CLOSURES, $i1, $i2 - $i1);
}
if ($op & (SequenceMatcher::OP_REP | SequenceMatcher::OP_INS)) {
$mbNew->str_enclose_i(RendererConstant::HTML_CLOSURES, $j1, $j2 - $j1);
}
}
return $this;
}
}

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff\Renderer\Html\LineRenderer;
use Jfcherng\Diff\Renderer\RendererConstant;
use Jfcherng\Utility\MbString;
final class Line extends AbstractLineRenderer
{
/**
* @return static
*/
public function render(MbString $mbOld, MbString $mbNew): LineRendererInterface
{
[$start, $end] = $this->getChangedExtentRegion($mbOld, $mbNew);
// two strings are the same
if ($end === 0) {
return $this;
}
// two strings are different, we do rendering
$mbOld->str_enclose_i(
RendererConstant::HTML_CLOSURES,
$start,
$end + $mbOld->strlen() - $start + 1,
);
$mbNew->str_enclose_i(
RendererConstant::HTML_CLOSURES,
$start,
$end + $mbNew->strlen() - $start + 1,
);
return $this;
}
/**
* Given two strings, determine where the changes in the two strings begin,
* and where the changes in the two strings end.
*
* @param MbString $mbOld the old megabytes line
* @param MbString $mbNew the new megabytes line
*
* @return int[] Array containing the starting position (non-negative) and the ending position (negative)
* [0, 0] if two strings are the same
*/
protected function getChangedExtentRegion(MbString $mbOld, MbString $mbNew): array
{
// two strings are the same
// most lines should be this cases, an early return could save many function calls
if ($mbOld->getRaw() === $mbNew->getRaw()) {
return [0, 0];
}
// calculate $start
$start = 0;
$startMax = min($mbOld->strlen(), $mbNew->strlen());
while (
$start < $startMax // index out of range
&& $mbOld->getAtRaw($start) === $mbNew->getAtRaw($start)
) {
++$start;
}
// calculate $end
$end = -1; // trick
$endMin = $startMax - $start;
while (
-$end <= $endMin // index out of range
&& $mbOld->getAtRaw($end) === $mbNew->getAtRaw($end)
) {
--$end;
}
return [$start, $end];
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff\Renderer\Html\LineRenderer;
use Jfcherng\Utility\MbString;
interface LineRendererInterface
{
/**
* Renderer the in-line changed extent.
*
* @param MbString $mbOld the old megabytes line
* @param MbString $mbNew the new megabytes line
*
* @return static
*/
public function render(MbString $mbOld, MbString $mbNew): self;
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff\Renderer\Html\LineRenderer;
use Jfcherng\Utility\MbString;
final class None extends AbstractLineRenderer
{
/**
* @return static
*/
public function render(MbString $mbOld, MbString $mbNew): LineRendererInterface
{
return $this;
}
}

View File

@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff\Renderer\Html\LineRenderer;
use Jfcherng\Diff\Renderer\RendererConstant;
use Jfcherng\Diff\SequenceMatcher;
use Jfcherng\Diff\Utility\ReverseIterator;
use Jfcherng\Diff\Utility\Str;
use Jfcherng\Utility\MbString;
final class Word extends AbstractLineRenderer
{
/**
* @return static
*/
public function render(MbString $mbOld, MbString $mbNew): LineRendererInterface
{
static $splitRegex = '/([' . RendererConstant::PUNCTUATIONS_RANGE . '])/uS';
static $dummyHtmlClosure = RendererConstant::HTML_CLOSURES[0] . RendererConstant::HTML_CLOSURES[1];
$pregFlag = \PREG_SPLIT_DELIM_CAPTURE | \PREG_SPLIT_NO_EMPTY;
$oldWords = $mbOld->toArraySplit($splitRegex, -1, $pregFlag);
$newWords = $mbNew->toArraySplit($splitRegex, -1, $pregFlag);
$hunk = $this->getChangedExtentSegments($oldWords, $newWords);
// reversely iterate hunk
foreach (ReverseIterator::fromArray($hunk) as [$op, $i1, $i2, $j1, $j2]) {
if ($op & (SequenceMatcher::OP_REP | SequenceMatcher::OP_DEL)) {
$oldWords[$i1] = RendererConstant::HTML_CLOSURES[0] . $oldWords[$i1];
$oldWords[$i2 - 1] .= RendererConstant::HTML_CLOSURES[1];
// insert dummy HTML closure to ensure there are always
// the same amounts of HTML closures in $oldWords and $newWords
// thus, this should make that "wordGlues" work correctly
// @see https://github.com/jfcherng/php-diff/pull/25
if ($op === SequenceMatcher::OP_DEL) {
array_splice($newWords, $j1, 0, [$dummyHtmlClosure]);
}
}
if ($op & (SequenceMatcher::OP_REP | SequenceMatcher::OP_INS)) {
$newWords[$j1] = RendererConstant::HTML_CLOSURES[0] . $newWords[$j1];
$newWords[$j2 - 1] .= RendererConstant::HTML_CLOSURES[1];
if ($op === SequenceMatcher::OP_INS) {
array_splice($oldWords, $i1, 0, [$dummyHtmlClosure]);
}
}
}
if (!empty($hunk) && !empty($this->rendererOptions['wordGlues'])) {
$regexGlues = array_map(
static fn (string $glue): string => preg_quote($glue, '/'),
$this->rendererOptions['wordGlues'],
);
$gluePattern = '/^(?:' . implode('|', $regexGlues) . ')+$/uS';
$this->glueWordsResult($oldWords, $gluePattern);
$this->glueWordsResult($newWords, $gluePattern);
}
$mbOld->set(implode('', $oldWords));
$mbNew->set(implode('', $newWords));
return $this;
}
/**
* Beautify diff result by glueing words.
*
* What this function does is basically making
* ["<diff_begin>good<diff_end>", "-", "<diff_begin>looking<diff_end>"]
* into
* ["<diff_begin>good", "-", "looking<diff_end>"].
*
* @param array $words the words
* @param string $gluePattern the regex to determine a string is purely glue or not
*/
protected function glueWordsResult(array &$words, string $gluePattern): void
{
/** @var int index of the word which has the trailing closure */
$endClosureIdx = -1;
foreach ($words as $idx => &$word) {
if ($word === '') {
continue;
}
if ($endClosureIdx < 0) {
if (Str::endsWith($word, RendererConstant::HTML_CLOSURES[1])) {
$endClosureIdx = $idx;
}
} elseif (Str::startsWith($word, RendererConstant::HTML_CLOSURES[0])) {
$words[$endClosureIdx] = substr($words[$endClosureIdx], 0, -\strlen(RendererConstant::HTML_CLOSURES[1]));
$word = substr($word, \strlen(RendererConstant::HTML_CLOSURES[0]));
$endClosureIdx = $idx;
} elseif (!preg_match($gluePattern, $word)) {
$endClosureIdx = -1;
}
}
}
}

View File

@ -0,0 +1,278 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff\Renderer\Html;
use Jfcherng\Diff\SequenceMatcher;
/**
* Side by Side HTML diff generator.
*/
final class SideBySide extends AbstractHtml
{
/**
* {@inheritdoc}
*/
public const INFO = [
'desc' => 'Side by side',
'type' => 'Html',
];
protected function redererChanges(array $changes): string
{
if (empty($changes)) {
return $this->getResultForIdenticals();
}
$wrapperClasses = [
...$this->options['wrapperClasses'],
'diff', 'diff-html', 'diff-side-by-side',
];
return
'<table class="' . implode(' ', $wrapperClasses) . '">' .
$this->renderTableHeader() .
$this->renderTableHunks($changes) .
'</table>';
}
/**
* Renderer the table header.
*/
protected function renderTableHeader(): string
{
if (!$this->options['showHeader']) {
return '';
}
$colspan = $this->options['lineNumbers'] ? ' colspan="2"' : '';
return
'<thead>' .
'<tr>' .
'<th' . $colspan . '>' . $this->_('old_version') . '</th>' .
'<th' . $colspan . '>' . $this->_('new_version') . '</th>' .
'</tr>' .
'</thead>';
}
/**
* Renderer the table separate block.
*/
protected function renderTableSeparateBlock(): string
{
$colspan = $this->options['lineNumbers'] ? '4' : '2';
return
'<tbody class="skipped">' .
'<tr>' .
'<td colspan="' . $colspan . '"></td>' .
'</tr>' .
'</tbody>';
}
/**
* Renderer table hunks.
*
* @param array[][] $hunks each hunk has many blocks
*/
protected function renderTableHunks(array $hunks): string
{
$ret = '';
foreach ($hunks as $i => $hunk) {
if ($i > 0 && $this->options['separateBlock']) {
$ret .= $this->renderTableSeparateBlock();
}
foreach ($hunk as $block) {
$ret .= $this->renderTableBlock($block);
}
}
return $ret;
}
/**
* Renderer the table block.
*
* @param array $block the block
*/
protected function renderTableBlock(array $block): string
{
switch ($block['tag']) {
case SequenceMatcher::OP_EQ:
$content = $this->renderTableBlockEqual($block);
break;
case SequenceMatcher::OP_INS:
$content = $this->renderTableBlockInsert($block);
break;
case SequenceMatcher::OP_DEL:
$content = $this->renderTableBlockDelete($block);
break;
case SequenceMatcher::OP_REP:
$content = $this->renderTableBlockReplace($block);
break;
default:
$content = '';
}
return '<tbody class="change change-' . self::TAG_CLASS_MAP[$block['tag']] . '">' . $content . '</tbody>';
}
/**
* Renderer the table block: equal.
*
* @param array $block the block
*/
protected function renderTableBlockEqual(array $block): string
{
$ret = '';
$rowCount = \count($block['new']['lines']);
for ($no = 0; $no < $rowCount; ++$no) {
$ret .= $this->renderTableRow(
$block['old']['lines'][$no],
$block['new']['lines'][$no],
$block['old']['offset'] + $no + 1,
$block['new']['offset'] + $no + 1,
);
}
return $ret;
}
/**
* Renderer the table block: insert.
*
* @param array $block the block
*/
protected function renderTableBlockInsert(array $block): string
{
$ret = '';
foreach ($block['new']['lines'] as $no => $newLine) {
$ret .= $this->renderTableRow(
null,
$newLine,
null,
$block['new']['offset'] + $no + 1,
);
}
return $ret;
}
/**
* Renderer the table block: delete.
*
* @param array $block the block
*/
protected function renderTableBlockDelete(array $block): string
{
$ret = '';
foreach ($block['old']['lines'] as $no => $oldLine) {
$ret .= $this->renderTableRow(
$oldLine,
null,
$block['old']['offset'] + $no + 1,
null,
);
}
return $ret;
}
/**
* Renderer the table block: replace.
*
* @param array $block the block
*/
protected function renderTableBlockReplace(array $block): string
{
$ret = '';
$lineCountMax = max(\count($block['old']['lines']), \count($block['new']['lines']));
for ($no = 0; $no < $lineCountMax; ++$no) {
if (isset($block['old']['lines'][$no])) {
$oldLineNum = $block['old']['offset'] + $no + 1;
$oldLine = $block['old']['lines'][$no];
} else {
$oldLineNum = $oldLine = null;
}
if (isset($block['new']['lines'][$no])) {
$newLineNum = $block['new']['offset'] + $no + 1;
$newLine = $block['new']['lines'][$no];
} else {
$newLineNum = $newLine = null;
}
$ret .= $this->renderTableRow($oldLine, $newLine, $oldLineNum, $newLineNum);
}
return $ret;
}
/**
* Renderer a content row of the output table.
*
* @param null|string $oldLine the old line
* @param null|string $newLine the new line
* @param null|int $oldLineNum the old line number
* @param null|int $newLineNum the new line number
*/
protected function renderTableRow(
?string $oldLine,
?string $newLine,
?int $oldLineNum,
?int $newLineNum
): string {
return
'<tr>' .
(
$this->options['lineNumbers']
? $this->renderLineNumberColumn('old', $oldLineNum)
: ''
) .
$this->renderLineContentColumn('old', $oldLine) .
(
$this->options['lineNumbers']
? $this->renderLineNumberColumn('new', $newLineNum)
: ''
) .
$this->renderLineContentColumn('new', $newLine) .
'</tr>';
}
/**
* Renderer the line number column.
*
* @param string $type the diff type
* @param null|int $lineNum the line number
*/
protected function renderLineNumberColumn(string $type, ?int $lineNum): string
{
return isset($lineNum)
? '<th class="n-' . $type . '">' . $lineNum . '</th>'
: '<th></th>';
}
/**
* Renderer the line content column.
*
* @param string $type the diff type
* @param null|string $content the line content
*/
protected function renderLineContentColumn(string $type, ?string $content): string
{
return
'<td class="' . $type . (isset($content) ? '' : ' none') . '">' .
$content .
'</td>';
}
}

View File

@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff\Renderer;
final class RendererConstant
{
/**
* The base namespace of renderers.
*
* @var string
*/
public const RENDERER_NAMESPACE = __NAMESPACE__;
/**
* Available renderer types.
*
* @var string[]
*/
public const RENDERER_TYPES = ['Html', 'Text'];
/**
* Closures that are used to enclose different parts in string.
*
* Arbitrary chars from the 15-16th Unicode reserved areas
* and hopefully, they won't appear in source texts.
*
* @var string[]
*/
public const HTML_CLOSURES = ["\u{fcffc}\u{ff2fb}", "\u{fff41}\u{fcffc}"];
/**
* Closures that are used to enclose deleted chars in output HTML.
*
* @var string[]
*/
public const HTML_CLOSURES_DEL = ['<del>', '</del>'];
/**
* Closures that are used to enclose inserted chars in output HTML.
*
* @var string[]
*/
public const HTML_CLOSURES_INS = ['<ins>', '</ins>'];
/**
* The delimiter to be used as the glue in string/array functions.
*
* Arbitrary chars from the 15-16th Unicode reserved areas
* and hopefully, it won't appear in source texts.
*
* @var string
*/
public const IMPLODE_DELIMITER = "\u{ff2fa}\u{fcffc}\u{fff42}";
/**
* Regex range for punctuations.
*
* Presuming the regex delimiter is "/".
*
* @var string
*/
public const PUNCTUATIONS_RANGE = (
// Latin-1 Supplement
// @see https://unicode-table.com/en/blocks/latin-1-supplement/
"\u{0080}-\u{00BB}" .
// Spacing Modifier Letters
// @see https://unicode-table.com/en/blocks/spacing-modifier-letters/
"\u{02B0}-\u{02FF}" .
// Combining Diacritical Marks
// @see https://unicode-table.com/en/blocks/combining-diacritical-marks/
"\u{0300}-\u{036F}" .
// Small Form Variants
// @see https://unicode-table.com/en/blocks/small-form-variants/
"\u{FE50}-\u{FE6F}" .
// General Punctuation
// @see https://unicode-table.com/en/blocks/general-punctuation/
"\u{2000}-\u{206F}" .
// Supplemental Punctuation
// @see https://unicode-table.com/en/blocks/supplemental-punctuation/
"\u{2E00}-\u{2E7F}" .
// CJK Symbols and Punctuation
// @see https://unicode-table.com/en/blocks/cjk-symbols-and-punctuation/
"\u{3000}-\u{303F}" .
// Ideographic Symbols and Punctuation
// @see https://unicode-table.com/en/blocks/ideographic-symbols-and-punctuation/
"\u{16FE0}-\u{16FFF}" .
// hmm... these seem to be no rule
" \t\r\n$,.:;!?'\"()\\[\\]{}%@<=>_+\\-*\\/~\\\\|" .
' _' .
'「」『』〈〉《》【】()()‘’“”' .
'.‧・・•·¿'
);
/**
* Colorize the CLI output if possible.
*
* @var int
*/
public const CLI_COLOR_AUTO = -1;
/**
* Force not to colorize the CLI output.
*
* @var int
*/
public const CLI_COLOR_DISABLE = 0;
/**
* Force to colorize the CLI output if possible.
*
* @var int
*/
public const CLI_COLOR_ENABLE = 1;
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff\Renderer;
use Jfcherng\Diff\Differ;
use Jfcherng\Diff\Exception\UnsupportedFunctionException;
/**
* Renderer Interface.
*/
interface RendererInterface
{
/**
* Get the renderer result when the old and the new are the same.
*/
public function getResultForIdenticals(): string;
/**
* Render the differ and return the result.
*
* @param Differ $differ the Differ object to be rendered
*/
public function render(Differ $differ): string;
/**
* Render the differ array and return the result.
*
* @param array[][] $differArray the Differ array to be rendered
*
* @throws UnsupportedFunctionException if the renderer does not support this method
*/
public function renderArray(array $differArray): string;
}

View File

@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff\Renderer\Text;
use Jfcherng\Diff\Exception\UnsupportedFunctionException;
use Jfcherng\Diff\Renderer\AbstractRenderer;
use Jfcherng\Diff\Renderer\RendererConstant;
use Jfcherng\Utility\CliColor;
/**
* Base renderer for rendering text-based diffs.
*/
abstract class AbstractText extends AbstractRenderer
{
/**
* @var bool is this renderer pure text?
*/
public const IS_TEXT_RENDERER = true;
/**
* @var string the diff output representing there is no EOL at EOF in the GNU diff tool
*/
public const GNU_OUTPUT_NO_EOL_AT_EOF = '\ No newline at end of file';
/**
* @var bool controls whether cliColoredString() is enabled or not
*/
protected $isCliColorEnabled = false;
public function setOptions(array $options): AbstractRenderer
{
parent::setOptions($options);
// determine $this->isCliColorEnabled
if ($this->options['cliColorization'] === RendererConstant::CLI_COLOR_ENABLE) {
$this->isCliColorEnabled = true;
} elseif ($this->options['cliColorization'] === RendererConstant::CLI_COLOR_DISABLE) {
$this->isCliColorEnabled = false;
} else {
$this->isCliColorEnabled = \PHP_SAPI === 'cli' && $this->hasColorSupport(\STDOUT);
}
return $this;
}
public function getResultForIdenticalsDefault(): string
{
return '';
}
protected function renderArrayWorker(array $differArray): string
{
throw new UnsupportedFunctionException(__METHOD__);
return ''; // make IDE not complain
}
/**
* Colorize the string for CLI output.
*
* @param string $str the string
* @param null|string $symbol the symbol
*
* @return string the (maybe) colorized string
*/
protected function cliColoredString(string $str, ?string $symbol): string
{
static $symbolToStyles = [
'@' => ['f_purple', 'bold'], // header
'-' => ['f_red', 'bold'], // deleted
'+' => ['f_green', 'bold'], // inserted
'!' => ['f_yellow', 'bold'], // replaced
];
$styles = $symbolToStyles[$symbol] ?? [];
if (!$this->isCliColorEnabled || empty($styles)) {
return $str;
}
return CliColor::color($str, $styles);
}
/**
* Returns true if the stream supports colorization.
*
* Colorization is disabled if not supported by the stream:
*
* This is tricky on Windows, because Cygwin, Msys2 etc emulate pseudo
* terminals via named pipes, so we can only check the environment.
*
* Reference: Composer\XdebugHandler\Process::supportsColor
* https://github.com/composer/xdebug-handler
*
* @see https://github.com/symfony/console/blob/647c51ff073300a432a4a504e29323cf0d5e0571/Output/StreamOutput.php#L81-L124
*
* @param resource $stream
*
* @return bool true if the stream supports colorization, false otherwise
*
* @suppress PhanUndeclaredFunction
*/
protected function hasColorSupport($stream): bool
{
// Follow https://no-color.org/
if (isset($_SERVER['NO_COLOR']) || false !== getenv('NO_COLOR')) {
return false;
}
if ('Hyper' === getenv('TERM_PROGRAM')) {
return true;
}
if (\DIRECTORY_SEPARATOR === '\\') {
return (\function_exists('sapi_windows_vt100_support')
&& @sapi_windows_vt100_support($stream))
|| false !== getenv('ANSICON')
|| 'ON' === getenv('ConEmuANSI')
|| 'xterm' === getenv('TERM');
}
if (\function_exists('stream_isatty')) {
return @stream_isatty($stream);
}
if (\function_exists('posix_isatty')) {
return @posix_isatty($stream);
}
$stat = @fstat($stream);
// Check if formatted mode is S_IFCHR
return $stat ? 0020000 === ($stat['mode'] & 0170000) : false;
}
}

View File

@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff\Renderer\Text;
use Jfcherng\Diff\Differ;
use Jfcherng\Diff\SequenceMatcher;
/**
* Context diff generator.
*
* @see https://en.wikipedia.org/wiki/Diff#Context_format
*/
final class Context extends AbstractText
{
/**
* {@inheritdoc}
*/
public const INFO = [
'desc' => 'Context',
'type' => 'Text',
];
/**
* @var int the union of OPs that indicate there is a change
*/
public const OP_BLOCK_CHANGED =
SequenceMatcher::OP_DEL |
SequenceMatcher::OP_INS |
SequenceMatcher::OP_REP;
protected function renderWorker(Differ $differ): string
{
$ret = '';
foreach ($differ->getGroupedOpcodesGnu() as $hunk) {
$lastBlockIdx = \count($hunk) - 1;
// note that these line number variables are 0-based
$i1 = $hunk[0][1];
$i2 = $hunk[$lastBlockIdx][2];
$j1 = $hunk[0][3];
$j2 = $hunk[$lastBlockIdx][4];
$ret .=
$this->cliColoredString("***************\n", '@') .
$this->renderHunkHeader('*', $i1, $i2) .
$this->renderHunkOld($differ, $hunk) .
$this->renderHunkHeader('-', $j1, $j2) .
$this->renderHunkNew($differ, $hunk);
}
return $ret;
}
/**
* Render the hunk header.
*
* @param string $symbol the symbol
* @param int $a1 the begin index
* @param int $a2 the end index
*/
protected function renderHunkHeader(string $symbol, int $a1, int $a2): string
{
$a1x = $a1 + 1; // 1-based begin line number
return $this->cliColoredString(
"{$symbol}{$symbol}{$symbol} " .
($a1x < $a2 ? "{$a1x},{$a2}" : $a2) .
" {$symbol}{$symbol}{$symbol}{$symbol}\n",
'@', // symbol
);
}
/**
* Render the old hunk.
*
* @param Differ $differ the differ object
* @param int[][] $hunk the hunk
*/
protected function renderHunkOld(Differ $differ, array $hunk): string
{
$ret = '';
$hunkOps = 0;
$noEolAtEofIdx = $differ->getOldNoEolAtEofIdx();
foreach ($hunk as [$op, $i1, $i2, $j1, $j2]) {
// OP_INS does not belongs to an old hunk
if ($op === SequenceMatcher::OP_INS) {
continue;
}
$hunkOps |= $op;
$ret .= $this->renderContext(
self::SYMBOL_MAP[$op],
$differ->getOld($i1, $i2),
$i2 === $noEolAtEofIdx,
);
}
// if there is no content changed, the hunk context should be omitted
return $hunkOps & self::OP_BLOCK_CHANGED ? $ret : '';
}
/**
* Render the new hunk.
*
* @param Differ $differ the differ object
* @param int[][] $hunk the hunk
*/
protected function renderHunkNew(Differ $differ, array $hunk): string
{
$ret = '';
$hunkOps = 0;
$noEolAtEofIdx = $differ->getNewNoEolAtEofIdx();
foreach ($hunk as [$op, $i1, $i2, $j1, $j2]) {
// OP_DEL does not belongs to a new hunk
if ($op === SequenceMatcher::OP_DEL) {
continue;
}
$hunkOps |= $op;
$ret .= $this->renderContext(
self::SYMBOL_MAP[$op],
$differ->getNew($j1, $j2),
$j2 === $noEolAtEofIdx,
);
}
// if there is no content changed, the hunk context should be omitted
return $hunkOps & self::OP_BLOCK_CHANGED ? $ret : '';
}
/**
* Render the context array with the symbol.
*
* @param string $symbol the symbol
* @param string[] $context the context
* @param bool $noEolAtEof there is no EOL at EOF in this block
*/
protected function renderContext(string $symbol, array $context, bool $noEolAtEof = false): string
{
if (empty($context)) {
return '';
}
$ret = "{$symbol} " . implode("\n{$symbol} ", $context) . "\n";
$ret = $this->cliColoredString($ret, $symbol);
if ($noEolAtEof) {
$ret .= self::GNU_OUTPUT_NO_EOL_AT_EOF . "\n";
}
return $ret;
}
}

View File

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff\Renderer\Text;
use Jfcherng\Diff\Differ;
use Jfcherng\Diff\SequenceMatcher;
/**
* Plain text Json diff generator.
*/
final class JsonText extends AbstractText
{
/**
* {@inheritdoc}
*/
public const INFO = [
'desc' => 'Text JSON',
'type' => 'Text',
];
protected function renderWorker(Differ $differ): string
{
$ret = [];
foreach ($differ->getGroupedOpcodes() as $hunk) {
$ret[] = $this->renderHunk($differ, $hunk);
}
if ($this->options['outputTagAsString']) {
$this->convertTagToString($ret);
}
return json_encode($ret, $this->options['jsonEncodeFlags']);
}
/**
* Render the hunk.
*
* @param Differ $differ the differ object
* @param int[][] $hunk the hunk
*/
protected function renderHunk(Differ $differ, array $hunk): array
{
$ret = [];
foreach ($hunk as [$op, $i1, $i2, $j1, $j2]) {
$ret[] = [
'tag' => $op,
'old' => [
'offset' => $i1,
'lines' => $differ->getOld($i1, $i2),
],
'new' => [
'offset' => $j1,
'lines' => $differ->getNew($j1, $j2),
],
];
}
return $ret;
}
/**
* Convert tags of changes to their string form for better readability.
*
* @param array[][] $changes the changes
*/
protected function convertTagToString(array &$changes): void
{
foreach ($changes as &$hunks) {
foreach ($hunks as &$block) {
$block['tag'] = SequenceMatcher::opIntToStr($block['tag']);
}
}
}
}

View File

@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff\Renderer\Text;
use Jfcherng\Diff\Differ;
use Jfcherng\Diff\SequenceMatcher;
/**
* Unified diff generator.
*
* @see https://en.wikipedia.org/wiki/Diff#Unified_format
*/
final class Unified extends AbstractText
{
/**
* {@inheritdoc}
*/
public const INFO = [
'desc' => 'Unified',
'type' => 'Text',
];
protected function renderWorker(Differ $differ): string
{
$ret = '';
foreach ($differ->getGroupedOpcodesGnu() as $hunk) {
$ret .= $this->renderHunkHeader($differ, $hunk);
$ret .= $this->renderHunkBlocks($differ, $hunk);
}
return $ret;
}
/**
* Render the hunk header.
*
* @param Differ $differ the differ
* @param int[][] $hunk the hunk
*/
protected function renderHunkHeader(Differ $differ, array $hunk): string
{
$lastBlockIdx = \count($hunk) - 1;
// note that these line number variables are 0-based
$i1 = $hunk[0][1];
$i2 = $hunk[$lastBlockIdx][2];
$j1 = $hunk[0][3];
$j2 = $hunk[$lastBlockIdx][4];
$oldLinesCount = $i2 - $i1;
$newLinesCount = $j2 - $j1;
return $this->cliColoredString(
'@@' .
' -' .
// the line number in GNU diff is 1-based, so we add 1
// a special case is when a hunk has only changed blocks,
// i.e., context is set to 0, we do not need the adding
($i1 === $i2 ? $i1 : $i1 + 1) .
// if the line counts is 1, it can (and mostly) be omitted
($oldLinesCount === 1 ? '' : ",{$oldLinesCount}") .
' +' .
($j1 === $j2 ? $j1 : $j1 + 1) .
($newLinesCount === 1 ? '' : ",{$newLinesCount}") .
" @@\n",
'@', // symbol
);
}
/**
* Render the hunk content.
*
* @param Differ $differ the differ
* @param int[][] $hunk the hunk
*/
protected function renderHunkBlocks(Differ $differ, array $hunk): string
{
$ret = '';
$oldNoEolAtEofIdx = $differ->getOldNoEolAtEofIdx();
$newNoEolAtEofIdx = $differ->getNewNoEolAtEofIdx();
foreach ($hunk as [$op, $i1, $i2, $j1, $j2]) {
// note that although we are in a OP_EQ situation,
// the old and the new may not be exactly the same
// because of ignoreCase, ignoreWhitespace, etc
if ($op === SequenceMatcher::OP_EQ) {
// we could only pick either the old or the new to show
// note that the GNU diff will use the old one because it creates a patch
$ret .= $this->renderContext(
' ',
$differ->getOld($i1, $i2),
$i2 === $oldNoEolAtEofIdx,
);
continue;
}
if ($op & (SequenceMatcher::OP_REP | SequenceMatcher::OP_DEL)) {
$ret .= $this->renderContext(
'-',
$differ->getOld($i1, $i2),
$i2 === $oldNoEolAtEofIdx,
);
}
if ($op & (SequenceMatcher::OP_REP | SequenceMatcher::OP_INS)) {
$ret .= $this->renderContext(
'+',
$differ->getNew($j1, $j2),
$j2 === $newNoEolAtEofIdx,
);
}
}
return $ret;
}
/**
* Render the context array with the symbol.
*
* @param string $symbol the symbol
* @param string[] $context the context
* @param bool $noEolAtEof there is no EOL at EOF in this block
*/
protected function renderContext(string $symbol, array $context, bool $noEolAtEof = false): string
{
if (empty($context)) {
return '';
}
$ret = $symbol . implode("\n{$symbol}", $context) . "\n";
$ret = $this->cliColoredString($ret, $symbol);
if ($noEolAtEof) {
$ret .= self::GNU_OUTPUT_NO_EOL_AT_EOF . "\n";
}
return $ret;
}
}

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff\Utility;
final class Arr
{
/**
* Get a partial array slice with start/end indexes.
*
* @param array $array the array
* @param int $start the starting index (negative = count from backward)
* @param null|int $end the ending index (negative = count from backward)
* if is null, it returns a slice from $start to the end
*
* @return array array of all of the lines between the specified range
*/
public static function getPartialByIndex(array $array, int $start = 0, ?int $end = null): array
{
$count = \count($array);
// make $end set
$end ??= $count;
// make $start non-negative
if ($start < 0) {
$start += $count;
if ($start < 0) {
$start = 0;
}
}
// make $end non-negative
if ($end < 0) {
$end += $count;
if ($end < 0) {
$end = 0;
}
}
// make the length non-negative
return \array_slice($array, $start, max(0, $end - $start));
}
/**
* Determines whether the array is associative.
*
* @param array $arr the array
*
* @return bool `true` if the array is associative, `false` otherwise
*/
public static function isAssociative($arr): bool
{
foreach ($arr as $key => $value) {
if (\is_string($key)) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff\Utility;
final class Language
{
/**
* @var string[] the translation dict
*/
private array $translations = [];
/**
* @var string the language name
*/
private string $language = '_custom_';
/**
* The constructor.
*
* @param array<int,string|string[]>|string|string[] $target the language ID or translations dict
*/
public function __construct($target = 'eng')
{
$this->load($target);
}
/**
* Gets the language.
*
* @return string the language
*/
public function getLanguage(): string
{
return $this->language;
}
/**
* Gets the translations.
*
* @return array the translations
*/
public function getTranslations(): array
{
return $this->translations;
}
/**
* Loads the target language.
*
* @param array<int,string|string[]>|string|string[] $target the language ID or translations dict
*/
public function load($target): void
{
$this->translations = $this->resolve($target);
$this->language = \is_string($target) ? $target : '_custom_';
}
/**
* Translates the text.
*
* @param string $text the text
*/
public function translate(string $text): string
{
return $this->translations[$text] ?? "![{$text}]";
}
/**
* Get the translations from the language file.
*
* @param string $language the language
*
* @throws \Exception fail to decode the JSON file
* @throws \LogicException path is a directory
* @throws \RuntimeException path cannot be opened
*
* @return string[]
*/
private static function getTranslationsByLanguage(string $language): array
{
$filePath = __DIR__ . "/../languages/{$language}.json";
$file = new \SplFileObject($filePath, 'r');
$fileContent = $file->fread($file->getSize());
try {
$decoded = json_decode($fileContent, true, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new \Exception(sprintf('Fail to decode JSON file (%s): %s', realpath($filePath), (string) $e));
}
return (array) $decoded;
}
/**
* Resolves the target language.
*
* @param array<int,string|string[]>|string|string[] $target the language ID or translations array
*
* @throws \InvalidArgumentException
*
* @return string[] the resolved translations
*/
private function resolve($target): array
{
if (\is_string($target)) {
return self::getTranslationsByLanguage($target);
}
if (\is_array($target)) {
// $target is an associative array
if (Arr::isAssociative($target)) {
return $target;
}
// $target is a list of "key-value pairs or language ID"
return array_reduce(
$target,
fn (array $carry, $translation): array => array_merge($carry, $this->resolve($translation)),
[],
);
}
throw new \InvalidArgumentException('$target is not in valid form');
}
}

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff\Utility;
final class ReverseIterator
{
public const ITERATOR_GET_VALUE = 0;
public const ITERATOR_GET_KEY = 1 << 0;
public const ITERATOR_GET_BOTH = 1 << 1;
/**
* The constructor.
*/
private function __construct()
{
}
/**
* Iterate the array reversely.
*
* @param array $array the array
* @param int $flags the flags
*/
public static function fromArray(array $array, int $flags = self::ITERATOR_GET_VALUE): \Generator
{
// iterate [key => value] pair
if ($flags & self::ITERATOR_GET_BOTH) {
for (end($array); ($key = key($array)) !== null; prev($array)) {
yield $key => current($array);
}
return;
}
// iterate only key
if ($flags & self::ITERATOR_GET_KEY) {
for (end($array); ($key = key($array)) !== null; prev($array)) {
yield $key;
}
return;
}
// iterate only value
for (end($array); key($array) !== null; prev($array)) {
yield current($array);
}
}
}

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Jfcherng\Diff\Utility;
final class Str
{
/**
* Determine if a given string starts with a given substring.
*
* @param string $haystack the haystack
* @param string $needle the needle
*/
public static function startsWith(string $haystack, string $needle): bool
{
return substr($haystack, 0, \strlen($needle)) === $needle;
}
/**
* Determine if a given string ends with a given substring.
*
* @param string $haystack the haystack
* @param string $needle the needle
*/
public static function endsWith(string $haystack, string $needle): bool
{
return substr($haystack, -\strlen($needle)) === $needle;
}
}

View File

@ -0,0 +1,5 @@
{
"old_version": "Стара версия",
"new_version": "Нова версия",
"differences": "Разлики"
}

View File

@ -0,0 +1,5 @@
{
"old_version": "旧版本",
"new_version": "新版本",
"differences": "差异"
}

View File

@ -0,0 +1,5 @@
{
"old_version": "舊版本",
"new_version": "新版本",
"differences": "差異"
}

View File

@ -0,0 +1,5 @@
{
"old_version": "Původní",
"new_version": "Nové",
"differences": "Rozdíly"
}

View File

@ -0,0 +1,5 @@
{
"old_version": "Alt",
"new_version": "Neu",
"differences": "Unterschiede"
}

View File

@ -0,0 +1,5 @@
{
"old_version": "Old",
"new_version": "New",
"differences": "Differences"
}

View File

@ -0,0 +1,5 @@
{
"old_version": "Avant",
"new_version": "Après",
"differences": "Différences"
}

View File

@ -0,0 +1,5 @@
{
"old_version": "Vecchio",
"new_version": "Nuovo",
"differences": "Differenze"
}

View File

@ -0,0 +1,5 @@
{
"old_version": "古い",
"new_version": "新しい",
"differences": "差異"
}

View File

@ -0,0 +1,5 @@
{
"old_version": "Tidligere versjon",
"new_version": "Ny versjon",
"differences": "Differanse"
}

View File

@ -0,0 +1,5 @@
{
"old_version": "Original",
"new_version": "Nova",
"differences": "Diferenças"
}

View File

@ -0,0 +1,5 @@
{
"old_version": "Старая версия",
"new_version": "Новая версия",
"differences": "Различия"
}

View File

@ -0,0 +1,5 @@
{
"old_version": "Anterior",
"new_version": "Nuevo",
"differences": "Diferencias"
}

View File

@ -0,0 +1,5 @@
{
"old_version": "Eski",
"new_version": "Yeni",
"differences": "Değişiklikler"
}

View File

@ -0,0 +1,5 @@
{
"old_version": "Було",
"new_version": "Стало",
"differences": "Відмінності"
}