FixerDocumentGenerator.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  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. $warningsHeader,
  101. $warningsHeaderLine,
  102. $deprecationDescription,
  103. $experimentalDescription,
  104. $riskyDescription,
  105. ]));
  106. }
  107. if ($fixer instanceof ConfigurableFixerInterface) {
  108. $doc .= <<<'RST'
  109. Configuration
  110. -------------
  111. RST;
  112. $configurationDefinition = $fixer->getConfigurationDefinition();
  113. foreach ($configurationDefinition->getOptions() as $option) {
  114. $optionInfo = "``{$option->getName()}``";
  115. $optionInfo .= "\n".str_repeat('~', \strlen($optionInfo));
  116. if ($option instanceof DeprecatedFixerOptionInterface) {
  117. $deprecationMessage = RstUtils::toRst($option->getDeprecationMessage());
  118. $optionInfo .= "\n\n.. warning:: This option is deprecated and will be removed in the next major version. {$deprecationMessage}";
  119. }
  120. $optionInfo .= "\n\n".RstUtils::toRst($option->getDescription());
  121. if ($option instanceof AliasedFixerOption) {
  122. $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.";
  123. }
  124. $allowed = HelpCommand::getDisplayableAllowedValues($option);
  125. if (null === $allowed) {
  126. $allowedKind = 'Allowed types';
  127. $allowed = array_map(
  128. static fn ($value): string => '``'.$value.'``',
  129. $option->getAllowedTypes(),
  130. );
  131. } else {
  132. $allowedKind = 'Allowed values';
  133. $allowed = array_map(static fn ($value): string => $value instanceof AllowedValueSubset
  134. ? 'a subset of ``'.Utils::toString($value->getAllowedValues()).'``'
  135. : '``'.Utils::toString($value).'``', $allowed);
  136. }
  137. $allowed = Utils::naturalLanguageJoin($allowed, '');
  138. $optionInfo .= "\n\n{$allowedKind}: {$allowed}";
  139. if ($option->hasDefault()) {
  140. $default = Utils::toString($option->getDefault());
  141. $optionInfo .= "\n\nDefault value: ``{$default}``";
  142. } else {
  143. $optionInfo .= "\n\nThis option is required.";
  144. }
  145. $doc .= "\n\n{$optionInfo}";
  146. }
  147. }
  148. $samples = $definition->getCodeSamples();
  149. if (0 !== \count($samples)) {
  150. $doc .= <<<'RST'
  151. Examples
  152. --------
  153. RST;
  154. foreach ($samples as $index => $sample) {
  155. $title = sprintf('Example #%d', $index + 1);
  156. $titleLine = str_repeat('~', \strlen($title));
  157. $doc .= "\n\n{$title}\n{$titleLine}";
  158. if ($fixer instanceof ConfigurableFixerInterface) {
  159. if (null === $sample->getConfiguration()) {
  160. $doc .= "\n\n*Default* configuration.";
  161. } else {
  162. $doc .= sprintf(
  163. "\n\nWith configuration: ``%s``.",
  164. Utils::toString($sample->getConfiguration())
  165. );
  166. }
  167. }
  168. $doc .= "\n".$this->generateSampleDiff($fixer, $sample, $index + 1, $name);
  169. }
  170. }
  171. $ruleSetConfigs = self::getSetsOfRule($name);
  172. if ([] !== $ruleSetConfigs) {
  173. $plural = 1 !== \count($ruleSetConfigs) ? 's' : '';
  174. $doc .= <<<RST
  175. Rule sets
  176. ---------
  177. The rule is part of the following rule set{$plural}:\n\n
  178. RST;
  179. foreach ($ruleSetConfigs as $set => $config) {
  180. $ruleSetPath = $this->locator->getRuleSetsDocumentationFilePath($set);
  181. $ruleSetPath = substr($ruleSetPath, strrpos($ruleSetPath, '/'));
  182. $configInfo = (null !== $config)
  183. ? " with config:\n\n ``".Utils::toString($config)."``\n"
  184. : '';
  185. $doc .= <<<RST
  186. - `{$set} <./../../ruleSets{$ruleSetPath}>`_{$configInfo}\n
  187. RST;
  188. }
  189. }
  190. $reflectionObject = new \ReflectionObject($fixer);
  191. $className = str_replace('\\', '\\\\', $reflectionObject->getName());
  192. $fileName = $reflectionObject->getFileName();
  193. $fileName = str_replace('\\', '/', $fileName);
  194. $fileName = substr($fileName, strrpos($fileName, '/src/Fixer/') + 1);
  195. $fileName = "`{$className} <./../../../{$fileName}>`_";
  196. $testFileName = Preg::replace('~.*\K/src/(?=Fixer/)~', '/tests/', $fileName);
  197. $testFileName = Preg::replace('~PhpCsFixer\\\\\\\\\K(?=Fixer\\\\\\\\)~', 'Tests\\\\\\\\', $testFileName);
  198. $testFileName = Preg::replace('~(?= <|\.php>)~', 'Test', $testFileName);
  199. $doc .= <<<RST
  200. References
  201. ----------
  202. - Fixer class: {$fileName}
  203. - Test class: {$testFileName}
  204. The test class defines officially supported behaviour. Each test case is a part of our backward compatibility promise.
  205. RST;
  206. $doc = str_replace("\t", '<TAB>', $doc);
  207. return "{$doc}\n";
  208. }
  209. /**
  210. * @internal
  211. *
  212. * @return array<string, null|array<string, mixed>>
  213. */
  214. public static function getSetsOfRule(string $ruleName): array
  215. {
  216. $ruleSetConfigs = [];
  217. foreach (RuleSets::getSetDefinitionNames() as $set) {
  218. $ruleSet = new RuleSet([$set => true]);
  219. if ($ruleSet->hasRule($ruleName)) {
  220. $ruleSetConfigs[$set] = $ruleSet->getRuleConfiguration($ruleName);
  221. }
  222. }
  223. return $ruleSetConfigs;
  224. }
  225. /**
  226. * @param FixerInterface[] $fixers
  227. */
  228. public function generateFixersDocumentationIndex(array $fixers): string
  229. {
  230. $overrideGroups = [
  231. 'PhpUnit' => 'PHPUnit',
  232. 'PhpTag' => 'PHP Tag',
  233. 'Phpdoc' => 'PHPDoc',
  234. ];
  235. usort($fixers, static fn (FixerInterface $a, FixerInterface $b): int => \get_class($a) <=> \get_class($b));
  236. $documentation = <<<'RST'
  237. =======================
  238. List of Available Rules
  239. =======================
  240. RST;
  241. $currentGroup = null;
  242. foreach ($fixers as $fixer) {
  243. $namespace = Preg::replace('/^.*\\\\(.+)\\\\.+Fixer$/', '$1', \get_class($fixer));
  244. $group = $overrideGroups[$namespace] ?? Preg::replace('/(?<=[[:lower:]])(?=[[:upper:]])/', ' ', $namespace);
  245. if ($group !== $currentGroup) {
  246. $underline = str_repeat('-', \strlen($group));
  247. $documentation .= "\n\n{$group}\n{$underline}\n";
  248. $currentGroup = $group;
  249. }
  250. $path = './'.$this->locator->getFixerDocumentationFileRelativePath($fixer);
  251. $attributes = [];
  252. if ($fixer instanceof DeprecatedFixerInterface) {
  253. $attributes[] = 'deprecated';
  254. }
  255. if ($fixer instanceof ExperimentalFixerInterface) {
  256. $attributes[] = 'experimental';
  257. }
  258. if ($fixer->isRisky()) {
  259. $attributes[] = 'risky';
  260. }
  261. $attributes = 0 === \count($attributes)
  262. ? ''
  263. : ' *('.implode(', ', $attributes).')*';
  264. $summary = str_replace('`', '``', $fixer->getDefinition()->getSummary());
  265. $documentation .= <<<RST
  266. - `{$fixer->getName()} <{$path}>`_{$attributes}
  267. {$summary}
  268. RST;
  269. }
  270. return "{$documentation}\n";
  271. }
  272. private function generateSampleDiff(FixerInterface $fixer, CodeSampleInterface $sample, int $sampleNumber, string $ruleName): string
  273. {
  274. if ($sample instanceof VersionSpecificCodeSampleInterface && !$sample->isSuitableFor(\PHP_VERSION_ID)) {
  275. $existingFile = @file_get_contents($this->locator->getFixerDocumentationFilePath($fixer));
  276. if (false !== $existingFile) {
  277. Preg::match("/\\RExample #{$sampleNumber}\\R.+?(?<diff>\\R\\.\\. code-block:: diff\\R\\R.*?)\\R(?:\\R\\S|$)/s", $existingFile, $matches);
  278. if (isset($matches['diff'])) {
  279. return $matches['diff'];
  280. }
  281. }
  282. $error = <<<RST
  283. .. error::
  284. Cannot generate diff for code sample #{$sampleNumber} of rule {$ruleName}:
  285. the sample is not suitable for current version of PHP (%s).
  286. RST;
  287. return sprintf($error, PHP_VERSION);
  288. }
  289. $old = $sample->getCode();
  290. $tokens = Tokens::fromCode($old);
  291. $file = $sample instanceof FileSpecificCodeSampleInterface
  292. ? $sample->getSplFileInfo()
  293. : new StdinFileInfo();
  294. if ($fixer instanceof ConfigurableFixerInterface) {
  295. $fixer->configure($sample->getConfiguration() ?? []);
  296. }
  297. $fixer->fix($file, $tokens);
  298. $diff = $this->differ->diff($old, $tokens->generateCode());
  299. $diff = Preg::replace('/@@[ \+\-\d,]+@@\n/', '', $diff);
  300. $diff = Preg::replace('/\r/', '^M', $diff);
  301. $diff = Preg::replace('/^ $/m', '', $diff);
  302. $diff = Preg::replace('/\n$/', '', $diff);
  303. $diff = RstUtils::indent($diff, 3);
  304. return <<<RST
  305. .. code-block:: diff
  306. {$diff}
  307. RST;
  308. }
  309. }