Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
74.07% |
100 / 135 |
|
40.00% |
4 / 10 |
CRAP | |
0.00% |
0 / 1 |
Prefixer | |
74.07% |
100 / 135 |
|
40.00% |
4 / 10 |
56.35 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
replaceInFiles | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
56 | |||
replaceInProjectFiles | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
20 | |||
replaceInString | |
86.67% |
13 / 15 |
|
0.00% |
0 / 1 |
6.09 | |||
replaceNamespace | |
86.21% |
25 / 29 |
|
0.00% |
0 / 1 |
4.04 | |||
replaceClassname | |
83.87% |
26 / 31 |
|
0.00% |
0 / 1 |
8.27 | |||
replaceGlobalClassInsideNamedNamespace | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
1 | |||
replaceConstants | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
replaceConstant | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getModifiedFiles | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace BrianHenryIE\Strauss; |
4 | |
5 | use BrianHenryIE\Strauss\Composer\ComposerPackage; |
6 | use BrianHenryIE\Strauss\Composer\Extra\StraussConfig; |
7 | use BrianHenryIE\Strauss\Types\NamespaceSymbol; |
8 | use Exception; |
9 | use League\Flysystem\Filesystem; |
10 | use League\Flysystem\Local\LocalFilesystemAdapter; |
11 | use Symfony\Component\Filesystem\Exception\FileNotFoundException; |
12 | |
13 | class Prefixer |
14 | { |
15 | /** @var StraussConfig */ |
16 | protected $config; |
17 | |
18 | /** @var Filesystem */ |
19 | protected $filesystem; |
20 | |
21 | protected string $targetDirectory; |
22 | protected string $namespacePrefix; |
23 | protected string $classmapPrefix; |
24 | protected ?string $constantsPrefix; |
25 | |
26 | /** @var string[] */ |
27 | protected array $excludePackageNamesFromPrefixing; |
28 | |
29 | /** @var string[] */ |
30 | protected array $excludeNamespacesFromPrefixing; |
31 | |
32 | /** @var string[] */ |
33 | protected array $excludeFilePatternsFromPrefixing; |
34 | |
35 | /** |
36 | * array<$workingDirRelativeFilepath, $package> or null if the file is not from a dependency (i.e. a project file). |
37 | * |
38 | * @var array<string, ?ComposerPackage> |
39 | */ |
40 | protected array $changedFiles = array(); |
41 | |
42 | public function __construct(StraussConfig $config, string $workingDir) |
43 | { |
44 | $this->config = $config; |
45 | |
46 | $this->filesystem = new Filesystem(new LocalFilesystemAdapter($workingDir)); |
47 | |
48 | $this->targetDirectory = $config->getTargetDirectory(); |
49 | $this->namespacePrefix = $config->getNamespacePrefix(); |
50 | $this->classmapPrefix = $config->getClassmapPrefix(); |
51 | $this->constantsPrefix = $config->getConstantsPrefix(); |
52 | |
53 | $this->excludePackageNamesFromPrefixing = $config->getExcludePackagesFromPrefixing(); |
54 | $this->excludeNamespacesFromPrefixing = $config->getExcludeNamespacesFromPrefixing(); |
55 | $this->excludeFilePatternsFromPrefixing = $config->getExcludeFilePatternsFromPrefixing(); |
56 | } |
57 | |
58 | // Don't replace a classname if there's an import for a class with the same name. |
59 | // but do replace \Classname always |
60 | |
61 | |
62 | /** |
63 | * @param DiscoveredSymbols $discoveredSymbols |
64 | * @param array<string,array{dependency:ComposerPackage,sourceAbsoluteFilepath:string,targetRelativeFilepath:string}> $phpFileArrays |
65 | */ |
66 | public function replaceInFiles(DiscoveredSymbols $discoveredSymbols, array $phpFileArrays): void |
67 | { |
68 | |
69 | foreach ($phpFileArrays as $targetRelativeFilepath => $fileArray) { |
70 | $package = $fileArray['dependency']; |
71 | |
72 | // Skip excluded namespaces. |
73 | if (in_array($package->getPackageName(), $this->excludePackageNamesFromPrefixing)) { |
74 | continue; |
75 | } |
76 | |
77 | // Skip files whose filepath matches an excluded pattern. |
78 | foreach ($this->excludeFilePatternsFromPrefixing as $excludePattern) { |
79 | if (1 === preg_match($excludePattern, $targetRelativeFilepath)) { |
80 | continue 2; |
81 | } |
82 | } |
83 | |
84 | $targetRelativeFilepathFromProject = $this->targetDirectory. $targetRelativeFilepath; |
85 | |
86 | if (! $this->filesystem->fileExists($targetRelativeFilepathFromProject)) { |
87 | continue; |
88 | } |
89 | |
90 | // Throws an exception, but unlikely to happen. |
91 | $contents = $this->filesystem->read($targetRelativeFilepathFromProject); |
92 | |
93 | $updatedContents = $this->replaceInString($discoveredSymbols, $contents); |
94 | |
95 | if ($updatedContents !== $contents) { |
96 | $this->changedFiles[$targetRelativeFilepath] = $package; |
97 | $this->filesystem->write($targetRelativeFilepathFromProject, $updatedContents); |
98 | } |
99 | } |
100 | } |
101 | |
102 | /** |
103 | * @param DiscoveredSymbols $discoveredSymbols |
104 | * @param string[] $relativeFilePaths |
105 | * @return void |
106 | * @throws \League\Flysystem\FilesystemException |
107 | */ |
108 | public function replaceInProjectFiles(DiscoveredSymbols $discoveredSymbols, array $relativeFilePaths): void |
109 | { |
110 | foreach ($relativeFilePaths as $workingDirRelativeFilepath) { |
111 | if (! $this->filesystem->fileExists($workingDirRelativeFilepath)) { |
112 | continue; |
113 | } |
114 | |
115 | // Throws an exception, but unlikely to happen. |
116 | $contents = $this->filesystem->read($workingDirRelativeFilepath); |
117 | |
118 | $updatedContents = $this->replaceInString($discoveredSymbols, $contents); |
119 | |
120 | if ($updatedContents !== $contents) { |
121 | $this->changedFiles[ $workingDirRelativeFilepath ] = null; |
122 | $this->filesystem->write($workingDirRelativeFilepath, $updatedContents); |
123 | } |
124 | } |
125 | } |
126 | |
127 | /** |
128 | * @param DiscoveredSymbols $discoveredSymbols |
129 | * @param string $contents |
130 | */ |
131 | public function replaceInString(DiscoveredSymbols $discoveredSymbols, string $contents): string |
132 | { |
133 | $namespacesChanges = $discoveredSymbols->getDiscoveredNamespaces($this->config->getNamespacePrefix()); |
134 | $classes = $discoveredSymbols->getDiscoveredClasses($this->config->getClassmapPrefix()); |
135 | $constants = $discoveredSymbols->getDiscoveredConstants($this->config->getConstantsPrefix()); |
136 | |
137 | foreach ($classes as $originalClassname) { |
138 | if ('ReturnTypeWillChange' === $originalClassname) { |
139 | continue; |
140 | } |
141 | |
142 | $classmapPrefix = $this->classmapPrefix; |
143 | |
144 | $contents = $this->replaceClassname($contents, $originalClassname, $classmapPrefix); |
145 | } |
146 | |
147 | foreach ($namespacesChanges as $originalNamespace => $namespaceSymbol) { |
148 | if (in_array($originalNamespace, $this->excludeNamespacesFromPrefixing)) { |
149 | continue; |
150 | } |
151 | |
152 | $contents = $this->replaceNamespace($contents, $originalNamespace, $namespaceSymbol->getReplacement()); |
153 | } |
154 | |
155 | if (!is_null($this->constantsPrefix)) { |
156 | $contents = $this->replaceConstants($contents, $constants, $this->constantsPrefix); |
157 | } |
158 | |
159 | return $contents; |
160 | } |
161 | |
162 | /** |
163 | * TODO: Test against traits. |
164 | * |
165 | * @param string $contents The text to make replacements in. |
166 | * @param string $originalNamespace |
167 | * @param string $replacement |
168 | * |
169 | * @return string The updated text. |
170 | */ |
171 | public function replaceNamespace(string $contents, string $originalNamespace, string $replacement): string |
172 | { |
173 | |
174 | $searchNamespace = '\\'.rtrim($originalNamespace, '\\') . '\\'; |
175 | $searchNamespace = str_replace('\\\\', '\\', $searchNamespace); |
176 | $searchNamespace = str_replace('\\', '\\\\{0,2}', $searchNamespace); |
177 | |
178 | $pattern = " |
179 | / # Start the pattern |
180 | ( |
181 | ^\s* # start of the string |
182 | |\\n\s* # start of the line |
183 | |(<?php\s+namespace|^\s*namespace|[\r\n]+\s*namespace)\s+ # the namespace keyword |
184 | |use\s+ # the use keyword |
185 | |use\s+function\s+ # the use function syntax |
186 | |new\s+ |
187 | |static\s+ |
188 | |\" # inside a string that does not contain spaces - needs work |
189 | |' # right now its just inside a string that doesnt start with a space |
190 | |implements\s+ |
191 | |extends\s+ # when the class being extended is namespaced inline |
192 | |return\s+ |
193 | |instanceof\s+ # when checking the class type of an object in a conditional |
194 | |\(\s* # inside a function declaration as the first parameters type |
195 | |,\s* # inside a function declaration as a subsequent parameter type |
196 | |\.\s* # as part of a concatenated string |
197 | |=\s* # as the value being assigned to a variable |
198 | |\*\s+@\w+\s* # In a comments param etc |
199 | |&\s* # a static call as a second parameter of an if statement |
200 | |\|\s* |
201 | |!\s* # negating the result of a static call |
202 | |=>\s* # as the value in an associative array |
203 | |\[\s* # In a square array |
204 | |\?\s* # In a ternary operator |
205 | |:\s* # In a ternary operator |
206 | |\(string\)\s* # casting a namespaced class to a string |
207 | ) |
208 | @? # Maybe preceeded by the @ symbol for error suppression |
209 | (?<searchNamespace> |
210 | {$searchNamespace} # followed by the namespace to replace |
211 | ) |
212 | (?!:) # Not followed by : which would only be valid after a classname |
213 | ( |
214 | \s*; # followed by a semicolon |
215 | |\\\\{1,2}[a-zA-Z0-9_\x7f-\xff]{1,} # or a classname no slashes |
216 | |\s+as # or the keyword as |
217 | |\" # or quotes |
218 | |' # or single quote |
219 | |: # or a colon to access a static |
220 | |\\\\{ |
221 | ) |
222 | /Ux"; // U: Non-greedy matching, x: ignore whitespace in pattern. |
223 | |
224 | $replacingFunction = function ($matches) use ($originalNamespace, $replacement) { |
225 | $singleBackslash = '\\'; |
226 | $doubleBackslash = '\\\\'; |
227 | |
228 | if (false !== strpos($matches['0'], $doubleBackslash)) { |
229 | $originalNamespace = str_replace($singleBackslash, $doubleBackslash, $originalNamespace); |
230 | $replacement = str_replace($singleBackslash, $doubleBackslash, $replacement); |
231 | } |
232 | |
233 | $replaced = str_replace($originalNamespace, $replacement, $matches[0]); |
234 | |
235 | return $replaced; |
236 | }; |
237 | |
238 | $result = preg_replace_callback($pattern, $replacingFunction, $contents); |
239 | |
240 | $matchingError = preg_last_error(); |
241 | if (0 !== $matchingError) { |
242 | $message = "Matching error {$matchingError}"; |
243 | if (PREG_BACKTRACK_LIMIT_ERROR === $matchingError) { |
244 | $message = 'Preg Backtrack limit was exhausted!'; |
245 | } |
246 | throw new Exception($message); |
247 | } |
248 | |
249 | // For prefixed functions which do not begin with a backslash, add one. |
250 | // I'm not certain this is a good idea. |
251 | // @see https://github.com/BrianHenryIE/strauss/issues/65 |
252 | $functionReplacingPattern = '/\\\\?('.preg_quote(ltrim($replacement, '\\'), '/').'\\\\(?:[a-zA-Z0-9_\x7f-\xff]+\\\\)*[a-zA-Z0-9_\x7f-\xff]+\\()/'; |
253 | $result = preg_replace( |
254 | $functionReplacingPattern, |
255 | "\\\\$1", |
256 | $result |
257 | ); |
258 | |
259 | return $result; |
260 | } |
261 | |
262 | /** |
263 | * In a namespace: |
264 | * * use \Classname; |
265 | * * new \Classname() |
266 | * |
267 | * In a global namespace: |
268 | * * new Classname() |
269 | * |
270 | * @param string $contents |
271 | * @param string $originalClassname |
272 | * @param string $classnamePrefix |
273 | * @throws \Exception |
274 | */ |
275 | public function replaceClassname(string $contents, string $originalClassname, string $classnamePrefix): string |
276 | { |
277 | $searchClassname = preg_quote($originalClassname, '/'); |
278 | |
279 | // This could be more specific if we could enumerate all preceding and proceeding words ("new", "("...). |
280 | $pattern = ' |
281 | / # Start the pattern |
282 | (^\s*namespace|\r\n\s*namespace)\s+[a-zA-Z0-9_\x7f-\xff\\\\]+\s*{(.*?)(namespace|\z) |
283 | # Look for a preceding namespace declaration, up until a |
284 | # potential second namespace declaration. |
285 | | # if found, match that much before continuing the search on |
286 | # the remainder of the string. |
287 | (^\s*namespace|\r\n\s*namespace)\s+[a-zA-Z0-9_\x7f-\xff\\\\]+\s*;(.*) # Skip lines just declaring the namespace. |
288 | | |
289 | ([^a-zA-Z0-9_\x7f-\xff\$\\\])('. $searchClassname . ')([^a-zA-Z0-9_\x7f-\xff\\\]) # outside a namespace the class will not be prefixed with a slash |
290 | |
291 | /xsm'; // # x: ignore whitespace in regex. s dot matches newline, m: ^ and $ match start and end of line |
292 | |
293 | $replacingFunction = function ($matches) use ($originalClassname, $classnamePrefix) { |
294 | |
295 | // If we're inside a namespace other than the global namespace: |
296 | if (1 === preg_match('/\s*namespace\s+[a-zA-Z0-9_\x7f-\xff\\\\]+[;{\s\n]{1}.*/', $matches[0])) { |
297 | $updated = $this->replaceGlobalClassInsideNamedNamespace( |
298 | $matches[0], |
299 | $originalClassname, |
300 | $classnamePrefix |
301 | ); |
302 | |
303 | return $updated; |
304 | } else { |
305 | $newContents = ''; |
306 | foreach ($matches as $index => $captured) { |
307 | if (0 === $index) { |
308 | continue; |
309 | } |
310 | |
311 | if ($captured == $originalClassname) { |
312 | $newContents .= $classnamePrefix; |
313 | } |
314 | |
315 | $newContents .= $captured; |
316 | } |
317 | return $newContents; |
318 | } |
319 | // return $matches[1] . $matches[2] . $matches[3] . $classnamePrefix . $originalClassname . $matches[5]; |
320 | }; |
321 | |
322 | $result = preg_replace_callback($pattern, $replacingFunction, $contents); |
323 | |
324 | if (is_null($result)) { |
325 | throw new Exception('preg_replace_callback returned null'); |
326 | } |
327 | |
328 | $matchingError = preg_last_error(); |
329 | if (0 !== $matchingError) { |
330 | $message = "Matching error {$matchingError}"; |
331 | if (PREG_BACKTRACK_LIMIT_ERROR === $matchingError) { |
332 | $message = 'Backtrack limit was exhausted!'; |
333 | } |
334 | throw new Exception($message); |
335 | } |
336 | |
337 | return $result; |
338 | } |
339 | |
340 | /** |
341 | * Pass in a string and look for \Classname instances. |
342 | * |
343 | * @param string $contents |
344 | * @param string $originalClassname |
345 | * @param string $classnamePrefix |
346 | * @return string |
347 | */ |
348 | protected function replaceGlobalClassInsideNamedNamespace($contents, $originalClassname, $classnamePrefix): string |
349 | { |
350 | $replacement = $classnamePrefix . $originalClassname; |
351 | |
352 | // use Prefixed_Class as Class; |
353 | $usePattern = '/ |
354 | (\s*use\s+) |
355 | ('.$originalClassname.') # Followed by the classname |
356 | \s*; |
357 | /x'; // # x: ignore whitespace in regex. |
358 | |
359 | $contents = preg_replace_callback( |
360 | $usePattern, |
361 | function ($matches) use ($replacement) { |
362 | return $matches[1] . $replacement . ' as '. $matches[2] . ';'; |
363 | }, |
364 | $contents |
365 | ); |
366 | |
367 | $bodyPattern = |
368 | '/([^a-zA-Z0-9_\x7f-\xff] # Not a class character |
369 | \\\) # Followed by a backslash to indicate global namespace |
370 | ('.$originalClassname.') # Followed by the classname |
371 | ([^\\\;]{1}) # Not a backslash or semicolon which might indicate a namespace |
372 | /x'; // # x: ignore whitespace in regex. |
373 | |
374 | $contents = preg_replace_callback( |
375 | $bodyPattern, |
376 | function ($matches) use ($replacement) { |
377 | return $matches[1] . $replacement . $matches[3]; |
378 | }, |
379 | $contents |
380 | ); |
381 | |
382 | return $contents; |
383 | } |
384 | |
385 | /** |
386 | * TODO: This should be split and brought to FileScanner. |
387 | * |
388 | * @param string $contents |
389 | * @param string[] $originalConstants |
390 | * @param string $prefix |
391 | */ |
392 | protected function replaceConstants(string $contents, array $originalConstants, string $prefix): string |
393 | { |
394 | |
395 | foreach ($originalConstants as $constant) { |
396 | $contents = $this->replaceConstant($contents, $constant, $prefix . $constant); |
397 | } |
398 | |
399 | return $contents; |
400 | } |
401 | |
402 | protected function replaceConstant(string $contents, string $originalConstant, string $replacementConstant): string |
403 | { |
404 | return str_replace($originalConstant, $replacementConstant, $contents); |
405 | } |
406 | |
407 | /** |
408 | * @return array<string, ComposerPackage> |
409 | */ |
410 | public function getModifiedFiles(): array |
411 | { |
412 | return $this->changedFiles; |
413 | } |
414 | } |