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