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/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/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/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/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/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\"" 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..8432908 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,26 @@ 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 { + // 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) { 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; } @@ -89,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, @@ -97,7 +110,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; + } + ); } /** @@ -338,6 +392,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), @@ -2003,10 +2060,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 @@ -2197,6 +2251,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 +2487,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 +2810,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 +2877,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()) { @@ -4013,14 +4154,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(); } @@ -4106,13 +4244,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(); } @@ -4187,13 +4322,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(); } @@ -4294,14 +4426,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..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(); @@ -736,6 +758,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 +795,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. *