• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

nette / command-line / 20911287643

12 Jan 2026 07:28AM UTC coverage: 96.894% (+0.5%) from 96.35%
20911287643

push

github

dg
removed deprecated stuff

2 of 2 new or added lines in 1 file covered. (100.0%)

5 existing lines in 2 files now uncovered.

156 of 161 relevant lines covered (96.89%)

0.97 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.06
/src/CommandLine/Parser.php
1
<?php
2

3
/**
4
 * This file is part of the Nette Framework (https://nette.org)
5
 * Copyright (c) 2004 David Grudl (https://davidgrudl.com)
6
 */
7

8
declare(strict_types=1);
9

10
namespace Nette\CommandLine;
11

12

13
/**
14
 * Stupid command line arguments parser.
15
 */
16
class Parser
17
{
18
        public const
19
                Argument = 'argument',
20
                Optional = 'optional',
21
                Repeatable = 'repeatable',
22
                Enum = 'enum',
23
                RealPath = 'realpath',
24
                Normalizer = 'normalizer',
25
                Default = 'default';
26

27
        private const OptionPresent = true;
28

29
        /** @var array<string, Option> */
30
        private array $options = [];
31
        private string $help = '';
32

33
        /** @var string[] */
34
        private array $args;
35

36

37
        public function __construct(string $help = '', array $defaults = [])
1✔
38
        {
39
                $this->args = isset($_SERVER['argv']) ? array_slice($_SERVER['argv'], 1) : [];
1✔
40

41
                if ($help || $defaults) {
1✔
42
                        $this->addFromHelp($help, $defaults);
1✔
43
                }
44
        }
1✔
45

46

47
        /**
48
         * Extracts option definitions from formatted help text.
49
         */
50
        public function addFromHelp(string $help, array $defaults = []): static
1✔
51
        {
52
                preg_match_all('#^[ \t]+(--?\w.*?)(?:  .*\(default: (.*)\)|  |\r|$)#m', $help, $lines, PREG_SET_ORDER);
1✔
53
                foreach ($lines as $line) {
1✔
54
                        preg_match_all('#(--?\w[\w-]*)(?:[= ](<.*?>|\[.*?]|\w+)(\.{0,3}))?[ ,|]*#A', $line[1], $m);
1✔
55
                        if (!count($m[0]) || count($m[0]) > 2 || implode('', $m[0]) !== $line[1]) {
1✔
UNCOV
56
                                throw new \InvalidArgumentException("Unable to parse '$line[1]'.");
×
57
                        }
58

59
                        $name = end($m[1]);
1✔
60
                        $defaults[$name] = ($defaults[$name] ?? []) + [
1✔
61
                                self::Argument => (bool) end($m[2]),
1✔
62
                                self::Optional => isset($line[2]) || (str_starts_with(end($m[2]), '[')),
1✔
63
                                self::Repeatable => (bool) end($m[3]),
1✔
64
                                self::Enum => count($enums = explode('|', trim(end($m[2]), '<[]>'))) > 1 ? $enums : null,
1✔
65
                                self::Default => $line[2] ?? null,
1✔
66
                        ];
67
                        $aliases[$name] = $name !== $m[1][0] ? $m[1][0] : null;
1✔
68
                }
69

70
                foreach ($defaults as $name => $opt) {
1✔
71
                        $default = $opt[self::Default] ?? null;
1✔
72
                        if ($opt[self::RealPath] ?? false) {
1✔
73
                                $opt[self::Normalizer] = ($opt[self::Normalizer] ?? null)
1✔
UNCOV
74
                                        ? fn($value) => self::normalizeRealPath($opt[self::Normalizer]($value))
×
75
                                        : self::normalizeRealPath(...);
1✔
76
                        }
77
                        $this->options[$name] = new Option(
1✔
78
                                name: $name,
1✔
79
                                alias: $aliases[$name] ?? null,
1✔
80
                                type: match (true) {
1✔
81
                                        !($opt[self::Argument] ?? true) => ValueType::None,
1✔
82
                                        ($opt[self::Optional] ?? false) || $default !== null => ValueType::Optional,
1✔
83
                                        default => ValueType::Required,
1✔
84
                                },
85
                                repeatable: (bool) ($opt[self::Repeatable] ?? null),
1✔
86
                                fallback: $default,
87
                                normalizer: $opt[self::Normalizer] ?? null,
1✔
88
                                enum: $opt[self::Enum] ?? null,
1✔
89
                        );
90
                }
91

92
                $this->help .= $help;
1✔
93
                return $this;
1✔
94
        }
95

96

97
        /**
98
         * Adds a switch (flag without value), e.g. --foo or -f.
99
         * Parses as true when used, null when not.
100
         */
101
        public function addSwitch(
1✔
102
                string $name,
103
                ?string $alias = null,
104
                bool $repeatable = false,
105
        ): static
106
        {
107
                $this->options[$name] = new Option(
1✔
108
                        name: $name,
1✔
109
                        alias: $alias,
110
                        type: ValueType::None,
1✔
111
                        repeatable: $repeatable,
112
                );
113
                return $this;
1✔
114
        }
115

116

117
        /**
118
         * Adds an option with value, e.g. --foo json or -f json.
119
         * @param bool $optionalValue  If true, value can be omitted (--foo parses as true)
120
         * @param mixed $fallback      Parsed value when option is not used at all
121
         */
122
        public function addOption(
1✔
123
                string $name,
124
                ?string $alias = null,
125
                bool $optionalValue = false,
126
                mixed $fallback = null,
127
                ?array $enum = null,
128
                bool $repeatable = false,
129
                ?\Closure $normalizer = null,
130
        ): static
131
        {
132
                $this->options[$name] = new Option(
1✔
133
                        name: $name,
1✔
134
                        alias: $alias,
135
                        type: $optionalValue ? ValueType::Optional : ValueType::Required,
1✔
136
                        fallback: $fallback,
137
                        repeatable: $repeatable,
138
                        enum: $enum,
139
                        normalizer: $normalizer,
140
                );
141
                return $this;
1✔
142
        }
143

144

145
        /**
146
         * Adds a positional argument, e.g. <foo> or [foo].
147
         * @param bool $optional   If true, argument can be omitted
148
         * @param mixed $fallback  Parsed value when argument is not provided
149
         */
150
        public function addArgument(
1✔
151
                string $name,
152
                bool $optional = false,
153
                mixed $fallback = null,
154
                ?array $enum = null,
155
                bool $repeatable = false,
156
                ?\Closure $normalizer = null,
157
        ): static
158
        {
159
                $this->options[$name] = new Option(
1✔
160
                        name: $name,
1✔
161
                        type: $optional ? ValueType::Optional : ValueType::Required,
1✔
162
                        fallback: $fallback,
163
                        repeatable: $repeatable,
164
                        enum: $enum,
165
                        normalizer: $normalizer,
166
                );
167
                return $this;
1✔
168
        }
169

170

171
        /**
172
         * Parses command-line arguments and returns associative array of values.
173
         * @param array|null $args  Arguments to parse (defaults to $_SERVER['argv'])
174
         */
175
        public function parse(?array $args = null): array
1✔
176
        {
177
                $args ??= $this->args;
1✔
178

179
                $aliases = $positional = [];
1✔
180
                foreach ($this->options as $opt) {
1✔
181
                        if ($opt->positional) {
1✔
182
                                $positional[] = $opt;
1✔
183
                        } elseif ($opt->alias !== null) {
1✔
184
                                $aliases[$opt->alias] = $opt;
1✔
185
                        }
186
                }
187

188
                $params = [];
1✔
189
                reset($positional);
1✔
190
                $i = 0;
1✔
191
                while ($i < count($args)) {
1✔
192
                        $arg = $args[$i++];
1✔
193
                        if ($arg[0] !== '-') {
1✔
194
                                if (!current($positional)) {
1✔
195
                                        throw new \Exception("Unexpected parameter $arg.");
1✔
196
                                }
197

198
                                $opt = current($positional);
1✔
199
                                $arg = $this->normalizeValue($opt, $arg);
1✔
200
                                if (!$opt->repeatable) {
1✔
201
                                        $params[$opt->name] = $arg;
1✔
202
                                        next($positional);
1✔
203
                                } else {
204
                                        $params[$opt->name][] = $arg;
1✔
205
                                }
206

207
                                continue;
1✔
208
                        }
209

210
                        [$name, $arg] = strpos($arg, '=') ? explode('=', $arg, 2) : [$arg, self::OptionPresent];
1✔
211
                        $opt = $aliases[$name] ?? $this->options[$name] ?? null;
1✔
212
                        if (!$opt) {
1✔
213
                                throw new \Exception("Unknown option $name.");
1✔
214
                        }
215

216
                        if ($arg !== self::OptionPresent && $opt->type === ValueType::None) {
1✔
217
                                throw new \Exception("Option $opt->name has not argument.");
1✔
218

219
                        } elseif ($arg === self::OptionPresent && $opt->type !== ValueType::None) {
1✔
220
                                if (isset($args[$i]) && $args[$i][0] !== '-') {
1✔
221
                                        $arg = $args[$i++];
1✔
222
                                } elseif ($opt->type === ValueType::Required) {
1✔
223
                                        throw new \Exception("Option $opt->name requires argument.");
1✔
224
                                }
225
                        }
226

227
                        $arg = $this->normalizeValue($opt, $arg);
1✔
228

229
                        if (!$opt->repeatable) {
1✔
230
                                $params[$opt->name] = $arg;
1✔
231
                        } else {
232
                                $params[$opt->name][] = $arg;
1✔
233
                        }
234
                }
235

236
                foreach ($this->options as $opt) {
1✔
237
                        if (isset($params[$opt->name])) {
1✔
238
                                continue;
1✔
239
                        } elseif ($opt->type !== ValueType::Required) {
1✔
240
                                $params[$opt->name] = $opt->fallback;
1✔
241
                        } elseif ($opt->positional) {
1✔
242
                                throw new \Exception("Missing required argument <$opt->name>.");
1✔
243
                        } else {
244
                                $params[$opt->name] = null;
1✔
245
                        }
246

247
                        if ($opt->repeatable) {
1✔
248
                                $params[$opt->name] = (array) $params[$opt->name];
1✔
249
                        }
250
                }
251

252
                return $params;
1✔
253
        }
254

255

256
        /**
257
         * Parses only specified options, ignoring everything else.
258
         * No validation, no exceptions. Useful for early-exit options like --help.
259
         * @param  string[]  $names  Option names to parse (e.g., ['--help', '--version'])
260
         * @return array<string, mixed>  Parsed values (null if option not used)
261
         */
262
        public function parseOnly(array $names, ?array $args = null): array
1✔
263
        {
264
                $args ??= $this->args;
1✔
265
                $lookup = [];
1✔
266
                foreach ($names as $name) {
1✔
267
                        $opt = $this->options[$name] ?? null;
1✔
268
                        if ($opt) {
1✔
269
                                $lookup[$name] = $opt;
1✔
270
                                if ($opt->alias !== null) {
1✔
271
                                        $lookup[$opt->alias] = $opt;
1✔
272
                                }
273
                        }
274
                }
275

276
                $params = array_fill_keys($names, null);
1✔
277
                $i = 0;
1✔
278
                while ($i < count($args)) {
1✔
279
                        $arg = $args[$i++];
1✔
280
                        if ($arg[0] !== '-') {
1✔
281
                                continue;
1✔
282
                        }
283

284
                        [$name, $value] = strpos($arg, '=') ? explode('=', $arg, 2) : [$arg, self::OptionPresent];
1✔
285
                        $opt = $lookup[$name] ?? null;
1✔
286
                        if (!$opt) {
1✔
287
                                continue;
1✔
288
                        }
289

290
                        if ($value === self::OptionPresent && $opt->type !== ValueType::None) {
1✔
291
                                if (isset($args[$i]) && $args[$i][0] !== '-') {
1✔
292
                                        $value = $args[$i++];
1✔
293
                                }
294
                        }
295

296
                        $params[$opt->name] = $value;
1✔
297
                }
298

299
                return $params;
1✔
300
        }
301

302

303
        /**
304
         * Prints help text to stdout.
305
         */
306
        public function help(): void
307
        {
UNCOV
308
                echo $this->help;
×
309
        }
310

311

312
        private function normalizeValue(Option $opt, mixed $value): mixed
1✔
313
        {
314
                if ($opt->enum && $value !== self::OptionPresent && !in_array($value, $opt->enum, strict: true)) {
1✔
315
                        throw new \Exception("Value of option $opt->name must be " . implode(', or ', $opt->enum) . '.');
1✔
316
                }
317

318
                return $opt->normalizer ? ($opt->normalizer)($value) : $value;
1✔
319
        }
320

321

322
        /**
323
         * Normalizer that resolves path to absolute and validates existence.
324
         */
325
        public static function normalizeRealPath(string $value): string
1✔
326
        {
327
                $path = realpath($value);
1✔
328
                if ($path === false) {
1✔
329
                        throw new \Exception("File path '$value' not found.");
1✔
330
                }
331

332
                return $path;
1✔
333
        }
334

335

336
        /**
337
         * Returns true if no command-line arguments were provided.
338
         */
339
        public function isEmpty(): bool
340
        {
UNCOV
341
                return !$this->args;
×
342
        }
343
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc