StrictUnifiedDiffOutputBuilder.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. <?php declare(strict_types=1);
  2. /*
  3. * This file is part of sebastian/diff.
  4. *
  5. * (c) Sebastian Bergmann <sebastian@phpunit.de>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace SebastianBergmann\Diff\Output;
  11. use function array_merge;
  12. use function array_splice;
  13. use function count;
  14. use function fclose;
  15. use function fopen;
  16. use function fwrite;
  17. use function is_bool;
  18. use function is_int;
  19. use function is_string;
  20. use function max;
  21. use function min;
  22. use function sprintf;
  23. use function stream_get_contents;
  24. use function substr;
  25. use SebastianBergmann\Diff\ConfigurationException;
  26. use SebastianBergmann\Diff\Differ;
  27. /**
  28. * Strict Unified diff output builder.
  29. *
  30. * Generates (strict) Unified diff's (unidiffs) with hunks.
  31. */
  32. final class StrictUnifiedDiffOutputBuilder implements DiffOutputBuilderInterface
  33. {
  34. private static array $default = [
  35. 'collapseRanges' => true, // ranges of length one are rendered with the trailing `,1`
  36. 'commonLineThreshold' => 6, // number of same lines before ending a new hunk and creating a new one (if needed)
  37. 'contextLines' => 3, // like `diff: -u, -U NUM, --unified[=NUM]`, for patch/git apply compatibility best to keep at least @ 3
  38. 'fromFile' => null,
  39. 'fromFileDate' => null,
  40. 'toFile' => null,
  41. 'toFileDate' => null,
  42. ];
  43. private bool $changed;
  44. private bool $collapseRanges;
  45. /**
  46. * @psalm-var positive-int
  47. */
  48. private int $commonLineThreshold;
  49. private string $header;
  50. /**
  51. * @psalm-var positive-int
  52. */
  53. private int $contextLines;
  54. public function __construct(array $options = [])
  55. {
  56. $options = array_merge(self::$default, $options);
  57. if (!is_bool($options['collapseRanges'])) {
  58. throw new ConfigurationException('collapseRanges', 'a bool', $options['collapseRanges']);
  59. }
  60. if (!is_int($options['contextLines']) || $options['contextLines'] < 0) {
  61. throw new ConfigurationException('contextLines', 'an int >= 0', $options['contextLines']);
  62. }
  63. if (!is_int($options['commonLineThreshold']) || $options['commonLineThreshold'] <= 0) {
  64. throw new ConfigurationException('commonLineThreshold', 'an int > 0', $options['commonLineThreshold']);
  65. }
  66. $this->assertString($options, 'fromFile');
  67. $this->assertString($options, 'toFile');
  68. $this->assertStringOrNull($options, 'fromFileDate');
  69. $this->assertStringOrNull($options, 'toFileDate');
  70. $this->header = sprintf(
  71. "--- %s%s\n+++ %s%s\n",
  72. $options['fromFile'],
  73. null === $options['fromFileDate'] ? '' : "\t" . $options['fromFileDate'],
  74. $options['toFile'],
  75. null === $options['toFileDate'] ? '' : "\t" . $options['toFileDate'],
  76. );
  77. $this->collapseRanges = $options['collapseRanges'];
  78. $this->commonLineThreshold = $options['commonLineThreshold'];
  79. $this->contextLines = $options['contextLines'];
  80. }
  81. public function getDiff(array $diff): string
  82. {
  83. if (0 === count($diff)) {
  84. return '';
  85. }
  86. $this->changed = false;
  87. $buffer = fopen('php://memory', 'r+b');
  88. fwrite($buffer, $this->header);
  89. $this->writeDiffHunks($buffer, $diff);
  90. if (!$this->changed) {
  91. fclose($buffer);
  92. return '';
  93. }
  94. $diff = stream_get_contents($buffer, -1, 0);
  95. fclose($buffer);
  96. // If the last char is not a linebreak: add it.
  97. // This might happen when both the `from` and `to` do not have a trailing linebreak
  98. $last = substr($diff, -1);
  99. return "\n" !== $last && "\r" !== $last
  100. ? $diff . "\n"
  101. : $diff;
  102. }
  103. private function writeDiffHunks($output, array $diff): void
  104. {
  105. // detect "No newline at end of file" and insert into `$diff` if needed
  106. $upperLimit = count($diff);
  107. if (0 === $diff[$upperLimit - 1][1]) {
  108. $lc = substr($diff[$upperLimit - 1][0], -1);
  109. if ("\n" !== $lc) {
  110. array_splice($diff, $upperLimit, 0, [["\n\\ No newline at end of file\n", Differ::NO_LINE_END_EOF_WARNING]]);
  111. }
  112. } else {
  113. // search back for the last `+` and `-` line,
  114. // check if it has a trailing linebreak, else add a warning under it
  115. $toFind = [1 => true, 2 => true];
  116. for ($i = $upperLimit - 1; $i >= 0; $i--) {
  117. if (isset($toFind[$diff[$i][1]])) {
  118. unset($toFind[$diff[$i][1]]);
  119. $lc = substr($diff[$i][0], -1);
  120. if ("\n" !== $lc) {
  121. array_splice($diff, $i + 1, 0, [["\n\\ No newline at end of file\n", Differ::NO_LINE_END_EOF_WARNING]]);
  122. }
  123. if (!count($toFind)) {
  124. break;
  125. }
  126. }
  127. }
  128. }
  129. // write hunks to output buffer
  130. $cutOff = max($this->commonLineThreshold, $this->contextLines);
  131. $hunkCapture = false;
  132. $sameCount = $toRange = $fromRange = 0;
  133. $toStart = $fromStart = 1;
  134. $i = 0;
  135. /** @var int $i */
  136. foreach ($diff as $i => $entry) {
  137. if (0 === $entry[1]) { // same
  138. if (false === $hunkCapture) {
  139. $fromStart++;
  140. $toStart++;
  141. continue;
  142. }
  143. $sameCount++;
  144. $toRange++;
  145. $fromRange++;
  146. if ($sameCount === $cutOff) {
  147. $contextStartOffset = ($hunkCapture - $this->contextLines) < 0
  148. ? $hunkCapture
  149. : $this->contextLines;
  150. // note: $contextEndOffset = $this->contextLines;
  151. //
  152. // because we never go beyond the end of the diff.
  153. // with the cutoff/contextlines here the follow is never true;
  154. //
  155. // if ($i - $cutOff + $this->contextLines + 1 > \count($diff)) {
  156. // $contextEndOffset = count($diff) - 1;
  157. // }
  158. //
  159. // ; that would be true for a trailing incomplete hunk case which is dealt with after this loop
  160. $this->writeHunk(
  161. $diff,
  162. $hunkCapture - $contextStartOffset,
  163. $i - $cutOff + $this->contextLines + 1,
  164. $fromStart - $contextStartOffset,
  165. $fromRange - $cutOff + $contextStartOffset + $this->contextLines,
  166. $toStart - $contextStartOffset,
  167. $toRange - $cutOff + $contextStartOffset + $this->contextLines,
  168. $output,
  169. );
  170. $fromStart += $fromRange;
  171. $toStart += $toRange;
  172. $hunkCapture = false;
  173. $sameCount = $toRange = $fromRange = 0;
  174. }
  175. continue;
  176. }
  177. $sameCount = 0;
  178. if ($entry[1] === Differ::NO_LINE_END_EOF_WARNING) {
  179. continue;
  180. }
  181. $this->changed = true;
  182. if (false === $hunkCapture) {
  183. $hunkCapture = $i;
  184. }
  185. if (Differ::ADDED === $entry[1]) { // added
  186. $toRange++;
  187. }
  188. if (Differ::REMOVED === $entry[1]) { // removed
  189. $fromRange++;
  190. }
  191. }
  192. if (false === $hunkCapture) {
  193. return;
  194. }
  195. // we end here when cutoff (commonLineThreshold) was not reached, but we were capturing a hunk,
  196. // do not render hunk till end automatically because the number of context lines might be less than the commonLineThreshold
  197. $contextStartOffset = $hunkCapture - $this->contextLines < 0
  198. ? $hunkCapture
  199. : $this->contextLines;
  200. // prevent trying to write out more common lines than there are in the diff _and_
  201. // do not write more than configured through the context lines
  202. $contextEndOffset = min($sameCount, $this->contextLines);
  203. $fromRange -= $sameCount;
  204. $toRange -= $sameCount;
  205. $this->writeHunk(
  206. $diff,
  207. $hunkCapture - $contextStartOffset,
  208. $i - $sameCount + $contextEndOffset + 1,
  209. $fromStart - $contextStartOffset,
  210. $fromRange + $contextStartOffset + $contextEndOffset,
  211. $toStart - $contextStartOffset,
  212. $toRange + $contextStartOffset + $contextEndOffset,
  213. $output,
  214. );
  215. }
  216. private function writeHunk(
  217. array $diff,
  218. int $diffStartIndex,
  219. int $diffEndIndex,
  220. int $fromStart,
  221. int $fromRange,
  222. int $toStart,
  223. int $toRange,
  224. $output
  225. ): void {
  226. fwrite($output, '@@ -' . $fromStart);
  227. if (!$this->collapseRanges || 1 !== $fromRange) {
  228. fwrite($output, ',' . $fromRange);
  229. }
  230. fwrite($output, ' +' . $toStart);
  231. if (!$this->collapseRanges || 1 !== $toRange) {
  232. fwrite($output, ',' . $toRange);
  233. }
  234. fwrite($output, " @@\n");
  235. for ($i = $diffStartIndex; $i < $diffEndIndex; $i++) {
  236. if ($diff[$i][1] === Differ::ADDED) {
  237. $this->changed = true;
  238. fwrite($output, '+' . $diff[$i][0]);
  239. } elseif ($diff[$i][1] === Differ::REMOVED) {
  240. $this->changed = true;
  241. fwrite($output, '-' . $diff[$i][0]);
  242. } elseif ($diff[$i][1] === Differ::OLD) {
  243. fwrite($output, ' ' . $diff[$i][0]);
  244. } elseif ($diff[$i][1] === Differ::NO_LINE_END_EOF_WARNING) {
  245. $this->changed = true;
  246. fwrite($output, $diff[$i][0]);
  247. }
  248. // } elseif ($diff[$i][1] === Differ::DIFF_LINE_END_WARNING) { // custom comment inserted by PHPUnit/diff package
  249. // skip
  250. // } else {
  251. // unknown/invalid
  252. // }
  253. }
  254. }
  255. private function assertString(array $options, string $option): void
  256. {
  257. if (!is_string($options[$option])) {
  258. throw new ConfigurationException($option, 'a string', $options[$option]);
  259. }
  260. }
  261. private function assertStringOrNull(array $options, string $option): void
  262. {
  263. if (null !== $options[$option] && !is_string($options[$option])) {
  264. throw new ConfigurationException($option, 'a string or <null>', $options[$option]);
  265. }
  266. }
  267. }