Passed
Push — dev ( 981267...ebe71d )
by James Ekow Abaka
02:38
created

ArgumentParser   B

Complexity

Total Complexity 50

Size/Duplication

Total Lines 318
Duplicated Lines 0 %

Test Coverage

Coverage 96.46%

Importance

Changes 0
Metric Value
wmc 50
dl 0
loc 318
ccs 109
cts 113
cp 0.9646
rs 8.6206
c 0
b 0
f 0

16 Methods

Rating   Name   Duplication   Size   Complexity  
A getNextValueOrFail() 0 7 3
B parseArgumentArray() 0 12 5
B fillInDefaults() 0 15 8
A addToOptionCache() 0 11 3
A validateOption() 0 7 4
A parseShortArgument() 0 16 3
A addOption() 0 8 1
A assignValue() 0 6 3
A maybeShowHelp() 0 5 4
A __construct() 0 4 1
A parse() 0 18 3
A parseLongArgument() 0 17 3
A parseCommand() 0 5 3
A addCommand() 0 9 3
A enableHelp() 0 9 2
A getHelpMessage() 0 6 1

How to fix   Complexity   

Complex Class

Complex classes like ArgumentParser 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.

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 ArgumentParser, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace clearice\argparser;
4
use clearice\utils\ProgramControl;
5
6
7
/**
8
 * Class ArgumentParser
9
 *
10
 * @package clearice\argparser
11
 */
