diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..37ad7064
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,13 @@
+/.github        export-ignore
+/.phive         export-ignore
+/.psalm         export-ignore
+/.gitattributes export-ignore
+/.gitignore     export-ignore
+/.php_cs.dist   export-ignore
+/build.xml      export-ignore
+/phpunit.xml    export-ignore
+/tests          export-ignore
+/tools          export-ignore
+/tools/*        binary
+
+*.php diff=php
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 00000000..c2fba0f9
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1 @@
+github: sebastianbergmann
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..c6cd94e5
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,102 @@
+# https://help.github.com/en/categories/automating-your-workflow-with-github-actions
+
+on:
+  - "pull_request"
+  - "push"
+
+name: "CI"
+
+jobs:
+  coding-guidelines:
+    name: "Coding Guidelines"
+
+    runs-on: "ubuntu-latest"
+
+    steps:
+      - name: "Checkout"
+        uses: "actions/checkout@v2"
+
+      - name: "Run friendsofphp/php-cs-fixer"
+        run: "php7.4 ./tools/php-cs-fixer fix --diff-format=udiff --dry-run --show-progress=dots --using-cache=no --verbose"
+
+  type-checker:
+    name: "Type Checker"
+
+    runs-on: "ubuntu-latest"
+
+    steps:
+      - name: "Checkout"
+        uses: "actions/checkout@v2"
+
+      - name: "Update dependencies with composer"
+        run: "php7.4 ./tools/composer update --no-ansi --no-interaction --no-progress"
+
+      - name: "Run vimeo/psalm"
+        run: "php7.4 ./tools/psalm --config=.psalm/config.xml --no-progress --shepherd --show-info=false --stats"
+
+  backward-compatibility:
+    name: Backward Compatibility
+
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v2
+        with:
+          fetch-depth: 0
+
+      - name: Fetch tags
+        run: git fetch --depth=1 origin +refs/tags/*:refs/tags/*
+
+      - name: Install PHP with extensions
+        uses: shivammathur/setup-php@v2
+        with:
+          php-version: 7.4
+          coverage: none
+          extensions: intl
+
+      - name: Run roave/backward-compatibility-check
+        run: ./tools/roave-backward-compatibility-check --from=4.0.4
+
+  tests:
+    name: "Tests"
+
+    runs-on: "ubuntu-latest"
+
+    strategy:
+      fail-fast: false
+      matrix:
+        php-version:
+          - "7.3"
+          - "7.4"
+          - "8.0"
+          - "8.1"
+
+    steps:
+      - name: "Checkout"
+        uses: "actions/checkout@v2"
+
+      - name: "Install PHP with extensions"
+        uses: "shivammathur/setup-php@v2"
+        with:
+          php-version: "${{ matrix.php-version }}"
+          coverage: "pcov"
+          ini-values: memory_limit=-1
+
+      - name: "Cache dependencies installed with composer"
+        uses: "actions/cache@v1"
+        with:
+          path: "~/.composer/cache"
+          key: "php${{ matrix.php-version }}-composer-${{ matrix.dependencies }}-${{ hashFiles('**/composer.json') }}"
+          restore-keys: "php${{ matrix.php-version }}-composer-${{ matrix.dependencies }}-"
+
+      - name: "Install dependencies with composer"
+        run: "./tools/composer update --no-ansi --no-interaction --no-progress"
+
+      - name: "Run tests with phpunit/phpunit"
+        run: "vendor/bin/phpunit --coverage-clover=coverage.xml"
+
+      - name: "Send code coverage report to Codecov.io"
+        env:
+          CODECOV_TOKEN: "${{ secrets.CODECOV_TOKEN }}"
+        run: "bash <(curl -s https://codecov.io/bash) || true"
diff --git a/.gitignore b/.gitignore
index cd879d7b..93a0be47 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,7 @@
 /.idea
+/.php_cs
+/.php_cs.cache
+/.phpunit.result.cache
+/.psalm/cache
 /composer.lock
 /vendor
