├── ChangeLog.md ├── LICENSE ├── README.md ├── SECURITY.md ├── composer.json └── src ├── Chunk.php ├── Diff.php ├── Differ.php ├── Exception ├── ConfigurationException.php └── Exception.php ├── Line.php ├── LongestCommonSubsequenceCalculator.php ├── MemoryEfficientLongestCommonSubsequenceCalculator.php ├── Output ├── AbstractChunkOutputBuilder.php ├── DiffOnlyOutputBuilder.php ├── DiffOutputBuilderInterface.php ├── StrictUnifiedDiffOutputBuilder.php └── UnifiedDiffOutputBuilder.php ├── Parser.php └── TimeEfficientLongestCommonSubsequenceCalculator.php /ChangeLog.md: -------------------------------------------------------------------------------- 1 | # ChangeLog 2 | 3 | All notable changes are documented in this file using the [Keep a CHANGELOG](http://keepachangelog.com/) principles. 4 | 5 | ## [7.0.0] - 2025-02-07 6 | 7 | ### Removed 8 | 9 | * This component is no longer supported on PHP 8.2 10 | 11 | ## [6.0.2] - 2024-07-03 12 | 13 | ### Changed 14 | 15 | * This project now uses PHPStan instead of Psalm for static analysis 16 | 17 | ## [6.0.1] - 2024-03-02 18 | 19 | ### Changed 20 | 21 | * Do not use implicitly nullable parameters 22 | 23 | ## [6.0.0] - 2024-02-02 24 | 25 | ### Removed 26 | 27 | * `SebastianBergmann\Diff\Chunk::getStart()`, `SebastianBergmann\Diff\Chunk::getStartRange()`, `SebastianBergmann\Diff\Chunk::getEnd()`, `SebastianBergmann\Diff\Chunk::getEndRange()`, and `SebastianBergmann\Diff\Chunk::getLines()` 28 | * `SebastianBergmann\Diff\Diff::getFrom()`, `SebastianBergmann\Diff\Diff::getTo()`, and `SebastianBergmann\Diff\Diff::getChunks()` 29 | * `SebastianBergmann\Diff\Line::getContent()` and `SebastianBergmann\Diff\Diff::getType()` 30 | * This component is no longer supported on PHP 8.1 31 | 32 | ## [5.1.1] - 2024-03-02 33 | 34 | ### Changed 35 | 36 | * Do not use implicitly nullable parameters 37 | 38 | ## [5.1.0] - 2023-12-22 39 | 40 | ### Added 41 | 42 | * `SebastianBergmann\Diff\Chunk::start()`, `SebastianBergmann\Diff\Chunk::startRange()`, `SebastianBergmann\Diff\Chunk::end()`, `SebastianBergmann\Diff\Chunk::endRange()`, and `SebastianBergmann\Diff\Chunk::lines()` 43 | * `SebastianBergmann\Diff\Diff::from()`, `SebastianBergmann\Diff\Diff::to()`, and `SebastianBergmann\Diff\Diff::chunks()` 44 | * `SebastianBergmann\Diff\Line::content()` and `SebastianBergmann\Diff\Diff::type()` 45 | * `SebastianBergmann\Diff\Line::isAdded()`,`SebastianBergmann\Diff\Line::isRemoved()`, and `SebastianBergmann\Diff\Line::isUnchanged()` 46 | 47 | ### Changed 48 | 49 | * `SebastianBergmann\Diff\Diff` now implements `IteratorAggregate`, iterating over it yields the aggregated `SebastianBergmann\Diff\Chunk` objects 50 | * `SebastianBergmann\Diff\Chunk` now implements `IteratorAggregate`, iterating over it yields the aggregated `SebastianBergmann\Diff\Line` objects 51 | 52 | ### Deprecated 53 | 54 | * `SebastianBergmann\Diff\Chunk::getStart()`, `SebastianBergmann\Diff\Chunk::getStartRange()`, `SebastianBergmann\Diff\Chunk::getEnd()`, `SebastianBergmann\Diff\Chunk::getEndRange()`, and `SebastianBergmann\Diff\Chunk::getLines()` 55 | * `SebastianBergmann\Diff\Diff::getFrom()`, `SebastianBergmann\Diff\Diff::getTo()`, and `SebastianBergmann\Diff\Diff::getChunks()` 56 | * `SebastianBergmann\Diff\Line::getContent()` and `SebastianBergmann\Diff\Diff::getType()` 57 | 58 | ## [5.0.3] - 2023-05-01 59 | 60 | ### Changed 61 | 62 | * [#119](https://github.com/sebastianbergmann/diff/pull/119): Improve performance of `TimeEfficientLongestCommonSubsequenceCalculator` 63 | 64 | ## [5.0.2] - 2023-05-01 65 | 66 | ### Changed 67 | 68 | * [#118](https://github.com/sebastianbergmann/diff/pull/118): Improve performance of `MemoryEfficientLongestCommonSubsequenceCalculator` 69 | 70 | ## [5.0.1] - 2023-03-23 71 | 72 | ### Fixed 73 | 74 | * [#115](https://github.com/sebastianbergmann/diff/pull/115): `Parser::parseFileDiff()` does not handle diffs correctly that only add lines or only remove lines 75 | 76 | ## [5.0.0] - 2023-02-03 77 | 78 | ### Changed 79 | 80 | * Passing a `DiffOutputBuilderInterface` instance to `Differ::__construct()` is no longer optional 81 | 82 | ### Removed 83 | 84 | * This component is no longer supported on PHP 7.3, PHP 7.4, and PHP 8.0 85 | 86 | ## [4.0.4] - 2020-10-26 87 | 88 | ### Fixed 89 | 90 | * `SebastianBergmann\Diff\Exception` now correctly extends `\Throwable` 91 | 92 | ## [4.0.3] - 2020-09-28 93 | 94 | ### Changed 95 | 96 | * Changed PHP version constraint in `composer.json` from `^7.3 || ^8.0` to `>=7.3` 97 | 98 | ## [4.0.2] - 2020-06-30 99 | 100 | ### Added 101 | 102 | * This component is now supported on PHP 8 103 | 104 | ## [4.0.1] - 2020-05-08 105 | 106 | ### Fixed 107 | 108 | * [#99](https://github.com/sebastianbergmann/diff/pull/99): Regression in unified diff output of identical strings 109 | 110 | ## [4.0.0] - 2020-02-07 111 | 112 | ### Removed 113 | 114 | * This component is no longer supported on PHP 7.1 and PHP 7.2 115 | 116 | ## [3.0.2] - 2019-02-04 117 | 118 | ### Changed 119 | 120 | * `Chunk::setLines()` now ensures that the `$lines` array only contains `Line` objects 121 | 122 | ## [3.0.1] - 2018-06-10 123 | 124 | ### Fixed 125 | 126 | * Removed `"minimum-stability": "dev",` from `composer.json` 127 | 128 | ## [3.0.0] - 2018-02-01 129 | 130 | * The `StrictUnifiedDiffOutputBuilder` implementation of the `DiffOutputBuilderInterface` was added 131 | 132 | ### Changed 133 | 134 | * The default `DiffOutputBuilderInterface` implementation now generates context lines (unchanged lines) 135 | 136 | ### Removed 137 | 138 | * This component is no longer supported on PHP 7.0 139 | 140 | ### Fixed 141 | 142 | * [#70](https://github.com/sebastianbergmann/diff/issues/70): Diffing of arrays no longer works 143 | 144 | ## [2.0.1] - 2017-08-03 145 | 146 | ### Fixed 147 | 148 | * [#66](https://github.com/sebastianbergmann/diff/pull/66): Restored backwards compatibility for PHPUnit 6.1.4, 6.2.0, 6.2.1, 6.2.2, and 6.2.3 149 | 150 | ## [2.0.0] - 2017-07-11 [YANKED] 151 | 152 | ### Added 153 | 154 | * [#64](https://github.com/sebastianbergmann/diff/pull/64): Show line numbers for chunks of a diff 155 | 156 | ### Removed 157 | 158 | * This component is no longer supported on PHP 5.6 159 | 160 | [7.0.0]: https://github.com/sebastianbergmann/diff/compare/6.0...7.0.0 161 | [6.0.2]: https://github.com/sebastianbergmann/diff/compare/6.0.1...6.0.2 162 | [6.0.1]: https://github.com/sebastianbergmann/diff/compare/6.0.0...6.0.1 163 | [6.0.0]: https://github.com/sebastianbergmann/diff/compare/5.1...6.0.0 164 | [5.1.1]: https://github.com/sebastianbergmann/diff/compare/5.1.0...5.1.1 165 | [5.1.0]: https://github.com/sebastianbergmann/diff/compare/5.0.3...5.1.0 166 | [5.0.3]: https://github.com/sebastianbergmann/diff/compare/5.0.2...5.0.3 167 | [5.0.2]: https://github.com/sebastianbergmann/diff/compare/5.0.1...5.0.2 168 | [5.0.1]: https://github.com/sebastianbergmann/diff/compare/5.0.0...5.0.1 169 | [5.0.0]: https://github.com/sebastianbergmann/diff/compare/4.0.4...5.0.0 170 | [4.0.4]: https://github.com/sebastianbergmann/diff/compare/4.0.3...4.0.4 171 | [4.0.3]: https://github.com/sebastianbergmann/diff/compare/4.0.2...4.0.3 172 | [4.0.2]: https://github.com/sebastianbergmann/diff/compare/4.0.1...4.0.2 173 | [4.0.1]: https://github.com/sebastianbergmann/diff/compare/4.0.0...4.0.1 174 | [4.0.0]: https://github.com/sebastianbergmann/diff/compare/3.0.2...4.0.0 175 | [3.0.2]: https://github.com/sebastianbergmann/diff/compare/3.0.1...3.0.2 176 | [3.0.1]: https://github.com/sebastianbergmann/diff/compare/3.0.0...3.0.1 177 | [3.0.0]: https://github.com/sebastianbergmann/diff/compare/2.0...3.0.0 178 | [2.0.1]: https://github.com/sebastianbergmann/diff/compare/c341c98ce083db77f896a0aa64f5ee7652915970...2.0.1 179 | [2.0.0]: https://github.com/sebastianbergmann/diff/compare/1.4...c341c98ce083db77f896a0aa64f5ee7652915970 180 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2002-2025, Sebastian Bergmann 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Latest Stable Version](https://poser.pugx.org/sebastian/diff/v)](https://packagist.org/packages/sebastian/diff) 2 | [![CI Status](https://github.com/sebastianbergmann/diff/workflows/CI/badge.svg)](https://github.com/sebastianbergmann/diff/actions) 3 | [![codecov](https://codecov.io/gh/sebastianbergmann/diff/branch/main/graph/badge.svg)](https://codecov.io/gh/sebastianbergmann/diff) 4 | 5 | # sebastian/diff 6 | 7 | Diff implementation for PHP, factored out of PHPUnit into a stand-alone component. 8 | 9 | ## Installation 10 | 11 | You can add this library as a local, per-project dependency to your project using [Composer](https://getcomposer.org/): 12 | 13 | ``` 14 | composer require sebastian/diff 15 | ``` 16 | 17 | If you only need this library during development, for instance to run your project's test suite, then you should add it as a development-time dependency: 18 | 19 | ``` 20 | composer require --dev sebastian/diff 21 | ``` 22 | 23 | ### Usage 24 | 25 | #### Generating diff 26 | 27 | The `Differ` class can be used to generate a textual representation of the difference between two strings: 28 | 29 | ```php 30 | diff('foo', 'bar'); 37 | ``` 38 | 39 | The code above yields the output below: 40 | 41 | ```diff 42 | --- Original 43 | +++ New 44 | @@ @@ 45 | -foo 46 | +bar 47 | ``` 48 | 49 | The `UnifiedDiffOutputBuilder` used in the example above generates output in "unified diff" 50 | format and is used by PHPUnit, for example. 51 | 52 | The `StrictUnifiedDiffOutputBuilder` generates output in "strict unified diff" format with 53 | hunks, similar to `diff -u` and compatible with `patch` or `git apply`. 54 | 55 | The `DiffOnlyOutputBuilder` generates output that only contains the lines that differ. 56 | 57 | If none of these three output builders match your use case then you can implement 58 | `DiffOutputBuilderInterface` to generate custom output. 59 | 60 | #### Parsing diff 61 | 62 | The `Parser` class can be used to parse a unified diff into an object graph: 63 | 64 | ```php 65 | use SebastianBergmann\Diff\Parser; 66 | use SebastianBergmann\Git; 67 | 68 | $git = new Git('/usr/local/src/money'); 69 | 70 | $diff = $git->getDiff( 71 | '948a1a07768d8edd10dcefa8315c1cbeffb31833', 72 | 'c07a373d2399f3e686234c4f7f088d635eb9641b' 73 | ); 74 | 75 | $parser = new Parser; 76 | 77 | print_r($parser->parse($diff)); 78 | ``` 79 | 80 | The code above yields the output below: 81 | 82 | Array 83 | ( 84 | [0] => SebastianBergmann\Diff\Diff Object 85 | ( 86 | [from:SebastianBergmann\Diff\Diff:private] => a/tests/MoneyTest.php 87 | [to:SebastianBergmann\Diff\Diff:private] => b/tests/MoneyTest.php 88 | [chunks:SebastianBergmann\Diff\Diff:private] => Array 89 | ( 90 | [0] => SebastianBergmann\Diff\Chunk Object 91 | ( 92 | [start:SebastianBergmann\Diff\Chunk:private] => 87 93 | [startRange:SebastianBergmann\Diff\Chunk:private] => 7 94 | [end:SebastianBergmann\Diff\Chunk:private] => 87 95 | [endRange:SebastianBergmann\Diff\Chunk:private] => 7 96 | [lines:SebastianBergmann\Diff\Chunk:private] => Array 97 | ( 98 | [0] => SebastianBergmann\Diff\Line Object 99 | ( 100 | [type:SebastianBergmann\Diff\Line:private] => 3 101 | [content:SebastianBergmann\Diff\Line:private] => * @covers SebastianBergmann\Money\Money::add 102 | ) 103 | 104 | [1] => SebastianBergmann\Diff\Line Object 105 | ( 106 | [type:SebastianBergmann\Diff\Line:private] => 3 107 | [content:SebastianBergmann\Diff\Line:private] => * @covers SebastianBergmann\Money\Money::newMoney 108 | ) 109 | 110 | [2] => SebastianBergmann\Diff\Line Object 111 | ( 112 | [type:SebastianBergmann\Diff\Line:private] => 3 113 | [content:SebastianBergmann\Diff\Line:private] => */ 114 | ) 115 | 116 | [3] => SebastianBergmann\Diff\Line Object 117 | ( 118 | [type:SebastianBergmann\Diff\Line:private] => 2 119 | [content:SebastianBergmann\Diff\Line:private] => public function testAnotherMoneyWithSameCurrencyObjectCanBeAdded() 120 | ) 121 | 122 | [4] => SebastianBergmann\Diff\Line Object 123 | ( 124 | [type:SebastianBergmann\Diff\Line:private] => 1 125 | [content:SebastianBergmann\Diff\Line:private] => public function testAnotherMoneyObjectWithSameCurrencyCanBeAdded() 126 | ) 127 | 128 | [5] => SebastianBergmann\Diff\Line Object 129 | ( 130 | [type:SebastianBergmann\Diff\Line:private] => 3 131 | [content:SebastianBergmann\Diff\Line:private] => { 132 | ) 133 | 134 | [6] => SebastianBergmann\Diff\Line Object 135 | ( 136 | [type:SebastianBergmann\Diff\Line:private] => 3 137 | [content:SebastianBergmann\Diff\Line:private] => $a = new Money(1, new Currency('EUR')); 138 | ) 139 | 140 | [7] => SebastianBergmann\Diff\Line Object 141 | ( 142 | [type:SebastianBergmann\Diff\Line:private] => 3 143 | [content:SebastianBergmann\Diff\Line:private] => $b = new Money(2, new Currency('EUR')); 144 | ) 145 | ) 146 | ) 147 | ) 148 | ) 149 | ) 150 | 151 | Note: If the chunk size is 0 lines, i.e., `getStartRange()` or `getEndRange()` return 0, the number of line returned by `getStart()` or `getEnd()` is one lower than one would expect. It is the line number after which the chunk should be inserted or deleted; in all other cases, it gives the first line number of the replaced range of lines. 152 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | If you believe you have found a security vulnerability in the library that is developed in this repository, please report it to us through coordinated disclosure. 4 | 5 | **Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.** 6 | 7 | Instead, please email `sebastian@phpunit.de`. 8 | 9 | Please include as much of the information listed below as you can to help us better understand and resolve the issue: 10 | 11 | * The type of issue 12 | * Full paths of source file(s) related to the manifestation of the issue 13 | * The location of the affected source code (tag/branch/commit or direct URL) 14 | * Any special configuration required to reproduce the issue 15 | * Step-by-step instructions to reproduce the issue 16 | * Proof-of-concept or exploit code (if possible) 17 | * Impact of the issue, including how an attacker might exploit the issue 18 | 19 | This information will help us triage your report more quickly. 20 | 21 | ## Web Context 22 | 23 | The library that is developed in this repository was either extracted from [PHPUnit](https://github.com/sebastianbergmann/phpunit) or developed specifically as a dependency for PHPUnit. 24 | 25 | The library is developed with a focus on development environments and the command-line. No specific testing or hardening with regard to using the library in an HTTP or web context or with untrusted input data is performed. The library might also contain functionality that intentionally exposes internal application data for debugging purposes. 26 | 27 | If the library is used in a web application, the application developer is responsible for filtering inputs or escaping outputs as necessary and for verifying that the used functionality is safe for use within the intended context. 28 | 29 | Vulnerabilities specific to the use outside a development context will be fixed as applicable, provided that the fix does not have an averse effect on the primary use case for development purposes. 30 | 31 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sebastian/diff", 3 | "description": "Diff implementation", 4 | "keywords": ["diff", "udiff", "unidiff", "unified diff"], 5 | "homepage": "https://github.com/sebastianbergmann/diff", 6 | "license": "BSD-3-Clause", 7 | "authors": [ 8 | { 9 | "name": "Sebastian Bergmann", 10 | "email": "sebastian@phpunit.de" 11 | }, 12 | { 13 | "name": "Kore Nordmann", 14 | "email": "mail@kore-nordmann.de" 15 | } 16 | ], 17 | "support": { 18 | "issues": "https://github.com/sebastianbergmann/diff/issues", 19 | "security": "https://github.com/sebastianbergmann/diff/security/policy" 20 | }, 21 | "prefer-stable": true, 22 | "config": { 23 | "platform": { 24 | "php": "8.3.0" 25 | }, 26 | "optimize-autoloader": true, 27 | "sort-packages": true 28 | }, 29 | "require": { 30 | "php": ">=8.3" 31 | }, 32 | "require-dev": { 33 | "phpunit/phpunit": "^12.0", 34 | "symfony/process": "^7.2" 35 | }, 36 | "autoload": { 37 | "classmap": [ 38 | "src/" 39 | ] 40 | }, 41 | "autoload-dev": { 42 | "classmap": [ 43 | "tests/" 44 | ] 45 | }, 46 | "extra": { 47 | "branch-alias": { 48 | "dev-main": "7.0-dev" 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Chunk.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace SebastianBergmann\Diff; 11 | 12 | use ArrayIterator; 13 | use IteratorAggregate; 14 | use Traversable; 15 | 16 | /** 17 | * @template-implements IteratorAggregate 18 | */ 19 | final class Chunk implements IteratorAggregate 20 | { 21 | private int $start; 22 | private int $startRange; 23 | private int $end; 24 | private int $endRange; 25 | 26 | /** 27 | * @var list 28 | */ 29 | private array $lines; 30 | 31 | /** 32 | * @param list $lines 33 | */ 34 | public function __construct(int $start = 0, int $startRange = 1, int $end = 0, int $endRange = 1, array $lines = []) 35 | { 36 | $this->start = $start; 37 | $this->startRange = $startRange; 38 | $this->end = $end; 39 | $this->endRange = $endRange; 40 | $this->lines = $lines; 41 | } 42 | 43 | public function start(): int 44 | { 45 | return $this->start; 46 | } 47 | 48 | public function startRange(): int 49 | { 50 | return $this->startRange; 51 | } 52 | 53 | public function end(): int 54 | { 55 | return $this->end; 56 | } 57 | 58 | public function endRange(): int 59 | { 60 | return $this->endRange; 61 | } 62 | 63 | /** 64 | * @return list 65 | */ 66 | public function lines(): array 67 | { 68 | return $this->lines; 69 | } 70 | 71 | /** 72 | * @param list $lines 73 | */ 74 | public function setLines(array $lines): void 75 | { 76 | $this->lines = $lines; 77 | } 78 | 79 | public function getIterator(): Traversable 80 | { 81 | return new ArrayIterator($this->lines); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Diff.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace SebastianBergmann\Diff; 11 | 12 | use ArrayIterator; 13 | use IteratorAggregate; 14 | use Traversable; 15 | 16 | /** 17 | * @template-implements IteratorAggregate 18 | */ 19 | final class Diff implements IteratorAggregate 20 | { 21 | /** 22 | * @var non-empty-string 23 | */ 24 | private string $from; 25 | 26 | /** 27 | * @var non-empty-string 28 | */ 29 | private string $to; 30 | 31 | /** 32 | * @var list 33 | */ 34 | private array $chunks; 35 | 36 | /** 37 | * @param non-empty-string $from 38 | * @param non-empty-string $to 39 | * @param list $chunks 40 | */ 41 | public function __construct(string $from, string $to, array $chunks = []) 42 | { 43 | $this->from = $from; 44 | $this->to = $to; 45 | $this->chunks = $chunks; 46 | } 47 | 48 | /** 49 | * @return non-empty-string 50 | */ 51 | public function from(): string 52 | { 53 | return $this->from; 54 | } 55 | 56 | /** 57 | * @return non-empty-string 58 | */ 59 | public function to(): string 60 | { 61 | return $this->to; 62 | } 63 | 64 | /** 65 | * @return list 66 | */ 67 | public function chunks(): array 68 | { 69 | return $this->chunks; 70 | } 71 | 72 | /** 73 | * @param list $chunks 74 | */ 75 | public function setChunks(array $chunks): void 76 | { 77 | $this->chunks = $chunks; 78 | } 79 | 80 | public function getIterator(): Traversable 81 | { 82 | return new ArrayIterator($this->chunks); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Differ.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace SebastianBergmann\Diff; 11 | 12 | use const PHP_INT_SIZE; 13 | use const PREG_SPLIT_DELIM_CAPTURE; 14 | use const PREG_SPLIT_NO_EMPTY; 15 | use function array_shift; 16 | use function array_unshift; 17 | use function array_values; 18 | use function count; 19 | use function current; 20 | use function end; 21 | use function is_string; 22 | use function key; 23 | use function min; 24 | use function preg_split; 25 | use function prev; 26 | use function reset; 27 | use function str_ends_with; 28 | use function substr; 29 | use SebastianBergmann\Diff\Output\DiffOutputBuilderInterface; 30 | 31 | final class Differ 32 | { 33 | public const int OLD = 0; 34 | public const int ADDED = 1; 35 | public const int REMOVED = 2; 36 | public const int DIFF_LINE_END_WARNING = 3; 37 | public const int NO_LINE_END_EOF_WARNING = 4; 38 | private DiffOutputBuilderInterface $outputBuilder; 39 | 40 | public function __construct(DiffOutputBuilderInterface $outputBuilder) 41 | { 42 | $this->outputBuilder = $outputBuilder; 43 | } 44 | 45 | /** 46 | * @param list|string $from 47 | * @param list|string $to 48 | */ 49 | public function diff(array|string $from, array|string $to, ?LongestCommonSubsequenceCalculator $lcs = null): string 50 | { 51 | $diff = $this->diffToArray($from, $to, $lcs); 52 | 53 | return $this->outputBuilder->getDiff($diff); 54 | } 55 | 56 | /** 57 | * @param list|string $from 58 | * @param list|string $to 59 | */ 60 | public function diffToArray(array|string $from, array|string $to, ?LongestCommonSubsequenceCalculator $lcs = null): array 61 | { 62 | if (is_string($from)) { 63 | $from = $this->splitStringByLines($from); 64 | } 65 | 66 | if (is_string($to)) { 67 | $to = $this->splitStringByLines($to); 68 | } 69 | 70 | [$from, $to, $start, $end] = self::getArrayDiffParted($from, $to); 71 | 72 | if ($lcs === null) { 73 | $lcs = $this->selectLcsImplementation($from, $to); 74 | } 75 | 76 | $common = $lcs->calculate(array_values($from), array_values($to)); 77 | $diff = []; 78 | 79 | foreach ($start as $token) { 80 | $diff[] = [$token, self::OLD]; 81 | } 82 | 83 | reset($from); 84 | reset($to); 85 | 86 | foreach ($common as $token) { 87 | while ((/* from-token */ reset($from)) !== $token) { 88 | $diff[] = [array_shift($from), self::REMOVED]; 89 | } 90 | 91 | while ((/* to-token */ reset($to)) !== $token) { 92 | $diff[] = [array_shift($to), self::ADDED]; 93 | } 94 | 95 | $diff[] = [$token, self::OLD]; 96 | 97 | array_shift($from); 98 | array_shift($to); 99 | } 100 | 101 | while (($token = array_shift($from)) !== null) { 102 | $diff[] = [$token, self::REMOVED]; 103 | } 104 | 105 | while (($token = array_shift($to)) !== null) { 106 | $diff[] = [$token, self::ADDED]; 107 | } 108 | 109 | foreach ($end as $token) { 110 | $diff[] = [$token, self::OLD]; 111 | } 112 | 113 | if ($this->detectUnmatchedLineEndings($diff)) { 114 | array_unshift($diff, ["#Warning: Strings contain different line endings!\n", self::DIFF_LINE_END_WARNING]); 115 | } 116 | 117 | return $diff; 118 | } 119 | 120 | private function splitStringByLines(string $input): array 121 | { 122 | return preg_split('/(.*\R)/', $input, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); 123 | } 124 | 125 | private function selectLcsImplementation(array $from, array $to): LongestCommonSubsequenceCalculator 126 | { 127 | // We do not want to use the time-efficient implementation if its memory 128 | // footprint will probably exceed this value. Note that the footprint 129 | // calculation is only an estimation for the matrix and the LCS method 130 | // will typically allocate a bit more memory than this. 131 | $memoryLimit = 100 * 1024 * 1024; 132 | 133 | if ($this->calculateEstimatedFootprint($from, $to) > $memoryLimit) { 134 | return new MemoryEfficientLongestCommonSubsequenceCalculator; 135 | } 136 | 137 | return new TimeEfficientLongestCommonSubsequenceCalculator; 138 | } 139 | 140 | private function calculateEstimatedFootprint(array $from, array $to): int 141 | { 142 | $itemSize = PHP_INT_SIZE === 4 ? 76 : 144; 143 | 144 | return $itemSize * min(count($from), count($to)) ** 2; 145 | } 146 | 147 | private function detectUnmatchedLineEndings(array $diff): bool 148 | { 149 | $newLineBreaks = ['' => true]; 150 | $oldLineBreaks = ['' => true]; 151 | 152 | foreach ($diff as $entry) { 153 | if (self::OLD === $entry[1]) { 154 | $ln = $this->getLinebreak($entry[0]); 155 | $oldLineBreaks[$ln] = true; 156 | $newLineBreaks[$ln] = true; 157 | } elseif (self::ADDED === $entry[1]) { 158 | $newLineBreaks[$this->getLinebreak($entry[0])] = true; 159 | } elseif (self::REMOVED === $entry[1]) { 160 | $oldLineBreaks[$this->getLinebreak($entry[0])] = true; 161 | } 162 | } 163 | 164 | // if either input or output is a single line without breaks than no warning should be raised 165 | if (['' => true] === $newLineBreaks || ['' => true] === $oldLineBreaks) { 166 | return false; 167 | } 168 | 169 | // two-way compare 170 | foreach ($newLineBreaks as $break => $set) { 171 | if (!isset($oldLineBreaks[$break])) { 172 | return true; 173 | } 174 | } 175 | 176 | foreach ($oldLineBreaks as $break => $set) { 177 | if (!isset($newLineBreaks[$break])) { 178 | return true; 179 | } 180 | } 181 | 182 | return false; 183 | } 184 | 185 | private function getLinebreak(int|string $line): string 186 | { 187 | if (!is_string($line)) { 188 | return ''; 189 | } 190 | 191 | $lc = substr($line, -1); 192 | 193 | if ("\r" === $lc) { 194 | return "\r"; 195 | } 196 | 197 | if ("\n" !== $lc) { 198 | return ''; 199 | } 200 | 201 | if (str_ends_with($line, "\r\n")) { 202 | return "\r\n"; 203 | } 204 | 205 | return "\n"; 206 | } 207 | 208 | private static function getArrayDiffParted(array &$from, array &$to): array 209 | { 210 | $start = []; 211 | $end = []; 212 | 213 | reset($to); 214 | 215 | foreach ($from as $k => $v) { 216 | $toK = key($to); 217 | 218 | if ($toK === $k && $v === $to[$k]) { 219 | $start[$k] = $v; 220 | 221 | unset($from[$k], $to[$k]); 222 | } else { 223 | break; 224 | } 225 | } 226 | 227 | end($from); 228 | end($to); 229 | 230 | do { 231 | $fromK = key($from); 232 | $toK = key($to); 233 | 234 | if (null === $fromK || null === $toK || current($from) !== current($to)) { 235 | break; 236 | } 237 | 238 | prev($from); 239 | prev($to); 240 | 241 | $end = [$fromK => $from[$fromK]] + $end; 242 | unset($from[$fromK], $to[$toK]); 243 | } while (true); 244 | 245 | return [$from, $to, $start, $end]; 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /src/Exception/ConfigurationException.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace SebastianBergmann\Diff; 11 | 12 | use function gettype; 13 | use function is_object; 14 | use function sprintf; 15 | use InvalidArgumentException; 16 | 17 | final class ConfigurationException extends InvalidArgumentException implements Exception 18 | { 19 | public function __construct(string $option, string $expected, mixed $value, int $code = 0, ?\Exception $previous = null) 20 | { 21 | parent::__construct( 22 | sprintf( 23 | 'Option "%s" must be %s, got "%s".', 24 | $option, 25 | $expected, 26 | is_object($value) ? $value::class : (null === $value ? '' : gettype($value) . '#' . $value), 27 | ), 28 | $code, 29 | $previous, 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Exception/Exception.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace SebastianBergmann\Diff; 11 | 12 | use Throwable; 13 | 14 | interface Exception extends Throwable 15 | { 16 | } 17 | -------------------------------------------------------------------------------- /src/Line.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace SebastianBergmann\Diff; 11 | 12 | final class Line 13 | { 14 | public const int ADDED = 1; 15 | public const int REMOVED = 2; 16 | public const int UNCHANGED = 3; 17 | private int $type; 18 | private string $content; 19 | 20 | public function __construct(int $type = self::UNCHANGED, string $content = '') 21 | { 22 | $this->type = $type; 23 | $this->content = $content; 24 | } 25 | 26 | public function content(): string 27 | { 28 | return $this->content; 29 | } 30 | 31 | public function type(): int 32 | { 33 | return $this->type; 34 | } 35 | 36 | public function isAdded(): bool 37 | { 38 | return $this->type === self::ADDED; 39 | } 40 | 41 | public function isRemoved(): bool 42 | { 43 | return $this->type === self::REMOVED; 44 | } 45 | 46 | public function isUnchanged(): bool 47 | { 48 | return $this->type === self::UNCHANGED; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/LongestCommonSubsequenceCalculator.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace SebastianBergmann\Diff; 11 | 12 | interface LongestCommonSubsequenceCalculator 13 | { 14 | /** 15 | * Calculates the longest common subsequence of two arrays. 16 | */ 17 | public function calculate(array $from, array $to): array; 18 | } 19 | -------------------------------------------------------------------------------- /src/MemoryEfficientLongestCommonSubsequenceCalculator.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace SebastianBergmann\Diff; 11 | 12 | use function array_fill; 13 | use function array_merge; 14 | use function array_reverse; 15 | use function array_slice; 16 | use function count; 17 | use function in_array; 18 | 19 | final class MemoryEfficientLongestCommonSubsequenceCalculator implements LongestCommonSubsequenceCalculator 20 | { 21 | /** 22 | * @inheritDoc 23 | */ 24 | public function calculate(array $from, array $to): array 25 | { 26 | $cFrom = count($from); 27 | $cTo = count($to); 28 | 29 | if ($cFrom === 0) { 30 | return []; 31 | } 32 | 33 | if ($cFrom === 1) { 34 | if (in_array($from[0], $to, true)) { 35 | return [$from[0]]; 36 | } 37 | 38 | return []; 39 | } 40 | 41 | $i = (int) ($cFrom / 2); 42 | $fromStart = array_slice($from, 0, $i); 43 | $fromEnd = array_slice($from, $i); 44 | $llB = $this->length($fromStart, $to); 45 | $llE = $this->length(array_reverse($fromEnd), array_reverse($to)); 46 | $jMax = 0; 47 | $max = 0; 48 | 49 | for ($j = 0; $j <= $cTo; $j++) { 50 | $m = $llB[$j] + $llE[$cTo - $j]; 51 | 52 | if ($m >= $max) { 53 | $max = $m; 54 | $jMax = $j; 55 | } 56 | } 57 | 58 | $toStart = array_slice($to, 0, $jMax); 59 | $toEnd = array_slice($to, $jMax); 60 | 61 | return array_merge( 62 | $this->calculate($fromStart, $toStart), 63 | $this->calculate($fromEnd, $toEnd), 64 | ); 65 | } 66 | 67 | private function length(array $from, array $to): array 68 | { 69 | $current = array_fill(0, count($to) + 1, 0); 70 | $cFrom = count($from); 71 | $cTo = count($to); 72 | 73 | for ($i = 0; $i < $cFrom; $i++) { 74 | $prev = $current; 75 | 76 | for ($j = 0; $j < $cTo; $j++) { 77 | if ($from[$i] === $to[$j]) { 78 | $current[$j + 1] = $prev[$j] + 1; 79 | } else { 80 | /** 81 | * @noinspection PhpConditionCanBeReplacedWithMinMaxCallInspection 82 | * 83 | * We do not use max() here to avoid the function call overhead 84 | */ 85 | if ($current[$j] > $prev[$j + 1]) { 86 | $current[$j + 1] = $current[$j]; 87 | } else { 88 | $current[$j + 1] = $prev[$j + 1]; 89 | } 90 | } 91 | } 92 | } 93 | 94 | return $current; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Output/AbstractChunkOutputBuilder.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace SebastianBergmann\Diff\Output; 11 | 12 | use function count; 13 | 14 | abstract class AbstractChunkOutputBuilder implements DiffOutputBuilderInterface 15 | { 16 | /** 17 | * Takes input of the diff array and returns the common parts. 18 | * Iterates through diff line by line. 19 | * 20 | * @return array 21 | */ 22 | protected function getCommonChunks(array $diff, int $lineThreshold = 5): array 23 | { 24 | $diffSize = count($diff); 25 | $capturing = false; 26 | $chunkStart = 0; 27 | $chunkSize = 0; 28 | $commonChunks = []; 29 | 30 | for ($i = 0; $i < $diffSize; $i++) { 31 | if ($diff[$i][1] === 0 /* OLD */) { 32 | if ($capturing === false) { 33 | $capturing = true; 34 | $chunkStart = $i; 35 | $chunkSize = 0; 36 | } else { 37 | $chunkSize++; 38 | } 39 | } elseif ($capturing !== false) { 40 | if ($chunkSize >= $lineThreshold) { 41 | $commonChunks[$chunkStart] = $chunkStart + $chunkSize; 42 | } 43 | 44 | $capturing = false; 45 | } 46 | } 47 | 48 | if ($capturing !== false && $chunkSize >= $lineThreshold) { 49 | $commonChunks[$chunkStart] = $chunkStart + $chunkSize; 50 | } 51 | 52 | return $commonChunks; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Output/DiffOnlyOutputBuilder.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace SebastianBergmann\Diff\Output; 11 | 12 | use function assert; 13 | use function fclose; 14 | use function fopen; 15 | use function fwrite; 16 | use function is_resource; 17 | use function str_ends_with; 18 | use function stream_get_contents; 19 | use function substr; 20 | use SebastianBergmann\Diff\Differ; 21 | 22 | /** 23 | * Builds a diff string representation in a loose unified diff format 24 | * listing only changes lines. Does not include line numbers. 25 | */ 26 | final class DiffOnlyOutputBuilder implements DiffOutputBuilderInterface 27 | { 28 | private string $header; 29 | 30 | public function __construct(string $header = "--- Original\n+++ New\n") 31 | { 32 | $this->header = $header; 33 | } 34 | 35 | public function getDiff(array $diff): string 36 | { 37 | $buffer = fopen('php://memory', 'r+b'); 38 | 39 | assert(is_resource($buffer)); 40 | 41 | if ('' !== $this->header) { 42 | fwrite($buffer, $this->header); 43 | 44 | if (!str_ends_with($this->header, "\n")) { 45 | fwrite($buffer, "\n"); 46 | } 47 | } 48 | 49 | foreach ($diff as $diffEntry) { 50 | if ($diffEntry[1] === Differ::ADDED) { 51 | fwrite($buffer, '+' . $diffEntry[0]); 52 | } elseif ($diffEntry[1] === Differ::REMOVED) { 53 | fwrite($buffer, '-' . $diffEntry[0]); 54 | } elseif ($diffEntry[1] === Differ::DIFF_LINE_END_WARNING) { 55 | fwrite($buffer, ' ' . $diffEntry[0]); 56 | 57 | continue; // Warnings should not be tested for line break, it will always be there 58 | } else { /* Not changed (old) 0 */ 59 | continue; // we didn't write the not-changed line, so do not add a line break either 60 | } 61 | 62 | $lc = substr($diffEntry[0], -1); 63 | 64 | if ($lc !== "\n" && $lc !== "\r") { 65 | fwrite($buffer, "\n"); // \No newline at end of file 66 | } 67 | } 68 | 69 | $diff = stream_get_contents($buffer, -1, 0); 70 | fclose($buffer); 71 | 72 | return $diff; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Output/DiffOutputBuilderInterface.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace SebastianBergmann\Diff\Output; 11 | 12 | /** 13 | * Defines how an output builder should take a generated 14 | * diff array and return a string representation of that diff. 15 | */ 16 | interface DiffOutputBuilderInterface 17 | { 18 | public function getDiff(array $diff): string; 19 | } 20 | -------------------------------------------------------------------------------- /src/Output/StrictUnifiedDiffOutputBuilder.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace SebastianBergmann\Diff\Output; 11 | 12 | use function array_merge; 13 | use function array_splice; 14 | use function assert; 15 | use function count; 16 | use function fclose; 17 | use function fopen; 18 | use function fwrite; 19 | use function is_bool; 20 | use function is_int; 21 | use function is_resource; 22 | use function is_string; 23 | use function max; 24 | use function min; 25 | use function sprintf; 26 | use function stream_get_contents; 27 | use function substr; 28 | use SebastianBergmann\Diff\ConfigurationException; 29 | use SebastianBergmann\Diff\Differ; 30 | 31 | /** 32 | * Strict Unified diff output builder. 33 | * 34 | * Generates (strict) Unified diff's (unidiffs) with hunks. 35 | */ 36 | final class StrictUnifiedDiffOutputBuilder implements DiffOutputBuilderInterface 37 | { 38 | private static array $default = [ 39 | 'collapseRanges' => true, // ranges of length one are rendered with the trailing `,1` 40 | 'commonLineThreshold' => 6, // number of same lines before ending a new hunk and creating a new one (if needed) 41 | 'contextLines' => 3, // like `diff: -u, -U NUM, --unified[=NUM]`, for patch/git apply compatibility best to keep at least @ 3 42 | 'fromFile' => null, 43 | 'fromFileDate' => null, 44 | 'toFile' => null, 45 | 'toFileDate' => null, 46 | ]; 47 | private bool $changed; 48 | private bool $collapseRanges; 49 | 50 | /** 51 | * @var positive-int 52 | */ 53 | private int $commonLineThreshold; 54 | private string $header; 55 | 56 | /** 57 | * @var positive-int 58 | */ 59 | private int $contextLines; 60 | 61 | public function __construct(array $options = []) 62 | { 63 | $options = array_merge(self::$default, $options); 64 | 65 | if (!is_bool($options['collapseRanges'])) { 66 | throw new ConfigurationException('collapseRanges', 'a bool', $options['collapseRanges']); 67 | } 68 | 69 | if (!is_int($options['contextLines']) || $options['contextLines'] < 0) { 70 | throw new ConfigurationException('contextLines', 'an int >= 0', $options['contextLines']); 71 | } 72 | 73 | if (!is_int($options['commonLineThreshold']) || $options['commonLineThreshold'] <= 0) { 74 | throw new ConfigurationException('commonLineThreshold', 'an int > 0', $options['commonLineThreshold']); 75 | } 76 | 77 | $this->assertString($options, 'fromFile'); 78 | $this->assertString($options, 'toFile'); 79 | $this->assertStringOrNull($options, 'fromFileDate'); 80 | $this->assertStringOrNull($options, 'toFileDate'); 81 | 82 | $this->header = sprintf( 83 | "--- %s%s\n+++ %s%s\n", 84 | $options['fromFile'], 85 | null === $options['fromFileDate'] ? '' : "\t" . $options['fromFileDate'], 86 | $options['toFile'], 87 | null === $options['toFileDate'] ? '' : "\t" . $options['toFileDate'], 88 | ); 89 | 90 | $this->collapseRanges = $options['collapseRanges']; 91 | $this->commonLineThreshold = $options['commonLineThreshold']; 92 | $this->contextLines = $options['contextLines']; 93 | } 94 | 95 | public function getDiff(array $diff): string 96 | { 97 | if (0 === count($diff)) { 98 | return ''; 99 | } 100 | 101 | $this->changed = false; 102 | 103 | $buffer = fopen('php://memory', 'r+b'); 104 | 105 | assert(is_resource($buffer)); 106 | 107 | fwrite($buffer, $this->header); 108 | 109 | $this->writeDiffHunks($buffer, $diff); 110 | 111 | if (!$this->changed) { 112 | fclose($buffer); 113 | 114 | return ''; 115 | } 116 | 117 | $diff = stream_get_contents($buffer, -1, 0); 118 | 119 | fclose($buffer); 120 | 121 | // If the last char is not a linebreak: add it. 122 | // This might happen when both the `from` and `to` do not have a trailing linebreak 123 | $last = substr($diff, -1); 124 | 125 | return "\n" !== $last && "\r" !== $last 126 | ? $diff . "\n" 127 | : $diff; 128 | } 129 | 130 | private function writeDiffHunks(mixed $output, array $diff): void 131 | { 132 | assert(is_resource($output)); 133 | 134 | // detect "No newline at end of file" and insert into `$diff` if needed 135 | 136 | $upperLimit = count($diff); 137 | 138 | if (0 === $diff[$upperLimit - 1][1]) { 139 | $lc = substr($diff[$upperLimit - 1][0], -1); 140 | 141 | if ("\n" !== $lc) { 142 | array_splice($diff, $upperLimit, 0, [["\n\\ No newline at end of file\n", Differ::NO_LINE_END_EOF_WARNING]]); 143 | } 144 | } else { 145 | // search back for the last `+` and `-` line, 146 | // check if it has a trailing linebreak, else add a warning under it 147 | $toFind = [1 => true, 2 => true]; 148 | 149 | for ($i = $upperLimit - 1; $i >= 0; $i--) { 150 | if (isset($toFind[$diff[$i][1]])) { 151 | unset($toFind[$diff[$i][1]]); 152 | $lc = substr($diff[$i][0], -1); 153 | 154 | if ("\n" !== $lc) { 155 | array_splice($diff, $i + 1, 0, [["\n\\ No newline at end of file\n", Differ::NO_LINE_END_EOF_WARNING]]); 156 | } 157 | 158 | if ($toFind === []) { 159 | break; 160 | } 161 | } 162 | } 163 | } 164 | 165 | // write hunks to output buffer 166 | 167 | $cutOff = max($this->commonLineThreshold, $this->contextLines); 168 | $hunkCapture = false; 169 | $sameCount = $toRange = $fromRange = 0; 170 | $toStart = $fromStart = 1; 171 | 172 | foreach ($diff as $i => $entry) { 173 | if (0 === $entry[1]) { // same 174 | if (false === $hunkCapture) { 175 | $fromStart++; 176 | $toStart++; 177 | 178 | continue; 179 | } 180 | 181 | $sameCount++; 182 | $toRange++; 183 | $fromRange++; 184 | 185 | if ($sameCount === $cutOff) { 186 | $contextStartOffset = ($hunkCapture - $this->contextLines) < 0 187 | ? $hunkCapture 188 | : $this->contextLines; 189 | 190 | // note: $contextEndOffset = $this->contextLines; 191 | // 192 | // because we never go beyond the end of the diff. 193 | // with the cutoff/contextlines here the follow is never true; 194 | // 195 | // if ($i - $cutOff + $this->contextLines + 1 > \count($diff)) { 196 | // $contextEndOffset = count($diff) - 1; 197 | // } 198 | // 199 | // ; that would be true for a trailing incomplete hunk case which is dealt with after this loop 200 | 201 | $this->writeHunk( 202 | $diff, 203 | $hunkCapture - $contextStartOffset, 204 | $i - $cutOff + $this->contextLines + 1, 205 | $fromStart - $contextStartOffset, 206 | $fromRange - $cutOff + $contextStartOffset + $this->contextLines, 207 | $toStart - $contextStartOffset, 208 | $toRange - $cutOff + $contextStartOffset + $this->contextLines, 209 | $output, 210 | ); 211 | 212 | $fromStart += $fromRange; 213 | $toStart += $toRange; 214 | 215 | $hunkCapture = false; 216 | $sameCount = $toRange = $fromRange = 0; 217 | } 218 | 219 | continue; 220 | } 221 | 222 | $sameCount = 0; 223 | 224 | if ($entry[1] === Differ::NO_LINE_END_EOF_WARNING) { 225 | continue; 226 | } 227 | 228 | $this->changed = true; 229 | 230 | if (false === $hunkCapture) { 231 | $hunkCapture = $i; 232 | } 233 | 234 | if (Differ::ADDED === $entry[1]) { // added 235 | $toRange++; 236 | } 237 | 238 | if (Differ::REMOVED === $entry[1]) { // removed 239 | $fromRange++; 240 | } 241 | } 242 | 243 | if (false === $hunkCapture) { 244 | return; 245 | } 246 | 247 | // we end here when cutoff (commonLineThreshold) was not reached, but we were capturing a hunk, 248 | // do not render hunk till end automatically because the number of context lines might be less than the commonLineThreshold 249 | 250 | $contextStartOffset = $hunkCapture - $this->contextLines < 0 251 | ? $hunkCapture 252 | : $this->contextLines; 253 | 254 | // prevent trying to write out more common lines than there are in the diff _and_ 255 | // do not write more than configured through the context lines 256 | $contextEndOffset = min($sameCount, $this->contextLines); 257 | 258 | $fromRange -= $sameCount; 259 | $toRange -= $sameCount; 260 | 261 | assert(isset($i) && is_int($i)); 262 | 263 | $this->writeHunk( 264 | $diff, 265 | $hunkCapture - $contextStartOffset, 266 | $i - $sameCount + $contextEndOffset + 1, 267 | $fromStart - $contextStartOffset, 268 | $fromRange + $contextStartOffset + $contextEndOffset, 269 | $toStart - $contextStartOffset, 270 | $toRange + $contextStartOffset + $contextEndOffset, 271 | $output, 272 | ); 273 | } 274 | 275 | private function writeHunk( 276 | array $diff, 277 | int $diffStartIndex, 278 | int $diffEndIndex, 279 | int $fromStart, 280 | int $fromRange, 281 | int $toStart, 282 | int $toRange, 283 | mixed $output 284 | ): void { 285 | assert(is_resource($output)); 286 | 287 | fwrite($output, '@@ -' . $fromStart); 288 | 289 | if (!$this->collapseRanges || 1 !== $fromRange) { 290 | fwrite($output, ',' . $fromRange); 291 | } 292 | 293 | fwrite($output, ' +' . $toStart); 294 | 295 | if (!$this->collapseRanges || 1 !== $toRange) { 296 | fwrite($output, ',' . $toRange); 297 | } 298 | 299 | fwrite($output, " @@\n"); 300 | 301 | for ($i = $diffStartIndex; $i < $diffEndIndex; $i++) { 302 | if ($diff[$i][1] === Differ::ADDED) { 303 | $this->changed = true; 304 | fwrite($output, '+' . $diff[$i][0]); 305 | } elseif ($diff[$i][1] === Differ::REMOVED) { 306 | $this->changed = true; 307 | fwrite($output, '-' . $diff[$i][0]); 308 | } elseif ($diff[$i][1] === Differ::OLD) { 309 | fwrite($output, ' ' . $diff[$i][0]); 310 | } elseif ($diff[$i][1] === Differ::NO_LINE_END_EOF_WARNING) { 311 | $this->changed = true; 312 | fwrite($output, $diff[$i][0]); 313 | } 314 | // } elseif ($diff[$i][1] === Differ::DIFF_LINE_END_WARNING) { // custom comment inserted by PHPUnit/diff package 315 | // skip 316 | // } else { 317 | // unknown/invalid 318 | // } 319 | } 320 | } 321 | 322 | private function assertString(array $options, string $option): void 323 | { 324 | if (!is_string($options[$option])) { 325 | throw new ConfigurationException($option, 'a string', $options[$option]); 326 | } 327 | } 328 | 329 | private function assertStringOrNull(array $options, string $option): void 330 | { 331 | if (null !== $options[$option] && !is_string($options[$option])) { 332 | throw new ConfigurationException($option, 'a string or ', $options[$option]); 333 | } 334 | } 335 | } 336 | -------------------------------------------------------------------------------- /src/Output/UnifiedDiffOutputBuilder.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace SebastianBergmann\Diff\Output; 11 | 12 | use function array_splice; 13 | use function assert; 14 | use function count; 15 | use function fclose; 16 | use function fopen; 17 | use function fwrite; 18 | use function is_int; 19 | use function is_resource; 20 | use function max; 21 | use function min; 22 | use function str_ends_with; 23 | use function stream_get_contents; 24 | use function substr; 25 | use SebastianBergmann\Diff\Differ; 26 | 27 | /** 28 | * Builds a diff string representation in unified diff format in chunks. 29 | */ 30 | final class UnifiedDiffOutputBuilder extends AbstractChunkOutputBuilder 31 | { 32 | private bool $collapseRanges = true; 33 | private int $commonLineThreshold = 6; 34 | 35 | /** 36 | * @var positive-int 37 | */ 38 | private int $contextLines = 3; 39 | private string $header; 40 | private bool $addLineNumbers; 41 | 42 | public function __construct(string $header = "--- Original\n+++ New\n", bool $addLineNumbers = false) 43 | { 44 | $this->header = $header; 45 | $this->addLineNumbers = $addLineNumbers; 46 | } 47 | 48 | public function getDiff(array $diff): string 49 | { 50 | $buffer = fopen('php://memory', 'r+b'); 51 | 52 | assert(is_resource($buffer)); 53 | 54 | if ('' !== $this->header) { 55 | fwrite($buffer, $this->header); 56 | 57 | if (!str_ends_with($this->header, "\n")) { 58 | fwrite($buffer, "\n"); 59 | } 60 | } 61 | 62 | if (0 !== count($diff)) { 63 | $this->writeDiffHunks($buffer, $diff); 64 | } 65 | 66 | $diff = stream_get_contents($buffer, -1, 0); 67 | 68 | fclose($buffer); 69 | 70 | // If the diff is non-empty and last char is not a linebreak: add it. 71 | // This might happen when both the `from` and `to` do not have a trailing linebreak 72 | $last = substr($diff, -1); 73 | 74 | return '' !== $diff && "\n" !== $last && "\r" !== $last 75 | ? $diff . "\n" 76 | : $diff; 77 | } 78 | 79 | private function writeDiffHunks(mixed $output, array $diff): void 80 | { 81 | assert(is_resource($output)); 82 | 83 | // detect "No newline at end of file" and insert into `$diff` if needed 84 | 85 | $upperLimit = count($diff); 86 | 87 | if (0 === $diff[$upperLimit - 1][1]) { 88 | $lc = substr($diff[$upperLimit - 1][0], -1); 89 | 90 | if ("\n" !== $lc) { 91 | array_splice($diff, $upperLimit, 0, [["\n\\ No newline at end of file\n", Differ::NO_LINE_END_EOF_WARNING]]); 92 | } 93 | } else { 94 | // search back for the last `+` and `-` line, 95 | // check if it has trailing linebreak, else add a warning under it 96 | $toFind = [1 => true, 2 => true]; 97 | 98 | for ($i = $upperLimit - 1; $i >= 0; $i--) { 99 | if (isset($toFind[$diff[$i][1]])) { 100 | unset($toFind[$diff[$i][1]]); 101 | $lc = substr($diff[$i][0], -1); 102 | 103 | if ("\n" !== $lc) { 104 | array_splice($diff, $i + 1, 0, [["\n\\ No newline at end of file\n", Differ::NO_LINE_END_EOF_WARNING]]); 105 | } 106 | 107 | if ($toFind === []) { 108 | break; 109 | } 110 | } 111 | } 112 | } 113 | 114 | // write hunks to output buffer 115 | 116 | $cutOff = max($this->commonLineThreshold, $this->contextLines); 117 | $hunkCapture = false; 118 | $sameCount = $toRange = $fromRange = 0; 119 | $toStart = $fromStart = 1; 120 | 121 | foreach ($diff as $i => $entry) { 122 | if (0 === $entry[1]) { // same 123 | if (false === $hunkCapture) { 124 | $fromStart++; 125 | $toStart++; 126 | 127 | continue; 128 | } 129 | 130 | $sameCount++; 131 | $toRange++; 132 | $fromRange++; 133 | 134 | if ($sameCount === $cutOff) { 135 | $contextStartOffset = ($hunkCapture - $this->contextLines) < 0 136 | ? $hunkCapture 137 | : $this->contextLines; 138 | 139 | // note: $contextEndOffset = $this->contextLines; 140 | // 141 | // because we never go beyond the end of the diff. 142 | // with the cutoff/contextlines here the follow is never true; 143 | // 144 | // if ($i - $cutOff + $this->contextLines + 1 > \count($diff)) { 145 | // $contextEndOffset = count($diff) - 1; 146 | // } 147 | // 148 | // ; that would be true for a trailing incomplete hunk case which is dealt with after this loop 149 | 150 | $this->writeHunk( 151 | $diff, 152 | $hunkCapture - $contextStartOffset, 153 | $i - $cutOff + $this->contextLines + 1, 154 | $fromStart - $contextStartOffset, 155 | $fromRange - $cutOff + $contextStartOffset + $this->contextLines, 156 | $toStart - $contextStartOffset, 157 | $toRange - $cutOff + $contextStartOffset + $this->contextLines, 158 | $output, 159 | ); 160 | 161 | $fromStart += $fromRange; 162 | $toStart += $toRange; 163 | 164 | $hunkCapture = false; 165 | $sameCount = $toRange = $fromRange = 0; 166 | } 167 | 168 | continue; 169 | } 170 | 171 | $sameCount = 0; 172 | 173 | if ($entry[1] === Differ::NO_LINE_END_EOF_WARNING) { 174 | continue; 175 | } 176 | 177 | if (false === $hunkCapture) { 178 | $hunkCapture = $i; 179 | } 180 | 181 | if (Differ::ADDED === $entry[1]) { 182 | $toRange++; 183 | } 184 | 185 | if (Differ::REMOVED === $entry[1]) { 186 | $fromRange++; 187 | } 188 | } 189 | 190 | if (false === $hunkCapture) { 191 | return; 192 | } 193 | 194 | // we end here when cutoff (commonLineThreshold) was not reached, but we were capturing a hunk, 195 | // do not render hunk till end automatically because the number of context lines might be less than the commonLineThreshold 196 | 197 | $contextStartOffset = $hunkCapture - $this->contextLines < 0 198 | ? $hunkCapture 199 | : $this->contextLines; 200 | 201 | // prevent trying to write out more common lines than there are in the diff _and_ 202 | // do not write more than configured through the context lines 203 | $contextEndOffset = min($sameCount, $this->contextLines); 204 | 205 | $fromRange -= $sameCount; 206 | $toRange -= $sameCount; 207 | 208 | assert(isset($i) && is_int($i)); 209 | 210 | $this->writeHunk( 211 | $diff, 212 | $hunkCapture - $contextStartOffset, 213 | $i - $sameCount + $contextEndOffset + 1, 214 | $fromStart - $contextStartOffset, 215 | $fromRange + $contextStartOffset + $contextEndOffset, 216 | $toStart - $contextStartOffset, 217 | $toRange + $contextStartOffset + $contextEndOffset, 218 | $output, 219 | ); 220 | } 221 | 222 | private function writeHunk( 223 | array $diff, 224 | int $diffStartIndex, 225 | int $diffEndIndex, 226 | int $fromStart, 227 | int $fromRange, 228 | int $toStart, 229 | int $toRange, 230 | mixed $output 231 | ): void { 232 | assert(is_resource($output)); 233 | 234 | if ($this->addLineNumbers) { 235 | fwrite($output, '@@ -' . $fromStart); 236 | 237 | if (!$this->collapseRanges || 1 !== $fromRange) { 238 | fwrite($output, ',' . $fromRange); 239 | } 240 | 241 | fwrite($output, ' +' . $toStart); 242 | 243 | if (!$this->collapseRanges || 1 !== $toRange) { 244 | fwrite($output, ',' . $toRange); 245 | } 246 | 247 | fwrite($output, " @@\n"); 248 | } else { 249 | fwrite($output, "@@ @@\n"); 250 | } 251 | 252 | for ($i = $diffStartIndex; $i < $diffEndIndex; $i++) { 253 | if ($diff[$i][1] === Differ::ADDED) { 254 | fwrite($output, '+' . $diff[$i][0]); 255 | } elseif ($diff[$i][1] === Differ::REMOVED) { 256 | fwrite($output, '-' . $diff[$i][0]); 257 | } elseif ($diff[$i][1] === Differ::OLD) { 258 | fwrite($output, ' ' . $diff[$i][0]); 259 | } elseif ($diff[$i][1] === Differ::NO_LINE_END_EOF_WARNING) { 260 | fwrite($output, "\n"); // $diff[$i][0] 261 | } else { /* Not changed (old) Differ::OLD or Warning Differ::DIFF_LINE_END_WARNING */ 262 | fwrite($output, ' ' . $diff[$i][0]); 263 | } 264 | } 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /src/Parser.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace SebastianBergmann\Diff; 11 | 12 | use const PREG_UNMATCHED_AS_NULL; 13 | use function array_pop; 14 | use function count; 15 | use function max; 16 | use function preg_match; 17 | use function preg_split; 18 | 19 | /** 20 | * Unified diff parser. 21 | */ 22 | final class Parser 23 | { 24 | /** 25 | * @return Diff[] 26 | */ 27 | public function parse(string $string): array 28 | { 29 | $lines = preg_split('(\r\n|\r|\n)', $string); 30 | 31 | if ($lines !== false && 32 | $lines !== [] && 33 | $lines[count($lines) - 1] === '') { 34 | array_pop($lines); 35 | } 36 | 37 | $lineCount = count($lines); 38 | $diffs = []; 39 | $diff = null; 40 | $collected = []; 41 | 42 | for ($i = 0; $i < $lineCount; $i++) { 43 | if (preg_match('#^---\h+"?(?P[^\\v\\t"]+)#', $lines[$i], $fromMatch) && 44 | preg_match('#^\\+\\+\\+\\h+"?(?P[^\\v\\t"]+)#', $lines[$i + 1], $toMatch)) { 45 | if ($diff !== null) { 46 | $this->parseFileDiff($diff, $collected); 47 | 48 | $diffs[] = $diff; 49 | $collected = []; 50 | } 51 | 52 | $diff = new Diff($fromMatch['file'], $toMatch['file']); 53 | 54 | $i++; 55 | } else { 56 | if (preg_match('/^(?:diff --git |index [\da-f.]+|[+-]{3} [ab])/', $lines[$i])) { 57 | continue; 58 | } 59 | 60 | $collected[] = $lines[$i]; 61 | } 62 | } 63 | 64 | if ($diff !== null && $collected !== []) { 65 | $this->parseFileDiff($diff, $collected); 66 | 67 | $diffs[] = $diff; 68 | } 69 | 70 | return $diffs; 71 | } 72 | 73 | /** 74 | * @param string[] $lines 75 | */ 76 | private function parseFileDiff(Diff $diff, array $lines): void 77 | { 78 | $chunks = []; 79 | $chunk = null; 80 | $diffLines = []; 81 | 82 | foreach ($lines as $line) { 83 | if (preg_match('/^@@\s+-(?P\d+)(?:,\s*(?P\d+))?\s+\+(?P\d+)(?:,\s*(?P\d+))?\s+@@/', $line, $match, PREG_UNMATCHED_AS_NULL)) { 84 | $chunk = new Chunk( 85 | (int) $match['start'], 86 | isset($match['startrange']) ? max(0, (int) $match['startrange']) : 1, 87 | (int) $match['end'], 88 | isset($match['endrange']) ? max(0, (int) $match['endrange']) : 1, 89 | ); 90 | 91 | $chunks[] = $chunk; 92 | $diffLines = []; 93 | 94 | continue; 95 | } 96 | 97 | if (preg_match('/^(?P[+ -])?(?P.*)/', $line, $match)) { 98 | $type = Line::UNCHANGED; 99 | 100 | if ($match['type'] === '+') { 101 | $type = Line::ADDED; 102 | } elseif ($match['type'] === '-') { 103 | $type = Line::REMOVED; 104 | } 105 | 106 | $diffLines[] = new Line($type, $match['line']); 107 | 108 | $chunk?->setLines($diffLines); 109 | } 110 | } 111 | 112 | $diff->setChunks($chunks); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/TimeEfficientLongestCommonSubsequenceCalculator.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace SebastianBergmann\Diff; 11 | 12 | use function array_reverse; 13 | use function count; 14 | use SplFixedArray; 15 | 16 | final class TimeEfficientLongestCommonSubsequenceCalculator implements LongestCommonSubsequenceCalculator 17 | { 18 | /** 19 | * @inheritDoc 20 | */ 21 | public function calculate(array $from, array $to): array 22 | { 23 | $common = []; 24 | $fromLength = count($from); 25 | $toLength = count($to); 26 | $width = $fromLength + 1; 27 | $matrix = new SplFixedArray($width * ($toLength + 1)); 28 | 29 | for ($i = 0; $i <= $fromLength; $i++) { 30 | $matrix[$i] = 0; 31 | } 32 | 33 | for ($j = 0; $j <= $toLength; $j++) { 34 | $matrix[$j * $width] = 0; 35 | } 36 | 37 | for ($i = 1; $i <= $fromLength; $i++) { 38 | for ($j = 1; $j <= $toLength; $j++) { 39 | $o = ($j * $width) + $i; 40 | 41 | // don't use max() to avoid function call overhead 42 | $firstOrLast = $from[$i - 1] === $to[$j - 1] ? $matrix[$o - $width - 1] + 1 : 0; 43 | 44 | if ($matrix[$o - 1] > $matrix[$o - $width]) { 45 | if ($firstOrLast > $matrix[$o - 1]) { 46 | $matrix[$o] = $firstOrLast; 47 | } else { 48 | $matrix[$o] = $matrix[$o - 1]; 49 | } 50 | } else { 51 | if ($firstOrLast > $matrix[$o - $width]) { 52 | $matrix[$o] = $firstOrLast; 53 | } else { 54 | $matrix[$o] = $matrix[$o - $width]; 55 | } 56 | } 57 | } 58 | } 59 | 60 | $i = $fromLength; 61 | $j = $toLength; 62 | 63 | while ($i > 0 && $j > 0) { 64 | if ($from[$i - 1] === $to[$j - 1]) { 65 | $common[] = $from[$i - 1]; 66 | $i--; 67 | $j--; 68 | } else { 69 | $o = ($j * $width) + $i; 70 | 71 | if ($matrix[$o - $width] > $matrix[$o - 1]) { 72 | $j--; 73 | } else { 74 | $i--; 75 | } 76 | } 77 | } 78 | 79 | return array_reverse($common); 80 | } 81 | } 82 | --------------------------------------------------------------------------------