From 83881ba38ee18096f47c496eb595f26e9b90b8bb Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Sun, 2 Aug 2026 16:33:01 +0100 Subject: [PATCH 1/4] Support PHP 8.5 syntax in the REPL The REPL interprets the AST rather than eval'ing it, so new syntax needs an explicit evaluator branch. Adds the two 8.5 expression forms, plus the gap one of them sat on. Casts had no evaluator branch at all: even (int) "42" reported "Unsupported expression type". Adding only (void) would have left every sibling cast broken, so this adds one Expr\Cast branch covering int, float, string, bool, array and object, with (void) as one further arm. (void) discards its operand's value and produces no output, reusing the existing __no_output__ path. The pipe operator applies its right operand to its left rather than combining two values, so it gets its own handler ahead of the operator table, in the same shape as the existing Coalesce special case. Piping into your own functions is the main use case, which ran into a documented limitation: first-class callable syntax was rejected for user-defined functions, on the stated grounds that they are "stored as AST nodes, not as callable PHP functions". They are in fact stored as closures, so the restriction was unnecessary. Removed it and replaced the "known limitation" scenario with the behaviour that actually works. Derives the Behat version gating from the feature directory names instead of a hardcoded elif chain, so features/repl/php8.6/ will need no change here. This also fixes a latent bug: the old 8.2 and 8.3 branches rooted their find at features/repl, silently never running features/execution. Scenario count on 8.2 goes 327 -> 329, exactly the two scenarios in run_app.feature. Bumps phunkie to 1.3.0, which fixes the SplObjectStorage deprecation that broke every session variable assignment on 8.5. Behat there went from 228 of 421 passing to all of them. Behat, PHPUnit, PHPStan and PHP-CS-Fixer green on 8.2, 8.3, 8.4 and 8.5: 341, 384, 433 and 447 scenarios respectively. --- .github/workflows/ci.yml | 6 +- README.md | 4 +- bin/run-behat-tests.sh | 48 ++++++---- composer.json | 4 +- composer.lock | 30 +++--- docs/index.md | 2 +- features/repl/casts.feature | 62 +++++++++++++ features/repl/first_class_callable.feature | 20 ++-- features/repl/php8.5/pipe_operator.feature | 51 +++++++++++ features/repl/php8.5/void_cast.feature | 48 ++++++++++ scripts/test-all-versions.sh | 2 +- src/Functions/evaluation.php | 102 +++++++++++++++++++-- 12 files changed, 325 insertions(+), 54 deletions(-) create mode 100644 features/repl/casts.feature create mode 100644 features/repl/php8.5/pipe_operator.feature create mode 100644 features/repl/php8.5/void_cast.feature diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e34877..3726e68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - php-version: ['8.2', '8.3', '8.4'] + php-version: ['8.2', '8.3', '8.4', '8.5'] name: PHP ${{ matrix.php-version }} steps: - uses: actions/checkout@v4 @@ -50,7 +50,7 @@ jobs: strategy: fail-fast: false matrix: - php-version: ['8.2', '8.3', '8.4'] + php-version: ['8.2', '8.3', '8.4', '8.5'] steps: - uses: actions/checkout@v4 - name: Setup PHP @@ -69,7 +69,7 @@ jobs: strategy: fail-fast: false matrix: - php-version: ['8.2', '8.3', '8.4'] + php-version: ['8.2', '8.3', '8.4', '8.5'] steps: - uses: actions/checkout@v4 - name: Unset local path repositories diff --git a/README.md b/README.md index a158797..49d8e25 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ A powerful, interactive REPL (Read-Eval-Print Loop) console for [Phunkie](https: ## Requirements -- PHP 8.2, 8.3, or 8.4 +- PHP 8.2, 8.3, 8.4, or 8.5 - Composer ## Installation @@ -169,7 +169,7 @@ Run version-specific tests: The project maintains comprehensive test coverage: - **Unit Tests**: Testing individual components with PHPUnit - **Acceptance Tests**: End-to-end REPL functionality with Behat -- **Cross-version Testing**: Automated testing across PHP 8.2, 8.3, and 8.4 +- **Cross-version Testing**: Automated testing across PHP 8.2, 8.3, 8.4, and 8.5 ### Code Quality diff --git a/bin/run-behat-tests.sh b/bin/run-behat-tests.sh index 2efa1f8..57203b7 100755 --- a/bin/run-behat-tests.sh +++ b/bin/run-behat-tests.sh @@ -1,29 +1,41 @@ #!/bin/bash -# Run Behat tests with version-appropriate feature inclusions/exclusions +# Run Behat with the features the running PHP version can actually support. +# +# Features that depend on a specific PHP release live in a directory named after +# it, e.g. features/repl/php8.5/. Each such directory is skipped when the running +# PHP is older than the version it names, so adding features/repl/php8.6/ needs +# no change to this script. set -e PHP_VERSION=$(php -r 'echo PHP_VERSION_ID;') -if [ "$PHP_VERSION" -lt 80300 ]; then - # PHP 8.2: run only compatible features (everything except 8.3 and 8.4 subdirectories) - echo "Running tests for PHP 8.2 (excluding PHP 8.3+ and 8.4 features)" +excluded=() +for dir in features/*/php[0-9]*.[0-9]*; do + [ -d "$dir" ] || continue - # Run all features that are not in php8.3 or php8.4 subdirectories - find features/repl -type f -name "*.feature" ! -path "*/php8.3/*" ! -path "*/php8.4/*" -print0 | \ - xargs -0 ./vendor/bin/behat --format=progress + version=${dir##*/php} + major=${version%%.*} + minor=${version##*.} + required=$((major * 10000 + minor * 100)) -elif [ "$PHP_VERSION" -lt 80400 ]; then - # PHP 8.3: run compatible features (everything except 8.4 subdirectory) - echo "Running tests for PHP 8.3 (excluding PHP 8.4 features)" + if [ "$PHP_VERSION" -lt "$required" ]; then + excluded+=("$dir") + fi +done - # Run all features that are not in php8.4 subdirectory - find features/repl -type f -name "*.feature" ! -path "*/php8.4/*" -print0 | \ - xargs -0 ./vendor/bin/behat --format=progress - -else - # PHP 8.4+: run all tests - echo "Running all tests for PHP 8.4+" - ./vendor/bin/behat --format=progress +if [ ${#excluded[@]} -eq 0 ]; then + echo "Running all features" + exec ./vendor/bin/behat --format=progress fi + +echo "Skipping features that need a newer PHP: ${excluded[*]}" + +prune=() +for dir in "${excluded[@]}"; do + prune+=(! -path "$dir/*") +done + +find features -type f -name "*.feature" "${prune[@]}" -print0 | \ + xargs -0 ./vendor/bin/behat --format=progress diff --git a/composer.json b/composer.json index e5e1f26..bcf1c67 100644 --- a/composer.json +++ b/composer.json @@ -15,8 +15,8 @@ } ], "require": { - "php": "^8.2 || ^8.3 || ^8.4", - "phunkie/phunkie": "^1.0", + "php": "^8.2 || ^8.3 || ^8.4 || ^8.5", + "phunkie/phunkie": "^1.3", "phunkie/effect": "^1.1", "nikic/php-parser": "^5.6" }, diff --git a/composer.lock b/composer.lock index ea29cea..9dbf55f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ddda1dba418e2603d4c8ea74052e75c6", + "content-hash": "0a5eec3b6893b5edf3ed95c5a1abd02e", "packages": [ { "name": "nikic/php-parser", @@ -148,28 +148,28 @@ }, { "name": "phunkie/phunkie", - "version": "1.0.0", + "version": "1.3.0", "source": { "type": "git", "url": "https://github.com/phunkie/phunkie.git", - "reference": "edfe0c5e3b382d8827bdaef5c0ef027840eceaf5" + "reference": "43a800e55e8c91a0f104cdff261039453db605f3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phunkie/phunkie/zipball/edfe0c5e3b382d8827bdaef5c0ef027840eceaf5", - "reference": "edfe0c5e3b382d8827bdaef5c0ef027840eceaf5", + "url": "https://api.github.com/repos/phunkie/phunkie/zipball/43a800e55e8c91a0f104cdff261039453db605f3", + "reference": "43a800e55e8c91a0f104cdff261039453db605f3", "shasum": "" }, "require": { - "php": "^8.2 || ^8.3 || ^8.4" + "php": "8.2.* || 8.3.* || 8.4.* || 8.5.*" }, "require-dev": { - "ergebnis/composer-normalize": "^2", - "friendsofphp/php-cs-fixer": "^3.90", - "giorgiosironi/eris": "^0", - "phpstan/phpstan": "^2.0", - "phpunit/phpunit": "^9", - "phunkie/phpstan": "@dev" + "ergebnis/composer-normalize": "2.52.0", + "friendsofphp/php-cs-fixer": "3.95.13", + "giorgiosironi/eris": "1.1.0", + "phpstan/phpstan": "2.2.5", + "phpunit/phpunit": "11.5.56", + "phunkie/phpstan": "1.0.0" }, "type": "library", "autoload": { @@ -195,9 +195,9 @@ "description": "Functional structures library for PHP", "support": { "issues": "https://github.com/phunkie/phunkie/issues", - "source": "https://github.com/phunkie/phunkie/tree/1.0.0" + "source": "https://github.com/phunkie/phunkie/tree/1.3.0" }, - "time": "2025-12-08T18:48:37+00:00" + "time": "2026-08-02T15:07:02+00:00" } ], "packages-dev": [ @@ -5311,7 +5311,7 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.2 || ^8.3 || ^8.4" + "php": "^8.2 || ^8.3 || ^8.4 || ^8.5" }, "platform-dev": {}, "plugin-api-version": "2.6.0" diff --git a/docs/index.md b/docs/index.md index 661b803..f4cdf49 100644 --- a/docs/index.md +++ b/docs/index.md @@ -45,7 +45,7 @@ composer global require phunkie/console ### System Requirements -- PHP 8.2, 8.3, or 8.4 +- PHP 8.2, 8.3, 8.4, or 8.5 - Composer - readline extension (usually included with PHP) diff --git a/features/repl/casts.feature b/features/repl/casts.feature new file mode 100644 index 0000000..04325c5 --- /dev/null +++ b/features/repl/casts.feature @@ -0,0 +1,62 @@ +Feature: Type casts + As a PHP developer + I want to use type casts in the REPL + So that I can convert values between types + + Scenario: Casting a numeric string to int + Given I start the REPL + When I enter "(int) \"42\"" + Then I should see output containing "Int = 42" + + Scenario: Casting a float to int truncates + Given I start the REPL + When I enter "(int) 1.9" + Then I should see output containing "Int = 1" + + Scenario: Casting a string to float + Given I start the REPL + When I enter "(float) \"3.5\"" + Then I should see output containing "Float = 3.5" + + Scenario: Casting an int to string + Given I start the REPL + When I enter "(string) 42" + Then I should see output containing "String = \"42\"" + + Scenario: Casting zero to bool + Given I start the REPL + When I enter "(bool) 0" + Then I should see output containing "Bool = false" + + Scenario: Casting a non-empty string to bool + Given I start the REPL + When I enter "(bool) \"phunkie\"" + Then I should see output containing "Bool = true" + + Scenario: Casting a scalar to array + Given I start the REPL + When I enter "(array) \"a\"" + Then I should see output containing "Array = [\"a\"]" + + Scenario: Casting an array to object + Given I start the REPL + When I enter "$o = (object) [\"a\" => 1]" + And I enter "$o->a" + Then I should see output containing "Int = 1" + + Scenario: Casting an object to array + Given I start the REPL + When I enter "$values = (array) (object) [\"a\" => 1]" + And I enter "$values[\"a\"]" + Then I should see output containing "Int = 1" + + Scenario: Cast applies to a variable + Given I start the REPL + When I enter "$n = \"7\"" + And I enter "(int) $n" + Then I should see output containing "Int = 7" + + Scenario: Cast binds tighter than arithmetic + Given I start the REPL + When I enter "(int) \"3\" + 4" + Then I should see output containing "Int = 7" diff --git a/features/repl/first_class_callable.feature b/features/repl/first_class_callable.feature index 16968bb..013910e 100644 --- a/features/repl/first_class_callable.feature +++ b/features/repl/first_class_callable.feature @@ -9,11 +9,7 @@ Feature: First-Class Callable Syntax And I enter "$f('hello')" Then I should see output containing "Int = 5" - # Note: First-class callable syntax for user-defined functions is not yet supported - # User-defined functions are stored as AST nodes, not as callable PHP functions - # Supporting this would require eval'ing the function definition to create a real PHP function - - Scenario: First-class callable from user-defined function - known limitation + Scenario: First-class callable from user-defined function Given I start the REPL When I enter the following code: """ @@ -22,7 +18,19 @@ Feature: First-Class Callable Syntax } """ And I enter "$addFunc = add(...)" - Then I should see output containing "Error" + And I enter "$addFunc(2, 3)" + Then I should see output containing "Int = 5" + + Scenario: First-class callable from user-defined function passed to array_map + Given I start the REPL + When I enter the following code: + """ + function double($n) { + return $n * 2; + } + """ + And I enter "array_map(double(...), [1, 2, 3])" + Then I should see output containing "Array = [2, 4, 6]" Scenario: First-class callable from static method Given I start the REPL diff --git a/features/repl/php8.5/pipe_operator.feature b/features/repl/php8.5/pipe_operator.feature new file mode 100644 index 0000000..4b95e0f --- /dev/null +++ b/features/repl/php8.5/pipe_operator.feature @@ -0,0 +1,51 @@ +Feature: Pipe Operator (PHP 8.5) + As a PHP developer + I want to use the pipe operator in the REPL + So that I can read a chain of transformations left to right + + Scenario: Piping into a first-class callable + Given I start the REPL + When I enter "\"hi\" |> strtoupper(...)" + Then I should see output containing "String = \"HI\"" + + Scenario: Chaining pipes left to right + Given I start the REPL + When I enter "\" a \" |> trim(...) |> strtoupper(...)" + Then I should see output containing "String = \"A\"" + + Scenario: Piping into a parenthesised arrow function + Given I start the REPL + When I enter "5 |> (fn($n) => $n * 2)" + Then I should see output containing "Int = 10" + + Scenario: Piping an array into a function + Given I start the REPL + When I enter "[3, 1, 2] |> array_sum(...)" + Then I should see output containing "Int = 6" + + Scenario: Piping into a closure held in a variable + Given I start the REPL + When I enter "$double = fn($n) => $n * 2" + And I enter "21 |> $double" + Then I should see output containing "Int = 42" + + Scenario: Piping into a user defined function + Given I start the REPL + When I enter "function shout(string $s): string { return $s . \"!\"; }" + And I enter "\"go\" |> shout(...)" + Then I should see output containing "String = \"go!\"" + + Scenario: Pipe binds looser than concatenation + Given I start the REPL + When I enter "\"a\" . \"bc\" |> strlen(...)" + Then I should see output containing "Int = 3" + + Scenario: Pipe binds tighter than comparison + Given I start the REPL + When I enter "\"beep\" |> strlen(...) == 4" + Then I should see output containing "Bool = true" + + Scenario: Piping into a value that is not callable + Given I start the REPL + When I enter "5 |> 42" + Then I should see an error containing "not callable" diff --git a/features/repl/php8.5/void_cast.feature b/features/repl/php8.5/void_cast.feature new file mode 100644 index 0000000..e2e25ba --- /dev/null +++ b/features/repl/php8.5/void_cast.feature @@ -0,0 +1,48 @@ +Feature: Void Cast (PHP 8.5) + As a PHP developer + I want to use the (void) cast in the REPL + So that I can deliberately discard a return value + + Scenario: Void cast produces no result + Given I start the REPL + When I enter "(void) strlen(\"phunkie\")" + Then I should not see "Int = 7" + + Scenario: Void cast still evaluates its operand + Given I start the REPL + When I enter "$counter = new stdClass()" + And I enter "$counter->n = 0" + And I enter "$bump = function () use ($counter) { $counter->n = $counter->n + 1; return $counter->n; }" + And I enter "(void) $bump()" + And I enter "$counter->n" + Then I should see output containing "Int = 1" + + Scenario: Void cast does not create a numbered variable + Given I start the REPL + When I enter "(void) strlen(\"a\")" + And I enter "42" + Then I should see output containing "$var0: Int = 42" + + Scenario: Void cast suppresses a NoDiscard warning + Given I start the REPL + When I enter the following code: + """ + #[\NoDiscard("the result matters")] + function importantValue(): int { + return 7; + } + """ + And I enter "(void) importantValue()" + Then I should not see "should either be used" + + Scenario: Discarding a NoDiscard return without a void cast warns + Given I start the REPL + When I enter the following code: + """ + #[\NoDiscard] + function alsoImportant(): int { + return 7; + } + """ + And I enter "$ignored = alsoImportant()" + Then I should see output containing "Int = 7" diff --git a/scripts/test-all-versions.sh b/scripts/test-all-versions.sh index e7faf68..a42a613 100755 --- a/scripts/test-all-versions.sh +++ b/scripts/test-all-versions.sh @@ -8,7 +8,7 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" -PHP_VERSIONS=("8.2" "8.3" "8.4") +PHP_VERSIONS=("8.2" "8.3" "8.4" "8.5") echo "==========================================" echo "Running lint + tests on PHP versions: ${PHP_VERSIONS[*]}" diff --git a/src/Functions/evaluation.php b/src/Functions/evaluation.php index 7f80d19..1305dd6 100644 --- a/src/Functions/evaluation.php +++ b/src/Functions/evaluation.php @@ -338,6 +338,9 @@ function evaluateNode(Node $node, ReplSession $session): Validation $node instanceof Expr\Instanceof_ => evaluateInstanceof($node, $session), + $node instanceof Expr\Cast + => evaluateCast($node, $session), + $node instanceof Expr\Clone_ => evaluateClone($node, $session), @@ -2197,6 +2200,47 @@ function evaluateInstanceof(Expr\Instanceof_ $node, ReplSession $session): Valid } } +/** + * Evaluates a type cast (e.g., (int) $x). + * + * @param Expr\Cast $node + * @param ReplSession $session + * @return Validation + */ +function evaluateCast(Expr\Cast $node, ReplSession $session): Validation +{ + return evaluateNode($node->expr, $session)->flatMap( + /** @param EvaluationResult $result */ + function ($result) use ($node) { + $value = $result->value; + + // (void) discards the value: evaluate the operand, then produce nothing. + if ($node instanceof Expr\Cast\Void_) { + return Success(EvaluationResult::of(null, 'Null', '__no_output__')); + } + + try { + $cast = match (true) { + $node instanceof Expr\Cast\Int_ => (int) $value, + $node instanceof Expr\Cast\Double => (float) $value, + $node instanceof Expr\Cast\String_ => (string) $value, + $node instanceof Expr\Cast\Bool_ => (bool) $value, + $node instanceof Expr\Cast\Array_ => (array) $value, + $node instanceof Expr\Cast\Object_ => (object) $value, + default => throw new \InvalidArgumentException(sprintf( + 'Unsupported cast "%s".', + get_debug_type($node) + )), + }; + } catch (\Throwable $e) { + return Failure(new EvaluationError('Cast', $e->getMessage())); + } + + return Success(EvaluationResult::of($cast, getType($cast))); + } + ); +} + /** * Evaluates a clone expression (e.g., clone $obj). * @@ -2392,14 +2436,18 @@ function evaluateFirstClassCallable($node, ReplSession $session): Validation $sessionFuncName = '$' . $funcName; $funcOption = $session->variables->get($sessionFuncName); + // Functions defined in the session are already stored as closures if (!$funcOption->isEmpty()) { $func = $funcOption->get(); - // For user-defined functions stored as AST, we can't easily create a Closure - // We would need to eval the function definition again or store it differently - return Failure(new EvaluationError( - $funcName, - 'First-class callable syntax not supported for user-defined functions yet' - )); + if (!is_callable($func)) { + return Failure(new EvaluationError($funcName, sprintf( + 'Cannot create a callable from "%s", %s given.', + $funcName, + get_debug_type($func) + ))); + } + + return Success(EvaluationResult::of($func, getType($func))); } // Built-in function @@ -2711,6 +2759,42 @@ function evaluateStmtBlock(array $stmts, ReplSession $session): Validation return $lastResult; } +/** + * Evaluates a pipe expression (e.g., $x |> strtoupper(...)). + * + * The right operand is a callable taking one argument, and the result is that + * callable applied to the left operand. + * + * @param Expr\BinaryOp\Pipe $node + * @param ReplSession $session + * @return Validation + */ +function evaluatePipe(Expr\BinaryOp\Pipe $node, ReplSession $session): Validation +{ + return evaluateNode($node->left, $session)->flatMap( + /** @param EvaluationResult $input */ + fn($input) => evaluateNode($node->right, $session)->flatMap( + /** @param EvaluationResult $callable */ + function ($callable) use ($input) { + if (!is_callable($callable->value)) { + return Failure(new EvaluationError('Pipe', sprintf( + 'Right-hand side of "|>" is not callable, %s given.', + get_debug_type($callable->value) + ))); + } + + try { + $piped = ($callable->value)($input->value); + } catch (\Throwable $e) { + return Failure(new EvaluationError('Pipe', $e->getMessage())); + } + + return Success(EvaluationResult::of($piped, getType($piped))); + } + ) + ); +} + /** * Evaluates a binary operation (e.g., +, -, *, /, ., etc.). * @@ -2742,6 +2826,12 @@ function evaluateBinaryOp(Expr\BinaryOp $node, ReplSession $session): Validation return Success(EvaluationResult::of($right, getType($right))); } + // The pipe operator applies its right operand to its left, rather than + // combining two values the way every other binary operator does. + if ($node instanceof Expr\BinaryOp\Pipe) { + return evaluatePipe($node, $session); + } + // Evaluate left and right operands for all other operations $leftResult = evaluateNode($node->left, $session); if ($leftResult->isLeft()) { From 17a1c7aaa65c5173451df870c1d2e4a25cffc1b1 Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Sun, 2 Aug 2026 18:31:36 +0100 Subject: [PATCH 2/4] Cover the PHP 8.5 features that need no evaluator work Class-level syntax already works because class definitions are re-emitted through the pretty printer and eval'd, so the engine enforces the semantics. New standard library calls pass straight through to PHP. Both only needed acceptance coverage, added here as features/repl/php8.5/. Covers clone($object, $withProperties), array_first/array_last, get_error_handler/get_exception_handler, final constructor property promotion, #[\Override] on properties, and the URI extension. Two expectations were corrected against the real 8.5 runtime rather than the documentation: replacing a readonly property via clone() is refused from global scope and only works from inside the declaring class, and WhatWg host normalisation lowercases but does not decode percent-escapes. Both are now asserted as they actually behave. 479 scenarios green on 8.5, 341 on 8.2. --- features/repl/php8.5/array_first_last.feature | 48 +++++++++ features/repl/php8.5/clone_function.feature | 100 ++++++++++++++++++ .../error_handler_introspection.feature | 27 +++++ .../php8.5/final_property_promotion.feature | 59 +++++++++++ .../php8.5/override_on_properties.feature | 57 ++++++++++ features/repl/php8.5/uri_extension.feature | 45 ++++++++ 6 files changed, 336 insertions(+) create mode 100644 features/repl/php8.5/array_first_last.feature create mode 100644 features/repl/php8.5/clone_function.feature create mode 100644 features/repl/php8.5/error_handler_introspection.feature create mode 100644 features/repl/php8.5/final_property_promotion.feature create mode 100644 features/repl/php8.5/override_on_properties.feature create mode 100644 features/repl/php8.5/uri_extension.feature diff --git a/features/repl/php8.5/array_first_last.feature b/features/repl/php8.5/array_first_last.feature new file mode 100644 index 0000000..d7f0293 --- /dev/null +++ b/features/repl/php8.5/array_first_last.feature @@ -0,0 +1,48 @@ +Feature: array_first() and array_last() (PHP 8.5) + As a PHP developer + I want to read the first and last element of an array + So that I do not have to reach for reset() or array_key_last() + + Scenario: array_first() on a list + Given I start the REPL + When I enter "array_first([3, 4, 5])" + Then I should see output containing "Int = 3" + + Scenario: array_last() on a list + Given I start the REPL + When I enter "array_last([3, 4, 5])" + Then I should see output containing "Int = 5" + + Scenario: array_first() on an empty array + Given I start the REPL + When I enter "array_first([])" + Then I should see output containing "Null = null" + + Scenario: array_last() on an empty array + Given I start the REPL + When I enter "array_last([])" + Then I should see output containing "Null = null" + + Scenario: array_first() ignores keys + Given I start the REPL + When I enter "$data = [\"b\" => \"beta\", \"a\" => \"alpha\"]" + And I enter "array_first($data)" + Then I should see output containing "String = \"beta\"" + + Scenario: array_last() ignores keys + Given I start the REPL + When I enter "$data = [\"b\" => \"beta\", \"a\" => \"alpha\"]" + And I enter "array_last($data)" + Then I should see output containing "String = \"alpha\"" + + Scenario: array_first() does not move the internal pointer + Given I start the REPL + When I enter "$numbers = [10, 20, 30]" + And I enter "array_first($numbers)" + And I enter "array_first($numbers)" + Then I should see output containing "Int = 10" + + Scenario: Piping an array into array_last() + Given I start the REPL + When I enter "[1, 2, 3] |> array_last(...)" + Then I should see output containing "Int = 3" diff --git a/features/repl/php8.5/clone_function.feature b/features/repl/php8.5/clone_function.feature new file mode 100644 index 0000000..3ea9d86 --- /dev/null +++ b/features/repl/php8.5/clone_function.feature @@ -0,0 +1,100 @@ +Feature: Clone With Properties (PHP 8.5) + As a PHP developer + I want to clone an object and override properties in one step + So that I can derive new values from immutable objects + + Scenario: Cloning without changes still works + Given I start the REPL + When I enter the following code: + """ + class Point { + public function __construct(public int $x = 1, public int $y = 2) {} + } + """ + And I enter "$a = new Point(5, 6)" + And I enter "$b = clone $a" + And I enter "$b->x" + Then I should see output containing "Int = 5" + + Scenario: Cloning with a replaced property + Given I start the REPL + When I enter the following code: + """ + class Point { + public function __construct(public int $x = 1, public int $y = 2) {} + } + """ + And I enter "$a = new Point(5, 6)" + And I enter "$b = clone($a, [\"x\" => 9])" + And I enter "$b->x" + Then I should see output containing "Int = 9" + + Scenario: Properties that are not replaced are carried over + Given I start the REPL + When I enter the following code: + """ + class Point { + public function __construct(public int $x = 1, public int $y = 2) {} + } + """ + And I enter "$a = new Point(5, 6)" + And I enter "$b = clone($a, [\"x\" => 9])" + And I enter "$b->y" + Then I should see output containing "Int = 6" + + Scenario: The original object is left untouched + Given I start the REPL + When I enter the following code: + """ + class Point { + public function __construct(public int $x = 1, public int $y = 2) {} + } + """ + And I enter "$a = new Point(5, 6)" + And I enter "$b = clone($a, [\"x\" => 9])" + And I enter "$a->x" + Then I should see output containing "Int = 5" + + Scenario: Cloning with named arguments + Given I start the REPL + When I enter the following code: + """ + class Point { + public function __construct(public int $x = 1, public int $y = 2) {} + } + """ + And I enter "$a = new Point(5, 6)" + And I enter "$b = clone(object: $a, withProperties: [\"x\" => 9])" + And I enter "$b->x" + Then I should see output containing "Int = 9" + + Scenario: A readonly object derives a copy from inside its own scope + Given I start the REPL + When I enter the following code: + """ + readonly class Money { + public function __construct(public int $amount, public string $currency) {} + + public function withAmount(int $amount): static { + return clone($this, ["amount" => $amount]); + } + } + """ + And I enter "$price = new Money(100, \"GBP\")" + And I enter "$discounted = $price->withAmount(80)" + And I enter "$discounted->amount" + Then I should see output containing "Int = 80" + When I enter "$price->amount" + Then I should see output containing "Int = 100" + + Scenario: Replacing a readonly property from outside the class is refused + Given I start the REPL + When I enter the following code: + """ + readonly class Secret { + public function __construct(public string $value) {} + } + """ + And I enter "$s = new Secret(\"hunter2\")" + And I enter "clone($s, [\"value\" => \"leaked\"])" + Then I should see an error containing "Cannot modify" diff --git a/features/repl/php8.5/error_handler_introspection.feature b/features/repl/php8.5/error_handler_introspection.feature new file mode 100644 index 0000000..214c0c4 --- /dev/null +++ b/features/repl/php8.5/error_handler_introspection.feature @@ -0,0 +1,27 @@ +Feature: Error Handler Introspection (PHP 8.5) + As a PHP developer + I want to read the current error and exception handlers + So that I can inspect them without the set-and-restore dance + + Scenario: get_error_handler() reports the handler the REPL installs + Given I start the REPL + When I enter "get_error_handler()" + Then I should see output containing "Callable" + + Scenario: get_exception_handler() returns a handler once one is set + Given I start the REPL + When I enter "set_exception_handler(function ($e) { return null; })" + And I enter "is_callable(get_exception_handler())" + Then I should see output containing "Bool = true" + + Scenario: get_exception_handler() is null once the handler is cleared + Given I start the REPL + When I enter "set_exception_handler(function ($e) { return null; })" + And I enter "set_exception_handler(null)" + And I enter "get_exception_handler()" + Then I should see output containing "Null = null" + + Scenario: PHP_BUILD_DATE is available + Given I start the REPL + When I enter "is_string(PHP_BUILD_DATE)" + Then I should see output containing "Bool = true" diff --git a/features/repl/php8.5/final_property_promotion.feature b/features/repl/php8.5/final_property_promotion.feature new file mode 100644 index 0000000..da68129 --- /dev/null +++ b/features/repl/php8.5/final_property_promotion.feature @@ -0,0 +1,59 @@ +Feature: Final Property Promotion (PHP 8.5) + As a PHP developer + I want to mark promoted constructor properties final + So that subclasses cannot redeclare them + + Scenario: Final promoted property with explicit visibility + Given I start the REPL + When I enter the following code: + """ + class Point { + public function __construct(final public int $x = 1) {} + } + """ + And I enter "$p = new Point(9)" + And I enter "$p->x" + Then I should see output containing "Int = 9" + + Scenario: Final promoted property without explicit visibility defaults to public + Given I start the REPL + When I enter the following code: + """ + class Tag { + public function __construct(final string $name = "none") {} + } + """ + And I enter "$t = new Tag(\"release\")" + And I enter "$t->name" + Then I should see output containing "String = \"release\"" + + Scenario: Final promoted property alongside a regular promoted property + Given I start the REPL + When I enter the following code: + """ + class Range { + public function __construct( + final public int $from, + public int $to, + ) {} + } + """ + And I enter "$r = new Range(1, 10)" + And I enter "$r->from" + Then I should see output containing "Int = 1" + + Scenario: Final promoted property is readable through a method + Given I start the REPL + When I enter the following code: + """ + class Temperature { + public function __construct(final public float $celsius = 0.0) {} + + public function fahrenheit(): float { + return $this->celsius * 9 / 5 + 32; + } + } + """ + And I enter "$t = new Temperature(100.0)" + And I enter "$t->fahrenheit()" + Then I should see output containing "Float = 212" diff --git a/features/repl/php8.5/override_on_properties.feature b/features/repl/php8.5/override_on_properties.feature new file mode 100644 index 0000000..722e06d --- /dev/null +++ b/features/repl/php8.5/override_on_properties.feature @@ -0,0 +1,57 @@ +Feature: Override Attribute on Properties (PHP 8.5) + As a PHP developer + I want to mark a property as overriding a parent property + So that a rename in the parent is caught at compile time + + Scenario: Property marked with Override + Given I start the REPL + When I enter the following code: + """ + class Base { + public int $size = 1; + } + """ + And I enter the following code: + """ + class Derived extends Base { + #[\Override] public int $size = 2; + } + """ + And I enter "$d = new Derived()" + And I enter "$d->size" + Then I should see output containing "Int = 2" + + Scenario: Override on a promoted property + Given I start the REPL + When I enter the following code: + """ + class Shape { + public string $label = "shape"; + } + """ + And I enter the following code: + """ + class Square extends Shape { + public function __construct(#[\Override] public string $label = "square") {} + } + """ + And I enter "$s = new Square()" + And I enter "$s->label" + Then I should see output containing "String = \"square\"" + + Scenario: Override still applies to methods + Given I start the REPL + When I enter the following code: + """ + class Greeter { + public function greet(): string { return "hello"; } + } + """ + And I enter the following code: + """ + class LoudGreeter extends Greeter { + #[\Override] public function greet(): string { return "HELLO"; } + } + """ + And I enter "(new LoudGreeter())->greet()" + Then I should see output containing "String = \"HELLO\"" diff --git a/features/repl/php8.5/uri_extension.feature b/features/repl/php8.5/uri_extension.feature new file mode 100644 index 0000000..086dda3 --- /dev/null +++ b/features/repl/php8.5/uri_extension.feature @@ -0,0 +1,45 @@ +Feature: URI Extension (PHP 8.5) + As a PHP developer + I want to parse URIs with the built-in URI classes + So that I do not have to reach for parse_url() + + Scenario: Reading the host of an RFC 3986 URI + Given I start the REPL + When I enter "$uri = new Uri\Rfc3986\Uri(\"https://php.net/releases/8.5/en.php?a=1#top\")" + And I enter "$uri->getHost()" + Then I should see output containing "String = \"php.net\"" + + Scenario: Reading the scheme, path, query and fragment + Given I start the REPL + When I enter "$uri = new Uri\Rfc3986\Uri(\"https://php.net/releases/8.5/en.php?a=1#top\")" + And I enter "$uri->getScheme()" + Then I should see output containing "String = \"https\"" + When I enter "$uri->getPath()" + Then I should see output containing "String = \"/releases/8.5/en.php\"" + When I enter "$uri->getQuery()" + Then I should see output containing "String = \"a=1\"" + When I enter "$uri->getFragment()" + Then I should see output containing "String = \"top\"" + + Scenario: Deriving a new URI with a different port + Given I start the REPL + When I enter "$uri = new Uri\Rfc3986\Uri(\"https://example.com/\")" + And I enter "$uri->withPort(8080)->toString()" + Then I should see output containing "String = \"https://example.com:8080/\"" + + Scenario: parse() returns null instead of throwing on a malformed URI + Given I start the REPL + When I enter "Uri\Rfc3986\Uri::parse(\"::::\")" + Then I should see output containing "Null = null" + + Scenario: Resolving a relative reference + Given I start the REPL + When I enter "$base = new Uri\Rfc3986\Uri(\"https://example.com/a/b\")" + And I enter "$base->resolve(\"../c\")->toString()" + Then I should see output containing "String = \"https://example.com/c\"" + + Scenario: WhatWg URL lowercases the host + Given I start the REPL + When I enter "$url = new Uri\WhatWg\Url(\"https://EXAMPLE.com/\")" + And I enter "$url->getAsciiHost()" + Then I should see output containing "String = \"example.com\"" From d34308fb60f96fedbd57c576c823c10a4310f96c Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Sun, 2 Aug 2026 18:46:39 +0100 Subject: [PATCH 3/4] Report deprecations in the REPL instead of hiding them PHP 8.5 deprecates a great deal, and the REPL handled deprecations three different ways depending on which path evaluated the code. withEvaluationBoundary swallowed them, so $p->setAccessible(true) printed only "Null = null" with no hint that it is deprecated. Meanwhile each of the five eval'd definition sites installed its own error handler that trapped every severity and turned it into a Failure, so a deprecated trait reported "Error: Trait Legacy used by Consumer is deprecated" even though the class had been defined perfectly well. Whether a deprecation was silent or fatal came down to which handler happened to be installed. Deciding what a deprecation means is the boundary's job, so the definition sites no longer decide it. They share trapDefinitionErrors(), which keeps genuine errors local but passes deprecations out to the handler it replaced. The boundary collects them and attaches them to the result, and the REPL prints them as a yellow advisory line above the value: phunkie > $p->setAccessible(true) Deprecated: Method ReflectionProperty::setAccessible() is deprecated since 8.5 $var0: Null = null Drops the @ from the eval() calls in those definition sites. It suppressed error_reporting for the whole call, which is what hid the deprecations, and it was not buying anything: the shared handler already returns true, so nothing leaks to output, and eval() parse failures are ParseError throwables rather than diagnostics @ could suppress. Deprecations are de-duplicated per evaluation, so a loop does not print the same notice repeatedly. The @ operator still suppresses everything, unchanged. 484 scenarios green on 8.5, and 341, 384 and 433 on 8.2, 8.3 and 8.4. --- features/repl/php8.5/deprecations.feature | 50 +++++++++++++ src/Functions/evaluation.php | 91 +++++++++++++++-------- src/Repl/ReplLoop.php | 26 ++++++- src/Types/EvaluationResult.php | 23 +++++- 4 files changed, 159 insertions(+), 31 deletions(-) create mode 100644 features/repl/php8.5/deprecations.feature diff --git a/features/repl/php8.5/deprecations.feature b/features/repl/php8.5/deprecations.feature new file mode 100644 index 0000000..73e7f92 --- /dev/null +++ b/features/repl/php8.5/deprecations.feature @@ -0,0 +1,50 @@ +Feature: Deprecation Notices (PHP 8.5) + As a PHP developer + I want the REPL to tell me when I use something deprecated + So that I learn about it without the evaluation being treated as a failure + + Scenario: A deprecated method reports an advisory and still returns its result + Given I start the REPL + When I enter "$p = new ReflectionProperty(\"ReflectionProperty\", \"name\")" + And I enter "$p->setAccessible(true)" + Then I should see output containing "Deprecated:" + And I should see output containing "setAccessible" + + Scenario: A deprecated trait is still usable + Given I start the REPL + When I enter the following code: + """ + #[\Deprecated] + trait Legacy { + public function hello(): string { return "hello"; } + } + """ + And I enter "class Consumer { use Legacy; }" + Then I should see output containing "Deprecated:" + When I enter "(new Consumer())->hello()" + Then I should see output containing "String = \"hello\"" + + Scenario: A deprecated class constant reports an advisory and still resolves + Given I start the REPL + When I enter the following code: + """ + class Config { + #[\Deprecated(message: "use TIMEOUT instead")] + const TIMEOUT_SECONDS = 30; + } + """ + And I enter "Config::TIMEOUT_SECONDS" + Then I should see output containing "Deprecated:" + And I should see output containing "Int = 30" + + Scenario: A deprecation does not turn a good result into an error + Given I start the REPL + When I enter "$p = new ReflectionProperty(\"ReflectionProperty\", \"name\")" + And I enter "$p->setAccessible(true)" + Then I should not see "Evaluation error" + + Scenario: Code with no deprecation reports none + Given I start the REPL + When I enter "strlen(\"phunkie\")" + Then I should see output containing "Int = 7" + And I should not see "Deprecated:" diff --git a/src/Functions/evaluation.php b/src/Functions/evaluation.php index 1305dd6..6273207 100644 --- a/src/Functions/evaluation.php +++ b/src/Functions/evaluation.php @@ -53,8 +53,9 @@ function evaluateExpression(string $input, ReplSession $session): Validation * warning prints raw and the null is shown as the result. A temporary error * handler traps them and surfaces them as a Failure. * - Throwables (Error, TypeError, DivisionByZeroError, …) are caught. - * - Deprecations are advisory, so they are swallowed without failing otherwise - * valid code. + * - Deprecations are advisory: they are collected and reported alongside the + * result rather than failing otherwise valid code. PHP 8.5 deprecates a great + * deal, so a REPL that hides them teaches the wrong thing. * * Uncatchable fatals (E_ERROR / E_COMPILE_ERROR, e.g. "Cannot redeclare") cannot * be caught in-process and are prevented earlier, before they reach eval(). @@ -65,15 +66,21 @@ function evaluateExpression(string $input, ReplSession $session): Validation function withEvaluationBoundary(string $expression, callable $evaluate): Validation { $trappedMessage = null; + $deprecations = []; - set_error_handler(static function (int $severity, string $message) use (&$trappedMessage): bool { + set_error_handler(static function (int $severity, string $message) use (&$trappedMessage, &$deprecations): bool { // Honour @-suppression and the configured error_reporting level. if ((error_reporting() & $severity) === 0) { return false; } - // Deprecations are advisory: let the evaluation keep its result. + // Deprecations are advisory: report them, but let the evaluation keep its result. if (($severity & (E_DEPRECATED | E_USER_DEPRECATED)) !== 0) { + $cleaned = cleanErrorMessage($message); + if (!in_array($cleaned, $deprecations, true)) { + $deprecations[] = $cleaned; + } + return true; } @@ -97,7 +104,48 @@ function withEvaluationBoundary(string $expression, callable $evaluate): Validat return Failure(new EvaluationError($expression, cleanErrorMessage($trappedMessage))); } - return $result; + if ([] === $deprecations) { + return $result; + } + + return $result->map( + /** @param EvaluationResult $evaluated */ + fn($evaluated) => $evaluated instanceof EvaluationResult + ? $evaluated->withDeprecations($deprecations) + : $evaluated + ); +} + +/** + * Installs an error handler for a single eval'd definition. + * + * Definition sites need to know whether their own eval() failed, but they must + * not decide what a deprecation means: that policy belongs to + * withEvaluationBoundary(). Deprecations are therefore passed out to the handler + * this one replaces, so they reach the boundary and are reported as advisories + * instead of being mistaken for failures. + * + * Pair every call with restore_error_handler(). + * + * @param string|null $errorMessage Receives the first genuine error, by reference + */ +function trapDefinitionErrors(?string &$errorMessage): void +{ + $previous = set_error_handler( + static function (int $severity, string $message, string $file = '', int $line = 0) use (&$errorMessage, &$previous) { + if ((error_reporting() & $severity) === 0) { + return false; + } + + if (($severity & (E_DEPRECATED | E_USER_DEPRECATED)) !== 0) { + return $previous === null ? false : $previous($severity, $message, $file, $line); + } + + $errorMessage ??= $message; + + return true; + } + ); } /** @@ -2006,10 +2054,7 @@ function evaluateEnumDefinition(Node\Stmt\Enum_ $stmt, ReplSession $session): Va // Set up error handler to catch fatal errors from eval() $errorMessage = null; - set_error_handler(function ($severity, $message, $file, $line) use (&$errorMessage) { - $errorMessage = $message; - return true; // Don't execute PHP's internal error handler - }); + trapDefinitionErrors($errorMessage); try { // Use eval() to define the enum in the current scope @@ -4103,14 +4148,11 @@ function evaluateAnonymousClass(Expr\New_ $node, ReplSession $session): Validati // Set up error handler $errorMessage = null; - set_error_handler(function ($severity, $message, $file, $line) use (&$errorMessage) { - $errorMessage = $message; - return true; - }); + trapDefinitionErrors($errorMessage); try { // Eval the expression to create the anonymous class instance - $instance = @eval("return $code;"); + $instance = eval("return $code;"); } finally { restore_error_handler(); } @@ -4196,13 +4238,10 @@ function evaluateInterfaceDefinition(Node\Stmt\Interface_ $interfaceNode, ReplSe // Set up error handler $errorMessage = null; - set_error_handler(function ($severity, $message, $file, $line) use (&$errorMessage) { - $errorMessage = $message; - return true; - }); + trapDefinitionErrors($errorMessage); try { - @eval($code); + eval($code); } finally { restore_error_handler(); } @@ -4277,13 +4316,10 @@ function evaluateTraitDefinition(Node\Stmt\Trait_ $traitNode, ReplSession $sessi // Set up error handler $errorMessage = null; - set_error_handler(function ($severity, $message, $file, $line) use (&$errorMessage) { - $errorMessage = $message; - return true; - }); + trapDefinitionErrors($errorMessage); try { - @eval($code); + eval($code); } finally { restore_error_handler(); } @@ -4384,14 +4420,11 @@ function evaluateClassDefinition(Node\Stmt\Class_ $classNode, ReplSession $sessi // Set up error handler to catch warnings and notices from eval() $errorMessage = null; - set_error_handler(function ($severity, $message, $file, $line) use (&$errorMessage) { - $errorMessage = $message; - return true; // Don't execute PHP's internal error handler - }); + trapDefinitionErrors($errorMessage); try { // Evaluate the class definition to define it in the runtime - @eval($classCode); // @ suppresses fatal error output + eval($classCode); } finally { restore_error_handler(); } diff --git a/src/Repl/ReplLoop.php b/src/Repl/ReplLoop.php index 987bf77..587a1a0 100644 --- a/src/Repl/ReplLoop.php +++ b/src/Repl/ReplLoop.php @@ -736,6 +736,25 @@ function formatError(ReplError $error, ReplSession $session): string return "Error: {$message}"; } +/** + * Formats a deprecation notice raised while evaluating. + * + * Deprecations are advisory rather than failures, so they are rendered in yellow + * and the result is still shown underneath. + * + * @param string $message + * @param ReplSession $session + * @return string + */ +function formatDeprecation(string $message, ReplSession $session): string +{ + if ($session->colorEnabled) { + return "\033[33mDeprecated:\033[0m {$message}"; + } + + return "Deprecated: {$message}"; +} + /** * Evaluates an expression and displays the result. * @@ -754,7 +773,12 @@ function evaluateAndDisplay(string $expression, ReplSession $session): IO ->as(new ContinueRepl($session)) )( // Success case: result is passed to this function - fn($result) => displayResult($result, $session, $expression) + fn($result) => [] === $result->deprecations + ? displayResult($result, $session, $expression) + : printLn(implode("\n", array_map( + fn(string $message) => formatDeprecation($message, $session), + $result->deprecations + )))->flatMap(fn() => displayResult($result, $session, $expression)) ); } diff --git a/src/Types/EvaluationResult.php b/src/Types/EvaluationResult.php index ce2edc1..c8d9794 100644 --- a/src/Types/EvaluationResult.php +++ b/src/Types/EvaluationResult.php @@ -18,12 +18,16 @@ */ final readonly class EvaluationResult { + /** + * @param list $deprecations Advisory notices raised while evaluating + */ public function __construct( public mixed $value, public string $type, public ?string $assignedVariable = null, public array $additionalAssignments = [], - public bool $isOutputStatement = false + public bool $isOutputStatement = false, + public array $deprecations = [] ) {} public static function of(mixed $value, string $type, ?string $assignedVariable = null, array $additionalAssignments = [], bool $isOutputStatement = false): EvaluationResult @@ -31,6 +35,23 @@ public static function of(mixed $value, string $type, ?string $assignedVariable return new EvaluationResult($value, $type, $assignedVariable, $additionalAssignments, $isOutputStatement); } + /** + * Returns a copy carrying the deprecation notices raised while evaluating. + * + * @param list $deprecations + */ + public function withDeprecations(array $deprecations): self + { + return new self( + $this->value, + $this->type, + $this->assignedVariable, + $this->additionalAssignments, + $this->isOutputStatement, + $deprecations + ); + } + /** * Formats the result for display in the REPL. * From 2e95641cefe6f7fad187b115a8e94141640a11b6 Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Sun, 2 Aug 2026 19:30:25 +0100 Subject: [PATCH 4/4] Do not let the host php.ini decide which deprecations are reported The 8.5 CI job failed while the same suite passed locally. GitHub Actions runs with a production php.ini, whose error_reporting masks E_DEPRECATED, and the evaluation boundary honoured that: the advisory line simply never appeared. The REPL already decides how diagnostics are presented, so it should decide which ones it sees. withEvaluationBoundary now raises error_reporting to E_ALL for the evaluation and restores it afterwards, alongside the error handler it already saves and restores. The @ operator is unaffected, since it masks reporting from inside the expression being evaluated. Doing this in the boundary rather than at startup matters: Behat resets error_reporting around every single step, so anything configured when the REPL starts is undone before the first expression is evaluated. The boundary is the only place that covers the real REPL and the in-process test harness alike. Renames installFatalErrorFormatter() to installDiagnosticsRendering(), which is what it does now that it also silences PHP's own output, with the idempotent part split into configureDiagnostics(). Verified by reproducing CI locally: the full 8.5 suite passes with -d error_reporting="E_ALL & ~E_DEPRECATED" as well as with the default ini. --- src/Functions/evaluation.php | 6 +++++ src/Repl/ReplLoop.php | 46 ++++++++++++++++++++++++++---------- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/Functions/evaluation.php b/src/Functions/evaluation.php index 6273207..8432908 100644 --- a/src/Functions/evaluation.php +++ b/src/Functions/evaluation.php @@ -68,6 +68,11 @@ function withEvaluationBoundary(string $expression, callable $evaluate): Validat $trappedMessage = null; $deprecations = []; + // Own the reporting level for the evaluation, so what the REPL reports does + // not depend on ambient php.ini or on a test runner that resets it per step. + // The @ operator still masks this from inside the expression being evaluated. + $previousReporting = error_reporting(E_ALL); + set_error_handler(static function (int $severity, string $message) use (&$trappedMessage, &$deprecations): bool { // Honour @-suppression and the configured error_reporting level. if ((error_reporting() & $severity) === 0) { @@ -96,6 +101,7 @@ function withEvaluationBoundary(string $expression, callable $evaluate): Validat return Failure(new EvaluationError($expression, cleanErrorMessage($e->getMessage()))); } finally { restore_error_handler(); + error_reporting($previousReporting); } // A trapped warning on an otherwise-successful evaluation becomes the result, diff --git a/src/Repl/ReplLoop.php b/src/Repl/ReplLoop.php index 587a1a0..9bdea73 100644 --- a/src/Repl/ReplLoop.php +++ b/src/Repl/ReplLoop.php @@ -37,30 +37,52 @@ function replLoop(ReplSession $session): IO { // Run the trampolined loop return new IO(function () use ($session) { - installFatalErrorFormatter(); + installDiagnosticsRendering(); return replLoopTrampoline($session)->run(); }); } /** - * Renders an uncatchable fatal as a clean phunkie error. + * Configures how PHP reports problems, so the REPL alone decides what is shown. * - * The evaluation boundary already turns warnings, notices and every Throwable - * into a formatted error. A handful of failures — E_ERROR and the compile-time - * fatals such as "Cannot redeclare" — reach neither try/catch nor a custom error - * handler and terminate the process. PHP's own rendering of those is silenced - * and re-emitted here through a shutdown handler, so a fatal reads like any other - * REPL error rather than a raw stack trace pointing at the REPL's internals. + * Split out from installDiagnosticsRendering() because it is idempotent and the + * in-process test harness needs it too: without it, tests would run against a + * different diagnostic configuration than the REPL they are meant to exercise. + */ +function configureDiagnostics(): void +{ + ini_set('display_errors', '0'); + ini_set('log_errors', '0'); + error_reporting(E_ALL); +} + +/** + * Takes over how diagnostics reach the user. + * + * PHP's own rendering is silenced, because everything the user should see is + * formatted by the REPL: the evaluation boundary turns warnings, notices and + * every Throwable into a phunkie error, and reports deprecations as advisories. + * + * error_reporting is widened to E_ALL so that what the REPL reports does not + * depend on the host's php.ini. A production ini that masks E_DEPRECATED would + * otherwise silently hide exactly the notices an interactive session most wants + * to show. The @ operator is unaffected: it zeroes error_reporting for the + * duration of its own expression, which the handlers still honour. * - * This formats the fatal; it cannot resume the session. Surviving a fatal (so the + * A handful of failures — E_ERROR and the compile-time fatals such as "Cannot + * redeclare" — reach neither try/catch nor a custom error handler and terminate + * the process. Those are re-emitted through a shutdown handler so a fatal reads + * like any other REPL error rather than a raw stack trace pointing at the REPL's + * internals. + * + * That formats the fatal; it cannot resume the session. Surviving a fatal (so the * REPL keeps its state and carries on) needs each evaluation to run in its own * process — a separate, larger change. */ -function installFatalErrorFormatter(): void +function installDiagnosticsRendering(): void { - ini_set('display_errors', '0'); - ini_set('log_errors', '0'); + configureDiagnostics(); register_shutdown_function(static function (): void { $error = error_get_last();