Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
73.96% covered (warning)
73.96%
125 / 169
36.36% covered (danger)
36.36%
4 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Prefixer
73.96% covered (warning)
73.96%
125 / 169
36.36% covered (danger)
36.36%
4 / 11
68.24
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 replaceInFiles
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
42
 replaceInProjectFiles
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 replaceInString
85.71% covered (warning)
85.71%
18 / 21
0.00% covered (danger)
0.00%
0 / 1
7.14
 replaceNamespace
85.19% covered (warning)
85.19%
23 / 27
0.00% covered (danger)
0.00%
0 / 1
4.05
 replaceClassname
83.33% covered (warning)
83.33%
25 / 30
0.00% covered (danger)
0.00%
0 / 1
8.30
 replaceGlobalClassInsideNamedNamespace
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
1
 replaceConstants
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 replaceConstant
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 replaceFunctions
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
4
 getModifiedFiles
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace BrianHenryIE\Strauss\Pipeline;
4
5use BrianHenryIE\Strauss\Composer\ComposerPackage;
6use BrianHenryIE\Strauss\Config\PrefixerConfigInterface;
7use BrianHenryIE\Strauss\Files\File;
8use BrianHenryIE\Strauss\Helpers\FileSystem;
9use BrianHenryIE\Strauss\Helpers\NamespaceSort;
10use BrianHenryIE\Strauss\Types\DiscoveredSymbols;
11use BrianHenryIE\Strauss\Types\FunctionSymbol;
12use Exception;
13use League\Flysystem\FilesystemException;
14use Psr\Log\LoggerAwareTrait;
15use Psr\Log\LoggerInterface;
16use Psr\Log\NullLogger;
17
18class Prefixer
19{
20    use LoggerAwareTrait;
21
22    protected PrefixerConfigInterface $config;
23
24    protected FileSystem $filesystem;
25
26    /**
27     * array<$filePath, $package> or null if the file is not from a dependency (i.e. a project file).
28     *
29     * @var array<string, ?ComposerPackage>
30     */
31    protected array $changedFiles = array();
32
33    public function __construct(
34        PrefixerConfigInterface $config,
35        FileSystem $filesystem,
36        ?LoggerInterface $logger = null
37    ) {
38        $this->config = $config;
39        $this->filesystem = $filesystem;
40        $this->logger = $logger ?? new NullLogger();
41    }
42
43    // Don't replace a classname if there's an import for a class with the same name.
44    // but do replace \Classname always
45
46    /**
47     * @param DiscoveredSymbols $discoveredSymbols
48     * ///param array<string,array{dependency:ComposerPackage,sourceAbsoluteFilepath:string,targetRelativeFilepath:string}> $phpFileArrays
49     * @param array<File> $files
50     *
51     * @throws FilesystemException
52     * @throws FilesystemException
53     */
54    public function replaceInFiles(DiscoveredSymbols $discoveredSymbols, array $files): void
55    {
56        foreach ($files as $file) {
57            if ($this->filesystem->directoryExists($file->getAbsoluteTargetPath())) {
58                $this->logger->debug("is_dir() / nothing to do : {$file->getAbsoluteTargetPath()}");
59                continue;
60            }
61
62            if (! $this->filesystem->fileExists($file->getAbsoluteTargetPath())) {
63                $this->logger->warning("Expected file does not exist: {$file->getAbsoluteTargetPath()}");
64                continue;
65            }
66
67            if (!$file->isPhpFile()) {
68                continue;
69            }
70
71            /**
72             * Throws an exception, but unlikely to happen.
73             */
74            $contents = $this->filesystem->read($file->getAbsoluteTargetPath());
75
76            $updatedContents = $this->replaceInString($discoveredSymbols, $contents);
77
78            if ($updatedContents !== $contents) {
79                // TODO: diff here and debug log.
80                $file->setDidUpdate();
81                $this->filesystem->write($file->getAbsoluteTargetPath(), $updatedContents);
82                $this->logger->info('Updated contents of file: ' . $file->getAbsoluteTargetPath());
83            } else {
84                $this->logger->debug('No changes to file: ' . $file->getAbsoluteTargetPath());
85            }
86        }
87    }
88
89    /**
90     * @param DiscoveredSymbols $discoveredSymbols
91     * @param string[] $absoluteFilePathsArray
92     *
93     * @return void
94     * @throws FilesystemException
95     */
96    public function replaceInProjectFiles(DiscoveredSymbols $discoveredSymbols, array $absoluteFilePathsArray): void
97    {
98
99        foreach ($absoluteFilePathsArray as $fileAbsolutePath) {
100            if ($this->filesystem->directoryExists($fileAbsolutePath)) {
101                $this->logger->debug("is_dir() / nothing to do : {$fileAbsolutePath}");
102                continue;
103            }
104
105            if (! $this->filesystem->fileExists($fileAbsolutePath)) {
106                $this->logger->warning("Expected file does not exist: {$fileAbsolutePath}");
107                continue;
108            }
109
110            // Throws an exception, but unlikely to happen.
111            $contents = $this->filesystem->read($fileAbsolutePath);
112
113            $updatedContents = $this->replaceInString($discoveredSymbols, $contents);
114
115            if ($updatedContents !== $contents) {
116                $this->changedFiles[ $fileAbsolutePath ] = null;
117                $this->filesystem->write($fileAbsolutePath, $updatedContents);
118                $this->logger->info('Updated contents of file: ' . $fileAbsolutePath);
119            } else {
120                $this->logger->debug('No changes to file: ' . $fileAbsolutePath);
121            }
122        }
123    }
124
125    /**
126     * @param DiscoveredSymbols $discoveredSymbols
127     * @param string $contents
128     *
129     * @throws Exception
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        $functions = $discoveredSymbols->getDiscoveredFunctions();
137
138        foreach ($classes as $originalClassname) {
139            $classmapPrefix = $this->config->getClassmapPrefix();
140
141            $contents = $this->replaceClassname($contents, $originalClassname, $classmapPrefix);
142        }
143
144        // TODO: Move this out of the loop.
145        $namespacesChangesStrings = [];
146        foreach ($namespacesChanges as $originalNamespace => $namespaceSymbol) {
147            if (in_array($originalNamespace, $this->config->getExcludeNamespacesFromPrefixing())) {
148                $this->logger->info("Skipping namespace: $originalNamespace");
149                continue;
150            }
151            $namespacesChangesStrings[$originalNamespace] = $namespaceSymbol->getReplacement();
152        }
153        // This matters... it shouldn't.
154        uksort($namespacesChangesStrings, new NamespaceSort(NamespaceSort::SHORTEST));
155        foreach ($namespacesChangesStrings as $originalNamespace => $replacementNamespace) {
156            $contents = $this->replaceNamespace($contents, $originalNamespace, $replacementNamespace);
157        }
158
159        if (!is_null($this->config->getConstantsPrefix())) {
160            $contents = $this->replaceConstants($contents, $constants, $this->config->getConstantsPrefix());
161        }
162
163        foreach ($functions as $functionSymbol) {
164            $contents = $this->replaceFunctions($contents, $functionSymbol);
165        }
166
167        return $contents;
168    }
169
170    /**
171     * TODO: Test against traits.
172     *
173     * @param string $contents The text to make replacements in.
174     * @param string $originalNamespace
175     * @param string $replacement
176     *
177     * @return string The updated text.
178     * @throws Exception
179     */
180    public function replaceNamespace(string $contents, string $originalNamespace, string $replacement): string
181    {
182
183        $searchNamespace = '\\'.rtrim($originalNamespace, '\\') . '\\';
184        $searchNamespace = str_replace('\\\\', '\\', $searchNamespace);
185        $searchNamespace = str_replace('\\', '\\\\{0,2}', $searchNamespace);
186
187        $pattern = "
188            /                              # Start the pattern
189            (
190            ^\s*                          # start of the string
191            |\\n\s*                        # start of the line
192            |(<?php\s+namespace|^\s*namespace|[\r\n]+\s*namespace)\s+                  # the namespace keyword
193            |use\s+                        # the use keyword
194            |use\s+function\s+               # the use function syntax
195            |new\s+
196            |static\s+
197            |\"                            # inside a string that does not contain spaces - needs work
198            |'                             #   right now its just inside a string that doesnt start with a space
199            |implements\s+
200            |extends\s+                    # when the class being extended is namespaced inline
201            |return\s+
202            |instanceof\s+                 # when checking the class type of an object in a conditional
203            |\(\s*                         # inside a function declaration as the first parameters type
204            |,\s*                          # inside a function declaration as a subsequent parameter type
205            |\.\s*                         # as part of a concatenated string
206            |=\s*                          # as the value being assigned to a variable
207            |\*\s+@\w+\s*                  # In a comments param etc  
208            |&\s*                             # a static call as a second parameter of an if statement
209            |\|\s*
210            |!\s*                             # negating the result of a static call
211            |=>\s*                            # as the value in an associative array
212            |\[\s*                         # In a square array 
213            |\?\s*                         # In a ternary operator
214            |:\s*                          # In a ternary operator
215            |<                             # In a generic type declaration
216            |\(string\)\s*                 # casting a namespaced class to a string
217            )
218            @?                             # Maybe preceeded by the @ symbol for error suppression
219            (?<searchNamespace>
220            {$searchNamespace}             # followed by the namespace to replace
221            )
222            (?!:)                          # Not followed by : which would only be valid after a classname
223            (
224            \s*;                           # followed by a semicolon 
225            |\\\\{1,2}[a-zA-Z0-9_\x7f-\xff]{1,}         # or a classname no slashes 
226            |\s+as                         # or the keyword as 
227            |\"                            # or quotes
228            |'                             # or single quote         
229            |:                             # or a colon to access a static
230            |\\\\{
231            |>                             # In a generic type declaration (end)
232            )                            
233            /Ux";                          // U: Non-greedy matching, x: ignore whitespace in pattern.
234
235        $replacingFunction = function ($matches) use ($originalNamespace, $replacement) {
236            $singleBackslash = '\\';
237            $doubleBackslash = '\\\\';
238
239            if (false !== strpos($matches['0'], $doubleBackslash)) {
240                $originalNamespace = str_replace($singleBackslash, $doubleBackslash, $originalNamespace);
241                $replacement = str_replace($singleBackslash, $doubleBackslash, $replacement);
242            }
243
244            return str_replace($originalNamespace, $replacement, $matches[0]);
245        };
246
247        $result = preg_replace_callback($pattern, $replacingFunction, $contents);
248
249        $matchingError = preg_last_error();
250        if (0 !== $matchingError) {
251            $message = "Matching error {$matchingError}";
252            if (PREG_BACKTRACK_LIMIT_ERROR === $matchingError) {
253                $message = 'Preg Backtrack limit was exhausted!';
254            }
255            throw new Exception($message);
256        }
257
258        // For prefixed functions which do not begin with a backslash, add one.
259        // I'm not certain this is a good idea.
260        // @see https://github.com/BrianHenryIE/strauss/issues/65
261        $functionReplacingPattern = '/\\\\?('.preg_quote(ltrim($replacement, '\\'), '/').'\\\\(?:[a-zA-Z0-9_\x7f-\xff]+\\\\)*[a-zA-Z0-9_\x7f-\xff]+\\()/';
262
263        return preg_replace(
264            $functionReplacingPattern,
265            "\\\\$1",
266            $result
267        );
268    }
269
270    /**
271     * In a namespace:
272     * * use \Classname;
273     * * new \Classname()
274     *
275     * In a global namespace:
276     * * new Classname()
277     *
278     * @param string $contents
279     * @param string $originalClassname
280     * @param string $classnamePrefix
281     *
282     * @throws Exception
283     */
284    public function replaceClassname(string $contents, string $originalClassname, string $classnamePrefix): string
285    {
286        $searchClassname = preg_quote($originalClassname, '/');
287
288        // This could be more specific if we could enumerate all preceding and proceeding words ("new", "("...).
289        $pattern = '
290            /                                            # Start the pattern
291                (^\s*namespace|\r\n\s*namespace)\s+[a-zA-Z0-9_\x7f-\xff\\\\]+\s*{(.*?)(namespace|\z) 
292                                                        # Look for a preceding namespace declaration, up until a 
293                                                        # potential second namespace declaration.
294                |                                        # if found, match that much before continuing the search on
295                                                        # the remainder of the string.
296                (^\s*namespace|\r\n\s*namespace)\s+[a-zA-Z0-9_\x7f-\xff\\\\]+\s*;(.*) # Skip lines just declaring the namespace.
297                |                    
298                ([^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
299                
300            /xsm'; //                                    # x: ignore whitespace in regex.  s dot matches newline, m: ^ and $ match start and end of line
301
302        $replacingFunction = function ($matches) use ($originalClassname, $classnamePrefix) {
303
304            // If we're inside a namespace other than the global namespace:
305            if (1 === preg_match('/\s*namespace\s+[a-zA-Z0-9_\x7f-\xff\\\\]+[;{\s\n]{1}.*/', $matches[0])) {
306                return $this->replaceGlobalClassInsideNamedNamespace(
307                    $matches[0],
308                    $originalClassname,
309                    $classnamePrefix
310                );
311            } else {
312                $newContents = '';
313                foreach ($matches as $index => $captured) {
314                    if (0 === $index) {
315                        continue;
316                    }
317
318                    if ($captured == $originalClassname) {
319                        $newContents .= $classnamePrefix;
320                    }
321
322                    $newContents .= $captured;
323                }
324                return $newContents;
325            }
326//            return $matches[1] . $matches[2] . $matches[3] . $classnamePrefix . $originalClassname . $matches[5];
327        };
328
329        $result = preg_replace_callback($pattern, $replacingFunction, $contents);
330
331        if (is_null($result)) {
332            throw new Exception('preg_replace_callback returned null');
333        }
334
335        $matchingError = preg_last_error();
336        if (0 !== $matchingError) {
337            $message = "Matching error {$matchingError}";
338            if (PREG_BACKTRACK_LIMIT_ERROR === $matchingError) {
339                $message = 'Backtrack limit was exhausted!';
340            }
341            throw new Exception($message);
342        }
343
344        return $result;
345    }
346
347    /**
348     * Pass in a string and look for \Classname instances.
349     *
350     * @param string $contents
351     * @param string $originalClassname
352     * @param string $classnamePrefix
353     * @return string
354     */
355    protected function replaceGlobalClassInsideNamedNamespace(
356        string $contents,
357        string $originalClassname,
358        string $classnamePrefix
359    ): string {
360        $replacement = $classnamePrefix . $originalClassname;
361
362        // use Prefixed_Class as Class;
363        $usePattern = '/
364            (\s*use\s+)
365            ('.$originalClassname.')   # Followed by the classname
366            \s*;
367            /x'; //                    # x: ignore whitespace in regex.
368
369        $contents = preg_replace_callback(
370            $usePattern,
371            function ($matches) use ($replacement) {
372                return $matches[1] . $replacement . ' as '. $matches[2] . ';';
373            },
374            $contents
375        );
376
377        $bodyPattern =
378            '/([^a-zA-Z0-9_\x7f-\xff]  # Not a class character
379            \\\)                       # Followed by a backslash to indicate global namespace
380            ('.$originalClassname.')   # Followed by the classname
381            ([^\\\;]{1})               # Not a backslash or semicolon which might indicate a namespace
382            /x'; //                    # x: ignore whitespace in regex.
383
384        return preg_replace_callback(
385            $bodyPattern,
386            function ($matches) use ($replacement) {
387                return $matches[1] . $replacement . $matches[3];
388            },
389            $contents
390        ) ?? $contents; // TODO: If this happens, it should raise an exception.
391    }
392
393    /**
394     * TODO: This should be split and brought to FileScanner.
395     *
396     * @param string $contents
397     * @param string[] $originalConstants
398     * @param string $prefix
399     */
400    protected function replaceConstants(string $contents, array $originalConstants, string $prefix): string
401    {
402
403        foreach ($originalConstants as $constant) {
404            $contents = $this->replaceConstant($contents, $constant, $prefix . $constant);
405        }
406
407        return $contents;
408    }
409
410    protected function replaceConstant(string $contents, string $originalConstant, string $replacementConstant): string
411    {
412        return str_replace($originalConstant, $replacementConstant, $contents);
413    }
414
415    protected function replaceFunctions(string $contents, FunctionSymbol $functionSymbol): string
416    {
417        $originalFunctionString = $functionSymbol->getOriginalSymbol();
418        $replacementFunctionString = $functionSymbol->getReplacement();
419
420        if ($originalFunctionString === $replacementFunctionString) {
421            return $contents;
422        }
423
424        $functionsUsingCallable = [
425            'function_exists',
426            'call_user_func',
427            'call_user_func_array',
428            'forward_static_call',
429            'forward_static_call_array',
430            'register_shutdown_function',
431            'register_tick_function',
432            'unregister_tick_function',
433        ];
434// TODO: Immediately surrounded by quotes is sometimes valid, e.g. passing a callable, but not always.
435// Log cases like this and present a list to users. Maybe CLI confirmation to replace?
436
437        $pattern = '/
438            (\s*use\s+function\s+)('.preg_quote($originalFunctionString, '/').')(\s+as|\s+;) # use function as
439            |
440            |('.implode('|', $functionsUsingCallable).')(\s*\(\s*[\'"])('.preg_quote($originalFunctionString, '/').')([\'"]) # function related calls without closing bracket
441            |
442            (\s*function\s+)('.preg_quote($originalFunctionString, '/').')(\s*\() # function declaration
443            |
444            ([;\s]+)('.preg_quote($originalFunctionString, '/').')(\s*\() # function call
445            /x'; // x: ignore whitespace in regex.
446
447        return preg_replace_callback(
448            $pattern,
449            function ($matches) use ($originalFunctionString, $replacementFunctionString) {
450                foreach ($matches as $index => $match) {
451                    if ($match == $originalFunctionString) {
452                        $matches[$index] = $replacementFunctionString;
453                    }
454                }
455                unset($matches[0]);
456                return implode('', $matches);
457            },
458            $contents
459        );
460    }
461
462    /**
463     * TODO: This should be a function on {@see DiscoveredFiles}.
464     *
465     * @return array<string, ComposerPackage>
466     */
467    public function getModifiedFiles(): array
468    {
469        return $this->changedFiles;
470    }
471}