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

nette / command-line / 22289045265

23 Feb 2026 12:45AM UTC coverage: 96.226%. Remained the same
22289045265

push

github

dg
added CLAUDE.md

153 of 159 relevant lines covered (96.23%)

0.96 hits per line

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

97.06
/src/CommandLine/Parser.php
1
<?php declare(strict_types=1);
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
namespace Nette\CommandLine;
9

10

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

25
        #[\Deprecated('use Parser::Argument')]
26
        public const ARGUMENT = self::Argument;
27

28
        #[\Deprecated('use Parser::Optional')]
29
        public const OPTIONAL = self::Optional;
30

31
        #[\Deprecated('use Parser::Repeatable')]
32
        public const REPEATABLE = self::Repeatable;
33

34
        #[\Deprecated('use Parser::Enum')]
35
        public const ENUM = self::Enum;
36

37
        #[\Deprecated('use Parser::RealPath')]
38
        public const REALPATH = self::RealPath;
39

40
        #[\Deprecated('use Parser::Default')]
41
        public const VALUE = self::Default;
42
        private const OptionPresent = true;
43

44
        /** @var array<string, Option> */
45
        private array $options = [];
46
        private string $help = '';
47

48
        /** @var string[] */
49
        private array $args;
50

51

52
        /** @param array<string, array<string, mixed>> $defaults */
53
        public function __construct(string $help = '', array $defaults = [])
1✔
54
        {
55
                $this->args = isset($_SERVER['argv']) ? array_slice($_SERVER['argv'], 1) : [];
1✔
56

57
                if ($help || $defaults) {
1✔
58
                        $this->addFromHelp($help, $defaults);
1✔
59
                }
60
        }
1✔
61

62

63
        /**
64
         * Extracts option definitions from formatted help text.
65
         * @param array<string, array<string, mixed>> $defaults
66
         */
67
        public function addFromHelp(string $help, array $defaults = []): static
1✔
68
        {
69
                preg_match_all('#^[ \t]+(--?\w.*?)(?:  .*\(default: (.*)\)|  |\r|$)#m', $help, $lines, PREG_SET_ORDER);
1✔
70
                foreach ($lines as $line) {
1✔
71
                        preg_match_all('#(--?\w[\w-]*)(?:[= ](<.*?>|\[.*?]|\w+)(\.{0,3}))?[ ,|]*#A', $line[1], $m);
1✔
72
                        if (!count($m[0]) || count($m[0]) > 2 || implode('', $m[0]) !== $line[1]) {
1✔
73
                                throw new \InvalidArgumentException("Unable to parse '$line[1]'.");
×
74
                        }
75

76
                        $name = (string) end($m[1]);
1✔
77
                        $defaults[$name] = ($defaults[$name] ?? []) + [
1✔
78
                                self::Argument => (bool) end($m[2]),
1✔
79
                                self::Optional => isset($line[2]) || (str_starts_with((string) end($m[2]), '[')),
1✔
80
                                self::Repeatable => (bool) end($m[3]),
1✔
81
                                self::Enum => count($enums = explode('|', trim((string) end($m[2]), '<[]>'))) > 1 ? $enums : null,
1✔
82
                                self::Default => $line[2] ?? null,
1✔
83
                        ];
84
                        $aliases[$name] = $name !== $m[1][0] ? $m[1][0] : null;
1✔
85
                }
86

87
                foreach ($defaults as $name => $opt) {
1✔
88
                        $default = $opt[self::Default] ?? null;
1✔
89
                        if ($opt[self::RealPath] ?? false) {
1✔
90
                                $opt[self::Normalizer] = ($opt[self::Normalizer] ?? null)
1✔
91
                                        ? fn($value) => self::normalizeRealPath($opt[self::Normalizer]($value))
×
92
                                        : self::normalizeRealPath(...);
1✔
93
                        }
94
                        $this->options[$name] = new Option(
1✔
95
                                name: $name,
1✔
96
                                alias: $aliases[$name] ?? null,
1✔
97
                                type: match (true) {
1✔
98
                                        !($opt[self::Argument] ?? true) => ValueType::None,
1✔
99
                                        ($opt[self::Optional] ?? false) || $default !== null => ValueType::Optional,
1✔
100
                                        default => ValueType::Required,
1✔
101
                                },
102
                                repeatable: (bool) ($opt[self::Repeatable] ?? null),
1✔
103
                                fallback: $default,
104
                                normalizer: $opt[self::Normalizer] ?? null,
1✔
105
                                enum: $opt[self::Enum] ?? null,
1✔
106
                        );
107
                }
108

109
                $this->help .= $help;
1✔
110
                return $this;
1✔
111
        }
