PhpcsDiff::__construct()   A
last analyzed

Complexity

Conditions 4
Paths 6

Size

Total Lines 20
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 12
c 1
b 0
f 0
dl 0
loc 20
rs 9.8666
cc 4
nc 6
nop 2
1
<?php
2
3
namespace PhpcsDiff;
4
5
use League\CLImate\CLImate;
6
use PhpcsDiff\Filter\Exception\FilterException;
7
use PhpcsDiff\Filter\Filter;
8
use PhpcsDiff\Filter\Rule\HasMessagesRule;
9
use PhpcsDiff\Filter\Rule\PhpFileRule;
10
use PhpcsDiff\Mapper\PhpcsViolationsMapper;
11
12
class PhpcsDiff
13
{
14
    /**
15
     * @var array
16
     */
17
    protected $argv = [];
18
19
    /**
20
     * @var CLImate
21
     */
22
    protected $climate;
23
24
    /**
25
     * @var bool
26
     */
27
    protected $isVerbose = false;
28
29
    /**
30
     * @var int
31
     */
32
    protected $exitCode = 0;
33
34
    /**
35
     * @var string
36
     */
37
    protected $baseBranch;
38
39
    /**
40
     * @var string
41
     */
42
    protected $currentBranch = '';
43
44
    /**
45
     * @param array $argv
46
     * @param CLImate $climate
47
     */
48
    public function __construct(array $argv, CLImate $climate)
49
    {
50
        $this->argv = $argv;
51
        $this->climate = $climate;
52
53
        if ($this->isFlagSet('-v')) {
54
            $this->climate->comment('Running in verbose mode.');
55
            $this->isVerbose = true;
56
        }
57
58
        if (!isset($this->argv[1])) {
59
            $this->error('Please provide a <bold>base branch</bold> as the first argument.');
60
            return;
61
        }
62
63
        $this->baseBranch = 'origin/' . str_replace('origin/', '', $this->argv[1]);
64
        $this->currentBranch = trim(shell_exec('git rev-parse --verify HEAD'));
65
66
        if (empty($this->currentBranch)) {
67
            $this->error('Unable to get <bold>current</bold> branch.');
68
        }
69
    }
70
71
    /**
72
     * @param string $flag
73
     * @return bool
74
     */
75
    protected function isFlagSet(string $flag)
76
    {
77
        $isFlagSet = false;
78
        $argv = $this->argv;
79
80
        $key = array_search($flag, $argv, true);
81
        if (false !== $key) {
82
            unset($argv[$key]);
83
            $argv = array_values($argv);
84
85
            $isFlagSet = true;
86
        }
87
88
        $this->argv = $argv;
89
        return $isFlagSet;
90
    }
91
92
    /**
93
     * @param int $exitCode
94
     */
95
    protected function setExitCode(int $exitCode)
96
    {
97
        $this->exitCode = $exitCode;
98
    }
99
100
    /**
101
     * @return int
102
     */
103
    public function getExitCode(): int
104
    {
105
        return $this->exitCode;
106
    }
107
108
    /**
109
     * @todo Automatically look at server envs for the travis base branch, if not provided?
110
     * @todo Define custom ruleset from command line argv for runPhpcs()
111
     */
112
    public function run(): void
113
    {
114
        try {
115
            $filter = new Filter([new PhpFileRule()], $this->getChangedFiles());
116
        } catch (FilterException $exception) {
117
            $this->error($exception->getMessage());
118
            return;
119
        }
120
121
        $fileDiff = $filter->filter()->getFilteredData();
122
123
        if (empty($fileDiff)) {
124
            $this->climate->info('No difference to compare.');
125
            return;
126
        }
127
128
        if ($this->isVerbose) {
129
            $fileDiffCount = count($fileDiff);
130
            $this->climate->comment(
131
                'Checking ' . $fileDiffCount . ' ' .
132
                ngettext('file', 'files', $fileDiffCount) . ' for violations.'
133
            );
134
        }
135
136
        $phpcsOutput = $this->runPhpcs($fileDiff);
137
138
        if (is_null($phpcsOutput)) {
139
            $this->error('Unable to run phpcs executable.');
140
            return;
141
        }
142
143
        if ($this->isVerbose) {
144
            $this->climate->comment('Filtering phpcs output.');
145
        }
146
147
        try {
148
            $filter = new Filter([new HasMessagesRule()], $phpcsOutput['files']);
149
        } catch (FilterException $exception) {
150
            $this->error($exception->getMessage());
151
            return;
152
        }
153
154
        $files = $filter->filter()->getFilteredData();
155
156
        if ($this->isVerbose) {
157
            $this->climate->comment('Getting changed lines from git diff.');
158
        }
159
160
        $changedLinesPerFile = $this->getChangedLinesPerFile($files);
161
162
        if ($this->isVerbose) {
163
            $this->climate->comment('Comparing phpcs output with changes lines from git diff.');
164
        }
165
166
        $violations = (new PhpcsViolationsMapper(
167
            $changedLinesPerFile,
168
            getcwd()
169
        ))->map($files);
170
171
        if ($this->isVerbose) {
172
            $this->climate->comment('Preparing report.');
173
        }
174
175
        if (empty($violations)) {
176
            $this->climate->info('No violations to report.');
177
            return;
178
        }
179
180
        $this->outputViolations($violations);
181
    }
182
183
    /**
184
     * Run phpcs on a list of files passed into the method
185
     *
186
     * @param array $files
187
     * @param string $ruleset
188
     * @return mixed
189
     */
190
    protected function runPhpcs(array $files = [], string $ruleset = 'ruleset.xml')
191
    {
192
        $exec = null;
193
        $root = dirname(__DIR__);
194
195
        $locations = [
196
            'vendor/bin/phpcs',
197
            $root . '/../../bin/phpcs',
198
            $root . '/../bin/phpcs',
199
            $root . '/bin/phpcs',
200
            $root . '/vendor/bin/phpcs',
201
            '~/.config/composer/vendor/bin/phpcs',
202
            '~/.composer/vendor/bin/phpcs',
203
        ];
204
205
        foreach ($locations as $location) {
206
            if (is_file($location)) {
207
                $exec = $location;
208
                break;
209
            }
210
        }
211
212
        if (!$exec) {
213
            return null;
214
        }
215
216
        if ($this->isVerbose) {
217
            $this->climate->info('Using phpcs executable: ' . $exec);
218
        }
219
220
        $exec = PHP_BINARY . ' ' . $exec;
221
        $command = $exec . ' --report=json --standard=' . $ruleset . ' ' . implode(' ', $files);
222
        $output = shell_exec($command);
223
224
        if ($this->isVerbose) {
225
            $this->climate->info('Running: ' . $command);
226
        }
227
228
        $json = $output ? json_decode($output, true) : null;
229
        if ($json === null && $output) {
230
            $this->climate->error($output);
231
        }
232
233
        return $json;
234
    }
235
236
    /**
237
     * @param array $output
238
     */
239
    protected function outputViolations(array $output): void
240
    {
241
        $this->climate->flank(strtoupper('Start of phpcs check'), '#', 10)->br();
242
        $this->climate->out(implode(PHP_EOL, $output));
243
        $this->climate->flank(strtoupper('End of phpcs check'), '#', 11)->br();
244
245
        $this->error('Violations have been reported.');
246
    }
247
248
    /**
249
     * Returns a list of files which are within the diff based on the current branch
250
     *
251
     * @return array
252
     */
253
    protected function getChangedFiles(): array
254
    {
255
        // Get a list of changed files (not including deleted files)
256
        $output = shell_exec(
257
            'git diff ' . $this->baseBranch . ' ' . $this->currentBranch . ' --name-only --diff-filter=ACM'
258
        );
259
260
        // Convert files into an array
261
        $output = explode(PHP_EOL, $output);
262
263
        // Remove any empty values
264
        return array_filter($output);
265
    }
266
267
    /**
268
     * Extract the changed lines for each file from the git diff output
269
     *
270
     * @param array $files
271
     * @return array
272
     */
273
    protected function getChangedLinesPerFile(array $files): array
274
    {
275
        $extract = [];
276
        $pattern = [
277
            'basic' => '^@@ (.*) @@',
278
            'specific' => '@@ -[0-9]+(?:,[0-9]+)? \+([0-9]+)(?:,([0-9]+))? @@',
279
        ];
280
281
        foreach ($files as $file => $data) {
282
            $command = 'git diff -U0 ' . $this->baseBranch . ' ' . $this->currentBranch . ' ' . $file .
283
                ' | grep -E ' . escapeshellarg($pattern['basic']);
284
285
            $lineDiff = shell_exec($command);
286
            $lines = array_filter(explode(PHP_EOL, $lineDiff));
287
            $linesChanged = [];
288
289
            foreach ($lines as $line) {
290
                preg_match('/' . $pattern['specific'] . '/', $line, $matches);
291
292
                // If there were no specific matches, skip this line
293
                if ([] === $matches) {
294
                    continue;
295
                }
296
297
                $start = $end = (int)$matches[1];
298
299
                // Multiple lines were changed, so we need to calculate the end line
300
                if (isset($matches[2])) {
301
                    $length = (int)$matches[2];
302
                    $end = $start + $length - 1;
303
                }
304
305
                foreach (range($start, $end) as $l) {
306
                    $linesChanged[$l] = null;
307
                }
308
            }
309
310
            $extract[$file] = array_keys($linesChanged);
311
        }
312
313
        return $extract;
314
    }
315
316
    /**
317
     * @param string $message
318
     * @param int $exitCode
319
     */
320
    protected function error(string $message, int $exitCode = 1): void
321
    {
322
        $this->climate->error($message);
323
        $this->setExitCode($exitCode);
324
    }
325
}
326