UnifiedDiffAssertTrait.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  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\Utils;
  11. trait UnifiedDiffAssertTrait
  12. {
  13. /**
  14. * @param string $diff
  15. *
  16. * @throws \UnexpectedValueException
  17. */
  18. public function assertValidUnifiedDiffFormat(string $diff): void
  19. {
  20. if ('' === $diff) {
  21. $this->addToAssertionCount(1);
  22. return;
  23. }
  24. // test diff ends with a line break
  25. $last = \substr($diff, -1);
  26. if ("\n" !== $last && "\r" !== $last) {
  27. throw new \UnexpectedValueException(\sprintf('Expected diff to end with a line break, got "%s".', $last));
  28. }
  29. $lines = \preg_split('/(.*\R)/', $diff, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
  30. $lineCount = \count($lines);
  31. $lineNumber = $diffLineFromNumber = $diffLineToNumber = 1;
  32. $fromStart = $fromTillOffset = $toStart = $toTillOffset = -1;
  33. $expectHunkHeader = true;
  34. // check for header
  35. if ($lineCount > 1) {
  36. $this->unifiedDiffAssertLinePrefix($lines[0], 'Line 1.');
  37. $this->unifiedDiffAssertLinePrefix($lines[1], 'Line 2.');
  38. if ('---' === \substr($lines[0], 0, 3)) {
  39. if ('+++' !== \substr($lines[1], 0, 3)) {
  40. 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]));
  41. }
  42. $this->unifiedDiffAssertHeaderLine($lines[0], '--- ', 'Line 1.');
  43. $this->unifiedDiffAssertHeaderLine($lines[1], '+++ ', 'Line 2.');
  44. $lineNumber = 3;
  45. }
  46. }
  47. $endOfLineTypes = [];
  48. $diffClosed = false;
  49. // assert format of lines, get all hunks, test the line numbers
  50. for (; $lineNumber <= $lineCount; ++$lineNumber) {
  51. if ($diffClosed) {
  52. throw new \UnexpectedValueException(\sprintf('Unexpected line as 2 "No newline" markers have found, ". Line %d.', $lineNumber));
  53. }
  54. $line = $lines[$lineNumber - 1]; // line numbers start by 1, array index at 0
  55. $type = $this->unifiedDiffAssertLinePrefix($line, \sprintf('Line %d.', $lineNumber));
  56. if ($expectHunkHeader && '@' !== $type && '\\' !== $type) {
  57. throw new \UnexpectedValueException(\sprintf('Expected hunk start (\'@\'), got "%s". Line %d.', $type, $lineNumber));
  58. }
  59. if ('@' === $type) {
  60. if (!$expectHunkHeader) {
  61. throw new \UnexpectedValueException(\sprintf('Unexpected hunk start (\'@\'). Line %d.', $lineNumber));
  62. }
  63. $previousHunkFromEnd = $fromStart + $fromTillOffset;
  64. $previousHunkTillEnd = $toStart + $toTillOffset;
  65. [$fromStart, $fromTillOffset, $toStart, $toTillOffset] = $this->unifiedDiffAssertHunkHeader($line, \sprintf('Line %d.', $lineNumber));
  66. // detect overlapping hunks
  67. if ($fromStart < $previousHunkFromEnd) {
  68. throw new \UnexpectedValueException(\sprintf('Unexpected new hunk; "from" (\'-\') start overlaps previous hunk. Line %d.', $lineNumber));
  69. }
  70. if ($toStart < $previousHunkTillEnd) {
  71. throw new \UnexpectedValueException(\sprintf('Unexpected new hunk; "to" (\'+\') start overlaps previous hunk. Line %d.', $lineNumber));
  72. }
  73. /* valid states; hunks touches against each other:
  74. $fromStart === $previousHunkFromEnd
  75. $toStart === $previousHunkTillEnd
  76. */
  77. $diffLineFromNumber = $fromStart;
  78. $diffLineToNumber = $toStart;
  79. $expectHunkHeader = false;
  80. continue;
  81. }
  82. if ('-' === $type) {
  83. if (isset($endOfLineTypes['-'])) {
  84. throw new \UnexpectedValueException(\sprintf('Not expected from (\'-\'), already closed by "\\ No newline at end of file". Line %d.', $lineNumber));
  85. }
  86. ++$diffLineFromNumber;
  87. } elseif ('+' === $type) {
  88. if (isset($endOfLineTypes['+'])) {
  89. throw new \UnexpectedValueException(\sprintf('Not expected to (\'+\'), already closed by "\\ No newline at end of file". Line %d.', $lineNumber));
  90. }
  91. ++$diffLineToNumber;
  92. } elseif (' ' === $type) {
  93. if (isset($endOfLineTypes['-'])) {
  94. throw new \UnexpectedValueException(\sprintf('Not expected same (\' \'), \'-\' already closed by "\\ No newline at end of file". Line %d.', $lineNumber));
  95. }
  96. if (isset($endOfLineTypes['+'])) {
  97. throw new \UnexpectedValueException(\sprintf('Not expected same (\' \'), \'+\' already closed by "\\ No newline at end of file". Line %d.', $lineNumber));
  98. }
  99. ++$diffLineFromNumber;
  100. ++$diffLineToNumber;
  101. } elseif ('\\' === $type) {
  102. if (!isset($lines[$lineNumber - 2])) {
  103. throw new \UnexpectedValueException(\sprintf('Unexpected "\\ No newline at end of file", it must be preceded by \'+\' or \'-\' line. Line %d.', $lineNumber));
  104. }
  105. $previousType = $this->unifiedDiffAssertLinePrefix($lines[$lineNumber - 2], \sprintf('Preceding line of "\\ No newline at end of file" of unexpected format. Line %d.', $lineNumber));
  106. if (isset($endOfLineTypes[$previousType])) {
  107. throw new \UnexpectedValueException(\sprintf('Unexpected "\\ No newline at end of file", "%s" was already closed. Line %d.', $type, $lineNumber));
  108. }
  109. $endOfLineTypes[$previousType] = true;
  110. $diffClosed = \count($endOfLineTypes) > 1;
  111. } else {
  112. // internal state error
  113. throw new \RuntimeException(\sprintf('Unexpected line type "%s" Line %d.', $type, $lineNumber));
  114. }
  115. $expectHunkHeader =
  116. $diffLineFromNumber === ($fromStart + $fromTillOffset)
  117. && $diffLineToNumber === ($toStart + $toTillOffset)
  118. ;
  119. }
  120. if (
  121. $diffLineFromNumber !== ($fromStart + $fromTillOffset)
  122. && $diffLineToNumber !== ($toStart + $toTillOffset)
  123. ) {
  124. throw new \UnexpectedValueException(\sprintf('Unexpected EOF, number of lines in hunk "from" (\'-\')) and "to" (\'+\') mismatched. Line %d.', $lineNumber));
  125. }
  126. if ($diffLineFromNumber !== ($fromStart + $fromTillOffset)) {
  127. throw new \UnexpectedValueException(\sprintf('Unexpected EOF, number of lines in hunk "from" (\'-\')) mismatched. Line %d.', $lineNumber));
  128. }
  129. if ($diffLineToNumber !== ($toStart + $toTillOffset)) {
  130. throw new \UnexpectedValueException(\sprintf('Unexpected EOF, number of lines in hunk "to" (\'+\')) mismatched. Line %d.', $lineNumber));
  131. }
  132. $this->addToAssertionCount(1);
  133. }
  134. /**
  135. * @param string $line
  136. * @param string $message
  137. *
  138. * @return string '+', '-', '@', ' ' or '\'
  139. */
  140. private function unifiedDiffAssertLinePrefix(string $line, string $message): string
  141. {
  142. $this->unifiedDiffAssertStrLength($line, 2, $message); // 2: line type indicator ('+', '-', ' ' or '\') and a line break
  143. $firstChar = $line[0];
  144. if ('+' === $firstChar || '-' === $firstChar || '@' === $firstChar || ' ' === $firstChar) {
  145. return $firstChar;
  146. }
  147. if ("\\ No newline at end of file\n" === $line) {
  148. return '\\';
  149. }
  150. throw new \UnexpectedValueException(\sprintf('Expected line to start with \'@\', \'-\' or \'+\', got "%s". %s', $line, $message));
  151. }
  152. private function unifiedDiffAssertStrLength(string $line, int $min, string $message): void
  153. {
  154. $length = \strlen($line);
  155. if ($length < $min) {
  156. throw new \UnexpectedValueException(\sprintf('Expected string length of minimal %d, got %d. %s', $min, $length, $message));
  157. }
  158. }
  159. /**
  160. * Assert valid unified diff header line
  161. *
  162. * Samples:
  163. * - "+++ from1.txt\t2017-08-24 19:51:29.383985722 +0200"
  164. * - "+++ from1.txt"
  165. *
  166. * @param string $line
  167. * @param string $start
  168. * @param string $message
  169. */
  170. private function unifiedDiffAssertHeaderLine(string $line, string $start, string $message): void
  171. {
  172. if (0 !== \strpos($line, $start)) {
  173. throw new \UnexpectedValueException(\sprintf('Expected header line to start with "%s", got "%s". %s', $start . ' ', $line, $message));
  174. }
  175. // sample "+++ from1.txt\t2017-08-24 19:51:29.383985722 +0200\n"
  176. $match = \preg_match(
  177. "/^([^\t]*)(?:[\t]([\\S].*[\\S]))?\n$/",
  178. \substr($line, 4), // 4 === string length of "+++ " / "--- "
  179. $matches
  180. );
  181. if (1 !== $match) {
  182. throw new \UnexpectedValueException(\sprintf('Header line does not match expected pattern, got "%s". %s', $line, $message));
  183. }
  184. // $file = $matches[1];
  185. if (\count($matches) > 2) {
  186. $this->unifiedDiffAssertHeaderDate($matches[2], $message);
  187. }
  188. }
  189. private function unifiedDiffAssertHeaderDate(string $date, string $message): void
  190. {
  191. // sample "2017-08-24 19:51:29.383985722 +0200"
  192. $match = \preg_match(
  193. '/^([\d]{4})-([01]?[\d])-([0123]?[\d])(:? [\d]{1,2}:[\d]{1,2}(?::[\d]{1,2}(:?\.[\d]+)?)?(?: ([\+\-][\d]{4}))?)?$/',
  194. $date,
  195. $matches
  196. );
  197. if (1 !== $match || ($matchesCount = \count($matches)) < 4) {
  198. throw new \UnexpectedValueException(\sprintf('Date of header line does not match expected pattern, got "%s". %s', $date, $message));
  199. }
  200. // [$full, $year, $month, $day, $time] = $matches;
  201. }
  202. /**
  203. * @param string $line
  204. * @param string $message
  205. *
  206. * @return int[]
  207. */
  208. private function unifiedDiffAssertHunkHeader(string $line, string $message): array
  209. {
  210. if (1 !== \preg_match('#^@@ -([\d]+)((?:,[\d]+)?) \+([\d]+)((?:,[\d]+)?) @@\n$#', $line, $matches)) {
  211. throw new \UnexpectedValueException(
  212. \sprintf(
  213. 'Hunk header line does not match expected pattern, got "%s". %s',
  214. $line,
  215. $message
  216. )
  217. );
  218. }
  219. return [
  220. (int) $matches[1],
  221. empty($matches[2]) ? 1 : (int) \substr($matches[2], 1),
  222. (int) $matches[3],
  223. empty($matches[4]) ? 1 : (int) \substr($matches[4], 1),
  224. ];
  225. }
  226. }