12
class ArgumentParser
13
{
14
    private $description;
15
16
    private $footer;
17
18
    private $name;
19
20
    private $commands = [];
21
22
    /**
23
     * @var array
24
     */
25
    private $optionsCache = [];
26
27
    /**
28
     * All the possible options for arguments.
29
     * @var array
30
     */
31
    private $options = [];
32
33
    /**
34
     * An instance of the help generator.
35
     * @var HelpMessageGenerator
36
     */
37
    private $helpGenerator;
38
39
    private $programControl;
40
41
    private $helpEnabled = false;
42
43 14
    public function __construct($helpWriter = null, $programControl = null)
44
    {
45 14
        $this->helpGenerator = $helpWriter ?? new HelpMessageGenerator();
46 14
        $this->programControl = $programControl ?? new ProgramControl();
47 14
    }
48
49
    /**
50
     * Add a value to the available possible options for later parsing.
51
     *
52
     * @param string $key
53
     * @param array $value
54
     * @throws OptionExistsException
55
     */
56 13
    private function addToOptionCache(string $key, array $value) : void
57
    {
58 13
        if (!isset($value[$key])) {
59 2
            return;
60
        }
61
        $cacheKey = "${value['command']}${value[$key]}";
62
        if (!isset($this->optionsCache[$cacheKey])) {
63 13
            $this->optionsCache[$cacheKey] = $value;
64
        } else {
65 2
            throw new OptionExistsException(
66 2
                "An argument option with $key {$value['command']} {$value[$key]} already exists."
67
            );
68
        }
69 13
    }
70
71
    /**
72
     * @param array $option
73
     * @throws InvalidArgumentDescriptionException
74
     * @throws UnknownCommandException
75
     */
76
    private function validateOption($option) : void
77
    {
78 14
        if (!isset($option['name'])) {
79 1
            throw new InvalidArgumentDescriptionException("Argument must have a name");
80
        }
81 13
        if (isset($option['command']) && !isset($this->commands[$option['command']])) {
82 1
            throw new UnknownCommandException("The command {$option['command']} is unknown");
83
        }
84 13
    }
85
86
    /**
87
     * Add an option to be parsed.
88
     * Arguments are presented as a structured array with the following possible keys.
89
     *
90
     *  name: The name of the option prefixed with a double dash --
91
     *  short_name: A shorter single character option prefixed with a single dash -
92
     *  type: Required for all options that take values. An option specified without a type is considered to be a
93
     *        boolean flag.
94
     *  repeats: A boolean value that states whether the option can be repeated or not. Repeatable options are returned
95
     *        as arrays.
96
     *  default: A default value for the option.
97
     *  help: A help message for the option
98
     *
99
     * @param array $option
100
     * @throws OptionExistsException
101
     * @throws InvalidArgumentDescriptionException
102
     * @throws UnknownCommandException
103
     */
104
    public function addOption(array $option): void
105
    {
106 14
        $this->validateOption($option);
107 13
        $option['command'] = $option['command'] ?? '';
108 13
        $option['repeats'] = $option['repeats'] ?? false;
109 13
        $this->options[] = $option;
110 13
        $this->addToOptionCache('name', $option);
111 13
        $this->addToOptionCache('short_name', $option);
112 13
    }
113
114
    /**
115
     * @param $arguments
116
     * @param $argPointer
117
     * @return mixed
118
     * @throws InvalidValueException
119
     */
120
    private function getNextValueOrFail($arguments, &$argPointer, $name)
121
    {
122 5
        if (isset($arguments[$argPointer + 1]) && $arguments[$argPointer + 1][0] != '-') {
123 3
            $argPointer++;
124 3
            return $arguments[$argPointer];
125
        } else {
126 2
            throw new InvalidValueException("A value must be passed along with argument $name.");
127
        }
128
    }
129
130
    /**
131
     * Parse a long argument that is prefixed with a double dash "--"
132
     *
133
     * @param $arguments
134
     * @param $argPointer
135
     * @throws InvalidValueException
136
     */
137
    private function parseLongArgument($command, $arguments, &$argPointer, &$output)
138
    {
139 5
        $string = substr($arguments[$argPointer], 2);
140 5
        preg_match("/(?<name>[a-zA-Z_0-9-]+)(?<equal>=?)(?<value>.*)/", $string, $matches);
141 5
        $name = $command . $matches['name'];
142 5
        $option = $this->optionsCache[$name];
143 5
        $value = true;
144
145 5
        if (isset($option['type'])) {
146 4
            if ($matches['equal'] === '=') {
147 1
                $value = $matches['value'];
148
            } else {
149 4
                $value = $this->getNextValueOrFail($arguments, $argPointer, $name);
150
            }
151
        }
152
153 4
        $this->assignValue($option, $output, $option['name'], $value);
154 4
    }
155
156
    /**
157
     * Parse a short argument that is prefixed with a single dash '-'
158
     *
159
     * @param $command
160
     * @param $arguments
161
     * @param $argPointer
162
     * @throws InvalidValueException
163
     */
164
    public function parseShortArgument($command, $arguments, &$argPointer, &$output)
165
    {
166 3
        $argument = $arguments[$argPointer];
167 3
        $key = $command . substr($argument, 1, 1);
168 3
        $option = $this->optionsCache[$key];
169 3
        $value = true;
170
171 3
        if (isset($option['type'])) {
172 3
            if (substr($argument, 2) != "") {
173 2
                $value = substr($argument, 2);
174
            } else {
175 2
                $value = $this->getNextValueOrFail($arguments, $argPointer, $option['name']);
176
            }
177
        }
178
179 2
        $this->assignValue($option, $output, $option['name'], $value);
180 2
    }
181
182
    private function assignValue($option, &$output, $key, $value)
183
    {
184 5
        if($option['repeats']) {
185 1
            $output[$key] = isset($output[$key]) ? array_merge($output[$key], [$value]) : [$value];
186
        } else {
187 4
            $output[$key] = $value;
188
        }
189 5
    }
190
191
    /**
192
     * @param $arguments
193
     * @param $argPointer
194
     * @param $output
195
     * @throws InvalidValueException
196
     */
197
    private function parseArgumentArray($arguments, &$argPointer, &$output)
198
    {
199 7
        $numArguments = count($arguments);
200 7
        $command = $output['__command'] ?? '';
201 7
        for (; $argPointer < $numArguments; $argPointer++) {
202 7
            $arg = $arguments[$argPointer];
203 7
            if (substr($arg, 0, 2) == "--") {
204 5
                $this->parseLongArgument($command, $arguments, $argPointer, $output);
205 3
            } else if ($arg[0] == '-') {
206 3
                $this->parseShortArgument($command, $arguments, $argPointer, $output);
207
            } else {
208 1
                $output['__args'] = isset($output['__args']) ? array_merge($output['__args'], [$arg]) : [$arg];
209
            }
210
        }
211 5
    }
212
213
    /**
214
     * @param $output
215
     * @throws HelpMessageRequestedException
216
     */
217
    private function maybeShowHelp($output)
218
    {
219 5
        if ((isset($output['help']) && $output['help'] && $this->helpEnabled)) {
220 1
            print $this->getHelpMessage($output['__command'] ?? null);
221 1
            throw new HelpMessageRequestedException();
222
        }
223 4
    }
224
225
    public function parseCommand($arguments, &$argPointer, &$output)
226
    {
227 7
        if (count($arguments) > 1 && isset($this->commands[$arguments[$argPointer]])) {
228 1
            $output["__command"] = $arguments[$argPointer];
229 1
            $argPointer++;
230
        }
231 7
    }
232
233
    /**
234
     * @param $parsed
235
     * @throws InvalidArgumentException
236
     */
237
    public function fillInDefaults(&$parsed)
238
    {
239 5
        $required = [];
240 5
        foreach($this->options as $option) {
241 5
            if(!isset($parsed[$option['name']]) && isset($option['default'])) {
242 1
                $parsed[$option['name']] = $option['default'];
243
            }
244 5
            if(isset($option['required']) && $option['required'] && !isset($parsed[$option['name']])) {
245 5
                $required[] = $option['name'];
246
            }
247
        }
248
249 5
        if(!empty(($required))) {
250
            throw new InvalidArgumentException(
251
                sprintf("The following options are required: %s. Pass the --help option for more information about possible options.", implode(", $required"))
0 ignored issues
show
Bug introduced by
', '.$required of type string is incompatible with the type array expected by parameter $pieces of implode(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

251
                sprintf("The following options are required: %s. Pass the --help option for more information about possible options.", implode(/** @scrutinizer ignore-type */ ", $required"))
Loading history...
252
            );
253
        }
254 5
    }
255
256
    /**
257
     * Parses command line arguments and return a structured array of options and their associated values.
258
     *
259
     * @param array $arguments An optional array of arguments that would be parsed instead of those passed to the CLI.
260
     * @return array
261
     * @throws InvalidValueException
262
     */
263
    public function parse($arguments = null)
264
    {
265
        try{
266 7
            global $argv;
0 ignored issues
show
Compatibility Best Practice introduced by
Use of global functionality is not recommended; it makes your code harder to test, and less reusable.

Instead of relying on global state, we recommend one of these alternatives:

1. Pass all data via parameters

function myFunction($a, $b) {
    // Do something
}

2. Create a class that maintains your state

class MyClass {
    private $a;
    private $b;

    public function __construct($a, $b) {
        $this->a = $a;
        $this->b = $b;
    }

    public function myFunction() {
        // Do something
    }
}
Loading history...
267 7
            $arguments = $arguments ?? $argv;
268 7
            $argPointer = 1;
269 7
            $parsed = [];
270 7
            $this->name = $this->name ?? $arguments[0];
271 7
            $this->parseCommand($arguments, $argPointer, $parsed);
272 7
            $this->parseArgumentArray($arguments, $argPointer, $parsed);
273 5
            $this->fillInDefaults($parsed);
274 5
            $this->maybeShowHelp($parsed);
275 4
            return $parsed;
276 3
        } catch (HelpMessageRequestedException $exception) {
277 1
            $this->programControl->quit();
278 2
        } catch (InvalidArgumentException $exception) {
279
            print $exception->getMessage();
280
            $this->programControl->quit();
281
        }
282 1
    }
283
284
    /**
285
     * Enables help messages so they show automatically.
286
     *
287
     * @param string $name
288
     * @param string $description
289
     * @param string $footer
290
     *
291
     * @throws InvalidArgumentDescriptionException
292
     * @throws OptionExistsException
293
     * @throws UnknownCommandException
294
     */
295
    public function enableHelp(string $description = null, string $footer = null, string $name = null) : void
296
    {
297 2
        $this->name = $name;
298 2
        $this->description = $description;
299 2
        $this->footer = $footer;
300 2
        $this->helpEnabled = true;
301 2
        $this->addOption(['name' => 'help', 'short_name' => 'h', 'help' => "display this help message"]);
302 2
        foreach($this->commands as $command) {
303 1
            $this->addOption(['name' => 'help', 'help' => 'display this help message', 'command' => $command['name']]);
304
        }
305 2
    }
306
307
    public function getHelpMessage($command = '')
308
    {
309 2
        return $this->helpGenerator->generate(
310 2
            $this->name, $command ?? null,
311 2
            ['options' => $this->options, 'commands' => $this->commands],
312 2
            $this->description, $this->footer
313
        );
314
    }
315
316
    /**
317
     * @param $command
318
     * @throws CommandExistsException
319
     * @throws InvalidArgumentDescriptionException
320
     */
321
    public function addCommand($command)
322
    {
323 5
        if (!isset($command['name'])) {
324 1
            throw new InvalidArgumentDescriptionException("Command description must contain a name");
325
        }
326 5
        if (isset($this->commands[$command['name']])) {
327 1
            throw new CommandExistsException("Command ${command['name']} already exists.");
328
        }
329 5
        $this->commands[$command['name']] = $command;
330 5
    }
331
}
332