HeaderCommentFixer.php 15 KB

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