Issues (224)

src/Compactor/Php.php (2 issues)

1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of the box project.
7
 *
8
 * (c) Kevin Herrera <[email protected]>
9
 *     Théo Fidry <[email protected]>
10
 *
11
 * This source file is subject to the MIT license that is bundled
12
 * with this source code in the file LICENSE.
13
 */
14
15
namespace KevinGH\Box\Compactor;
16
17
use KevinGH\Box\Annotation\CompactedFormatter;
18
use KevinGH\Box\Annotation\DocblockAnnotationParser;
19
use phpDocumentor\Reflection\DocBlockFactory;
20
use PhpToken;
21
use RuntimeException;
22
use Webmozart\Assert\Assert;
23
use function array_pop;
24
use function array_slice;
25
use function array_splice;
26
use function count;
27
use function is_int;
28
use function ltrim;
29
use function preg_replace;
30
use function str_repeat;
31
use const T_COMMENT;
32
use const T_DOC_COMMENT;
33
use const T_WHITESPACE;
34
35
/**
36
 * A PHP source code compactor copied from Composer.
37
 *
38
 * @see https://github.com/composer/composer/blob/a8df30c09be550bffc37ba540fb7c7f0383c3944/src/Composer/Compiler.php#L214
39
 *
40
 * @author Kevin Herrera <[email protected]>
41
 * @author Fabien Potencier <[email protected]>
42
 * @author Jordi Boggiano <[email protected]>
43
 * @author Théo Fidry <[email protected]>
44
 * @author Juliette Reinders Folmer <[email protected]>
45
 * @author Alessandro Chitolina <[email protected]>
46
 *
47
 * @private
48
 */
