FixerDocumentGenerator.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  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\Documentation;
  13. use PhpCsFixer\Console\Command\HelpCommand;
  14. use PhpCsFixer\Differ\FullDiffer;
  15. use PhpCsFixer\Fixer\ConfigurableFixerInterface;
  16. use PhpCsFixer\Fixer\DeprecatedFixerInterface;
  17. use PhpCsFixer\Fixer\ExperimentalFixerInterface;
  18. use PhpCsFixer\Fixer\FixerInterface;
  19. use PhpCsFixer\FixerConfiguration\AliasedFixerOption;
  20. use PhpCsFixer\FixerConfiguration\AllowedValueSubset;
  21. use PhpCsFixer\FixerConfiguration\DeprecatedFixerOptionInterface;
  22. use PhpCsFixer\FixerDefinition\CodeSampleInterface;
  23. use PhpCsFixer\FixerDefinition\FileSpecificCodeSampleInterface;
  24. use PhpCsFixer\FixerDefinition\VersionSpecificCodeSampleInterface;
  25. use PhpCsFixer\Preg;
  26. use PhpCsFixer\RuleSet\RuleSet;
  27. use PhpCsFixer\RuleSet\RuleSets;
  28. use PhpCsFixer\StdinFileInfo;
  29. use PhpCsFixer\Tokenizer\Tokens;
  30. use PhpCsFixer\Utils;
  31. /**
  32. * @internal
  33. */
  34. final class FixerDocumentGenerator
  35. {
  36. private DocumentationLocator $locator;
  37. private FullDiffer $differ;
  38. public function __construct(DocumentationLocator $locator)
  39. {
  40. $this->locator = $locator;
  41. $this->differ = new FullDiffer();
  42. }
  43. public function generateFixerDocumentation(FixerInterface $fixer): string
  44. {
  45. $name = $fixer->getName();
  46. $title = "Rule ``{$name}``";
  47. $titleLine = str_repeat('=', \strlen($title));
  48. $doc = "{$titleLine}\n{$title}\n{$titleLine}";
  49. $definition = $fixer->getDefinition();
  50. $doc .= "\n\n".RstUtils::toRst($definition->getSummary());
  51. $description = $definition->getDescription();
  52. if (null !== $description) {
  53. $description = RstUtils::toRst($description);
  54. $doc .= <<<RST
  55. Description
  56. -----------
  57. {$description}
  58. RST;
  59. }
  60. $deprecationDescription = '';
  61. if ($fixer instanceof DeprecatedFixerInterface) {
  62. $deprecationDescription = <<<'RST'
  63. This rule is deprecated and will be removed in the next major version
  64. ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  65. RST;
  66. $alternatives = $fixer->getSuccessorsNames();
  67. if (0 !== \count($alternatives)) {
  68. $deprecationDescription .= RstUtils::toRst(sprintf(
  69. "\n\nYou should use %s instead.",
  70. Utils::naturalLanguageJoinWithBackticks($alternatives)
  71. ), 0);
  72. }
  73. }
  74. $experimentalDescription = '';
  75. if ($fixer instanceof ExperimentalFixerInterface) {
  76. $experimentalDescriptionRaw = RstUtils::toRst('Rule is not covered with backward compatibility promise, use it at your own risk. Rule\'s behaviour may be changed at any point, including rule\'s name; its options\' names, availability and allowed values; its default configuration. Rule may be even removed without prior notice. Feel free to provide feedback and help with determining final state of the rule.', 0);
  77. $experimentalDescription = <<<RST
  78. This rule is experimental
  79. ~~~~~~~~~~~~~~~~~~~~~~~~~
  80. {$experimentalDescriptionRaw}
  81. RST;
  82. }
  83. $riskyDescription = '';
  84. $riskyDescriptionRaw = $definition->getRiskyDescription();
  85. if (null !== $riskyDescriptionRaw) {
  86. $riskyDescriptionRaw = RstUtils::toRst($riskyDescriptionRaw, 0);
  87. $riskyDescription = <<<RST
  88. Using this rule is risky
  89. ~~~~~~~~~~~~~~~~~~~~~~~~
  90. {$riskyDescriptionRaw}
  91. RST;
  92. }
  93. if ('' !== $deprecationDescription || '' !== $riskyDescription) {
  94. $warningsHeader = 'Warning';
  95. if ('' !== $deprecationDescription && '' !== $riskyDescription) {
  96. $warningsHeader = 'Warnings';
  97. }
  98. $warningsHeaderLine = str_repeat('-', \strlen($warningsHeader));
  99. $doc .= "\n\n".implode("\n", array_filter(
  100. [
  101. $warningsHeader,
  102. $warningsHeaderLine,
  103. $deprecationDescription,
  104. $experimentalDescription,
  105. $riskyDescription,
  106. ],
  107. static fn (string $text): bool => '' !== $text
  108. ));
  109. }
  110. if ($fixer instanceof ConfigurableFixerInterface) {
  111. $doc .= <<<'RST'
  112. Configuration
  113. -------------
  114. RST;
  115. $configurationDefinition = $fixer->getConfigurationDefinition();
  116. foreach ($configurationDefinition->getOptions() as $option) {
  117. $optionInfo = "``{$option->getName()}``";
  118. $optionInfo .= "\n".str_repeat('~', \strlen($optionInfo));
  119. if ($option instanceof DeprecatedFixerOptionInterface) {
  120. $deprecationMessage = RstUtils::toRst($option->getDeprecationMessage());
  121. $optionInfo .= "\n\n.. warning:: This option is deprecated and will be removed in the next major version. {$deprecationMessage}";
  122. }
  123. $optionInfo .= "\n\n".RstUtils::toRst($option->getDescription());
  124. if ($option instanceof AliasedFixerOption) {
  125. $optionInfo .= "\n\n.. note:: The previous name of this option was ``{$option->getAlias()}`` but it is now deprecated and will be removed in the next major version.";
  126. }
  127. $allowed = HelpCommand::getDisplayableAllowedValues($option);
  128. if (null === $allowed) {
  129. $allowedKind = 'Allowed types';
  130. $allowed = array_map(
  131. static fn (string $value): string => '``'.(str_ends_with($value, '[]') ? sprintf('list<%s>', substr($value, 0, -2)) : $value).'``',
  132. $option->getAllowedTypes(),
  133. );
  134. } else {
  135. $allowedKind = 'Allowed values';
  136. $allowed = array_map(static fn ($value): string => $value instanceof AllowedValueSubset
  137. ? 'a subset of ``'.Utils::toString($value->getAllowedValues()).'``'
  138. : '``'.Utils::toString($value).'``', $allowed);
  139. }
  140. $allowed = Utils::naturalLanguageJoin($allowed, '');
  141. $optionInfo .= "\n\n{$allowedKind}: {$allowed}";
  142. if ($option->hasDefault()) {
  143. $default = Utils::toString($option->getDefault());
  144. $optionInfo .= "\n\nDefault value: ``{$default}``";
  145. } else {
  146. $optionInfo .= "\n\nThis option is required.";
  147. }
  148. $doc .= "\n\n{$optionInfo}";
  149. }
  150. }
  151. $samples = $definition->getCodeSamples();
  152. if (0 !== \count($samples)) {
  153. $doc .= <<<'RST'
  154. Examples
  155. --------
  156. RST;
  157. foreach ($samples as $index => $sample) {
  158. $title = sprintf('Example #%d', $index + 1);
  159. $titleLine = str_repeat('~', \strlen($title));
  160. $doc .= "\n\n{$title}\n{$titleLine}";
  161. if ($fixer instanceof ConfigurableFixerInterface) {
  162. if (null === $sample->getConfiguration()) {
  163. $doc .= "\n\n*Default* configuration.";
  164. } else {
  165. $doc .= sprintf(
  166. "\n\nWith configuration: ``%s``.",
  167. Utils::toString($sample->getConfiguration())
  168. );
  169. }
  170. }
  171. $doc .= "\n".$this->generateSampleDiff($fixer, $sample, $index + 1, $name);
  172. }
  173. }
  174. $ruleSetConfigs = self::getSetsOfRule($name);
  175. if ([] !== $ruleSetConfigs) {
  176. $plural = 1 !== \count($ruleSetConfigs) ? 's' : '';
  177. $doc .= <<<RST
  178. Rule sets
  179. ---------
  180. The rule is part of the following rule set{$plural}:\n\n
  181. RST;
  182. foreach ($ruleSetConfigs as $set => $config) {
  183. $ruleSetPath = $this->locator->getRuleSetsDocumentationFilePath($set);
  184. $ruleSetPath = substr($ruleSetPath, strrpos($ruleSetPath, '/'));
  185. $configInfo = (null !== $config)
  186. ? " with config:\n\n ``".Utils::toString($config)."``\n"
  187. : '';
  188. $doc .= <<<RST
  189. - `{$set} <./../../ruleSets{$ruleSetPath}>`_{$configInfo}\n
  190. RST;
  191. }
  192. }
  193. $reflectionObject = new \ReflectionObject($fixer);
  194. $className = str_replace('\\', '\\\\', $reflectionObject->getName());
  195. $fileName = $reflectionObject->getFileName();
  196. $fileName = str_replace('\\', '/', $fileName);
  197. $fileName = substr($fileName, strrpos($fileName, '/src/Fixer/') + 1);
  198. $fileName = "`{$className} <./../../../{$fileName}>`_";
  199. $testFileName = Preg::replace('~.*\K/src/(?=Fixer/)~', '/tests/', $fileName);
  200. $testFileName = Preg::replace('~PhpCsFixer\\\\\\\\\K(?=Fixer\\\\\\\)~', 'Tests\\\\\\\\', $testFileName);
  201. $testFileName = Preg::replace('~(?= <|\.php>)~', 'Test', $testFileName);
  202. $doc .= <<<RST
  203. References
  204. ----------
  205. - Fixer class: {$fileName}
  206. - Test class: {$testFileName}
  207. The test class defines officially supported behaviour. Each test case is a part of our backward compatibility promise.
  208. RST;
  209. $doc = str_replace("\t", '<TAB>', $doc);
  210. return "{$doc}\n";
  211. }
  212. /**
  213. * @internal
  214. *
  215. * @return array<string, null|array<string, mixed>>
  216. */
  217. public static function getSetsOfRule(string $ruleName): array
  218. {
  219. $ruleSetConfigs = [];
  220. foreach (RuleSets::getSetDefinitionNames() as $set) {
  221. $ruleSet = new RuleSet([$set => true]);
  222. if ($ruleSet->hasRule($ruleName)) {
  223. $ruleSetConfigs[$set] = $ruleSet->getRuleConfiguration($ruleName);
  224. }
  225. }
  226. return $ruleSetConfigs;
  227. }
  228. /**
  229. * @param list<FixerInterface> $fixers
  230. */
  231. public function generateFixersDocumentationIndex(array $fixers): string
  232. {
  233. $overrideGroups = [
  234. 'PhpUnit' => 'PHPUnit',
  235. 'PhpTag' => 'PHP Tag',
  236. 'Phpdoc' => 'PHPDoc',
  237. ];
  238. usort($fixers, static fn (FixerInterface $a, FixerInterface $b): int => \get_class($a) <=> \get_class($b));
  239. $documentation = <<<'RST'
  240. =======================
  241. List of Available Rules
  242. =======================
  243. RST;
  244. $currentGroup = null;
  245. foreach ($fixers as $fixer) {
  246. $namespace = Preg::replace('/^.*\\\(.+)\\\.+Fixer$/', '$1', \get_class($fixer));
  247. $group = $overrideGroups[$namespace] ?? Preg::replace('/(?<=[[:lower:]])(?=[[:upper:]])/', ' ', $namespace);
  248. if ($group !== $currentGroup) {
  249. $underline = str_repeat('-', \strlen($group));
  250. $documentation .= "\n\n{$group}\n{$underline}\n";
  251. $currentGroup = $group;
  252. }
  253. $path = './'.$this->locator->getFixerDocumentationFileRelativePath($fixer);
  254. $attributes = [];
  255. if ($fixer instanceof DeprecatedFixerInterface) {
  256. $attributes[] = 'deprecated';
  257. }
  258. if ($fixer instanceof ExperimentalFixerInterface) {
  259. $attributes[] = 'experimental';
  260. }
  261. if ($fixer->isRisky()) {
  262. $attributes[] = 'risky';
  263. }
  264. $attributes = 0 === \count($attributes)
  265. ? ''
  266. : ' *('.implode(', ', $attributes).')*';
  267. $summary = str_replace('`', '``', $fixer->getDefinition()->getSummary());
  268. $documentation .= <<<RST
  269. - `{$fixer->getName()} <{$path}>`_{$attributes}
  270. {$summary}
  271. RST;
  272. }
  273. return "{$documentation}\n";
  274. }
  275. private function generateSampleDiff(FixerInterface $fixer, CodeSampleInterface $sample, int $sampleNumber, string $ruleName): string
  276. {
  277. if ($sample instanceof VersionSpecificCodeSampleInterface && !$sample->isSuitableFor(\PHP_VERSION_ID)) {
  278. $existingFile = @file_get_contents($this->locator->getFixerDocumentationFilePath($fixer));
  279. if (false !== $existingFile) {
  280. Preg::match("/\\RExample #{$sampleNumber}\\R.+?(?<diff>\\R\\.\\. code-block:: diff\\R\\R.*?)\\R(?:\\R\\S|$)/s", $existingFile, $matches);
  281. if (isset($matches['diff'])) {
  282. return $matches['diff'];
  283. }
  284. }
  285. $error = <<<RST
  286. .. error::
  287. Cannot generate diff for code sample #{$sampleNumber} of rule {$ruleName}:
  288. the sample is not suitable for current version of PHP (%s).
  289. RST;
  290. return sprintf($error, PHP_VERSION);
  291. }
  292. $old = $sample->getCode();
  293. $tokens = Tokens::fromCode($old);
  294. $file = $sample instanceof FileSpecificCodeSampleInterface
  295. ? $sample->getSplFileInfo()
  296. : new StdinFileInfo();
  297. if ($fixer instanceof ConfigurableFixerInterface) {
  298. $fixer->configure($sample->getConfiguration() ?? []);
  299. }
  300. $fixer->fix($file, $tokens);
  301. $diff = $this->differ->diff($old, $tokens->generateCode());
  302. $diff = Preg::replace('/@@[ \+\-\d,]+@@\n/', '', $diff);
  303. $diff = Preg::replace('/\r/', '^M', $diff);
  304. $diff = Preg::replace('/^ $/m', '', $diff);
  305. $diff = Preg::replace('/\n$/', '', $diff);
  306. $diff = RstUtils::indent($diff, 3);
  307. return <<<RST
  308. .. code-block:: diff
  309. {$diff}
  310. RST;
  311. }
  312. }