112

113

114
        /**
115
         * Adds a switch (flag without value), e.g. --foo or -f.
116
         * Parses as true when used, null when not.
117
         */
118
        public function addSwitch(
1✔
119
                string $name,
120
                ?string $alias = null,
121
                bool $repeatable = false,
122
        ): static
123
        {
124
                $this->options[$name] = new Option(
1✔
125
                        name: $name,
1✔
126
                        alias: $alias,
127
                        type: ValueType::None,
1✔
128
                        repeatable: $repeatable,
129
                );
130
                return $this;
1✔
131
        }
132

133

134
        /**
135
         * Adds an option with value, e.g. --foo json or -f json.
136
         * @param bool $optionalValue  If true, value can be omitted (--foo parses as true)
137
         * @param mixed $fallback      Parsed value when option is not used at all
138
         * @param string[]|null $enum
139
         * @param (\Closure(mixed): mixed)|null $normalizer
140
         */
141
        public function addOption(
1✔
142
                string $name,
143
                ?string $alias = null,
144
                bool $optionalValue = false,
145
                mixed $fallback = null,
146
                ?array $enum = null,
147
                bool $repeatable = false,
148
                ?\Closure $normalizer = null,
149
        ): static
150
        {
151
                $this->options[$name] = new Option(
1✔
152
                        name: $name,
1✔
153
                        alias: $alias,
154
                        type: $optionalValue ? ValueType::Optional : ValueType::Required,
1✔
155
                        fallback: $fallback,
156
                        repeatable: $repeatable,
157
                        enum: $enum,
158
                        normalizer: $normalizer,
159
                );
160
                return $this;
1✔
161
        }
162

163

164
        /**
165
         * Adds a positional argument, e.g. <foo> or [foo].
166
         * @param bool $optional   If true, argument can be omitted
167
         * @param mixed $fallback  Parsed value when argument is not provided
168
         * @param string[]|null $enum
169
         * @param (\Closure(mixed): mixed)|null $normalizer
170
         */
171
        public function addArgument(
1✔
172
                string $name,
173
                bool $optional = false,
174
                mixed $fallback = null,
175
                ?array $enum = null,
176
                bool $repeatable = false,
177
                ?\Closure $normalizer = null,
178
        ): static
179
        {
180
                $this->options[$name] = new Option(
1✔
181
                        name: $name,
1✔
182
                        type: $optional ? ValueType::Optional : ValueType::Required,
1✔
183
                        fallback: $fallback,
184
                        repeatable: $repeatable,
185
                        enum: $enum,
186
                        normalizer: $normalizer,
187
                );
188
                return $this;
1✔
189
        }
190

191

192
        /**
193
         * Parses command-line arguments and returns associative array of values.
194
         * @param string[]|null $args  Arguments to parse (defaults to $_SERVER['argv'])
195
         * @return array<string, mixed>
196
         */
197
        public function parse(?array $args = null): array
1✔
198
        {
199
                $args ??= $this->args;
1✔
200

201
                $aliases = $positional = [];
1✔
202
                foreach ($this->options as $opt) {
1✔
203
                        if ($opt->positional) {
1✔
204
                                $positional[] = $opt;
1✔
205
                        } elseif ($opt->alias !== null) {
1✔
206
                                $aliases[$opt->alias] = $opt;
1✔
207
                        }
208
                }
209

210
                $params = [];
1✔
211
                reset($positional);
1✔
212
                $i = 0;
1✔
213
                while ($i < count($args)) {
1✔
214
                        $arg = $args[$i++];
1✔
215
                        if ($arg[0] !== '-') {
1✔
216
                                if (!current($positional)) {
1✔
217
                                        throw new \Exception("Unexpected parameter $arg.");
1✔
218
                                }
219

220
                                $opt = current($positional);
1✔
221
                                $arg = $this->normalizeValue($opt, $arg);
1✔
222
                                if (!$opt->repeatable) {
1✔
223
                                        $params[$opt->name] = $arg;
1✔
224
                                        next($positional);
1✔
225
                                } else {
226
                                        $params[$opt->name][] = $arg;
1✔
227
                                }
228

229
                                continue;
1✔
230
                        }
231

232
                        [$name, $arg] = strpos($arg, '=') ? explode('=', $arg, 2) : [$arg, self::OptionPresent];
1✔
233
                        $opt = $aliases[$name] ?? $this->options[$name] ?? null;
1✔
234
                        if (!$opt) {
1✔
235
                                throw new \Exception("Unknown option $name.");
1✔
236
                        }
237

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

241
                        } elseif ($arg === self::OptionPresent && $opt->type !== ValueType::None) {
1✔
242
                                if (isset($args[$i]) && $args[$i][0] !== '-') {
1✔
243
                                        $arg = $args[$i++];
1✔
244
                                } elseif ($opt->type === ValueType::Required) {
1✔
245
                                        throw new \Exception("Option $opt->name requires argument.");
1✔
246
                                }
247
                        }
248

249
                        $arg = $this->normalizeValue($opt, $arg);
1✔
250

251
                        if (!$opt->repeatable) {
1✔
252
                                $params[$opt->name] = $arg;
1✔
253
                        } else {
254
                                $params[$opt->name][] = $arg;
1✔
255
                        }
256
                }
