Completed
Pull Request — master (#68)
by Tamas
04:04 queued 01:57
created

LintCommand   B

Complexity

Total Complexity 43

Size/Duplication

Total Lines 439
Duplicated Lines 0 %

Coupling/Cohesion

Components 2
Dependencies 14

Importance

Changes 0
Metric Value
wmc 43
lcom 2
cbo 14
dl 0
loc 439
rs 8.96
c 0
b 0
f 0

12 Methods

Rating   Name   Duplication   Size   Complexity  
B configure() 0 71 1
A initialize() 0 5 1
B execute() 0 74 9
A dumpJsonResult() 0 10 1
A dumpXmlResult() 0 13 2
B executeLint() 0 30 8
A showErrors() 0 13 2
A getCodeSnippet() 0 18 3
A getHighlightedCodeSnippet() 0 15 3
B mergeOptions() 0 31 6
A getConfigFile() 0 14 4
A loadConfiguration() 0 15 3

How to fix   Complexity   

Complex Class

Complex classes like LintCommand often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use LintCommand, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/*
4
 * This file is part of the overtrue/phplint.
5
 *
6
 * (c) overtrue <[email protected]>
7
 *
8
 * This source file is subject to the MIT license that is bundled
9
 * with this source code in the file LICENSE.
10
 */
11
12
namespace Overtrue\PHPLint\Command;
13
14
use DateTime;
15
use Exception;
16
use JakubOnderka\PhpConsoleColor\ConsoleColor;
17
use JakubOnderka\PhpConsoleHighlighter\Highlighter;
18
use N98\JUnitXml\Document;
19
use Overtrue\PHPLint\Cache;
20
use Overtrue\PHPLint\Linter;
21
use Symfony\Component\Console\Command\Command;
22
use Symfony\Component\Console\Helper\Helper;
23
use Symfony\Component\Console\Input\InputArgument;
24
use Symfony\Component\Console\Input\InputInterface;
25
use Symfony\Component\Console\Input\InputOption;
26
use Symfony\Component\Console\Output\OutputInterface;
27
use Symfony\Component\Console\Terminal;
28
use Symfony\Component\Finder\SplFileInfo;
29
use Symfony\Component\Yaml\Exception\ParseException;
30
use Symfony\Component\Yaml\Yaml;
31
32
/**
33
 * Class LintCommand.
34
 */
35
class LintCommand extends Command
36
{
37
    /**
38
     * @var array
39
     */
40
    protected $defaults = [
41
        'jobs' => 5,
42
        'exclude' => [],
43
        'extensions' => ['php'],
44
    ];
45
46
    /**
47
     * @var \Symfony\Component\Console\Input\InputInterface
48
     */
49
    protected $input;
50
51
    /**
52
     * @var \Symfony\Component\Console\Output\OutputInterface
53
     */
54
    protected $output;
55
56
    /**
57
     * Configures the current command.
58
     */
59
    protected function configure()
60
    {
61
        $this
62
            ->setName('phplint')
63
            ->setDescription('Lint something')
64
            ->addArgument(
65
                'path',
66
                InputArgument::OPTIONAL | InputArgument::IS_ARRAY,
67
                'Path to file or directory to lint.'
68
            )
69
            ->addOption(
70
                'exclude',
71
                null,
72
                InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
73
                'Path to file or directory to exclude from linting'
74
            )
75
            ->addOption(
76
                'extensions',
77
                null,
78
                InputOption::VALUE_REQUIRED,
79
                'Check only files with selected extensions (default: php)'
80
            )
81
            ->addOption(
82
                'jobs',
83
                'j',
84
                InputOption::VALUE_REQUIRED,
85
                'Number of parraled jobs to run (default: 5)'
86
            )
87
            ->addOption(
88
                'configuration',
89
                'c',
90
                InputOption::VALUE_REQUIRED,
91
                'Read configuration from config file (default: ./.phplint.yml).'
92
            )
93
            ->addOption(
94
                'no-configuration',
95
                null,
96
                InputOption::VALUE_NONE,
97
                'Ignore default configuration file (default: ./.phplint.yml).'
98
            )
99
            ->addOption(
100
                'no-cache',
101
                null,
102
                InputOption::VALUE_NONE,
103
                'Ignore cached data.'
104
            )
105
            ->addOption(
106
                'cache',
107
                null,
108
                InputOption::VALUE_REQUIRED,
109
                'Path to the cache file.'
110
            )
111
            ->addOption(
112
                'no-progress',
113
                null,
114
                InputOption::VALUE_NONE,
115
                'Hide the progress output.'
116
            )
117
            ->addOption(
118
                'json',
119
                null,
120
                InputOption::VALUE_OPTIONAL,
121
                'Path to store JSON results.'
122
            )
123
            ->addOption(
124
                'xml',
125
                null,
126
                InputOption::VALUE_OPTIONAL,
127
                'Path to store JUnit XML results.'
128
            );
129
    }
130
131
    /**
132
     * Initializes the command just after the input has been validated.
133
     *
134
     * This is mainly useful when a lot of commands extends one main command
135
     * where some things need to be initialized based on the input arguments and options.
136
     *
137
     * @param InputInterface  $input  An InputInterface instance
138
     * @param OutputInterface $output An OutputInterface instance
139
     */
140
    public function initialize(InputInterface $input, OutputInterface $output)
141
    {
142
        $this->input = $input;
143
        $this->output = $output;
144
    }
145
146
    /**
147
     * Executes the current command.
148
     *
149
     * This method is not abstract because you can use this class
150
     * as a concrete class. In this case, instead of defining the
151
     * execute() method, you set the code to execute by passing
152
     * a Closure to the setCode() method.
153
     *
154
     * @param InputInterface  $input  An InputInterface instance
155
     * @param OutputInterface $output An OutputInterface instance
156
     *
157
     * @throws \LogicException When this abstract method is not implemented
158
     *
159
     * @return null|int null or 0 if everything went fine, or an error code
160
     *
161
     * @see setCode()
162
     *
163
     * @throws \JakubOnderka\PhpConsoleColor\InvalidStyleException
164
     */
165
    protected function execute(InputInterface $input, OutputInterface $output)
166
    {
167
        $startTime = microtime(true);
168
        $startMemUsage = memory_get_usage(true);
169
170
        $output->writeln($this->getApplication()->getLongVersion()." by overtrue and contributors.\n");
171
172
        $options = $this->mergeOptions();
173
        $verbosity = $output->getVerbosity();
174
175
        if ($verbosity >= OutputInterface::VERBOSITY_DEBUG) {
176
            $output->writeln('Options: '.json_encode($options)."\n");
177
        }
178
179
        $linter = new Linter($options['path'], $options['exclude'], $options['extensions']);
180
        $linter->setProcessLimit($options['jobs']);
181
182
        if (!empty($options['cache'])) {
183
            Cache::setFilename($options['cache']);
184
        }
185
186
        $usingCache = 'No';
187
        if (!$input->getOption('no-cache') && Cache::isCached()) {
188
            $usingCache = 'Yes';
189
            $linter->setCache(Cache::get());
190
        }
191
192
        $fileCount = count($linter->getFiles());
193
194
        if ($fileCount <= 0) {
195
            $output->writeln('<info>Could not find files to lint</info>');
196
197
            return 0;
198
        }
199
200
        $errors = $this->executeLint($linter, $input, $output, $fileCount);
201
202
        $timeUsage = Helper::formatTime(microtime(true) - $startTime);
203
        $memUsage = Helper::formatMemory(memory_get_usage(true) - $startMemUsage);
204
205
        $code = 0;
206
        $errCount = count($errors);
207
208
        $output->writeln(sprintf(
209
            "\n\nTime: <info>%s</info>\tMemory: <info>%s</info>\tCache: <info>%s</info>\n",
210
            $timeUsage, $memUsage, $usingCache
211
        ));
212
213
        if ($errCount > 0) {
214
            $output->writeln('<error>FAILURES!</error>');
215
            $output->writeln("<error>Files: {$fileCount}, Failures: {$errCount}</error>");
216
            $this->showErrors($errors);
217
            $code = 1;
218
        } else {
219
            $output->writeln("<info>OK! (Files: {$fileCount}, Success: {$fileCount})</info>");
220
        }
221
222
        $context = [
223
            'time_usage' => $timeUsage,
224
            'memory_usage' => $memUsage,
225
            'using_cache' => 'Yes' == $usingCache,
226
            'files_count' => $fileCount,
227
        ];
228
229
        if (!empty($options['json'])) {
230
            $this->dumpJsonResult((string) $options['json'], $errors, $options, $context);
231
        }
232
233
        if (!empty($options['xml'])) {
234
            $this->dumpXmlResult((string) $options['xml'], $errors, $options, $context);
235
        }
236
237
        return $code;
238
    }
239
240
    /**
241
     * @param string $path
242
     * @param array  $errors
243
     * @param array  $options
244
     * @param array  $context
245
     */
246
    protected function dumpJsonResult($path, array $errors, array $options, array $context = [])
247
    {
248
        $result = [
249
            'status' => 'success',
250
            'options' => $options,
251
            'errors' => $errors,
252
        ];
253
254
        \file_put_contents($path, \json_encode(\array_merge($result, $context)));
255
    }
256
257
    /**
258
     * @param string $path
259
     * @param array  $errors
260
     * @param array  $options
261
     * @param array  $context
262
     *
263
     * @throws Exception
264
     */
265
    protected function dumpXmlResult($path, array $errors, array $options, array $context = [])
0 ignored issues
show
Unused Code introduced by
The parameter $options is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
266
    {
267
        $document = new Document();
268
        $suite = $document->addTestSuite();
269
        $suite->setName('PHP Linter');
270
        $suite->setTimestamp(new DateTime());
271
        $suite->setTime($context['time_usage']);
272
        $testCase = $suite->addTestCase();
273
        foreach ($errors as $errorName => $value) {
274
            $testCase->addError($errorName, 'Error', $value['error']);
275
        }
276
        $document->save($path);
277
    }
278
279
    /**
280
     * Execute lint and return errors.
281
     *
282
     * @param Linter          $linter
283
     * @param InputInterface  $input
284
     * @param OutputInterface $output
285
     * @param int             $fileCount
286
     *
287
     * @return array
288
     */
289
    protected function executeLint($linter, $input, $output, $fileCount)
290
    {
291
        $cache = !$input->getOption('no-cache');
292
        $maxColumns = floor((new Terminal())->getWidth() / 2);
293
        $verbosity = $output->getVerbosity();
294
        $displayProgress = !$input->getOption('no-progress');
295
296
        $displayProgress && $linter->setProcessCallback(function ($status, SplFileInfo $file) use ($output, $verbosity, $fileCount, $maxColumns) {
297
            static $i = 1;
298
299
            $percent = floor(($i / $fileCount) * 100);
300
            $process = str_pad(" {$i} / {$fileCount} ({$percent}%)", 18, ' ', STR_PAD_LEFT);
301
302
            if ($verbosity >= OutputInterface::VERBOSITY_VERBOSE) {
303
                $filename = str_pad(" {$i}: ".$file->getRelativePathname(), $maxColumns - 10, ' ', \STR_PAD_RIGHT);
304
                $status = \str_pad(('ok' === $status ? '<info>OK</info>' : '<error>Error</error>'), 20, ' ', \STR_PAD_RIGHT);
305
                $output->writeln(\sprintf("%s\t%s\t%s", $filename, $status, $process));
306
            } else {
307
                if ($i && 0 === $i % $maxColumns) {
308
                    $output->writeln($process);
309
                }
310
                $output->write('ok' === $status ? '<info>.</info>' : '<error>E</error>');
311
            }
312
            ++$i;
313
        });
314
315
        $displayProgress || $output->write('<info>Checking...</info>');
316
317
        return $linter->lint([], $cache);
318
    }
319
320
    /**
321
     * Show errors detail.
322
     *
323
     * @param array $errors
324
     *
325
     * @throws \JakubOnderka\PhpConsoleColor\InvalidStyleException
326
     */
327
    protected function showErrors($errors)
328
    {
329
        $i = 0;
330
        $this->output->writeln("\nThere was ".count($errors).' errors:');
331
332
        foreach ($errors as $filename => $error) {
333
            $this->output->writeln('<comment>'.++$i.". {$filename}:{$error['line']}".'</comment>');
334
335
            $this->output->write($this->getHighlightedCodeSnippet($filename, $error['line']));
336
337
            $this->output->writeln("<error> {$error['error']}</error>");
338
        }
339
    }
340
341
    /**
342
     * @param string $filePath
343
     * @param int    $lineNumber
344
     * @param int    $linesBefore
345
     * @param int    $linesAfter
346
     *
347
     * @return string
348
     */
349
    protected function getCodeSnippet($filePath, $lineNumber, $linesBefore = 3, $linesAfter = 3)
350
    {
351
        $lines = file($filePath);
352
        $offset = $lineNumber - $linesBefore - 1;
353
        $offset = max($offset, 0);
354
        $length = $linesAfter + $linesBefore + 1;
355
        $lines = array_slice($lines, $offset, $length, $preserveKeys = true);
356
        end($lines);
357
        $lineStrlen = strlen(key($lines) + 1);
358
        $snippet = '';
359
360
        foreach ($lines as $i => $line) {
361
            $snippet .= (abs($lineNumber) === $i + 1 ? '  > ' : '    ');
362
            $snippet .= str_pad($i + 1, $lineStrlen, ' ', STR_PAD_LEFT).'| '.rtrim($line).PHP_EOL;
363
        }
364
365
        return $snippet;
366
    }
367
368
    /**
369
     * @param string $filePath
370
     * @param int    $lineNumber
371
     * @param int    $linesBefore
372
     * @param int    $linesAfter
373
     *
374
     * @return string
375
     *
376
     * @throws \JakubOnderka\PhpConsoleColor\InvalidStyleException
377
     */
378
    public function getHighlightedCodeSnippet($filePath, $lineNumber, $linesBefore = 3, $linesAfter = 3)
379
    {
380
        if (
381
            !class_exists('\JakubOnderka\PhpConsoleHighlighter\Highlighter') ||
382
            !class_exists('\JakubOnderka\PhpConsoleColor\ConsoleColor')
383
        ) {
384
            return $this->getCodeSnippet($filePath, $lineNumber, $linesBefore, $linesAfter);
385
        }
386
387
        $colors = new ConsoleColor();
388
        $highlighter = new Highlighter($colors);
389
        $fileContent = file_get_contents($filePath);
390
391
        return $highlighter->getCodeSnippet($fileContent, $lineNumber, $linesBefore, $linesAfter);
392
    }
393
394
    /**
395
     * Merge options.
396
     *
397
     * @return array
398
     */
399
    protected function mergeOptions()
400
    {
401
        $options = $this->input->getOptions();
402
        $options['path'] = $this->input->getArgument('path');
403
        $options['cache'] = $this->input->getOption('cache');
404
405
        $config = [];
406
407
        if (!$this->input->getOption('no-configuration')) {
408
            $filename = $this->getConfigFile();
409
410
            if (empty($options['configuration']) && $filename) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $filename of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
411
                $options['configuration'] = $filename;
412
            }
413
414
            if (!empty($options['configuration'])) {
415
                $this->output->writeln("<comment>Loaded config from \"{$options['configuration']}\"</comment>\n");
416
                $config = $this->loadConfiguration($options['configuration']);
417
            } else {
418
                $this->output->writeln("<comment>No config file loaded.</comment>\n");
419
            }
420
        } else {
421
            $this->output->writeln("<comment>No config file loaded.</comment>\n");
422
        }
423
424
        $options = array_merge($this->defaults, array_filter($config), array_filter($options));
425
426
        is_array($options['extensions']) || $options['extensions'] = explode(',', $options['extensions']);
427
428
        return $options;
429
    }
430
431
    /**
432
     * Get configuration file.
433
     *
434
     * @return string|null
435
     */
436
    protected function getConfigFile()
437
    {
438
        $inputPath = $this->input->getArgument('path');
439
440
        $dir = './';
441
442
        if (1 == count($inputPath) && $first = reset($inputPath)) {
443
            $dir = is_dir($first) ? $first : dirname($first);
444
        }
445
446
        $filename = rtrim($dir, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'.phplint.yml';
447
448
        return realpath($filename);
449
    }
450
451
    /**
452
     * Load configuration from yaml.
453
     *
454
     * @param string $path
455
     *
456
     * @return array
457
     */
458
    protected function loadConfiguration($path)
459
    {
460
        try {
461
            $configuration = Yaml::parse(file_get_contents($path));
462
            if (!is_array($configuration)) {
463
                throw new ParseException('Invalid content.', 1);
464
            }
465
466
            return $configuration;
467
        } catch (ParseException $e) {
468
            $this->output->writeln(sprintf('<error>Unable to parse the YAML string: %s</error>', $e->getMessage()));
469
470
            return [];
471
        }
472
    }
473
}
474