Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
53.60% covered (warning)
53.60%
67 / 125
16.67% covered (danger)
16.67%
1 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Licenser
53.60% covered (warning)
53.60%
67 / 125
16.67% covered (danger)
16.67%
1 / 6
119.91
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 copyLicenses
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
20
 findLicenseFiles
68.42% covered (warning)
68.42%
13 / 19
0.00% covered (danger)
0.00%
0 / 1
8.54
 getDiscoveredLicenseFiles
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addInformationToUpdatedFiles
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 addChangeDeclarationToPhpString
97.92% covered (success)
97.92%
47 / 48
0.00% covered (danger)
0.00%
0 / 1
13
1<?php
2/**
3 * Copies license files from original folders.
4 * Edits Phpdoc to record the file was changed.
5 *
6 * MIT states: "The above copyright notice and this permission notice shall be
7 * included in all copies or substantial portions of the Software."
8 *
9 * GPL states: "You must cause the modified files to carry prominent notices stating
10 * that you changed the files and the date of any change."
11 *
12 * @see https://github.com/coenjacobs/mozart/issues/87
13 *
14 * @author BrianHenryIE
15 */
16
17namespace BrianHenryIE\Strauss\Pipeline;
18
19use BrianHenryIE\Strauss\Composer\ComposerPackage;
20use BrianHenryIE\Strauss\Composer\DependenciesCollection;
21use BrianHenryIE\Strauss\Composer\Extra\StraussConfig;
22use BrianHenryIE\Strauss\Config\LicenserConfigInterface;
23use BrianHenryIE\Strauss\Helpers\Flysystem\FileSystem;
24use League\Flysystem\FileAttributes;
25use League\Flysystem\FilesystemException;
26use League\Flysystem\StorageAttributes;
27use Psr\Log\LoggerAwareTrait;
28use Psr\Log\LoggerInterface;
29use Psr\Log\NullLogger;
30
31class Licenser
32{
33    use LoggerAwareTrait;
34
35    protected DependenciesCollection $dependencies;
36
37    // The author of the current project who is running Strauss to make the changes to the required libraries.
38    protected string $author;
39
40    protected bool $includeModifiedDate;
41
42    /**
43     * @see StraussConfig::isIncludeAuthor()
44     * @var bool
45     */
46    protected bool $includeAuthor = true;
47
48    /**
49     * An array of files relative to the project vendor folder.
50     *
51     * @var string[]
52     */
53    protected array $discoveredLicenseFiles = array();
54
55    protected FileSystem $filesystem;
56
57    protected LicenserConfigInterface $config;
58
59    /**
60     * Licenser constructor.
61     *
62     * @param DependenciesCollection $dependencies Whose folders are searched for existing license.txt files.
63     * @param string $author To add to each modified file's header
64     */
65    public function __construct(
66        LicenserConfigInterface $config,
67        DependenciesCollection  $dependencies,
68        string           $author,
69        FileSystem       $filesystem,
70        ?LoggerInterface $logger = null
71    ) {
72        $this->dependencies = $dependencies;
73        $this->author = $author;
74
75        $this->includeModifiedDate = $config->isIncludeModifiedDate();
76        $this->includeAuthor = $config->isIncludeAuthor();
77
78        $this->filesystem = $filesystem;
79
80        $this->config = $config;
81
82        $this->setLogger($logger ?? new NullLogger());
83    }
84
85    /**
86     * @throws FilesystemException
87     */
88    public function copyLicenses(): void
89    {
90        $this->findLicenseFiles();
91
92        foreach ($this->getDiscoveredLicenseFiles() as $licenseFile) {
93            $targetLicenseFile = str_replace(
94                $this->config->getAbsoluteVendorDirectory(),
95                $this->config->getAbsoluteTargetDirectory(),
96                $licenseFile
97            );
98
99            $targetLicenseFileDir = dirname($targetLicenseFile);
100
101            // Don't try copy it if it's already there.
102            if ($this->filesystem->fileExists($targetLicenseFile)) {
103                $this->logger->debug(sprintf(
104                    "Skipping %s because it already exists at %s",
105                    basename($licenseFile),
106                    $targetLicenseFile
107                ));
108                continue;
109            }
110
111            // Don't add licenses to non-existent directories – there were no files copied there!
112            if (! $this->filesystem->directoryExists($targetLicenseFileDir)) {
113                $this->logger->debug(sprintf(
114                    "Skipping %s because the directory %s does not exist",
115                    basename($licenseFile),
116                    $targetLicenseFileDir
117                ));
118                continue;
119            }
120
121            $this->logger->info(
122                sprintf(
123                    "Copying license file from %s to %s",
124                    basename($licenseFile),
125                    $targetLicenseFile
126                )
127            );
128            $this->filesystem->copy(
129                $licenseFile,
130                $targetLicenseFile
131            );
132        }
133    }
134
135    /**
136     * @see https://www.phpliveregex.com/p/A5y
137     */
138    public function findLicenseFiles(): void
139    {
140        // Include all license files in the dependency path.
141
142        /** @var ComposerPackage $dependency */
143        foreach ($this->dependencies as $dependency) {
144            $packagePath = $dependency->getPackageAbsolutePath();
145            // Meta packages.
146            if (is_null($packagePath)) {
147                continue;
148            }
149            $packagePath = $this->filesystem->normalizePath($packagePath);
150
151            if (!$packagePath) {
152                $this->logger->debug('Dependency {dependency} had no package path?', [
153                    'dependency' => $dependency->getPackageName()
154                ]);
155                continue;
156            }
157
158            $files = $this->filesystem->listContents($packagePath, true)
159                ->filter(fn (StorageAttributes $attributes) => $attributes->isFile());
160            /** @var FileAttributes $file */
161            foreach ($files as $file) {
162                $filePath = $this->filesystem->makeAbsolute($file->path());
163
164                // If packages happen to have their vendor dir, i.e. locally required packages, don't included the licenses
165                // from their vendor dir (they should be included otherwise anyway).
166                // I.e. in symlinked packages, the vendor dir might still exist.
167                if (0 === strpos($packagePath . '/vendor', $filePath)) {
168                    continue;
169                }
170
171                if (!preg_match('/^.*licen.e[^\\/]*$/i', $filePath)) {
172                    continue;
173                }
174
175                $this->discoveredLicenseFiles[$filePath] = $dependency->getPackageName();
176            }
177        }
178    }
179    /**
180     * @return string[]
181     */
182    public function getDiscoveredLicenseFiles(): array
183    {
184        return array_keys($this->discoveredLicenseFiles);
185    }
186
187    /**
188     * @param array<string, ComposerPackage> $modifiedFiles
189     *
190     * @throws \Exception
191     * @throws FilesystemException
192     */
193    public function addInformationToUpdatedFiles(array $modifiedFiles): void
194    {
195        // E.g. "25-April-2021".
196        $date = gmdate("d-F-Y", time());
197
198        foreach ($modifiedFiles as $relativeFilePath => $package) {
199            $filepath = $this->config->getAbsoluteTargetDirectory() . '/'.$relativeFilePath;
200
201            if (!$this->filesystem->fileExists($filepath)) {
202                continue;
203            }
204
205            $contents = $this->filesystem->read($filepath);
206
207            $updatedContents = $this->addChangeDeclarationToPhpString(
208                $contents,
209                $date,
210                $package->getPackageName(),
211                $package->getLicense()
212            );
213
214            if ($updatedContents !== $contents) {
215                $this->logger->info("Adding change declaration to {filepath}", [
216                    'filepath' => $filepath
217                ]);
218                $this->filesystem->write($filepath, $updatedContents);
219            }
220        }
221    }
222
223    /**
224     * Given a php file as a string, edit its header phpdoc, or add a header, to include:
225     *
226     * "Modified by {author} on {date} using Strauss.
227     * @see https://github.com/BrianHenryIE/strauss"
228     *
229     * Should probably include the original license in each file since it'll often be a mix, with the parent
230     * project often being a GPL WordPress plugin.
231     *
232     * Find the string between the end of php-opener and the first valid code.
233     * First valid code will be a line whose first non-whitespace character is not / or * ?... NO!
234     * If the first non whitespace string after php-opener is multiline-comment-opener, find the
235     * closing multiline-comment-closer
236     * / If there's already a comment, work within that comment
237     * If there is no mention in the header of the license already, add it.
238     * Add a note that changes have been made.
239     *
240     * @param string $phpString Code.
241     */
242    public function addChangeDeclarationToPhpString(
243        string $phpString,
244        string $modifiedDate,
245        string $packageName,
246        string $packageLicense
247    ) : string {
248
249        $author = $this->author;
250
251        $licenseDeclaration = "@license {$packageLicense}";
252        $modifiedDeclaration = 'Modified';
253        if ($this->includeAuthor) {
254            $modifiedDeclaration .= " by {$author}";
255        }
256        if ($this->includeModifiedDate) {
257            $modifiedDeclaration .= " on {$modifiedDate}";
258        }
259        $straussLink = 'https://github.com/BrianHenryIE/strauss';
260        $modifiedDeclaration .= " using {@see {$straussLink}}.";
261
262        $startOfFileArray = [];
263        $tokenizeString =  token_get_all($phpString);
264
265        foreach ($tokenizeString as $token) {
266            if (is_array($token) && !in_array($token[1], ['namespace', '/*', ' /*'])) {
267                $startOfFileArray[] = $token[1];
268                $token = array_shift($tokenizeString);
269
270                if (is_array($token) && stristr($token[1], 'strauss')) {
271                    // Already done?
272                    return $phpString;
273                }
274            } elseif (!is_array($token)) {
275                $startOfFileArray[] = $token;
276            }
277        }
278        // Not in use yet (because all tests are passing) but the idea of capturing the file header and only editing
279        // that seems more reasonable than searching the whole file.
280        $startOfFile = implode('', $startOfFileArray);
281
282        // php-open followed by some whitespace and new line until the first ...
283        $noCommentBetweenPhpOpenAndFirstCodePattern = '~<\?php[\s\n]*[\w\\\?]+~';
284
285        $multilineCommentCapturePattern = '
286            ~                        # Start the pattern
287            (
288            <\?php[\S\s]*            #  match the beginning of the files php-open and following whitespace
289            )
290            (
291            \*[\S\s.]*               # followed by a multiline-comment-open
292            )
293            (
294            \*/                      # Capture the multiline-comment-close separately
295            )
296            ~Ux';                          // U: Non-greedy matching, x: ignore whitespace in pattern.
297
298        $replaceInMultilineCommentFunction = function ($matches) use (
299            $licenseDeclaration,
300            $modifiedDeclaration
301        ) {
302            // Find the line prefix and use it, i.e. could be none, asterisk or space-asterisk.
303            $commentLines = explode("\n", $matches[2]);
304
305            if (isset($commentLines[1])&& 1 === preg_match('/^([\s\\\*]*)/', $commentLines[1], $output_array)) {
306                $lineStart = $output_array[1];
307            } else {
308                $lineStart = ' * ';
309            }
310
311            $appendString = "*\n";
312
313            // If the license is not already specified in the header, add it.
314            if (false === strpos($matches[2], 'licen')) {
315                $appendString .= "{$lineStart}{$licenseDeclaration}\n";
316            }
317
318            $appendString .= "{$lineStart}{$modifiedDeclaration}\n";
319
320            $commentEnd =  rtrim(rtrim($lineStart, ' '), '*').'*/';
321
322            return $matches[1] . $matches[2] . $appendString . $commentEnd;
323        };
324
325        // If it's a simple case where there is no existing header, add the existing license.
326        if (1 === preg_match($noCommentBetweenPhpOpenAndFirstCodePattern, $phpString)) {
327            $modifiedComment = "/**\n * {$licenseDeclaration}\n *\n * {$modifiedDeclaration}\n */";
328            $updatedPhpString = preg_replace('~<\?php~', "<?php\n". $modifiedComment, $phpString, 1);
329        } else {
330            $updatedPhpString = preg_replace_callback(
331                $multilineCommentCapturePattern,
332                $replaceInMultilineCommentFunction,
333                $phpString,
334                1
335            );
336        }
337
338        /**
339         * In some cases `preg_replace_callback` returns `null` instead of the string. If that happens, return
340         * the original, unaltered string.
341         *
342         * @see https://github.com/BrianHenryIE/strauss/issues/115
343         */
344        return $updatedPhpString ?? $phpString;
345    }
346}