Passed
Push — master ( 3a433f...3478fd )
by Malte
03:18
created

Task::getRelevantPackagePaths()   B

Complexity

Conditions 5
Paths 8

Size

Total Lines 27
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 27
rs 8.439
cc 5
eloc 16
nc 8
nop 2
1
<?php
2
3
namespace AppBundle\ShowUnusedComposerPackages;
4
5
use Composer\Composer;
6
use Composer\Factory;
7
use Composer\IO\BufferIO;
8
use Composer\Package\PackageInterface;
9
use Helper\FileSystem;
10
use Helper\NullStyle;
11
use Symfony\Component\Console\Style\OutputStyle;
12
13
/**
14
 * Get unused composer packages.
15
 *
16
 * The idea is that packages are required either directly from the root package or indirectly. We call these packages
17
 * n-th degree requirements, where n is the number of links between the package in question and the root package.
18
 * E.g. a 2nd degree requirement is a package that is required by a package that in turn is directly required by the
19
 * root package.
20
 *
21
 * Deleting a requirement of 2nd or higher degree alone makes no sense, as it will still be required by a first degree
22
 * requirement and therefore be installed. Hence we concentrate on the first level requirements only.
23
 *
24
 * If the only logged use of a package is a *Bundle.php, it's probably only registered in the AppKernel and not really
25
 * used, i.e. can be deleted.
26
 */
