ChangesCommand::execute()   B
last analyzed

Complexity

Conditions 5
Paths 7

Size

Total Lines 35
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 35
rs 8.439
cc 5
eloc 22
nc 7
nop 2
1
<?php
2
3
namespace BZIon\Command;
4
5
use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;
6
use Symfony\Component\Console\Input\InputInterface;
7
use Symfony\Component\Console\Input\InputOption;
8
use Symfony\Component\Console\Output\OutputInterface;
9
use Symfony\Component\Yaml\Yaml;
10
11
class ChangesCommand extends ContainerAwareCommand
12
{
13
    /**
14
     * The date when the latest changes were most recently shown
15
     *
16
     * @var null|\TimeDate
17
     */
18
    private $lastUpdateDate = null;
19
20
    /**
21
     * An array of the most recent changelog entries that were shown to the user
22
     * on the last update
23
     *
24
     * Used to prevent showing the same changelog entries if the user updated
25
     * two times in the same day
26
     *
27
     * @var array
28
     */
29
    private $alreadyListedChanges = array();
30
31
    /**
32
     * {@inheritdoc}
33
     */
34
    protected function configure()
35
    {
36
        $this
37
            ->setName('bzion:changes')
38
            ->setDescription('List new features and bug fixes since the last update')
39
            ->addOption(
40
                'changelog',
41
                'c',
42
                InputOption::VALUE_OPTIONAL,
43
                'The path to the changelog file',
44
                dirname(dirname(__DIR__)) . '/app/changelog.yml'
45
            )
46
            ->addOption(
47
                'lastupdate',
48
                'l',
49
                InputOption::VALUE_OPTIONAL,
50
                'The path to the file containing the date of the last update',
51
                dirname(dirname(__DIR__)) . '/app/lastupdate.yml'
52
            )
53
            ->addOption(
54
                'date',
55
                'd',
56
                InputOption::VALUE_OPTIONAL,
57
                'Show all the changes made since the given date, overrides the lastupdate file'
58
            )
59
            ->addOption(
60
                'read',
61
               null,
62
               InputOption::VALUE_NONE,
63
               'Mark all the changes made before the current date as read'
64
            );
65
    }
66
67
    /**
68
     * {@inheritdoc}
69
     */
70
    protected function execute(InputInterface $input, OutputInterface $output)
71
    {
72
        $lastUpdatePath = $input->getOption('lastupdate');
73
        $date           = $input->getOption('date');
74
        $markRead       = $input->getOption('read');
75
        $changelog      = Yaml::parse($input->getOption('changelog'));
76
77
        $this->parseOptions($lastUpdatePath, $date, $output);
78
79
        // Make sure the changelog dates are properly sorted (more recent to older)
80
        LogCommand::sort($changelog);
81
        $listed = $this->parseChangelog($changelog);
82
83
        if (!$markRead) {
84
            if ($date) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $date 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...
85
                $last = $date->isFuture() ? 'next' : 'last';
0 ignored issues
show
Bug introduced by
The method isFuture cannot be called on $date (of type string).

Methods can only be called on objects. This check looks for methods being called on variables that have been inferred to never be objects.

Loading history...
86
                $since = "in the $last " . $date->diffForHumans(null, true);
0 ignored issues
show
Bug introduced by
The method diffForHumans cannot be called on $date (of type string).

Methods can only be called on objects. This check looks for methods being called on variables that have been inferred to never be objects.

Loading history...
87
            } else {
88
                $since = 'since the last update';
89
            }
90
91
            if ($this->isEmpty($listed)) {
92
                $output->writeln("No significant changes $since.");
93
            } else {
94
                $output->writeln("Changes $since:");
95
                $this->renderChangeList($listed, $output);
96
            }
97
        }
98
99
        $this->storeLastUpdate($lastUpdatePath, $date);
0 ignored issues
show
Documentation introduced by
$date is of type string|null, but the function expects a boolean.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
100
101
        // Reset properties in case execute() is run again
102
        $this->lastUpdateDate = null;
103
        $this->alreadyListedChanges = array();
104
    }
105
106
    /**
107
     * Parse the command line options concerning the date of the last update
108
     *
109
     * @param  string          $lastUpdatePath The path to the last update file
110
     * @param  string|null     $date           The date command line argument
111
     * @param  OutputInterface $output         The command's output
112
     * @return void
113
     */
114
    private function parseOptions($lastUpdatePath, &$date, $output)
115
    {
116
        $message = null;
117
118
        if ($date) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $date 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...
119
            $this->lastUpdateDate = $date = \TimeDate::from($date)->startOfDay();
120
        } elseif (!file_exists($lastUpdatePath)) {
121
            $message = "Last update file not found, a new one is going to be created";
122
        } else {
123
            $message = $this->parseLastUpdate($lastUpdatePath);
124
        }
