Passed
Pull Request — master (#11)
by Tiago
16:35 queued 13:32
created

PhpcsDiff::getFlag()   A

Complexity

Conditions 5
Paths 8

Size

Total Lines 18
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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