27
final class Task
28
{
29
    /** @var string */
30
    private $pathToVendor;
31
32
    /** @var OutputStyle */
33
    private $ioStyle;
34
35
    /**
36
     * @param string $pathToComposerJson
37
     * @param string|null $pathToVendor
38
     * @param string $pathToUsedFiles
39
     * @param string|null $pathToBlacklist
40
     * @param OutputStyle|null $ioStyle
41
     */
42
    public function getUnusedPackagePaths($pathToComposerJson, $pathToVendor, $pathToUsedFiles, $pathToBlacklist = null, OutputStyle $ioStyle = null)
43
    {
44
        $this->ioStyle = $ioStyle ?: new NullStyle();
0 ignored issues
show
Documentation Bug introduced by
It seems like $ioStyle ?: new Helper\NullStyle() can also be of type Helper\NullStyle. However, the property $ioStyle is declared as type Symfony\Component\Console\Style\OutputStyle. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
45
46
        $usedFiles = FileSystem::readFileIntoArray($pathToUsedFiles);
47
        $this->ioStyle->text('Found ' . (count($usedFiles)) . ' used files.');
48
49
        $usedFiles = $this->getRelevantUsedFiles($usedFiles);
50
51
        $pathToVendor = $pathToVendor ?: $this->getDefaultPathToVendor($pathToComposerJson);
52
        $this->pathToVendor = $this->assertPathToVendorIsValid($pathToVendor);
53
54
        $unusedPackagePaths = [];
55
        foreach ($this->getRelevantPackagePaths($pathToComposerJson, $pathToBlacklist) as $packagePath) {
56
            if (!$this->atLeastOneFileIsInPath($usedFiles, $packagePath)) {
57
                $unusedPackagePaths[] = $packagePath;
58
            }
59
        }
60
61
        $ioStyle->newLine();
0 ignored issues
show
Bug introduced by
The method newLine() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

61
        $ioStyle->/** @scrutinizer ignore-call */ newLine();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
62
        $ioStyle->text('Calculated ' . count($unusedPackagePaths) . ' potentially unused packages:');
63
        $ioStyle->listing($unusedPackagePaths);
64
        $ioStyle->success('Finished listing potentially unused packages.');
65
    }
66
67
    /**
68
     * @param string[] $usedFiles
69
     * @return string[]
70
     */
71
    private function getRelevantUsedFiles(array $usedFiles)
72
    {
73
        $filteredFiles = array_filter($usedFiles, function ($usedFile) { return strpos($usedFile, 'Bundle.php') === false; });
74
        $difference = count($usedFiles) - count($filteredFiles);
75
        if ($difference > 0) {
76
            $this->ioStyle->text('Removed ' . $difference . ' *Bundle.php files from used files as they are likely irrelevant.');
77
        }
78
79
        return $filteredFiles;
80
    }
81
82
    /**
83
     * @param string $pathToComposerJson
84
     * @return string
85
     */
86
    private function getDefaultPathToVendor($pathToComposerJson)
87
    {
88
        $projectRoot = realpath(dirname($pathToComposerJson));
89
        $vendorDir = $projectRoot . '/vendor/';
90
        $this->ioStyle->text('Assume vendor directory to be ' . $vendorDir . ' (you can set it with the --' . Command::OPTION_VENDOR_DIRECTORY . ' option).');
91
92
        return $vendorDir;
93
    }
94
95
    /**
96
     * @param string $path
97
     * @return string path to a readable directory with a trailing slash
98
     */
99
    private function assertPathToVendorIsValid($path)
100
    {
101
        if (is_dir($path) === false) {
102
            $message = 'The path "' . $path . '" is no valid directory.';
103
        } elseif (is_readable($path) === false) {
104
            $message = 'The directory "' . $path . '" is not readable.';
105
        }
106
107
        if (isset($message)) {
108
            $message .= ' Please specify a readable directory with the ' . Command::OPTION_VENDOR_DIRECTORY . ' option.';
109
            $this->ioStyle->error($message);
110
            throw new \InvalidArgumentException($message);
111
        }
112
113
        return rtrim($path, '/') . '/';
114
    }
115
116
    /**
117
     * @param string $pathToComposerJson
118
     * @param string $pathToBlacklist
119
     * @return string[]
120
     */
121
    private function getRelevantPackagePaths($pathToComposerJson, $pathToBlacklist)
122
    {
123
        $packagePaths = [];
124
        $composer = Factory::create(new BufferIO(), $pathToComposerJson);
125
126
        $blacklistingRegExps = FileSystem::getBlacklistingRegExps($pathToBlacklist);
127
        foreach ($composer->getPackage()->getRequires() as $link) {
128
            $package = $composer->getLocker()->getLockedRepository()->findPackage($link->getTarget(), $link->getConstraint());
129
            if ($package === null) {
130
                continue;
131
            }
132
133
            $packagePath = realpath($this->getInstallPath($composer, $package));
134
            if ($this->packagePathIsBlacklisted($packagePath, $blacklistingRegExps)) {
135
                continue;
136
            }
137
138
            $packagePaths[] = $packagePath;
139
        }
140
141
        $message = 'Found ' . count($packagePaths) . ' composer packages';
142
        if (count($blacklistingRegExps) > 0) {
143
            $message .= ' not matching the ' . count($blacklistingRegExps) . ' blacklisting regular expressions';
144
        }
145
        $this->ioStyle->text($message . '.');
146
147
        return $packagePaths;
148
    }
149
150
    /**
151
     * @param string[] $files
152
     * @param string $path
153
     * @return bool
154
     */
155
    private function atLeastOneFileIsInPath(array $files, $path)
156
    {
157
        foreach ($files as $file) {
158
            if (strpos($file, $path) !== false) {
159
                return true;
160
            }
161
        }
162
163
        return false;
164
    }
165
166
    /**
167
     * @param Composer $composer
168
     * @param PackageInterface $package
169
     * @return string
170
     */
171
    private function getInstallPath(Composer $composer, PackageInterface $package)
172
    {
173
        $pathToVendorInZauberlehrling = $composer->getConfig()->get('vendor-dir');
174
175
        $pathToPackageInstallationInZauberlehrling = $composer->getInstallationManager()->getInstallPath($package);
176
        $pathToPackageInstallationInProject = str_replace($pathToVendorInZauberlehrling, $this->pathToVendor, $pathToPackageInstallationInZauberlehrling);
177
        return realpath($pathToPackageInstallationInProject);
178
    }
179
180
    /**
181
     * @param string $path
182
     * @param string[] $blacklistRegExps
183
     * @return bool
184
     */
185
    private function packagePathIsBlacklisted($path, array $blacklistRegExps)
186
    {
187
        foreach ($blacklistRegExps as $blacklistRegExp) {
188
            if (preg_match($blacklistRegExp, $path) === 1 || preg_match($blacklistRegExp, $path . '/') === 1) {
189
                return true;
190
            }
191
        }
192
193
        return false;
194
    }
195
}
196