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