257

258
                foreach ($this->options as $opt) {
1✔
259
                        if (isset($params[$opt->name])) {
1✔
260
                                continue;
1✔
261
                        } elseif ($opt->type !== ValueType::Required) {
1✔
262
                                $params[$opt->name] = $opt->fallback;
1✔
263
                        } elseif ($opt->positional) {
1✔
264
                                throw new \Exception("Missing required argument <$opt->name>.");
1✔
265
                        } else {
266
                                $params[$opt->name] = null;
1✔
267
                        }
268

269
                        if ($opt->repeatable) {
1✔
270
                                $params[$opt->name] = (array) $params[$opt->name];
1✔
271
                        }
272
                }
273

274
                return $params;
1✔
275
        }
276

277

278
        /**
279
         * Parses only specified options, ignoring everything else.
280
         * No validation, no exceptions. Useful for early-exit options like --help.
281
         * @param  string[]  $names  Option names to parse (e.g., ['--help', '--version'])
282
         * @param  string[]|null  $args
283
         * @return array<string, mixed>  Parsed values (null if option not used)
284
         */
285
        public function parseOnly(array $names, ?array $args = null): array
1✔
286
        {
287
                $args ??= $this->args;
1✔
288
                $lookup = [];
1✔
289
                foreach ($names as $name) {
1✔
290
                        $opt = $this->options[$name] ?? null;
1✔
291
                        if ($opt) {
1✔
292
                                $lookup[$name] = $opt;
1✔
293
                                if ($opt->alias !== null) {
1✔
294
                                        $lookup[$opt->alias] = $opt;
1✔
295
                                }
296
                        }
297
                }
298

299
                $params = array_fill_keys($names, null);
1✔
300
                $i = 0;
1✔
301
                while ($i < count($args)) {
1✔
302
                        $arg = $args[$i++];
1✔
303
                        if ($arg[0] !== '-') {
1✔
304
                                continue;
1✔
305
                        }
306

307
                        [$name, $value] = strpos($arg, '=') ? explode('=', $arg, 2) : [$arg, self::OptionPresent];
1✔
308
                        $opt = $lookup[$name] ?? null;
1✔
309
                        if (!$opt) {
1✔
310
                                continue;
1✔
311
                        }
312

313
                        if ($value === self::OptionPresent && $opt->type !== ValueType::None) {
1✔
314
                                if (isset($args[$i]) && $args[$i][0] !== '-') {
1✔
315
                                        $value = $args[$i++];
1✔
316
                                }
317
                        }
318

319
                        $params[$opt->name] = $value;
1✔
320
                }
321

322
                return $params;
1✔
323
        }
324

325

326
        /**
327
         * Prints help text to stdout.
328
         */
329
        public function help(): void
330
        {
331
                echo $this->help;
×
332
        }
333

334

335
        private function normalizeValue(Option $opt, mixed $value): mixed
1✔
336
        {
337
                if ($opt->enum && $value !== self::OptionPresent && !in_array($value, $opt->enum, strict: true)) {
1✔
338
                        throw new \Exception("Value of option $opt->name must be " . implode(', or ', $opt->enum) . '.');
1✔
339
                }
340

341
                return $opt->normalizer ? ($opt->normalizer)($value) : $value;
1✔
342
        }
343

344

345
        /**
346
         * Normalizer that resolves path to absolute and validates existence.
347
         */
348
        public static function normalizeRealPath(string $value): string
1✔
349
        {
350
                $path = realpath($value);
1✔
351
                if ($path === false) {
1✔
352
                        throw new \Exception("File path '$value' not found.");
1✔
353
                }
354

355
                return $path;
1✔
356
        }
357

358

359
        /**
360
         * Returns true if no command-line arguments were provided.
361
         */
362
        public function isEmpty(): bool
363
        {
364
                return !$this->args;
×
365
        }
366
}
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