Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
74.07% covered (warning)
74.07%
100 / 135
40.00% covered (danger)
40.00%
4 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Prefixer
74.07% covered (warning)
74.07%
100 / 135
40.00% covered (danger)
40.00%
4 / 10
56.35
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 replaceInFiles
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
56
 replaceInProjectFiles
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 replaceInString
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
6.09
 replaceNamespace
86.21% covered (warning)
86.21%
25 / 29
0.00% covered (danger)
0.00%
0 / 1
4.04
 replaceClassname
83.87% covered (warning)
83.87%
26 / 31
0.00% covered (danger)
0.00%
0 / 1
8.27
 replaceGlobalClassInsideNamedNamespace
100.00% covered (success)
100.00%
23 / 23
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
 getModifiedFiles
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace BrianHenryIE\Strauss;
4
5use BrianHenryIE\Strauss\Composer\ComposerPackage;
6use BrianHenryIE\Strauss\Composer\Extra\StraussConfig;
7use BrianHenryIE\Strauss\Types\NamespaceSymbol;
8use Exception;
9use League\Flysystem\Filesystem;
10use League\Flysystem\Local\LocalFilesystemAdapter;
11use Symfony\Component\Filesystem\Exception\FileNotFoundException;
12
13class 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            (?<searchNamespace>
209            {$searchNamespace}             # followed by the namespace to replace
210            )
211            (?!:)                          # Not followed by : which would only be valid after a classname
212            (
213            \s*;                           # followed by a semicolon 
214            |\\\\{1,2}[a-zA-Z0-9_\x7f-\xff]{1,}         # or a classname no slashes 
215            |\s+as                         # or the keyword as 
216            |\"                            # or quotes
217            |'                             # or single quote         
218            |:                             # or a colon to access a static
219            |\\\\{
220            )                            
221            /Ux";                          // U: Non-greedy matching, x: ignore whitespace in pattern.
222
223        $replacingFunction = function ($matches) use ($originalNamespace, $replacement) {
224            $singleBackslash = '\\';
225            $doubleBackslash = '\\\\';
226
227            if (false !== strpos($matches['0'], $doubleBackslash)) {
228                $originalNamespace = str_replace($singleBackslash, $doubleBackslash, $originalNamespace);
229                $replacement = str_replace($singleBackslash, $doubleBackslash, $replacement);
230            }
231
232            $replaced = str_replace($originalNamespace, $replacement, $matches[0]);
233
234            return $replaced;
235        };
236
237        $result = preg_replace_callback($pattern, $replacingFunction, $contents);
238
239        $matchingError = preg_last_error();
240        if (0 !== $matchingError) {
241            $message = "Matching error {$matchingError}";
242            if (PREG_BACKTRACK_LIMIT_ERROR === $matchingError) {
243                $message = 'Preg Backtrack limit was exhausted!';
244            }
245            throw new Exception($message);
246        }
247
248        // For prefixed functions which do not begin with a backslash, add one.
249        // I'm not certain this is a good idea.
250        // @see https://github.com/BrianHenryIE/strauss/issues/65
251        $functionReplacingPatten = '/\\\\?('.preg_quote(ltrim($replacement, '\\'), '/').'\\\\(?:[a-zA-Z0-9_\x7f-\xff]+\\\\)*[a-zA-Z0-9_\x7f-\xff]+\\()/';
252        $result = preg_replace(
253            $functionReplacingPatten,
254            "\\\\$1",
255            $result
256        );
257
258        return $result;
259    }
260
261    /**
262     * In a namespace:
263     * * use \Classname;
264     * * new \Classname()
265     *
266     * In a global namespace:
267     * * new Classname()
268     *
269     * @param string $contents
270     * @param string $originalClassname
271     * @param string $classnamePrefix
272     * @throws \Exception
273     */
274    public function replaceClassname(string $contents, string $originalClassname, string $classnamePrefix): string
275    {
276        $searchClassname = preg_quote($originalClassname, '/');
277
278        // This could be more specific if we could enumerate all preceding and proceeding words ("new", "("...).
279        $pattern = '
280            /                                            # Start the pattern
281                (^\s*namespace|\r\n\s*namespace)\s+[a-zA-Z0-9_\x7f-\xff\\\\]+\s*{(.*?)(namespace|\z) 
282                                                        # Look for a preceding namespace declaration, up until a 
283                                                        # potential second namespace declaration.
284                |                                        # if found, match that much before continuing the search on
285                                                        # the remainder of the string.
286                (^\s*namespace|\r\n\s*namespace)\s+[a-zA-Z0-9_\x7f-\xff\\\\]+\s*;(.*) # Skip lines just declaring the namespace.
287                |                    
288                ([^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
289                
290            /xsm'; //                                    # x: ignore whitespace in regex.  s dot matches newline, m: ^ and $ match start and end of line
291
292        $replacingFunction = function ($matches) use ($originalClassname, $classnamePrefix) {
293
294            // If we're inside a namespace other than the global namespace:
295            if (1 === preg_match('/\s*namespace\s+[a-zA-Z0-9_\x7f-\xff\\\\]+[;{\s\n]{1}.*/', $matches[0])) {
296                $updated = $this->replaceGlobalClassInsideNamedNamespace(
297                    $matches[0],
298                    $originalClassname,
299                    $classnamePrefix
300                );
301
302                return $updated;
303            } else {
304                $newContents = '';
305                foreach ($matches as $index => $captured) {
306                    if (0 === $index) {
307                        continue;
308                    }
309
310                    if ($captured == $originalClassname) {
311                        $newContents .= $classnamePrefix;
312                    }
313
314                    $newContents .= $captured;
315                }
316                return $newContents;
317            }
318//            return $matches[1] . $matches[2] . $matches[3] . $classnamePrefix . $originalClassname . $matches[5];
319        };
320
321        $result = preg_replace_callback($pattern, $replacingFunction, $contents);
322
323        if (is_null($result)) {
324            throw new Exception('preg_replace_callback returned null');
325        }
326
327        $matchingError = preg_last_error();
328        if (0 !== $matchingError) {
329            $message = "Matching error {$matchingError}";
330            if (PREG_BACKTRACK_LIMIT_ERROR === $matchingError) {
331                $message = 'Backtrack limit was exhausted!';
332            }
333            throw new Exception($message);
334        }
335
336        return $result;
337    }
338
339    /**
340     * Pass in a string and look for \Classname instances.
341     *
342     * @param string $contents
343     * @param string $originalClassname
344     * @param string $classnamePrefix
345     * @return string
346     */
347    protected function replaceGlobalClassInsideNamedNamespace($contents, $originalClassname, $classnamePrefix): string
348    {
349        $replacement = $classnamePrefix . $originalClassname;
350
351        // use Prefixed_Class as Class;
352        $usePattern = '/
353            (\s*use\s+)
354            ('.$originalClassname.')   # Followed by the classname
355            \s*;
356            /x'; //                    # x: ignore whitespace in regex.
357
358        $contents = preg_replace_callback(
359            $usePattern,
360            function ($matches) use ($replacement) {
361                return $matches[1] . $replacement . ' as '. $matches[2] . ';';
362            },
363            $contents
364        );
365
366        $bodyPattern =
367            '/([^a-zA-Z0-9_\x7f-\xff]  # Not a class character
368            \\\)                       # Followed by a backslash to indicate global namespace
369            ('.$originalClassname.')   # Followed by the classname
370            ([^\\\;]{1})               # Not a backslash or semicolon which might indicate a namespace
371            /x'; //                    # x: ignore whitespace in regex.
372
373        $contents = preg_replace_callback(
374            $bodyPattern,
375            function ($matches) use ($replacement) {
376                return $matches[1] . $replacement . $matches[3];
377            },
378            $contents
379        );
380
381        return $contents;
382    }
383
384    /**
385     * TODO: This should be split and brought to FileScanner.
386     *
387     * @param string $contents
388     * @param string[] $originalConstants
389     * @param string $prefix
390     */
391    protected function replaceConstants(string $contents, array $originalConstants, string $prefix): string
392    {
393
394        foreach ($originalConstants as $constant) {
395            $contents = $this->replaceConstant($contents, $constant, $prefix . $constant);
396        }
397
398        return $contents;
399    }
400
401    protected function replaceConstant(string $contents, string $originalConstant, string $replacementConstant): string
402    {
403        return str_replace($originalConstant, $replacementConstant, $contents);
404    }
405
406    /**
407     * @return array<string, ComposerPackage>
408     */
409    public function getModifiedFiles(): array
410    {
411        return $this->changedFiles;
412    }
413}