Files
michaelschiemer/src/Framework/Console/ArgumentParser.php
Michael Schiemer fc3d7e6357 feat(Production): Complete production deployment infrastructure
- Add comprehensive health check system with multiple endpoints
- Add Prometheus metrics endpoint
- Add production logging configurations (5 strategies)
- Add complete deployment documentation suite:
  * QUICKSTART.md - 30-minute deployment guide
  * DEPLOYMENT_CHECKLIST.md - Printable verification checklist
  * DEPLOYMENT_WORKFLOW.md - Complete deployment lifecycle
  * PRODUCTION_DEPLOYMENT.md - Comprehensive technical reference
  * production-logging.md - Logging configuration guide
  * ANSIBLE_DEPLOYMENT.md - Infrastructure as Code automation
  * README.md - Navigation hub
  * DEPLOYMENT_SUMMARY.md - Executive summary
- Add deployment scripts and automation
- Add DEPLOYMENT_PLAN.md - Concrete plan for immediate deployment
- Update README with production-ready features

All production infrastructure is now complete and ready for deployment.
2025-10-25 19:18:37 +02:00

341 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Framework\Console;
/**
* Advanced argument parser for console commands
*
* Supports:
* - Long options: --option=value, --option value, --flag
* - Short options: -o value, -f, -abc (combined flags)
* - Positional arguments
* - Type validation and casting
* - Required/optional arguments
*/
final readonly class ArgumentParser
{
/** @param array<string, ArgumentDefinition> $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<string, ArgumentDefinition>
*/
public function getDefinitions(): array
{
return $this->definitions;
}
/**
* Create parser with fluent interface
*/
public static function create(): ArgumentParserBuilder
{
return new ArgumentParserBuilder();
}
}