diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8e8596..928282a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,9 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Unset local path repositories + run: composer config --unset repositories + - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -45,9 +48,13 @@ jobs: run: composer test lint: - name: Code Style (PHP-CS-Fixer) + name: PHP ${{ matrix.php }} Code Style (PHP-CS-Fixer) runs-on: ubuntu-latest - + strategy: + fail-fast: false + matrix: + php: ['8.2', '8.3', '8.4'] + steps: - name: Checkout code uses: actions/checkout@v4 @@ -55,26 +62,33 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.3 + php-version: ${{ matrix.php }} extensions: mbstring, json coverage: none tools: php-cs-fixer - name: Run PHP-CS-Fixer - run: php-cs-fixer fix --dry-run --diff --verbose + run: php-cs-fixer fix --dry-run --diff --verbose --allow-risky=yes static-analysis: - name: Static Analysis (PHPStan) + name: PHP ${{ matrix.php }} Static Analysis (PHPStan) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.2', '8.3', '8.4'] steps: - name: Checkout code uses: actions/checkout@v4 + - name: Unset local path repositories + run: composer config --unset repositories + - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.3 + php-version: ${{ matrix.php }} extensions: mbstring, json, pcntl, posix, zlib, sockets coverage: none @@ -83,9 +97,9 @@ jobs: uses: actions/cache@v4 with: path: vendor - key: ${{ runner.os }}-php-8.3-${{ hashFiles('**/composer.lock') }} + key: ${{ runner.os }}-php-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }} restore-keys: | - ${{ runner.os }}-php-8.3- + ${{ runner.os }}-php-${{ matrix.php }}- - name: Install dependencies run: composer install --prefer-dist --no-progress diff --git a/README.md b/README.md index d8a7bad..c21367b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Phunkie Effect -[![CI](https://github.com/phunkie/effect/workflows/CI/badge.svg)](https://github.com/phunkie/effect/actions) +[![CI](https://github.com/phunkie/effect/actions/workflows/ci.yml/badge.svg)](https://github.com/phunkie/effect/actions) [![Latest Version](https://img.shields.io/packagist/v/phunkie/effect.svg?style=flat-square)](https://packagist.org/packages/phunkie/effect) [![Total Downloads](https://poser.pugx.org/phunkie/effect/downloads)](https://packagist.org/packages/phunkie/effect) [![License](https://img.shields.io/packagist/l/phunkie/effect.svg?style=flat-square)](https://github.com/phunkie/effect/blob/main/LICENSE) diff --git a/composer.json b/composer.json index 38cebd2..867407c 100644 --- a/composer.json +++ b/composer.json @@ -10,15 +10,14 @@ } ], "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" - + "phunkie/phpstan": "^1.0", + "friendsofphp/php-cs-fixer": "^3.90" }, "suggest": { "ext-parallel": "Required for parallel execution using threads. PHP must be compiled with ZTS support." @@ -38,9 +37,18 @@ }, "scripts": { "test": "phpunit", - "phpstan": "phpstan analyse", - "cs-fix": "php-cs-fixer fix", - "cs-check": "php-cs-fixer fix --dry-run --diff" + "phpstan": "phpstan analyse --memory-limit=512M", + "cs-fix": "php-cs-fixer fix --allow-risky=yes", + "cs-check": "php-cs-fixer fix --dry-run --diff --allow-risky=yes", + "lint": [ + "@cs-check", + "@phpstan" + ], + "test-all": "scripts/test-all-versions.sh", + "check": [ + "@lint", + "@test" + ] }, "minimum-stability": "dev", "prefer-stable": true, diff --git a/docs/index.md b/docs/index.md index 5ee3b03..3eaa174 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,25 +2,50 @@ A functional effects library for PHP inspired by Scala's cats-effect. Phunkie Effects provides a way to manage side effects in a purely functional way, making your code more predictable, testable, and maintainable. -## Table of Contents +## Overview -1. [Introduction](introduction.md) +Phunkie Effect brings the power of purely functional programming to PHP, allowing you to describe side effects as values that can be composed, tested, and reasoned about before execution. + +## Getting Started + +New to Phunkie Effect? Start here: + +- [Introduction](introduction.md) - Learn what Phunkie Effect is and when to use it +- [Getting Started](getting-started.md) - Installation and your first effect +- [IO App and Console](io-app.md) - Build command-line applications + +## Core Concepts + +Master the fundamental building blocks: + +- [Combinators](combinators.md) - Core IO operations and composition patterns +- [Resources](resources.md) - Safe resource management with brackets +- [Concurrency](concurrency.md) - Parallel execution and concurrent programming + +## Documentation Index + +### 1. [Introduction](introduction.md) - What is Phunkie Effect? - Philosophy and design goals - When to use Phunkie Effect -2. [Getting Started](getting-started.md) +### 2. [Getting Started](getting-started.md) - Installation - Basic Usage - Your First Effect -3. [IO App and IO Console](io-app.md) +### 3. [IO App and IO Console](io-app.md) - Console Functions - Creating an IO App - Exit Codes and Error Handling - Best Practices -4. [Concurrency](concurrency.md) +### 4. [Combinators](combinators.md) + - Core IO Operations + - Error Handling + - Composition Patterns + +### 5. [Concurrency](concurrency.md) - Blockers - Execution Context - Parallel Execution @@ -30,48 +55,20 @@ A functional effects library for PHP inspired by Scala's cats-effect. Phunkie Ef - Delay - Channels -5. [Resources](resources.md) +### 6. [Resources](resources.md) - Brackets - Resource Combinators - Files - URL - Database -6. [Networks](networks.md) - - TCP - - Server - - Client - - Connection Management - - UDP - - Datagram Handling - - Broadcasting - - Multicasting - -7. [Cookbook](cookbook.md) - - Supervisor Patterns - - One-for-One Strategy - - All-for-One Strategy - - Custom Supervision - - Shared State with Ref - - Atomic Updates - - Cross-Fiber Communication - - Graceful Shutdown - - Resource Cleanup - - Signal Handling - - Circuit Breakers - - Failure Detection - - Automatic Recovery - - Rate Limiting - - Token Bucket - - Leaky Bucket - ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. -## License +## Licence -This project is licensed under the MIT License - see the LICENSE file for details. +This project is licensed under the MIT Licence - see the LICENCE file for details. ## Acknowledgments diff --git a/docs/introduction.md b/docs/introduction.md new file mode 100644 index 0000000..b8c4d0f --- /dev/null +++ b/docs/introduction.md @@ -0,0 +1,147 @@ +# Introduction to Phunkie Effect + +## What is Phunkie Effect? + +Phunkie Effect is a functional effects library for PHP that brings the power of purely functional programming to side effect management. Inspired by Scala's cats-effect and built on top of the Phunkie functional programming library, it provides a robust framework for writing predictable, composable, and testable code. + +At its core, Phunkie Effect introduces the `IO` monad - a data structure that represents a computation that may perform side effects. Unlike traditional imperative code where side effects happen immediately, `IO` allows you to describe what should happen without actually executing it. This separation of description from execution is the key to writing purely functional code in PHP. + +```php +use Phunkie\Effect\IO; + +// This doesn't execute anything - it just describes what should happen +$program = io(fn() => file_get_contents('config.json')) + ->map(fn($json) => json_decode($json, true)) + ->flatMap(fn($config) => io(fn() => connectToDatabase($config['db']))); + +// The program only runs when you explicitly call unsafeRun() +$result = $program->unsafeRun(); +``` + +## Philosophy and Design Goals + +### Referential Transparency + +Phunkie Effect is built on the principle of referential transparency - the idea that you should be able to replace an expression with its value without changing the program's behavior. This makes code easier to reason about, test, and refactor. + +```php +// Without IO - side effects happen immediately +$data = file_get_contents('data.txt'); // Executes now! +$result = processData($data); + +// With IO - side effects are described, not executed +$program = io(fn() => file_get_contents('data.txt')) + ->map(fn($data) => processData($data)); +// Nothing has executed yet - we can compose, test, and reason about it +``` + +### Composability + +Effects should compose just like pure functions. Phunkie Effect provides a rich set of combinators that allow you to build complex programs from simple building blocks. + +```php +$readConfig = io(fn() => file_get_contents('config.json')); +$parseJson = fn($json) => io(fn() => json_decode($json, true)); +$validateConfig = fn($config) => io(fn() => validate($config)); + +// Compose them together +$program = $readConfig + ->flatMap($parseJson) + ->flatMap($validateConfig); +``` + +### Resource Safety + +Managing resources (files, database connections, network sockets) is error-prone. Phunkie Effect provides automatic resource management through brackets, ensuring cleanup happens even when errors occur. + +```php +use function Phunkie\Effect\Functions\blocking\blocking; + +$result = bracket( + // Acquire + blocking(fn() => fopen('file.txt', 'r')), + // Use + fn($handle) => blocking(fn() => fread($handle, 1024)), + // Release + fn($handle) => blocking(fn() => fclose($handle)) +); +``` + +### Testability + +By separating effect description from execution, your code becomes trivially testable. You can test the logic without performing actual side effects. + +```php +// Your business logic returns IO values +function processOrder(Order $order): IO { + return saveToDatabase($order) + ->flatMap(fn() => sendConfirmationEmail($order->email)) + ->flatMap(fn() => updateInventory($order->items)); +} + +// In tests, you can inspect the IO structure without executing it +$io = processOrder($testOrder); +// Assert on the structure, or provide test implementations +``` + +### Concurrency and Parallelism + +Phunkie Effect provides safe, composable concurrency primitives that make it easy to write concurrent programs without the typical pitfalls of shared mutable state. + +```php +use function Phunkie\Effect\Functions\io\io; + +// Run multiple effects in parallel +$result = $fetchUserData + ->parMap2($fetchOrderHistory, fn($user, $orders) => [$user, $orders]) + ->unsafeRun(); +``` + +## When to Use Phunkie Effect + +### Perfect For: + +**Complex Applications with Many Side Effects** +- Web applications with database access, external APIs, file I/O +- CLI tools that interact with the filesystem and network +- Background workers and job processors + +**Applications Requiring High Reliability** +- Financial systems where correctness is critical +- Healthcare applications with strict error handling requirements +- Any system where bugs have serious consequences + +**Concurrent and Parallel Processing** +- Data processing pipelines +- Microservices that need to coordinate multiple operations +- Applications that benefit from parallel execution + +**Testable Code** +- When you need comprehensive test coverage +- When you want to test business logic without hitting real databases/APIs +- When you need to verify error handling paths + +### Consider Alternatives When: + +**Simple Scripts** +- One-off scripts with minimal side effects +- Quick prototypes where correctness isn't critical + +**Performance-Critical Hot Paths** +- While Phunkie Effect is reasonably performant, the abstraction overhead may not be suitable for extremely performance-sensitive code +- Consider using it for the application structure and dropping down to imperative code for hot paths + +**Team Unfamiliar with Functional Programming** +- There's a learning curve to functional programming concepts +- Ensure your team is willing to invest in learning these patterns + +## Getting Started + +Ready to dive in? Check out the [Getting Started](getting-started.md) guide to install Phunkie Effect and write your first effectful program. + +## Learn More + +- [IO App and Console](io-app.md) - Build command-line applications +- [Combinators](combinators.md) - Core operations for working with IO +- [Concurrency](concurrency.md) - Parallel and concurrent execution +- [Resources](resources.md) - Safe resource management diff --git a/phpstan.neon b/phpstan.neon index fbe0db0..d955c7b 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,3 +1,6 @@ +includes: + - vendor/phunkie/phpstan/extension.neon + parameters: level: 5 paths: @@ -5,24 +8,14 @@ parameters: excludePaths: - src/Functions/common.php ignoreErrors: - # Allow mixed types for now in Ops traits (will be fixed as we add type classes) - - '#Method .+Ops::.+ has parameter \$.+ with no value type specified in iterable type array#' - - '#Method .+Ops::.+ return type has no value type specified in iterable type array#' - - '#Function .+ has parameter \$.+ with no value type specified in iterable type array#' - - '#Function .+ return type with generic class .+ does not specify its types#' - # Allow new static() in traits - will be refactored in Phase 1 - - '#Unsafe usage of new static\(\)#' - # Allow undefined property access in traits that access parent class properties - - '#Access to an undefined property .+::\$unsafeRun#' - # Generic type issues - will be fixed with proper type classes - - '#PHPDoc tag @return contains generic type .+ but (class|interface) .+ is not generic#' - - '#PHPDoc tag @param .+ contains generic type .+ but (class|interface) .+ is not generic#' - - '#PHPDoc tag @return with type .+ is not subtype of native type#' - - '#PHPDoc tag @param .+ with type .+ is not subtype of native type#' - - '#Method .+ has invalid return type .+\\Ops\\B#' - - '#PHPDoc tag @template .+ is not referenced in a parameter#' - - '#has parameter \$.+ with no type specified#' - - '#Method .+ should return .+ but returns#' - - '#Call to an undefined method .+::apply\(\)#' - - '#Template type B of method .+::flatMap\(\) is not referenced in a parameter#' + # HKT Limitation: PHPStan can't express that Kind is a higher-kinded type marker + # Kind is used to simulate HKTs in PHP - it's intentionally not generic + - '#PHPDoc tag @param .+ contains generic type Phunkie\\Types\\Kind\<.+\> but interface Phunkie\\Types\\Kind is not generic#' + + # Method body inference: PHPStan can't infer generic types through closures in method bodies + # The phunkie/phpstan extension provides correct types at CALL SITES + # These ignores suppress false positives in the method DEFINITIONS + - '#Method .+::zipWith\(\) should return .+ but returns .+Pair#' + - '#Method .+::attempt\(\) should return .+ but returns .+Validation#' reportUnmatchedIgnoredErrors: false + diff --git a/scripts/pre-commit b/scripts/pre-commit new file mode 100755 index 0000000..3f8a882 --- /dev/null +++ b/scripts/pre-commit @@ -0,0 +1,30 @@ +#!/bin/bash + +# Pre-commit hook for Phunkie projects +# Runs lint and tests before allowing commit + +set -e + +echo "๐Ÿ” Running pre-commit checks..." + +# Run lint (cs-check + phpstan) +echo "" +echo "๐Ÿ“‹ Running lint checks..." +if ! composer lint; then + echo "" + echo "โŒ Lint checks failed. Please fix the issues before committing." + echo " Run 'composer cs-fix' to auto-fix code style issues." + exit 1 +fi + +# Run tests +echo "" +echo "๐Ÿงช Running tests..." +if ! composer test; then + echo "" + echo "โŒ Tests failed. Please fix the issues before committing." + exit 1 +fi + +echo "" +echo "โœ… All pre-commit checks passed!" diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..298d99c --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +# Setup script for development environment +# Installs git hooks and dependencies + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +echo "๐Ÿ”ง Setting up development environment..." + +# Install composer dependencies +echo "" +echo "๐Ÿ“ฆ Installing dependencies..." +composer install + +# Install pre-commit hook +echo "" +echo "๐Ÿช Installing git hooks..." +cp "$SCRIPT_DIR/pre-commit" "$PROJECT_ROOT/.git/hooks/pre-commit" +chmod +x "$PROJECT_ROOT/.git/hooks/pre-commit" + +echo "" +echo "โœ… Development environment setup complete!" +echo "" +echo "Available commands:" +echo " composer test - Run tests" +echo " composer lint - Run code style and static analysis" +echo " composer cs-fix - Auto-fix code style issues" +echo " composer check - Run lint + tests" +echo " composer test-all - Run tests on all PHP versions (requires Docker)" diff --git a/scripts/test-all-versions.sh b/scripts/test-all-versions.sh new file mode 100755 index 0000000..e7faf68 --- /dev/null +++ b/scripts/test-all-versions.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +# Test against all supported PHP versions using Docker +# Requires Docker to be installed and running + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +PHP_VERSIONS=("8.2" "8.3" "8.4") + +echo "==========================================" +echo "Running lint + tests on PHP versions: ${PHP_VERSIONS[*]}" +echo "==========================================" + +for version in "${PHP_VERSIONS[@]}"; do + echo "" + echo "==========================================" + echo "PHP $version - Installing dependencies..." + echo "==========================================" + + docker run --rm -v "$PROJECT_ROOT:/app" -w /app \ + "php:${version}-cli" \ + sh -c "apt-get update -qq && apt-get install -y -qq git unzip > /dev/null && \ + curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer > /dev/null && \ + composer install --no-interaction --prefer-dist --quiet && \ + echo '--- Running lint (cs-check + phpstan) ---' && \ + composer lint && \ + echo '--- Running tests ---' && \ + composer test" + + echo "โœ… PHP $version passed (lint + tests)" +done + +echo "" +echo "==========================================" +echo "โœ… All PHP versions passed!" +echo "==========================================" + diff --git a/src/Cats/Parallel.php b/src/Cats/Parallel.php index a4bf2a9..c9075b6 100644 --- a/src/Cats/Parallel.php +++ b/src/Cats/Parallel.php @@ -11,7 +11,31 @@ namespace Phunkie\Effect\Cats; +/** + * Interface representing a computation that can be run in parallel. + * + * This interface corresponds to Cats' Parallel type class, allowing + * conversion between sequential (Monad) and parallel (Applicative) execution modes. + * + * @template A + */ interface Parallel { + /** + * Combines this parallel computation with another using a combining function. + * + * @template B + * @template C + * @param Parallel $fb The computation to combine with + * @param callable(A, B): C $f The function to combine the results + * @return Parallel The result of combined computation + */ public function parMap2(Parallel $fb, callable $f): Parallel; + + /** + * Returns the underlying unsafe runner. + * + * @return callable(): A + */ + public function getUnsafeRun(): callable; } diff --git a/src/Concurrent/AsyncHandle.php b/src/Concurrent/AsyncHandle.php index 6a4925f..3e5d92e 100644 --- a/src/Concurrent/AsyncHandle.php +++ b/src/Concurrent/AsyncHandle.php @@ -11,7 +11,20 @@ namespace Phunkie\Effect\Concurrent; +/** + * Represents a handle to an asynchronous computation. + * + * An AsyncHandle allows waiting for the result of an asynchronous operation, + * similar to a Future or Promise in other languages. + * + * @template A + */ interface AsyncHandle { + /** + * Blocks until the asynchronous computation is complete and returns the result. + * + * @return A The result of the computation + */ public function await(): mixed; } diff --git a/src/Concurrent/Blocker.php b/src/Concurrent/Blocker.php index 8ef1c4a..5239657 100644 --- a/src/Concurrent/Blocker.php +++ b/src/Concurrent/Blocker.php @@ -11,8 +11,19 @@ namespace Phunkie\Effect\Concurrent; +/** + * Provides an execution context for blocking operations. + * + * Blocker is designed to isolate blocking code from the main execution context, + * typically by running it on a separate thread or fiber pool to prevent + * starving the main event loop. + */ class Blocker { + /** + * @param \Closure(): mixed $thunk The computation to execute + * @param ExecutionContext|null $context The execution context to use (defaults to FiberExecutionContext) + */ public function __construct( private readonly \Closure $thunk, private ?ExecutionContext $context = null @@ -22,16 +33,31 @@ public function __construct( } } + /** + * Executes the blocking operation asynchronously. + * + * @return AsyncHandle A handle to await the result + */ public function __invoke(): AsyncHandle { return $this->context->executeAsync($this->thunk); } + /** + * Executes the blocking operation synchronously. + * + * @return mixed The result of the computation + */ public function runSync(): mixed { return $this->context?->execute($this->thunk); } + /** + * Returns the underlying execution context. + * + * @return ExecutionContext + */ public function blockingContext(): ExecutionContext { return $this->context ?? new FiberExecutionContext(); diff --git a/src/Concurrent/ExecutionContext.php b/src/Concurrent/ExecutionContext.php index c06f44b..992314a 100644 --- a/src/Concurrent/ExecutionContext.php +++ b/src/Concurrent/ExecutionContext.php @@ -11,14 +11,29 @@ namespace Phunkie\Effect\Concurrent; +/** + * Interface for execution contexts. + * + * An ExecutionContext defines how computations are executed. Implementations + * may execute code synchronously (e.g., in a Fiber) or asynchronously + * (e.g., using ext-parallel threads). + */ interface ExecutionContext { /** * Runs the given thunk in this execution context. * - * @param callable $thunk The operation to run + * @param callable(): mixed $thunk The operation to run * @return mixed The result of running the thunk */ public function execute(callable $thunk): mixed; + + /** + * Runs the given thunk asynchronously in this execution context. + * + * @template A + * @param callable(): A $thunk The operation to run + * @return AsyncHandle A handle for the result + */ public function executeAsync(callable $thunk): AsyncHandle; } diff --git a/src/Concurrent/FiberExecutionContext.php b/src/Concurrent/FiberExecutionContext.php index 6a3fa26..04b1047 100644 --- a/src/Concurrent/FiberExecutionContext.php +++ b/src/Concurrent/FiberExecutionContext.php @@ -14,10 +14,21 @@ use FiberError; use Throwable; +/** + * Execution context based on PHP Fibers. + * + * This implementation uses PHP 8.1+ Fibers to execute computations cooperatively. + * It is single-threaded (uses the main thread) but supports non-blocking behavior + * when used with Fiber-aware primitives. + */ class FiberExecutionContext implements ExecutionContext { /** - * @throws FiberError|Throwable + * Executes the thunk in a new Fiber. + * + * @param callable(): mixed $thunk The operation to run + * @return mixed The result of running the thunk + * @throws FiberError|Throwable If the fiber fails */ public function execute(callable $thunk): mixed { @@ -31,6 +42,16 @@ public function execute(callable $thunk): mixed return $fiber->getReturn(); } + /** + * Executes the thunk asynchronously. + * + * In this implementation, despite the name, the execution happens + * when `await()` is called, effectively deferring execution. + * + * @template A + * @param callable(): A $thunk The operation to run + * @return AsyncHandle A handle for the result + */ public function executeAsync(callable $thunk): AsyncHandle { $that = $this; diff --git a/src/Concurrent/ParallelExecutionContext.php b/src/Concurrent/ParallelExecutionContext.php index baf00a0..dc3bbfc 100644 --- a/src/Concurrent/ParallelExecutionContext.php +++ b/src/Concurrent/ParallelExecutionContext.php @@ -15,10 +15,20 @@ use parallel\Runtime; use Throwable; +/** + * Execution context using ext-parallel for true parallelism. + * + * This implementation utilizes the `parallel` extension (requires ZTS PHP) + * to execute computations in separate threads. + */ class ParallelExecutionContext implements ExecutionContext { /** - * @throws Throwable + * Executes the thunk in a new thread and blocks until completion. + * + * @param callable(): mixed $thunk The operation to run + * @return mixed The result of running the thunk + * @throws Throwable If execution fails or ext-parallel is missing */ public function execute(callable $thunk): mixed { @@ -32,6 +42,16 @@ public function execute(callable $thunk): mixed } } + /** + * Executes the thunk asynchronously in a new thread. + * + * Returns an AsyncHandle that can be awaited to retrieve the result. + * + * @template A + * @param callable(): A $thunk The operation to run + * @return AsyncHandle A handle for the result + * @throws \RuntimeException If ext-parallel is missing + */ public function executeAsync(callable $thunk): AsyncHandle { if (\extension_loaded('parallel')) { diff --git a/src/Functions/blocking.php b/src/Functions/blocking.php index 821ca58..2601f1e 100644 --- a/src/Functions/blocking.php +++ b/src/Functions/blocking.php @@ -20,6 +20,15 @@ use Phunkie\Effect\IO\IO; const blocking = '\Phunkie\Effect\Functions\blocking\blocking'; + +/** + * Wraps a blocking computation in an IO using a Blocker to isolate it. + * + * @template A + * @param \Closure(): A $thunk The blocking operation + * @param ExecutionContext|null $context Optional execution context (uses fiber default if null) + * @return IO + */ function blocking(Closure $thunk, ?ExecutionContext $context = null): IO { return io(new Blocker($thunk, $context)); diff --git a/src/Functions/console.php b/src/Functions/console.php index 48f9e4c..3b93aa2 100644 --- a/src/Functions/console.php +++ b/src/Functions/console.php @@ -13,21 +13,51 @@ use Phunkie\Effect\IO\IO; use Phunkie\Types\ImmList; +use Phunkie\Types\Unit; const printLn = '\Phunkie\Effect\Functions\console\printLn'; + +/** + * Prints a message to stdout followed by a newline. + * + * @param string $message The message to print + * @return IO + */ function printLn(string $message): IO { - return new IO(fn () => print($message . PHP_EOL)); + return new IO(function () use ($message): Unit { + print($message . PHP_EOL); + + return Unit(); + }); } const printLines = '\Phunkie\Effect\Functions\console\printLines'; + +/** + * Prints a list of messages to stdout, each followed by a newline. + * + * @param ImmList $lines The lines to print + * @return IO + */ function printLines(ImmList $lines): IO { - return new IO(fn () => - $lines->withEach(fn ($message) => print($message . PHP_EOL))); + return new IO(function () use ($lines): Unit { + $lines->withEach(fn ($message) => print($message . PHP_EOL)); + + return Unit(); + }); } const readLine = '\Phunkie\Effect\Functions\console\readLine'; + +/** + * Reads a line from the input stream. + * + * @param string $prompt Optional prompt to display + * @param resource|null $stream Optional input stream (defaults to STDIN) + * @return IO + */ function readLine(string $prompt, $stream = null): IO { return new IO(function () use ($prompt, $stream) { @@ -40,36 +70,78 @@ function readLine(string $prompt, $stream = null): IO } const printError = '\Phunkie\Effect\Functions\console\printError'; + +/** + * Prints an error message (red) to stdout. + * + * @param string $message The error message + * @return IO + */ function printError(string $message): IO { return new IO(fn () => print("\033[31mError: {$message}\033[0m" . PHP_EOL)); } const printWarning = '\Phunkie\Effect\Functions\console\printWarning'; + +/** + * Prints a warning message (yellow) to stdout. + * + * @param string $message The warning message + * @return IO + */ function printWarning(string $message): IO { return new IO(fn () => print("\033[33mWarning: {$message}\033[0m" . PHP_EOL)); } const printSuccess = '\Phunkie\Effect\Functions\console\printSuccess'; + +/** + * Prints a success message (green) to stdout. + * + * @param string $message The success message + * @return IO + */ function printSuccess(string $message): IO { return new IO(fn () => print("\033[32mSuccess: {$message}\033[0m" . PHP_EOL)); } const printInfo = '\Phunkie\Effect\Functions\console\printInfo'; + +/** + * Prints an info message (cyan) to stdout. + * + * @param string $message The info message + * @return IO + */ function printInfo(string $message): IO { return new IO(fn () => print("\033[36mInfo: {$message}\033[0m" . PHP_EOL)); } const printDebug = '\Phunkie\Effect\Functions\console\printDebug'; + +/** + * Prints a debug message (magenta) to stdout. + * + * @param string $message The debug message + * @return IO + */ function printDebug(string $message): IO { return new IO(fn () => print("\033[35mDebug: {$message}\033[0m" . PHP_EOL)); } const printTable = '\Phunkie\Effect\Functions\console\printTable'; + +/** + * Prints a formatted table to stdout. + * + * @param array> $data 2D array of data to print + * @return IO + */ function printTable(array $data): IO { return new IO(function () use ($data) { @@ -101,6 +173,14 @@ function printTable(array $data): IO } const printProgress = '\Phunkie\Effect\Functions\console\printProgress'; + +/** + * Prints a progress bar to stdout (in-place). + * + * @param int $current Current progress value + * @param int $total Total value + * @return IO + */ function printProgress(int $current, int $total): IO { return new IO(function () use ($current, $total) { @@ -115,6 +195,13 @@ function printProgress(int $current, int $total): IO } const printSpinner = '\Phunkie\Effect\Functions\console\printSpinner'; + +/** + * Prints a spinner animation with a message to stdout. + * + * @param string $message The message to display next to spinner + * @return IO + */ function printSpinner(string $message): IO { return new IO(function () use ($message) { diff --git a/src/Functions/io.php b/src/Functions/io.php index a6655b1..3bb5c3f 100644 --- a/src/Functions/io.php +++ b/src/Functions/io.php @@ -14,13 +14,15 @@ use Phunkie\Effect\IO\IO; /** - * Creates an IO from either a callable or a plain value. + * Creates an IO effect from a value or a callable. + * + * If the input is a callable, it is wrapped in an IO as a thunk (lazy execution). + * If the input is a value, it is wrapped in an IO that returns that value (pure). * * @template A - * @param callable|A $value + * @param callable(): A|A $value The side-effecting function or pure value * @return IO */ - const io = "\\Phunkie\\Effect\\Functions\\io\\io"; const IO = "\\Phunkie\\Effect\\Functions\\io\\io"; function io($value): IO diff --git a/src/IO/IO.php b/src/IO/IO.php index de1466b..e8aaf4a 100644 --- a/src/IO/IO.php +++ b/src/IO/IO.php @@ -25,7 +25,18 @@ use Throwable; /** - * @template A + * The IO Monad. + * + * IO represents a computation that performs side effects. + * It is lazily evaluated, meaning the side effects are not performed until + * `unsafeRun()` or `unsafeRunSync()` is called. + * + * It is the core data type of the effect system, supporting: + * - Sequential execution (flatMap) + * - Parallel execution (via Parallel type class) + * - Error handling + * + * @template-covariant A */ class IO implements Functor, Applicative, Monad, Parallel, Kind { @@ -36,16 +47,44 @@ class IO implements Functor, Applicative, Monad, Parallel, Kind private $unsafeRun; + /** + * @param callable(): A $unsafeRun The effectful computation + */ public function __construct(callable $unsafeRun) { $this->unsafeRun = $unsafeRun; } + /** + * Executes the effect and returns the result. + * + * WARNING: This method performs side effects. It should ideally only be called + * at the "end of the world" (e.g., in the main entry point of the application). + * + * @return A + * @throws Throwable + */ public function unsafeRun(): mixed { return ($this->unsafeRun)(); } + /** + * Returns the underlying unsafe runner callable. + * + * @return callable(): A + */ + public function getUnsafeRun(): callable + { + return $this->unsafeRun; + } + + /** + * Executes the effect synchronously, awaiting any async handles. + * + * @return A + * @throws Throwable + */ public function unsafeRunSync(): mixed { $handle = ($this->unsafeRun)(); @@ -57,6 +96,13 @@ public function unsafeRunSync(): mixed return $handle; } + /** + * Handles an error that occurred during the execution of this IO. + * + * @template B + * @param callable(Throwable): B $handler Function to recover from the error + * @return IO + */ public function handleError(callable $handler): IO { return new IO(function () use ($handler) { @@ -69,18 +115,38 @@ public function handleError(callable $handler): IO } /** - * @return IO> + * Attempts to execute the effect, capturing any error in a Validation. + * + * This mirrors the `attempt` behavior in Scala/Cats, where the result + * is lifted into an Either (here Validation) to handle errors as values. + * + * @return IO> IO containing either Success(A) or Failure(Throwable) */ public function attempt(): IO { - return new IO(fn () => Attempt($this->unsafeRun)); + $run = $this->unsafeRun; + + return new IO( + /** @return Validation<\Throwable, A> */ + fn () => Attempt($run) + ); } + /** + * Returns the arity of the IO type constructor. + * + * @return int Always 1 for IO + */ public function getTypeArity(): int { return 1; } + /** + * Returns the type variable names for IO. + * + * @return array Always ['A'] + */ public function getTypeVariables(): array { return ['A']; diff --git a/src/IO/IOApp.php b/src/IO/IOApp.php index ac0c177..b57055d 100644 --- a/src/IO/IOApp.php +++ b/src/IO/IOApp.php @@ -21,7 +21,13 @@ abstract class IOApp { /** - * @return IO + * The main entry point for the application. + * + * This method is called by the runtime and should return an IO effect + * that produces an integer exit code. + * + * @param array|null $args Command line arguments + * @return IO The exit code (0 for success, non-zero for failure) */ abstract public function run(?array $args = []): IO; } diff --git a/src/Ops/IO/ApplicativeOps.php b/src/Ops/IO/ApplicativeOps.php index 7397d3e..ed2faa9 100644 --- a/src/Ops/IO/ApplicativeOps.php +++ b/src/Ops/IO/ApplicativeOps.php @@ -22,8 +22,10 @@ trait ApplicativeOps { /** + * Lifts a value into the IO context. + * * @template B - * @param B $a + * @param B $a The value to lift * @return IO */ public function pure(mixed $a): IO @@ -34,8 +36,10 @@ public function pure(mixed $a): IO } /** + * Applies the function contained in this IO to the value contained in another IO. + * * @template B - * @param Kind|IO $f + * @param Kind|IO $f The IO containing the value * @return IO */ public function apply(Kind|IO $f): IO @@ -52,10 +56,12 @@ public function apply(Kind|IO $f): IO } /** + * Map a function over two IO values. + * * @template B * @template C - * @param Kind|IO $fb - * @param callable(mixed,B):C $f + * @param Kind|IO $fb The second IO value + * @param callable(mixed,B):C $f The function to apply * @return IO */ public function map2(Kind|IO $fb, callable $f): IO diff --git a/src/Ops/IO/FunctorOps.php b/src/Ops/IO/FunctorOps.php index 7f590c7..d1ea8b7 100644 --- a/src/Ops/IO/FunctorOps.php +++ b/src/Ops/IO/FunctorOps.php @@ -23,8 +23,10 @@ trait FunctorOps { /** + * Maps a function over the IO value. + * * @template B - * @param callable(A): B $f + * @param callable(A): B $f The function to apply * @return IO */ public function map(callable $f): IO @@ -35,8 +37,10 @@ public function map(callable $f): IO } /** + * Lifts a function to operate on IO values. + * * @template B - * @param callable(A): B $f + * @param callable(A): B $f The function to lift * @return callable(IO): IO */ public function lift($f): callable @@ -47,8 +51,10 @@ public function lift($f): callable } /** + * Replaces the IO value with a constant value. + * * @template B - * @param B $b + * @param B $b The constant value * @return IO */ public function as(mixed $b): IO @@ -59,6 +65,8 @@ public function as(mixed $b): IO } /** + * Discards the IO value and returns Unit. + * * @return IO */ public function void(): IO @@ -67,21 +75,26 @@ public function void(): IO } /** + * Paris the IO value with the result of applying a function to it. + * * @template B - * @param callable(A): B $f + * @param callable(A): B $f The function to produce the second element * @return IO> */ - /** - * @param callable $f - */ public function zipWith($f): IO { - return $this->map(function ($a) use ($f): Pair { - return Pair($a, $f($a)); - }); + return $this->map( + /** @return Pair */ + function ($a) use ($f): Pair { + return Pair($a, $f($a)); + } + ); } /** + * Invariant map (not used for IO, but part of Functor in some contexts). + * IO is covariant, so this delegates to map. + * * @template B * @param callable(A): B $f * @param callable(B): A $g diff --git a/src/Ops/IO/MonadOps.php b/src/Ops/IO/MonadOps.php index 243eafa..ad5634a 100644 --- a/src/Ops/IO/MonadOps.php +++ b/src/Ops/IO/MonadOps.php @@ -19,8 +19,10 @@ trait MonadOps { /** + * Binds a function to the IO value, returning a new IO. + * * @template B - * @param callable(A):IO $f + * @param callable(A):IO $f The function to bind * @return IO */ public function flatMap(callable $f): IO @@ -33,7 +35,9 @@ public function flatMap(callable $f): IO } /** - * @return IO where B is the type parameter of the inner IO + * Flattens a nested IO structure (IO> -> IO). + * + * @return IO where A is the type parameter of the inner IO */ public function flatten(): IO { diff --git a/src/Ops/IO/ParallelOps.php b/src/Ops/IO/ParallelOps.php index 85254b4..c1e5c53 100644 --- a/src/Ops/IO/ParallelOps.php +++ b/src/Ops/IO/ParallelOps.php @@ -26,25 +26,30 @@ trait ParallelOps { /** + * Combines two parallel computations using a combining function. + * Starts execution of both computations immediately (if in a parallel context). + * * @template B * @template C - * @param Parallel $fb - * @param callable(A, B): C $f + * @param Parallel $fb The second computation + * @param callable(A, B): C $f The combining function * @return IO */ public function parMap2(Parallel $fb, callable $f): IO { return io(function () use ($fb, $f) { - if (! $this->unsafeRun instanceof Blocker && ! $this->unsafeRun->blockingContext() instanceof ParallelExecutionContext) { + $runA = $this->unsafeRun; + if (! $runA instanceof Blocker || ! $runA->blockingContext() instanceof ParallelExecutionContext) { throw new \Exception("First effect Blocker isn't in a parallel context"); } - if (! $fb->unsafeRun instanceof Blocker && ! $fb->unsafeRun->blockingContext() instanceof ParallelExecutionContext) { + $runB = $fb->getUnsafeRun(); + if (! $runB instanceof Blocker || ! $runB->blockingContext() instanceof ParallelExecutionContext) { throw new \Exception("Second effect Blocker isn't in a parallel context"); } - $handle1 = ($this->unsafeRun)(); - $handle2 = ($fb->unsafeRun)(); + $handle1 = $runA(); + $handle2 = $runB(); $a = $handle1->await(); $b = $handle2->await(); @@ -54,31 +59,36 @@ public function parMap2(Parallel $fb, callable $f): IO } /** + * Combines three parallel computations using a combining function. + * * @template B * @template C - * @param Parallel $fb - * @param Parallel $fc - * @param callable(A, B, C): IO $f + * @param Parallel $fb The second computation + * @param Parallel $fc The third computation + * @param callable(A, B, C): IO $f The combining function * @return IO */ public function parMap3(Parallel $fb, Parallel $fc, callable $f): IO { return io(function () use ($fb, $fc, $f) { - if (! $this->unsafeRun instanceof Blocker && ! $this->unsafeRun->blockingContext() instanceof ParallelExecutionContext) { + $runA = $this->unsafeRun; + if (! $runA instanceof Blocker || ! $runA->blockingContext() instanceof ParallelExecutionContext) { throw new \Exception("First effect Blocker isn't in a parallel context"); } - if (! $fb->unsafeRun instanceof Blocker && ! $fb->unsafeRun->blockingContext() instanceof ParallelExecutionContext) { + $runB = $fb->getUnsafeRun(); + if (! $runB instanceof Blocker || ! $runB->blockingContext() instanceof ParallelExecutionContext) { throw new \Exception("Second effect Blocker isn't in a parallel context"); } - if (! $fc->unsafeRun instanceof Blocker && ! $fc->unsafeRun->blockingContext() instanceof ParallelExecutionContext) { + $runC = $fc->getUnsafeRun(); + if (! $runC instanceof Blocker || ! $runC->blockingContext() instanceof ParallelExecutionContext) { throw new \Exception("Third effect Blocker isn't in a parallel context"); } - $handle1 = ($this->unsafeRun)(); - $handle2 = ($fb->unsafeRun)(); - $handle3 = ($fc->unsafeRun)(); + $handle1 = $runA(); + $handle2 = $runB(); + $handle3 = $runC(); $a = $handle1->await(); $b = $handle2->await(); @@ -89,26 +99,32 @@ public function parMap3(Parallel $fb, Parallel $fc, callable $f): IO } /** + * Combines multiple parallel computations using a combining function. + * * @template B * @template C - * @param array> $fbs - * @param callable(B ...$args): C $f + * @param array> $fbs Array of computations + * @param callable(B ...$args): C $f The combining function * @return IO */ public function parMapN(array $fbs, callable $f): IO { return io(function () use ($fbs, $f) { - if (! $this->unsafeRun instanceof Blocker && ! $this->unsafeRun->blockingContext() instanceof ParallelExecutionContext) { + $runA = $this->unsafeRun; + if (! $runA instanceof Blocker || ! $runA->blockingContext() instanceof ParallelExecutionContext) { throw new \Exception("First effect Blocker isn't in a parallel context"); } + $runners = []; foreach ($fbs as $fb) { - if (! $fb->unsafeRun instanceof Blocker && ! $fb->unsafeRun->blockingContext() instanceof ParallelExecutionContext) { + $runner = $fb->getUnsafeRun(); + if (! $runner instanceof Blocker || ! $runner->blockingContext() instanceof ParallelExecutionContext) { throw new \Exception("Effect Blocker isn't in a parallel context"); } + $runners[] = $runner; } - $handles = array_map(fn ($fb) => ($fb->unsafeRun)(), $fbs); + $handles = array_map(fn (Blocker $runner) => $runner(), $runners); $results = array_map(fn ($handle) => $handle->await(), $handles);