diff --git a/composer.json b/composer.json index 867407c..b72fe5d 100644 --- a/composer.json +++ b/composer.json @@ -16,8 +16,8 @@ "require-dev": { "phpunit/phpunit": "^10.5", "phpstan/phpstan": "^2.1", - "phunkie/phpstan": "^1.0", - "friendsofphp/php-cs-fixer": "^3.90" + "friendsofphp/php-cs-fixer": "^3.90", + "phunkie/phpstan": "^1.0" }, "suggest": { "ext-parallel": "Required for parallel execution using threads. PHP must be compiled with ZTS support." @@ -50,7 +50,6 @@ "@test" ] }, - "minimum-stability": "dev", "prefer-stable": true, "config": { "bin-dir": "bin" diff --git a/docs/io-app.md b/docs/io-app.md index 3442b8b..1942df1 100644 --- a/docs/io-app.md +++ b/docs/io-app.md @@ -66,16 +66,21 @@ To create an IO application, extend the `IOApp` class and implement the `run` me ```php use Phunkie\Effect\IO\IOApp; -use function Phunkie\Effect\Functions\io\io; +use Phunkie\Effect\IO\IO; use function Phunkie\Effect\Functions\console\printLn; use const Phunkie\Effect\IOApp\ExitSuccess; class MyApp extends IOApp { + public function __construct() + { + parent::__construct("1.0.0"); // Optional version string + } + /** * @return IO */ - public function run(): IO + public function run(?array $args = []): IO { return printLn("Hello, Effects!") ->map(fn() => ExitSuccess); @@ -85,15 +90,175 @@ class MyApp extends IOApp The `run` method must return an `IO` that will be executed when the application starts. The return value of the IO will be used as the application's exit code. +### Version Support + +IOApp automatically provides `--version` and `-v` flags. You can specify your application version in the constructor: + +```php +class MyApp extends IOApp +{ + public function __construct() + { + parent::__construct("2.1.0"); + } + + // ... rest of implementation +} +``` + +Running with the version flag: +```bash +$ bin/phunkie MyApp.php --version +2.1.0 +``` + +## Command-Line Argument Parsing + +IOApp provides a powerful DSL for defining and parsing command-line arguments: + +```php +use Phunkie\Effect\IO\IOApp; +use Phunkie\Effect\IO\IO; +use function Phunkie\Effect\Functions\ioapp\arguments; +use function Phunkie\Effect\Functions\ioapp\option; +use const Phunkie\Effect\Functions\ioapp\Required; +use const Phunkie\Effect\Functions\ioapp\Optional; +use const Phunkie\Effect\Functions\ioapp\NoInput; +use const Phunkie\Effect\Functions\ioapp\Negatable; + +class MyApp extends IOApp +{ + protected function define(): Validation + { + return arguments( + option('f', 'file', 'Input file path', Required), + option('o', 'output', 'Output file path', Optional), + option('v', 'verbose', 'Enable verbose output', NoInput), + option('c', 'color', 'Enable colored output', Negatable) + ); + } + + public function run(?array $args = []): IO + { + return $this->parse($args) + ->flatMap(fn($options) => $this->processOptions($options)); + } + + private function processOptions($options): IO + { + return new IO(function() use ($options) { + // Access parsed options + $file = $options->fetch('file')->getOrElse('default.txt'); + $verbose = $options->has('verbose'); + + // Access positional arguments + $positionalArgs = $options->args; + + // Your application logic here + // ... + + return ExitSuccess; + }); + } +} +``` + +### Option Formats + +- **Required**: Option must have a value (`-f file.txt` or `--file=file.txt`) +- **Optional**: Option may have a value, defaults to `true` if present without value +- **NoInput**: Flag option, no value expected (`-v` or `--verbose`) +- **Negatable**: Can be negated with `no-` prefix (`--color` or `--no-color`) + +### Accessing Parsed Options + +```php +// Check if option exists +if ($options->has('verbose')) { + // ... +} + +// Fetch option value (returns Either) +$options->fetch('file')->fold( + fn($error) => printLn("File not specified"), + fn($input) => printLn("File: " . $input->value) +); + +// Get with default +$file = $options->fetch('file')->getOrElse('default.txt'); + +// Access positional arguments +$files = $options->args; // Array of non-option arguments +``` + +### Built-in Options + +IOApp automatically provides and handles: +- `-h, --help`: Display usage information and exit +- `-v, --version`: Display application version and exit + +These flags are automatically handled by `parse()`, which will display the appropriate information and exit the process. Your `run()` method only receives control if the user provides valid arguments without help/version flags: + +```php +use const Phunkie\Effect\IOApp\ExitSuccess; + +public function run(?array $args = []): IO +{ + return $this->parse($args) + ->flatMap(fn($options) => $this->runApp($options)); +} + +private function runApp($options): IO +{ + // Your application logic here - only called with valid options + return new IO(function() use ($options) { + $verbose = $options->has('verbose'); + // ... your code + return ExitSuccess; + }); +} +``` + +The `parse()` method handles all error cases internally: +- Invalid arguments → shows usage and exits with code 1 +- `--help` or `-h` → shows usage and exits with code 1 +- `--version` or `-v` → shows version and exits with code 0 +- Valid arguments → returns `IO` for your application to process + ## Running with IO Console -Phunkie Effects provides a console application to run your IO apps. After installing the Phunkie console, you can run your application using: +Phunkie Effects provides a console application to run your IO apps in multiple ways: + +### Running IOApp Classes + +You can run an IOApp by passing a file that defines the class: ```bash -$ bin/phunkie MyApp +$ bin/phunkie MyApp.php Hello, Effects! ``` +Simply define your IOApp class in the file - no need to explicitly return an instance: + +```php +map(fn() => 0); + } +} +``` + The console will: 1. Load your application class 2. Execute the `run` method @@ -102,122 +267,227 @@ The console will: ## Exit Codes and Error Handling -IOApp provides a way to handle errors and return appropriate exit codes: +### Exit Codes + +IOApp provides standard exit code constants for common scenarios: + +```php +use const Phunkie\Effect\IOApp\ExitSuccess; // 0 +use const Phunkie\Effect\IOApp\ExitFailure; // 1 +use const Phunkie\Effect\IOApp\ExitMisuse; // 2 +use const Phunkie\Effect\IOApp\ExitCannotExec; // 126 +use const Phunkie\Effect\IOApp\ExitNotFound; // 127 +use const Phunkie\Effect\IOApp\ExitInvalid; // 128 +use const Phunkie\Effect\IOApp\ExitInterrupted; // 130 +``` + +Your `run()` method should return an `IO` where the integer is the exit code: + +```php +public function run(?array $args = []): IO +{ + return $this->parse($args) + ->flatMap(fn($options) => $this->processFile($options)) + ->map(fn() => ExitSuccess); // Return 0 on success +} +``` + +### Error Handling with handleError + +Use `handleError()` to catch exceptions and return appropriate exit codes: ```php use Phunkie\Effect\IO\IOApp; -use function Phunkie\Effect\Functions\io\io; -use function Phunkie\Effect\Functions\console\printError; -use const Phunkie\Effect\IOApp\ExitSuccess; -use const Phunkie\Effect\IOApp\ExitFailure; +use Phunkie\Effect\IO\IO; +use function Phunkie\Effect\Functions\console\{printLn, printError}; +use const Phunkie\Effect\IOApp\{ExitSuccess, ExitFailure}; -class MyApp extends IOApp +class FileProcessor extends IOApp { - /** - * @return IO - */ - public function run(): IO - { - return io(function() { - try { - // Your application logic here - return ExitSuccess; - } catch (\Exception $e) { - return printError($e->getMessage()) + public function run(?array $args = []): IO + { + return $this->parse($args) + ->flatMap(fn($options) => $this->processFile($options)) + ->handleError(function($error) { + return printError("Error: " . $error->getMessage()) ->map(fn() => ExitFailure); + }); + } + + private function processFile($options): IO + { + return new IO(function() use ($options) { + $file = $options->fetch('file')->get()->value; + + if (!file_exists($file)) { + throw new \RuntimeException("File not found: $file"); } + + // Process file... + printLn("Processed: $file")->unsafeRun(); + + return ExitSuccess; }); } } ``` +### Try-Catch Within IO + +For more granular error handling, use try-catch inside your IO: + +```php +private function processFile($options): IO +{ + return new IO(function() use ($options) { + try { + $file = $options->fetch('file')->get()->value; + + // Risky operation + $content = file_get_contents($file); + if ($content === false) { + throw new \RuntimeException("Failed to read file"); + } + + // Process content... + printSuccess("File processed successfully")->unsafeRun(); + return ExitSuccess; + + } catch (\RuntimeException $e) { + printError($e->getMessage())->unsafeRun(); + return ExitFailure; + } catch (\Exception $e) { + printError("Unexpected error: " . $e->getMessage())->unsafeRun(); + return ExitInvalid; + } + }); +} +``` + +### Validation Errors + +Argument parsing errors are handled automatically by `parse()` - it will show usage and exit with code 1. You don't need to handle these yourself. + ## Best Practices -1. **Keep it Pure**: The `run` method should return an IO without side effects. All side effects should be wrapped in IO. +1. **Use the Argument DSL**: Define your CLI interface with `define()` and let IOApp handle parsing and validation. -2. **Error Handling**: Use proper error handling and return meaningful exit codes. +2. **Automatic Error Handling**: The `parse()` method automatically handles errors, help, and version flags - you just focus on your application logic. -3. **Resource Management**: Use bracket or resource patterns to manage resources properly. +3. **Keep it Pure**: The `run` method should return an IO without side effects. All side effects should be wrapped in IO. 4. **Composition**: Break down your application into smaller, composable IOs. -Example of a well-structured IOApp: +Example of a well-structured IOApp with argument parsing: ```php use Phunkie\Effect\IO\IOApp; -use function Phunkie\Effect\Functions\io\io; -use function Phunkie\Effect\Functions\console\printLn; -use function Phunkie\Effect\Functions\console\printError; -use function Phunkie\Effect\Functions\console\printSuccess; -use const Phunkie\Effect\IOApp\ExitSuccess; -use const Phunkie\Effect\IOApp\ExitFailure; - -class MyApp extends IOApp +use Phunkie\Effect\IO\IO; +use Phunkie\Validation\Validation; +use function Phunkie\Effect\Functions\ioapp\arguments; +use function Phunkie\Effect\Functions\ioapp\option; +use function Phunkie\Effect\Functions\console\{printLn, printError, printSuccess}; +use const Phunkie\Effect\Functions\ioapp\{Required, Optional, NoInput}; + +class DatabaseApp extends IOApp { - /** - * @return IO - */ - public function run(): IO - { - return io(function() { - try { - $config = $this->loadConfig(); - $db = $this->connectToDatabase($config); - - return $this->runApplication($db) - ->flatMap(function($result) use ($db) { - return printSuccess("Operation completed") - ->map(function() use ($db) { - $this->cleanup($db); - return ExitSuccess; - }); - }) - ->handleError(function($error) { - return printError($error->getMessage()) - ->map(fn() => ExitFailure); - }) - ->unsafeRun(); - } catch (\Exception $e) { - return printError("Fatal error: " . $e->getMessage()) - ->map(fn() => ExitFailure); - } - }); + public function __construct() + { + parent::__construct("1.0.0"); + } + + protected function define(): Validation + { + return arguments( + option('h', 'host', 'Database host', Optional), + option('p', 'port', 'Database port', Optional), + option('d', 'database', 'Database name', Required), + option('v', 'verbose', 'Verbose output', NoInput) + // Note: This overrides -v for verbose, but --version is still available + ); } - private function loadConfig(): IO + public function run(?array $args = []): IO { - return io(function() { - // Load configuration - return ['host' => 'localhost', 'port' => 5432]; + return $this->parse($args)->flatMap(fn($options) => $this->runApp($options)); + } + + private function runApp($options): IO + { + return $this->loadConfig($options) + ->flatMap(fn($config) => $this->connectToDatabase($config)) + ->flatMap(fn($db) => $this->runQueries($db, $options)) + ->flatMap(fn($result) => printSuccess("Operation completed")) + ->map(fn() => 0) + ->handleError(function($error) { + return printError("Error: " . $error->getMessage()) + ->map(fn() => 1); + }); + } + + private function loadConfig($options): IO + { + return new IO(function() use ($options) { + return [ + 'host' => $options->fetch('host')->getOrElse('localhost'), + 'port' => $options->fetch('port')->getOrElse('5432'), + 'database' => $options->fetch('database')->get()->value, + 'verbose' => $options->has('verbose') + ]; }); } private function connectToDatabase(array $config): IO { - return io(function() use ($config) { - // Connect to database + return new IO(function() use ($config) { + if ($config['verbose']) { + printLn("Connecting to {$config['host']}:{$config['port']}") + ->unsafeRun(); + } + // Simulate database connection return new Database($config); }); } - private function runApplication(Database $db): IO + private function runQueries(Database $db, $options): IO { - return io(function() use ($db) { - // Run application logic - return $db->query("SELECT * FROM users"); + return new IO(function() use ($db, $options) { + $verbose = $options->has('verbose'); + + if ($verbose) { + printLn("Running queries...")->unsafeRun(); + } + + $result = $db->query("SELECT * FROM users"); + + if ($verbose) { + printLn("Found " . count($result) . " users")->unsafeRun(); + } + + return $result; }); } +} - private function cleanup(Database $db): void +class Database +{ + public function __construct(private array $config) {} + + public function query(string $sql): array { - $db->close(); + // Simulate query + return [['id' => 1, 'name' => 'John']]; } } ``` -This example shows: -- Proper error handling with console functions -- Resource management -- Composition of IOs +This example demonstrates: +- Version support via constructor +- Comprehensive argument parsing with multiple option types +- Automatic error/help/version handling by `parse()` +- Simple `parse()->flatMap()` pattern for application logic +- Composition of IOs with `flatMap` +- Accessing parsed options with `fetch()` and `has()` +- Verbose mode controlled by command-line flag (overrides `-v` but `--version` remains) - Clean separation of concerns -- Meaningful exit codes \ No newline at end of file +- Error handling with `handleError()` \ No newline at end of file diff --git a/src/Functions/ioapp.php b/src/Functions/ioapp.php new file mode 100644 index 0000000..ab32252 --- /dev/null +++ b/src/Functions/ioapp.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Phunkie\Effect\Functions\ioapp; + +use Phunkie\Effect\IO\IOApp\Error; +use Phunkie\Effect\IO\IOApp\OptionDefinition; +use Phunkie\Effect\IO\IOApp\OptionFormat; +use Phunkie\Effect\IO\IOApp\Options; +use Phunkie\Types\Either; +use Phunkie\Validation\Validation; + +const arguments = "\\Phunkie\\Effect\\Functions\\ioapp\\arguments"; +const option = "\\Phunkie\\Effect\\Functions\\ioapp\\option"; + +const Optional = OptionFormat::Optional; +const Required = OptionFormat::Required; +const Negatable = OptionFormat::Negatable; +const ArrayValues = OptionFormat::ArrayValues; +const NoInput = OptionFormat::NoInput; + +function option(string $p1, string|null $p2 = null, string|OptionFormat|null $p3 = null, OptionFormat $p4 = OptionFormat::Optional): Either +{ + try { + return Right(new OptionDefinition($p1, $p2, $p3, $p4)); + } catch (\Throwable $e) { + return Left(new Error($e->getMessage())); + } +} + +function arguments(Either ...$definitions): Validation +{ + /** @var Error[] $failures */ + $failures = []; + /** @var OptionDefinition[] $validDefinitions */ + $validDefinitions = []; + + foreach ($definitions as $d) { + if ($d->isLeft()) { + $failures[] = $d->get(); + } else { + $validDefinitions[] = $d->get(); + } + } + + if (count($failures) > 0) { + return Failure(Nel(...$failures)); + } + + return Success(Options::create(...$validDefinitions)); +} diff --git a/src/IO/IOApp.php b/src/IO/IOApp.php index b57055d..76eac19 100644 --- a/src/IO/IOApp.php +++ b/src/IO/IOApp.php @@ -11,15 +11,52 @@ namespace Phunkie\Effect\IO; +use function Phunkie\Effect\Functions\ioapp\arguments; +use function Phunkie\Effect\Functions\ioapp\option; + +use Phunkie\Effect\IO\IOApp\Error; +use Phunkie\Effect\IO\IOApp\Options; +use Phunkie\Effect\IO\IOApp\ParsedOptions; +use Phunkie\Types\NonEmptyList; +use Phunkie\Validation\Validation; + /** * Base class for IO applications. * * Extend this class to create your own IO application. * The run method must return an IO that will be executed when the application starts. * The return value of the IO will be used as the application's exit code. + * + * Example Usage: + * ```php + * class MyApp extends IOApp + * { + * public function define(): Validation + * { + * return arguments( + * option('f', 'file', 'Input file', Required), + * option('v', 'verbose', 'Verbose output', NoInput) + * ); + * } + * + * public function run(?array $args = []): IO + * { + * return io(function() use ($args) { + * $this->parse($args)->fold( + * fn($errors) => $this->handleErrors($errors), + * fn($opts) => $this->program($opts) + * ); + * }); + * } + * } + * ``` */ abstract class IOApp { + public function __construct(private string $version = "0.0.1") + { + } + /** * The main entry point for the application. * @@ -30,4 +67,106 @@ abstract class IOApp * @return IO The exit code (0 for success, non-zero for failure) */ abstract public function run(?array $args = []): IO; + + /** + * Defines the CLI arguments for the application. + * Override this method to provide your own definitions. + * + * @return Validation, Options> + */ + protected function define(): Validation + { + return arguments(); + } + + /** + * Parses CLI arguments and handles errors, help, and version flags automatically. + * + * This method will exit the process if: + * - There are parsing errors (shows usage and exits with code 1) + * - --help/-h flag is present (shows usage and exits with code 1) + * - --version/-v flag is present (shows version and exits with code 0) + * + * Otherwise, returns an IO containing the parsed options. + * + * Example: + * ```php + * public function run(?array $args = []): IO + * { + * return $this->parse($args) + * ->flatMap(fn($options) => $this->runApp($options)); + * } + * ``` + * + * @param array $args The raw arguments (usually $argv) + * @return IO IO that resolves to parsed options + */ + protected function parse(array $args): IO + { + return new IO(function () use ($args) { + $validation = $this->define() + ->flatMap(fn ($options) => $options->parse($args)); + + $validation->fold( + fn ($errors) => exit($this->showUsage($errors)->unsafeRun()) + )( + fn ($options) => match(true) { + $options->has('help') => exit($this->showUsage()->unsafeRun()), + $options->has('version') => exit($this->showVersion()->unsafeRun()), + default => null + } + ); + + // If we reach here, validation was Success and no help/version flags + return $validation->toOption()->get(); + }); + } + + /** + * Low-level parse that returns Validation for advanced use cases. + * Most users should use parse() instead. + * + * @param array $args The raw arguments (usually $argv) + * @return Validation, ParsedOptions> + */ + protected function parseValidation(array $args): Validation + { + return $this->define() + ->flatMap(fn ($options) => $options->parse($args)); + } + + protected function showErrors(?NonEmptyList $errors = null): IO + { + return new IO(function () use ($errors) { + if ($errors) { + $errorMessages = $errors->map(fn (Error $e) => $e->message)->mkString(", "); + fwrite(STDERR, "Error: " . $errorMessages . "\r\n\r\n"); + } + }); + } + + protected function showVersion(): IO + { + return new IO(function () { + echo $this->version . "\r\n"; + + return 0; + }); + } + + protected function showUsage(?NonEmptyList $errors = null): IO + { + return $this->showErrors($errors)->flatMap(function () { + return new IO(function () { + echo "Usage: application [options]\r\n\r\n"; + + $this->define() + ->map(function (Options $options) { + echo $options->describe(); + }); + + return 1; + }); + }); + } } diff --git a/src/IO/IOApp/Error.php b/src/IO/IOApp/Error.php new file mode 100644 index 0000000..dc24f05 --- /dev/null +++ b/src/IO/IOApp/Error.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Phunkie\Effect\IO\IOApp; + +/** + * Represents an error during option parsing. + */ +class Error +{ + public function __construct(public readonly string $message) + { + } +} diff --git a/src/IO/IOApp/Input.php b/src/IO/IOApp/Input.php new file mode 100644 index 0000000..dd85791 --- /dev/null +++ b/src/IO/IOApp/Input.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Phunkie\Effect\IO\IOApp; + +/** + * Represents a parsed input value. + */ +class Input +{ + public function __construct(public readonly mixed $value) + { + } +} diff --git a/src/IO/IOApp/OptionDefinition.php b/src/IO/IOApp/OptionDefinition.php new file mode 100644 index 0000000..fd70220 --- /dev/null +++ b/src/IO/IOApp/OptionDefinition.php @@ -0,0 +1,85 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Phunkie\Effect\IO\IOApp; + +/** + * Defines a command line option. + */ +class OptionDefinition +{ + public readonly ?string $short; + public readonly ?string $long; + public readonly string $description; + public readonly OptionFormat $format; + + public function __construct(string $p1, string|null $p2 = null, string|OptionFormat|null $p3 = null, OptionFormat $p4 = OptionFormat::Optional) + { + $short = null; + $long = null; + $description = ''; + $format = OptionFormat::Optional; + + if ($p3 instanceof OptionFormat) { + $format = $p3; + $description = $p2 ?? ''; + + if (strlen($p1) === 1) { + $short = $p1; + } else { + $long = $p1; + } + } elseif (is_string($p3)) { + $short = $p1; + $long = $p2; + $description = $p3; + $format = $p4; + } else { + if ($p2 !== null && str_contains($p2, ' ')) { + $description = $p2; + if (strlen($p1) === 1) { + $short = $p1; + } else { + $long = $p1; + } + } else { + $short = $p1; + $long = $p2; + } + } + + if ($short === null && $long === null) { + throw new \InvalidArgumentException("At least one of short or long name must be provided"); + } + + $this->short = $short; + $this->long = $long; + $this->description = $description; + $this->format = $format; + } + + public function describe(): string + { + $parts = []; + if ($this->short) { + $parts[] = "-" . $this->short; + } + if ($this->long) { + $parts[] = "--" . $this->long; + } + $flags = implode(', ', $parts); + if ($this->format === OptionFormat::Required || $this->format === OptionFormat::Optional) { + $flags .= " "; + } + + return sprintf(" %-25s %s", $flags, $this->description); + } +} diff --git a/src/IO/IOApp/OptionFormat.php b/src/IO/IOApp/OptionFormat.php new file mode 100644 index 0000000..24a2238 --- /dev/null +++ b/src/IO/IOApp/OptionFormat.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Phunkie\Effect\IO\IOApp; + +/** + * Enumeration of possible formats for command line options. + */ +enum OptionFormat +{ + case Optional; + case Required; + case Negatable; + case ArrayValues; + case NoInput; +} diff --git a/src/IO/IOApp/Options.php b/src/IO/IOApp/Options.php new file mode 100644 index 0000000..d14b023 --- /dev/null +++ b/src/IO/IOApp/Options.php @@ -0,0 +1,277 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Phunkie\Effect\IO\IOApp; + +use function Failure; +use function Nel; + +use Phunkie\Validation\Validation; + +use function Success; + +/** + * Registry of option definitions and the main parser. + */ +class Options +{ + private $definitions = []; + + private function __construct(array $definitions = []) + { + $this->definitions = $definitions; + } + + public static function create(OptionDefinition ...$definitions): self + { + $hasHelp = false; + $hasVersion = false; + foreach ($definitions as $def) { + if ($def->short === 'h' || $def->long === 'help') { + $hasHelp = true; + } + if ($def->short === 'v' || $def->long === 'version') { + $hasVersion = true; + } + } + + if (! $hasHelp) { + $definitions[] = new OptionDefinition('h', 'help', 'Display this help message', OptionFormat::NoInput); + } + + if (! $hasVersion) { + $definitions[] = new OptionDefinition('v', 'version', 'Display version', OptionFormat::NoInput); + } + + return new self($definitions); + } + + public function add(OptionDefinition $definition): self + { + $definitions = $this->definitions; + $definitions[] = $definition; + + return new self($definitions); + } + + public function remove(string $name): self + { + $definitions = array_values(array_filter($this->definitions, function (OptionDefinition $def) use ($name) { + return $def->short !== $name && $def->long !== $name; + })); + + return new self($definitions); + } + + public function describe(): string + { + $output = "Options:\n"; + foreach ($this->definitions as $def) { + $output .= $def->describe() . "\n"; + } + + return $output; + } + + public function parse(array $args): Validation + { + if (isset($args[0]) && ! str_starts_with($args[0], '-')) { + array_shift($args); + } + + $parsed = []; + $errors = []; + $positionalArgs = []; + $count = count($args); + + for ($i = 0; $i < $count; $i++) { + $arg = $args[$i]; + + if ($arg === '--') { + for ($j = $i + 1; $j < $count; $j++) { + $positionalArgs[] = $args[$j]; + } + + break; + } + + if (str_starts_with($arg, '--')) { + $err = $this->parseLongOption($arg, $args, $i, $parsed); + if ($err) { + $errors[] = $err; + } + + continue; + } + + if (str_starts_with($arg, '-') && strlen($arg) > 1) { + $errs = $this->parseShortOptions($arg, $args, $i, $parsed); + if ($errs) { + $errors = array_merge($errors, $errs); + } + + continue; + } + + $positionalArgs[] = $arg; + } + + if (count($errors) > 0) { + return Failure(Nel(...$errors)); + } + + return Success(new ParsedOptions($parsed, $positionalArgs)); + } + + private function parseLongOption(string $arg, array &$args, int &$i, array &$parsed): ?Error + { + $name = substr($arg, 2); + $value = null; + + if (str_contains($name, '=')) { + [$name, $value] = explode('=', $name, 2); + } + + $def = $this->findDefByLong($name); + if (! $def) { + return new Error("Unknown option: --$name"); + } + + $key = $def->long ?? $def->short; + $parsed[$key] = $this->resolveValue($def, $value, $args, $i, $name); + + return null; + } + + private function parseShortOptions(string $arg, array &$args, int &$i, array &$parsed): array + { + $chars = str_split(substr($arg, 1)); + $len = count($chars); + $errors = []; + + // Standard bundled short options parsing logic + for ($j = 0; $j < $len; $j++) { + $char = $chars[$j]; + $def = $this->findDefByShort($char); + + if (! $def) { + $errors[] = new Error("Unknown option: -$char"); + + continue; + } + + $key = $def->long ?? $def->short; + + // Check if this option expects a value + if ($def->format === OptionFormat::Required || $def->format === OptionFormat::Optional) { + // If there are more chars in this group, they are the value + if ($j + 1 < $len) { + $value = substr($arg, $j + 2); + $parsed[$key] = $this->resolveValue($def, substr($arg, $j + 2), $args, $i, $char); + + break; + } else { + $parsed[$key] = $this->resolveValue($def, null, $args, $i, $char); + + break; + } + } else { + $parsed[$key] = $this->resolveValue($def, null, $args, $i, $char); + } + } + + return $errors; + } + + private function resolveValue(OptionDefinition $def, ?string $explicitValue, array &$args, int &$i, string $name): mixed + { + if ($def->format === OptionFormat::NoInput) { + return true; + } + + if ($def->format === OptionFormat::Negatable) { + // If name starts with no-, return false. Else true. + return ! str_starts_with($name, 'no-'); + } + + if ($def->format === OptionFormat::ArrayValues) { + return []; + } + + // Required or Optional + if ($explicitValue !== null) { + return $explicitValue; + } + + // consume next arg + if (isset($args[$i + 1]) && ! str_starts_with($args[$i + 1], '-')) { + $i++; + + return $args[$i]; + } + + if ($def->format === OptionFormat::Required) { + return null; + } + + return true; + } + + private function findDefByLong(string $name): ?OptionDefinition + { + foreach ($this->definitions as $def) { + if ($def->long === $name) { + return $def; + } + + // Check for negatable --no-name match + if ($def->format === OptionFormat::Negatable && str_starts_with($name, 'no-') && $def->long === substr($name, 3)) { + return $def; + } + } + + return null; + } + + private function findDefByShort(string $char): ?OptionDefinition + { + foreach ($this->definitions as $def) { + if ($def->short === $char) { + return $def; + } + } + + return null; + } + + public function hasOption(string $query, array $args): bool + { + return $this->parse($args)->fold( + fn ($errors) => false + )( + fn ($parsed) => $this->checkOption($query, $parsed) + ); + } + + private function checkOption(string $query, ParsedOptions $parsed): bool + { + foreach ($this->definitions as $d) { + if ($d->short === $query || $d->long === $query) { + // Determine the key used in parsed + $targetKey = $d->long ?? $d->short; + + return $parsed->has($targetKey); + } + } + + return false; + } +} diff --git a/src/IO/IOApp/ParsedOptions.php b/src/IO/IOApp/ParsedOptions.php new file mode 100644 index 0000000..8ce7bed --- /dev/null +++ b/src/IO/IOApp/ParsedOptions.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Phunkie\Effect\IO\IOApp; + +use Phunkie\Types\Either; + +/** + * Result of the options parsing process. + */ +class ParsedOptions +{ + /** + * @param array $values + */ + public function __construct(private readonly array $values, public readonly array $args = []) + { + } + + /** + * @return Either + */ + public function fetch(string $option): Either + { + if (array_key_exists($option, $this->values)) { + return Right(new Input($this->values[$option])); + } + + return Left(new Error("Option $option not found")); + } + + public function has(string $option): bool + { + return array_key_exists($option, $this->values); + } + + public function isEmpty(): bool + { + return empty($this->values); + } +} diff --git a/tests/Unit/IO/IOApp/OptionsTest.php b/tests/Unit/IO/IOApp/OptionsTest.php new file mode 100644 index 0000000..e027242 --- /dev/null +++ b/tests/Unit/IO/IOApp/OptionsTest.php @@ -0,0 +1,122 @@ +assertTrue($validation->isRight()); + $this->assertInstanceOf(Options::class, $validation->toOption()->get()); + } + + public function test_it_parses_short_flags() + { + $options = arguments( + option('v', 'verbose', 'Verbose', NoInput), + option('d', 'debug', 'Debug', NoInput) + )->toOption()->get(); + + $parsed = $options->parse(['-v', '-d'])->getOrElse(null); + + $this->assertTrue($parsed->has('verbose')); + $this->assertTrue($parsed->has('debug')); + } + + public function test_it_parses_bundled_short_flags() + { + $options = arguments( + option('a', 'all', '', NoInput), + option('l', 'long', '', NoInput) + )->toOption()->get(); + + $parsed = $options->parse(['-al'])->getOrElse(null); + + $this->assertTrue($parsed->has('all')); + $this->assertTrue($parsed->has('long')); + } + + public function test_it_parses_required_values() + { + $options = arguments( + option('f', 'file', 'File', Required) + )->toOption()->get(); + + $parsed = $options->parse(['-f', 'test.txt'])->getOrElse(null); + + $this->assertTrue($parsed->has('file')); + $this->assertEquals('test.txt', $parsed->fetch('file')->get()->value); + } + + public function test_it_parses_bundled_value_in_short_flag() + { + $options = arguments( + option('p', 'port', 'Port', Required) + )->toOption()->get(); + + $parsed = $options->parse(['-p8080'])->getOrElse(null); + + $this->assertEquals('8080', $parsed->fetch('port')->get()->value); + } + + public function test_it_parses_long_flags_with_equals() + { + $options = arguments( + option('n', 'name', 'Name', Required) + )->toOption()->get(); + + $parsed = $options->parse(['--name=John'])->getOrElse(null); + + $this->assertEquals('John', $parsed->fetch('name')->get()->value); + } + + public function test_it_parses_negatable_options() + { + $options = arguments( + option('c', 'color', 'Enable color', Negatable) + )->toOption()->get(); + + $parsedEnable = $options->parse(['--color'])->getOrElse(null); + $this->assertEquals(true, $parsedEnable->fetch('color')->get()->value); + + $parsedDisable = $options->parse(['--no-color'])->getOrElse(null); + $this->assertEquals(false, $parsedDisable->fetch('color')->get()->value); + } + + public function test_dsl_polymorphic_add() + { + $options = arguments( + option('h', 'help', 'Help'), // 3 args standard + option('verify', 'Verify', Negatable), // 3 args, 3rd is format + option('silent', 'Be silent') // 2 args + )->toOption()->get(); + + // Test verify logic + $parsed = $options->parse(['--no-verify'])->getOrElse(null); + $this->assertEquals(false, $parsed->fetch('verify')->get()->value); + + // Test silent logic (Optional default) + $parsedSilent = $options->parse(['--silent'])->getOrElse(null); + $this->assertEquals(true, $parsedSilent->fetch('silent')->get()->value); + } +}