HeaderCommentFixer.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. <?php
  2. declare(strict_types=1);
  3. /*
  4. * This file is part of PHP CS Fixer.
  5. *
  6. * (c) Fabien Potencier <fabien@symfony.com>
  7. * Dariusz Rumiński <dariusz.ruminski@gmail.com>
  8. *
  9. * This source file is subject to the MIT license that is bundled
  10. * with this source code in the file LICENSE.
  11. */
  12. namespace PhpCsFixer\Fixer\Comment;
  13. use PhpCsFixer\AbstractFixer;
  14. use PhpCsFixer\ConfigurationException\InvalidFixerConfigurationException;
  15. use PhpCsFixer\Fixer\ConfigurableFixerInterface;
  16. use PhpCsFixer\Fixer\WhitespacesAwareFixerInterface;
  17. use PhpCsFixer\FixerConfiguration\FixerConfigurationResolver;
  18. use PhpCsFixer\FixerConfiguration\FixerConfigurationResolverInterface;
  19. use PhpCsFixer\FixerConfiguration\FixerOptionBuilder;
  20. use PhpCsFixer\FixerDefinition\CodeSample;
  21. use PhpCsFixer\FixerDefinition\FixerDefinition;
  22. use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
  23. use PhpCsFixer\Preg;
  24. use PhpCsFixer\Tokenizer\Token;
  25. use PhpCsFixer\Tokenizer\Tokens;
  26. use Symfony\Component\OptionsResolver\Options;
  27. /**
  28. * @author Antonio J. García Lagar <aj@garcialagar.es>
  29. */
  30. final class HeaderCommentFixer extends AbstractFixer implements ConfigurableFixerInterface, WhitespacesAwareFixerInterface
  31. {
  32. /**
  33. * @internal
  34. */
  35. public const HEADER_PHPDOC = 'PHPDoc';
  36. /**
  37. * @internal
  38. */
  39. public const HEADER_COMMENT = 'comment';
  40. public function getDefinition(): FixerDefinitionInterface
  41. {
  42. return new FixerDefinition(
  43. 'Add, replace or remove header comment.',
  44. [
  45. new CodeSample(
  46. '<?php
  47. declare(strict_types=1);
  48. namespace A\B;
  49. echo 1;
  50. ',
  51. [
  52. 'header' => 'Made with love.',
  53. ]
  54. ),
  55. new CodeSample(
  56. '<?php
  57. declare(strict_types=1);
  58. namespace A\B;
  59. echo 1;
  60. ',
  61. [
  62. 'header' => 'Made with love.',
  63. 'comment_type' => 'PHPDoc',
  64. 'location' => 'after_open',
  65. 'separate' => 'bottom',
  66. ]
  67. ),
  68. new CodeSample(
  69. '<?php
  70. declare(strict_types=1);
  71. namespace A\B;
  72. echo 1;
  73. ',
  74. [
  75. 'header' => 'Made with love.',
  76. 'comment_type' => 'comment',
  77. 'location' => 'after_declare_strict',
  78. ]
  79. ),
  80. new CodeSample(
  81. '<?php
  82. declare(strict_types=1);
  83. /*
  84. * Comment is not wanted here.
  85. */
  86. namespace A\B;
  87. echo 1;
  88. ',
  89. [
  90. 'header' => '',
  91. ]
  92. ),
  93. ]
  94. );
  95. }
  96. public function isCandidate(Tokens $tokens): bool
  97. {
  98. return $tokens->isMonolithicPhp() && !$tokens->isTokenKindFound(T_OPEN_TAG_WITH_ECHO);
  99. }
  100. /**
  101. * {@inheritdoc}
  102. *
  103. * Must run before BlankLinesBeforeNamespaceFixer, SingleBlankLineBeforeNamespaceFixer, SingleLineCommentStyleFixer.
  104. * Must run after DeclareStrictTypesFixer, NoBlankLinesAfterPhpdocFixer.
  105. */
  106. public function getPriority(): int
  107. {
  108. // When this fixer is configured with ["separate" => "bottom", "comment_type" => "PHPDoc"]
  109. // and the target file has no namespace or declare() construct,
  110. // the fixed header comment gets trimmed by NoBlankLinesAfterPhpdocFixer if we run before it.
  111. return -30;
  112. }
  113. protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
  114. {
  115. $location = $this->configuration['location'];
  116. $locationIndices = [];
  117. foreach (['after_open', 'after_declare_strict'] as $possibleLocation) {
  118. $locationIndex = $this->findHeaderCommentInsertionIndex($tokens, $possibleLocation);
  119. if (!isset($locationIndices[$locationIndex]) || $possibleLocation === $location) {
  120. $locationIndices[$locationIndex] = $possibleLocation;
  121. }
  122. }
  123. foreach ($locationIndices as $possibleLocation) {
  124. // figure out where the comment should be placed
  125. $headerNewIndex = $this->findHeaderCommentInsertionIndex($tokens, $possibleLocation);
  126. // check if there is already a comment
  127. $headerCurrentIndex = $this->findHeaderCommentCurrentIndex($tokens, $headerNewIndex - 1);
  128. if (null === $headerCurrentIndex) {
  129. if ('' === $this->configuration['header'] || $possibleLocation !== $location) {
  130. continue;
  131. }
  132. $this->insertHeader($tokens, $headerNewIndex);
  133. continue;
  134. }
  135. $sameComment = $this->getHeaderAsComment() === $tokens[$headerCurrentIndex]->getContent();
  136. $expectedLocation = $possibleLocation === $location;
  137. if (!$sameComment || !$expectedLocation) {
  138. if ($expectedLocation xor $sameComment) {
  139. $this->removeHeader($tokens, $headerCurrentIndex);
  140. }
  141. if ('' === $this->configuration['header']) {
  142. continue;
  143. }
  144. if ($possibleLocation === $location) {
  145. $this->insertHeader($tokens, $headerNewIndex);
  146. }
  147. continue;
  148. }
  149. $this->fixWhiteSpaceAroundHeader($tokens, $headerCurrentIndex);
  150. }
  151. }
  152. protected function createConfigurationDefinition(): FixerConfigurationResolverInterface
  153. {
  154. $fixerName = $this->getName();
  155. return new FixerConfigurationResolver([
  156. (new FixerOptionBuilder('header', 'Proper header content.'))
  157. ->setAllowedTypes(['string'])
  158. ->setNormalizer(static function (Options $options, string $value) use ($fixerName): string {
  159. if ('' === trim($value)) {
  160. return '';
  161. }
  162. if (str_contains($value, '*/')) {
  163. throw new InvalidFixerConfigurationException($fixerName, 'Cannot use \'*/\' in header.');
  164. }
  165. return $value;
  166. })
  167. ->getOption(),
  168. (new FixerOptionBuilder('comment_type', 'Comment syntax type.'))
  169. ->setAllowedValues([self::HEADER_PHPDOC, self::HEADER_COMMENT])
  170. ->setDefault(self::HEADER_COMMENT)
  171. ->getOption(),
  172. (new FixerOptionBuilder('location', 'The location of the inserted header.'))
  173. ->setAllowedValues(['after_open', 'after_declare_strict'])
  174. ->setDefault('after_declare_strict')
  175. ->getOption(),
  176. (new FixerOptionBuilder('separate', 'Whether the header should be separated from the file content with a new line.'))
  177. ->setAllowedValues(['both', 'top', 'bottom', 'none'])
  178. ->setDefault('both')
  179. ->getOption(),
  180. ]);
  181. }
  182. /**
  183. * Enclose the given text in a comment block.
  184. */
  185. private function getHeaderAsComment(): string
  186. {
  187. $lineEnding = $this->whitespacesConfig->getLineEnding();
  188. $comment = (self::HEADER_COMMENT === $this->configuration['comment_type'] ? '/*' : '/**').$lineEnding;
  189. $lines = explode("\n", str_replace("\r", '', $this->configuration['header']));
  190. foreach ($lines as $line) {
  191. $comment .= rtrim(' * '.$line).$lineEnding;
  192. }
  193. return $comment.' */';
  194. }
  195. private function findHeaderCommentCurrentIndex(Tokens $tokens, int $headerNewIndex): ?int
  196. {
  197. $index = $tokens->getNextNonWhitespace($headerNewIndex);
  198. if (null === $index || !$tokens[$index]->isComment()) {
  199. return null;
  200. }
  201. $next = $index + 1;
  202. if (!isset($tokens[$next]) || \in_array($this->configuration['separate'], ['top', 'none'], true) || !$tokens[$index]->isGivenKind(T_DOC_COMMENT)) {
  203. return $index;
  204. }
  205. if ($tokens[$next]->isWhitespace()) {
  206. if (!Preg::match('/^\h*\R\h*$/D', $tokens[$next]->getContent())) {
  207. return $index;
  208. }
  209. ++$next;
  210. }
  211. if (!isset($tokens[$next]) || !$tokens[$next]->isClassy() && !$tokens[$next]->isGivenKind(T_FUNCTION)) {
  212. return $index;
  213. }
  214. return $this->getHeaderAsComment() === $tokens[$index]->getContent() ? $index : null;
  215. }
  216. /**
  217. * Find the index where the header comment must be inserted.
  218. */
  219. private function findHeaderCommentInsertionIndex(Tokens $tokens, string $location): int
  220. {
  221. $openTagIndex = $tokens[0]->isGivenKind(T_INLINE_HTML) ? 1 : 0;
  222. if ('after_open' === $location) {
  223. return $openTagIndex + 1;
  224. }
  225. $index = $tokens->getNextMeaningfulToken($openTagIndex);
  226. if (null === $index) {
  227. return $openTagIndex + 1; // file without meaningful tokens but an open tag, comment should always be placed directly after the open tag
  228. }
  229. if (!$tokens[$index]->isGivenKind(T_DECLARE)) {
  230. return $openTagIndex + 1;
  231. }
  232. $next = $tokens->getNextMeaningfulToken($index);
  233. if (null === $next || !$tokens[$next]->equals('(')) {
  234. return $openTagIndex + 1;
  235. }
  236. $next = $tokens->getNextMeaningfulToken($next);
  237. if (null === $next || !$tokens[$next]->equals([T_STRING, 'strict_types'], false)) {
  238. return $openTagIndex + 1;
  239. }
  240. $next = $tokens->getNextMeaningfulToken($next);
  241. if (null === $next || !$tokens[$next]->equals('=')) {
  242. return $openTagIndex + 1;
  243. }
  244. $next = $tokens->getNextMeaningfulToken($next);
  245. if (null === $next || !$tokens[$next]->isGivenKind(T_LNUMBER)) {
  246. return $openTagIndex + 1;
  247. }
  248. $next = $tokens->getNextMeaningfulToken($next);
  249. if (null === $next || !$tokens[$next]->equals(')')) {
  250. return $openTagIndex + 1;
  251. }
  252. $next = $tokens->getNextMeaningfulToken($next);
  253. if (null === $next || !$tokens[$next]->equals(';')) { // don't insert after close tag
  254. return $openTagIndex + 1;
  255. }
  256. return $next + 1;
  257. }
  258. private function fixWhiteSpaceAroundHeader(Tokens $tokens, int $headerIndex): void
  259. {
  260. $lineEnding = $this->whitespacesConfig->getLineEnding();
  261. // fix lines after header comment
  262. if (
  263. ('both' === $this->configuration['separate'] || 'bottom' === $this->configuration['separate'])
  264. && null !== $tokens->getNextMeaningfulToken($headerIndex)
  265. ) {
  266. $expectedLineCount = 2;
  267. } else {
  268. $expectedLineCount = 1;
  269. }
  270. if ($headerIndex === \count($tokens) - 1) {
  271. $tokens->insertAt($headerIndex + 1, new Token([T_WHITESPACE, str_repeat($lineEnding, $expectedLineCount)]));
  272. } else {
  273. $lineBreakCount = $this->getLineBreakCount($tokens, $headerIndex, 1);
  274. if ($lineBreakCount < $expectedLineCount) {
  275. $missing = str_repeat($lineEnding, $expectedLineCount - $lineBreakCount);
  276. if ($tokens[$headerIndex + 1]->isWhitespace()) {
  277. $tokens[$headerIndex + 1] = new Token([T_WHITESPACE, $missing.$tokens[$headerIndex + 1]->getContent()]);
  278. } else {
  279. $tokens->insertAt($headerIndex + 1, new Token([T_WHITESPACE, $missing]));
  280. }
  281. } elseif ($lineBreakCount > $expectedLineCount && $tokens[$headerIndex + 1]->isWhitespace()) {
  282. $newLinesToRemove = $lineBreakCount - $expectedLineCount;
  283. $tokens[$headerIndex + 1] = new Token([
  284. T_WHITESPACE,
  285. Preg::replace("/^\\R{{$newLinesToRemove}}/", '', $tokens[$headerIndex + 1]->getContent()),
  286. ]);
  287. }
  288. }
  289. // fix lines before header comment
  290. $expectedLineCount = 'both' === $this->configuration['separate'] || 'top' === $this->configuration['separate'] ? 2 : 1;
  291. $prev = $tokens->getPrevNonWhitespace($headerIndex);
  292. $regex = '/\h$/';
  293. if ($tokens[$prev]->isGivenKind(T_OPEN_TAG) && Preg::match($regex, $tokens[$prev]->getContent())) {
  294. $tokens[$prev] = new Token([T_OPEN_TAG, Preg::replace($regex, $lineEnding, $tokens[$prev]->getContent())]);
  295. }
  296. $lineBreakCount = $this->getLineBreakCount($tokens, $headerIndex, -1);
  297. if ($lineBreakCount < $expectedLineCount) {
  298. // because of the way the insert index was determined for header comment there cannot be an empty token here
  299. $tokens->insertAt($headerIndex, new Token([T_WHITESPACE, str_repeat($lineEnding, $expectedLineCount - $lineBreakCount)]));
  300. }
  301. }
  302. private function getLineBreakCount(Tokens $tokens, int $index, int $direction): int
  303. {
  304. $whitespace = '';
  305. for ($index += $direction; isset($tokens[$index]); $index += $direction) {
  306. $token = $tokens[$index];
  307. if ($token->isWhitespace()) {
  308. $whitespace .= $token->getContent();
  309. continue;
  310. }
  311. if (-1 === $direction && $token->isGivenKind(T_OPEN_TAG)) {
  312. $whitespace .= $token->getContent();
  313. }
  314. if ('' !== $token->getContent()) {
  315. break;
  316. }
  317. }
  318. return substr_count($whitespace, "\n");
  319. }
  320. private function removeHeader(Tokens $tokens, int $index): void
  321. {
  322. $prevIndex = $index - 1;
  323. $prevToken = $tokens[$prevIndex];
  324. $newlineRemoved = false;
  325. if ($prevToken->isWhitespace()) {
  326. $content = $prevToken->getContent();
  327. if (Preg::match('/\R/', $content)) {
  328. $newlineRemoved = true;
  329. }
  330. $content = Preg::replace('/\R?\h*$/', '', $content);
  331. $tokens->ensureWhitespaceAtIndex($prevIndex, 0, $content);
  332. }
  333. $nextIndex = $index + 1;
  334. $nextToken = $tokens[$nextIndex] ?? null;
  335. if (!$newlineRemoved && null !== $nextToken && $nextToken->isWhitespace()) {
  336. $content = Preg::replace('/^\R/', '', $nextToken->getContent());
  337. $tokens->ensureWhitespaceAtIndex($nextIndex, 0, $content);
  338. }
  339. $tokens->clearTokenAndMergeSurroundingWhitespace($index);
  340. }
  341. private function insertHeader(Tokens $tokens, int $index): void
  342. {
  343. $tokens->insertAt($index, new Token([self::HEADER_COMMENT === $this->configuration['comment_type'] ? T_COMMENT : T_DOC_COMMENT, $this->getHeaderAsComment()]));
  344. $this->fixWhiteSpaceAroundHeader($tokens, $index);
  345. }
  346. }