$definitions */ public function __construct( private array $definitions = [] ) { } /** * Parse command line arguments * * @param string[] $arguments Raw command line arguments */ public function parse(array $arguments): ParsedArguments { $parsed = [ 'arguments' => [], 'options' => [], 'flags' => [], 'positional' => [], ]; $i = 0; while ($i < count($arguments)) { $arg = $arguments[$i]; if (str_starts_with($arg, '--')) { $i = $this->parseLongOption($arguments, $i, $parsed); } elseif (str_starts_with($arg, '-') && strlen($arg) > 1) { $i = $this->parseShortOption($arguments, $i, $parsed); } else { $this->parsePositionalArgument($arg, $parsed); $i++; } } // Merge all parsed values $allValues = array_merge($parsed['arguments'], $parsed['options'], $parsed['flags']); // Apply defaults for missing values foreach ($this->definitions as $name => $definition) { if (! array_key_exists($name, $allValues) && $definition->default !== null) { $allValues[$name] = $definition->default; } } $result = new ParsedArguments( $parsed['arguments'], array_merge($parsed['options'], $parsed['flags']), $this->definitions ); // Validate all requirements $result->validate(); return $result; } /** * Parse long option (--option or --option=value) */ private function parseLongOption(array $arguments, int $index, array &$parsed): int { $arg = $arguments[$index]; $optionPart = substr($arg, 2); // Remove '--' // Handle --option=value if (str_contains($optionPart, '=')) { [$name, $value] = explode('=', $optionPart, 2); $camelCaseName = $this->kebabToCamelCase($name); $parsed['options'][$camelCaseName] = $this->parseValue($camelCaseName, $value); return $index + 1; } // Convert kebab-case to camelCase for definition lookup $camelCaseName = $this->kebabToCamelCase($optionPart); // Handle --option value or --flag $definition = $this->findDefinitionByName($camelCaseName); if ($definition && $definition->type === ArgumentType::BOOLEAN) { // Boolean flag - use camelCase name $parsed['flags'][$camelCaseName] = true; return $index + 1; } // Option that expects a value if ($index + 1 >= count($arguments) || str_starts_with($arguments[$index + 1], '-')) { if ($definition && $definition->required) { throw new \InvalidArgumentException("Option '--{$optionPart}' requires a value"); } // Optional option without value - treat as flag $parsed['flags'][$camelCaseName] = true; return $index + 1; } $parsed['options'][$camelCaseName] = $this->parseValue($camelCaseName, $arguments[$index + 1]); return $index + 2; } /** * Convert kebab-case to camelCase * Example: dry-run -> dryRun */ private function kebabToCamelCase(string $kebab): string { return lcfirst(str_replace('-', '', ucwords($kebab, '-'))); } /** * Parse short option (-o or -o value or -abc) */ private function parseShortOption(array $arguments, int $index, array &$parsed): int { $arg = $arguments[$index]; $options = substr($arg, 1); // Remove '-' // Handle combined short options: -abc = -a -b -c if (strlen($options) > 1) { foreach (str_split($options) as $shortOption) { $definition = $this->findDefinitionByShortName($shortOption); $longName = $definition?->name ?? $shortOption; if ($definition && $definition->type !== ArgumentType::BOOLEAN) { throw new \InvalidArgumentException( "Short option '-{$shortOption}' requires a value and cannot be combined with other options" ); } $parsed['flags'][$longName] = true; } return $index + 1; } // Handle single short option: -f or -f value $shortName = $options; $definition = $this->findDefinitionByShortName($shortName); $longName = $definition?->name ?? $shortName; if ($definition && $definition->type === ArgumentType::BOOLEAN) { $parsed['flags'][$longName] = true; return $index + 1; } // Option that expects a value if ($index + 1 >= count($arguments) || str_starts_with($arguments[$index + 1], '-')) { if ($definition && $definition->required) { throw new \InvalidArgumentException("Option '-{$shortName}' requires a value"); } // Optional option without value - treat as flag $parsed['flags'][$longName] = true; return $index + 1; } $parsed['options'][$longName] = $this->parseValue($longName, $arguments[$index + 1]); return $index + 2; } /** * Parse positional argument */ private function parsePositionalArgument(string $value, array &$parsed): void { $parsed['positional'][] = $value; // Try to match with positional argument definitions $positionalIndex = count($parsed['positional']) - 1; $positionalDefs = array_values(array_filter( $this->definitions, fn ($def) => $def->type !== ArgumentType::BOOLEAN )); if (isset($positionalDefs[$positionalIndex])) { $definition = $positionalDefs[$positionalIndex]; $parsed['arguments'][$definition->name] = $this->parseValue($definition->name, $value); } } /** * Parse and validate a value according to its definition */ private function parseValue(string $name, string $value): mixed { $definition = $this->findDefinitionByName($name); if (! $definition) { return $value; // Unknown option, keep as string } // Validate against allowed values if (! empty($definition->allowedValues) && ! in_array($value, $definition->allowedValues, true)) { throw new \InvalidArgumentException( "Invalid value '{$value}' for '{$name}'. Allowed values: " . implode(', ', $definition->allowedValues) ); } return $this->castToType($value, $definition->type); } /** * Cast value to appropriate type */ private function castToType(mixed $value, ArgumentType $type): mixed { return match($type) { ArgumentType::STRING => (string) $value, ArgumentType::INTEGER => $this->parseInteger($value), ArgumentType::FLOAT => $this->parseFloat($value), ArgumentType::BOOLEAN => $this->parseBoolean($value), ArgumentType::ARRAY => $this->parseArray($value), ArgumentType::EMAIL => $this->validateEmail($value), ArgumentType::URL => $this->validateUrl($value), }; } private function parseInteger(mixed $value): int { if (! is_numeric($value)) { throw new \InvalidArgumentException("Value '{$value}' is not a valid integer"); } return (int) $value; } private function parseFloat(mixed $value): float { if (! is_numeric($value)) { throw new \InvalidArgumentException("Value '{$value}' is not a valid number"); } return (float) $value; } private function parseBoolean(mixed $value): bool { if (is_bool($value)) { return $value; } $lowered = strtolower((string) $value); return in_array($lowered, ['true', '1', 'yes', 'on'], true); } private function parseArray(mixed $value): array { if (is_array($value)) { return $value; } $items = array_map('trim', explode(',', (string) $value)); return array_filter($items, fn ($item) => $item !== ''); } private function validateEmail(mixed $value): string { $email = (string) $value; if (! filter_var($email, FILTER_VALIDATE_EMAIL)) { throw new \InvalidArgumentException("'{$email}' is not a valid email address"); } return $email; } private function validateUrl(mixed $value): string { $url = (string) $value; if (! filter_var($url, FILTER_VALIDATE_URL)) { throw new \InvalidArgumentException("'{$url}' is not a valid URL"); } return $url; } /** * Find argument definition by long name */ private function findDefinitionByName(string $name): ?ArgumentDefinition { return $this->definitions[$name] ?? null; } /** * Find argument definition by short name */ private function findDefinitionByShortName(string $shortName): ?ArgumentDefinition { foreach ($this->definitions as $definition) { if ($definition->shortName === $shortName) { return $definition; } } return null; } /** * Get all argument definitions * * @return array */ public function getDefinitions(): array { return $this->definitions; } /** * Create parser with fluent interface */ public static function create(): ArgumentParserBuilder { return new ArgumentParserBuilder(); } }