Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
54.92% covered (warning)
54.92%
67 / 122
16.67% covered (danger)
16.67%
1 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Licenser
54.92% covered (warning)
54.92%
67 / 122
16.67% covered (danger)
16.67%
1 / 6
106.06
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
70.59% covered (warning)
70.59%
12 / 17
0.00% covered (danger)
0.00%
0 / 1
6.92
 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 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 addChangeDeclarationToPhpString
97.96% covered (success)
97.96%
48 / 49
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\Extra\StraussConfig;
21use BrianHenryIE\Strauss\Config\LicenserConfigInterface;
22use BrianHenryIE\Strauss\Helpers\FileSystem;
23use League\Flysystem\FileAttributes;
24use League\Flysystem\FilesystemException;
25use League\Flysystem\StorageAttributes;
26use Psr\Log\LoggerAwareTrait;
27use Psr\Log\LoggerInterface;
28use Psr\Log\NullLogger;
29
30class Licenser
31{
32    use LoggerAwareTrait;
33
34    /** @var ComposerPackage[]  */
35    protected array $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 ComposerPackage[] $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        array            $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            $packagePath = $this->filesystem->normalizePath($packagePath);
146
147            if (!$packagePath) {
148                $this->logger->debug('Dependency {dependency} had no package path?', [
149                    'dependency' => $dependency->getPackageName()
150                ]);
151                continue;
152            }
153
154            $files = $this->filesystem->listContents($packagePath, true)
155                ->filter(fn (StorageAttributes $attributes) => $attributes->isFile());
156            /** @var FileAttributes $file */
157            foreach ($files as $file) {
158                $filePath = $this->filesystem->makeAbsolute($file->path());
159
160                // If packages happen to have their vendor dir, i.e. locally required packages, don't included the licenses
161                // from their vendor dir (they should be included otherwise anyway).
162                // I.e. in symlinked packages, the vendor dir might still exist.
163                if (0 === strpos($packagePath . '/vendor', $filePath)) {
164                    continue;
165                }
166
167                if (!preg_match('/^.*licen.e[^\\/]*$/i', $filePath)) {
168                    continue;
169                }
170
171                $this->discoveredLicenseFiles[$filePath] = $dependency->getPackageName();
172            }
173        }
174    }
175    /**
176     * @return string[]
177     */
178    public function getDiscoveredLicenseFiles(): array
179    {
180        return array_keys($this->discoveredLicenseFiles);
181    }
182
183    /**
184     * @param array<string, ComposerPackage> $modifiedFiles
185     *
186     * @throws \Exception
187     * @throws FilesystemException
188     */
189    public function addInformationToUpdatedFiles(array $modifiedFiles): void
190    {
191        // E.g. "25-April-2021".
192        $date = gmdate("d-F-Y", time());
193
194        foreach ($modifiedFiles as $relativeFilePath => $package) {
195            $filepath = $this->config->getAbsoluteTargetDirectory() . '/'.$relativeFilePath;
196
197            if (!$this->filesystem->fileExists($filepath)) {
198                continue;
199            }
200
201            $contents = $this->filesystem->read($filepath);
202
203            $updatedContents = $this->addChangeDeclarationToPhpString(
204                $contents,
205                $date,
206                $package->getPackageName(),
207                $package->getLicense()
208            );
209
210            if ($updatedContents !== $contents) {
211                $this->logger->info("Adding change declaration to {$filepath}");
212                $this->filesystem->write($filepath, $updatedContents);
213            }
214        }
215    }
216
217    /**
218     * Given a php file as a string, edit its header phpdoc, or add a header, to include:
219     *
220     * "Modified by {author} on {date} using Strauss.
221     * @see https://github.com/BrianHenryIE/strauss"
222     *
223     * Should probably include the original license in each file since it'll often be a mix, with the parent
224     * project often being a GPL WordPress plugin.
225     *
226     * Find the string between the end of php-opener and the first valid code.
227     * First valid code will be a line whose first non-whitespace character is not / or * ?... NO!
228     * If the first non whitespace string after php-opener is multiline-comment-opener, find the
229     * closing multiline-comment-closer
230     * / If there's already a comment, work within that comment
231     * If there is no mention in the header of the license already, add it.
232     * Add a note that changes have been made.
233     *
234     * @param string $phpString Code.
235     */
236    public function addChangeDeclarationToPhpString(
237        string $phpString,
238        string $modifiedDate,
239        string $packageName,
240        string $packageLicense
241    ) : string {
242
243        $author = $this->author;
244
245        $licenseDeclaration = "@license {$packageLicense}";
246        $modifiedDeclaration = 'Modified';
247        if ($this->includeAuthor) {
248            $modifiedDeclaration .= " by {$author}";
249        }
250        if ($this->includeModifiedDate) {
251            $modifiedDeclaration .= " on {$modifiedDate}";
252        }
253        $straussLink = 'https://github.com/BrianHenryIE/strauss';
254        $modifiedDeclaration .= " using {@see {$straussLink}}.";
255
256        $startOfFileArray = [];
257        $tokenizeString =  token_get_all($phpString);
258
259        foreach ($tokenizeString as $token) {
260            if (is_array($token) && !in_array($token[1], ['namespace', '/*', ' /*'])) {
261                $startOfFileArray[] = $token[1];
262                $token = array_shift($tokenizeString);
263
264                if (is_array($token) && stristr($token[1], 'strauss')) {
265                    // Already done?
266                    return $phpString;
267                }
268            } elseif (!is_array($token)) {
269                $startOfFileArray[] = $token;
270            }
271        }
272        // Not in use yet (because all tests are passing) but the idea of capturing the file header and only editing
273        // that seems more reasonable than searching the whole file.
274        $startOfFile = implode('', $startOfFileArray);
275
276        // php-open followed by some whitespace and new line until the first ...
277        $noCommentBetweenPhpOpenAndFirstCodePattern = '~<\?php[\s\n]*[\w\\\?]+~';
278
279        $multilineCommentCapturePattern = '
280            ~                        # Start the pattern
281            (
282            <\?php[\S\s]*            #  match the beginning of the files php-open and following whitespace
283            )
284            (
285            \*[\S\s.]*               # followed by a multiline-comment-open
286            )
287            (
288            \*/                      # Capture the multiline-comment-close separately
289            )
290            ~Ux';                          // U: Non-greedy matching, x: ignore whitespace in pattern.
291
292        $replaceInMultilineCommentFunction = function ($matches) use (
293            $licenseDeclaration,
294            $modifiedDeclaration
295        ) {
296            // Find the line prefix and use it, i.e. could be none, asterisk or space-asterisk.
297            $commentLines = explode("\n", $matches[2]);
298
299            if (isset($commentLines[1])&& 1 === preg_match('/^([\s\\\*]*)/', $commentLines[1], $output_array)) {
300                $lineStart = $output_array[1];
301            } else {
302                $lineStart = ' * ';
303            }
304
305            $appendString = "*\n";
306
307            // If the license is not already specified in the header, add it.
308            if (false === strpos($matches[2], 'licen')) {
309                $appendString .= "{$lineStart}{$licenseDeclaration}\n";
310            }
311
312            $appendString .= "{$lineStart}{$modifiedDeclaration}\n";
313
314            $commentEnd =  rtrim(rtrim($lineStart, ' '), '*').'*/';
315
316            $replaceWith = $matches[1] . $matches[2] . $appendString . $commentEnd;
317
318            return $replaceWith;
319        };
320
321        // If it's a simple case where there is no existing header, add the existing license.
322        if (1 === preg_match($noCommentBetweenPhpOpenAndFirstCodePattern, $phpString)) {
323            $modifiedComment = "/**\n * {$licenseDeclaration}\n *\n * {$modifiedDeclaration}\n */";
324            $updatedPhpString = preg_replace('~<\?php~', "<?php\n". $modifiedComment, $phpString, 1);
325        } else {
326            $updatedPhpString = preg_replace_callback(
327                $multilineCommentCapturePattern,
328                $replaceInMultilineCommentFunction,
329                $phpString,
330                1
331            );
332        }
333
334        /**
335         * In some cases `preg_replace_callback` returns `null` instead of the string. If that happens, return
336         * the original, unaltered string.
337         *
338         * @see https://github.com/BrianHenryIE/strauss/issues/115
339         */
340        return $updatedPhpString ?? $phpString;
341    }
342}