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            @?                             # 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}