From a6c45a1d1ba7485ce5d79f1d56c441ced491a1b3 Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Wed, 17 Dec 2025 17:10:32 +0000 Subject: [PATCH 01/20] feat: Implement IOApp CLI Argument DSL --- composer.json | 8 +- src/Functions/ioapp.php | 50 +++++++ src/IO/IOApp.php | 64 ++++++++ src/IO/IOApp/Error.php | 10 ++ src/IO/IOApp/Input.php | 10 ++ src/IO/IOApp/OptionDefinition.php | 73 +++++++++ src/IO/IOApp/OptionFormat.php | 12 ++ src/IO/IOApp/Options.php | 221 ++++++++++++++++++++++++++++ src/IO/IOApp/ParsedOptions.php | 37 +++++ tests/Unit/IO/IOApp/OptionsTest.php | 125 ++++++++++++++++ 10 files changed, 605 insertions(+), 5 deletions(-) create mode 100644 src/Functions/ioapp.php create mode 100644 src/IO/IOApp/Error.php create mode 100644 src/IO/IOApp/Input.php create mode 100644 src/IO/IOApp/OptionDefinition.php create mode 100644 src/IO/IOApp/OptionFormat.php create mode 100644 src/IO/IOApp/Options.php create mode 100644 src/IO/IOApp/ParsedOptions.php create mode 100644 tests/Unit/IO/IOApp/OptionsTest.php diff --git a/composer.json b/composer.json index 38cebd2..18ea4d3 100644 --- a/composer.json +++ b/composer.json @@ -10,15 +10,13 @@ } ], "require": { - "php": ">=8.2", - "phunkie/phunkie": "^0.11" + "php": "^8.2 || ^8.3 || ^8.4", + "phunkie/phunkie": "^1.0" }, "require-dev": { "phpunit/phpunit": "^10.5", - "phunkie/phunkie-console": "dev-master", "phpstan/phpstan": "^2.1", "friendsofphp/php-cs-fixer": "^3.75" - }, "suggest": { "ext-parallel": "Required for parallel execution using threads. PHP must be compiled with ZTS support." @@ -47,4 +45,4 @@ "config": { "bin-dir": "bin" } -} +} \ No newline at end of file diff --git a/src/Functions/ioapp.php b/src/Functions/ioapp.php new file mode 100644 index 0000000..e6eaf9b --- /dev/null +++ b/src/Functions/ioapp.php @@ -0,0 +1,50 @@ +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 ac0c177..493a4af 100644 --- a/src/IO/IOApp.php +++ b/src/IO/IOApp.php @@ -11,12 +11,45 @@ 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 { @@ -24,4 +57,35 @@ abstract class IOApp * @return IO */ 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 the CLI arguments based on the definitions from define(). + * + * Example: + * ```php + * $result = $this->parse($argv); + * $result->fold( + * fn($errors) => echo "Invalid arguments", // $errors is NonEmptyList + * fn($options) => $options->has('verbose') // $options is ParsedOptions + * ); + * ``` + * + * @param array $args The raw arguments (usually $argv) + * @return Validation, ParsedOptions> + */ + protected function parse(array $args): Validation + { + return $this->define()->map(fn ($options) => $options->parse($args)); + } } diff --git a/src/IO/IOApp/Error.php b/src/IO/IOApp/Error.php new file mode 100644 index 0000000..666c481 --- /dev/null +++ b/src/IO/IOApp/Error.php @@ -0,0 +1,10 @@ +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..61bf2c8 --- /dev/null +++ b/src/IO/IOApp/OptionFormat.php @@ -0,0 +1,12 @@ +definitions = $definitions; + } + + public static function create(OptionDefinition ...$definitions): self + { + $hasHelp = false; + foreach ($definitions as $def) { + if ($def->short === 'h' || $def->long === 'help') { + $hasHelp = true; + + break; + } + } + + if (! $hasHelp) { + $definitions[] = new OptionDefinition('h', 'help', 'Display this help message', 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): ParsedOptions + { + if (isset($args[0]) && ! str_starts_with($args[0], '-')) { + array_shift($args); + } + + $parsed = []; + $i = 0; + $count = count($args); + + for ($i = 0; $i < $count; $i++) { + $arg = $args[$i]; + + if ($arg === '--') { + break; + } + + if (str_starts_with($arg, '--')) { + $this->parseLongOption($arg, $args, $i, $parsed); + + continue; + } + + if (str_starts_with($arg, '-') && strlen($arg) > 1) { + $this->parseShortOptions($arg, $args, $i, $parsed); + + continue; + } + } + + return new ParsedOptions($parsed); + } + + private function parseLongOption(string $arg, array &$args, int &$i, array &$parsed): void + { + $name = substr($arg, 2); + $value = null; + + if (str_contains($name, '=')) { + [$name, $value] = explode('=', $name, 2); + } + + $def = $this->findDefByLong($name); + if (! $def) { + return; + } + + $key = $def->long ?? $def->short; + $parsed[$key] = $this->resolveValue($def, $value, $args, $i, $name); + } + + private function parseShortOptions(string $arg, array &$args, int &$i, array &$parsed): void + { + $chars = str_split(substr($arg, 1)); + $len = count($chars); + + // Standard bundled short options parsing logic + for ($j = 0; $j < $len; $j++) { + $char = $chars[$j]; + $def = $this->findDefByShort($char); + + if (! $def) { + 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); + } + } + } + + 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 + { + $parsed = $this->parse($args); + + 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..66e24ee --- /dev/null +++ b/src/IO/IOApp/ParsedOptions.php @@ -0,0 +1,37 @@ + $values + */ + public function __construct(private readonly array $values) + { + } + + /** + * @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..24c86e0 --- /dev/null +++ b/tests/Unit/IO/IOApp/OptionsTest.php @@ -0,0 +1,125 @@ +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']); + + $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']); + + $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']); + + $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']); + + $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']); + + $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(); + + // Default absence check - actually fetch returns failure if not present, but parsed has() should be false + // Wait, if it's negatable, usually presence implies true/false? + + $parsedEnable = $options->parse(['--color']); + $this->assertEquals(true, $parsedEnable->fetch('color')->get()->value); + + $parsedDisable = $options->parse(['--no-color']); + $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']); + $this->assertEquals(false, $parsed->fetch('verify')->get()->value); + + // Test silent logic (Optional default) + $parsedSilent = $options->parse(['--silent']); + $this->assertEquals(true, $parsedSilent->fetch('silent')->get()->value); + } +} From 0232f9e4c15ce224e5210b715a8853b57019cc33 Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Wed, 17 Dec 2025 17:19:32 +0000 Subject: [PATCH 02/20] feat: Add showUsage method to IOApp --- src/IO/IOApp.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/IO/IOApp.php b/src/IO/IOApp.php index 493a4af..6943d2f 100644 --- a/src/IO/IOApp.php +++ b/src/IO/IOApp.php @@ -88,4 +88,22 @@ protected function parse(array $args): Validation { return $this->define()->map(fn ($options) => $options->parse($args)); } + + protected function showUsage(?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 . "\n\n"); + } + + echo "Usage: application [options]\n\n"; + + $this->define()->map(function (Options $options) { + echo $options->describe(); + }); + + return 1; + }); + } } From 30a695bd6c374918d02800e9bb78f712e6e3ed58 Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Wed, 17 Dec 2025 19:19:52 +0000 Subject: [PATCH 03/20] feat(IOApp): support version flag and implicit runner execution - Add constructor to IOApp accepting version string - Implement showVersion() and showErrors() methods - Auto-inject --version/-v option in Options::create() - Support positional arguments in ParsedOptions - Enable running IOApp from file without explicit return - Update parse() to return Validation for better error handling - Use \r\n for cross-platform line endings --- src/IO/IOApp.php | 38 +++++++++++++---- src/IO/IOApp/Options.php | 66 ++++++++++++++++++++++++----- src/IO/IOApp/ParsedOptions.php | 2 +- tests/Unit/IO/IOApp/OptionsTest.php | 21 ++++----- 4 files changed, 95 insertions(+), 32 deletions(-) diff --git a/src/IO/IOApp.php b/src/IO/IOApp.php index 6943d2f..ec6b8f4 100644 --- a/src/IO/IOApp.php +++ b/src/IO/IOApp.php @@ -53,6 +53,10 @@ */ abstract class IOApp { + public function __construct(private string $version = "0.0.1") + { + } + /** * @return IO */ @@ -86,24 +90,42 @@ protected function define(): Validation */ protected function parse(array $args): Validation { - return $this->define()->map(fn ($options) => $options->parse($args)); + return $this->define() + ->flatMap(fn ($options) => $options->parse($args)); } - protected function showUsage(?NonEmptyList $errors = null): IO + 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 . "\n\n"); + fwrite(STDERR, "Error: " . $errorMessages . "\r\n\r\n"); } + }); + } - echo "Usage: application [options]\n\n"; + protected function showVersion(): IO + { + return new IO(function () { + echo $this->version . "\r\n"; - $this->define()->map(function (Options $options) { - echo $options->describe(); - }); + return 0; + }); + } - return 1; + 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/Options.php b/src/IO/IOApp/Options.php index 92409b0..7abe748 100644 --- a/src/IO/IOApp/Options.php +++ b/src/IO/IOApp/Options.php @@ -2,6 +2,13 @@ namespace Phunkie\Effect\IO\IOApp; +use function Failure; +use function Nel; + +use Phunkie\Validation\Validation; + +use function Success; + class Options { private $definitions = []; @@ -14,11 +21,13 @@ private function __construct(array $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; - - break; + } + if ($def->short === 'v' || $def->long === 'version') { + $hasVersion = true; } } @@ -26,6 +35,10 @@ public static function create(OptionDefinition ...$definitions): self $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); } @@ -56,40 +69,57 @@ public function describe(): string return $output; } - public function parse(array $args): ParsedOptions + public function parse(array $args): Validation { if (isset($args[0]) && ! str_starts_with($args[0], '-')) { array_shift($args); } $parsed = []; - $i = 0; + $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, '--')) { - $this->parseLongOption($arg, $args, $i, $parsed); + $err = $this->parseLongOption($arg, $args, $i, $parsed); + if ($err) { + $errors[] = $err; + } continue; } if (str_starts_with($arg, '-') && strlen($arg) > 1) { - $this->parseShortOptions($arg, $args, $i, $parsed); + $errs = $this->parseShortOptions($arg, $args, $i, $parsed); + if ($errs) { + $errors = array_merge($errors, $errs); + } continue; } + + $positionalArgs[] = $arg; } - return new ParsedOptions($parsed); + 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): void + private function parseLongOption(string $arg, array &$args, int &$i, array &$parsed): ?Error { $name = substr($arg, 2); $value = null; @@ -100,17 +130,20 @@ private function parseLongOption(string $arg, array &$args, int &$i, array &$par $def = $this->findDefByLong($name); if (! $def) { - return; + 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): void + 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++) { @@ -118,6 +151,8 @@ private function parseShortOptions(string $arg, array &$args, int &$i, array &$p $def = $this->findDefByShort($char); if (! $def) { + $errors[] = new Error("Unknown option: -$char"); + continue; } @@ -140,6 +175,8 @@ private function parseShortOptions(string $arg, array &$args, int &$i, array &$p $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 @@ -205,8 +242,15 @@ private function findDefByShort(string $char): ?OptionDefinition public function hasOption(string $query, array $args): bool { - $parsed = $this->parse($args); + 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 diff --git a/src/IO/IOApp/ParsedOptions.php b/src/IO/IOApp/ParsedOptions.php index 66e24ee..3d9c5d9 100644 --- a/src/IO/IOApp/ParsedOptions.php +++ b/src/IO/IOApp/ParsedOptions.php @@ -9,7 +9,7 @@ class ParsedOptions /** * @param array $values */ - public function __construct(private readonly array $values) + public function __construct(private readonly array $values, public readonly array $args = []) { } diff --git a/tests/Unit/IO/IOApp/OptionsTest.php b/tests/Unit/IO/IOApp/OptionsTest.php index 24c86e0..e027242 100644 --- a/tests/Unit/IO/IOApp/OptionsTest.php +++ b/tests/Unit/IO/IOApp/OptionsTest.php @@ -37,7 +37,7 @@ public function test_it_parses_short_flags() option('d', 'debug', 'Debug', NoInput) )->toOption()->get(); - $parsed = $options->parse(['-v', '-d']); + $parsed = $options->parse(['-v', '-d'])->getOrElse(null); $this->assertTrue($parsed->has('verbose')); $this->assertTrue($parsed->has('debug')); @@ -50,7 +50,7 @@ public function test_it_parses_bundled_short_flags() option('l', 'long', '', NoInput) )->toOption()->get(); - $parsed = $options->parse(['-al']); + $parsed = $options->parse(['-al'])->getOrElse(null); $this->assertTrue($parsed->has('all')); $this->assertTrue($parsed->has('long')); @@ -62,7 +62,7 @@ public function test_it_parses_required_values() option('f', 'file', 'File', Required) )->toOption()->get(); - $parsed = $options->parse(['-f', 'test.txt']); + $parsed = $options->parse(['-f', 'test.txt'])->getOrElse(null); $this->assertTrue($parsed->has('file')); $this->assertEquals('test.txt', $parsed->fetch('file')->get()->value); @@ -74,7 +74,7 @@ public function test_it_parses_bundled_value_in_short_flag() option('p', 'port', 'Port', Required) )->toOption()->get(); - $parsed = $options->parse(['-p8080']); + $parsed = $options->parse(['-p8080'])->getOrElse(null); $this->assertEquals('8080', $parsed->fetch('port')->get()->value); } @@ -85,7 +85,7 @@ public function test_it_parses_long_flags_with_equals() option('n', 'name', 'Name', Required) )->toOption()->get(); - $parsed = $options->parse(['--name=John']); + $parsed = $options->parse(['--name=John'])->getOrElse(null); $this->assertEquals('John', $parsed->fetch('name')->get()->value); } @@ -96,13 +96,10 @@ public function test_it_parses_negatable_options() option('c', 'color', 'Enable color', Negatable) )->toOption()->get(); - // Default absence check - actually fetch returns failure if not present, but parsed has() should be false - // Wait, if it's negatable, usually presence implies true/false? - - $parsedEnable = $options->parse(['--color']); + $parsedEnable = $options->parse(['--color'])->getOrElse(null); $this->assertEquals(true, $parsedEnable->fetch('color')->get()->value); - $parsedDisable = $options->parse(['--no-color']); + $parsedDisable = $options->parse(['--no-color'])->getOrElse(null); $this->assertEquals(false, $parsedDisable->fetch('color')->get()->value); } @@ -115,11 +112,11 @@ public function test_dsl_polymorphic_add() )->toOption()->get(); // Test verify logic - $parsed = $options->parse(['--no-verify']); + $parsed = $options->parse(['--no-verify'])->getOrElse(null); $this->assertEquals(false, $parsed->fetch('verify')->get()->value); // Test silent logic (Optional default) - $parsedSilent = $options->parse(['--silent']); + $parsedSilent = $options->parse(['--silent'])->getOrElse(null); $this->assertEquals(true, $parsedSilent->fetch('silent')->get()->value); } } From ac2e5c53401eaaa72f55972e1a65a28de2480f17 Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Wed, 17 Dec 2025 19:22:29 +0000 Subject: [PATCH 04/20] docs(IOApp): add documentation for new features - Document version support and constructor - Add comprehensive command-line argument parsing examples - Document option formats (Required, Optional, NoInput, Negatable) - Explain accessing parsed options and positional arguments - Add examples for running IOApp from files - Document implicit IOApp instantiation - Show running plain IO values - Add argument passing examples --- docs/io-app.md | 220 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 214 insertions(+), 6 deletions(-) diff --git a/docs/io-app.md b/docs/io-app.md index 3442b8b..83b55ae 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,18 +90,221 @@ 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)->fold( + fn($errors) => $this->showUsage($errors) + )( + 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 0; + }); + } +} +``` + +### 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: +- `-h, --help`: Display usage information +- `-v, --version`: Display application version + ## 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 returns an instance: ```bash -$ bin/phunkie MyApp +$ bin/phunkie MyApp.php Hello, Effects! ``` +The file should return an IOApp instance: + +```php +map(fn() => 0); + } +} + +return new MyApp(); +``` + +### Implicit IOApp Instantiation + +You can also define an IOApp class without explicitly returning an instance. The console will automatically detect and instantiate it: + +```php +map(fn() => 0); + } +} + +// No need to return new MyApp() - it's detected automatically! +``` + +### Running Plain IO + +You can also run a file that returns a plain `IO` value: + +```php +map(fn() => 0); +``` + +```bash +$ bin/phunkie hello.php +Hello from IO! +``` + +### Passing Arguments + +Arguments are passed to the IOApp's `run` method: + +```php +class MyApp extends IOApp +{ + public function run(?array $args = []): IO + { + return new IO(function() use ($args) { + echo "Arguments: " . implode(", ", $args) . "\n"; + return 0; + }); + } +} +``` + +```bash +$ bin/phunkie MyApp.php arg1 arg2 arg3 +Arguments: MyApp.php, arg1, arg2, arg3 +``` + The console will: -1. Load your application class -2. Execute the `run` method +1. Load your application class or IO +2. Execute the `run` method (for IOApp) or the IO directly 3. Handle any errors that occur during execution 4. Return the appropriate exit code From 3a53dd1dab96ee8b3182d9ff7b5f91c97c6b4794 Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Wed, 17 Dec 2025 19:27:48 +0000 Subject: [PATCH 05/20] docs(IOApp): clarify implicit instantiation is the default - Remove explicit return from first example - Reorder sections to show implicit instantiation as primary approach - Mark explicit return as optional - Update descriptions to reflect automatic detection --- docs/io-app.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/docs/io-app.md b/docs/io-app.md index 83b55ae..4a98b05 100644 --- a/docs/io-app.md +++ b/docs/io-app.md @@ -204,14 +204,14 @@ Phunkie Effects provides a console application to run your IO apps in multiple w ### Running IOApp Classes -You can run an IOApp by passing a file that returns an instance: +You can run an IOApp by passing a file that defines the class: ```bash $ bin/phunkie MyApp.php Hello, Effects! ``` -The file should return an IOApp instance: +Simply define your IOApp class in the file - no need to explicitly return an instance: ```php map(fn() => 0); } } - -return new MyApp(); ``` -### Implicit IOApp Instantiation +### Explicit Instantiation (Optional) -You can also define an IOApp class without explicitly returning an instance. The console will automatically detect and instantiate it: +If you prefer, you can explicitly return an instance of your IOApp: ```php Date: Wed, 17 Dec 2025 20:04:54 +0000 Subject: [PATCH 06/20] docs(IOApp): modernize examples throughout to use new features - Update error handling example to use parse() and showUsage() - Replace old best practices with argument DSL-first approach - Rewrite comprehensive example to showcase: - Version support in constructor - Multiple option types (Required, Optional, NoInput) - parse()->fold() pattern for error handling - Match expression for help/version flags - Option access with fetch() and has() - Verbose mode controlled by CLI flag - Proper IO composition with flatMap - Remove outdated io() function usage - Add Database class for complete working example - Progressive teaching: simple -> complex with consistent patterns --- docs/io-app.md | 194 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 128 insertions(+), 66 deletions(-) diff --git a/docs/io-app.md b/docs/io-app.md index 4a98b05..6f0f8fc 100644 --- a/docs/io-app.md +++ b/docs/io-app.md @@ -308,29 +308,51 @@ The console will: ## Exit Codes and Error Handling -IOApp provides a way to handle errors and return appropriate exit codes: +IOApp provides built-in error handling through the `parse()` method and `showUsage()`: ```php use Phunkie\Effect\IO\IOApp; -use function Phunkie\Effect\Functions\io\io; +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\printError; -use const Phunkie\Effect\IOApp\ExitSuccess; -use const Phunkie\Effect\IOApp\ExitFailure; +use function Phunkie\Effect\Functions\console\printSuccess; +use const Phunkie\Effect\Functions\ioapp\Required; class MyApp extends IOApp { - /** - * @return IO - */ - public function run(): IO + protected function define(): Validation { - return io(function() { + return arguments( + option('f', 'file', 'Input file', Required) + ); + } + + public function run(?array $args = []): IO + { + return $this->parse($args)->fold( + fn($errors) => $this->showUsage($errors) + )( + fn($options) => $this->processFile($options) + ); + } + + private function processFile($options): IO + { + return new IO(function() use ($options) { + $file = $options->fetch('file') + ->getOrElse('default.txt'); + try { - // Your application logic here - return ExitSuccess; + // Process file + return printSuccess("File processed: $file") + ->map(fn() => 0) + ->unsafeRun(); } catch (\Exception $e) { return printError($e->getMessage()) - ->map(fn() => ExitFailure); + ->map(fn() => 1) + ->unsafeRun(); } }); } @@ -339,91 +361,131 @@ class MyApp extends IOApp ## 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. **Leverage Validation**: Use `parse()->fold()` to handle both success and error cases elegantly. -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; +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 MyApp extends IOApp +class DatabaseApp extends IOApp { - /** - * @return IO - */ - public function run(): IO + public function __construct() { - 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); + 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) + ); + } + + public function run(?array $args = []): IO + { + return $this->parse($args)->fold( + fn($errors) => $this->showUsage($errors) + )( + fn($options) => match(true) { + $options->has('help') => $this->showUsage(), + $options->has('version') => $this->showVersion(), + default => $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(): IO + private function loadConfig($options): IO { - return io(function() { - // Load configuration - return ['host' => 'localhost', 'port' => 5432]; + 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 +- Proper use of `parse()->fold()` for error handling +- Match expression for handling help/version flags +- Composition of IOs with `flatMap` +- Accessing parsed options with `fetch()` and `has()` +- Verbose mode controlled by command-line flag - Clean separation of concerns -- Meaningful exit codes \ No newline at end of file +- Error handling with `handleError()` \ No newline at end of file From 844ed884861e17ed2464f3c3cf9fa431abdcbe29 Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Wed, 17 Dec 2025 20:06:56 +0000 Subject: [PATCH 07/20] docs(IOApp): remove incorrect explicit instantiation section The explicit return statement is not needed - IOApp classes are automatically detected and instantiated by the console runner. --- docs/io-app.md | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/docs/io-app.md b/docs/io-app.md index 6f0f8fc..806dbc0 100644 --- a/docs/io-app.md +++ b/docs/io-app.md @@ -232,31 +232,6 @@ class MyApp extends IOApp } ``` -### Explicit Instantiation (Optional) - -If you prefer, you can explicitly return an instance of your IOApp: - -```php -map(fn() => 0); - } -} - -return new MyApp(); // Explicit return is optional but supported -``` - ### Running Plain IO You can also run a file that returns a plain `IO` value: From 91bd98727412b07bfd783269414623f89441b7be Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Wed, 17 Dec 2025 20:08:14 +0000 Subject: [PATCH 08/20] docs(IOApp): remove plain IO section - not relevant to IOApp This documentation is specifically about IOApp, not about running plain IO values. Removed the section to keep focus on IOApp features. --- docs/io-app.md | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/docs/io-app.md b/docs/io-app.md index 806dbc0..f232e69 100644 --- a/docs/io-app.md +++ b/docs/io-app.md @@ -232,27 +232,6 @@ class MyApp extends IOApp } ``` -### Running Plain IO - -You can also run a file that returns a plain `IO` value: - -```php -map(fn() => 0); -``` - -```bash -$ bin/phunkie hello.php -Hello from IO! -``` - ### Passing Arguments Arguments are passed to the IOApp's `run` method: From ca6c5a92d56a9ec8afb40011f5153d8af1e2845d Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Wed, 17 Dec 2025 20:10:10 +0000 Subject: [PATCH 09/20] docs(IOApp): remove redundant Passing Arguments section Arguments and options are already comprehensively covered in the 'Command-Line Argument Parsing' section with proper examples using the DSL. The redundant section showed outdated raw args access. --- docs/io-app.md | 26 ++------------------------ 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/docs/io-app.md b/docs/io-app.md index f232e69..0b2a578 100644 --- a/docs/io-app.md +++ b/docs/io-app.md @@ -232,31 +232,9 @@ class MyApp extends IOApp } ``` -### Passing Arguments - -Arguments are passed to the IOApp's `run` method: - -```php -class MyApp extends IOApp -{ - public function run(?array $args = []): IO - { - return new IO(function() use ($args) { - echo "Arguments: " . implode(", ", $args) . "\n"; - return 0; - }); - } -} -``` - -```bash -$ bin/phunkie MyApp.php arg1 arg2 arg3 -Arguments: MyApp.php, arg1, arg2, arg3 -``` - The console will: -1. Load your application class or IO -2. Execute the `run` method (for IOApp) or the IO directly +1. Load your application class +2. Execute the `run` method 3. Handle any errors that occur during execution 4. Return the appropriate exit code From 9f6b2ebf10747d5e1fd15125076ceacb894c00af Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Wed, 17 Dec 2025 20:14:41 +0000 Subject: [PATCH 10/20] docs(IOApp): clarify -v override behavior in verbose example Add note that using -v for verbose overrides the version shorthand, but --version long form remains available. This is an important detail for users defining their own options. --- docs/io-app.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/io-app.md b/docs/io-app.md index 0b2a578..3283601 100644 --- a/docs/io-app.md +++ b/docs/io-app.md @@ -326,6 +326,7 @@ class DatabaseApp extends IOApp 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 ); } @@ -418,6 +419,6 @@ This example demonstrates: - Match expression for handling help/version flags - Composition of IOs with `flatMap` - Accessing parsed options with `fetch()` and `has()` -- Verbose mode controlled by command-line flag +- Verbose mode controlled by command-line flag (overrides `-v` but `--version` remains) - Clean separation of concerns - Error handling with `handleError()` \ No newline at end of file From 2cc85328ca90a07e671298f28094a6d99ce35404 Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Wed, 17 Dec 2025 20:19:07 +0000 Subject: [PATCH 11/20] docs(IOApp): clarify that help/version must be explicitly checked Add important note explaining that while -h/--help and -v/--version are automatically added to option definitions, users must explicitly check for them in run() using $options->has(). This gives users full control over when and how to display help/version information. Include example showing the recommended pattern with match expression. --- docs/io-app.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/io-app.md b/docs/io-app.md index 3283601..8a84491 100644 --- a/docs/io-app.md +++ b/docs/io-app.md @@ -198,6 +198,23 @@ IOApp automatically provides: - `-h, --help`: Display usage information - `-v, --version`: Display application version +**Important:** While these options are automatically added to your option definitions, you must explicitly check for them in your `run()` method using `$options->has('help')` and `$options->has('version')`. They are not handled automatically - this gives you full control over when and how to display help or version information. + +```php +public function run(?array $args = []): IO +{ + return $this->parse($args)->fold( + fn($errors) => $this->showUsage($errors) + )( + fn($options) => match(true) { + $options->has('help') => $this->showUsage(), + $options->has('version') => $this->showVersion(), + default => $this->runApp($options) + } + ); +} +``` + ## Running with IO Console Phunkie Effects provides a console application to run your IO apps in multiple ways: From 9679c2193c5d20a4e4a0f8a878e1d2aa961128b9 Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Wed, 17 Dec 2025 20:22:48 +0000 Subject: [PATCH 12/20] feat(IOApp): add parseAndHandle for automatic help/version handling Add parseAndHandle() convenience method that automatically handles --help/-h and --version/-v flags, eliminating the need for users to manually check for these flags in every application. Users can still use parse() directly if they need custom control over help/version behavior, but parseAndHandle() is now the recommended default approach. Updated documentation to show parseAndHandle() as the primary pattern, with parse() shown as an advanced option for custom control. --- docs/io-app.md | 33 +++++++++++++++++++++------------ src/IO/IOApp.php | 24 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/docs/io-app.md b/docs/io-app.md index 8a84491..94c5e76 100644 --- a/docs/io-app.md +++ b/docs/io-app.md @@ -198,7 +198,25 @@ IOApp automatically provides: - `-h, --help`: Display usage information - `-v, --version`: Display application version -**Important:** While these options are automatically added to your option definitions, you must explicitly check for them in your `run()` method using `$options->has('help')` and `$options->has('version')`. They are not handled automatically - this gives you full control over when and how to display help or version information. +These flags are automatically handled when you use `parseAndHandle()`: + +```php +public function run(?array $args = []): IO +{ + return $this->parseAndHandle($args, fn($options) => $this->runApp($options)); +} + +private function runApp($options): IO +{ + // Your application logic here + return new IO(function() use ($options) { + // Access options and run your program + return 0; + }); +} +``` + +If you need more control, you can use `parse()` directly and handle the flags yourself: ```php public function run(?array $args = []): IO @@ -349,15 +367,7 @@ class DatabaseApp extends IOApp public function run(?array $args = []): IO { - return $this->parse($args)->fold( - fn($errors) => $this->showUsage($errors) - )( - fn($options) => match(true) { - $options->has('help') => $this->showUsage(), - $options->has('version') => $this->showVersion(), - default => $this->runApp($options) - } - ); + return $this->parseAndHandle($args, fn($options) => $this->runApp($options)); } private function runApp($options): IO @@ -432,8 +442,7 @@ class Database This example demonstrates: - Version support via constructor - Comprehensive argument parsing with multiple option types -- Proper use of `parse()->fold()` for error handling -- Match expression for handling help/version flags +- Automatic help/version handling with `parseAndHandle()` - Composition of IOs with `flatMap` - Accessing parsed options with `fetch()` and `has()` - Verbose mode controlled by command-line flag (overrides `-v` but `--version` remains) diff --git a/src/IO/IOApp.php b/src/IO/IOApp.php index ec6b8f4..ca4df9f 100644 --- a/src/IO/IOApp.php +++ b/src/IO/IOApp.php @@ -94,6 +94,30 @@ protected function parse(array $args): Validation ->flatMap(fn ($options) => $options->parse($args)); } + /** + * Parses CLI arguments and automatically handles help and version flags. + * + * This is a convenience method that wraps parse() and automatically checks + * for --help/-h and --version/-v flags, calling showUsage() or showVersion() + * respectively. If neither flag is present, it calls the provided callback. + * + * @param array $args The raw arguments (usually $argv) + * @param callable(ParsedOptions):IO $onSuccess Callback to execute with parsed options + * @return IO + */ + protected function parseAndHandle(array $args, callable $onSuccess): IO + { + return $this->parse($args)->fold( + fn ($errors) => $this->showUsage($errors) + )( + fn ($options) => match(true) { + $options->has('help') => $this->showUsage(), + $options->has('version') => $this->showVersion(), + default => $onSuccess($options) + } + ); + } + protected function showErrors(?NonEmptyList $errors = null): IO { return new IO(function () use ($errors) { From 445e18ddd4132f1fdc58e122b6ed7f1f49d6c843 Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Wed, 17 Dec 2025 20:55:10 +0000 Subject: [PATCH 13/20] refactor(IOApp): parse() now returns IO with automatic error/help/version handling BREAKING CHANGE: parse() signature changed from Validation to IO - parse() now handles errors, help, and version flags internally - Errors/help/version cause process exit with appropriate code - Only valid options reach the application code - Simplified usage: parse($args)->flatMap(fn($opts) => runApp($opts)) - Added parseValidation() for advanced users who need Validation - Updated all documentation and examples This makes the API much simpler - users don't need to remember to check for help/version flags or handle errors explicitly. --- docs/io-app.md | 42 +++++++++++++++++------------------------- src/IO/IOApp.php | 46 +++++++++++++++++++++++++--------------------- 2 files changed, 42 insertions(+), 46 deletions(-) diff --git a/docs/io-app.md b/docs/io-app.md index 94c5e76..88fa128 100644 --- a/docs/io-app.md +++ b/docs/io-app.md @@ -194,44 +194,35 @@ $files = $options->args; // Array of non-option arguments ### Built-in Options -IOApp automatically provides: -- `-h, --help`: Display usage information -- `-v, --version`: Display application version +IOApp automatically provides and handles: +- `-h, --help`: Display usage information and exit +- `-v, --version`: Display application version and exit -These flags are automatically handled when you use `parseAndHandle()`: +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 public function run(?array $args = []): IO { - return $this->parseAndHandle($args, fn($options) => $this->runApp($options)); + return $this->parse($args) + ->flatMap(fn($options) => $this->runApp($options)); } private function runApp($options): IO { - // Your application logic here + // Your application logic here - only called with valid options return new IO(function() use ($options) { - // Access options and run your program + $verbose = $options->has('verbose'); + // ... your code return 0; }); } ``` -If you need more control, you can use `parse()` directly and handle the flags yourself: - -```php -public function run(?array $args = []): IO -{ - return $this->parse($args)->fold( - fn($errors) => $this->showUsage($errors) - )( - fn($options) => match(true) { - $options->has('help') => $this->showUsage(), - $options->has('version') => $this->showVersion(), - default => $this->runApp($options) - } - ); -} -``` +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 @@ -367,7 +358,7 @@ class DatabaseApp extends IOApp public function run(?array $args = []): IO { - return $this->parseAndHandle($args, fn($options) => $this->runApp($options)); + return $this->parse($args)->flatMap(fn($options) => $this->runApp($options)); } private function runApp($options): IO @@ -442,7 +433,8 @@ class Database This example demonstrates: - Version support via constructor - Comprehensive argument parsing with multiple option types -- Automatic help/version handling with `parseAndHandle()` +- 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) diff --git a/src/IO/IOApp.php b/src/IO/IOApp.php index ca4df9f..a3f675c 100644 --- a/src/IO/IOApp.php +++ b/src/IO/IOApp.php @@ -86,36 +86,40 @@ protected function define(): Validation * ``` * * @param array $args The raw arguments (usually $argv) - * @return Validation, ParsedOptions> + * @return IO IO that resolves to parsed options, handling errors/help/version internally */ - protected function parse(array $args): Validation + protected function parse(array $args): IO { - return $this->define() - ->flatMap(fn ($options) => $options->parse($args)); + 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(); + }); } /** - * Parses CLI arguments and automatically handles help and version flags. - * - * This is a convenience method that wraps parse() and automatically checks - * for --help/-h and --version/-v flags, calling showUsage() or showVersion() - * respectively. If neither flag is present, it calls the provided callback. + * Low-level parse that returns Validation for advanced use cases. + * Most users should use parse() instead. * * @param array $args The raw arguments (usually $argv) - * @param callable(ParsedOptions):IO $onSuccess Callback to execute with parsed options - * @return IO + * @return Validation, ParsedOptions> */ - protected function parseAndHandle(array $args, callable $onSuccess): IO + protected function parseValidation(array $args): Validation { - return $this->parse($args)->fold( - fn ($errors) => $this->showUsage($errors) - )( - fn ($options) => match(true) { - $options->has('help') => $this->showUsage(), - $options->has('version') => $this->showVersion(), - default => $onSuccess($options) - } - ); + return $this->define() + ->flatMap(fn ($options) => $options->parse($args)); } protected function showErrors(?NonEmptyList $errors = null): IO From 7c3430f031b60f73fa0093e0910e8dbbda28fb6c Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Wed, 17 Dec 2025 20:56:41 +0000 Subject: [PATCH 14/20] docs(IOApp): fix PHPDoc for parse() method Update documentation to accurately reflect that parse() returns IO and handles errors/help/version internally with process exit. Include correct usage example. --- src/IO/IOApp.php | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/IO/IOApp.php b/src/IO/IOApp.php index a3f675c..f0da796 100644 --- a/src/IO/IOApp.php +++ b/src/IO/IOApp.php @@ -74,19 +74,26 @@ protected function define(): Validation } /** - * Parses the CLI arguments based on the definitions from define(). + * 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 - * $result = $this->parse($argv); - * $result->fold( - * fn($errors) => echo "Invalid arguments", // $errors is NonEmptyList - * fn($options) => $options->has('verbose') // $options is ParsedOptions - * ); + * 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, handling errors/help/version internally + * @return IO IO that resolves to parsed options */ protected function parse(array $args): IO { From 6cb168bfc961fb384f5250873c26361257292b7a Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Wed, 17 Dec 2025 21:01:31 +0000 Subject: [PATCH 15/20] docs(IOApp): update argument parsing example to use new API Update the command-line argument parsing example to use the new parse()->flatMap() pattern instead of the outdated fold() approach. --- docs/io-app.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/docs/io-app.md b/docs/io-app.md index 88fa128..8d13bd8 100644 --- a/docs/io-app.md +++ b/docs/io-app.md @@ -140,11 +140,7 @@ class MyApp extends IOApp public function run(?array $args = []): IO { - return $this->parse($args)->fold( - fn($errors) => $this->showUsage($errors) - )( - fn($options) => $this->processOptions($options) - ); + return $this->parse($args)->flatMap(fn($options) => $this->processOptions($options)); } private function processOptions($options): IO From c0e42f45f27da59980ea9d925b88da140462a317 Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Wed, 17 Dec 2025 21:05:31 +0000 Subject: [PATCH 16/20] docs(IOApp): comprehensive QA fixes and improvements - Fix all remaining parse()->fold() references to parse()->flatMap() - Update 'Exit Codes and Error Handling' section to actually cover: - Exit code constants and their usage - Error handling with handleError() - Try-catch patterns within IO - Validation error handling - Use ExitSuccess constant instead of numeric 0 - Update Best Practices to reflect automatic error handling - Remove outdated Validation-based examples All documentation now consistently uses the new simplified API. --- docs/io-app.md | 125 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 90 insertions(+), 35 deletions(-) diff --git a/docs/io-app.md b/docs/io-app.md index 8d13bd8..1942df1 100644 --- a/docs/io-app.md +++ b/docs/io-app.md @@ -140,7 +140,8 @@ class MyApp extends IOApp public function run(?array $args = []): IO { - return $this->parse($args)->flatMap(fn($options) => $this->processOptions($options)); + return $this->parse($args) + ->flatMap(fn($options) => $this->processOptions($options)); } private function processOptions($options): IO @@ -154,7 +155,9 @@ class MyApp extends IOApp $positionalArgs = $options->args; // Your application logic here - return 0; + // ... + + return ExitSuccess; }); } } @@ -197,6 +200,8 @@ IOApp automatically provides and handles: 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) @@ -209,7 +214,7 @@ private function runApp($options): IO return new IO(function() use ($options) { $verbose = $options->has('verbose'); // ... your code - return 0; + return ExitSuccess; }); } ``` @@ -262,62 +267,112 @@ The console will: ## Exit Codes and Error Handling -IOApp provides built-in error handling through the `parse()` method and `showUsage()`: +### 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 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\printError; -use function Phunkie\Effect\Functions\console\printSuccess; -use const Phunkie\Effect\Functions\ioapp\Required; +use function Phunkie\Effect\Functions\console\{printLn, printError}; +use const Phunkie\Effect\IOApp\{ExitSuccess, ExitFailure}; -class MyApp extends IOApp +class FileProcessor extends IOApp { - protected function define(): Validation - { - return arguments( - option('f', 'file', 'Input file', Required) - ); - } - public function run(?array $args = []): IO { - return $this->parse($args)->fold( - fn($errors) => $this->showUsage($errors) - )( - fn($options) => $this->processFile($options) - ); + 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') - ->getOrElse('default.txt'); + $file = $options->fetch('file')->get()->value; - try { - // Process file - return printSuccess("File processed: $file") - ->map(fn() => 0) - ->unsafeRun(); - } catch (\Exception $e) { - return printError($e->getMessage()) - ->map(fn() => 1) - ->unsafeRun(); + 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. **Use the Argument DSL**: Define your CLI interface with `define()` and let IOApp handle parsing and validation. -2. **Leverage Validation**: Use `parse()->fold()` to handle both success and error cases elegantly. +2. **Automatic Error Handling**: The `parse()` method automatically handles errors, help, and version flags - you just focus on your application logic. 3. **Keep it Pure**: The `run` method should return an IO without side effects. All side effects should be wrapped in IO. From 6f4f6d5a6c613613c84a0d1375cefa44270bf072 Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Wed, 17 Dec 2025 21:33:37 +0000 Subject: [PATCH 17/20] chore: remove minimum-stability (stable is default) --- composer.json | 1 - 1 file changed, 1 deletion(-) diff --git a/composer.json b/composer.json index 18ea4d3..a66449a 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,6 @@ "cs-fix": "php-cs-fixer fix", "cs-check": "php-cs-fixer fix --dry-run --diff" }, - "minimum-stability": "dev", "prefer-stable": true, "config": { "bin-dir": "bin" From 46c346426decabd2228f287e29822c2bbafc09ca Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Wed, 17 Dec 2025 23:56:04 +0000 Subject: [PATCH 18/20] Update composer.json --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 7e8630c..b72fe5d 100644 --- a/composer.json +++ b/composer.json @@ -54,4 +54,4 @@ "config": { "bin-dir": "bin" } -} \ No newline at end of file +} From 8f05875e5353fb906995619c0292213fc0facbab Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Wed, 17 Dec 2025 23:58:05 +0000 Subject: [PATCH 19/20] Add credits --- src/Functions/ioapp.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Functions/ioapp.php b/src/Functions/ioapp.php index e6eaf9b..ab32252 100644 --- a/src/Functions/ioapp.php +++ b/src/Functions/ioapp.php @@ -1,5 +1,14 @@ + * + * 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; From 48e5e0c69ae9d674577f998e086b2e465c90d408 Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Thu, 18 Dec 2025 00:00:58 +0000 Subject: [PATCH 20/20] docs: add copyright and class documentation to IOApp classes --- src/IO/IOApp/Error.php | 12 ++++++++++++ src/IO/IOApp/Input.php | 12 ++++++++++++ src/IO/IOApp/OptionDefinition.php | 12 ++++++++++++ src/IO/IOApp/OptionFormat.php | 12 ++++++++++++ src/IO/IOApp/Options.php | 12 ++++++++++++ src/IO/IOApp/ParsedOptions.php | 12 ++++++++++++ 6 files changed, 72 insertions(+) diff --git a/src/IO/IOApp/Error.php b/src/IO/IOApp/Error.php index 666c481..dc24f05 100644 --- a/src/IO/IOApp/Error.php +++ b/src/IO/IOApp/Error.php @@ -1,7 +1,19 @@ + * + * 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 index b000e28..dd85791 100644 --- a/src/IO/IOApp/Input.php +++ b/src/IO/IOApp/Input.php @@ -1,7 +1,19 @@ + * + * 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 index 350fc9a..fd70220 100644 --- a/src/IO/IOApp/OptionDefinition.php +++ b/src/IO/IOApp/OptionDefinition.php @@ -1,7 +1,19 @@ + * + * 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; diff --git a/src/IO/IOApp/OptionFormat.php b/src/IO/IOApp/OptionFormat.php index 61bf2c8..24a2238 100644 --- a/src/IO/IOApp/OptionFormat.php +++ b/src/IO/IOApp/OptionFormat.php @@ -1,7 +1,19 @@ + * + * 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; diff --git a/src/IO/IOApp/Options.php b/src/IO/IOApp/Options.php index 7abe748..d14b023 100644 --- a/src/IO/IOApp/Options.php +++ b/src/IO/IOApp/Options.php @@ -1,5 +1,14 @@ + * + * 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; @@ -9,6 +18,9 @@ use function Success; +/** + * Registry of option definitions and the main parser. + */ class Options { private $definitions = []; diff --git a/src/IO/IOApp/ParsedOptions.php b/src/IO/IOApp/ParsedOptions.php index 3d9c5d9..8ce7bed 100644 --- a/src/IO/IOApp/ParsedOptions.php +++ b/src/IO/IOApp/ParsedOptions.php @@ -1,9 +1,21 @@ + * + * 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 { /**