├── .github
└── workflows
│ └── tests.yml
├── .gitignore
├── .phpcs
├── LICENSE
├── README.md
├── composer.json
├── phpunit.xml
├── src
├── Analyzer
│ ├── ImportInfo.php
│ ├── NamespaceAnalyzer.php
│ ├── NamespaceInfo.php
│ └── Range.php
├── FQCN
│ ├── FQCNResolver.php
│ └── FQCNTypeNormalizer.php
└── Fixer
│ └── Phpdoc
│ └── ForceFQCNFixer.php
└── tests
└── CorrectlyFormatTypesTest.php
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | name: Tests
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | build-test:
7 | runs-on: ubuntu-latest
8 |
9 | steps:
10 | - uses: actions/checkout@v2
11 | - uses: php-actions/composer@v5
12 | - uses: php-actions/phpunit@v3
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /vendor
2 | /.php_cs.cache
3 | /.idea
4 | /composer.lock
5 | .php-cs-fixer.cache
6 | .phpunit.result.cache
7 |
--------------------------------------------------------------------------------
/.phpcs:
--------------------------------------------------------------------------------
1 | setRules([
5 | '@Symfony' => true,
6 | '@Symfony:risky' => true,
7 | 'concat_space' => ['spacing' => 'one'],
8 | 'array_syntax' => false,
9 | 'simplified_null_return' => false,
10 | 'phpdoc_align' => false,
11 | 'phpdoc_separation' => false,
12 | 'phpdoc_to_comment' => false,
13 | 'cast_spaces' => false,
14 | 'blank_line_after_opening_tag' => false,
15 | 'single_blank_line_before_namespace' => false,
16 | 'phpdoc_annotation_without_dot' => false,
17 | 'phpdoc_no_alias_tag' => false,
18 | 'space_after_semicolon' => false,
19 | ])
20 | ->setRiskyAllowed(true)
21 | ->setFinder(
22 | PhpCsFixer\Finder::create()
23 | ->in(__DIR__)
24 | ->exclude([
25 | 'doc',
26 | 'vendor'
27 | ])
28 | ->files()->name('*.php')
29 | )
30 | ;
31 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Adam Wójs
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # php-cs-fixer-phpdoc-force-fqcn
2 |
3 | [php-cs-fixer](https://github.com/FriendsOfPHP/PHP-CS-Fixer) rule to force using FQCN (Fully-Qualified Class Name) in DocBlock comments.
4 |
5 | ## Installation
6 |
7 | You can install the package via composer:
8 |
9 | ```bash
10 | composer require --dev adamwojs/php-cs-fixer-phpdoc-force-fqcn
11 | ```
12 |
13 | ## Usage
14 |
15 | In your .php_cs file:
16 |
17 | ```php
18 | registerCustomFixers([
24 | new \AdamWojs\PhpCsFixerPhpdocForceFQCN\Fixer\Phpdoc\ForceFQCNFixer()
25 | ])
26 | ->setRules([
27 | // ...
28 | // (2) Enable AdamWojs/phpdoc_force_fqcn_fixer rule
29 | 'AdamWojs/phpdoc_force_fqcn_fixer' => true,
30 | ])
31 | // ...
32 | ;
33 | ```
34 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "adamwojs/php-cs-fixer-phpdoc-force-fqcn",
3 | "type": "library",
4 | "license": "MIT",
5 | "authors": [
6 | {
7 | "name": "Adam Wójs",
8 | "email": "adam@wojs.pl"
9 | }
10 | ],
11 | "require": {
12 | "php": "^7.1||^8.0",
13 | "friendsofphp/php-cs-fixer": "^3.0"
14 | },
15 | "require-dev": {
16 | "phpunit/phpunit": "^9.5"
17 | },
18 | "autoload": {
19 | "psr-4": {
20 | "AdamWojs\\PhpCsFixerPhpdocForceFQCN\\": "src/",
21 | "AdamWojs\\PhpCsFixerPhpdocForceFQCN\\Tests\\": "tests/"
22 | }
23 | },
24 | "scripts": {
25 | "fix-cs": "php-cs-fixer fix src -v --show-progress=dots"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 | ./tests
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/src/Analyzer/ImportInfo.php:
--------------------------------------------------------------------------------
1 | fullName = $fullName;
30 | $this->shortName = $shortName;
31 | $this->aliased = $aliased;
32 | $this->declaration = $declaration;
33 | }
34 |
35 | /**
36 | * @return string
37 | */
38 | public function getFullName(): string
39 | {
40 | return $this->fullName;
41 | }
42 |
43 | /**
44 | * @return string
45 | */
46 | public function getShortName(): string
47 | {
48 | return $this->shortName;
49 | }
50 |
51 | /**
52 | * @return bool
53 | */
54 | public function isAliased(): bool
55 | {
56 | return $this->aliased;
57 | }
58 |
59 | /**
60 | * @return \AdamWojs\PhpCsFixerPhpdocForceFQCN\Analyzer\Range
61 | */
62 | public function getDeclaration(): Range
63 | {
64 | return $this->declaration;
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/Analyzer/NamespaceAnalyzer.php:
--------------------------------------------------------------------------------
1 | tokens = $tokens;
19 | }
20 |
21 | /**
22 | * @return \AdamWojs\PhpCsFixerPhpdocForceFQCN\Analyzer\NamespaceInfo[]
23 | */
24 | public function getNamespaces(): array
25 | {
26 | $namespaces = [];
27 |
28 | $imports = $this->getImportsPerNamespace();
29 |
30 | if (empty($imports)) {
31 | // Global namespace without imports
32 | return [
33 | new NamespaceInfo(
34 | "",
35 | new Range(0, $this->tokens->count()),
36 | []
37 | )
38 | ];
39 | }
40 |
41 | if (\PHP_VERSION_ID < 80000) {
42 | $this->tokens->rewind();
43 | }
44 |
45 | foreach ($this->tokens as $index => $token) {
46 | if (!$token->isGivenKind(T_NAMESPACE)) {
47 | continue;
48 | }
49 |
50 | $declarationStartIndex = $index;
51 | $declarationEndIndex = $this->tokens->getNextTokenOfKind($index, [';', '{']);
52 |
53 | $namespaceName = trim($this->tokens->generatePartialCode(
54 | $declarationStartIndex + 1,
55 | $declarationEndIndex - 1
56 | ));
57 |
58 | $scope = $this->getNamespaceScope($declarationEndIndex);
59 |
60 | $namespaceImports = [];
61 | foreach ($imports as $shortName => $import) {
62 | if ($scope->inRange($import->getDeclaration()->getStartIndex())) {
63 | $namespaceImports[$shortName] = $import;
64 | unset($imports[$shortName]);
65 | }
66 | }
67 |
68 | $namespaces[] = new NamespaceInfo(
69 | $namespaceName,
70 | $scope,
71 | $namespaceImports
72 | );
73 | }
74 |
75 | if (!empty($imports)) {
76 | $namespaces[] = new NamespaceInfo(
77 | "",
78 | $this->getNamespaceScope(reset($imports)->getDeclaration()->getStartIndex()),
79 | $imports
80 | );
81 | }
82 |
83 | return $namespaces;
84 | }
85 |
86 | /**
87 | * Based on \PhpCsFixer\Fixer\Import\NoUnusedImportsFixer::getNamespaceUseDeclarations
88 | *
89 | * @return \AdamWojs\PhpCsFixerPhpdocForceFQCN\Analyzer\ImportInfo[]
90 | */
91 | private function getImportsPerNamespace(): array
92 | {
93 | $tokenAnalyzer = new TokensAnalyzer($this->tokens);
94 |
95 | $imports = [];
96 | foreach ($tokenAnalyzer->getImportUseIndexes() as $declarationStartIndex) {
97 | $declarationEndIndex = $this->tokens->getNextTokenOfKind($declarationStartIndex, [';', [T_CLOSE_TAG]]);
98 | $declarationContent = $this->tokens->generatePartialCode($declarationStartIndex + 1, $declarationEndIndex - 1);
99 |
100 | if (false !== strpos($declarationContent, ',')) {
101 | // ignore multiple use statements that should be split into few separate statements
102 | // (for example: `use BarB, BarC as C;`)
103 | continue;
104 | }
105 |
106 | if (false !== strpos($declarationContent, '{')) {
107 | // do not touch group use declarations until the logic of this is added
108 | // (for example: `use some\a\{ClassD};`)
109 | continue;
110 | }
111 |
112 | $declarationParts = preg_split('/\s+as\s+/i', $declarationContent);
113 |
114 | if (1 === count($declarationParts)) {
115 | $fullName = $declarationContent;
116 | $declarationParts = explode('\\', $fullName);
117 | $shortName = end($declarationParts);
118 | $isAliased = false;
119 | } else {
120 | list($fullName, $shortName) = $declarationParts;
121 | $declarationParts = explode('\\', $fullName);
122 | $isAliased = $shortName !== end($declarationParts);
123 | }
124 |
125 | $fullName = trim($fullName);
126 | $shortName = trim($shortName);
127 |
128 | $imports[$shortName] = new ImportInfo(
129 | $fullName,
130 | $shortName,
131 | $isAliased,
132 | new Range(
133 | $declarationStartIndex,
134 | $declarationEndIndex
135 | )
136 | );
137 | }
138 |
139 | return $imports;
140 | }
141 |
142 | /**
143 | * Returns scope of the namespace.
144 | *
145 | * @param int $startIndex Start index of the namespace declaration
146 | *
147 | * @return \AdamWojs\PhpCsFixerPhpdocForceFQCN\Analyzer\Range
148 | */
149 | private function getNamespaceScope(int $startIndex): Range
150 | {
151 | $endIndex = null;
152 | if ($this->tokens[$startIndex]->isGivenKind('{')) {
153 | $endIndex = $this->tokens->findBlockEnd(Tokens::BLOCK_TYPE_CURLY_BRACE, $startIndex);
154 | } else {
155 | $nextNamespace = $this->tokens->getNextTokenOfKind($startIndex, [T_NAMESPACE]);
156 | if (!empty($nextNamespace)) {
157 | $endIndex = $nextNamespace[0];
158 | }
159 | }
160 |
161 | return new Range($startIndex, $endIndex);
162 | }
163 | }
164 |
--------------------------------------------------------------------------------
/src/Analyzer/NamespaceInfo.php:
--------------------------------------------------------------------------------
1 | name = $name;
28 | $this->scope = $scope;
29 | $this->imports = $imports;
30 | }
31 |
32 | /**
33 | * @return string
34 | */
35 | public function getName(): string
36 | {
37 | return $this->name;
38 | }
39 |
40 | /**
41 | * @return \AdamWojs\PhpCsFixerPhpdocForceFQCN\Analyzer\Range
42 | */
43 | public function getScope(): Range
44 | {
45 | return $this->scope;
46 | }
47 |
48 | /**
49 | * @return \AdamWojs\PhpCsFixerPhpdocForceFQCN\Analyzer\ImportInfo[]
50 | */
51 | public function getImports(): array
52 | {
53 | return $this->imports;
54 | }
55 |
56 | /**
57 | * Return true if given class name is imported into namespace.
58 | *
59 | * @param string $class
60 | *
61 | * @return bool
62 | */
63 | public function hasImport(string $class): bool
64 | {
65 | return isset($this->imports[$class]);
66 | }
67 |
68 | /**
69 | * Return information about given class import.
70 | *
71 | * @param string $class
72 | *
73 | * @return \AdamWojs\PhpCsFixerPhpdocForceFQCN\Analyzer\ImportInfo
74 | */
75 | public function getImport(string $class): ImportInfo
76 | {
77 | if ($this->hasImport($class)) {
78 | return $this->imports[$class];
79 | }
80 |
81 | throw new RuntimeException("$class is not imported into current namespace.");
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/src/Analyzer/Range.php:
--------------------------------------------------------------------------------
1 | startIndex = $startIndex;
22 | $this->endIndex = $endIndex;
23 | }
24 |
25 | /**
26 | * @return int
27 | */
28 | public function getStartIndex(): int
29 | {
30 | return $this->startIndex;
31 | }
32 |
33 | /**
34 | * @return int
35 | */
36 | public function getEndIndex(): int
37 | {
38 | return $this->endIndex;
39 | }
40 |
41 | /**
42 | * Returns true if given index is in range.
43 | *
44 | * @param int $index
45 | * @return bool
46 | */
47 | public function inRange(int $index): bool
48 | {
49 | if ($index <= $this->startIndex) {
50 | return false;
51 | }
52 |
53 | if ($index >= $this->endIndex && $this->endIndex !== null) {
54 | return false;
55 | }
56 |
57 | return true;
58 | }
59 |
60 | public function __toString()
61 | {
62 | return sprintf("[%s,%s]", $this->startIndex, $this->endIndex ?? 'INF');
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/FQCN/FQCNResolver.php:
--------------------------------------------------------------------------------
1 | namespaceInfo = $namespaceInfo;
20 | }
21 |
22 | /**
23 | * Tries to resolve FQCN based on short name and imports of the current namespace.
24 | *
25 | * @param string $className
26 | *
27 | * @return string
28 | */
29 | public function resolveFQCN(string $className): string
30 | {
31 | $shortName = $this->getShortName($className);
32 |
33 | if ($this->namespaceInfo->hasImport($shortName)) {
34 | $className = $this->namespaceInfo->getImport($shortName)->getFullName();
35 | if (strpos($className, '\\') !== 0) {
36 | $className = '\\' . $className;
37 | }
38 | }
39 |
40 | return $className;
41 | }
42 |
43 |
44 | /**
45 | * Returns last part of the full qualified class name.
46 | *
47 | * @param string $name
48 | *
49 | * @return string
50 | */
51 | private function getShortName(string $name): string
52 | {
53 | $chunks = explode('\\', $name);
54 | if (count($chunks) > 1) {
55 | $name = end($chunks);
56 | }
57 |
58 | return $name;
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/FQCN/FQCNTypeNormalizer.php:
--------------------------------------------------------------------------------
1 | ', '|', '&', ',', ' ', ':', "'", '"'];
32 |
33 | /**
34 | * @param \AdamWojs\PhpCsFixerPhpdocForceFQCN\Analyzer\NamespaceInfo $namespaceInfo
35 | * @param string $type
36 | *
37 | * @return string
38 | */
39 | public function normalizeType(NamespaceInfo $namespaceInfo, string $type): string
40 | {
41 | $typeToCheck = '';
42 | $typeNew = '';
43 |
44 | for ($i = 0; $i < strlen($type); $i++) {
45 | if (in_array($type[$i], static::SPECIAL_CHARS)) {
46 | if ($typeToCheck !== '') {
47 | $typeNew .= $this->normalizeSingleType($namespaceInfo, $typeToCheck);
48 | $typeToCheck = '';
49 | }
50 |
51 | $typeNew .= $type[$i];
52 |
53 | continue;
54 | }
55 |
56 | $typeToCheck .= $type[$i];
57 | }
58 |
59 | if ($typeToCheck !== '') {
60 | $typeNew .= $this->normalizeSingleType($namespaceInfo, $typeToCheck);
61 | }
62 |
63 | return $typeNew;
64 | }
65 |
66 | /**
67 | * @param \AdamWojs\PhpCsFixerPhpdocForceFQCN\Analyzer\NamespaceInfo $namespaceInfo
68 | * @param string $type
69 | *
70 | * @return string
71 | */
72 | public function normalizeSingleType(NamespaceInfo $namespaceInfo, string $type): string
73 | {
74 | if ($this->isBuildInType($type) || $this->isFQCN($type)) {
75 | return $type;
76 | }
77 |
78 | return (new FQCNResolver($namespaceInfo))->resolveFQCN($type);
79 | }
80 |
81 |
82 | /**
83 | * Returns true is given identifier is FQCN.
84 | *
85 | * @param string $id
86 | *
87 | * @return bool
88 | */
89 | private function isFQCN(string $id): bool
90 | {
91 | return strpos($id, '\\') === 0;
92 | }
93 |
94 | /**
95 | * Returns true if given identifier is build-in type
96 | *
97 | * @see http://docs.phpdoc.org/references/phpdoc/types.html#keyword
98 | *
99 | * @param string $id
100 | *
101 | * @return bool
102 | */
103 | private function isBuildInType(string $id): bool
104 | {
105 | return in_array($id, self::BUILD_IN_TYPES);
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/src/Fixer/Phpdoc/ForceFQCNFixer.php:
--------------------------------------------------------------------------------
1 | normalizer = new FQCNTypeNormalizer();
27 | }
28 |
29 | /**
30 | * {@inheritdoc}
31 | */
32 | public function getDefinition(): FixerDefinitionInterface
33 | {
34 | return new FixerDefinition('FQCN should be used in phpdoc', []);
35 | }
36 |
37 | /**
38 | * {@inheritdoc}
39 | */
40 | public function isCandidate(Tokens $tokens): bool
41 | {
42 | return $tokens->isTokenKindFound(T_DOC_COMMENT);
43 | }
44 |
45 | /**
46 | * {@inheritdoc}
47 | */
48 | public function isRisky(): bool
49 | {
50 | return false;
51 | }
52 |
53 | /**
54 | * {@inheritdoc}
55 | */
56 | public function fix(SplFileInfo $file, Tokens $tokens): void
57 | {
58 | $namespaces = (new NamespaceAnalyzer($tokens))->getNamespaces();
59 |
60 | if (\PHP_VERSION_ID < 80000) {
61 | $tokens->rewind();
62 | }
63 |
64 | foreach ($tokens as $index => $token) {
65 | if ($token->isGivenKind(T_DOC_COMMENT)) {
66 | $currentNamespace = null;
67 | foreach ($namespaces as $namespace) {
68 | if ($namespace->getScope()->inRange($index)) {
69 | $currentNamespace = $namespace;
70 | break;
71 | }
72 | }
73 |
74 | if ($currentNamespace === null) {
75 | continue;
76 | }
77 |
78 | $docBlock = new DocBlock($token->getContent());
79 |
80 | $annotations = $docBlock->getAnnotationsOfType(Annotation::getTagsWithTypes());
81 | if (empty($annotations)) {
82 | continue;
83 | }
84 |
85 | foreach ($annotations as $annotation) {
86 | $this->fixAnnotation($currentNamespace, $annotation);
87 | }
88 |
89 | $tokens[$index] = new Token([T_DOC_COMMENT, $docBlock->getContent()]);
90 | }
91 | }
92 | }
93 |
94 | /**
95 | * {@inheritdoc}
96 | */
97 | public function getName(): string
98 | {
99 | return 'AdamWojs/phpdoc_force_fqcn_fixer';
100 | }
101 |
102 | /**
103 | * {@inheritdoc}
104 | */
105 | public function getPriority(): int
106 | {
107 | return 0;
108 | }
109 |
110 | /**
111 | * {@inheritdoc}
112 | */
113 | public function supports(SplFileInfo $file): bool
114 | {
115 | return true;
116 | }
117 |
118 | /**
119 | * @param \AdamWojs\PhpCsFixerPhpdocForceFQCN\Analyzer\NamespaceInfo $currentNamespace
120 | * @param \PhpCsFixer\DocBlock\Annotation $annotation
121 | */
122 | private function fixAnnotation(NamespaceInfo $currentNamespace, Annotation $annotation): void
123 | {
124 | $types = $annotation->getTypes();
125 | foreach ($types as $i => $type) {
126 | $types[$i] = $this->normalizer->normalizeType($currentNamespace, $type);
127 | }
128 |
129 | if ($types !== $annotation->getTypes()) {
130 | $annotation->setTypes($types);
131 | }
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/tests/CorrectlyFormatTypesTest.php:
--------------------------------------------------------------------------------
1 | new ImportInfo('CustomNamespace\TestClass', 'TestClass', false, new Range(0,0)),
22 | ]);
23 |
24 | $typeActual = (new FQCNTypeNormalizer)->normalizeType($namespaceInfo, $typeOriginal);
25 |
26 | $this->assertEquals($typeExpected, $typeActual);
27 | }
28 |
29 | public function testCanFormatComplexTypeCorrectly(): void
30 | {
31 | $typeExpected = "\CustomNamespace\TestClass|(\CustomNamespace\TestClass&\CustomNamespace\TestClass<\CustomNamespace\TestClass, \CustomNamespace\TestClass>)|\CustomNamespace\TestClass{string: int}";
32 | $typeOriginal = "TestClass|(TestClass&TestClass)|TestClass{string: int}";
33 |
34 | $namespaceInfo = new NamespaceInfo('', new Range(0, 0), [
35 | 'TestClass' => new ImportInfo('CustomNamespace\TestClass', 'TestClass', false, new Range(0,0)),
36 | ]);
37 |
38 | $typeActual = (new FQCNTypeNormalizer)->normalizeType($namespaceInfo, $typeOriginal);
39 |
40 | $this->assertEquals($typeExpected, $typeActual);
41 | }
42 | }
43 |
44 |
--------------------------------------------------------------------------------