-/.php_cs.cache
-/from.txt.orig
\ No newline at end of file
diff --git a/.phive/phars.xml b/.phive/phars.xml
new file mode 100644
index 00000000..9af6f9fd
--- /dev/null
+++ b/.phive/phars.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<phive xmlns="https://phar.io/phive">
+  <phar name="php-cs-fixer" version="^2.16" installed="2.16.7" location="./tools/php-cs-fixer" copy="true"/>
+  <phar name="psalm" version="^4.0" installed="4.2.1" location="./tools/psalm" copy="true"/>
+  <phar name="roave/backwardcompatibilitycheck" version="^5.0.0" installed="5.0.0" location="./tools/roave-backward-compatibility-check" copy="true"/>
+  <phar name="composer" version="^2.0.3" installed="2.0.7" location="./tools/composer" copy="true"/>
+</phive>
diff --git a/.php_cs b/.php_cs
deleted file mode 100644
index fa08b97a..00000000
--- a/.php_cs
+++ /dev/null
@@ -1,81 +0,0 @@
-<?php declare(strict_types=1);
-$header = <<<'EOF'
-This file is part of sebastian/diff.
-
-(c) Sebastian Bergmann <sebastian@phpunit.de>
-
-For the full copyright and license information, please view the LICENSE
-file that was distributed with this source code.
-EOF;
-
-return PhpCsFixer\Config::create()
-    ->setRiskyAllowed(true)
-    ->setRules(
-        [
-            'array_syntax' => ['syntax' => 'short'],
-            'binary_operator_spaces' => [
-                'align_double_arrow' => true,
-                'align_equals' => true
-            ],
-            'blank_line_after_namespace' => true,
-            'blank_line_before_return' => true,
-            'braces' => true,
-            'cast_spaces' => true,
-            'concat_space' => ['spacing' => 'one'],
-            'declare_strict_types' => true,
-            'elseif' => true,
-            'encoding' => true,
-            'full_opening_tag' => true,
-            'function_declaration' => true,
-            'header_comment' => ['header' => $header, 'separate' => 'none'],
-            'indentation_type' => true,
-            'line_ending' => true,
-            'lowercase_constants' => true,
-            'lowercase_keywords' => true,
-            'method_argument_space' => true,
-            'native_function_invocation' => true,
-            'no_alias_functions' => true,
-            'no_blank_lines_after_class_opening' => true,
-            'no_blank_lines_after_phpdoc' => true,
-            'no_closing_tag' => true,
-            'no_empty_phpdoc' => true,
-            'no_empty_statement' => true,
-            'no_extra_consecutive_blank_lines' => true,
-            'no_leading_namespace_whitespace' => true,
-            'no_singleline_whitespace_before_semicolons' => true,
-            'no_spaces_after_function_name' => true,
-            'no_spaces_inside_parenthesis' => true,
-            'no_trailing_comma_in_list_call' => true,
-            'no_trailing_whitespace' => true,
-            'no_unused_imports' => true,
-            'no_whitespace_in_blank_line' => true,
-            'phpdoc_align' => true,
-            'phpdoc_indent' => true,
-            'phpdoc_no_access' => true,
-            'phpdoc_no_empty_return' => true,
-            'phpdoc_no_package' => true,
-            'phpdoc_scalar' => true,
-            'phpdoc_separation' => true,
-            'phpdoc_to_comment' => true,
-            'phpdoc_trim' => true,
-            'phpdoc_types' => true,
-            'phpdoc_var_without_name' => true,
-            'pow_to_exponentiation' => true,
-            'self_accessor' => true,
-            'simplified_null_return' => true,
-            'single_blank_line_at_eof' => true,
-            'single_import_per_statement' => true,
-            'single_line_after_imports' => true,
-            'single_quote' => true,
-            'ternary_operator_spaces' => true,
-            'trim_array_spaces' => true,
-            'visibility_required' => true,
-        ]
-    )
-    ->setFinder(
-        PhpCsFixer\Finder::create()
-        ->files()
-        ->in(__DIR__ . '/src')
-        ->in(__DIR__ . '/tests')
-        ->name('*.php')
-    );
diff --git a/.php_cs.dist b/.php_cs.dist
new file mode 100644
index 00000000..12b64da6
--- /dev/null
+++ b/.php_cs.dist
@@ -0,0 +1,213 @@
+<?php declare(strict_types=1);
+$header = <<<'EOF'
+This file is part of sebastian/diff.
+
+(c) Sebastian Bergmann <sebastian@phpunit.de>
+
+For the full copyright and license information, please view the LICENSE
+file that was distributed with this source code.
+EOF;
+
+$finder = PhpCsFixer\Finder::create()
+    ->files()
+    ->in(__DIR__ . '/src')
+    ->in(__DIR__ . '/tests');
+
+return PhpCsFixer\Config::create()
+    ->setFinder($finder)
+    ->setRiskyAllowed(true)
+    ->setRules([
+        'align_multiline_comment' => true,
+        'array_indentation' => true,
+        'array_syntax' => ['syntax' => 'short'],
+        'binary_operator_spaces' => [
+            'operators' => [
+                '=' => 'align',
+                '=>' => 'align',
+            ],
+        ],
+        'blank_line_after_namespace' => true,
+        'blank_line_before_statement' => [
+            'statements' => [
+                'break',
+                'continue',
+                'declare',
+                'do',
+                'for',
+                'foreach',
+                'if',
+                'include',
+                'include_once',
+                'require',
+                'require_once',
+                'return',
+                'switch',
+                'throw',
+                'try',
+                'while',
+                'yield',
+            ],
+        ],
+        'braces' => true,
+        'cast_spaces' => true,
+        'class_attributes_separation' => ['elements' => ['const', 'method', 'property']],
+        'combine_consecutive_issets' => true,
+        'combine_consecutive_unsets' => true,
+        'compact_nullable_typehint' => true,
+        'concat_space' => ['spacing' => 'one'],
+        'declare_equal_normalize' => ['space' => 'none'],
+        'declare_strict_types' => true,
+        'dir_constant' => true,
+        'elseif' => true,
+        'encoding' => true,
+        'full_opening_tag' => true,
+        'function_declaration' => true,
+        'global_namespace_import' => [
+            'import_classes' => true,
+            'import_constants' => true,
+            'import_functions' => true,
+        ],
+        'header_comment' => ['header' => $header, 'separate' => 'none'],
+        'indentation_type' => true,
+        'is_null' => true,
+        'line_ending' => true,
+        'list_syntax' => ['syntax' => 'short'],
+        'logical_operators' => true,
+        'lowercase_cast' => true,
+        'lowercase_constants' => true,
+        'lowercase_keywords' => true,
+        'lowercase_static_reference' => true,
+        'magic_constant_casing' => true,
+        'method_argument_space' => ['ensure_fully_multiline' => true],
+        'modernize_types_casting' => true,
+        'multiline_comment_opening_closing' => true,
+        'multiline_whitespace_before_semicolons' => true,
+        'native_constant_invocation' => false,
+        'native_function_casing' => false,
+        'native_function_invocation' => false,
+        'new_with_braces' => false,
+        'no_alias_functions' => true,
+        'no_alternative_syntax' => true,
+        'no_blank_lines_after_class_opening' => true,
+        'no_blank_lines_after_phpdoc' => true,
+        'no_blank_lines_before_namespace' => true,
+        'no_closing_tag' => true,
+        'no_empty_comment' => true,
+        'no_empty_phpdoc' => true,
+        'no_empty_statement' => true,
+        'no_extra_blank_lines' => true,
+        'no_homoglyph_names' => true,
+        'no_leading_import_slash' => true,
+        'no_leading_namespace_whitespace' => true,
+        'no_mixed_echo_print' => ['use' => 'print'],
+        'no_multiline_whitespace_around_double_arrow' => true,
+        'no_null_property_initialization' => true,
+        'no_php4_constructor' => true,
+        'no_short_bool_cast' => true,
+        'no_short_echo_tag' => true,
+        'no_singleline_whitespace_before_semicolons' => true,
+        'no_spaces_after_function_name' => true,
+        'no_spaces_inside_parenthesis' => true,
+        'no_superfluous_elseif' => true,
+        'no_superfluous_phpdoc_tags' => [
+            'allow_mixed' => true,
+        ],
+        'no_trailing_comma_in_list_call' => true,
+        'no_trailing_comma_in_singleline_array' => true,
+        'no_trailing_whitespace' => true,
+        'no_trailing_whitespace_in_comment' => true,
+        'no_unneeded_control_parentheses' => true,
+        'no_unneeded_curly_braces' => true,
+        'no_unneeded_final_method' => true,
+        'no_unreachable_default_argument_value' => true,
+        'no_unset_on_property' => true,
+        'no_unused_imports' => true,
+        'no_useless_else' => true,
+        'no_useless_return' => true,
+        'no_whitespace_before_comma_in_array' => true,
+        'no_whitespace_in_blank_line' => true,
+        'non_printable_character' => true,
+        'normalize_index_brace' => true,
+        'object_operator_without_whitespace' => true,
+        'ordered_class_elements' => [
+            'order' => [
+                'use_trait',
+                'constant_public',
+                'constant_protected',
+                'constant_private',
+                'property_public_static',
+                'property_protected_static',
+                'property_private_static',
+                'property_public',
+                'property_protected',
+                'property_private',
+                'method_public_static',
+                'construct',
+                'destruct',
+                'magic',
+                'phpunit',
+                'method_public',
+                'method_protected',
+                'method_private',
+                'method_protected_static',
+                'method_private_static',
+            ],
+        ],
+        'ordered_imports' => [
+            'imports_order' => [
+                PhpCsFixer\Fixer\Import\OrderedImportsFixer::IMPORT_TYPE_CONST,
+                PhpCsFixer\Fixer\Import\OrderedImportsFixer::IMPORT_TYPE_FUNCTION,
+                PhpCsFixer\Fixer\Import\OrderedImportsFixer::IMPORT_TYPE_CLASS,
+            ]
+        ],
+        'ordered_interfaces' => [
+            'direction' => 'ascend',
+            'order' => 'alpha',
+        ],
+        'phpdoc_add_missing_param_annotation' => false,
+        'phpdoc_align' => true,
+        'phpdoc_annotation_without_dot' => true,
+        'phpdoc_indent' => true,
+        'phpdoc_no_access' => true,
+        'phpdoc_no_empty_return' => true,
+        'phpdoc_no_package' => true,
+        'phpdoc_order' => true,
+        'phpdoc_return_self_reference' => true,
+        'phpdoc_scalar' => true,
+        'phpdoc_separation' => true,
+        'phpdoc_single_line_var_spacing' => true,
+        'phpdoc_summary' => true,
+        'phpdoc_to_comment' => true,
+        'phpdoc_trim' => true,
+        'phpdoc_trim_consecutive_blank_line_separation' => true,
+        'phpdoc_types' => ['groups' => ['simple', 'meta']],
+        'phpdoc_types_order' => true,
+        'phpdoc_var_without_name' => true,
+        'pow_to_exponentiation' => true,
+        'protected_to_private' => true,
+        'return_assignment' => true,
+        'return_type_declaration' => ['space_before' => 'none'],
+        'self_accessor' => true,
+        'semicolon_after_instruction' => true,
+        'set_type_to_cast' => true,
+        'short_scalar_cast' => true,
+        'simplified_null_return' => false,
+        'single_blank_line_at_eof' => true,
+        'single_import_per_statement' => true,
+        'single_line_after_imports' => true,
+        'single_quote' => true,
+        'standardize_not_equals' => true,
+        'ternary_to_null_coalescing' => true,
+        'trailing_comma_in_multiline_array' => true,
+        'trim_array_spaces' => true,
+        'unary_operator_spaces' => true,
+        'visibility_required' => [
+            'elements' => [
+                'const',
+                'method',
+                'property',
+            ],
+        ],
+        'void_return' => true,
+        'whitespace_after_comma_in_array' => true,
+    ]);
diff --git a/.psalm/baseline.xml b/.psalm/baseline.xml
new file mode 100644
index 00000000..7e7f58e5
--- /dev/null
+++ b/.psalm/baseline.xml
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<files psalm-version="4.0.1@b1e2e30026936ef8d5bf6a354d1c3959b6231f44">
+  <file src="src/Chunk.php">
+    <DocblockTypeContradiction occurrences="1">
+      <code>$line instanceof Line</code>
+    </DocblockTypeContradiction>
+  </file>
+  <file src="src/Differ.php">
+    <DocblockTypeContradiction occurrences="2">
+      <code>is_array($from)</code>
+      <code>is_array($to)</code>
+    </DocblockTypeContradiction>
+    <MissingParamType occurrences="2">
+      <code>$input</code>
+      <code>$line</code>
+    </MissingParamType>
+    <RedundantConditionGivenDocblockType occurrences="1">
+      <code>null === $outputBuilder</code>
+    </RedundantConditionGivenDocblockType>
+  </file>
+  <file src="src/Exception/ConfigurationException.php">
+    <MissingParamType occurrences="1">
+      <code>$value</code>
+    </MissingParamType>
+  </file>
+  <file src="src/Output/StrictUnifiedDiffOutputBuilder.php">
+    <MissingParamType occurrences="2">
+      <code>$output</code>
+      <code>$output</code>
+    </MissingParamType>
+    <MissingPropertyType occurrences="1">
+      <code>$default</code>
+    </MissingPropertyType>
+    <PropertyNotSetInConstructor occurrences="1">
+      <code>$changed</code>
+    </PropertyNotSetInConstructor>
+  </file>
+  <file src="src/Output/UnifiedDiffOutputBuilder.php">
+    <MissingParamType occurrences="2">
+      <code>$output</code>
+      <code>$output</code>
+    </MissingParamType>
+  </file>
+</files>
diff --git a/.psalm/config.xml b/.psalm/config.xml
new file mode 100644
index 00000000..30f98cfd
--- /dev/null
+++ b/.psalm/config.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0"?>
+<psalm
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xmlns="https://getpsalm.org/schema/config"
+    xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
+    resolveFromConfigFile="false"
+    cacheDirectory=".psalm/cache"
+    totallyTyped="false"
+    errorBaseline=".psalm/baseline.xml"
+>
+    <projectFiles>
+        <directory name="src" />
+        <ignoreFiles>
+            <directory name="vendor" />
+        </ignoreFiles>
+    </projectFiles>
+</psalm>
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 90cd7269..00000000
--- a/.travis.yml
+++ /dev/null
@@ -1,27 +0,0 @@
-language: php
-
-php:
-  - 7.0
-  - 7.0snapshot
-  - 7.1
-  - 7.1snapshot
-  - master
-
-sudo: false
-
-before_install:
-  - composer self-update
-  - composer clear-cache
-
-install:
-  - travis_retry composer update --no-interaction --no-ansi --no-progress --no-suggest --optimize-autoloader --prefer-stable
-
-script:
-  - ./vendor/bin/phpunit --coverage-clover=coverage.xml
-
-after_success:
-  - bash <(curl -s https://codecov.io/bash)
-
-notifications:
-  email: false
-
diff --git a/ChangeLog.md b/ChangeLog.md
index 4f7eca66..9bdcc5b6 100644
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -2,21 +2,87 @@
 
 All notable changes are documented in this file using the [Keep a CHANGELOG](http://keepachangelog.com/) principles.
 
+## [4.0.4] - 2020-10-26
+
+### Fixed
+
+* `SebastianBergmann\Diff\Exception` now correctly extends `\Throwable`
+
+## [4.0.3] - 2020-09-28
+
+### Changed
+
+* Changed PHP version constraint in `composer.json` from `^7.3 || ^8.0` to `>=7.3`
+
+## [4.0.2] - 2020-06-30
+
+### Added
+
+* This component is now supported on PHP 8
+
+## [4.0.1] - 2020-05-08
+
+### Fixed
+
+* [#99](https://github.com/sebastianbergmann/diff/pull/99): Regression in unified diff output of identical strings
+
+## [4.0.0] - 2020-02-07
+
+### Removed
+
+* Removed support for PHP 7.1 and PHP 7.2
+
+## [3.0.2] - 2019-02-04
+
+### Changed
+
+* `Chunk::setLines()` now ensures that the `$lines` array only contains `Line` objects
+
+## [3.0.1] - 2018-06-10
+
+### Fixed
+
+* Removed `"minimum-stability": "dev",` from `composer.json`
+
+## [3.0.0] - 2018-02-01
+
+* The `StrictUnifiedDiffOutputBuilder` implementation of the `DiffOutputBuilderInterface` was added
+
+### Changed
+
+* The default `DiffOutputBuilderInterface` implementation now generates context lines (unchanged lines)
+
+### Removed
+
+* Removed support for PHP 7.0
+
+### Fixed
+
+* [#70](https://github.com/sebastianbergmann/diff/issues/70): Diffing of arrays no longer works
+
 ## [2.0.1] - 2017-08-03
 
 ### Fixed
 
-* Fixed [#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
+* [#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
 
 ## [2.0.0] - 2017-07-11 [YANKED]
 
 ### Added
 
-* Implemented [#64](https://github.com/sebastianbergmann/diff/pull/64): Show line numbers for chunks of a diff
+* [#64](https://github.com/sebastianbergmann/diff/pull/64): Show line numbers for chunks of a diff
 
 ### Removed
 
 * This component is no longer supported on PHP 5.6
 
+[4.0.4]: https://github.com/sebastianbergmann/diff/compare/4.0.3...4.0.4
+[4.0.3]: https://github.com/sebastianbergmann/diff/compare/4.0.2...4.0.3
+[4.0.2]: https://github.com/sebastianbergmann/diff/compare/4.0.1...4.0.2
+[4.0.1]: https://github.com/sebastianbergmann/diff/compare/4.0.0...4.0.1
+[4.0.0]: https://github.com/sebastianbergmann/diff/compare/3.0.2...4.0.0
+[3.0.2]: https://github.com/sebastianbergmann/diff/compare/3.0.1...3.0.2
+[3.0.1]: https://github.com/sebastianbergmann/diff/compare/3.0.0...3.0.1
+[3.0.0]: https://github.com/sebastianbergmann/diff/compare/2.0...3.0.0
 [2.0.1]: https://github.com/sebastianbergmann/diff/compare/c341c98ce083db77f896a0aa64f5ee7652915970...2.0.1
 [2.0.0]: https://github.com/sebastianbergmann/diff/compare/1.4...c341c98ce083db77f896a0aa64f5ee7652915970
diff --git a/LICENSE b/LICENSE
index e1ddf136..f22f31cf 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
 sebastian/diff
 
-Copyright (c) 2002-2017, Sebastian Bergmann <sebastian@phpunit.de>.
+Copyright (c) 2002-2020, Sebastian Bergmann <sebastian@phpunit.de>.
 All rights reserved.
 
 Redistribution and use in source and binary forms, with or without
diff --git a/README.md b/README.md
index 956038b4..734b852d 100644
--- a/README.md
+++ b/README.md
@@ -1,22 +1,32 @@
 # sebastian/diff
 
+[![CI Status](https://github.com/sebastianbergmann/diff/workflows/CI/badge.svg)](https://github.com/sebastianbergmann/diff/actions)
+[![Type Coverage](https://shepherd.dev/github/sebastianbergmann/diff/coverage.svg)](https://shepherd.dev/github/sebastianbergmann/diff)
+
 Diff implementation for PHP, factored out of PHPUnit into a stand-alone component.
 
 ## Installation
 
 You can add this library as a local, per-project dependency to your project using [Composer](https://getcomposer.org/):
 
-    composer require sebastian/diff
+```
+composer require sebastian/diff
+```
 
 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:
 
-    composer require --dev sebastian/diff
+```
+composer require --dev sebastian/diff
+```
 
 ### Usage
 
+#### Generating diff
+
 The `Differ` class can be used to generate a textual representation of the difference between two strings:
 
 ```php
+<?php
 use SebastianBergmann\Diff\Differ;
 
 $differ = new Differ;
@@ -24,12 +34,83 @@ print $differ->diff('foo', 'bar');
 ```
 
 The code above yields the output below:
+```diff
+--- Original
++++ New
+@@ @@
+-foo
++bar
+```
+
+There are three output builders available in this package:
+
+#### UnifiedDiffOutputBuilder
 
-    --- Original
-    +++ New
-    @@ @@
-    -foo
-    +bar
+This is default builder, which generates the output close to udiff and is used by PHPUnit.
+
+```php
+<?php
+
+use SebastianBergmann\Diff\Differ;
+use SebastianBergmann\Diff\Output\UnifiedDiffOutputBuilder;
+
+$builder = new UnifiedDiffOutputBuilder(
+    "--- Original\n+++ New\n", // custom header
+    false                      // do not add line numbers to the diff 
+);
+
+$differ = new Differ($builder);
+print $differ->diff('foo', 'bar');
+```
+
+#### StrictUnifiedDiffOutputBuilder
+
+Generates (strict) Unified diff's (unidiffs) with hunks,
+similar to `diff -u` and compatible with `patch` and `git apply`.
+
+```php
+<?php
+
+use SebastianBergmann\Diff\Differ;
+use SebastianBergmann\Diff\Output\StrictUnifiedDiffOutputBuilder;
+
+$builder = new StrictUnifiedDiffOutputBuilder([
+    'collapseRanges'      => true, // ranges of length one are rendered with the trailing `,1`
+    'commonLineThreshold' => 6,    // number of same lines before ending a new hunk and creating a new one (if needed)
+    'contextLines'        => 3,    // like `diff:  -u, -U NUM, --unified[=NUM]`, for patch/git apply compatibility best to keep at least @ 3
+    'fromFile'            => null,
+    'fromFileDate'        => null,
+    'toFile'              => null,
+    'toFileDate'          => null,
+]);
+
+$differ = new Differ($builder);
+print $differ->diff('foo', 'bar');
+```
+
+#### DiffOnlyOutputBuilder
+
+Output only the lines that differ.
+
+```php
+<?php
+
+use SebastianBergmann\Diff\Differ;
+use SebastianBergmann\Diff\Output\DiffOnlyOutputBuilder;
+
+$builder = new DiffOnlyOutputBuilder(
+    "--- Original\n+++ New\n"
+);
+
+$differ = new Differ($builder);
+print $differ->diff('foo', 'bar');
+```
+
+#### DiffOutputBuilderInterface
+
+You can pass any output builder to the `Differ` class as longs as it implements the `DiffOutputBuilderInterface`.
+
+#### Parsing diff
 
 The `Parser` class can be used to parse a unified diff into an object graph:
 
@@ -114,13 +195,8 @@ The code above yields the output below:
                                                 [type:SebastianBergmann\Diff\Line:private] => 3
                                                 [content:SebastianBergmann\Diff\Line:private] =>         $b = new Money(2, new Currency('EUR'));
                                             )
-
                                     )
-
                             )
-
                     )
-
             )
-
     )
diff --git a/build.xml b/build.xml
index fa7b7e2b..73c6a6e9 100644
--- a/build.xml
+++ b/build.xml
@@ -1,21 +1,26 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <project name="diff" default="setup">
-    <target name="setup" depends="clean,composer"/>
+    <target name="setup" depends="clean,install-dependencies"/>
 
     <target name="clean" description="Cleanup build artifacts">
         <delete dir="${basedir}/vendor"/>
         <delete file="${basedir}/composer.lock"/>
     </target>
 
-    <target name="composer" depends="clean" description="Install dependencies with Composer">
-        <exec executable="composer" taskname="composer">
+    <target name="install-dependencies" depends="clean" description="Install dependencies with Composer">
+        <exec executable="${basedir}/tools/composer" taskname="composer">
             <arg value="update"/>
             <arg value="--no-interaction"/>
             <arg value="--no-progress"/>
             <arg value="--no-ansi"/>
-            <arg value="--no-suggest"/>
-            <arg value="--optimize-autoloader"/>
-            <arg value="--prefer-stable"/>
+        </exec>
+    </target>
+
+    <target name="update-tools">
+        <exec executable="phive" taskname="phive">
+            <arg value="--no-progress"/>
+            <arg value="update"/>
+            <arg value="--force-accept-unsigned"/>
         </exec>
     </target>
 </project>
diff --git a/composer.json b/composer.json
index 5aaa7161..cf92202b 100644
--- a/composer.json
+++ b/composer.json
@@ -1,7 +1,7 @@
 {
     "name": "sebastian/diff",
     "description": "Diff implementation",
-    "keywords": ["diff"],
+    "keywords": ["diff", "udiff", "unidiff", "unified diff"],
     "homepage": "https://github.com/sebastianbergmann/diff",
     "license": "BSD-3-Clause",
     "authors": [
@@ -14,20 +14,34 @@
             "email": "mail@kore-nordmann.de"
         }
     ],
+    "prefer-stable": true,
+    "config": {
+        "platform": {
+            "php": "7.3.0"
+        },
+        "optimize-autoloader": true,
+        "sort-packages": true
+    },
     "require": {
-        "php": "^7.0"
+        "php": ">=7.3"
     },
     "require-dev": {
-        "phpunit/phpunit": "^6.2"
+        "phpunit/phpunit": "^9.3",
+        "symfony/process": "^4.2 || ^5"
     },
     "autoload": {
         "classmap": [
             "src/"
         ]
     },
+    "autoload-dev": {
+        "classmap": [
+            "tests/"
+        ]
+    },
     "extra": {
         "branch-alias": {
-            "dev-master": "2.0-dev"
+            "dev-master": "4.0-dev"
         }
     }
 }
diff --git a/phpunit.xml b/phpunit.xml
index 63ecd1c5..7be976b1 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -1,19 +1,24 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/6.2/phpunit.xsd"
+         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
          bootstrap="vendor/autoload.php"
+         executionOrder="depends,defects"
          forceCoversAnnotation="true"
          beStrictAboutCoversAnnotation="true"
          beStrictAboutOutputDuringTests="true"
          beStrictAboutTodoAnnotatedTests="true"
+         failOnRisky="true"
+         failOnWarning="true"
          verbose="true">
-    <testsuite>
-        <directory suffix="Test.php">tests</directory>
-    </testsuite>
+    <testsuites>
+        <testsuite name="default">
+            <directory suffix="Test.php">tests</directory>
+        </testsuite>
+    </testsuites>
 
-    <filter>
-        <whitelist processUncoveredFilesFromWhitelist="true">
+    <coverage processUncoveredFiles="true">
+        <include>
             <directory suffix=".php">src</directory>
-        </whitelist>
-    </filter>
+        </include>
+    </coverage>
 </phpunit>
diff --git a/src/Chunk.php b/src/Chunk.php
index 53339fce..16ae34f4 100644
--- a/src/Chunk.php
+++ b/src/Chunk.php
@@ -7,7 +7,6 @@
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
  */
-
 namespace SebastianBergmann\Diff;
 
 final class Chunk
@@ -33,7 +32,7 @@ final class Chunk
     private $endRange;
 
     /**
-     * @var array
+     * @var Line[]
      */
     private $lines;
 
@@ -66,13 +65,25 @@ public function getEndRange(): int
         return $this->endRange;
     }
 
+    /**
+     * @return Line[]
+     */
     public function getLines(): array
     {
         return $this->lines;
     }
 
-    public function setLines(array $lines)
+    /**
+     * @param Line[] $lines
+     */
+    public function setLines(array $lines): void
     {
+        foreach ($lines as $line) {
+            if (!$line instanceof Line) {
+                throw new InvalidArgumentException;
+            }
+        }
+
         $this->lines = $lines;
     }
 }
diff --git a/src/Diff.php b/src/Diff.php
index b94fd6f6..17b2084f 100644
--- a/src/Diff.php
+++ b/src/Diff.php
@@ -7,7 +7,6 @@
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
  */
-
 namespace SebastianBergmann\Diff;
 
 final class Diff
@@ -28,8 +27,6 @@ final class Diff
     private $chunks;
 
     /**
-     * @param string  $from
-     * @param string  $to
      * @param Chunk[] $chunks
      */
     public function __construct(string $from, string $to, array $chunks = [])
@@ -60,7 +57,7 @@ public function getChunks(): array
     /**
      * @param Chunk[] $chunks
      */
-    public function setChunks(array $chunks)
+    public function setChunks(array $chunks): void
     {
         $this->chunks = $chunks;
     }
diff --git a/src/Differ.php b/src/Differ.php
index 6e75b96e..5a4d9d10 100644
--- a/src/Differ.php
+++ b/src/Differ.php
@@ -7,17 +7,44 @@
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
  */
-
 namespace SebastianBergmann\Diff;
 
+use const PHP_INT_SIZE;
+use const PREG_SPLIT_DELIM_CAPTURE;
+use const PREG_SPLIT_NO_EMPTY;
+use function array_shift;
+use function array_unshift;
+use function array_values;
+use function count;
+use function current;
+use function end;
+use function get_class;
+use function gettype;
+use function is_array;
+use function is_object;
+use function is_string;
+use function key;
+use function min;
+use function preg_split;
+use function prev;
+use function reset;
+use function sprintf;
+use function substr;
 use SebastianBergmann\Diff\Output\DiffOutputBuilderInterface;
 use SebastianBergmann\Diff\Output\UnifiedDiffOutputBuilder;
 
-/**
- * Diff implementation.
- */
 final class Differ
 {
+    public const OLD                     = 0;
+
+    public const ADDED                   = 1;
+
+    public const REMOVED                 = 2;
+
+    public const DIFF_LINE_END_WARNING   = 3;
+
+    public const NO_LINE_END_EOF_WARNING = 4;
+
     /**
      * @var DiffOutputBuilderInterface
      */
@@ -34,16 +61,16 @@ public function __construct($outputBuilder = null)
             $this->outputBuilder = $outputBuilder;
         } elseif (null === $outputBuilder) {
             $this->outputBuilder = new UnifiedDiffOutputBuilder;
-        } elseif (\is_string($outputBuilder)) {
+        } elseif (is_string($outputBuilder)) {
             // PHPUnit 6.1.4, 6.2.0, 6.2.1, 6.2.2, and 6.2.3 support
             // @see https://github.com/sebastianbergmann/phpunit/issues/2734#issuecomment-314514056
             // @deprecated
             $this->outputBuilder = new UnifiedDiffOutputBuilder($outputBuilder);
         } else {
             throw new InvalidArgumentException(
-                \sprintf(
+                sprintf(
                     'Expected builder to be an instance of DiffOutputBuilderInterface, <null> or a string, got %s.',
-                    \is_object($outputBuilder) ? 'instance of "' . \get_class($outputBuilder) . '"' : \gettype($outputBuilder) . ' "' . $outputBuilder . '"'
+                    is_object($outputBuilder) ? 'instance of "' . get_class($outputBuilder) . '"' : gettype($outputBuilder) . ' "' . $outputBuilder . '"'
                 )
             );
         }
@@ -52,37 +79,20 @@ public function __construct($outputBuilder = null)
     /**
      * Returns the diff between two arrays or strings as string.
      *
-     * @param array|string                            $from
-     * @param array|string                            $to
-     * @param LongestCommonSubsequenceCalculator|null $lcs
-     *
-     * @return string
+     * @param array|string $from
+     * @param array|string $to
      */
     public function diff($from, $to, LongestCommonSubsequenceCalculator $lcs = null): string
     {
-        $from = $this->validateDiffInput($from);
-        $to   = $this->validateDiffInput($to);
-        $diff = $this->diffToArray($from, $to, $lcs);
+        $diff = $this->diffToArray(
+            $this->normalizeDiffInput($from),
+            $this->normalizeDiffInput($to),
+            $lcs
+        );
 
         return $this->outputBuilder->getDiff($diff);
     }
 
-    /**
-     * Casts variable to string if it is not a string or array.
-     *
-     * @param mixed $input
-     *
-     * @return string
-     */
-    private function validateDiffInput($input): string
-    {
-        if (!\is_array($input) && !\is_string($input)) {
-            return (string) $input;
-        }
-
-        return $input;
-    }
-
     /**
      * Returns the diff between two arrays or strings as array.
      *
@@ -97,91 +107,93 @@ private function validateDiffInput($input): string
      * @param array|string                       $from
      * @param array|string                       $to
      * @param LongestCommonSubsequenceCalculator $lcs
-     *
-     * @return array
      */
     public function diffToArray($from, $to, LongestCommonSubsequenceCalculator $lcs = null): array
     {
-        if (\is_string($from)) {
+        if (is_string($from)) {
             $from = $this->splitStringByLines($from);
-        } elseif (!\is_array($from)) {
-            throw new \InvalidArgumentException('"from" must be an array or string.');
+        } elseif (!is_array($from)) {
+            throw new InvalidArgumentException('"from" must be an array or string.');
         }
 
-        if (\is_string($to)) {
+        if (is_string($to)) {
             $to = $this->splitStringByLines($to);
-        } elseif (!\is_array($to)) {
-            throw new \InvalidArgumentException('"to" must be an array or string.');
+        } elseif (!is_array($to)) {
+            throw new InvalidArgumentException('"to" must be an array or string.');
         }
 
-        list($from, $to, $start, $end) = self::getArrayDiffParted($from, $to);
+        [$from, $to, $start, $end] = self::getArrayDiffParted($from, $to);
 
         if ($lcs === null) {
             $lcs = $this->selectLcsImplementation($from, $to);
         }
 
-        $common = $lcs->calculate(\array_values($from), \array_values($to));
+        $common = $lcs->calculate(array_values($from), array_values($to));
         $diff   = [];
 
         foreach ($start as $token) {
-            $diff[] = [$token, 0 /* OLD */];
+            $diff[] = [$token, self::OLD];
         }
 
-        \reset($from);
-        \reset($to);
+        reset($from);
+        reset($to);
 
         foreach ($common as $token) {
-            while (($fromToken = \reset($from)) !== $token) {
-                $diff[] = [\array_shift($from), 2 /* REMOVED */];
+            while (($fromToken = reset($from)) !== $token) {
+                $diff[] = [array_shift($from), self::REMOVED];
             }
 
-            while (($toToken = \reset($to)) !== $token) {
-                $diff[] = [\array_shift($to), 1 /* ADDED */];
+            while (($toToken = reset($to)) !== $token) {
+                $diff[] = [array_shift($to), self::ADDED];
             }
 
-            $diff[] = [$token, 0 /* OLD */];
+            $diff[] = [$token, self::OLD];
 
-            \array_shift($from);
-            \array_shift($to);
+            array_shift($from);
+            array_shift($to);
         }
 
-        while (($token = \array_shift($from)) !== null) {
-            $diff[] = [$token, 2 /* REMOVED */];
+        while (($token = array_shift($from)) !== null) {
+            $diff[] = [$token, self::REMOVED];
         }
 
-        while (($token = \array_shift($to)) !== null) {
-            $diff[] = [$token, 1 /* ADDED */];
+        while (($token = array_shift($to)) !== null) {
+            $diff[] = [$token, self::ADDED];
         }
 
         foreach ($end as $token) {
-            $diff[] = [$token, 0 /* OLD */];
+            $diff[] = [$token, self::OLD];
         }
 
         if ($this->detectUnmatchedLineEndings($diff)) {
-            \array_unshift($diff, ["#Warning: Strings contain different line endings!\n", 3]);
+            array_unshift($diff, ["#Warning: Strings contain different line endings!\n", self::DIFF_LINE_END_WARNING]);
         }
 
         return $diff;
     }
 
     /**
-     * Checks if input is string, if so it will split it line-by-line.
-     *
-     * @param string $input
+     * Casts variable to string if it is not a string or array.
      *
-     * @return array
+     * @return array|string
      */
-    private function splitStringByLines(string $input): array
+    private function normalizeDiffInput($input)
     {
-        return \preg_split('/(.*\R)/', $input, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
+        if (!is_array($input) && !is_string($input)) {
+            return (string) $input;
+        }
+
+        return $input;
     }
 
     /**
-     * @param array $from
-     * @param array $to
-     *
-     * @return LongestCommonSubsequenceCalculator
+     * Checks if input is string, if so it will split it line-by-line.
      */
+    private function splitStringByLines(string $input): array
+    {
+        return preg_split('/(.*\R)/', $input, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
+    }
+
     private function selectLcsImplementation(array $from, array $to): LongestCommonSubsequenceCalculator
     {
         // We do not want to use the time-efficient implementation if its memory
@@ -200,24 +212,17 @@ private function selectLcsImplementation(array $from, array $to): LongestCommonS
     /**
      * Calculates the estimated memory footprint for the DP-based method.
      *
-     * @param array $from
-     * @param array $to
-     *
-     * @return int|float
+     * @return float|int
      */
     private function calculateEstimatedFootprint(array $from, array $to)
     {
         $itemSize = PHP_INT_SIZE === 4 ? 76 : 144;
 
-        return $itemSize * \min(\count($from), \count($to)) ** 2;
+        return $itemSize * min(count($from), count($to)) ** 2;
     }
 
     /**
      * Returns true if line ends don't match in a diff.
-     *
-     * @param array $diff
-     *
-     * @return bool
      */
     private function detectUnmatchedLineEndings(array $diff): bool
     {
@@ -225,13 +230,13 @@ private function detectUnmatchedLineEndings(array $diff): bool
         $oldLineBreaks = ['' => true];
 
         foreach ($diff as $entry) {
-            if (0 === $entry[1]) { /* OLD */
+            if (self::OLD === $entry[1]) {
                 $ln                 = $this->getLinebreak($entry[0]);
                 $oldLineBreaks[$ln] = true;
                 $newLineBreaks[$ln] = true;
-            } elseif (1 === $entry[1]) {  /* ADDED */
+            } elseif (self::ADDED === $entry[1]) {
                 $newLineBreaks[$this->getLinebreak($entry[0])] = true;
-            } elseif (2 === $entry[1]) {  /* REMOVED */
+            } elseif (self::REMOVED === $entry[1]) {
                 $oldLineBreaks[$this->getLinebreak($entry[0])] = true;
             }
         }
@@ -259,11 +264,12 @@ private function detectUnmatchedLineEndings(array $diff): bool
 
     private function getLinebreak($line): string
     {
-        if (!\is_string($line)) {
+        if (!is_string($line)) {
             return '';
         }
 
-        $lc = \substr($line, -1);
+        $lc = substr($line, -1);
+
         if ("\r" === $lc) {
             return "\r";
         }
@@ -272,7 +278,7 @@ private function getLinebreak($line): string
             return '';
         }
 
-        if ("\r\n" === \substr($line, -2)) {
+        if ("\r\n" === substr($line, -2)) {
             return "\r\n";
         }
 
@@ -284,10 +290,10 @@ private static function getArrayDiffParted(array &$from, array &$to): array
         $start = [];
         $end   = [];
 
-        \reset($to);
+        reset($to);
 
         foreach ($from as $k => $v) {
-            $toK = \key($to);
+            $toK = key($to);
 
             if ($toK === $k && $v === $to[$k]) {
                 $start[$k] = $v;
@@ -298,19 +304,19 @@ private static function getArrayDiffParted(array &$from, array &$to): array
             }
         }
 
-        \end($from);
-        \end($to);
+        end($from);
+        end($to);
 
         do {
-            $fromK = \key($from);
-            $toK   = \key($to);
+            $fromK = key($from);
+            $toK   = key($to);
 
-            if (null === $fromK || null === $toK || \current($from) !== \current($to)) {
+            if (null === $fromK || null === $toK || current($from) !== current($to)) {
                 break;
             }
 
-            \prev($from);
-            \prev($to);
+            prev($from);
+            prev($to);
 
             $end = [$fromK => $from[$fromK]] + $end;
             unset($from[$fromK], $to[$toK]);
diff --git a/src/Exception/ConfigurationException.php b/src/Exception/ConfigurationException.php
new file mode 100644
index 00000000..b767b219
--- /dev/null
+++ b/src/Exception/ConfigurationException.php
@@ -0,0 +1,38 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of sebastian/diff.
+ *
+ * (c) Sebastian Bergmann <sebastian@phpunit.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+namespace SebastianBergmann\Diff;
+
+use function get_class;
+use function gettype;
+use function is_object;
+use function sprintf;
+use Exception;
+
+final class ConfigurationException extends InvalidArgumentException
+{
+    public function __construct(
+        string $option,
+        string $expected,
+        $value,
+        int $code = 0,
+        Exception $previous = null
+    ) {
+        parent::__construct(
+            sprintf(
+                'Option "%s" must be %s, got "%s".',
+                $option,
+                $expected,
+                is_object($value) ? get_class($value) : (null === $value ? '<null>' : gettype($value) . '#' . $value)
+            ),
+            $code,
+            $previous
+        );
+    }
+}
diff --git a/src/Exception/Exception.php b/src/Exception/Exception.php
index 249a2ba0..e20d3203 100644
--- a/src/Exception/Exception.php
+++ b/src/Exception/Exception.php
@@ -7,9 +7,10 @@
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
  */
-
 namespace SebastianBergmann\Diff;
 
-interface Exception
+use Throwable;
+
+interface Exception extends Throwable
 {
 }
diff --git a/src/Exception/InvalidArgumentException.php b/src/Exception/InvalidArgumentException.php
index 7bca77dc..846ac3fb 100644
--- a/src/Exception/InvalidArgumentException.php
+++ b/src/Exception/InvalidArgumentException.php
@@ -7,7 +7,6 @@
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
  */
-
 namespace SebastianBergmann\Diff;
 
 class InvalidArgumentException extends \InvalidArgumentException implements Exception
diff --git a/src/Line.php b/src/Line.php
index 0f34ec24..3596ed26 100644
--- a/src/Line.php
+++ b/src/Line.php
@@ -7,14 +7,15 @@
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
  */
-
 namespace SebastianBergmann\Diff;
 
 final class Line
 {
-    const ADDED     = 1;
-    const REMOVED   = 2;
-    const UNCHANGED = 3;
+    public const ADDED     = 1;
+
+    public const REMOVED   = 2;
+
+    public const UNCHANGED = 3;
 
     /**
      * @var int
diff --git a/src/LongestCommonSubsequenceCalculator.php b/src/LongestCommonSubsequenceCalculator.php
index 7eb1707e..dea8fe1c 100644
--- a/src/LongestCommonSubsequenceCalculator.php
+++ b/src/LongestCommonSubsequenceCalculator.php
@@ -7,18 +7,12 @@
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
  */
-
 namespace SebastianBergmann\Diff;
 
 interface LongestCommonSubsequenceCalculator
 {
     /**
      * Calculates the longest common subsequence of two arrays.
-     *
-     * @param array $from
-     * @param array $to
-     *
-     * @return array
      */
     public function calculate(array $from, array $to): array;
 }
diff --git a/src/MemoryEfficientLongestCommonSubsequenceCalculator.php b/src/MemoryEfficientLongestCommonSubsequenceCalculator.php
index 82dc20c8..0b626eaf 100644
--- a/src/MemoryEfficientLongestCommonSubsequenceCalculator.php
+++ b/src/MemoryEfficientLongestCommonSubsequenceCalculator.php
@@ -7,9 +7,16 @@
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
  */
-
 namespace SebastianBergmann\Diff;
 
+use function array_fill;
+use function array_merge;
+use function array_reverse;
+use function array_slice;
+use function count;
+use function in_array;
+use function max;
+
 final class MemoryEfficientLongestCommonSubsequenceCalculator implements LongestCommonSubsequenceCalculator
 {
     /**
@@ -17,15 +24,15 @@ final class MemoryEfficientLongestCommonSubsequenceCalculator implements Longest
      */
     public function calculate(array $from, array $to): array
     {
-        $cFrom = \count($from);
-        $cTo   = \count($to);
+        $cFrom = count($from);
+        $cTo   = count($to);
 
         if ($cFrom === 0) {
             return [];
         }
 
         if ($cFrom === 1) {
-            if (\in_array($from[0], $to, true)) {
+            if (in_array($from[0], $to, true)) {
                 return [$from[0]];
             }
 
@@ -33,10 +40,10 @@ public function calculate(array $from, array $to): array
         }
 
         $i         = (int) ($cFrom / 2);
-        $fromStart = \array_slice($from, 0, $i);
-        $fromEnd   = \array_slice($from, $i);
+        $fromStart = array_slice($from, 0, $i);
+        $fromEnd   = array_slice($from, $i);
         $llB       = $this->length($fromStart, $to);
-        $llE       = $this->length(\array_reverse($fromEnd), \array_reverse($to));
+        $llE       = $this->length(array_reverse($fromEnd), array_reverse($to));
         $jMax      = 0;
         $max       = 0;
 
@@ -49,10 +56,10 @@ public function calculate(array $from, array $to): array
             }
         }
 
-        $toStart = \array_slice($to, 0, $jMax);
-        $toEnd   = \array_slice($to, $jMax);
+        $toStart = array_slice($to, 0, $jMax);
+        $toEnd   = array_slice($to, $jMax);
 
-        return \array_merge(
+        return array_merge(
             $this->calculate($fromStart, $toStart),
             $this->calculate($fromEnd, $toEnd)
         );
@@ -60,9 +67,9 @@ public function calculate(array $from, array $to): array
 
     private function length(array $from, array $to): array
     {
-        $current = \array_fill(0, \count($to) + 1, 0);
-        $cFrom   = \count($from);
-        $cTo     = \count($to);
+        $current = array_fill(0, count($to) + 1, 0);
+        $cFrom   = count($from);
+        $cTo     = count($to);
 
         for ($i = 0; $i < $cFrom; $i++) {
             $prev = $current;
@@ -71,7 +78,7 @@ private function length(array $from, array $to): array
                 if ($from[$i] === $to[$j]) {
                     $current[$j + 1] = $prev[$j] + 1;
                 } else {
-                    $current[$j + 1] = \max($current[$j], $prev[$j + 1]);
+                    $current[$j + 1] = max($current[$j], $prev[$j + 1]);
                 }
             }
         }
diff --git a/src/Output/AbstractChunkOutputBuilder.php b/src/Output/AbstractChunkOutputBuilder.php
index 6e590b0b..e55757c3 100644
--- a/src/Output/AbstractChunkOutputBuilder.php
+++ b/src/Output/AbstractChunkOutputBuilder.php
@@ -7,23 +7,19 @@
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
  */
-
 namespace SebastianBergmann\Diff\Output;
 
+use function count;
+
 abstract class AbstractChunkOutputBuilder implements DiffOutputBuilderInterface
 {
     /**
      * Takes input of the diff array and returns the common parts.
      * Iterates through diff line by line.
-     *
-     * @param array $diff
-     * @param int   $lineThreshold
-     *
-     * @return array
      */
     protected function getCommonChunks(array $diff, int $lineThreshold = 5): array
     {
-        $diffSize     = \count($diff);
+        $diffSize     = count($diff);
         $capturing    = false;
         $chunkStart   = 0;
         $chunkSize    = 0;
diff --git a/src/Output/DiffOnlyOutputBuilder.php b/src/Output/DiffOnlyOutputBuilder.php
index 666d3bbf..f79a935c 100644
--- a/src/Output/DiffOnlyOutputBuilder.php
+++ b/src/Output/DiffOnlyOutputBuilder.php
@@ -9,6 +9,13 @@
  */
 namespace SebastianBergmann\Diff\Output;
 
+use function fclose;
+use function fopen;
+use function fwrite;
+use function stream_get_contents;
+use function substr;
+use SebastianBergmann\Diff\Differ;
+
 /**
  * Builds a diff string representation in a loose unified diff format
  * listing only changes lines. Does not include line numbers.
@@ -27,36 +34,38 @@ public function __construct(string $header = "--- Original\n+++ New\n")
 
     public function getDiff(array $diff): string
     {
-        $buffer = \fopen('php://memory', 'r+b');
+        $buffer = fopen('php://memory', 'r+b');
 
         if ('' !== $this->header) {
-            \fwrite($buffer, $this->header);
-            if ("\n" !== \substr($this->header, -1, 1)) {
-                \fwrite($buffer, "\n");
+            fwrite($buffer, $this->header);
+
+            if ("\n" !== substr($this->header, -1, 1)) {
+                fwrite($buffer, "\n");
             }
         }
 
         foreach ($diff as $diffEntry) {
-            if ($diffEntry[1] === 1 /* ADDED */) {
-                \fwrite($buffer, '+' . $diffEntry[0]);
-            } elseif ($diffEntry[1] === 2 /* REMOVED */) {
-                \fwrite($buffer, '-' . $diffEntry[0]);
-            } elseif ($diffEntry[1] === 3 /* WARNING */) {
-                \fwrite($buffer, ' ' . $diffEntry[0]);
+            if ($diffEntry[1] === Differ::ADDED) {
+                fwrite($buffer, '+' . $diffEntry[0]);
+            } elseif ($diffEntry[1] === Differ::REMOVED) {
+                fwrite($buffer, '-' . $diffEntry[0]);
+            } elseif ($diffEntry[1] === Differ::DIFF_LINE_END_WARNING) {
+                fwrite($buffer, ' ' . $diffEntry[0]);
 
                 continue; // Warnings should not be tested for line break, it will always be there
             } else { /* Not changed (old) 0 */
                 continue; // we didn't write the non changs line, so do not add a line break either
             }
 
-            $lc = \substr($diffEntry[0], -1);
+            $lc = substr($diffEntry[0], -1);
+
             if ($lc !== "\n" && $lc !== "\r") {
-                \fwrite($buffer, "\n"); // \No newline at end of file
+                fwrite($buffer, "\n"); // \No newline at end of file
             }
         }
 
-        $diff = \stream_get_contents($buffer, -1, 0);
-        \fclose($buffer);
+        $diff = stream_get_contents($buffer, -1, 0);
+        fclose($buffer);
 
         return $diff;
     }
diff --git a/src/Output/StrictUnifiedDiffOutputBuilder.php b/src/Output/StrictUnifiedDiffOutputBuilder.php
new file mode 100644
index 00000000..9c55ab2a
--- /dev/null
+++ b/src/Output/StrictUnifiedDiffOutputBuilder.php
@@ -0,0 +1,338 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of sebastian/diff.
+ *
+ * (c) Sebastian Bergmann <sebastian@phpunit.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+namespace SebastianBergmann\Diff\Output;
+
+use function array_merge;
+use function array_splice;
+use function count;
+use function fclose;
+use function fopen;
+use function fwrite;
+use function is_bool;
+use function is_int;
+use function is_string;
+use function max;
+use function min;
+use function sprintf;
+use function stream_get_contents;
+use function substr;
+use SebastianBergmann\Diff\ConfigurationException;
+use SebastianBergmann\Diff\Differ;
+
+/**
+ * Strict Unified diff output builder.
+ *
+ * Generates (strict) Unified diff's (unidiffs) with hunks.
+ */
+final class StrictUnifiedDiffOutputBuilder implements DiffOutputBuilderInterface
+{
+    private static $default = [
+        'collapseRanges'      => true, // ranges of length one are rendered with the trailing `,1`
+        'commonLineThreshold' => 6,    // number of same lines before ending a new hunk and creating a new one (if needed)
+        'contextLines'        => 3,    // like `diff:  -u, -U NUM, --unified[=NUM]`, for patch/git apply compatibility best to keep at least @ 3
+        'fromFile'            => null,
+        'fromFileDate'        => null,
+        'toFile'              => null,
+        'toFileDate'          => null,
+    ];
+
+    /**
+     * @var bool
+     */
+    private $changed;
+
+    /**
+     * @var bool
+     */
+    private $collapseRanges;
+
+    /**
+     * @var int >= 0
+     */
+    private $commonLineThreshold;
+
+    /**
+     * @var string
+     */
+    private $header;
+
+    /**
+     * @var int >= 0
+     */
+    private $contextLines;
+
+    public function __construct(array $options = [])
+    {
+        $options = array_merge(self::$default, $options);
+
+        if (!is_bool($options['collapseRanges'])) {
+            throw new ConfigurationException('collapseRanges', 'a bool', $options['collapseRanges']);
+        }
+
+        if (!is_int($options['contextLines']) || $options['contextLines'] < 0) {
+            throw new ConfigurationException('contextLines', 'an int >= 0', $options['contextLines']);
+        }
+
+        if (!is_int($options['commonLineThreshold']) || $options['commonLineThreshold'] <= 0) {
+            throw new ConfigurationException('commonLineThreshold', 'an int > 0', $options['commonLineThreshold']);
+        }
+
+        $this->assertString($options, 'fromFile');
+        $this->assertString($options, 'toFile');
+        $this->assertStringOrNull($options, 'fromFileDate');
+        $this->assertStringOrNull($options, 'toFileDate');
+
+        $this->header = sprintf(
+            "--- %s%s\n+++ %s%s\n",
+            $options['fromFile'],
+            null === $options['fromFileDate'] ? '' : "\t" . $options['fromFileDate'],
+            $options['toFile'],
+            null === $options['toFileDate'] ? '' : "\t" . $options['toFileDate']
+        );
+
+        $this->collapseRanges      = $options['collapseRanges'];
+        $this->commonLineThreshold = $options['commonLineThreshold'];
+        $this->contextLines        = $options['contextLines'];
+    }
+
+    public function getDiff(array $diff): string
+    {
+        if (0 === count($diff)) {
+            return '';
+        }
+
+        $this->changed = false;
+
+        $buffer = fopen('php://memory', 'r+b');
+        fwrite($buffer, $this->header);
+
+        $this->writeDiffHunks($buffer, $diff);
+
+        if (!$this->changed) {
+            fclose($buffer);
+
+            return '';
+        }
+
+        $diff = stream_get_contents($buffer, -1, 0);
+
+        fclose($buffer);
+
+        // If the last char is not a linebreak: add it.
+        // This might happen when both the `from` and `to` do not have a trailing linebreak
+        $last = substr($diff, -1);
+
+        return "\n" !== $last && "\r" !== $last
+            ? $diff . "\n"
+            : $diff;
+    }
+
+    private function writeDiffHunks($output, array $diff): void
+    {
+        // detect "No newline at end of file" and insert into `$diff` if needed
+
+        $upperLimit = count($diff);
+
+        if (0 === $diff[$upperLimit - 1][1]) {
+            $lc = substr($diff[$upperLimit - 1][0], -1);
+
+            if ("\n" !== $lc) {
+                array_splice($diff, $upperLimit, 0, [["\n\\ No newline at end of file\n", Differ::NO_LINE_END_EOF_WARNING]]);
+            }
+        } else {
+            // search back for the last `+` and `-` line,
+            // check if has trailing linebreak, else add under it warning under it
+            $toFind = [1 => true, 2 => true];
+
+            for ($i = $upperLimit - 1; $i >= 0; --$i) {
+                if (isset($toFind[$diff[$i][1]])) {
+                    unset($toFind[$diff[$i][1]]);
+                    $lc = substr($diff[$i][0], -1);
+
+                    if ("\n" !== $lc) {
+                        array_splice($diff, $i + 1, 0, [["\n\\ No newline at end of file\n", Differ::NO_LINE_END_EOF_WARNING]]);
+                    }
+
+                    if (!count($toFind)) {
+                        break;
+                    }
+                }
+            }
+        }
+
+        // write hunks to output buffer
+
+        $cutOff      = max($this->commonLineThreshold, $this->contextLines);
+        $hunkCapture = false;
+        $sameCount   = $toRange = $fromRange = 0;
+        $toStart     = $fromStart = 1;
+        $i           = 0;
+
+        /** @var int $i */
+        foreach ($diff as $i => $entry) {
+            if (0 === $entry[1]) { // same
+                if (false === $hunkCapture) {
+                    ++$fromStart;
+                    ++$toStart;
+
+                    continue;
+                }
+
+                ++$sameCount;
+                ++$toRange;
+                ++$fromRange;
+
+                if ($sameCount === $cutOff) {
+                    $contextStartOffset = ($hunkCapture - $this->contextLines) < 0
+                        ? $hunkCapture
+                        : $this->contextLines;
+
+                    // note: $contextEndOffset = $this->contextLines;
+                    //
+                    // because we never go beyond the end of the diff.
+                    // with the cutoff/contextlines here the follow is never true;
+                    //
+                    // if ($i - $cutOff + $this->contextLines + 1 > \count($diff)) {
+                    //    $contextEndOffset = count($diff) - 1;
+                    // }
+                    //
+                    // ; that would be true for a trailing incomplete hunk case which is dealt with after this loop
+
+                    $this->writeHunk(
+                        $diff,
+                        $hunkCapture - $contextStartOffset,
+                        $i - $cutOff + $this->contextLines + 1,
+                        $fromStart - $contextStartOffset,
+                        $fromRange - $cutOff + $contextStartOffset + $this->contextLines,
+                        $toStart - $contextStartOffset,
+                        $toRange - $cutOff + $contextStartOffset + $this->contextLines,
+                        $output
+                    );
+
+                    $fromStart += $fromRange;
+                    $toStart += $toRange;
+
+                    $hunkCapture = false;
+                    $sameCount   = $toRange = $fromRange = 0;
+                }
+
+                continue;
+            }
+
+            $sameCount = 0;
+
+            if ($entry[1] === Differ::NO_LINE_END_EOF_WARNING) {
+                continue;
+            }
+
+            $this->changed = true;
+
+            if (false === $hunkCapture) {
+                $hunkCapture = $i;
+            }
+
+            if (Differ::ADDED === $entry[1]) { // added
+                ++$toRange;
+            }
+
+            if (Differ::REMOVED === $entry[1]) { // removed
+                ++$fromRange;
+            }
+        }
+
+        if (false === $hunkCapture) {
+            return;
+        }
+
+        // we end here when cutoff (commonLineThreshold) was not reached, but we where capturing a hunk,
+        // do not render hunk till end automatically because the number of context lines might be less than the commonLineThreshold
+
+        $contextStartOffset = $hunkCapture - $this->contextLines < 0
+            ? $hunkCapture
+            : $this->contextLines;
+
+        // prevent trying to write out more common lines than there are in the diff _and_
+        // do not write more than configured through the context lines
+        $contextEndOffset = min($sameCount, $this->contextLines);
+
+        $fromRange -= $sameCount;
+        $toRange -= $sameCount;
+
+        $this->writeHunk(
+            $diff,
+            $hunkCapture - $contextStartOffset,
+            $i - $sameCount + $contextEndOffset + 1,
+            $fromStart - $contextStartOffset,
+            $fromRange + $contextStartOffset + $contextEndOffset,
+            $toStart - $contextStartOffset,
+            $toRange + $contextStartOffset + $contextEndOffset,
+            $output
+        );
+    }
+
+    private function writeHunk(
+        array $diff,
+        int $diffStartIndex,
+        int $diffEndIndex,
+        int $fromStart,
+        int $fromRange,
+        int $toStart,
+        int $toRange,
+        $output
+    ): void {
+        fwrite($output, '@@ -' . $fromStart);
+
+        if (!$this->collapseRanges || 1 !== $fromRange) {
+            fwrite($output, ',' . $fromRange);
+        }
+
+        fwrite($output, ' +' . $toStart);
+
+        if (!$this->collapseRanges || 1 !== $toRange) {
+            fwrite($output, ',' . $toRange);
+        }
+
+        fwrite($output, " @@\n");
+
+        for ($i = $diffStartIndex; $i < $diffEndIndex; ++$i) {
+            if ($diff[$i][1] === Differ::ADDED) {
+                $this->changed = true;
+                fwrite($output, '+' . $diff[$i][0]);
+            } elseif ($diff[$i][1] === Differ::REMOVED) {
+                $this->changed = true;
+                fwrite($output, '-' . $diff[$i][0]);
+            } elseif ($diff[$i][1] === Differ::OLD) {
+                fwrite($output, ' ' . $diff[$i][0]);
+            } elseif ($diff[$i][1] === Differ::NO_LINE_END_EOF_WARNING) {
+                $this->changed = true;
+                fwrite($output, $diff[$i][0]);
+            }
+            //} elseif ($diff[$i][1] === Differ::DIFF_LINE_END_WARNING) { // custom comment inserted by PHPUnit/diff package
+                //  skip
+            //} else {
+                //  unknown/invalid
+            //}
+        }
+    }
+
+    private function assertString(array $options, string $option): void
+    {
+        if (!is_string($options[$option])) {
+            throw new ConfigurationException($option, 'a string', $options[$option]);
+        }
+    }
+
+    private function assertStringOrNull(array $options, string $option): void
+    {
+        if (null !== $options[$option] && !is_string($options[$option])) {
+            throw new ConfigurationException($option, 'a string or <null>', $options[$option]);
+        }
+    }
+}
diff --git a/src/Output/UnifiedDiffOutputBuilder.php b/src/Output/UnifiedDiffOutputBuilder.php
index 211d64eb..8aae6450 100644
--- a/src/Output/UnifiedDiffOutputBuilder.php
+++ b/src/Output/UnifiedDiffOutputBuilder.php
@@ -7,14 +7,40 @@
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
  */
-
 namespace SebastianBergmann\Diff\Output;
 
+use function array_splice;
+use function count;
+use function fclose;
+use function fopen;
+use function fwrite;
+use function max;
+use function min;
+use function stream_get_contents;
+use function strlen;
+use function substr;
+use SebastianBergmann\Diff\Differ;
+
 /**
  * Builds a diff string representation in unified diff format in chunks.
  */
 final class UnifiedDiffOutputBuilder extends AbstractChunkOutputBuilder
 {
+    /**
+     * @var bool
+     */
+    private $collapseRanges = true;
+
+    /**
+     * @var int >= 0
+     */
+    private $commonLineThreshold = 6;
+
+    /**
+     * @var int >= 0
+     */
+    private $contextLines = 3;
+
     /**
      * @var string
      */
@@ -33,133 +59,214 @@ public function __construct(string $header = "--- Original\n+++ New\n", bool $ad
 
     public function getDiff(array $diff): string
     {
-        $buffer = \fopen('php://memory', 'r+b');
+        $buffer = fopen('php://memory', 'r+b');
 
         if ('' !== $this->header) {
-            \fwrite($buffer, $this->header);
-            if ("\n" !== \substr($this->header, -1, 1)) {
-                \fwrite($buffer, "\n");
+            fwrite($buffer, $this->header);
+
+            if ("\n" !== substr($this->header, -1, 1)) {
+                fwrite($buffer, "\n");
             }
         }
 
-        $this->writeDiffChunked($buffer, $diff, $this->getCommonChunks($diff));
+        if (0 !== count($diff)) {
+            $this->writeDiffHunks($buffer, $diff);
+        }
+
+        $diff = stream_get_contents($buffer, -1, 0);
 
-        $diff = \stream_get_contents($buffer, -1, 0);
+        fclose($buffer);
 
-        \fclose($buffer);
+        // If the diff is non-empty and last char is not a linebreak: add it.
+        // This might happen when both the `from` and `to` do not have a trailing linebreak
+        $last = substr($diff, -1);
 
-        return $diff;
+        return 0 !== strlen($diff) && "\n" !== $last && "\r" !== $last
+            ? $diff . "\n"
+            : $diff;
     }
 
-    // `old` is an array with key => value pairs . Each pair represents a start and end index of `diff`
-    // of a list of elements all containing `same` (0) entries.
-    private function writeDiffChunked($output, array $diff, array $old)
+    private function writeDiffHunks($output, array $diff): void
     {
-        $upperLimit = \count($diff);
-        $start      = 0;
-        $fromStart  = 0;
-        $toStart    = 0;
+        // detect "No newline at end of file" and insert into `$diff` if needed
+
+        $upperLimit = count($diff);
+
+        if (0 === $diff[$upperLimit - 1][1]) {
+            $lc = substr($diff[$upperLimit - 1][0], -1);
+
+            if ("\n" !== $lc) {
+                array_splice($diff, $upperLimit, 0, [["\n\\ No newline at end of file\n", Differ::NO_LINE_END_EOF_WARNING]]);
+            }
+        } else {
+            // search back for the last `+` and `-` line,
+            // check if has trailing linebreak, else add under it warning under it
+            $toFind = [1 => true, 2 => true];
+
+            for ($i = $upperLimit - 1; $i >= 0; --$i) {
+                if (isset($toFind[$diff[$i][1]])) {
+                    unset($toFind[$diff[$i][1]]);
+                    $lc = substr($diff[$i][0], -1);
+
+                    if ("\n" !== $lc) {
+                        array_splice($diff, $i + 1, 0, [["\n\\ No newline at end of file\n", Differ::NO_LINE_END_EOF_WARNING]]);
+                    }
+
+                    if (!count($toFind)) {
+                        break;
+                    }
+                }
+            }
+        }
+
+        // write hunks to output buffer
+
+        $cutOff      = max($this->commonLineThreshold, $this->contextLines);
+        $hunkCapture = false;
+        $sameCount   = $toRange   = $fromRange = 0;
+        $toStart     = $fromStart = 1;
+        $i           = 0;
+
+        /** @var int $i */
+        foreach ($diff as $i => $entry) {
+            if (0 === $entry[1]) { // same
+                if (false === $hunkCapture) {
+                    ++$fromStart;
+                    ++$toStart;
+
+                    continue;
+                }
+
+                ++$sameCount;
+                ++$toRange;
+                ++$fromRange;
 
-        if (\count($old)) { // no common parts, list all diff entries
-            \reset($old);
+                if ($sameCount === $cutOff) {
+                    $contextStartOffset = ($hunkCapture - $this->contextLines) < 0
+                        ? $hunkCapture
+                        : $this->contextLines;
 
-            // iterate the diff, go from chunk to chunk skipping common chunk of lines between those
-            do {
-                $commonStart = \key($old);
-                $commonEnd   = \current($old);
+                    // note: $contextEndOffset = $this->contextLines;
+                    //
+                    // because we never go beyond the end of the diff.
+                    // with the cutoff/contextlines here the follow is never true;
+                    //
+                    // if ($i - $cutOff + $this->contextLines + 1 > \count($diff)) {
+                    //    $contextEndOffset = count($diff) - 1;
+                    // }
+                    //
+                    // ; that would be true for a trailing incomplete hunk case which is dealt with after this loop
 
-                if ($commonStart !== $start) {
-                    list($fromRange, $toRange) = $this->getChunkRange($diff, $start, $commonStart);
-                    $this->writeChunk($output, $diff, $start, $commonStart, $fromStart, $fromRange, $toStart, $toRange);
+                    $this->writeHunk(
+                        $diff,
+                        $hunkCapture - $contextStartOffset,
+                        $i - $cutOff + $this->contextLines + 1,
+                        $fromStart - $contextStartOffset,
+                        $fromRange - $cutOff + $contextStartOffset + $this->contextLines,
+                        $toStart - $contextStartOffset,
+                        $toRange - $cutOff + $contextStartOffset + $this->contextLines,
+                        $output
+                    );
 
                     $fromStart += $fromRange;
                     $toStart += $toRange;
+
+                    $hunkCapture = false;
+                    $sameCount   = $toRange = $fromRange = 0;
                 }
 
-                $start        = $commonEnd + 1;
-                $commonLength = $commonEnd - $commonStart + 1; // calculate number of non-change lines in the common part
-                $fromStart += $commonLength;
-                $toStart += $commonLength;
-            } while (false !== \next($old));
-
-            \end($old); // short cut for finding possible last `change entry`
-            $tmp = \key($old);
-            \reset($old);
-            if ($old[$tmp] === $upperLimit - 1) {
-                $upperLimit = $tmp;
+                continue;
+            }
+
+            $sameCount = 0;
+
+            if ($entry[1] === Differ::NO_LINE_END_EOF_WARNING) {
+                continue;
             }
-        }
 
-        if ($start < $upperLimit - 1) { // check for trailing (non) diff entries
-            do {
-                --$upperLimit;
-            } while (isset($diff[$upperLimit][1]) && $diff[$upperLimit][1] === 0);
-            ++$upperLimit;
+            if (false === $hunkCapture) {
+                $hunkCapture = $i;
+            }
+
+            if (Differ::ADDED === $entry[1]) {
+                ++$toRange;
+            }
 
-            list($fromRange, $toRange) = $this->getChunkRange($diff, $start, $upperLimit);
-            $this->writeChunk($output, $diff, $start, $upperLimit, $fromStart, $fromRange, $toStart, $toRange);
+            if (Differ::REMOVED === $entry[1]) {
+                ++$fromRange;
+            }
+        }
+
+        if (false === $hunkCapture) {
+            return;
         }
+
+        // we end here when cutoff (commonLineThreshold) was not reached, but we where capturing a hunk,
+        // do not render hunk till end automatically because the number of context lines might be less than the commonLineThreshold
+
+        $contextStartOffset = $hunkCapture - $this->contextLines < 0
+            ? $hunkCapture
+            : $this->contextLines;
+
+        // prevent trying to write out more common lines than there are in the diff _and_
+        // do not write more than configured through the context lines
+        $contextEndOffset = min($sameCount, $this->contextLines);
+
+        $fromRange -= $sameCount;
+        $toRange -= $sameCount;
+
+        $this->writeHunk(
+            $diff,
+            $hunkCapture - $contextStartOffset,
+            $i - $sameCount + $contextEndOffset + 1,
+            $fromStart - $contextStartOffset,
+            $fromRange + $contextStartOffset + $contextEndOffset,
+            $toStart - $contextStartOffset,
+            $toRange + $contextStartOffset + $contextEndOffset,
+            $output
+        );
     }
 
-    private function writeChunk(
-        $output,
+    private function writeHunk(
         array $diff,
         int $diffStartIndex,
         int $diffEndIndex,
         int $fromStart,
         int $fromRange,
         int $toStart,
-        int $toRange
-    ) {
+        int $toRange,
+        $output
+    ): void {
         if ($this->addLineNumbers) {
-            \fwrite($output, '@@ -' . (1 + $fromStart));
-
-            if ($fromRange > 1) {
-                \fwrite($output, ',' . $fromRange);
-            }
+            fwrite($output, '@@ -' . $fromStart);
 
-            \fwrite($output, ' +' . (1 + $toStart));
-            if ($toRange > 1) {
-                \fwrite($output, ',' . $toRange);
+            if (!$this->collapseRanges || 1 !== $fromRange) {
+                fwrite($output, ',' . $fromRange);
             }
 
-            \fwrite($output, " @@\n");
-        } else {
-            \fwrite($output, "@@ @@\n");
-        }
+            fwrite($output, ' +' . $toStart);
 
-        for ($i = $diffStartIndex; $i < $diffEndIndex; ++$i) {
-            if ($diff[$i][1] === 1 /* ADDED */) {
-                \fwrite($output, '+' . $diff[$i][0]);
-            } elseif ($diff[$i][1] === 2 /* REMOVED */) {
-                \fwrite($output, '-' . $diff[$i][0]);
-            } else { /* Not changed (old) 0 or Warning 3 */
-                \fwrite($output, ' ' . $diff[$i][0]);
+            if (!$this->collapseRanges || 1 !== $toRange) {
+                fwrite($output, ',' . $toRange);
             }
 
-            $lc = \substr($diff[$i][0], -1);
-            if ($lc !== "\n" && $lc !== "\r") {
-                \fwrite($output, "\n"); // \No newline at end of file
-            }
+            fwrite($output, " @@\n");
+        } else {
+            fwrite($output, "@@ @@\n");
         }
-    }
-
-    private function getChunkRange(array $diff, int $diffStartIndex, int $diffEndIndex): array
-    {
-        $toRange   = 0;
-        $fromRange = 0;
 
         for ($i = $diffStartIndex; $i < $diffEndIndex; ++$i) {
-            if ($diff[$i][1] === 1) { // added
-                ++$toRange;
-            } elseif ($diff[$i][1] === 2) { // removed
-                ++$fromRange;
-            } elseif ($diff[$i][1] === 0) { // same
-                ++$fromRange;
-                ++$toRange;
+            if ($diff[$i][1] === Differ::ADDED) {
+                fwrite($output, '+' . $diff[$i][0]);
+            } elseif ($diff[$i][1] === Differ::REMOVED) {
+                fwrite($output, '-' . $diff[$i][0]);
+            } elseif ($diff[$i][1] === Differ::OLD) {
+                fwrite($output, ' ' . $diff[$i][0]);
+            } elseif ($diff[$i][1] === Differ::NO_LINE_END_EOF_WARNING) {
+                fwrite($output, "\n"); // $diff[$i][0]
+            } else { /* Not changed (old) Differ::OLD or Warning Differ::DIFF_LINE_END_WARNING */
+                fwrite($output, ' ' . $diff[$i][0]);
             }
         }
-
-        return [$fromRange, $toRange];
     }
 }
diff --git a/src/Parser.php b/src/Parser.php
index dbbf6f4c..cc9e3887 100644
--- a/src/Parser.php
+++ b/src/Parser.php
@@ -7,35 +7,38 @@
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
  */
-
 namespace SebastianBergmann\Diff;
 
+use function array_pop;
+use function count;
+use function max;
+use function preg_match;
+use function preg_split;
+
 /**
  * Unified diff parser.
  */
 final class Parser
 {
     /**
-     * @param string $string
-     *
      * @return Diff[]
      */
     public function parse(string $string): array
     {
-        $lines = \preg_split('(\r\n|\r|\n)', $string);
+        $lines = preg_split('(\r\n|\r|\n)', $string);
 
-        if (!empty($lines) && $lines[\count($lines) - 1] === '') {
-            \array_pop($lines);
+        if (!empty($lines) && $lines[count($lines) - 1] === '') {
+            array_pop($lines);
         }
 
-        $lineCount = \count($lines);
+        $lineCount = count($lines);
         $diffs     = [];
         $diff      = null;
         $collected = [];
 
         for ($i = 0; $i < $lineCount; ++$i) {
-            if (\preg_match('(^---\\s+(?P<file>\\S+))', $lines[$i], $fromMatch) &&
-                \preg_match('(^\\+\\+\\+\\s+(?P<file>\\S+))', $lines[$i + 1], $toMatch)) {
+            if (preg_match('#^---\h+"?(?P<file>[^\\v\\t"]+)#', $lines[$i], $fromMatch) &&
+                preg_match('#^\\+\\+\\+\\h+"?(?P<file>[^\\v\\t"]+)#', $lines[$i + 1], $toMatch)) {
                 if ($diff !== null) {
                     $this->parseFileDiff($diff, $collected);
 
@@ -47,7 +50,7 @@ public function parse(string $string): array
 
                 ++$i;
             } else {
-                if (\preg_match('/^(?:diff --git |index [\da-f\.]+|[+-]{3} [ab])/', $lines[$i])) {
+                if (preg_match('/^(?:diff --git |index [\da-f\.]+|[+-]{3} [ab])/', $lines[$i])) {
                     continue;
                 }
 
@@ -55,7 +58,7 @@ public function parse(string $string): array
             }
         }
 
-        if ($diff !== null && \count($collected)) {
+        if ($diff !== null && count($collected)) {
             $this->parseFileDiff($diff, $collected);
 
             $diffs[] = $diff;
@@ -64,18 +67,19 @@ public function parse(string $string): array
         return $diffs;
     }
 
-    private function parseFileDiff(Diff $diff, array $lines)
+    private function parseFileDiff(Diff $diff, array $lines): void
     {
-        $chunks = [];
-        $chunk  = null;
+        $chunks    = [];
+        $chunk     = null;
+        $diffLines = [];
 
         foreach ($lines as $line) {
-            if (\preg_match('/^@@\s+-(?P<start>\d+)(?:,\s*(?P<startrange>\d+))?\s+\+(?P<end>\d+)(?:,\s*(?P<endrange>\d+))?\s+@@/', $line, $match)) {
+            if (preg_match('/^@@\s+-(?P<start>\d+)(?:,\s*(?P<startrange>\d+))?\s+\+(?P<end>\d+)(?:,\s*(?P<endrange>\d+))?\s+@@/', $line, $match)) {
                 $chunk = new Chunk(
                     (int) $match['start'],
-                    isset($match['startrange']) ? \max(1, (int) $match['startrange']) : 1,
+                    isset($match['startrange']) ? max(1, (int) $match['startrange']) : 1,
                     (int) $match['end'],
-                    isset($match['endrange']) ? \max(1, (int) $match['endrange']) : 1
+                    isset($match['endrange']) ? max(1, (int) $match['endrange']) : 1
                 );
 
                 $chunks[]  = $chunk;
@@ -84,7 +88,7 @@ private function parseFileDiff(Diff $diff, array $lines)
                 continue;
             }
 
-            if (\preg_match('/^(?P<type>[+ -])?(?P<line>.*)/', $line, $match)) {
+            if (preg_match('/^(?P<type>[+ -])?(?P<line>.*)/', $line, $match)) {
                 $type = Line::UNCHANGED;
 
                 if ($match['type'] === '+') {
diff --git a/src/TimeEfficientLongestCommonSubsequenceCalculator.php b/src/TimeEfficientLongestCommonSubsequenceCalculator.php
index 97dadbd5..fd19cac7 100644
--- a/src/TimeEfficientLongestCommonSubsequenceCalculator.php
+++ b/src/TimeEfficientLongestCommonSubsequenceCalculator.php
@@ -7,9 +7,13 @@
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
  */
-
 namespace SebastianBergmann\Diff;
 
+use function array_reverse;
+use function count;
+use function max;
+use SplFixedArray;
+
 final class TimeEfficientLongestCommonSubsequenceCalculator implements LongestCommonSubsequenceCalculator
 {
     /**
@@ -18,10 +22,10 @@ final class TimeEfficientLongestCommonSubsequenceCalculator implements LongestCo
     public function calculate(array $from, array $to): array
     {
         $common     = [];
-        $fromLength = \count($from);
-        $toLength   = \count($to);
+        $fromLength = count($from);
+        $toLength   = count($to);
         $width      = $fromLength + 1;
-        $matrix     = new \SplFixedArray($width * ($toLength + 1));
+        $matrix     = new SplFixedArray($width * ($toLength + 1));
 
         for ($i = 0; $i <= $fromLength; ++$i) {
             $matrix[$i] = 0;
@@ -34,7 +38,7 @@ public function calculate(array $from, array $to): array
         for ($i = 1; $i <= $fromLength; ++$i) {
             for ($j = 1; $j <= $toLength; ++$j) {
                 $o          = ($j * $width) + $i;
-                $matrix[$o] = \max(
+                $matrix[$o] = max(
                     $matrix[$o - 1],
                     $matrix[$o - $width],
                     $from[$i - 1] === $to[$j - 1] ? $matrix[$o - $width - 1] + 1 : 0
@@ -61,6 +65,6 @@ public function calculate(array $from, array $to): array
             }
         }
 
-        return \array_reverse($common);
+        return array_reverse($common);
     }
 }
diff --git a/tests/ChunkTest.php b/tests/ChunkTest.php
index 57e3c994..453603bc 100644
--- a/tests/ChunkTest.php
+++ b/tests/ChunkTest.php
@@ -7,13 +7,14 @@
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
  */
-
 namespace SebastianBergmann\Diff;
 
 use PHPUnit\Framework\TestCase;
 
 /**
- * @covers SebastianBergmann\Diff\Chunk
+ * @covers \SebastianBergmann\Diff\Chunk
+ *
+ * @uses \SebastianBergmann\Diff\Line
  */
 final class ChunkTest extends TestCase
 {
@@ -22,47 +23,52 @@ final class ChunkTest extends TestCase
      */
     private $chunk;
 
-    protected function setUp()
+    protected function setUp(): void
     {
         $this->chunk = new Chunk;
     }
 
-    public function testCanBeCreatedWithoutArguments()
+    public function testHasInitiallyNoLines(): void
+    {
+        $this->assertSame([], $this->chunk->getLines());
+    }
+
+    public function testCanBeCreatedWithoutArguments(): void
     {
         $this->assertInstanceOf(Chunk::class, $this->chunk);
     }
 
-    public function testStartCanBeRetrieved()
+    public function testStartCanBeRetrieved(): void
     {
         $this->assertSame(0, $this->chunk->getStart());
     }
 
-    public function testStartRangeCanBeRetrieved()
+    public function testStartRangeCanBeRetrieved(): void
     {
         $this->assertSame(1, $this->chunk->getStartRange());
     }
 
-    public function testEndCanBeRetrieved()
+    public function testEndCanBeRetrieved(): void
     {
         $this->assertSame(0, $this->chunk->getEnd());
     }
 
-    public function testEndRangeCanBeRetrieved()
+    public function testEndRangeCanBeRetrieved(): void
     {
         $this->assertSame(1, $this->chunk->getEndRange());
     }
 
-    public function testLinesCanBeRetrieved()
+    public function testLinesCanBeRetrieved(): void
     {
         $this->assertSame([], $this->chunk->getLines());
     }
 
-    public function testLinesCanBeSet()
+    public function testLinesCanBeSet(): void
     {
-        $this->assertSame([], $this->chunk->getLines());
+        $lines = [new Line(Line::ADDED, 'added'), new Line(Line::REMOVED, 'removed')];
+
+        $this->chunk->setLines($lines);
 
-        $testValue = ['line0', 'line1'];
-        $this->chunk->setLines($testValue);
-        $this->assertSame($testValue, $this->chunk->getLines());
+        $this->assertSame($lines, $this->chunk->getLines());
     }
 }
diff --git a/tests/DiffTest.php b/tests/DiffTest.php
index fe5cb228..67ae039d 100644
--- a/tests/DiffTest.php
+++ b/tests/DiffTest.php
@@ -7,19 +7,18 @@
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
  */
-
 namespace SebastianBergmann\Diff;
 
 use PHPUnit\Framework\TestCase;
 
 /**
- * @covers SebastianBergmann\Diff\Diff
+ * @covers \SebastianBergmann\Diff\Diff
  *
- * @uses SebastianBergmann\Diff\Chunk
+ * @uses \SebastianBergmann\Diff\Chunk
  */
 final class DiffTest extends TestCase
 {
-    public function testGettersAfterConstructionWithDefault()
+    public function testGettersAfterConstructionWithDefault(): void
     {
         $from = 'line1a';
         $to   = 'line2a';
@@ -30,7 +29,7 @@ public function testGettersAfterConstructionWithDefault()
         $this->assertSame([], $diff->getChunks(), 'Expect chunks to be default value "array()".');
     }
 
-    public function testGettersAfterConstructionWithChunks()
+    public function testGettersAfterConstructionWithChunks(): void
     {
         $from   = 'line1b';
         $to     = 'line2b';
@@ -43,7 +42,7 @@ public function testGettersAfterConstructionWithChunks()
         $this->assertSame($chunks, $diff->getChunks(), 'Expect chunks to be passed value.');
     }
 
-    public function testSetChunksAfterConstruction()
+    public function testSetChunksAfterConstruction(): void
     {
         $diff = new Diff('line1c', 'line2c');
         $this->assertSame([], $diff->getChunks(), 'Expect chunks to be default value "array()".');
diff --git a/tests/DifferTest.php b/tests/DifferTest.php
index 3781992d..43112448 100644
--- a/tests/DifferTest.php
+++ b/tests/DifferTest.php
@@ -7,136 +7,72 @@
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
  */
-
 namespace SebastianBergmann\Diff;
 
 use PHPUnit\Framework\TestCase;
-use SebastianBergmann\Diff\Output\AbstractChunkOutputBuilder;
-use SebastianBergmann\Diff\Output\DiffOnlyOutputBuilder;
-use SebastianBergmann\Diff\Output\UnifiedDiffOutputBuilder;
+use ReflectionObject;
+use SplFileInfo;
+use stdClass;
 
 /**
- * @covers SebastianBergmann\Diff\Differ
- * @covers SebastianBergmann\Diff\Output\AbstractChunkOutputBuilder
- * @covers SebastianBergmann\Diff\Output\DiffOnlyOutputBuilder
- * @covers SebastianBergmann\Diff\Output\UnifiedDiffOutputBuilder
+ * @covers \SebastianBergmann\Diff\Differ
+ * @covers \SebastianBergmann\Diff\Output\UnifiedDiffOutputBuilder
  *
- * @uses SebastianBergmann\Diff\MemoryEfficientLongestCommonSubsequenceCalculator
- * @uses SebastianBergmann\Diff\TimeEfficientLongestCommonSubsequenceCalculator
- * @uses SebastianBergmann\Diff\Chunk
- * @uses SebastianBergmann\Diff\Diff
- * @uses SebastianBergmann\Diff\Line
- * @uses SebastianBergmann\Diff\Parser
+ * @uses \SebastianBergmann\Diff\MemoryEfficientLongestCommonSubsequenceCalculator
+ * @uses \SebastianBergmann\Diff\TimeEfficientLongestCommonSubsequenceCalculator
+ * @uses \SebastianBergmann\Diff\Output\AbstractChunkOutputBuilder
  */
 final class DifferTest extends TestCase
 {
-    const WARNING = 3;
-    const REMOVED = 2;
-    const ADDED   = 1;
-    const OLD     = 0;
-
     /**
      * @var Differ
      */
     private $differ;
 
-    protected function setUp()
+    protected function setUp(): void
     {
         $this->differ = new Differ;
     }
 
     /**
-     * @param array        $expected
-     * @param string|array $from
-     * @param string|array $to
+     * @param array|string $from
+     * @param array|string $to
+     *
      * @dataProvider arrayProvider
      */
-    public function testArrayRepresentationOfDiffCanBeRenderedUsingTimeEfficientLcsImplementation(array $expected, $from, $to)
+    public function testArrayRepresentationOfDiffCanBeRenderedUsingTimeEfficientLcsImplementation(array $expected, $from, $to): void
     {
         $this->assertSame($expected, $this->differ->diffToArray($from, $to, new TimeEfficientLongestCommonSubsequenceCalculator));
     }
 
     /**
-     * @param string $expected
-     * @param string $from
-     * @param string $to
      * @dataProvider textProvider
      */
-    public function testTextRepresentationOfDiffCanBeRenderedUsingTimeEfficientLcsImplementation(string $expected, string $from, string $to)
+    public function testTextRepresentationOfDiffCanBeRenderedUsingTimeEfficientLcsImplementation(string $expected, string $from, string $to): void
     {
         $this->assertSame($expected, $this->differ->diff($from, $to, new TimeEfficientLongestCommonSubsequenceCalculator));
     }
 
     /**
-     * @param array        $expected
-     * @param string|array $from
-     * @param string|array $to
+     * @param array|string $from
+     * @param array|string $to
+     *
      * @dataProvider arrayProvider
      */
-    public function testArrayRepresentationOfDiffCanBeRenderedUsingMemoryEfficientLcsImplementation(array $expected, $from, $to)
+    public function testArrayRepresentationOfDiffCanBeRenderedUsingMemoryEfficientLcsImplementation(array $expected, $from, $to): void
     {
         $this->assertSame($expected, $this->differ->diffToArray($from, $to, new MemoryEfficientLongestCommonSubsequenceCalculator));
     }
 
     /**
-     * @param string $expected
-     * @param string $from
-     * @param string $to
      * @dataProvider textProvider
      */
-    public function testTextRepresentationOfDiffCanBeRenderedUsingMemoryEfficientLcsImplementation(string $expected, string $from, string $to)
+    public function testTextRepresentationOfDiffCanBeRenderedUsingMemoryEfficientLcsImplementation(string $expected, string $from, string $to): void
     {
         $this->assertSame($expected, $this->differ->diff($from, $to, new MemoryEfficientLongestCommonSubsequenceCalculator));
     }
 
-    /**
-     * @param string $expected
-     * @param string $from
-     * @param string $to
-     * @param string $header
-     * @dataProvider headerProvider
-     */
-    public function testCustomHeaderCanBeUsed(string $expected, string $from, string $to, string $header)
-    {
-        $differ = new Differ(new UnifiedDiffOutputBuilder($header));
-
-        $this->assertSame(
-            $expected,
-            $differ->diff($from, $to)
-        );
-    }
-
-    public function headerProvider()
-    {
-        return [
-            [
-                "CUSTOM HEADER\n@@ @@\n-a\n+b\n",
-                'a',
-                'b',
-                'CUSTOM HEADER'
-            ],
-            [
-                "CUSTOM HEADER\n@@ @@\n-a\n+b\n",
-                'a',
-                'b',
-                "CUSTOM HEADER\n"
-            ],
-            [
-                "CUSTOM HEADER\n\n@@ @@\n-a\n+b\n",
-                'a',
-                'b',
-                "CUSTOM HEADER\n\n"
-            ],
-            [
-                "@@ @@\n-a\n+b\n",
-                'a',
-                'b',
-                ''
-            ],
-        ];
-    }
-
-    public function testTypesOtherThanArrayAndStringCanBePassed()
+    public function testTypesOtherThanArrayAndStringCanBePassed(): void
     {
         $this->assertSame(
             "--- Original\n+++ New\n@@ @@\n-1\n+2\n",
@@ -144,17 +80,17 @@ public function testTypesOtherThanArrayAndStringCanBePassed()
         );
     }
 
-    /**
-     * @param string $diff
-     * @param Diff[] $expected
-     * @dataProvider diffProvider
-     */
-    public function testParser(string $diff, array $expected)
+    public function testArrayDiffs(): void
     {
-        $parser = new Parser;
-        $result = $parser->parse($diff);
-
-        $this->assertEquals($expected, $result);
+        $this->assertSame(
+            '--- Original
++++ New
+@@ @@
+-one
++two
+',
+            $this->differ->diff(['one'], ['two'])
+        );
     }
 
     public function arrayProvider(): array
@@ -162,119 +98,119 @@ public function arrayProvider(): array
         return [
             [
                 [
-                    ['a', self::REMOVED],
-                    ['b', self::ADDED]
+                    ['a', Differ::REMOVED],
+                    ['b', Differ::ADDED],
                 ],
                 'a',
-                'b'
+                'b',
             ],
             [
                 [
-                    ['ba', self::REMOVED],
-                    ['bc', self::ADDED]
+                    ['ba', Differ::REMOVED],
+                    ['bc', Differ::ADDED],
                 ],
                 'ba',
-                'bc'
+                'bc',
             ],
             [
                 [
-                    ['ab', self::REMOVED],
-                    ['cb', self::ADDED]
+                    ['ab', Differ::REMOVED],
+                    ['cb', Differ::ADDED],
                 ],
                 'ab',
-                'cb'
+                'cb',
             ],
             [
                 [
-                    ['abc', self::REMOVED],
-                    ['adc', self::ADDED]
+                    ['abc', Differ::REMOVED],
+                    ['adc', Differ::ADDED],
                 ],
                 'abc',
-                'adc'
+                'adc',
             ],
             [
                 [
-                    ['ab', self::REMOVED],
-                    ['abc', self::ADDED]
+                    ['ab', Differ::REMOVED],
+                    ['abc', Differ::ADDED],
                 ],
                 'ab',
-                'abc'
+                'abc',
             ],
             [
                 [
-                    ['bc', self::REMOVED],
-                    ['abc', self::ADDED]
+                    ['bc', Differ::REMOVED],
+                    ['abc', Differ::ADDED],
                 ],
                 'bc',
-                'abc'
+                'abc',
             ],
             [
                 [
-                    ['abc', self::REMOVED],
-                    ['abbc', self::ADDED]
+                    ['abc', Differ::REMOVED],
+                    ['abbc', Differ::ADDED],
                 ],
                 'abc',
-                'abbc'
+                'abbc',
             ],
             [
                 [
-                    ['abcdde', self::REMOVED],
-                    ['abcde', self::ADDED]
+                    ['abcdde', Differ::REMOVED],
+                    ['abcde', Differ::ADDED],
                 ],
                 'abcdde',
-                'abcde'
+                'abcde',
             ],
             'same start' => [
                 [
-                    [17, self::OLD],
-                    ['b', self::REMOVED],
-                    ['d', self::ADDED],
+                    [17, Differ::OLD],
+                    ['b', Differ::REMOVED],
+                    ['d', Differ::ADDED],
                 ],
                 [30 => 17, 'a' => 'b'],
                 [30 => 17, 'c' => 'd'],
             ],
             'same end' => [
                 [
-                    [1, self::REMOVED],
-                    [2, self::ADDED],
-                    ['b', self::OLD],
+                    [1, Differ::REMOVED],
+                    [2, Differ::ADDED],
+                    ['b', Differ::OLD],
                 ],
                 [1 => 1, 'a' => 'b'],
                 [1 => 2, 'a' => 'b'],
             ],
             'same start (2), same end (1)' => [
                 [
-                    [17, self::OLD],
-                    [2, self::OLD],
-                    [4, self::REMOVED],
-                    ['a', self::ADDED],
-                    [5, self::ADDED],
-                    ['x', self::OLD],
+                    [17, Differ::OLD],
+                    [2, Differ::OLD],
+                    [4, Differ::REMOVED],
+                    ['a', Differ::ADDED],
+                    [5, Differ::ADDED],
+                    ['x', Differ::OLD],
                 ],
                 [30 => 17, 1 => 2, 2 => 4, 'z' => 'x'],
                 [30 => 17, 1 => 2, 3 => 'a', 2 => 5, 'z' => 'x'],
             ],
             'same' => [
                 [
-                    ['x', self::OLD],
+                    ['x', Differ::OLD],
                 ],
                 ['z' => 'x'],
                 ['z' => 'x'],
             ],
             'diff' => [
                 [
-                    ['y', self::REMOVED],
-                    ['x', self::ADDED],
+                    ['y', Differ::REMOVED],
+                    ['x', Differ::ADDED],
                 ],
                 ['x' => 'y'],
                 ['z' => 'x'],
             ],
             'diff 2' => [
                 [
-                    ['y', self::REMOVED],
-                    ['b', self::REMOVED],
-                    ['x', self::ADDED],
-                    ['d', self::ADDED],
+                    ['y', Differ::REMOVED],
+                    ['b', Differ::REMOVED],
+                    ['x', Differ::ADDED],
+                    ['d', Differ::ADDED],
                 ],
                 ['x' => 'y', 'a' => 'b'],
                 ['z' => 'x', 'c' => 'd'],
@@ -283,15 +219,15 @@ public function arrayProvider(): array
                 [
                     [
                         "#Warning: Strings contain different line endings!\n",
-                        self::WARNING,
+                        Differ::DIFF_LINE_END_WARNING,
                     ],
                     [
                         "<?php\r\n",
-                        self::REMOVED,
+                        Differ::REMOVED,
                     ],
                     [
                         "<?php\n",
-                        self::ADDED,
+                        Differ::ADDED,
                     ],
                 ],
                 "<?php\r\n",
@@ -301,15 +237,15 @@ public function arrayProvider(): array
                 [
                     [
                         "#Warning: Strings contain different line endings!\n",
-                        self::WARNING,
+                        Differ::DIFF_LINE_END_WARNING,
                     ],
                     [
                         "<?php\r\n",
-                        self::REMOVED,
+                        Differ::REMOVED,
                     ],
                     [
                         "<?php\n",
-                        self::ADDED,
+                        Differ::ADDED,
                     ],
                 ],
                 ["<?php\r\n"],
@@ -324,45 +260,10 @@ public function textProvider(): array
             [
                 "--- Original\n+++ New\n@@ @@\n-a\n+b\n",
                 'a',
-                'b'
-            ],
-            [
-                "--- Original\n+++ New\n@@ @@\n-ba\n+bc\n",
-                'ba',
-                'bc'
-            ],
-            [
-                "--- Original\n+++ New\n@@ @@\n-ab\n+cb\n",
-                'ab',
-                'cb'
-            ],
-            [
-                "--- Original\n+++ New\n@@ @@\n-abc\n+adc\n",
-                'abc',
-                'adc'
-            ],
-            [
-                "--- Original\n+++ New\n@@ @@\n-ab\n+abc\n",
-                'ab',
-                'abc'
-            ],
-            [
-                "--- Original\n+++ New\n@@ @@\n-bc\n+abc\n",
-                'bc',
-                'abc'
-            ],
-            [
-                "--- Original\n+++ New\n@@ @@\n-abc\n+abbc\n",
-                'abc',
-                'abbc'
-            ],
-            [
-                "--- Original\n+++ New\n@@ @@\n-abcdde\n+abcde\n",
-                'abcdde',
-                'abcde'
+                'b',
             ],
             [
-                "--- Original\n+++ New\n@@ @@\n-A\n+A1\n",
+                "--- Original\n+++ New\n@@ @@\n-A\n+A1\n B\n",
                 "A\nB",
                 "A1\nB",
             ],
@@ -374,14 +275,21 @@ public function textProvider(): array
  a
 -b
 +p
+ c
+ d
+ e
 @@ @@
+ g
+ h
+ i
 -j
 +w
+ k
 
 EOF
-            ,
-                "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk",
-                "a\np\nc\nd\ne\nf\ng\nh\ni\nw\nk",
+                ,
+                "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\n",
+                "a\np\nc\nd\ne\nf\ng\nh\ni\nw\nk\n",
             ],
             [
                 <<<EOF
@@ -390,242 +298,50 @@ public function textProvider(): array
 @@ @@
 -A
 +B
+ 1
+ 2
+ 3
 
 EOF
-            ,
-                "A\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1",
-                "B\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1",
+                ,
+                "A\n1\n2\n3\n4\n5\n6\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n",
+                "B\n1\n2\n3\n4\n5\n6\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n",
             ],
             [
-                "--- Original\n+++ New\n@@ @@\n #Warning: Strings contain different line endings!\n-<?php\r\n+<?php\n",
+                "--- Original\n+++ New\n@@ @@\n #Warning: Strings contain different line endings!\n-<?php\r\n+<?php\n A\n",
                 "<?php\r\nA\n",
                 "<?php\nA\n",
             ],
             [
-                "--- Original\n+++ New\n@@ @@\n #Warning: Strings contain different line endings!\n-a\r\n+\n+c\r",
+                "--- Original\n+++ New\n@@ @@\n #Warning: Strings contain different line endings!\n-a\r\n+\n+c\r\n",
                 "a\r\n",
                 "\nc\r",
             ],
         ];
     }
 
-    public function diffProvider(): array
+    public function testDiffToArrayInvalidFromType(): void
     {
-        $serialized_arr = <<<EOL
-a:1:{i:0;O:27:"SebastianBergmann\Diff\Diff":3:{s:33:"SebastianBergmann\Diff\Difffrom";s:7:"old.txt";s:31:"SebastianBergmann\Diff\Diffto";s:7:"new.txt";s:35:"SebastianBergmann\Diff\Diffchunks";a:3:{i:0;O:28:"SebastianBergmann\Diff\Chunk":5:{s:35:"SebastianBergmann\Diff\Chunkstart";i:1;s:40:"SebastianBergmann\Diff\ChunkstartRange";i:3;s:33:"SebastianBergmann\Diff\Chunkend";i:1;s:38:"SebastianBergmann\Diff\ChunkendRange";i:4;s:35:"SebastianBergmann\Diff\Chunklines";a:4:{i:0;O:27:"SebastianBergmann\Diff\Line":2:{s:33:"SebastianBergmann\Diff\Linetype";i:1;s:36:"SebastianBergmann\Diff\Linecontent";s:7:"2222111";}i:1;O:27:"SebastianBergmann\Diff\Line":2:{s:33:"SebastianBergmann\Diff\Linetype";i:3;s:36:"SebastianBergmann\Diff\Linecontent";s:7:"1111111";}i:2;O:27:"SebastianBergmann\Diff\Line":2:{s:33:"SebastianBergmann\Diff\Linetype";i:3;s:36:"SebastianBergmann\Diff\Linecontent";s:7:"1111111";}i:3;O:27:"SebastianBergmann\Diff\Line":2:{s:33:"SebastianBergmann\Diff\Linetype";i:3;s:36:"SebastianBergmann\Diff\Linecontent";s:7:"1111111";}}}i:1;O:28:"SebastianBergmann\Diff\Chunk":5:{s:35:"SebastianBergmann\Diff\Chunkstart";i:5;s:40:"SebastianBergmann\Diff\ChunkstartRange";i:10;s:33:"SebastianBergmann\Diff\Chunkend";i:6;s:38:"SebastianBergmann\Diff\ChunkendRange";i:8;s:35:"SebastianBergmann\Diff\Chunklines";a:11:{i:0;O:27:"SebastianBergmann\Diff\Line":2:{s:33:"SebastianBergmann\Diff\Linetype";i:3;s:36:"SebastianBergmann\Diff\Linecontent";s:7:"1111111";}i:1;O:27:"SebastianBergmann\Diff\Line":2:{s:33:"SebastianBergmann\Diff\Linetype";i:3;s:36:"SebastianBergmann\Diff\Linecontent";s:7:"1111111";}i:2;O:27:"SebastianBergmann\Diff\Line":2:{s:33:"SebastianBergmann\Diff\Linetype";i:3;s:36:"SebastianBergmann\Diff\Linecontent";s:7:"1111111";}i:3;O:27:"SebastianBergmann\Diff\Line":2:{s:33:"SebastianBergmann\Diff\Linetype";i:3;s:36:"SebastianBergmann\Diff\Linecontent";s:8:"+1121211";}i:4;O:27:"SebastianBergmann\Diff\Line":2:{s:33:"SebastianBergmann\Diff\Linetype";i:3;s:36:"SebastianBergmann\Diff\Linecontent";s:7:"1111111";}i:5;O:27:"SebastianBergmann\Diff\Line":2:{s:33:"SebastianBergmann\Diff\Linetype";i:3;s:36:"SebastianBergmann\Diff\Linecontent";s:8:"-1111111";}i:6;O:27:"SebastianBergmann\Diff\Line":2:{s:33:"SebastianBergmann\Diff\Linetype";i:3;s:36:"SebastianBergmann\Diff\Linecontent";s:8:"-1111111";}i:7;O:27:"SebastianBergmann\Diff\Line":2:{s:33:"SebastianBergmann\Diff\Linetype";i:3;s:36:"SebastianBergmann\Diff\Linecontent";s:8:"-2222222";}i:8;O:27:"SebastianBergmann\Diff\Line":2:{s:33:"SebastianBergmann\Diff\Linetype";i:3;s:36:"SebastianBergmann\Diff\Linecontent";s:7:"2222222";}i:9;O:27:"SebastianBergmann\Diff\Line":2:{s:33:"SebastianBergmann\Diff\Linetype";i:3;s:36:"SebastianBergmann\Diff\Linecontent";s:7:"2222222";}i:10;O:27:"SebastianBergmann\Diff\Line":2:{s:33:"SebastianBergmann\Diff\Linetype";i:3;s:36:"SebastianBergmann\Diff\Linecontent";s:7:"2222222";}}}i:2;O:28:"SebastianBergmann\Diff\Chunk":5:{s:35:"SebastianBergmann\Diff\Chunkstart";i:17;s:40:"SebastianBergmann\Diff\ChunkstartRange";i:5;s:33:"SebastianBergmann\Diff\Chunkend";i:16;s:38:"SebastianBergmann\Diff\ChunkendRange";i:6;s:35:"SebastianBergmann\Diff\Chunklines";a:6:{i:0;O:27:"SebastianBergmann\Diff\Line":2:{s:33:"SebastianBergmann\Diff\Linetype";i:3;s:36:"SebastianBergmann\Diff\Linecontent";s:7:"2222222";}i:1;O:27:"SebastianBergmann\Diff\Line":2:{s:33:"SebastianBergmann\Diff\Linetype";i:3;s:36:"SebastianBergmann\Diff\Linecontent";s:7:"2222222";}i:2;O:27:"SebastianBergmann\Diff\Line":2:{s:33:"SebastianBergmann\Diff\Linetype";i:3;s:36:"SebastianBergmann\Diff\Linecontent";s:7:"2222222";}i:3;O:27:"SebastianBergmann\Diff\Line":2:{s:33:"SebastianBergmann\Diff\Linetype";i:3;s:36:"SebastianBergmann\Diff\Linecontent";s:8:"+2122212";}i:4;O:27:"SebastianBergmann\Diff\Line":2:{s:33:"SebastianBergmann\Diff\Linetype";i:3;s:36:"SebastianBergmann\Diff\Linecontent";s:7:"2222222";}i:5;O:27:"SebastianBergmann\Diff\Line":2:{s:33:"SebastianBergmann\Diff\Linetype";i:3;s:36:"SebastianBergmann\Diff\Linecontent";s:7:"2222222";}}}}}}
-EOL;
-
-        return [
-            [
-                "--- old.txt	2014-11-04 08:51:02.661868729 +0300\n+++ new.txt	2014-11-04 08:51:02.665868730 +0300\n@@ -1,3 +1,4 @@\n+2222111\n 1111111\n 1111111\n 1111111\n@@ -5,10 +6,8 @@\n 1111111\n 1111111\n 1111111\n +1121211\n 1111111\n -1111111\n -1111111\n -2222222\n 2222222\n 2222222\n 2222222\n@@ -17,5 +16,6 @@\n 2222222\n 2222222\n 2222222\n +2122212\n 2222222\n 2222222\n",
-                \unserialize($serialized_arr)
-            ]
-        ];
-    }
-
-    /**
-     * @param string $expected
-     * @param string $from
-     * @param string $to
-     * @param string $header
-     * @dataProvider textForNoNonDiffLinesProvider
-     */
-    public function testDiffDoNotShowNonDiffLines(string $expected, string $from, string $to, string $header = '')
-    {
-        $differ = new Differ(new DiffOnlyOutputBuilder($header));
-
-        $this->assertSame($expected, $differ->diff($from, $to));
-    }
-
-    public function textForNoNonDiffLinesProvider(): array
-    {
-        return [
-            [
-                " #Warning: Strings contain different line endings!\n-A\r\n+B\n",
-                "A\r\n",
-                "B\n",
-            ],
-            [
-                "-A\n+B\n",
-                "\nA",
-                "\nB"
-            ],
-            [
-                '',
-                'a',
-                'a'
-            ],
-            [
-                "-A\n+C\n",
-                "A\n\n\nB",
-                "C\n\n\nB",
-            ],
-            [
-                "header\n",
-                'a',
-                'a',
-                'header'
-            ],
-            [
-                "header\n",
-                'a',
-                'a',
-                "header\n"
-            ],
-        ];
-    }
-
-    public function testDiffToArrayInvalidFromType()
-    {
-        $this->expectException('\InvalidArgumentException');
-        $this->expectExceptionMessageRegExp('#^"from" must be an array or string\.$#');
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessageMatches('#^"from" must be an array or string\.$#');
 
         $this->differ->diffToArray(null, '');
     }
 
-    public function testDiffInvalidToType()
-    {
-        $this->expectException('\InvalidArgumentException');
-        $this->expectExceptionMessageRegExp('#^"to" must be an array or string\.$#');
-
-        $this->differ->diffToArray('', new \stdClass);
-    }
-
-    /**
-     * @param array  $expected
-     * @param string $from
-     * @param string $to
-     * @param int    $lineThreshold
-     * @dataProvider provideGetCommonChunks
-     */
-    public function testGetCommonChunks(array $expected, string $from, string $to, int $lineThreshold = 5)
+    public function testDiffInvalidToType(): void
     {
-        $output = new class extends AbstractChunkOutputBuilder {
-            public function getDiff(array $diff): string
-            {
-                return '';
-            }
-
-            public function getChunks(array $diff, $lineThreshold)
-            {
-                return $this->getCommonChunks($diff, $lineThreshold);
-            }
-        };
-
-        $this->assertSame(
-            $expected,
-            $output->getChunks($this->differ->diffToArray($from, $to), $lineThreshold)
-        );
-    }
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessageMatches('#^"to" must be an array or string\.$#');
 
-    public function provideGetCommonChunks(): array
-    {
-        return[
-            'same (with default threshold)' => [
-                [],
-                'A',
-                'A',
-            ],
-            'same (threshold 0)' => [
-                [0 => 0],
-                'A',
-                'A',
-                0,
-            ],
-            'empty' => [
-                [],
-                '',
-                '',
-            ],
-            'single line diff' => [
-                [],
-                'A',
-                'B',
-            ],
-            'below threshold I' => [
-                [],
-                "A\nX\nC",
-                "A\nB\nC",
-            ],
-            'below threshold II' => [
-                [],
-                "A\n\n\n\nX\nC",
-                "A\n\n\n\nB\nC",
-            ],
-            'below threshold III' => [
-                [0 => 5],
-                "A\n\n\n\n\n\nB",
-                "A\n\n\n\n\n\nA",
-            ],
-            'same start' => [
-                [0 => 5],
-                "A\n\n\n\n\n\nX\nC",
-                "A\n\n\n\n\n\nB\nC",
-            ],
-            'same start long' => [
-                [0 => 13],
-                "\n\n\n\n\n\n\n\n\n\n\n\n\n\nA",
-                "\n\n\n\n\n\n\n\n\n\n\n\n\n\nB",
-            ],
-            'same part in between' => [
-                [2 => 8],
-                "A\n\n\n\n\n\n\nX\nY\nZ\n\n",
-                "B\n\n\n\n\n\n\nX\nA\nZ\n\n",
-            ],
-            'same trailing' => [
-                [2 => 14],
-                "A\n\n\n\n\n\n\n\n\n\n\n\n\n\n",
-                "B\n\n\n\n\n\n\n\n\n\n\n\n\n\n",
-            ],
-            'same part in between, same trailing' => [
-                [2 => 7, 10 => 15],
-                "A\n\n\n\n\n\n\nA\n\n\n\n\n\n\n",
-                "B\n\n\n\n\n\n\nB\n\n\n\n\n\n\n",
-            ],
-            'below custom threshold I' => [
-                [],
-                "A\n\nB",
-                "A\n\nD",
-                2
-            ],
-            'custom threshold I' => [
-                [0 => 1],
-                "A\n\nB",
-                "A\n\nD",
-                1
-            ],
-            'custom threshold II' => [
-                [],
-                "A\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",
-                "A\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",
-                19
-            ],
-            [
-                [3 => 9],
-                "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk",
-                "a\np\nc\nd\ne\nf\ng\nh\ni\nw\nk",
-            ],
-            [
-                [0 => 5, 8 => 13],
-                "A\nA\nA\nA\nA\nA\nX\nC\nC\nC\nC\nC\nC",
-                "A\nA\nA\nA\nA\nA\nB\nC\nC\nC\nC\nC\nC",
-            ],
-            [
-                [0 => 5, 8 => 13],
-                "A\nA\nA\nA\nA\nA\nX\nC\nC\nC\nC\nC\nC\nX",
-                "A\nA\nA\nA\nA\nA\nB\nC\nC\nC\nC\nC\nC\nY",
-            ],
-        ];
+        $this->differ->diffToArray('', new stdClass);
     }
 
     /**
-     * @param array  $expected
-     * @param string $input
      * @dataProvider provideSplitStringByLinesCases
      */
-    public function testSplitStringByLines(array $expected, string $input)
+    public function testSplitStringByLines(array $expected, string $input): void
     {
-        $reflection = new \ReflectionObject($this->differ);
+        $reflection = new ReflectionObject($this->differ);
         $method     = $reflection->getMethod('splitStringByLines');
         $method->setAccessible(true);
 
@@ -637,42 +353,42 @@ public function provideSplitStringByLinesCases(): array
         return [
             [
                 [],
-                ''
+                '',
             ],
             [
                 ['a'],
-                'a'
+                'a',
             ],
             [
                 ["a\n"],
-                "a\n"
+                "a\n",
             ],
             [
                 ["a\r"],
-                "a\r"
+                "a\r",
             ],
             [
                 ["a\r\n"],
-                "a\r\n"
+                "a\r\n",
             ],
             [
                 ["\n"],
-                "\n"
+                "\n",
             ],
             [
                 ["\r"],
-                "\r"
+                "\r",
             ],
             [
                 ["\r\n"],
-                "\r\n"
+                "\r\n",
             ],
             [
                 [
                     "A\n",
                     "B\n",
                     "\n",
-                    "C\n"
+                    "C\n",
                 ],
                 "A\nB\n\nC\n",
             ],
@@ -681,7 +397,7 @@ public function provideSplitStringByLinesCases(): array
                     "A\r\n",
                     "B\n",
                     "\n",
-                    "C\r"
+                    "C\r",
                 ],
                 "A\r\nB\n\nC\r",
             ],
@@ -691,352 +407,26 @@ public function provideSplitStringByLinesCases(): array
                     "A\r\n",
                     "B\n",
                     "\n",
-                    'C'
+                    'C',
                 ],
                 "\nA\r\nB\n\nC",
             ],
         ];
     }
 
-    /**
-     * @param string $expected
-     * @param string $from
-     * @param string $to
-     * @dataProvider provideDiffWithLineNumbers
-     */
-    public function testDiffWithLineNumbers($expected, $from, $to)
-    {
-        $differ = new Differ(new UnifiedDiffOutputBuilder("--- Original\n+++ New\n", true));
-        $this->assertSame($expected, $differ->diff($from, $to));
-    }
-
-    public function provideDiffWithLineNumbers(): array
-    {
-        return [
-            'diff line 1 non_patch_compat' => [
-                '--- Original
-+++ New
-@@ -1 +1 @@
--AA
-+BA
-',
-                'AA',
-                'BA',
-            ],
-            'diff line +1 non_patch_compat' => [
-                '--- Original
-+++ New
-@@ -1 +1,2 @@
--AZ
-+
-+B
-',
-                'AZ',
-                "\nB",
-            ],
-            'diff line -1 non_patch_compat' => [
-                '--- Original
-+++ New
-@@ -1,2 +1 @@
--
--AF
-+B
-',
-                "\nAF",
-                'B',
-            ],
-            'II non_patch_compat' => [
-                '--- Original
-+++ New
-@@ -1,2 +1 @@
--
--
-'
-                ,
-                "\n\nA\n1",
-                "A\n1",
-            ],
-            'diff last line II - no trailing linebreak non_patch_compat' => [
-                '--- Original
-+++ New
-@@ -8 +8 @@
--E
-+B
-',
-                "A\n\n\n\n\n\n\nE",
-                "A\n\n\n\n\n\n\nB",
-            ],
-            [
-                "--- Original\n+++ New\n@@ -1,2 +1 @@\n \n-\n",
-                "\n\n",
-                "\n",
-            ],
-            'diff line endings non_patch_compat' => [
-                "--- Original\n+++ New\n@@ -1 +1 @@\n #Warning: Strings contain different line endings!\n-<?php\r\n+<?php\n",
-                "<?php\r\n",
-                "<?php\n",
-            ],
-            'same non_patch_compat' => [
-                '--- Original
-+++ New
-',
-                "AT\n",
-                "AT\n",
-            ],
-            [
-                '--- Original
-+++ New
-@@ -1 +1 @@
--b
-+a
-',
-                "b\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",
-                "a\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n"
-            ],
-            'diff line @1' => [
-                '--- Original
-+++ New
-@@ -1,2 +1,2 @@
- ' . '
--AG
-+B
-',
-                "\nAG\n",
-                "\nB\n",
-            ],
-            'same multiple lines' => [
-                '--- Original
-+++ New
-@@ -1,3 +1,3 @@
- ' . '
- ' . '
--V
-+B
-'
-
-                ,
-                "\n\nV\nC213",
-                "\n\nB\nC213",
-            ],
-            'diff last line I' => [
-                '--- Original
-+++ New
-@@ -8 +8 @@
--E
-+B
-',
-                "A\n\n\n\n\n\n\nE\n",
-                "A\n\n\n\n\n\n\nB\n",
-            ],
-            'diff line middle' => [
-                '--- Original
-+++ New
-@@ -8 +8 @@
--X
-+Z
-',
-                "A\n\n\n\n\n\n\nX\n\n\n\n\n\n\nAY",
-                "A\n\n\n\n\n\n\nZ\n\n\n\n\n\n\nAY",
-            ],
-            'diff last line III' => [
-                '--- Original
-+++ New
-@@ -15 +15 @@
--A
-+B
-',
-                "A\n\n\n\n\n\n\nA\n\n\n\n\n\n\nA\n",
-                "A\n\n\n\n\n\n\nA\n\n\n\n\n\n\nB\n",
-            ],
-            [
-                '--- Original
-+++ New
-@@ -1,7 +1,7 @@
- A
--B
-+B1
- D
- E
- EE
- F
--G
-+G1
-',
-                "A\nB\nD\nE\nEE\nF\nG\nH",
-                "A\nB1\nD\nE\nEE\nF\nG1\nH",
-            ],
-            [
-                '--- Original
-+++ New
-@@ -1 +1,2 @@
- Z
-+
-@@ -10 +11 @@
--i
-+x
-',
-                'Z
-a
-b
-c
-d
-e
-f
-g
-h
-i
-j',
-                'Z
-
-a
-b
-c
-d
-e
-f
-g
-h
-x
-j'
-            ],
-            [
-                '--- Original
-+++ New
-@@ -1,5 +1,3 @@
--
--a
-+b
- A
--a
--
-+b
-',
-                "\na\nA\na\n\n\nA",
-                "b\nA\nb\n\nA"
-            ],
-            [
-                <<<EOF
---- Original
-+++ New
-@@ -1,4 +1,2 @@
--
--
- a
--b
-+p
-@@ -12 +10 @@
--j
-+w
-
-EOF
-                ,
-                "\n\na\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk",
-                "a\np\nc\nd\ne\nf\ng\nh\ni\nw\nk",
-            ],
-            [
-                '--- Original
-+++ New
-@@ -11 +11 @@
--A
-+C
-',
-                "E\n\n\n\n\nB\n\n\n\n\nA\n\n\n\n\n\n\n\n\nD1",
-                "E\n\n\n\n\nB\n\n\n\n\nC\n\n\n\n\n\n\n\n\nD1",
-            ],
-            [
-                '--- Original
-+++ New
-@@ -8 +8 @@
--Z
-+U
-@@ -15 +15 @@
--X
-+V
-@@ -22 +22 @@
--Y
-+W
-@@ -29 +29 @@
--W
-+X
-@@ -36 +36 @@
--V
-+Y
-@@ -43 +43 @@
--U
-+Z
-',
-                "\n\n\n\n\n\n\nZ\n\n\n\n\n\n\nX\n\n\n\n\n\n\nY\n\n\n\n\n\n\nW\n\n\n\n\n\n\nV\n\n\n\n\n\n\nU\n",
-                "\n\n\n\n\n\n\nU\n\n\n\n\n\n\nV\n\n\n\n\n\n\nW\n\n\n\n\n\n\nX\n\n\n\n\n\n\nY\n\n\n\n\n\n\nZ\n"
-            ],
-            [
-                <<<EOF
---- Original
-+++ New
-@@ -1,2 +1,2 @@
- a
--b
-+p
-@@ -10 +10 @@
--j
-+w
-
-EOF
-            ,
-                "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk",
-                "a\np\nc\nd\ne\nf\ng\nh\ni\nw\nk",
-            ],
-            [
-                <<<EOF
---- Original
-+++ New
-@@ -1 +1 @@
--A
-+B
-
-EOF
-            ,
-                "A\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1",
-                "B\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1",
-            ],
-            [
-                "--- Original\n+++ New\n@@ -7 +7 @@\n-X\n+B\n",
-                "A\nA\nA\nA\nA\nA\nX\nC\nC\nC\nC\nC\nC",
-                "A\nA\nA\nA\nA\nA\nB\nC\nC\nC\nC\nC\nC",
-            ],
-        ];
-    }
-
-    public function testConstructorNull()
-    {
-        $this->assertAttributeInstanceOf(
-            UnifiedDiffOutputBuilder::class,
-            'outputBuilder',
-            new Differ(null)
-        );
-    }
-
-    public function testConstructorString()
-    {
-        $this->assertAttributeInstanceOf(
-            UnifiedDiffOutputBuilder::class,
-            'outputBuilder',
-            new Differ("--- Original\n+++ New\n")
-        );
-    }
-
-    public function testConstructorInvalidArgInt()
+    public function testConstructorInvalidArgInt(): void
     {
         $this->expectException(InvalidArgumentException::class);
-        $this->expectExceptionMessageRegExp('/^Expected builder to be an instance of DiffOutputBuilderInterface, <null> or a string, got integer "1"\.$/');
+        $this->expectExceptionMessageMatches('/^Expected builder to be an instance of DiffOutputBuilderInterface, <null> or a string, got integer "1"\.$/');
 
         new Differ(1);
     }
 
-    public function testConstructorInvalidArgObject()
+    public function testConstructorInvalidArgObject(): void
     {
         $this->expectException(InvalidArgumentException::class);
-        $this->expectExceptionMessageRegExp('/^Expected builder to be an instance of DiffOutputBuilderInterface, <null> or a string, got instance of "SplFileInfo"\.$/');
+        $this->expectExceptionMessageMatches('/^Expected builder to be an instance of DiffOutputBuilderInterface, <null> or a string, got instance of "SplFileInfo"\.$/');
 
-        new Differ(new \SplFileInfo(__FILE__));
+        new Differ(new SplFileInfo(__FILE__));
     }
 }
diff --git a/tests/DifferTestTest.php b/tests/DifferTestTest.php
deleted file mode 100644
index af60e26d..00000000
--- a/tests/DifferTestTest.php
+++ /dev/null
@@ -1,83 +0,0 @@
-<?php declare(strict_types=1);
-/*
- * This file is part of sebastian/diff.
- *
- * (c) Sebastian Bergmann <sebastian@phpunit.de>
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-namespace SebastianBergmann\Diff;
-
-use PHPUnit\Framework\TestCase;
-
-/**
- * @requires OS Linux
- */
-final class DifferTestTest extends TestCase
-{
-    private $fileFrom;
-    private $filePatch;
-
-    protected function setUp()
-    {
-        $dir             = \realpath(__DIR__ . '/../') . '/';
-        $this->fileFrom  = $dir . 'from.txt';
-        $this->filePatch = $dir . 'patch.txt';
-    }
-
-    /**
-     * @dataProvider provideDiffWithLineNumbers
-     */
-    public function testTheTestProvideDiffWithLineNumbers($expected, $from, $to)
-    {
-        $this->runThisTest($expected, $from, $to);
-    }
-
-    public function provideDiffWithLineNumbers()
-    {
-        require_once __DIR__ . '/DifferTest.php';
-        $test  = new DifferTest();
-        $tests = $test->provideDiffWithLineNumbers();
-
-        $tests = \array_filter(
-            $tests,
-            function ($key) {
-                return !\is_string($key) || false === \strpos($key, 'non_patch_compat');
-            },
-            ARRAY_FILTER_USE_KEY
-        );
-
-        return $tests;
-    }
-
-    private function runThisTest(string $expected, string $from, string $to)
-    {
-        $expected = \str_replace('--- Original', '--- from.txt', $expected);
-        $expected = \str_replace('+++ New', '+++ from.txt', $expected);
-
-        @\unlink($this->fileFrom);
-        @\unlink($this->filePatch);
-
-        $this->assertNotFalse(\file_put_contents($this->fileFrom, $from));
-        $this->assertNotFalse(\file_put_contents($this->filePatch, $expected));
-
-        $command = \sprintf(
-            'patch -u --verbose %s < %s', // --posix
-            \escapeshellarg($this->fileFrom),
-            \escapeshellarg($this->filePatch)
-        );
-
-        \exec($command, $output, $d);
-
-        $this->assertSame(0, $d, \sprintf('%s | %s', $command, \implode("\n", $output)));
-
-        $patched = \file_get_contents($this->fileFrom);
-        $this->assertSame($patched, $to);
-
-        @\unlink($this->fileFrom . '.orig');
-        @\unlink($this->fileFrom);
-        @\unlink($this->filePatch);
-    }
-}
diff --git a/tests/Exception/ConfigurationExceptionTest.php b/tests/Exception/ConfigurationExceptionTest.php
new file mode 100644
index 00000000..6dcebad9
--- /dev/null
+++ b/tests/Exception/ConfigurationExceptionTest.php
@@ -0,0 +1,42 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of sebastian/diff.
+ *
+ * (c) Sebastian Bergmann <sebastian@phpunit.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+namespace SebastianBergmann\Diff;
+
+use BadMethodCallException;
+use PHPUnit\Framework\TestCase;
+use SplFileInfo;
+
+/**
+ * @covers \SebastianBergmann\Diff\ConfigurationException
+ */
+final class ConfigurationExceptionTest extends TestCase
+{
+    public function testConstructWithDefaults(): void
+    {
+        $e = new ConfigurationException('test', 'A', 'B');
+
+        $this->assertSame(0, $e->getCode());
+        $this->assertNull($e->getPrevious());
+        $this->assertSame('Option "test" must be A, got "string#B".', $e->getMessage());
+    }
+
+    public function testConstruct(): void
+    {
+        $e = new ConfigurationException(
+            'test',
+            'integer',
+            new SplFileInfo(__FILE__),
+            789,
+            new BadMethodCallException(__METHOD__)
+        );
+
+        $this->assertSame('Option "test" must be integer, got "SplFileInfo".', $e->getMessage());
+    }
+}
diff --git a/tests/Exception/InvalidArgumentExceptionTest.php b/tests/Exception/InvalidArgumentExceptionTest.php
new file mode 100644
index 00000000..ff29b4c7
--- /dev/null
+++ b/tests/Exception/InvalidArgumentExceptionTest.php
@@ -0,0 +1,33 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of sebastian/diff.
+ *
+ * (c) Sebastian Bergmann <sebastian@phpunit.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+namespace SebastianBergmann\Diff;
+
+use LogicException;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \SebastianBergmann\Diff\InvalidArgumentException
+ */
+final class InvalidArgumentExceptionTest extends TestCase
+{
+    public function testInvalidArgumentException(): void
+    {
+        $previousException = new LogicException();
+        $message           = 'test';
+        $code              = 123;
+
+        $exception = new InvalidArgumentException($message, $code, $previousException);
+
+        $this->assertInstanceOf(Exception::class, $exception);
+        $this->assertSame($message, $exception->getMessage());
+        $this->assertSame($code, $exception->getCode());
+        $this->assertSame($previousException, $exception->getPrevious());
+    }
+}
diff --git a/tests/LineTest.php b/tests/LineTest.php
index 7239d664..20a398ca 100644
--- a/tests/LineTest.php
+++ b/tests/LineTest.php
@@ -7,13 +7,12 @@
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
  */
-
 namespace SebastianBergmann\Diff;
 
 use PHPUnit\Framework\TestCase;
 
 /**
- * @covers SebastianBergmann\Diff\Line
+ * @covers \SebastianBergmann\Diff\Line
  */
 final class LineTest extends TestCase
 {
@@ -22,22 +21,22 @@ final class LineTest extends TestCase
      */
     private $line;
 
-    protected function setUp()
+    protected function setUp(): void
     {
         $this->line = new Line;
     }
 
-    public function testCanBeCreatedWithoutArguments()
+    public function testCanBeCreatedWithoutArguments(): void
     {
         $this->assertInstanceOf(Line::class, $this->line);
     }
 
-    public function testTypeCanBeRetrieved()
+    public function testTypeCanBeRetrieved(): void
     {
         $this->assertSame(Line::UNCHANGED, $this->line->getType());
     }
 
-    public function testContentCanBeRetrieved()
+    public function testContentCanBeRetrieved(): void
     {
         $this->assertSame('', $this->line->getContent());
     }
diff --git a/tests/LongestCommonSubsequenceTest.php b/tests/LongestCommonSubsequenceTest.php
index a2581e85..aab8db30 100644
--- a/tests/LongestCommonSubsequenceTest.php
+++ b/tests/LongestCommonSubsequenceTest.php
@@ -7,9 +7,13 @@
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
  */
-
 namespace SebastianBergmann\Diff;
 
+use function array_reverse;
+use function array_slice;
+use function ini_get;
+use function ini_set;
+use function range;
 use PHPUnit\Framework\TestCase;
 
 /**
@@ -32,25 +36,20 @@ abstract class LongestCommonSubsequenceTest extends TestCase
      */
     private $stress_sizes = [1, 2, 3, 100, 500, 1000, 2000];
 
-    protected function setUp()
+    protected function setUp(): void
     {
-        $this->memoryLimit = \ini_get('memory_limit');
-        \ini_set('memory_limit', '256M');
+        $this->memoryLimit = ini_get('memory_limit');
+        ini_set('memory_limit', '-1');
 
         $this->implementation = $this->createImplementation();
     }
 
-    /**
-     * @return LongestCommonSubsequenceCalculator
-     */
-    abstract protected function createImplementation();
-
-    protected function tearDown()
+    protected function tearDown(): void
     {
-        \ini_set('memory_limit', $this->memoryLimit);
+        ini_set('memory_limit', $this->memoryLimit);
     }
 
-    public function testBothEmpty()
+    public function testBothEmpty(): void
     {
         $from   = [];
         $to     = [];
@@ -59,11 +58,11 @@ public function testBothEmpty()
         $this->assertSame([], $common);
     }
 
-    public function testIsStrictComparison()
+    public function testIsStrictComparison(): void
     {
         $from = [
             false, 0, 0.0, '', null, [],
-            true, 1, 1.0, 'foo', ['foo', 'bar'], ['foo' => 'bar']
+            true, 1, 1.0, 'foo', ['foo', 'bar'], ['foo' => 'bar'],
         ];
         $to     = $from;
         $common = $this->implementation->calculate($from, $to);
@@ -72,7 +71,7 @@ public function testIsStrictComparison()
 
         $to = [
             false, false, false, false, false, false,
-            true, true, true, true, true, true
+            true, true, true, true, true, true,
         ];
 
         $expected = [
@@ -85,10 +84,10 @@ public function testIsStrictComparison()
         $this->assertSame($expected, $common);
     }
 
-    public function testEqualSequences()
+    public function testEqualSequences(): void
     {
         foreach ($this->stress_sizes as $size) {
-            $range  = \range(1, $size);
+            $range  = range(1, $size);
             $from   = $range;
             $to     = $range;
             $common = $this->implementation->calculate($from, $to);
@@ -97,7 +96,7 @@ public function testEqualSequences()
         }
     }
 
-    public function testDistinctSequences()
+    public function testDistinctSequences(): void
     {
         $from   = ['A'];
         $to     = ['B'];
@@ -110,14 +109,14 @@ public function testDistinctSequences()
         $this->assertSame([], $common);
 
         foreach ($this->stress_sizes as $size) {
-            $from   = \range(1, $size);
-            $to     = \range($size + 1, $size * 2);
+            $from   = range(1, $size);
+            $to     = range($size + 1, $size * 2);
             $common = $this->implementation->calculate($from, $to);
             $this->assertSame([], $common);
         }
     }
 
-    public function testCommonSubsequence()
+    public function testCommonSubsequence(): void
     {
         $from     = ['A',      'C',      'E', 'F', 'G'];
         $to       = ['A', 'B',      'D', 'E',           'H'];
@@ -132,49 +131,49 @@ public function testCommonSubsequence()
         $this->assertSame($expected, $common);
 
         foreach ($this->stress_sizes as $size) {
-            $from     = $size < 2 ? [1] : \range(1, $size + 1, 2);
-            $to       = $size < 3 ? [1] : \range(1, $size + 1, 3);
-            $expected = $size < 6 ? [1] : \range(1, $size + 1, 6);
+            $from     = $size < 2 ? [1] : range(1, $size + 1, 2);
+            $to       = $size < 3 ? [1] : range(1, $size + 1, 3);
+            $expected = $size < 6 ? [1] : range(1, $size + 1, 6);
             $common   = $this->implementation->calculate($from, $to);
 
             $this->assertSame($expected, $common);
         }
     }
 
-    public function testSingleElementSubsequenceAtStart()
+    public function testSingleElementSubsequenceAtStart(): void
     {
         foreach ($this->stress_sizes as $size) {
-            $from   = \range(1, $size);
-            $to     = \array_slice($from, 0, 1);
+            $from   = range(1, $size);
+            $to     = array_slice($from, 0, 1);
             $common = $this->implementation->calculate($from, $to);
 
             $this->assertSame($to, $common);
         }
     }
 
-    public function testSingleElementSubsequenceAtMiddle()
+    public function testSingleElementSubsequenceAtMiddle(): void
     {
         foreach ($this->stress_sizes as $size) {
-            $from   = \range(1, $size);
-            $to     = \array_slice($from, (int) ($size / 2), 1);
+            $from   = range(1, $size);
+            $to     = array_slice($from, (int) ($size / 2), 1);
             $common = $this->implementation->calculate($from, $to);
 
             $this->assertSame($to, $common);
         }
     }
 
-    public function testSingleElementSubsequenceAtEnd()
+    public function testSingleElementSubsequenceAtEnd(): void
     {
         foreach ($this->stress_sizes as $size) {
-            $from   = \range(1, $size);
-            $to     = \array_slice($from, $size - 1, 1);
+            $from   = range(1, $size);
+            $to     = array_slice($from, $size - 1, 1);
             $common = $this->implementation->calculate($from, $to);
 
             $this->assertSame($to, $common);
         }
     }
 
-    public function testReversedSequences()
+    public function testReversedSequences(): void
     {
         $from     = ['A', 'B'];
         $to       = ['B', 'A'];
@@ -183,19 +182,21 @@ public function testReversedSequences()
         $this->assertSame($expected, $common);
 
         foreach ($this->stress_sizes as $size) {
-            $from   = \range(1, $size);
-            $to     = \array_reverse($from);
+            $from   = range(1, $size);
+            $to     = array_reverse($from);
             $common = $this->implementation->calculate($from, $to);
 
             $this->assertSame([1], $common);
         }
     }
 
-    public function testStrictTypeCalculate()
+    public function testStrictTypeCalculate(): void
     {
         $diff = $this->implementation->calculate(['5'], ['05']);
 
-        $this->assertInternalType('array', $diff);
+        $this->assertIsArray($diff);
         $this->assertCount(0, $diff);
     }
+
+    abstract protected function createImplementation(): LongestCommonSubsequenceCalculator;
 }
diff --git a/tests/MemoryEfficientImplementationTest.php b/tests/MemoryEfficientImplementationTest.php
index b5f7bbe4..63500425 100644
--- a/tests/MemoryEfficientImplementationTest.php
+++ b/tests/MemoryEfficientImplementationTest.php
@@ -7,15 +7,14 @@
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
  */
-
 namespace SebastianBergmann\Diff;
 
 /**
- * @covers SebastianBergmann\Diff\MemoryEfficientLongestCommonSubsequenceCalculator
+ * @covers \SebastianBergmann\Diff\MemoryEfficientLongestCommonSubsequenceCalculator
  */
 final class MemoryEfficientImplementationTest extends LongestCommonSubsequenceTest
 {
-    protected function createImplementation()
+    protected function createImplementation(): LongestCommonSubsequenceCalculator
     {
         return new MemoryEfficientLongestCommonSubsequenceCalculator;
     }
diff --git a/tests/Output/AbstractChunkOutputBuilderTest.php b/tests/Output/AbstractChunkOutputBuilderTest.php
new file mode 100644
index 00000000..b20f9815
--- /dev/null
+++ b/tests/Output/AbstractChunkOutputBuilderTest.php
@@ -0,0 +1,146 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of sebastian/diff.
+ *
+ * (c) Sebastian Bergmann <sebastian@phpunit.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+namespace SebastianBergmann\Diff\Output;
+
+use PHPUnit\Framework\TestCase;
+use SebastianBergmann\Diff\Differ;
+
+/**
+ * @covers \SebastianBergmann\Diff\Output\AbstractChunkOutputBuilder
+ *
+ * @uses \SebastianBergmann\Diff\Differ
+ * @uses \SebastianBergmann\Diff\Output\UnifiedDiffOutputBuilder
+ * @uses \SebastianBergmann\Diff\TimeEfficientLongestCommonSubsequenceCalculator
+ */
+final class AbstractChunkOutputBuilderTest extends TestCase
+{
+    /**
+     * @dataProvider provideGetCommonChunks
+     */
+    public function testGetCommonChunks(array $expected, string $from, string $to, int $lineThreshold = 5): void
+    {
+        $output = new class extends AbstractChunkOutputBuilder {
+            public function getDiff(array $diff): string
+            {
+                return '';
+            }
+
+            public function getChunks(array $diff, $lineThreshold)
+            {
+                return $this->getCommonChunks($diff, $lineThreshold);
+            }
+        };
+
+        $this->assertSame(
+            $expected,
+            $output->getChunks((new Differ)->diffToArray($from, $to), $lineThreshold)
+        );
+    }
+
+    public function provideGetCommonChunks(): array
+    {
+        return[
+            'same (with default threshold)' => [
+                [],
+                'A',
+                'A',
+            ],
+            'same (threshold 0)' => [
+                [0 => 0],
+                'A',
+                'A',
+                0,
+            ],
+            'empty' => [
+                [],
+                '',
+                '',
+            ],
+            'single line diff' => [
+                [],
+                'A',
+                'B',
+            ],
+            'below threshold I' => [
+                [],
+                "A\nX\nC",
+                "A\nB\nC",
+            ],
+            'below threshold II' => [
+                [],
+                "A\n\n\n\nX\nC",
+                "A\n\n\n\nB\nC",
+            ],
+            'below threshold III' => [
+                [0 => 5],
+                "A\n\n\n\n\n\nB",
+                "A\n\n\n\n\n\nA",
+            ],
+            'same start' => [
+                [0 => 5],
+                "A\n\n\n\n\n\nX\nC",
+                "A\n\n\n\n\n\nB\nC",
+            ],
+            'same start long' => [
+                [0 => 13],
+                "\n\n\n\n\n\n\n\n\n\n\n\n\n\nA",
+                "\n\n\n\n\n\n\n\n\n\n\n\n\n\nB",
+            ],
+            'same part in between' => [
+                [2 => 8],
+                "A\n\n\n\n\n\n\nX\nY\nZ\n\n",
+                "B\n\n\n\n\n\n\nX\nA\nZ\n\n",
+            ],
+            'same trailing' => [
+                [2 => 14],
+                "A\n\n\n\n\n\n\n\n\n\n\n\n\n\n",
+                "B\n\n\n\n\n\n\n\n\n\n\n\n\n\n",
+            ],
+            'same part in between, same trailing' => [
+                [2 => 7, 10 => 15],
+                "A\n\n\n\n\n\n\nA\n\n\n\n\n\n\n",
+                "B\n\n\n\n\n\n\nB\n\n\n\n\n\n\n",
+            ],
+            'below custom threshold I' => [
+                [],
+                "A\n\nB",
+                "A\n\nD",
+                2,
+            ],
+            'custom threshold I' => [
+                [0 => 1],
+                "A\n\nB",
+                "A\n\nD",
+                1,
+            ],
+            'custom threshold II' => [
+                [],
+                "A\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",
+                "A\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",
+                19,
+            ],
+            [
+                [3 => 9],
+                "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk",
+                "a\np\nc\nd\ne\nf\ng\nh\ni\nw\nk",
+            ],
+            [
+                [0 => 5, 8 => 13],
+                "A\nA\nA\nA\nA\nA\nX\nC\nC\nC\nC\nC\nC",
+                "A\nA\nA\nA\nA\nA\nB\nC\nC\nC\nC\nC\nC",
+            ],
+            [
+                [0 => 5, 8 => 13],
+                "A\nA\nA\nA\nA\nA\nX\nC\nC\nC\nC\nC\nC\nX",
+                "A\nA\nA\nA\nA\nA\nB\nC\nC\nC\nC\nC\nC\nY",
+            ],
+        ];
+    }
+}
diff --git a/tests/Output/DiffOnlyOutputBuilderTest.php b/tests/Output/DiffOnlyOutputBuilderTest.php
new file mode 100644
index 00000000..22e079a6
--- /dev/null
+++ b/tests/Output/DiffOnlyOutputBuilderTest.php
@@ -0,0 +1,70 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of sebastian/diff.
+ *
+ * (c) Sebastian Bergmann <sebastian@phpunit.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+namespace SebastianBergmann\Diff\Output;
+
+use PHPUnit\Framework\TestCase;
+use SebastianBergmann\Diff\Differ;
+
+/**
+ * @covers \SebastianBergmann\Diff\Output\DiffOnlyOutputBuilder
+ *
+ * @uses \SebastianBergmann\Diff\Differ
+ * @uses \SebastianBergmann\Diff\TimeEfficientLongestCommonSubsequenceCalculator
+ */
+final class DiffOnlyOutputBuilderTest extends TestCase
+{
+    /**
+     * @dataProvider textForNoNonDiffLinesProvider
+     */
+    public function testDiffDoNotShowNonDiffLines(string $expected, string $from, string $to, string $header = ''): void
+    {
+        $differ = new Differ(new DiffOnlyOutputBuilder($header));
+
+        $this->assertSame($expected, $differ->diff($from, $to));
+    }
+
+    public function textForNoNonDiffLinesProvider(): array
+    {
+        return [
+            [
+                " #Warning: Strings contain different line endings!\n-A\r\n+B\n",
+                "A\r\n",
+                "B\n",
+            ],
+            [
+                "-A\n+B\n",
+                "\nA",
+                "\nB",
+            ],
+            [
+                '',
+                'a',
+                'a',
+            ],
+            [
+                "-A\n+C\n",
+                "A\n\n\nB",
+                "C\n\n\nB",
+            ],
+            [
+                "header\n",
+                'a',
+                'a',
+                'header',
+            ],
+            [
+                "header\n",
+                'a',
+                'a',
+                "header\n",
+            ],
+        ];
+    }
+}
diff --git a/tests/Output/Integration/StrictUnifiedDiffOutputBuilderIntegrationTest.php b/tests/Output/Integration/StrictUnifiedDiffOutputBuilderIntegrationTest.php
new file mode 100644
index 00000000..3e56a732
--- /dev/null
+++ b/tests/Output/Integration/StrictUnifiedDiffOutputBuilderIntegrationTest.php
@@ -0,0 +1,303 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of sebastian/diff.
+ *
+ * (c) Sebastian Bergmann <sebastian@phpunit.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+namespace SebastianBergmann\Diff\Output;
+
+use const PREG_SPLIT_DELIM_CAPTURE;
+use const PREG_SPLIT_NO_EMPTY;
+use function file_put_contents;
+use function implode;
+use function is_dir;
+use function preg_replace;
+use function preg_split;
+use function realpath;
+use function sprintf;
+use function unlink;
+use PHPUnit\Framework\TestCase;
+use RecursiveDirectoryIterator;
+use RecursiveIteratorIterator;
+use RuntimeException;
+use SebastianBergmann\Diff\Differ;
+use SebastianBergmann\Diff\Utils\FileUtils;
+use SebastianBergmann\Diff\Utils\UnifiedDiffAssertTrait;
+use SplFileInfo;
+use Symfony\Component\Process\Process;
+
+/**
+ * @covers \SebastianBergmann\Diff\Output\StrictUnifiedDiffOutputBuilder
+ *
+ * @uses \SebastianBergmann\Diff\Differ
+ * @uses \SebastianBergmann\Diff\TimeEfficientLongestCommonSubsequenceCalculator
+ * @uses \SebastianBergmann\Diff\MemoryEfficientLongestCommonSubsequenceCalculator
+ *
+ * @requires OS Linux
+ */
+final class StrictUnifiedDiffOutputBuilderIntegrationTest extends TestCase
+{
+    use UnifiedDiffAssertTrait;
+
+    private $dir;
+
+    private $fileFrom;
+
+    private $fileTo;
+
+    private $filePatch;
+
+    protected function setUp(): void
+    {
+        $this->dir       = realpath(__DIR__ . '/../../fixtures/out') . '/';
+        $this->fileFrom  = $this->dir . 'from.txt';
+        $this->fileTo    = $this->dir . 'to.txt';
+        $this->filePatch = $this->dir . 'diff.patch';
+
+        if (!is_dir($this->dir)) {
+            throw new RuntimeException('Integration test working directory not found.');
+        }
+
+        $this->cleanUpTempFiles();
+    }
+
+    protected function tearDown(): void
+    {
+        $this->cleanUpTempFiles();
+    }
+
+    /**
+     * Integration test.
+     *
+     * - get a file pair
+     * - create a `diff` between the files
+     * - test applying the diff using `git apply`
+     * - test applying the diff using `patch`
+     *
+     * @dataProvider provideFilePairs
+     */
+    public function testIntegrationUsingPHPFileInVendorGitApply(string $fileFrom, string $fileTo): void
+    {
+        $from = FileUtils::getFileContent($fileFrom);
+        $to   = FileUtils::getFileContent($fileTo);
+
+        $diff = (new Differ(new StrictUnifiedDiffOutputBuilder(['fromFile' => 'Original', 'toFile' => 'New'])))->diff($from, $to);
+
+        if ('' === $diff && $from === $to) {
+            // odd case: test after executing as it is more efficient than to read the files and check the contents every time
+            $this->addToAssertionCount(1);
+
+            return;
+        }
+
+        $this->doIntegrationTestGitApply($diff, $from, $to);
+    }
+
+    /**
+     * Integration test.
+     *
+     * - get a file pair
+     * - create a `diff` between the files
+     * - test applying the diff using `git apply`
+     * - test applying the diff using `patch`
+     *
+     * @dataProvider provideFilePairs
+     */
+    public function testIntegrationUsingPHPFileInVendorPatch(string $fileFrom, string $fileTo): void
+    {
+        $from = FileUtils::getFileContent($fileFrom);
+        $to   = FileUtils::getFileContent($fileTo);
+
+        $diff = (new Differ(new StrictUnifiedDiffOutputBuilder(['fromFile' => 'Original', 'toFile' => 'New'])))->diff($from, $to);
+
+        if ('' === $diff && $from === $to) {
+            // odd case: test after executing as it is more efficient than to read the files and check the contents every time
+            $this->addToAssertionCount(1);
+
+            return;
+        }
+
+        $this->doIntegrationTestPatch($diff, $from, $to);
+    }
+
+    /**
+     * @dataProvider provideOutputBuildingCases
+     * @dataProvider provideSample
+     * @dataProvider provideBasicDiffGeneration
+     */
+    public function testIntegrationOfUnitTestCasesGitApply(string $expected, string $from, string $to): void
+    {
+        $this->doIntegrationTestGitApply($expected, $from, $to);
+    }
+
+    /**
+     * @dataProvider provideOutputBuildingCases
+     * @dataProvider provideSample
+     * @dataProvider provideBasicDiffGeneration
+     */
+    public function testIntegrationOfUnitTestCasesPatch(string $expected, string $from, string $to): void
+    {
+        $this->doIntegrationTestPatch($expected, $from, $to);
+    }
+
+    public function provideOutputBuildingCases(): array
+    {
+        return StrictUnifiedDiffOutputBuilderDataProvider::provideOutputBuildingCases();
+    }
+
+    public function provideSample(): array
+    {
+        return StrictUnifiedDiffOutputBuilderDataProvider::provideSample();
+    }
+
+    public function provideBasicDiffGeneration(): array
+    {
+        return StrictUnifiedDiffOutputBuilderDataProvider::provideBasicDiffGeneration();
+    }
+
+    public function provideFilePairs(): array
+    {
+        $cases     = [];
+        $fromFile  = __FILE__;
+        $vendorDir = realpath(__DIR__ . '/../../../vendor');
+
+        $fileIterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($vendorDir, RecursiveDirectoryIterator::SKIP_DOTS));
+
+        /** @var SplFileInfo $file */
+        foreach ($fileIterator as $file) {
+            if ('php' !== $file->getExtension()) {
+                continue;
+            }
+
+            $toFile                                                                                         = $file->getPathname();
+            $cases[sprintf("Diff file:\n\"%s\"\nvs.\n\"%s\"\n", realpath($fromFile), realpath($toFile))]    = [$fromFile, $toFile];
+            $fromFile                                                                                       = $toFile;
+        }
+
+        return $cases;
+    }
+
+    /**
+     * Compare diff create by builder and against one create by `diff` command.
+     *
+     * @dataProvider provideBasicDiffGeneration
+     */
+    public function testIntegrationDiffOutputBuilderVersusDiffCommand(string $diff, string $from, string $to): void
+    {
+        $this->assertNotSame('', $diff);
+        $this->assertValidUnifiedDiffFormat($diff);
+
+        $this->assertNotFalse(file_put_contents($this->fileFrom, $from));
+        $this->assertNotFalse(file_put_contents($this->fileTo, $to));
+
+        $p = Process::fromShellCommandline('diff -u $from $to');
+        $p->run(
+            null,
+            [
+                'from' => $this->fileFrom,
+                'to'   => $this->fileTo,
+            ]
+        );
+
+        $this->assertSame(1, $p->getExitCode()); // note: Process assumes exit code 0 for `isSuccessful`, however `diff` uses the exit code `1` for success with diff
+
+        $output = $p->getOutput();
+
+        $diffLines    = preg_split('/(.*\R)/', $diff, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
+        $diffLines[0] = preg_replace('#^\-\-\- .*#', '--- /' . $this->fileFrom, $diffLines[0], 1);
+        $diffLines[1] = preg_replace('#^\+\+\+ .*#', '+++ /' . $this->fileFrom, $diffLines[1], 1);
+        $diff         = implode('', $diffLines);
+
+        $outputLines    = preg_split('/(.*\R)/', $output, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
+        $outputLines[0] = preg_replace('#^\-\-\- .*#', '--- /' . $this->fileFrom, $outputLines[0], 1);
+        $outputLines[1] = preg_replace('#^\+\+\+ .*#', '+++ /' . $this->fileFrom, $outputLines[1], 1);
+        $output         = implode('', $outputLines);
+
+        $this->assertSame($diff, $output);
+    }
+
+    private function doIntegrationTestGitApply(string $diff, string $from, string $to): void
+    {
+        $this->assertNotSame('', $diff);
+        $this->assertValidUnifiedDiffFormat($diff);
+
+        $diff = self::setDiffFileHeader($diff, $this->fileFrom);
+
+        $this->assertNotFalse(file_put_contents($this->fileFrom, $from));
+        $this->assertNotFalse(file_put_contents($this->filePatch, $diff));
+
+        $p = Process::fromShellCommandline('git --git-dir $dir apply --check -v --unsafe-paths --ignore-whitespace $patch');
+        $p->run(
+            null,
+            [
+                'dir'   => $this->dir,
+                'patch' => $this->filePatch,
+            ]
+        );
+
+        $this->assertProcessSuccessful($p);
+    }
+
+    private function doIntegrationTestPatch(string $diff, string $from, string $to): void
+    {
+        $this->assertNotSame('', $diff);
+        $this->assertValidUnifiedDiffFormat($diff);
+
+        $diff = self::setDiffFileHeader($diff, $this->fileFrom);
+
+        $this->assertNotFalse(file_put_contents($this->fileFrom, $from));
+        $this->assertNotFalse(file_put_contents($this->filePatch, $diff));
+
+        $p = Process::fromShellCommandline('patch -u --verbose --posix $from < $patch');
+        $p->run(
+            null,
+            [
+                'from'  => $this->fileFrom,
+                'patch' => $this->filePatch,
+            ]
+        );
+
+        $this->assertProcessSuccessful($p);
+
+        $this->assertStringEqualsFile(
+            $this->fileFrom,
+            $to,
+            sprintf('Patch command "%s".', $p->getCommandLine())
+        );
+    }
+
+    private function assertProcessSuccessful(Process $p): void
+    {
+        $this->assertTrue(
+            $p->isSuccessful(),
+            sprintf(
+                "Command exec. was not successful:\n\"%s\"\nOutput:\n\"%s\"\nStdErr:\n\"%s\"\nExit code %d.\n",
+                $p->getCommandLine(),
+                $p->getOutput(),
+                $p->getErrorOutput(),
+                $p->getExitCode()
+            )
+        );
+    }
+
+    private function cleanUpTempFiles(): void
+    {
+        @unlink($this->fileFrom . '.orig');
+        @unlink($this->fileFrom . '.rej');
+        @unlink($this->fileFrom);
+        @unlink($this->fileTo);
+        @unlink($this->filePatch);
+    }
+
+    private static function setDiffFileHeader(string $diff, string $file): string
+    {
+        $diffLines    = preg_split('/(.*\R)/', $diff, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
+        $diffLines[0] = preg_replace('#^\-\-\- .*#', '--- /' . $file, $diffLines[0], 1);
+        $diffLines[1] = preg_replace('#^\+\+\+ .*#', '+++ /' . $file, $diffLines[1], 1);
+
+        return implode('', $diffLines);
+    }
+}
diff --git a/tests/Output/Integration/UnifiedDiffOutputBuilderIntegrationTest.php b/tests/Output/Integration/UnifiedDiffOutputBuilderIntegrationTest.php
new file mode 100644
index 00000000..6a4da760
--- /dev/null
+++ b/tests/Output/Integration/UnifiedDiffOutputBuilderIntegrationTest.php
@@ -0,0 +1,167 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of sebastian/diff.
+ *
+ * (c) Sebastian Bergmann <sebastian@phpunit.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+namespace SebastianBergmann\Diff\Output;
+
+use const ARRAY_FILTER_USE_KEY;
+use const PREG_SPLIT_DELIM_CAPTURE;
+use const PREG_SPLIT_NO_EMPTY;
+use function array_filter;
+use function file_put_contents;
+use function implode;
+use function is_string;
+use function preg_replace;
+use function preg_split;
+use function realpath;
+use function sprintf;
+use function strpos;
+use function unlink;
+use PHPUnit\Framework\TestCase;
+use SebastianBergmann\Diff\Utils\UnifiedDiffAssertTrait;
+use Symfony\Component\Process\Process;
+
+/**
+ * @covers \SebastianBergmann\Diff\Output\UnifiedDiffOutputBuilder
+ *
+ * @uses \SebastianBergmann\Diff\Differ
+ * @uses \SebastianBergmann\Diff\TimeEfficientLongestCommonSubsequenceCalculator
+ *
+ * @requires OS Linux
+ */
+final class UnifiedDiffOutputBuilderIntegrationTest extends TestCase
+{
+    use UnifiedDiffAssertTrait;
+
+    private $dir;
+
+    private $fileFrom;
+
+    private $filePatch;
+
+    protected function setUp(): void
+    {
+        $this->dir       = realpath(__DIR__ . '/../../fixtures/out/') . '/';
+        $this->fileFrom  = $this->dir . 'from.txt';
+        $this->filePatch = $this->dir . 'patch.txt';
+
+        $this->cleanUpTempFiles();
+    }
+
+    protected function tearDown(): void
+    {
+        $this->cleanUpTempFiles();
+    }
+
+    /**
+     * @dataProvider provideDiffWithLineNumbers
+     */
+    public function testDiffWithLineNumbersPath($expected, $from, $to): void
+    {
+        $this->doIntegrationTestPatch($expected, $from, $to);
+    }
+
+    /**
+     * @dataProvider provideDiffWithLineNumbers
+     */
+    public function testDiffWithLineNumbersGitApply($expected, $from, $to): void
+    {
+        $this->doIntegrationTestGitApply($expected, $from, $to);
+    }
+
+    public function provideDiffWithLineNumbers()
+    {
+        return array_filter(
+            UnifiedDiffOutputBuilderDataProvider::provideDiffWithLineNumbers(),
+            static function ($key) {
+                return !is_string($key) || false === strpos($key, 'non_patch_compat');
+            },
+            ARRAY_FILTER_USE_KEY
+        );
+    }
+
+    private function doIntegrationTestPatch(string $diff, string $from, string $to): void
+    {
+        $this->assertNotSame('', $diff);
+        $this->assertValidUnifiedDiffFormat($diff);
+
+        $diff = self::setDiffFileHeader($diff, $this->fileFrom);
+
+        $this->assertNotFalse(file_put_contents($this->fileFrom, $from));
+        $this->assertNotFalse(file_put_contents($this->filePatch, $diff));
+
+        $p = Process::fromShellCommandline('patch -u --verbose --posix $from < $patch'); // --posix
+        $p->run(
+            null,
+            [
+                'from'  => $this->fileFrom,
+                'patch' => $this->filePatch,
+            ]
+        );
+
+        $this->assertProcessSuccessful($p);
+
+        $this->assertStringEqualsFile(
+            $this->fileFrom,
+            $to,
+            sprintf('Patch command "%s".', $p->getCommandLine())
+        );
+    }
+
+    private function doIntegrationTestGitApply(string $diff, string $from, string $to): void
+    {
+        $this->assertNotSame('', $diff);
+        $this->assertValidUnifiedDiffFormat($diff);
+
+        $diff = self::setDiffFileHeader($diff, $this->fileFrom);
+
+        $this->assertNotFalse(file_put_contents($this->fileFrom, $from));
+        $this->assertNotFalse(file_put_contents($this->filePatch, $diff));
+
+        $p = Process::fromShellCommandline('git --git-dir $dir apply --check -v --unsafe-paths --ignore-whitespace $patch');
+        $p->run(
+            null,
+            [
+                'dir'   => $this->dir,
+                'patch' => $this->filePatch,
+            ]
+        );
+
+        $this->assertProcessSuccessful($p);
+    }
+
+    private function assertProcessSuccessful(Process $p): void
+    {
+        $this->assertTrue(
+            $p->isSuccessful(),
+            sprintf(
+                "Command exec. was not successful:\n\"%s\"\nOutput:\n\"%s\"\nStdErr:\n\"%s\"\nExit code %d.\n",
+                $p->getCommandLine(),
+                $p->getOutput(),
+                $p->getErrorOutput(),
+                $p->getExitCode()
+            )
+        );
+    }
+
+    private function cleanUpTempFiles(): void
+    {
+        @unlink($this->fileFrom . '.orig');
+        @unlink($this->fileFrom);
+        @unlink($this->filePatch);
+    }
+
+    private static function setDiffFileHeader(string $diff, string $file): string
+    {
+        $diffLines    = preg_split('/(.*\R)/', $diff, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
+        $diffLines[0] = preg_replace('#^\-\-\- .*#', '--- /' . $file, $diffLines[0], 1);
+        $diffLines[1] = preg_replace('#^\+\+\+ .*#', '+++ /' . $file, $diffLines[1], 1);
+
+        return implode('', $diffLines);
+    }
+}
diff --git a/tests/Output/StrictUnifiedDiffOutputBuilderDataProvider.php b/tests/Output/StrictUnifiedDiffOutputBuilderDataProvider.php
new file mode 100644
index 00000000..553ea0f9
--- /dev/null
+++ b/tests/Output/StrictUnifiedDiffOutputBuilderDataProvider.php
@@ -0,0 +1,188 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of sebastian/diff.
+ *
+ * (c) Sebastian Bergmann <sebastian@phpunit.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+namespace SebastianBergmann\Diff\Output;
+
+final class StrictUnifiedDiffOutputBuilderDataProvider
+{
+    public static function provideOutputBuildingCases(): array
+    {
+        return [
+            [
+                '--- input.txt
++++ output.txt
+@@ -1,3 +1,4 @@
++b
+ ' . '
+ ' . '
+ ' . '
+@@ -16,5 +17,4 @@
+ ' . '
+ ' . '
+ ' . '
+-
+-B
++A
+',
+                "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nB\n",
+                "b\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nA\n",
+                [
+                    'fromFile' => 'input.txt',
+                    'toFile'   => 'output.txt',
+                ],
+            ],
+            [
+                '--- ' . __FILE__ . "\t2017-10-02 17:38:11.586413675 +0100
++++ output1.txt\t2017-10-03 12:09:43.086719482 +0100
+@@ -1,1 +1,1 @@
+-B
++X
+",
+                "B\n",
+                "X\n",
+                [
+                    'fromFile'       => __FILE__,
+                    'fromFileDate'   => '2017-10-02 17:38:11.586413675 +0100',
+                    'toFile'         => 'output1.txt',
+                    'toFileDate'     => '2017-10-03 12:09:43.086719482 +0100',
+                    'collapseRanges' => false,
+                ],
+            ],
+            [
+                '--- input.txt
++++ output.txt
+@@ -1 +1 @@
+-B
++X
+',
+                "B\n",
+                "X\n",
+                [
+                    'fromFile'       => 'input.txt',
+                    'toFile'         => 'output.txt',
+                    'collapseRanges' => true,
+                ],
+            ],
+        ];
+    }
+
+    public static function provideSample(): array
+    {
+        return [
+            [
+                '--- input.txt
++++ output.txt
+@@ -1,6 +1,6 @@
+ 1
+ 2
+ 3
+-4
++X
+ 5
+ 6
+',
+                "1\n2\n3\n4\n5\n6\n",
+                "1\n2\n3\nX\n5\n6\n",
+                [
+                    'fromFile' => 'input.txt',
+                    'toFile'   => 'output.txt',
+                ],
+            ],
+        ];
+    }
+
+    public static function provideBasicDiffGeneration(): array
+    {
+        return [
+            [
+                "--- input.txt
++++ output.txt
+@@ -1,2 +1 @@
+-A
+-B
++A\rB
+",
+                "A\nB\n",
+                "A\rB\n",
+            ],
+            [
+                "--- input.txt
++++ output.txt
+@@ -1 +1 @@
+-
++\r
+\\ No newline at end of file
+",
+                "\n",
+                "\r",
+            ],
+            [
+                "--- input.txt
++++ output.txt
+@@ -1 +1 @@
+-\r
+\\ No newline at end of file
++
+",
+                "\r",
+                "\n",
+            ],
+            [
+                '--- input.txt
++++ output.txt
+@@ -1,3 +1,3 @@
+ X
+ A
+-A
++B
+',
+                "X\nA\nA\n",
+                "X\nA\nB\n",
+            ],
+            [
+                '--- input.txt
++++ output.txt
+@@ -1,3 +1,3 @@
+ X
+ A
+-A
+\ No newline at end of file
++B
+',
+                "X\nA\nA",
+                "X\nA\nB\n",
+            ],
+            [
+                '--- input.txt
++++ output.txt
+@@ -1,3 +1,3 @@
+ A
+ A
+-A
++B
+\ No newline at end of file
+',
+                "A\nA\nA\n",
+                "A\nA\nB",
+            ],
+            [
+                '--- input.txt
++++ output.txt
+@@ -1 +1 @@
+-A
+\ No newline at end of file
++B
+\ No newline at end of file
+',
+                'A',
+                'B',
+            ],
+        ];
+    }
+}
diff --git a/tests/Output/StrictUnifiedDiffOutputBuilderTest.php b/tests/Output/StrictUnifiedDiffOutputBuilderTest.php
new file mode 100644
index 00000000..a9f58cae
--- /dev/null
+++ b/tests/Output/StrictUnifiedDiffOutputBuilderTest.php
@@ -0,0 +1,654 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of sebastian/diff.
+ *
+ * (c) Sebastian Bergmann <sebastian@phpunit.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+namespace SebastianBergmann\Diff\Output;
+
+use function array_merge;
+use function preg_quote;
+use function sprintf;
+use function substr;
+use function time;
+use PHPUnit\Framework\TestCase;
+use SebastianBergmann\Diff\ConfigurationException;
+use SebastianBergmann\Diff\Differ;
+use SebastianBergmann\Diff\Utils\UnifiedDiffAssertTrait;
+use SplFileInfo;
+
+/**
+ * @covers \SebastianBergmann\Diff\Output\StrictUnifiedDiffOutputBuilder
+ *
+ * @uses \SebastianBergmann\Diff\Differ
+ * @uses \SebastianBergmann\Diff\TimeEfficientLongestCommonSubsequenceCalculator
+ * @uses \SebastianBergmann\Diff\ConfigurationException
+ */
+final class StrictUnifiedDiffOutputBuilderTest extends TestCase
+{
+    use UnifiedDiffAssertTrait;
+
+    /**
+     * @dataProvider provideOutputBuildingCases
+     */
+    public function testOutputBuilding(string $expected, string $from, string $to, array $options): void
+    {
+        $diff = $this->getDiffer($options)->diff($from, $to);
+
+        $this->assertValidDiffFormat($diff);
+        $this->assertSame($expected, $diff);
+    }
+
+    /**
+     * @dataProvider provideSample
+     */
+    public function testSample(string $expected, string $from, string $to, array $options): void
+    {
+        $diff = $this->getDiffer($options)->diff($from, $to);
+
+        $this->assertValidDiffFormat($diff);
+        $this->assertSame($expected, $diff);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function assertValidDiffFormat(string $diff): void
+    {
+        $this->assertValidUnifiedDiffFormat($diff);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function provideOutputBuildingCases(): array
+    {
+        return StrictUnifiedDiffOutputBuilderDataProvider::provideOutputBuildingCases();
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function provideSample(): array
+    {
+        return StrictUnifiedDiffOutputBuilderDataProvider::provideSample();
+    }
+
+    /**
+     * @dataProvider provideBasicDiffGeneration
+     */
+    public function testBasicDiffGeneration(string $expected, string $from, string $to): void
+    {
+        $diff = $this->getDiffer([
+            'fromFile' => 'input.txt',
+            'toFile'   => 'output.txt',
+        ])->diff($from, $to);
+
+        $this->assertValidDiffFormat($diff);
+        $this->assertSame($expected, $diff);
+    }
+
+    public function provideBasicDiffGeneration(): array
+    {
+        return StrictUnifiedDiffOutputBuilderDataProvider::provideBasicDiffGeneration();
+    }
+
+    /**
+     * @dataProvider provideConfiguredDiffGeneration
+     */
+    public function testConfiguredDiffGeneration(string $expected, string $from, string $to, array $config = []): void
+    {
+        $diff = $this->getDiffer(array_merge([
+            'fromFile' => 'input.txt',
+            'toFile'   => 'output.txt',
+        ], $config))->diff($from, $to);
+
+        $this->assertValidDiffFormat($diff);
+        $this->assertSame($expected, $diff);
+    }
+
+    public function provideConfiguredDiffGeneration(): array
+    {
+        return [
+            [
+                '--- input.txt
++++ output.txt
+@@ -1 +1 @@
+-a
+\ No newline at end of file
++b
+\ No newline at end of file
+',
+                'a',
+                'b',
+            ],
+            [
+                '',
+                "1\n2",
+                "1\n2",
+            ],
+            [
+                '',
+                "1\n",
+                "1\n",
+            ],
+            [
+                '--- input.txt
++++ output.txt
+@@ -4 +4 @@
+-X
++4
+',
+                "1\n2\n3\nX\n5\n6\n7\n8\n9\n0\n",
+                "1\n2\n3\n4\n5\n6\n7\n8\n9\n0\n",
+                [
+                    'contextLines' => 0,
+                ],
+            ],
+            [
+                '--- input.txt
++++ output.txt
+@@ -3,3 +3,3 @@
+ 3
+-X
++4
+ 5
+',
+                "1\n2\n3\nX\n5\n6\n7\n8\n9\n0\n",
+                "1\n2\n3\n4\n5\n6\n7\n8\n9\n0\n",
+                [
+                    'contextLines' => 1,
+                ],
+            ],
+            [
+                '--- input.txt
++++ output.txt
+@@ -1,10 +1,10 @@
+ 1
+ 2
+ 3
+-X
++4
+ 5
+ 6
+ 7
+ 8
+ 9
+ 0
+',
+                "1\n2\n3\nX\n5\n6\n7\n8\n9\n0\n",
+                "1\n2\n3\n4\n5\n6\n7\n8\n9\n0\n",
+                [
+                    'contextLines' => 999,
+                ],
+            ],
+            [
+                '--- input.txt
++++ output.txt
+@@ -1,0 +1,2 @@
++
++A
+',
+                '',
+                "\nA\n",
+            ],
+            [
+                '--- input.txt
++++ output.txt
+@@ -1,2 +1,0 @@
+-
+-A
+',
+                "\nA\n",
+                '',
+            ],
+            [
+                '--- input.txt
++++ output.txt
+@@ -1,5 +1,5 @@
+ 1
+-X
++2
+ 3
+-Y
++4
+ 5
+@@ -8,3 +8,3 @@
+ 8
+-X
++9
+ 0
+',
+                "1\nX\n3\nY\n5\n6\n7\n8\nX\n0\n",
+                "1\n2\n3\n4\n5\n6\n7\n8\n9\n0\n",
+                [
+                    'commonLineThreshold' => 2,
+                    'contextLines'        => 1,
+                ],
+            ],
+            [
+                '--- input.txt
++++ output.txt
+@@ -2 +2 @@
+-X
++2
+@@ -4 +4 @@
+-Y
++4
+@@ -9 +9 @@
+-X
++9
+',
+                "1\nX\n3\nY\n5\n6\n7\n8\nX\n0\n",
+                "1\n2\n3\n4\n5\n6\n7\n8\n9\n0\n",
+                [
+                    'commonLineThreshold' => 1,
+                    'contextLines'        => 0,
+                ],
+            ],
+        ];
+    }
+
+    public function testReUseBuilder(): void
+    {
+        $differ = $this->getDiffer([
+            'fromFile' => 'input.txt',
+            'toFile'   => 'output.txt',
+        ]);
+
+        $diff = $differ->diff("A\nB\n", "A\nX\n");
+        $this->assertSame(
+            '--- input.txt
++++ output.txt
+@@ -1,2 +1,2 @@
+ A
+-B
++X
+',
+            $diff
+        );
+
+        $diff = $differ->diff("A\n", "A\n");
+        $this->assertSame(
+            '',
+            $diff
+        );
+    }
+
+    public function testEmptyDiff(): void
+    {
+        $builder = new StrictUnifiedDiffOutputBuilder([
+            'fromFile' => 'input.txt',
+            'toFile'   => 'output.txt',
+        ]);
+
+        $this->assertSame(
+            '',
+            $builder->getDiff([])
+        );
+    }
+
+    /**
+     * @dataProvider provideInvalidConfiguration
+     */
+    public function testInvalidConfiguration(array $options, string $message): void
+    {
+        $this->expectException(ConfigurationException::class);
+        $this->expectExceptionMessageMatches(sprintf('#^%s$#', preg_quote($message, '#')));
+
+        new StrictUnifiedDiffOutputBuilder($options);
+    }
+
+    public function provideInvalidConfiguration(): array
+    {
+        $time = time();
+
+        return [
+            [
+                ['collapseRanges' => 1],
+                'Option "collapseRanges" must be a bool, got "integer#1".',
+            ],
+            [
+                ['contextLines' => 'a'],
+                'Option "contextLines" must be an int >= 0, got "string#a".',
+            ],
+            [
+                ['commonLineThreshold' => -2],
+                'Option "commonLineThreshold" must be an int > 0, got "integer#-2".',
+            ],
+            [
+                ['commonLineThreshold' => 0],
+                'Option "commonLineThreshold" must be an int > 0, got "integer#0".',
+            ],
+            [
+                ['fromFile' => new SplFileInfo(__FILE__)],
+                'Option "fromFile" must be a string, got "SplFileInfo".',
+            ],
+            [
+                ['fromFile' => null],
+                'Option "fromFile" must be a string, got "<null>".',
+            ],
+            [
+                [
+                    'fromFile' => __FILE__,
+                    'toFile'   => 1,
+                ],
+                'Option "toFile" must be a string, got "integer#1".',
+            ],
+            [
+                [
+                    'fromFile'   => __FILE__,
+                    'toFile'     => __FILE__,
+                    'toFileDate' => $time,
+                ],
+                'Option "toFileDate" must be a string or <null>, got "integer#' . $time . '".',
+            ],
+            [
+                [],
+                'Option "fromFile" must be a string, got "<null>".',
+            ],
+        ];
+    }
+
+    /**
+     * @dataProvider provideCommonLineThresholdCases
+     */
+    public function testCommonLineThreshold(string $expected, string $from, string $to, int $threshold): void
+    {
+        $diff = $this->getDiffer([
+            'fromFile'            => 'input.txt',
+            'toFile'              => 'output.txt',
+            'commonLineThreshold' => $threshold,
+            'contextLines'        => 0,
+        ])->diff($from, $to);
+
+        $this->assertValidDiffFormat($diff);
+        $this->assertSame($expected, $diff);
+    }
+
+    public function provideCommonLineThresholdCases(): array
+    {
+        return [
+            [
+                '--- input.txt
++++ output.txt
+@@ -2,3 +2,3 @@
+-X
++B
+ C12
+-Y
++D
+@@ -7 +7 @@
+-X
++Z
+',
+                "A\nX\nC12\nY\nA\nA\nX\n",
+                "A\nB\nC12\nD\nA\nA\nZ\n",
+                2,
+            ],
+            [
+                '--- input.txt
++++ output.txt
+@@ -2 +2 @@
+-X
++B
+@@ -4 +4 @@
+-Y
++D
+',
+                "A\nX\nV\nY\n",
+                "A\nB\nV\nD\n",
+                1,
+            ],
+        ];
+    }
+
+    /**
+     * @dataProvider provideContextLineConfigurationCases
+     */
+    public function testContextLineConfiguration(string $expected, string $from, string $to, int $contextLines, int $commonLineThreshold = 6): void
+    {
+        $diff = $this->getDiffer([
+            'fromFile'            => 'input.txt',
+            'toFile'              => 'output.txt',
+            'contextLines'        => $contextLines,
+            'commonLineThreshold' => $commonLineThreshold,
+        ])->diff($from, $to);
+
+        $this->assertValidDiffFormat($diff);
+        $this->assertSame($expected, $diff);
+    }
+
+    public function provideContextLineConfigurationCases(): array
+    {
+        $from = "A\nB\nC\nD\nE\nF\nX\nG\nH\nI\nJ\nK\nL\nM\n";
+        $to   = "A\nB\nC\nD\nE\nF\nY\nG\nH\nI\nJ\nK\nL\nM\n";
+
+        return [
+            'EOF 0' => [
+                "--- input.txt\n+++ output.txt\n@@ -3 +3 @@
+-X
+\\ No newline at end of file
++Y
+\\ No newline at end of file
+",
+                "A\nB\nX",
+                "A\nB\nY",
+                0,
+            ],
+            'EOF 1' => [
+                "--- input.txt\n+++ output.txt\n@@ -2,2 +2,2 @@
+ B
+-X
+\\ No newline at end of file
++Y
+\\ No newline at end of file
+",
+                "A\nB\nX",
+                "A\nB\nY",
+                1,
+            ],
+            'EOF 2' => [
+                "--- input.txt\n+++ output.txt\n@@ -1,3 +1,3 @@
+ A
+ B
+-X
+\\ No newline at end of file
++Y
+\\ No newline at end of file
+",
+                "A\nB\nX",
+                "A\nB\nY",
+                2,
+            ],
+            'EOF 200' => [
+                "--- input.txt\n+++ output.txt\n@@ -1,3 +1,3 @@
+ A
+ B
+-X
+\\ No newline at end of file
++Y
+\\ No newline at end of file
+",
+                "A\nB\nX",
+                "A\nB\nY",
+                200,
+            ],
+            'n/a 0' => [
+                "--- input.txt\n+++ output.txt\n@@ -7 +7 @@\n-X\n+Y\n",
+                $from,
+                $to,
+                0,
+            ],
+            'G' => [
+                "--- input.txt\n+++ output.txt\n@@ -6,3 +6,3 @@\n F\n-X\n+Y\n G\n",
+                $from,
+                $to,
+                1,
+            ],
+            'H' => [
+                "--- input.txt\n+++ output.txt\n@@ -5,5 +5,5 @@\n E\n F\n-X\n+Y\n G\n H\n",
+                $from,
+                $to,
+                2,
+            ],
+            'I' => [
+                "--- input.txt\n+++ output.txt\n@@ -4,7 +4,7 @@\n D\n E\n F\n-X\n+Y\n G\n H\n I\n",
+                $from,
+                $to,
+                3,
+            ],
+            'J' => [
+                "--- input.txt\n+++ output.txt\n@@ -3,9 +3,9 @@\n C\n D\n E\n F\n-X\n+Y\n G\n H\n I\n J\n",
+                $from,
+                $to,
+                4,
+            ],
+            'K' => [
+                "--- input.txt\n+++ output.txt\n@@ -2,11 +2,11 @@\n B\n C\n D\n E\n F\n-X\n+Y\n G\n H\n I\n J\n K\n",
+                $from,
+                $to,
+                5,
+            ],
+            'L' => [
+                "--- input.txt\n+++ output.txt\n@@ -1,13 +1,13 @@\n A\n B\n C\n D\n E\n F\n-X\n+Y\n G\n H\n I\n J\n K\n L\n",
+                $from,
+                $to,
+                6,
+            ],
+            'M' => [
+                "--- input.txt\n+++ output.txt\n@@ -1,14 +1,14 @@\n A\n B\n C\n D\n E\n F\n-X\n+Y\n G\n H\n I\n J\n K\n L\n M\n",
+                $from,
+                $to,
+                7,
+            ],
+            'M no linebreak EOF .1' => [
+                "--- input.txt\n+++ output.txt\n@@ -1,14 +1,14 @@\n A\n B\n C\n D\n E\n F\n-X\n+Y\n G\n H\n I\n J\n K\n L\n-M\n+M\n\\ No newline at end of file\n",
+                $from,
+                substr($to, 0, -1),
+                7,
+            ],
+            'M no linebreak EOF .2' => [
+                "--- input.txt\n+++ output.txt\n@@ -1,14 +1,14 @@\n A\n B\n C\n D\n E\n F\n-X\n+Y\n G\n H\n I\n J\n K\n L\n-M\n\\ No newline at end of file\n+M\n",
+                substr($from, 0, -1),
+                $to,
+                7,
+            ],
+            'M no linebreak EOF .3' => [
+                "--- input.txt\n+++ output.txt\n@@ -1,14 +1,14 @@\n A\n B\n C\n D\n E\n F\n-X\n+Y\n G\n H\n I\n J\n K\n L\n M\n",
+                substr($from, 0, -1),
+                substr($to, 0, -1),
+                7,
+            ],
+            'M no linebreak EOF .4' => [
+                "--- input.txt\n+++ output.txt\n@@ -1,14 +1,14 @@\n A\n B\n C\n D\n E\n F\n-X\n+Y\n G\n H\n I\n J\n K\n L\n M\n\\ No newline at end of file\n",
+                substr($from, 0, -1),
+                substr($to, 0, -1),
+                10000,
+                10000,
+            ],
+            'M+1' => [
+                "--- input.txt\n+++ output.txt\n@@ -1,14 +1,14 @@\n A\n B\n C\n D\n E\n F\n-X\n+Y\n G\n H\n I\n J\n K\n L\n M\n",
+                $from,
+                $to,
+                8,
+            ],
+            'M+100' => [
+                "--- input.txt\n+++ output.txt\n@@ -1,14 +1,14 @@\n A\n B\n C\n D\n E\n F\n-X\n+Y\n G\n H\n I\n J\n K\n L\n M\n",
+                $from,
+                $to,
+                107,
+            ],
+            '0 II' => [
+                "--- input.txt\n+++ output.txt\n@@ -12 +12 @@\n-X\n+Y\n",
+                "A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nX\nM\n",
+                "A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nY\nM\n",
+                0,
+                999,
+            ],
+            '0\' II' => [
+                "--- input.txt\n+++ output.txt\n@@ -12 +12 @@\n-X\n+Y\n",
+                "A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nX\nM\nA\nA\nA\nA\nA\n",
+                "A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nY\nM\nA\nA\nA\nA\nA\n",
+                0,
+                999,
+            ],
+            '0\'\' II' => [
+                "--- input.txt\n+++ output.txt\n@@ -12,2 +12,2 @@\n-X\n-M\n\\ No newline at end of file\n+Y\n+M\n",
+                "A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nX\nM",
+                "A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nY\nM\n",
+                0,
+            ],
+            '0\'\'\' II' => [
+                "--- input.txt\n+++ output.txt\n@@ -12,2 +12,2 @@\n-X\n-X1\n+Y\n+Y2\n",
+                "A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nX\nX1\nM\nA\nA\nA\nA\nA\n",
+                "A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nY\nY2\nM\nA\nA\nA\nA\nA\n",
+                0,
+                999,
+            ],
+            '1 II' => [
+                "--- input.txt\n+++ output.txt\n@@ -11,3 +11,3 @@\n K\n-X\n+Y\n M\n",
+                "A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nX\nM\n",
+                "A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nY\nM\n",
+                1,
+            ],
+            '5 II' => [
+                "--- input.txt\n+++ output.txt\n@@ -7,7 +7,7 @@\n G\n H\n I\n J\n K\n-X\n+Y\n M\n",
+                "A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nX\nM\n",
+                "A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nY\nM\n",
+                5,
+            ],
+            [
+                '--- input.txt
++++ output.txt
+@@ -1,28 +1,28 @@
+ A
+-X
++B
+ V
+-Y
++D
+ 1
+ A
+ 2
+ A
+ 3
+ A
+ 4
+ A
+ 8
+ A
+ 9
+ A
+ 5
+ A
+ A
+ A
+ A
+ A
+ A
+ A
+ A
+ A
+ A
+ A
+',
+                "A\nX\nV\nY\n1\nA\n2\nA\n3\nA\n4\nA\n8\nA\n9\nA\n5\nA\nA\nA\nA\nA\nA\nA\nA\nA\nA\nA\n",
+                "A\nB\nV\nD\n1\nA\n2\nA\n3\nA\n4\nA\n8\nA\n9\nA\n5\nA\nA\nA\nA\nA\nA\nA\nA\nA\nA\nA\n",
+                9999,
+                99999,
+            ],
+        ];
+    }
+
+    /**
+     * Returns a new instance of a Differ with a new instance of the class (DiffOutputBuilderInterface) under test.
+     */
+    private function getDiffer(array $options = []): Differ
+    {
+        return new Differ(new StrictUnifiedDiffOutputBuilder($options));
+    }
+}
diff --git a/tests/Output/UnifiedDiffOutputBuilderDataProvider.php b/tests/Output/UnifiedDiffOutputBuilderDataProvider.php
new file mode 100644
index 00000000..5681bce9
--- /dev/null
+++ b/tests/Output/UnifiedDiffOutputBuilderDataProvider.php
@@ -0,0 +1,395 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of sebastian/diff.
+ *
+ * (c) Sebastian Bergmann <sebastian@phpunit.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+namespace SebastianBergmann\Diff\Output;
+
+final class UnifiedDiffOutputBuilderDataProvider
+{
+    public static function provideDiffWithLineNumbers(): array
+    {
+        return [
+            'diff line 1 non_patch_compat' => [
+                '--- Original
++++ New
+@@ -1 +1 @@
+-AA
++BA
+',
+                'AA',
+                'BA',
+            ],
+            'diff line +1 non_patch_compat' => [
+                '--- Original
++++ New
+@@ -1 +1,2 @@
+-AZ
++
++B
+',
+                'AZ',
+                "\nB",
+            ],
+            'diff line -1 non_patch_compat' => [
+                '--- Original
++++ New
+@@ -1,2 +1 @@
+-
+-AF
++B
+',
+                "\nAF",
+                'B',
+            ],
+            'II non_patch_compat' => [
+                '--- Original
++++ New
+@@ -1,4 +1,2 @@
+-
+-
+ A
+ 1
+',
+                "\n\nA\n1",
+                "A\n1",
+            ],
+            'diff last line II - no trailing linebreak non_patch_compat' => [
+                '--- Original
++++ New
+@@ -5,4 +5,4 @@
+ ' . '
+ ' . '
+ ' . '
+-E
++B
+',
+                "A\n\n\n\n\n\n\nE",
+                "A\n\n\n\n\n\n\nB",
+            ],
+            [
+                "--- Original\n+++ New\n@@ -1,2 +1 @@\n \n-\n",
+                "\n\n",
+                "\n",
+            ],
+            'diff line endings non_patch_compat' => [
+                "--- Original\n+++ New\n@@ -1 +1 @@\n #Warning: Strings contain different line endings!\n-<?php\r\n+<?php\n",
+                "<?php\r\n",
+                "<?php\n",
+            ],
+            'same non_patch_compat' => [
+                '--- Original
++++ New
+',
+                "AT\n",
+                "AT\n",
+            ],
+            [
+                '--- Original
++++ New
+@@ -1,4 +1,4 @@
+-b
++a
+ ' . '
+ ' . '
+ ' . '
+',
+                "b\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",
+                "a\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n",
+            ],
+            'diff line @1' => [
+                '--- Original
++++ New
+@@ -1,2 +1,2 @@
+ ' . '
+-AG
++B
+',
+                "\nAG\n",
+                "\nB\n",
+            ],
+            'same multiple lines' => [
+                '--- Original
++++ New
+@@ -1,4 +1,4 @@
+ ' . '
+ ' . '
+-V
++B
+ C213
+',
+                "\n\nV\nC213",
+                "\n\nB\nC213",
+            ],
+            'diff last line I' => [
+                '--- Original
++++ New
+@@ -5,4 +5,4 @@
+ ' . '
+ ' . '
+ ' . '
+-E
++B
+',
+                "A\n\n\n\n\n\n\nE\n",
+                "A\n\n\n\n\n\n\nB\n",
+            ],
+            'diff line middle' => [
+                '--- Original
++++ New
+@@ -5,7 +5,7 @@
+ ' . '
+ ' . '
+ ' . '
+-X
++Z
+ ' . '
+ ' . '
+ ' . '
+',
+                "A\n\n\n\n\n\n\nX\n\n\n\n\n\n\nAY",
+                "A\n\n\n\n\n\n\nZ\n\n\n\n\n\n\nAY",
+            ],
+            'diff last line III' => [
+                '--- Original
++++ New
+@@ -12,4 +12,4 @@
+ ' . '
+ ' . '
+ ' . '
+-A
++B
+',
+                "A\n\n\n\n\n\n\nA\n\n\n\n\n\n\nA\n",
+                "A\n\n\n\n\n\n\nA\n\n\n\n\n\n\nB\n",
+            ],
+            [
+                '--- Original
++++ New
+@@ -1,8 +1,8 @@
+ A
+-B
++B1
+ D
+ E
+ EE
+ F
+-G
++G1
+ H
+',
+                "A\nB\nD\nE\nEE\nF\nG\nH",
+                "A\nB1\nD\nE\nEE\nF\nG1\nH",
+            ],
+            [
+                '--- Original
++++ New
+@@ -1,4 +1,5 @@
+ Z
++
+ a
+ b
+ c
+@@ -7,5 +8,5 @@
+ f
+ g
+ h
+-i
++x
+ j
+',
+                'Z
+a
+b
+c
+d
+e
+f
+g
+h
+i
+j
+',
+                'Z
+
+a
+b
+c
+d
+e
+f
+g
+h
+x
+j
+',
+            ],
+            [
+                '--- Original
++++ New
+@@ -1,7 +1,5 @@
+-
+-a
++b
+ A
+-X
+-
++Y
+ ' . '
+ A
+',
+                "\na\nA\nX\n\n\nA\n",
+                "b\nA\nY\n\nA\n",
+            ],
+            [
+                <<<EOF
+--- Original
++++ New
+@@ -1,7 +1,5 @@
+-
+-
+ a
+-b
++p
+ c
+ d
+ e
+@@ -9,5 +7,5 @@
+ g
+ h
+ i
+-j
++w
+ k
+
+EOF
+                ,
+                "\n\na\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\n",
+                "a\np\nc\nd\ne\nf\ng\nh\ni\nw\nk\n",
+            ],
+            [
+                '--- Original
++++ New
+@@ -8,7 +8,7 @@
+ ' . '
+ ' . '
+ ' . '
+-A
++C
+ ' . '
+ ' . '
+ ' . '
+',
+                "E\n\n\n\n\nB\n\n\n\n\nA\n\n\n\n\n\n\n\n\nD1",
+                "E\n\n\n\n\nB\n\n\n\n\nC\n\n\n\n\n\n\n\n\nD1",
+            ],
+            [
+                '--- Original
++++ New
+@@ -5,7 +5,7 @@
+ ' . '
+ ' . '
+ ' . '
+-Z
++U
+ ' . '
+ ' . '
+ ' . '
+@@ -12,7 +12,7 @@
+ ' . '
+ ' . '
+ ' . '
+-X
++V
+ ' . '
+ ' . '
+ ' . '
+@@ -19,7 +19,7 @@
+ ' . '
+ ' . '
+ ' . '
+-Y
++W
+ ' . '
+ ' . '
+ ' . '
+@@ -26,7 +26,7 @@
+ ' . '
+ ' . '
+ ' . '
+-W
++X
+ ' . '
+ ' . '
+ ' . '
+@@ -33,7 +33,7 @@
+ ' . '
+ ' . '
+ ' . '
+-V
++Y
+ ' . '
+ ' . '
+ ' . '
+@@ -40,4 +40,4 @@
+ ' . '
+ ' . '
+ ' . '
+-U
++Z
+',
+                "\n\n\n\n\n\n\nZ\n\n\n\n\n\n\nX\n\n\n\n\n\n\nY\n\n\n\n\n\n\nW\n\n\n\n\n\n\nV\n\n\n\n\n\n\nU\n",
+                "\n\n\n\n\n\n\nU\n\n\n\n\n\n\nV\n\n\n\n\n\n\nW\n\n\n\n\n\n\nX\n\n\n\n\n\n\nY\n\n\n\n\n\n\nZ\n",
+            ],
+            [
+                <<<EOF
+--- Original
++++ New
+@@ -1,5 +1,5 @@
+ a
+-b
++p
+ c
+ d
+ e
+@@ -7,5 +7,5 @@
+ g
+ h
+ i
+-j
++w
+ k
+
+EOF
+                ,
+                "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk\n",
+                "a\np\nc\nd\ne\nf\ng\nh\ni\nw\nk\n",
+            ],
+            [
+                <<<EOF
+--- Original
++++ New
+@@ -1,4 +1,4 @@
+-A
++B
+ 1
+ 2
+ 3
+
+EOF
+                ,
+                "A\n1\n2\n3\n4\n5\n6\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1",
+                "B\n1\n2\n3\n4\n5\n6\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1\n1",
+            ],
+            [
+                "--- Original\n+++ New\n@@ -4,7 +4,7 @@\n D\n E\n F\n-X\n+Y\n G\n H\n I\n",
+                "A\nB\nC\nD\nE\nF\nX\nG\nH\nI\nJ\nK\nL\nM\n",
+                "A\nB\nC\nD\nE\nF\nY\nG\nH\nI\nJ\nK\nL\nM\n",
+            ],
+        ];
+    }
+}
diff --git a/tests/Output/UnifiedDiffOutputBuilderTest.php b/tests/Output/UnifiedDiffOutputBuilderTest.php
new file mode 100644
index 00000000..4b286810
--- /dev/null
+++ b/tests/Output/UnifiedDiffOutputBuilderTest.php
@@ -0,0 +1,103 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of sebastian/diff.
+ *
+ * (c) Sebastian Bergmann <sebastian@phpunit.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+namespace SebastianBergmann\Diff\Output;
+
+use PHPUnit\Framework\TestCase;
+use SebastianBergmann\Diff\Differ;
+
+/**
+ * @covers \SebastianBergmann\Diff\Output\UnifiedDiffOutputBuilder
+ *
+ * @uses \SebastianBergmann\Diff\Differ
+ * @uses \SebastianBergmann\Diff\Output\AbstractChunkOutputBuilder
+ * @uses \SebastianBergmann\Diff\TimeEfficientLongestCommonSubsequenceCalculator
+ */
+final class UnifiedDiffOutputBuilderTest extends TestCase
+{
+    /**
+     * @dataProvider headerProvider
+     */
+    public function testCustomHeaderCanBeUsed(string $expected, string $from, string $to, string $header): void
+    {
+        $differ = new Differ(new UnifiedDiffOutputBuilder($header));
+
+        $this->assertSame(
+            $expected,
+            $differ->diff($from, $to)
+        );
+    }
+
+    public function headerProvider(): array
+    {
+        return [
+            [
+                "CUSTOM HEADER\n@@ @@\n-a\n+b\n",
+                'a',
+                'b',
+                'CUSTOM HEADER',
+            ],
+            [
+                "CUSTOM HEADER\n@@ @@\n-a\n+b\n",
+                'a',
+                'b',
+                "CUSTOM HEADER\n",
+            ],
+            [
+                "CUSTOM HEADER\n\n@@ @@\n-a\n+b\n",
+                'a',
+                'b',
+                "CUSTOM HEADER\n\n",
+            ],
+            [
+                "@@ @@\n-a\n+b\n",
+                'a',
+                'b',
+                '',
+            ],
+        ];
+    }
+
+    /**
+     * @dataProvider provideDiffWithLineNumbers
+     */
+    public function testDiffWithLineNumbers(string $expected, string $from, string $to): void
+    {
+        $differ = new Differ(new UnifiedDiffOutputBuilder("--- Original\n+++ New\n", true));
+        $this->assertSame($expected, $differ->diff($from, $to));
+    }
+
+    public function provideDiffWithLineNumbers(): array
+    {
+        return UnifiedDiffOutputBuilderDataProvider::provideDiffWithLineNumbers();
+    }
+
+    /**
+     * @dataProvider provideStringsThatAreTheSame
+     */
+    public function testEmptyDiffProducesEmptyOutput(string $from, string $to): void
+    {
+        $differ = new Differ(new UnifiedDiffOutputBuilder('', false));
+
+        $output = $differ->diff($from, $to);
+
+        $this->assertEmpty($output);
+    }
+
+    public function provideStringsThatAreTheSame(): array
+    {
+        return [
+            ['', ''],
+            ['a', 'a'],
+            ['these strings are the same', 'these strings are the same'],
+            ["\n", "\n"],
+            ["multi-line strings\nare the same", "multi-line strings\nare the same"],
+        ];
+    }
+}
diff --git a/tests/ParserTest.php b/tests/ParserTest.php
index a3667e31..7ec06052 100644
--- a/tests/ParserTest.php
+++ b/tests/ParserTest.php
@@ -7,17 +7,18 @@
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
  */
-
 namespace SebastianBergmann\Diff;
 
+use function unserialize;
 use PHPUnit\Framework\TestCase;
+use SebastianBergmann\Diff\Utils\FileUtils;
 
 /**
- * @covers SebastianBergmann\Diff\Parser
+ * @covers \SebastianBergmann\Diff\Parser
  *
- * @uses SebastianBergmann\Diff\Chunk
- * @uses SebastianBergmann\Diff\Diff
- * @uses SebastianBergmann\Diff\Line
+ * @uses \SebastianBergmann\Diff\Chunk
+ * @uses \SebastianBergmann\Diff\Diff
+ * @uses \SebastianBergmann\Diff\Line
  */
 final class ParserTest extends TestCase
 {
@@ -26,23 +27,21 @@ final class ParserTest extends TestCase
      */
     private $parser;
 
-    protected function setUp()
+    protected function setUp(): void
     {
         $this->parser = new Parser;
     }
 
-    public function testParse()
+    public function testParse(): void
     {
-        $content = \file_get_contents(__DIR__ . '/fixtures/patch.txt');
+        $content = FileUtils::getFileContent(__DIR__ . '/fixtures/patch.txt');
 
         $diffs = $this->parser->parse($content);
 
-        $this->assertInternalType('array', $diffs);
         $this->assertContainsOnlyInstancesOf(Diff::class, $diffs);
         $this->assertCount(1, $diffs);
 
         $chunks = $diffs[0]->getChunks();
-        $this->assertInternalType('array', $chunks);
         $this->assertContainsOnlyInstancesOf(Chunk::class, $chunks);
 
         $this->assertCount(1, $chunks);
@@ -52,9 +51,9 @@ public function testParse()
         $this->assertCount(4, $chunks[0]->getLines());
     }
 
-    public function testParseWithMultipleChunks()
+    public function testParseWithMultipleChunks(): void
     {
-        $content = \file_get_contents(__DIR__ . '/fixtures/patch2.txt');
+        $content = FileUtils::getFileContent(__DIR__ . '/fixtures/patch2.txt');
 
         $diffs = $this->parser->parse($content);
 
@@ -72,9 +71,52 @@ public function testParseWithMultipleChunks()
         $this->assertCount(4, $chunks[2]->getLines());
     }
 
-    public function testParseWithRemovedLines()
+    public function testParseWithSpacesInFileNames(): void
     {
-        $content = <<<A
+        $content =
+<<<PATCH
+diff --git a/Foo Bar.txt b/Foo Bar.txt
+index abcdefg..abcdefh 100644
+--- a/Foo Bar.txt
++++ b/Foo Bar.txt
+@@ -20,4 +20,5 @@ class Foo
+     const ONE = 1;
+     const TWO = 2;
++    const THREE = 3;
+     const FOUR = 4;
+
+PATCH;
+
+        $diffs = $this->parser->parse($content);
+
+        $this->assertEquals('a/Foo Bar.txt', $diffs[0]->getFrom());
+        $this->assertEquals('b/Foo Bar.txt', $diffs[0]->getTo());
+    }
+
+    public function testParseWithSpacesInFileNamesAndTimesamp(): void
+    {
+        $content =
+            <<<PATCH
+diff --git a/Foo Bar.txt b/Foo Bar.txt
+index abcdefg..abcdefh 100644
+--- "a/Foo Bar.txt"  2020-10-02 13:31:52.938811371 +0200
++++ "b/Foo Bar.txt"  2020-10-02 13:31:50.022792064 +0200
+@@ -20,4 +20,5 @@ class Foo
+     const ONE = 1;
+     const TWO = 2;
++    const THREE = 3;
+     const FOUR = 4;
+PATCH;
+
+        $diffs = $this->parser->parse($content);
+
+        $this->assertEquals('a/Foo Bar.txt', $diffs[0]->getFrom());
+        $this->assertEquals('b/Foo Bar.txt', $diffs[0]->getTo());
+    }
+
+    public function testParseWithRemovedLines(): void
+    {
+        $content = <<<END
 diff --git a/Test.txt b/Test.txt
 index abcdefg..abcdefh 100644
 --- a/Test.txt
@@ -82,15 +124,13 @@ public function testParseWithRemovedLines()
 @@ -49,9 +49,8 @@
  A
 -B
-A;
+END;
         $diffs = $this->parser->parse($content);
-        $this->assertInternalType('array', $diffs);
         $this->assertContainsOnlyInstancesOf(Diff::class, $diffs);
         $this->assertCount(1, $diffs);
 
         $chunks = $diffs[0]->getChunks();
 
-        $this->assertInternalType('array', $chunks);
         $this->assertContainsOnlyInstancesOf(Chunk::class, $chunks);
         $this->assertCount(1, $chunks);
 
@@ -101,7 +141,6 @@ public function testParseWithRemovedLines()
         $this->assertSame(8, $chunk->getEndRange());
 
         $lines = $chunk->getLines();
-        $this->assertInternalType('array', $lines);
         $this->assertContainsOnlyInstancesOf(Line::class, $lines);
         $this->assertCount(2, $lines);
 
@@ -115,9 +154,9 @@ public function testParseWithRemovedLines()
         $this->assertSame(Line::REMOVED, $line->getType());
     }
 
-    public function testParseDiffForMulitpleFiles()
+    public function testParseDiffForMulitpleFiles(): void
     {
-        $content = <<<A
+        $content = <<<END
 diff --git a/Test.txt b/Test.txt
 index abcdefg..abcdefh 100644
 --- a/Test.txt
@@ -133,7 +172,7 @@ public function testParseDiffForMulitpleFiles()
 @@ -1,2 +1,3 @@
  A
 +B
-A;
+END;
         $diffs = $this->parser->parse($content);
         $this->assertCount(2, $diffs);
 
@@ -148,4 +187,26 @@ public function testParseDiffForMulitpleFiles()
         $this->assertSame('b/Test2.txt', $diff->getTo());
         $this->assertCount(1, $diff->getChunks());
     }
+
+    /**
+     * @psalm-param list<Diff> $expected
+     *
+     * @dataProvider diffProvider
+     */
+    public function testParser(string $diff, array $expected): void
+    {
+        $result = $this->parser->parse($diff);
+
+        $this->assertEquals($expected, $result);
+    }
+
+    public function diffProvider(): array
+    {
+        return [
+            [
+                "--- old.txt	2014-11-04 08:51:02.661868729 +0300\n+++ new.txt	2014-11-04 08:51:02.665868730 +0300\n@@ -1,3 +1,4 @@\n+2222111\n 1111111\n 1111111\n 1111111\n@@ -5,10 +6,8 @@\n 1111111\n 1111111\n 1111111\n +1121211\n 1111111\n -1111111\n -1111111\n -2222222\n 2222222\n 2222222\n 2222222\n@@ -17,5 +16,6 @@\n 2222222\n 2222222\n 2222222\n +2122212\n 2222222\n 2222222\n",
+                unserialize(FileUtils::getFileContent(__DIR__ . '/fixtures/serialized_diff.bin')),
+            ],
+        ];
+    }
 }
diff --git a/tests/TimeEfficientImplementationTest.php b/tests/TimeEfficientImplementationTest.php
index 713ccb88..1958ccab 100644
--- a/tests/TimeEfficientImplementationTest.php
+++ b/tests/TimeEfficientImplementationTest.php
@@ -7,15 +7,14 @@
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
  */
-
 namespace SebastianBergmann\Diff;
 
 /**
- * @covers SebastianBergmann\Diff\TimeEfficientLongestCommonSubsequenceCalculator
+ * @covers \SebastianBergmann\Diff\TimeEfficientLongestCommonSubsequenceCalculator
  */
 final class TimeEfficientImplementationTest extends LongestCommonSubsequenceTest
 {
-    protected function createImplementation()
+    protected function createImplementation(): LongestCommonSubsequenceCalculator
     {
         return new TimeEfficientLongestCommonSubsequenceCalculator;
     }
diff --git a/tests/Utils/FileUtils.php b/tests/Utils/FileUtils.php
new file mode 100644
index 00000000..3ba9925c
--- /dev/null
+++ b/tests/Utils/FileUtils.php
@@ -0,0 +1,35 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of sebastian/diff.
+ *
+ * (c) Sebastian Bergmann <sebastian@phpunit.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+namespace SebastianBergmann\Diff\Utils;
+
+use function error_get_last;
+use function file_get_contents;
+use function sprintf;
+use RuntimeException;
+
+final class FileUtils
+{
+    public static function getFileContent(string $file): string
+    {
+        $content = @file_get_contents($file);
+
+        if (false === $content) {
+            $error = error_get_last();
+
+            throw new RuntimeException(sprintf(
+                'Failed to read content of file "%s".%s',
+                $file,
+                $error ? ' ' . $error['message'] : ''
+            ));
+        }
+
+        return $content;
+    }
+}
diff --git a/tests/Utils/UnifiedDiffAssertTrait.php b/tests/Utils/UnifiedDiffAssertTrait.php
new file mode 100644
index 00000000..8c659a50
--- /dev/null
+++ b/tests/Utils/UnifiedDiffAssertTrait.php
@@ -0,0 +1,275 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of sebastian/diff.
+ *
+ * (c) Sebastian Bergmann <sebastian@phpunit.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+namespace SebastianBergmann\Diff\Utils;
+
+use const PREG_SPLIT_DELIM_CAPTURE;
+use const PREG_SPLIT_NO_EMPTY;
+use function count;
+use function preg_match;
+use function preg_split;
+use function sprintf;
+use function strlen;
+use function strpos;
+use function substr;
+use RuntimeException;
+use UnexpectedValueException;
+
+trait UnifiedDiffAssertTrait
+{
+    /**
+     * @throws UnexpectedValueException
+     */
+    public function assertValidUnifiedDiffFormat(string $diff): void
+    {
+        if ('' === $diff) {
+            $this->addToAssertionCount(1);
+
+            return;
+        }
+
+        // test diff ends with a line break
+        $last = substr($diff, -1);
+
+        if ("\n" !== $last && "\r" !== $last) {
+            throw new UnexpectedValueException(sprintf('Expected diff to end with a line break, got "%s".', $last));
+        }
+
+        $lines            = preg_split('/(.*\R)/', $diff, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
+        $lineCount        = count($lines);
+        $lineNumber       = $diffLineFromNumber       = $diffLineToNumber       = 1;
+        $fromStart        = $fromTillOffset        = $toStart        = $toTillOffset        = -1;
+        $expectHunkHeader = true;
+
+        // check for header
+        if ($lineCount > 1) {
+            $this->unifiedDiffAssertLinePrefix($lines[0], 'Line 1.');
+            $this->unifiedDiffAssertLinePrefix($lines[1], 'Line 2.');
+
+            if ('---' === substr($lines[0], 0, 3)) {
+                if ('+++' !== substr($lines[1], 0, 3)) {
+                    throw new UnexpectedValueException(sprintf("Line 1 indicates a header, so line 2 must start with \"+++\".\nLine 1: \"%s\"\nLine 2: \"%s\".", $lines[0], $lines[1]));
+                }
+
+                $this->unifiedDiffAssertHeaderLine($lines[0], '--- ', 'Line 1.');
+                $this->unifiedDiffAssertHeaderLine($lines[1], '+++ ', 'Line 2.');
+
+                $lineNumber = 3;
+            }
+        }
+
+        $endOfLineTypes = [];
+        $diffClosed     = false;
+
+        // assert format of lines, get all hunks, test the line numbers
+        for (; $lineNumber <= $lineCount; ++$lineNumber) {
+            if ($diffClosed) {
+                throw new UnexpectedValueException(sprintf('Unexpected line as 2 "No newline" markers have found, ". Line %d.', $lineNumber));
+            }
+
+            $line = $lines[$lineNumber - 1]; // line numbers start by 1, array index at 0
+            $type = $this->unifiedDiffAssertLinePrefix($line, sprintf('Line %d.', $lineNumber));
+
+            if ($expectHunkHeader && '@' !== $type && '\\' !== $type) {
+                throw new UnexpectedValueException(sprintf('Expected hunk start (\'@\'), got "%s". Line %d.', $type, $lineNumber));
+            }
+
+            if ('@' === $type) {
+                if (!$expectHunkHeader) {
+                    throw new UnexpectedValueException(sprintf('Unexpected hunk start (\'@\'). Line %d.', $lineNumber));
+                }
+
+                $previousHunkFromEnd = $fromStart + $fromTillOffset;
+                $previousHunkTillEnd = $toStart + $toTillOffset;
+
+                [$fromStart, $fromTillOffset, $toStart, $toTillOffset] = $this->unifiedDiffAssertHunkHeader($line, sprintf('Line %d.', $lineNumber));
+
+                // detect overlapping hunks
+                if ($fromStart < $previousHunkFromEnd) {
+                    throw new UnexpectedValueException(sprintf('Unexpected new hunk; "from" (\'-\') start overlaps previous hunk. Line %d.', $lineNumber));
+                }
+
+                if ($toStart < $previousHunkTillEnd) {
+                    throw new UnexpectedValueException(sprintf('Unexpected new hunk; "to" (\'+\') start overlaps previous hunk. Line %d.', $lineNumber));
+                }
+
+                /* valid states; hunks touches against each other:
+                    $fromStart === $previousHunkFromEnd
+                    $toStart === $previousHunkTillEnd
+                */
+
+                $diffLineFromNumber = $fromStart;
+                $diffLineToNumber   = $toStart;
+                $expectHunkHeader   = false;
+
+                continue;
+            }
+
+            if ('-' === $type) {
+                if (isset($endOfLineTypes['-'])) {
+                    throw new UnexpectedValueException(sprintf('Not expected from (\'-\'), already closed by "\\ No newline at end of file". Line %d.', $lineNumber));
+                }
+
+                ++$diffLineFromNumber;
+            } elseif ('+' === $type) {
+                if (isset($endOfLineTypes['+'])) {
+                    throw new UnexpectedValueException(sprintf('Not expected to (\'+\'), already closed by "\\ No newline at end of file". Line %d.', $lineNumber));
+                }
+
+                ++$diffLineToNumber;
+            } elseif (' ' === $type) {
+                if (isset($endOfLineTypes['-'])) {
+                    throw new UnexpectedValueException(sprintf('Not expected same (\' \'), \'-\' already closed by "\\ No newline at end of file". Line %d.', $lineNumber));
+                }
+
+                if (isset($endOfLineTypes['+'])) {
+                    throw new UnexpectedValueException(sprintf('Not expected same (\' \'), \'+\' already closed by "\\ No newline at end of file". Line %d.', $lineNumber));
+                }
+
+                ++$diffLineFromNumber;
+                ++$diffLineToNumber;
+            } elseif ('\\' === $type) {
+                if (!isset($lines[$lineNumber - 2])) {
+                    throw new UnexpectedValueException(sprintf('Unexpected "\\ No newline at end of file", it must be preceded by \'+\' or \'-\' line. Line %d.', $lineNumber));
+                }
+
+                $previousType = $this->unifiedDiffAssertLinePrefix($lines[$lineNumber - 2], sprintf('Preceding line of "\\ No newline at end of file" of unexpected format. Line %d.', $lineNumber));
+
+                if (isset($endOfLineTypes[$previousType])) {
+                    throw new UnexpectedValueException(sprintf('Unexpected "\\ No newline at end of file", "%s" was already closed. Line %d.', $type, $lineNumber));
+                }
+
+                $endOfLineTypes[$previousType] = true;
+                $diffClosed                    = count($endOfLineTypes) > 1;
+            } else {
+                // internal state error
+                throw new RuntimeException(sprintf('Unexpected line type "%s" Line %d.', $type, $lineNumber));
+            }
+
+            $expectHunkHeader =
+                $diffLineFromNumber === ($fromStart + $fromTillOffset)
+                && $diffLineToNumber === ($toStart + $toTillOffset);
+        }
+
+        if (
+            $diffLineFromNumber !== ($fromStart + $fromTillOffset)
+            && $diffLineToNumber !== ($toStart + $toTillOffset)
+        ) {
+            throw new UnexpectedValueException(sprintf('Unexpected EOF, number of lines in hunk "from" (\'-\')) and "to" (\'+\') mismatched. Line %d.', $lineNumber));
+        }
+
+        if ($diffLineFromNumber !== ($fromStart + $fromTillOffset)) {
+            throw new UnexpectedValueException(sprintf('Unexpected EOF, number of lines in hunk "from" (\'-\')) mismatched. Line %d.', $lineNumber));
+        }
+
+        if ($diffLineToNumber !== ($toStart + $toTillOffset)) {
+            throw new UnexpectedValueException(sprintf('Unexpected EOF, number of lines in hunk "to" (\'+\')) mismatched. Line %d.', $lineNumber));
+        }
+
+        $this->addToAssertionCount(1);
+    }
+
+    /**
+     * @return string '+', '-', '@', ' ' or '\'
+     */
+    private function unifiedDiffAssertLinePrefix(string $line, string $message): string
+    {
+        $this->unifiedDiffAssertStrLength($line, 2, $message); // 2: line type indicator ('+', '-', ' ' or '\') and a line break
+        $firstChar = $line[0];
+
+        if ('+' === $firstChar || '-' === $firstChar || '@' === $firstChar || ' ' === $firstChar) {
+            return $firstChar;
+        }
+
+        if ("\\ No newline at end of file\n" === $line) {
+            return '\\';
+        }
+
+        throw new UnexpectedValueException(sprintf('Expected line to start with \'@\', \'-\' or \'+\', got "%s". %s', $line, $message));
+    }
+
+    private function unifiedDiffAssertStrLength(string $line, int $min, string $message): void
+    {
+        $length = strlen($line);
+
+        if ($length < $min) {
+            throw new UnexpectedValueException(sprintf('Expected string length of minimal %d, got %d. %s', $min, $length, $message));
+        }
+    }
+
+    /**
+     * Assert valid unified diff header line.
+     *
+     * Samples:
+     * - "+++ from1.txt\t2017-08-24 19:51:29.383985722 +0200"
+     * - "+++ from1.txt"
+     */
+    private function unifiedDiffAssertHeaderLine(string $line, string $start, string $message): void
+    {
+        if (0 !== strpos($line, $start)) {
+            throw new UnexpectedValueException(sprintf('Expected header line to start with "%s", got "%s". %s', $start . ' ', $line, $message));
+        }
+
+        // sample "+++ from1.txt\t2017-08-24 19:51:29.383985722 +0200\n"
+        $match = preg_match(
+            "/^([^\t]*)(?:[\t]([\\S].*[\\S]))?\n$/",
+            substr($line, 4), // 4 === string length of "+++ " / "--- "
+            $matches
+        );
+
+        if (1 !== $match) {
+            throw new UnexpectedValueException(sprintf('Header line does not match expected pattern, got "%s". %s', $line, $message));
+        }
+
+        // $file = $matches[1];
+
+        if (count($matches) > 2) {
+            $this->unifiedDiffAssertHeaderDate($matches[2], $message);
+        }
+    }
+
+    private function unifiedDiffAssertHeaderDate(string $date, string $message): void
+    {
+        // sample "2017-08-24 19:51:29.383985722 +0200"
+        $match = preg_match(
+            '/^([\d]{4})-([01]?[\d])-([0123]?[\d])(:? [\d]{1,2}:[\d]{1,2}(?::[\d]{1,2}(:?\.[\d]+)?)?(?: ([\+\-][\d]{4}))?)?$/',
+            $date,
+            $matches
+        );
+
+        if (1 !== $match || ($matchesCount = count($matches)) < 4) {
+            throw new UnexpectedValueException(sprintf('Date of header line does not match expected pattern, got "%s". %s', $date, $message));
+        }
+
+        // [$full, $year, $month, $day, $time] = $matches;
+    }
+
+    /**
+     * @return int[]
+     */
+    private function unifiedDiffAssertHunkHeader(string $line, string $message): array
+    {
+        if (1 !== preg_match('#^@@ -([\d]+)((?:,[\d]+)?) \+([\d]+)((?:,[\d]+)?) @@\n$#', $line, $matches)) {
+            throw new UnexpectedValueException(
+                sprintf(
+                    'Hunk header line does not match expected pattern, got "%s". %s',
+                    $line,
+                    $message
+                )
+            );
+        }
+
+        return [
+            (int) $matches[1],
+            empty($matches[2]) ? 1 : (int) substr($matches[2], 1),
+            (int) $matches[3],
+            empty($matches[4]) ? 1 : (int) substr($matches[4], 1),
+        ];
+    }
+}
diff --git a/tests/Utils/UnifiedDiffAssertTraitIntegrationTest.php b/tests/Utils/UnifiedDiffAssertTraitIntegrationTest.php
new file mode 100644
index 00000000..e1d52248
--- /dev/null
+++ b/tests/Utils/UnifiedDiffAssertTraitIntegrationTest.php
@@ -0,0 +1,134 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of sebastian/diff.
+ *
+ * (c) Sebastian Bergmann <sebastian@phpunit.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+namespace SebastianBergmann\Diff\Utils;
+
+use function file_exists;
+use function realpath;
+use function sprintf;
+use function strlen;
+use function substr;
+use function unlink;
+use PHPUnit\Framework\TestCase;
+use RecursiveDirectoryIterator;
+use RecursiveIteratorIterator;
+use SplFileInfo;
+use Symfony\Component\Process\Process;
+
+/**
+ * @requires OS Linux
+ *
+ * @coversNothing
+ */
+final class UnifiedDiffAssertTraitIntegrationTest extends TestCase
+{
+    use UnifiedDiffAssertTrait;
+
+    private $filePatch;
+
+    protected function setUp(): void
+    {
+        $this->filePatch = __DIR__ . '/../fixtures/out/patch.txt';
+
+        $this->cleanUpTempFiles();
+    }
+
+    protected function tearDown(): void
+    {
+        $this->cleanUpTempFiles();
+    }
+
+    /**
+     * @dataProvider provideFilePairsCases
+     */
+    public function testValidPatches(string $fileFrom, string $fileTo): void
+    {
+        $p = Process::fromShellCommandline('diff -u $from $to > $patch');
+        $p->run(
+            null,
+            [
+                'from'  => realpath($fileFrom),
+                'to'    => realpath($fileTo),
+                'patch' => $this->filePatch,
+            ]
+        );
+
+        $exitCode = $p->getExitCode();
+
+        if (0 === $exitCode) {
+            // odd case when two files have the same content. Test after executing as it is more efficient than to read the files and check the contents every time.
+            $this->addToAssertionCount(1);
+
+            return;
+        }
+
+        $this->assertSame(
+            1, // means `diff` found a diff between the files we gave it
+            $exitCode,
+            sprintf(
+                "Command exec. was not successful:\n\"%s\"\nOutput:\n\"%s\"\nStdErr:\n\"%s\"\nExit code %d.\n",
+                $p->getCommandLine(),
+                $p->getOutput(),
+                $p->getErrorOutput(),
+                $p->getExitCode()
+            )
+        );
+
+        $this->assertValidUnifiedDiffFormat(FileUtils::getFileContent($this->filePatch));
+    }
+
+    /**
+     * @return array<string, array<string, string>>
+     */
+    public function provideFilePairsCases(): array
+    {
+        $cases = [];
+
+        // created cases based on dedicated fixtures
+        $dir       = realpath(__DIR__ . '/../fixtures/UnifiedDiffAssertTraitIntegrationTest');
+        $dirLength = strlen($dir);
+
+        for ($i = 1;; ++$i) {
+            $fromFile = sprintf('%s/%d_a.txt', $dir, $i);
+            $toFile   = sprintf('%s/%d_b.txt', $dir, $i);
+
+            if (!file_exists($fromFile)) {
+                break;
+            }
+
+            $this->assertFileExists($toFile);
+            $cases[sprintf("Diff file:\n\"%s\"\nvs.\n\"%s\"\n", substr(realpath($fromFile), $dirLength), substr(realpath($toFile), $dirLength))] = [$fromFile, $toFile];
+        }
+
+        // create cases based on PHP files within the vendor directory for integration testing
+        $dir       = realpath(__DIR__ . '/../../vendor');
+        $dirLength = strlen($dir);
+
+        $fileIterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS));
+        $fromFile     = __FILE__;
+
+        /** @var SplFileInfo $file */
+        foreach ($fileIterator as $file) {
+            if ('php' !== $file->getExtension()) {
+                continue;
+            }
+
+            $toFile                                                                                                                                   = $file->getPathname();
+            $cases[sprintf("Diff file:\n\"%s\"\nvs.\n\"%s\"\n", substr(realpath($fromFile), $dirLength), substr(realpath($toFile), $dirLength))]      = [$fromFile, $toFile];
+            $fromFile                                                                                                                                 = $toFile;
+        }
+
+        return $cases;
+    }
+
+    private function cleanUpTempFiles(): void
+    {
+        @unlink($this->filePatch);
+    }
+}
diff --git a/tests/Utils/UnifiedDiffAssertTraitTest.php b/tests/Utils/UnifiedDiffAssertTraitTest.php
new file mode 100644
index 00000000..b4f284a3
--- /dev/null
+++ b/tests/Utils/UnifiedDiffAssertTraitTest.php
@@ -0,0 +1,434 @@
+<?php declare(strict_types=1);
+/*
+ * This file is part of sebastian/diff.
+ *
+ * (c) Sebastian Bergmann <sebastian@phpunit.de>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+namespace SebastianBergmann\Diff\Utils;
+
+use function preg_quote;
+use function sprintf;
+use PHPUnit\Framework\TestCase;
+use UnexpectedValueException;
+
+/**
+ * @covers \SebastianBergmann\Diff\Utils\UnifiedDiffAssertTrait
+ */
+final class UnifiedDiffAssertTraitTest extends TestCase
+{
+    use UnifiedDiffAssertTrait;
+
+    /**
+     * @dataProvider provideValidCases
+     */
+    public function testValidCases(string $diff): void
+    {
+        $this->assertValidUnifiedDiffFormat($diff);
+    }
+
+    public function provideValidCases(): array
+    {
+        return [
+            [
+                '--- Original
++++ New
+@@ -8 +8 @@
+-Z
++U
+',
+            ],
+            [
+                '--- Original
++++ New
+@@ -8 +8 @@
+-Z
++U
+@@ -15 +15 @@
+-X
++V
+',
+            ],
+            'empty diff. is valid' => [
+                '',
+            ],
+        ];
+    }
+
+    public function testNoLinebreakEnd(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+        $this->expectExceptionMessageMatches(sprintf('#^%s$#', preg_quote('Expected diff to end with a line break, got "C".', '#')));
+
+        $this->assertValidUnifiedDiffFormat("A\nB\nC");
+    }
+
+    public function testInvalidStartWithoutHeader(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+        $this->expectExceptionMessageMatches(sprintf('#^%s$#', preg_quote("Expected line to start with '@', '-' or '+', got \"A\n\". Line 1.", '#')));
+
+        $this->assertValidUnifiedDiffFormat("A\n");
+    }
+
+    public function testInvalidStartHeader1(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+        $this->expectExceptionMessageMatches(sprintf('#^%s$#', preg_quote("Line 1 indicates a header, so line 2 must start with \"+++\".\nLine 1: \"--- A\n\"\nLine 2: \"+ 1\n\".", '#')));
+
+        $this->assertValidUnifiedDiffFormat("--- A\n+ 1\n");
+    }
+
+    public function testInvalidStartHeader2(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+        $this->expectExceptionMessageMatches(sprintf('#^%s$#', preg_quote("Header line does not match expected pattern, got \"+++ file	X\n\". Line 2.", '#')));
+
+        $this->assertValidUnifiedDiffFormat("--- A\n+++ file\tX\n");
+    }
+
+    public function testInvalidStartHeader3(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+        $this->expectExceptionMessageMatches(sprintf('#^%s$#', preg_quote('Date of header line does not match expected pattern, got "[invalid date]". Line 1.', '#')));
+
+        $this->assertValidUnifiedDiffFormat(
+            "--- Original\t[invalid date]
++++ New
+@@ -1,2 +1,2 @@
+-A
++B
+ " . '
+'
+        );
+    }
+
+    public function testInvalidStartHeader4(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+        $this->expectExceptionMessageMatches(sprintf('#^%s$#', preg_quote("Expected header line to start with \"+++  \", got \"+++INVALID\n\". Line 2.", '#')));
+
+        $this->assertValidUnifiedDiffFormat(
+            '--- Original
++++INVALID
+@@ -1,2 +1,2 @@
+-A
++B
+ ' . '
+'
+        );
+    }
+
+    public function testInvalidLine1(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+        $this->expectExceptionMessageMatches(sprintf('#^%s$#', preg_quote("Expected line to start with '@', '-' or '+', got \"1\n\". Line 5.", '#')));
+
+        $this->assertValidUnifiedDiffFormat(
+            '--- Original
++++ New
+@@ -8 +8 @@
+-Z
+1
++U
+'
+        );
+    }
+
+    public function testInvalidLine2(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+        $this->expectExceptionMessageMatches(sprintf('#^%s$#', preg_quote('Expected string length of minimal 2, got 1. Line 4.', '#')));
+
+        $this->assertValidUnifiedDiffFormat(
+            '--- Original
++++ New
+@@ -8 +8 @@
+
+
+'
+        );
+    }
+
+    public function testHunkInvalidFormat(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+        $this->expectExceptionMessageMatches(sprintf('#^%s$#', preg_quote("Hunk header line does not match expected pattern, got \"@@ INVALID -1,1 +1,1 @@\n\". Line 3.", '#')));
+
+        $this->assertValidUnifiedDiffFormat(
+            '--- Original
++++ New
+@@ INVALID -1,1 +1,1 @@
+-Z
++U
+'
+        );
+    }
+
+    public function testHunkOverlapFrom(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+        $this->expectExceptionMessageMatches(sprintf('#^%s$#', preg_quote('Unexpected new hunk; "from" (\'-\') start overlaps previous hunk. Line 6.', '#')));
+
+        $this->assertValidUnifiedDiffFormat(
+            '--- Original
++++ New
+@@ -8,1 +8,1 @@
+-Z
++U
+@@ -7,1 +9,1 @@
+-Z
++U
+'
+        );
+    }
+
+    public function testHunkOverlapTo(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+        $this->expectExceptionMessageMatches(sprintf('#^%s$#', preg_quote('Unexpected new hunk; "to" (\'+\') start overlaps previous hunk. Line 6.', '#')));
+
+        $this->assertValidUnifiedDiffFormat(
+            '--- Original
++++ New
+@@ -8,1 +8,1 @@
+-Z
++U
+@@ -17,1 +7,1 @@
+-Z
++U
+'
+        );
+    }
+
+    public function testExpectHunk1(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+        $this->expectExceptionMessageMatches(sprintf('#^%s$#', preg_quote('Expected hunk start (\'@\'), got "+". Line 6.', '#')));
+
+        $this->assertValidUnifiedDiffFormat(
+            '--- Original
++++ New
+@@ -8 +8 @@
+-Z
++U
++O
+'
+        );
+    }
+
+    public function testExpectHunk2(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+        $this->expectExceptionMessageMatches(sprintf('#^%s$#', preg_quote('Unexpected hunk start (\'@\'). Line 6.', '#')));
+
+        $this->assertValidUnifiedDiffFormat(
+            '--- Original
++++ New
+@@ -8,12 +8,12 @@
+ ' . '
+ ' . '
+@@ -38,12 +48,12 @@
+'
+        );
+    }
+
+    public function testMisplacedLineAfterComments1(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+        $this->expectExceptionMessageMatches(sprintf('#^%s$#', preg_quote('Unexpected line as 2 "No newline" markers have found, ". Line 8.', '#')));
+
+        $this->assertValidUnifiedDiffFormat(
+            '--- Original
++++ New
+@@ -8 +8 @@
+-Z
+\ No newline at end of file
++U
+\ No newline at end of file
++A
+'
+        );
+    }
+
+    public function testMisplacedLineAfterComments2(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+        $this->expectExceptionMessageMatches(sprintf('#^%s$#', preg_quote('Unexpected line as 2 "No newline" markers have found, ". Line 7.', '#')));
+
+        $this->assertValidUnifiedDiffFormat(
+            '--- Original
++++ New
+@@ -8 +8 @@
++U
+\ No newline at end of file
+\ No newline at end of file
+\ No newline at end of file
+'
+        );
+    }
+
+    public function testMisplacedLineAfterComments3(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+        $this->expectExceptionMessageMatches(sprintf('#^%s$#', preg_quote('Unexpected line as 2 "No newline" markers have found, ". Line 7.', '#')));
+
+        $this->assertValidUnifiedDiffFormat(
+            '--- Original
++++ New
+@@ -8 +8 @@
++U
+\ No newline at end of file
+\ No newline at end of file
++A
+'
+        );
+    }
+
+    public function testMisplacedComment(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+        $this->expectExceptionMessageMatches(sprintf('#^%s$#', preg_quote('Unexpected "\ No newline at end of file", it must be preceded by \'+\' or \'-\' line. Line 1.', '#')));
+
+        $this->assertValidUnifiedDiffFormat(
+            '\ No newline at end of file
+'
+        );
+    }
+
+    public function testUnexpectedDuplicateNoNewLineEOF(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+        $this->expectExceptionMessageMatches(sprintf('#^%s$#', preg_quote('Unexpected "\\ No newline at end of file", "\\" was already closed. Line 8.', '#')));
+
+        $this->assertValidUnifiedDiffFormat(
+            '--- Original
++++ New
+@@ -8,12 +8,12 @@
+ ' . '
+ ' . '
+\ No newline at end of file
+ ' . '
+\ No newline at end of file
+'
+        );
+    }
+
+    public function testFromAfterClose(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+        $this->expectExceptionMessageMatches(sprintf('#^%s$#', preg_quote('Not expected from (\'-\'), already closed by "\ No newline at end of file". Line 6.', '#')));
+
+        $this->assertValidUnifiedDiffFormat(
+            '--- Original
++++ New
+@@ -8,12 +8,12 @@
+-A
+\ No newline at end of file
+-A
+\ No newline at end of file
+'
+        );
+    }
+
+    public function testSameAfterFromClose(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+        $this->expectExceptionMessageMatches(sprintf('#^%s$#', preg_quote('Not expected same (\' \'), \'-\' already closed by "\ No newline at end of file". Line 6.', '#')));
+
+        $this->assertValidUnifiedDiffFormat(
+            '--- Original
++++ New
+@@ -8,12 +8,12 @@
+-A
+\ No newline at end of file
+ A
+\ No newline at end of file
+'
+        );
+    }
+
+    public function testToAfterClose(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+        $this->expectExceptionMessageMatches(sprintf('#^%s$#', preg_quote('Not expected to (\'+\'), already closed by "\ No newline at end of file". Line 6.', '#')));
+
+        $this->assertValidUnifiedDiffFormat(
+            '--- Original
++++ New
+@@ -8,12 +8,12 @@
++A
+\ No newline at end of file
++A
+\ No newline at end of file
+'
+        );
+    }
+
+    public function testSameAfterToClose(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+        $this->expectExceptionMessageMatches(sprintf('#^%s$#', preg_quote('Not expected same (\' \'), \'+\' already closed by "\ No newline at end of file". Line 6.', '#')));
+
+        $this->assertValidUnifiedDiffFormat(
+            '--- Original
++++ New
+@@ -8,12 +8,12 @@
++A
+\ No newline at end of file
+ A
+\ No newline at end of file
+'
+        );
+    }
+
+    public function testUnexpectedEOFFromMissingLines(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+        $this->expectExceptionMessageMatches(sprintf('#^%s$#', preg_quote('Unexpected EOF, number of lines in hunk "from" (\'-\')) mismatched. Line 7.', '#')));
+
+        $this->assertValidUnifiedDiffFormat(
+            '--- Original
++++ New
+@@ -8,19 +7,2 @@
+-A
++B
+ ' . '
+'
+        );
+    }
+
+    public function testUnexpectedEOFToMissingLines(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+        $this->expectExceptionMessageMatches(sprintf('#^%s$#', preg_quote('Unexpected EOF, number of lines in hunk "to" (\'+\')) mismatched. Line 7.', '#')));
+
+        $this->assertValidUnifiedDiffFormat(
+            '--- Original
++++ New
+@@ -8,2 +7,3 @@
+-A
++B
+ ' . '
+'
+        );
+    }
+
+    public function testUnexpectedEOFBothFromAndToMissingLines(): void
+    {
+        $this->expectException(UnexpectedValueException::class);
+        $this->expectExceptionMessageMatches(sprintf('#^%s$#', preg_quote('Unexpected EOF, number of lines in hunk "from" (\'-\')) and "to" (\'+\') mismatched. Line 7.', '#')));
+
+        $this->assertValidUnifiedDiffFormat(
+            '--- Original
++++ New
+@@ -1,12 +1,14 @@
+-A
++B
+ ' . '
+'
+        );
+    }
+}
diff --git a/tests/fixtures/.editorconfig b/tests/fixtures/.editorconfig
new file mode 100644
index 00000000..78b36ca0
--- /dev/null
+++ b/tests/fixtures/.editorconfig
@@ -0,0 +1 @@
+root = true
diff --git a/tests/fixtures/UnifiedDiffAssertTraitIntegrationTest/1_a.txt b/tests/fixtures/UnifiedDiffAssertTraitIntegrationTest/1_a.txt
new file mode 100644
index 00000000..2e65efe2
--- /dev/null
+++ b/tests/fixtures/UnifiedDiffAssertTraitIntegrationTest/1_a.txt
@@ -0,0 +1 @@
+a
\ No newline at end of file
diff --git a/tests/fixtures/UnifiedDiffAssertTraitIntegrationTest/1_b.txt b/tests/fixtures/UnifiedDiffAssertTraitIntegrationTest/1_b.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/fixtures/UnifiedDiffAssertTraitIntegrationTest/2_a.txt b/tests/fixtures/UnifiedDiffAssertTraitIntegrationTest/2_a.txt
new file mode 100644
index 00000000..c7fe26e9
--- /dev/null
+++ b/tests/fixtures/UnifiedDiffAssertTraitIntegrationTest/2_a.txt
@@ -0,0 +1,35 @@
+a
+a
+a
+a
+a
+a
+a
+a
+a
+a
+a
+a
+a
+a
+a
+a
+a
+a
+a
+a
+a
+a
+a
+a
+a
+a
+a
+a
+a
+a
+a
+a
+a
+a
+a
\ No newline at end of file
diff --git a/tests/fixtures/UnifiedDiffAssertTraitIntegrationTest/2_b.txt b/tests/fixtures/UnifiedDiffAssertTraitIntegrationTest/2_b.txt
new file mode 100644
index 00000000..377a70f8
--- /dev/null
+++ b/tests/fixtures/UnifiedDiffAssertTraitIntegrationTest/2_b.txt
@@ -0,0 +1,18 @@
+a
+a
+a
+a
+a
+a
+a
+a
+a
+a
+b
+a
+a
+a
+a
+a
+a
+c
\ No newline at end of file
diff --git a/tests/fixtures/out/.editorconfig b/tests/fixtures/out/.editorconfig
new file mode 100644
index 00000000..78b36ca0
--- /dev/null
+++ b/tests/fixtures/out/.editorconfig
@@ -0,0 +1 @@
+root = true
diff --git a/tests/fixtures/out/.gitignore b/tests/fixtures/out/.gitignore
new file mode 100644
index 00000000..f6f7a478
--- /dev/null
+++ b/tests/fixtures/out/.gitignore
@@ -0,0 +1,2 @@
+# reset all ignore rules to create sandbox for integration test
+!/**
\ No newline at end of file
diff --git a/tests/fixtures/serialized_diff.bin b/tests/fixtures/serialized_diff.bin
new file mode 100644
index 00000000..377404d4
Binary files /dev/null and b/tests/fixtures/serialized_diff.bin differ
diff --git a/tools/composer b/tools/composer
new file mode 100755
index 00000000..c435e898
Binary files /dev/null and b/tools/composer differ
diff --git a/tools/php-cs-fixer b/tools/php-cs-fixer
new file mode 100755
index 00000000..662b708e
Binary files /dev/null and b/tools/php-cs-fixer differ
diff --git a/tools/psalm b/tools/psalm
new file mode 100755
index 00000000..f91f1195
Binary files /dev/null and b/tools/psalm differ
diff --git a/tools/roave-backward-compatibility-check b/tools/roave-backward-compatibility-check
new file mode 100755
index 00000000..f6fc5ec0
Binary files /dev/null and b/tools/roave-backward-compatibility-check differ
