Completed
Pull Request — master (#68)
by Tamas
06:17
created

LintCommand::dumpJsonResult()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 10
rs 9.9332
c 0
b 0
f 0
cc 1
nc 1
nop 4
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
     * @throws Exception
263
     */
264
    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...
265
    {
266
        $document = new Document();
267
        $suite = $document->addTestSuite();
268
        $suite->setName('PHP Linter');
269
        $suite->setTimestamp(new DateTime());
270
        $suite->setTime($context["time_usage"]);
271
        $testCase = $suite->addTestCase();
272
        foreach ($errors as $errorName => $value) {
273
            $testCase->addError($errorName, 'Error', $value['error']);
274
        }
275
        $document->save($path);
276
    }
277
278
    /**
279
     * Execute lint and return errors.
280
     *
281
     * @param Linter          $linter
282
     * @param InputInterface  $input
283
     * @param OutputInterface $output
284
     * @param int             $fileCount
285
     *
286
     * @return array
287
     */
288
    protected function executeLint($linter, $input, $output, $fileCount)
289
    {
290
        $cache = !$input->getOption('no-cache');
291
        $maxColumns = floor((new Terminal())->getWidth() / 2);
292
        $verbosity = $output->getVerbosity();
293
        $displayProgress = !$input->getOption('no-progress');
294
295
        $displayProgress && $linter->setProcessCallback(function ($status, SplFileInfo $file) use ($output, $verbosity, $fileCount, $maxColumns) {
296
            static $i = 1;
297
298
            $percent = floor(($i / $fileCount) * 100);
299
            $process = str_pad(" {$i} / {$fileCount} ({$percent}%)", 18, ' ', STR_PAD_LEFT);
300
301
            if ($verbosity >= OutputInterface::VERBOSITY_VERBOSE) {
302
                $filename = str_pad(" {$i}: ".$file->getRelativePathname(), $maxColumns - 10, ' ', \STR_PAD_RIGHT);
303
                $status = \str_pad(('ok' === $status ? '<info>OK</info>' : '<error>Error</error>'), 20, ' ', \STR_PAD_RIGHT);
304
                $output->writeln(\sprintf("%s\t%s\t%s", $filename, $status, $process));
305
            } else {
306
                if ($i && 0 === $i % $maxColumns) {
307
                    $output->writeln($process);
308
                }
309
                $output->write('ok' === $status ? '<info>.</info>' : '<error>E</error>');
310
            }
311
            ++$i;
312
        });
313
314
        $displayProgress || $output->write('<info>Checking...</info>');
315
316
        return $linter->lint([], $cache);
317
    }
318
319
    /**
320
     * Show errors detail.
321
     *
322
     * @param array $errors
323
     *
324
     * @throws \JakubOnderka\PhpConsoleColor\InvalidStyleException
325
     */
326
    protected function showErrors($errors)
327
    {
328
        $i = 0;
329
        $this->output->writeln("\nThere was ".count($errors).' errors:');
330
331
        foreach ($errors as $filename => $error) {
332
            $this->output->writeln('<comment>'.++$i.". {$filename}:{$error['line']}".'</comment>');
333
334
            $this->output->write($this->getHighlightedCodeSnippet($filename, $error['line']));
335
336
            $this->output->writeln("<error> {$error['error']}</error>");
337
        }
338
    }
339
340
    /**
341
     * @param string $filePath
342
     * @param int    $lineNumber
343
     * @param int    $linesBefore
344
     * @param int    $linesAfter
345
     *
346
     * @return string
347
     */
348
    protected function getCodeSnippet($filePath, $lineNumber, $linesBefore = 3, $linesAfter = 3)
349
    {
350
        $lines = file($filePath);
351
        $offset = $lineNumber - $linesBefore - 1;
352
        $offset = max($offset, 0);
353
        $length = $linesAfter + $linesBefore + 1;
354
        $lines = array_slice($lines, $offset, $length, $preserveKeys = true);
355
        end($lines);
356
        $lineStrlen = strlen(key($lines) + 1);
357
        $snippet = '';
358
359
        foreach ($lines as $i => $line) {
360
            $snippet .= (abs($lineNumber) === $i + 1 ? '  > ' : '    ');
361
            $snippet .= str_pad($i + 1, $lineStrlen, ' ', STR_PAD_LEFT).'| '.rtrim($line).PHP_EOL;
362
        }
363
364
        return $snippet;
365
    }
366
367
    /**
368
     * @param string $filePath
369
     * @param int    $lineNumber
370
     * @param int    $linesBefore
371
     * @param int    $linesAfter
372
     *
373
     * @return string
374
     *
375
     * @throws \JakubOnderka\PhpConsoleColor\InvalidStyleException
376
     */
377
    public function getHighlightedCodeSnippet($filePath, $lineNumber, $linesBefore = 3, $linesAfter = 3)
378
    {
379
        if (
380
            !class_exists('\JakubOnderka\PhpConsoleHighlighter\Highlighter') ||
381
            !class_exists('\JakubOnderka\PhpConsoleColor\ConsoleColor')
382
        ) {
383
            return $this->getCodeSnippet($filePath, $lineNumber, $linesBefore, $linesAfter);
384
        }
385
386
        $colors = new ConsoleColor();
387
        $highlighter = new Highlighter($colors);
388
        $fileContent = file_get_contents($filePath);
389
390
        return $highlighter->getCodeSnippet($fileContent, $lineNumber, $linesBefore, $linesAfter);
391
    }
392
393
    /**
394
     * Merge options.
395
     *
396
     * @return array
397
     */
398
    protected function mergeOptions()
399
    {
400
        $options = $this->input->getOptions();
401
        $options['path'] = $this->input->getArgument('path');
402
        $options['cache'] = $this->input->getOption('cache');
403
404
        $config = [];
405
406
        if (!$this->input->getOption('no-configuration')) {
407
            $filename = $this->getConfigFile();
408
409
            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...
410
                $options['configuration'] = $filename;
411
            }
412
413
            if (!empty($options['configuration'])) {
414
                $this->output->writeln("<comment>Loaded config from \"{$options['configuration']}\"</comment>\n");
415
                $config = $this->loadConfiguration($options['configuration']);
416
            } else {
417
                $this->output->writeln("<comment>No config file loaded.</comment>\n");
418
            }
419
        } else {
420
            $this->output->writeln("<comment>No config file loaded.</comment>\n");
421
        }
422
423
        $options = array_merge($this->defaults, array_filter($config), array_filter($options));
424
425
        is_array($options['extensions']) || $options['extensions'] = explode(',', $options['extensions']);
426
427
        return $options;
428
    }
429
430
    /**
431
     * Get configuration file.
432
     *
433
     * @return string|null
434
     */
435
    protected function getConfigFile()
436
    {
437
        $inputPath = $this->input->getArgument('path');
438
439
        $dir = './';
440
441
        if (1 == count($inputPath) && $first = reset($inputPath)) {
442
            $dir = is_dir($first) ? $first : dirname($first);
443
        }
444
445
        $filename = rtrim($dir, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'.phplint.yml';
446
447
        return realpath($filename);
448
    }
449
450
    /**
451
     * Load configuration from yaml.
452
     *
453
     * @param string $path
454
     *
455
     * @return array
456
     */
457
    protected function loadConfiguration($path)
458
    {
459
        try {
460
            $configuration = Yaml::parse(file_get_contents($path));
461
            if (!is_array($configuration)) {
462
                throw new ParseException('Invalid content.', 1);
463
            }
464
465
            return $configuration;
466
        } catch (ParseException $e) {
467
            $this->output->writeln(sprintf('<error>Unable to parse the YAML string: %s</error>', $e->getMessage()));
468
469
            return [];
470
        }
471
    }
472
}
473