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