125
126
        if ($output->isVeryVerbose()) {
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface Symfony\Component\Console\Output\OutputInterface as the method isVeryVerbose() does only exist in the following implementations of said interface: BZIon\Command\NullOutput, Symfony\Component\Console\Output\BufferedOutput, Symfony\Component\Console\Output\ConsoleOutput, Symfony\Component\Console\Output\NullOutput, Symfony\Component\Console\Output\Output, Symfony\Component\Console\Output\StreamOutput, Symfony\Component\Consol...ts\Fixtures\DummyOutput, Symfony\Component\Console\Tests\Output\TestOutput.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
127
            $output->writeln($message);
128
129
            if ($this->lastUpdateDate) {
130
                $formattedDate = $this->lastUpdateDate->toFormattedDateString();
131
                $output->writeln("Showing changes since <options=bold>$formattedDate</options=bold>");
132
            }
133
134
            $output->writeln("");
135
        }
136
    }
137
138
    /**
139
     * Parse the last update file
140
     *
141
     * @param  string $path The path to the last update file
142
     * @return string The message to show to the user
143
     */
144
    private function parseLastUpdate($path)
145
    {
146
        $lastUpdate = Yaml::parse($path);
147
        $this->lastUpdateDate = \TimeDate::from($lastUpdate['date']);
148
        $this->alreadyListedChanges = $lastUpdate['changes'];
149
150
        return "Using <options=bold>$path</options=bold>";
151
    }
152
153
    /**
154
     * Get a list of changes that will be shown to the user
155
     *
156
     * @param  array[] $changelog The parsed changelog.yml file
157
     * @return array[] The changes to show to the user
158
     */
159
    private function parseChangelog($changelog)
160
    {
161
        $listed = array();
162
        $firstEntry = true;
163
        $lastChangeDate = \TimeDate::now()->startOfDay();
164
        $lastChanges = array();
165
166
        foreach ($changelog as $date => $changes) {
167
            $date = \TimeDate::from($date);
168
169
            if ($firstEntry) {
170
                // The array has been sorted, the first entry represents the
171
                // most recent change. Store its date so that we don't show the
172
                // same entry many times
173
                $firstEntry = false;
174
175
                if ($lastChangeDate >= $date) {
176
                    $lastChangeDate = $date;
177
                    $lastChanges = $changes;
178
                }
179
            }
180
181
            // Don't list changes that we've listed before
182
            if ($date == $this->lastUpdateDate) {
183
                $this->filterAlreadyListedChanges($changes);
184
            } elseif ($this->lastUpdateDate && $date < $this->lastUpdateDate) {
185
                break;
186
            }
187
188
            $listed = array_merge_recursive($listed, $changes);
189
        }
190
191
        $this->alreadyListedChanges = $lastChanges;
192
        $this->lastUpdateDate = $lastChangeDate;
193
194
        return $listed;
195
    }
196
197
    /**
198
     * Filter out the changes made today that have already been shown to the
199
     * user
200
     * @param  array $changes Today's changes
201
     * @return void
202
     */
203
    private function filterAlreadyListedChanges(&$changes)
204
    {
205
        $alreadyListed = $this->alreadyListedChanges;
206
207
        foreach ($changes as $type => &$changelist) {
208
            $changelist = array_filter($changelist, function ($change) use ($type, $alreadyListed) {
209
                if (!isset($alreadyListed[$type])) {
210
                    return true;
211
                }
212
213
                return !in_array($change, $alreadyListed[$type]);
214
            });
215
        }
216
    }
217
218
    /**
219
     * Show a list of changes in a user-readable format
220
     *
221
     * @param  array[]         $listed The changes that should be listed
222
     * @param  OutputInterface $output The command's output
223
     * @return void
224
     */
225
    private function renderChangeList($listed, OutputInterface $output)
226
    {
227
        $types = array(
228
            'Features' => '<info>[+] %s</info>',
229
            'Bugfixes' => '<comment>[*] %s</comment>',
230
            'Notes'    => '<bg=red;options=bold>[!] %s</bg=red;options=bold>',
231
        );
232
233
        foreach ($types as $type => $format) {
234
            if (isset($listed[$type])) {
235
                foreach ($listed[$type] as $change) {
236
                    $output->writeln(sprintf($format, $change));
237
                }
238
            }
239
        }
240
    }
241
242
    /**
243
     * Store the newest entry's date into the last update file, so that the user
244
     * isn't shown the same changes in the future
245
     *
246
     * @param  string  $path The path to the last update file
247
     * @param  bool $date The date command line argument (used to determine
248
     *                       whether we should store the last update or not)
249
     * @return void
250
     */
251
    private function storeLastUpdate($path, $date)
252
    {
253
        if ($date !== null) {
254
            // The user probably run the command to see old changes, we don't
255
            // consider this a result of an update
256
            return;
257
        }
258
259
        $data = array(
260
            'date'    => $this->lastUpdateDate->toFormattedDateString(),
261
            'changes' => $this->alreadyListedChanges
262
        );
263
264
        file_put_contents($path, Yaml::dump($data, 3));
265
    }
266
267
    /**
268
     * Recursively find out if an array is empty
269
     *
270
     * @param  array   $array The array to test
271
     * @return bool|null
272
     */
273
    private function isEmpty(array $array)
274
    {
275
        if (empty($array)) {
276
            return true;
277
        }
278
279
        foreach ($array as $child) {
280
            if (!is_array($child)) {
281
                return false;
282
            }
283
284
            return self::isEmpty($child);
285
        }
286
287
        return null;
288
    }
289
}
290