49
final class Php extends FileExtensionCompactor
50
{
51
    /**
52
     * @param list<string> $ignoredAnnotations
0 ignored issues
show
The type KevinGH\Box\Compactor\list was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
53
     */
54
    public static function create(array $ignoredAnnotations): self
55
    {
56
        return new self(
57
            new DocblockAnnotationParser(
58
                DocBlockFactory::createInstance(),
59
                new CompactedFormatter(),
60
                $ignoredAnnotations,
61
            ),
62
        );
63
    }
64
65
    public function __construct(
66
        private readonly ?DocblockAnnotationParser $annotationParser,
67
        array $extensions = ['php'],
68
    ) {
69
        parent::__construct($extensions);
70
    }
71
72
    protected function compactContent(string $contents): string
73
    {
74
        $output = '';
75
        $tokens = PhpToken::tokenize($contents);
76
        $tokenCount = count($tokens);
77
78
        for ($index = 0; $index < $tokenCount; ++$index) {
79
            $token = $tokens[$index];
80
            $tokenText = $token->text;
81
82
            if ($token->is([T_COMMENT, T_DOC_COMMENT])) {
83
                if (str_starts_with($tokenText, '#[')) {
84
                    // This is, in all likelihood, the start of a PHP >= 8.0 attribute.
85
                    // Note: $tokens may be updated by reference as well!
86
                    $retokenized = $this->retokenizeAttribute($tokens, $index);
87
88
                    if (null !== $retokenized) {
89
                        array_splice($tokens, $index, 1, $retokenized);
90
                        $tokenCount = count($tokens);
91
                    }
92
93
                    $attributeCloser = self::findAttributeCloser($tokens, $index);
94
95
                    if (is_int($attributeCloser)) {
96
                        $output .= '#[';
97
                    } else {
98
                        // Turns out this was not an attribute. Treat it as a plain comment.
99
                        $output .= str_repeat("\n", mb_substr_count($tokenText, "\n"));
100
                    }
101
                } elseif (str_contains($tokenText, '@')) {
102
                    try {
103
                        $output .= $this->compactAnnotations($tokenText);
104
                    } catch (RuntimeException) {
105
                        $output .= $tokenText;
106
                    }
107
                } else {
108
                    $output .= str_repeat("\n", mb_substr_count($tokenText, "\n"));
109
                }
110
            } elseif ($token->is(T_WHITESPACE)) {
111
                $whitespace = $tokenText;
112
                $previousIndex = ($index - 1);
113
114
                // Handle whitespace potentially being split into two tokens after attribute retokenization.
115
                $nextToken = $tokens[$index + 1] ?? null;
116
117
                if (null !== $nextToken
118
                    && $nextToken->is(T_WHITESPACE)
119
                ) {
120
                    $whitespace .= $nextToken->text;
121
                    ++$index;
122
                }
123
124
                // reduce wide spaces
125
                $whitespace = preg_replace('{[ \t]+}', ' ', $whitespace);
126
127
                // normalize newlines to \n
128
                $whitespace = preg_replace('{(?:\r\n|\r|\n)}', "\n", $whitespace);
129
130
                // If the new line was split off from the whitespace token due to it being included in
131
                // the previous (comment) token (PHP < 8), remove leading spaces.
132
133
                $previousToken = $tokens[$previousIndex];
134
135
                if ($previousToken->is(T_COMMENT)
136
                    && str_contains($previousToken->text, "\n")
137
                ) {
138
                    $whitespace = ltrim($whitespace, ' ');
139
                }
140
141
                // trim leading spaces
142
                $whitespace = preg_replace('{\n +}', "\n", $whitespace);
143
144
                $output .= $whitespace;
145
            } else {
146
                $output .= $tokenText;
147
            }
148
        }
149
150
        return $output;
151
    }
152
153
    private function compactAnnotations(string $docblock): string
154
    {
155
        if (null === $this->annotationParser) {
156
            return $docblock;
157
        }
158
159
        $breaksNbr = mb_substr_count($docblock, "\n");
160
161
        $annotations = $this->annotationParser->parse($docblock);
162
163
        if ([] === $annotations) {
164
            return str_repeat("\n", $breaksNbr);
165
        }
166
167
        $compactedDocblock = '/**';
168
169
        foreach ($annotations as $annotation) {
170
            $compactedDocblock .= "\n".$annotation;
171
        }
172
173
        $breaksNbr -= count($annotations);
174
175
        if ($breaksNbr > 0) {
176
            $compactedDocblock .= str_repeat("\n", $breaksNbr - 1);
177
            $compactedDocblock .= "\n*/";
178
        } else {
179
            // A space is required here to avoid having /***/
180
            $compactedDocblock .= ' */';
181
        }
182
183
        return $compactedDocblock;
184
    }
185
186
    /**
187
     * @param list<PhpToken> $tokens
188
     */
189
    private static function findAttributeCloser(array $tokens, int $opener): ?int
190
    {
191
        $tokenCount = count($tokens);
192
        $brackets = [$opener];
193
        $closer = null;
194
195
        for ($i = ($opener + 1); $i < $tokenCount; ++$i) {
196
            $tokenText = $tokens[$i]->text;
197
198
            // Allow for short arrays within attributes.
199
            if ('[' === $tokenText) {
200
                $brackets[] = $i;
201
202
                continue;
203
            }
204
205
            if (']' === $tokenText) {
206
                array_pop($brackets);
207
208
                if (0 === count($brackets)) {
209
                    $closer = $i;
210
                    break;
211
                }
212
            }
213
        }
214
215
        return $closer;
216
    }
217
218
    /**
219
     * @param non-empty-list<PhpToken> $tokens
0 ignored issues
show
Documentation Bug introduced by
The doc comment non-empty-list<PhpToken> at position 0 could not be parsed: Unknown type name 'non-empty-list' at position 0 in non-empty-list<PhpToken>.
Loading history...
220
     */
221
    private function retokenizeAttribute(array &$tokens, int $opener): ?array
222
    {
223
        Assert::keyExists($tokens, $opener);
224
225
        $token = $tokens[$opener];
226
        $attributeBody = mb_substr($token->text, 2);
227
        $subTokens = PhpToken::tokenize('<?php '.$attributeBody);
228
229
        // Replace the PHP open tag with the attribute opener as a simple token.
230
        array_splice($subTokens, 0, 1, ['#[']);
231
232
        $closer = self::findAttributeCloser($subTokens, 0);
233
234
        // Multi-line attribute or attribute containing something which looks like a PHP close tag.
235
        // Retokenize the rest of the file after the attribute opener.
236
        if (null === $closer) {
237
            foreach (array_slice($tokens, $opener + 1) as $token) {
238
                $attributeBody .= $token->text;
239
            }
240
241
            $subTokens = PhpToken::tokenize('<?php '.$attributeBody);
242
            array_splice($subTokens, 0, 1, ['#[']);
243
244
            $closer = self::findAttributeCloser($subTokens, 0);
245
246
            if (null !== $closer) {
247
                array_splice(
248
                    $tokens,
249
                    $opener + 1,
250
                    count($tokens),
251
                    array_slice($subTokens, $closer + 1),
252
                );
253
254
                $subTokens = array_slice($subTokens, 0, $closer + 1);
255
            }
256
        }
257
258
        if (null === $closer) {
259
            return null;
260
        }
261
262
        return $subTokens;
263
    }
264
}
265