From 244fa4d8b25739c3e33fcf10ce1d382ab6c834cb Mon Sep 17 00:00:00 2001 From: tianfenghan Date: Wed, 19 Aug 2026 10:49:08 +0800 Subject: [PATCH] =?UTF-8?q?Immutable=20=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/IMMUTABLE.md | 114 ++++++ docs/README.md | 1 + examples/attributes/Immutable.php | 9 + examples/attributes/readonly.php | 22 ++ .../code/compiler_api/library_import_php.php | 12 + .../code/immutable-array-byref-builtin.php | 5 + .../code/immutable-array-mutating-method.php | 6 + phpunit/code/immutable-chain-alias.php | 12 + .../immutable-closure-capture-mutation.php | 14 + phpunit/code/immutable-closure-this.php | 14 + phpunit/code/immutable-compound-write.php | 6 + .../code/immutable-constructor-parameter.php | 13 + .../code/immutable-destructuring-write.php | 6 + phpunit/code/immutable-generator-context.php | 12 + .../code/immutable-method-calls-mutable.php | 11 + ...mutable-method-override-drops-contract.php | 18 + .../code/immutable-method-property-write.php | 12 + .../immutable-mutable-extension-method.php | 14 + .../code/immutable-mutable-property-hook.php | 14 + .../code/immutable-object-alias-mutation.php | 11 + ...mutable-object-parameter-calls-mutable.php | 11 + ...ble-object-passed-to-mutable-parameter.php | 10 + .../code/immutable-object-return-escape.php | 8 + .../code/immutable-object-storage-escape.php | 13 + ...able-parameter-override-drops-contract.php | 13 + phpunit/code/immutable-parameter-reassign.php | 6 + .../code/immutable-property-byref-builtin.php | 11 + phpunit/code/immutable-reference.php | 6 + phpunit/code/immutable-unset.php | 6 + phpunit/code/immutable-write-forms.php | 8 + .../src/CompileTimeAttributeRegistryTest.php | 4 +- phpunit/src/CompilerBaseApiTest.php | 5 + .../src/Immutable/ImmutableValidationTest.php | 183 +++++++++ src/CompilerBase.php | 9 + src/Context/FunctionContext.php | 6 + src/Entity/ArgInfo.php | 2 + src/Entity/FunctionDef.php | 2 + src/Generator/ClosureGenerator.php | 26 ++ src/Generator/FiberGenerator.php | 8 + src/Immutable/ImmutableSupportTrait.php | 365 ++++++++++++++++++ src/Parser/AssignOpTrait.php | 10 + src/Parser/ForeachTrait.php | 3 + src/Parser/FunctionCallTrait.php | 1 + src/Parser/MethodCallTrait.php | 2 + src/Parser/PropertyAccessTrait.php | 1 + src/Parser/UniversalMethodCall.php | 1 + src/Preprocessor.php | 2 + src/Transform/CompileTimeAttribute.php | 4 + .../CompileTimeAttributeRegistry.php | 2 + src/Transform/FunctionAttributeLowering.php | 12 + src/Translator.php | 11 + src/polyfills.php | 5 + tests/compiler/attribute/immutable.phpt | 137 +++++++ 53 files changed, 1217 insertions(+), 2 deletions(-) create mode 100644 docs/IMMUTABLE.md create mode 100644 examples/attributes/Immutable.php create mode 100644 examples/attributes/readonly.php create mode 100644 phpunit/code/immutable-array-byref-builtin.php create mode 100644 phpunit/code/immutable-array-mutating-method.php create mode 100644 phpunit/code/immutable-chain-alias.php create mode 100644 phpunit/code/immutable-closure-capture-mutation.php create mode 100644 phpunit/code/immutable-closure-this.php create mode 100644 phpunit/code/immutable-compound-write.php create mode 100644 phpunit/code/immutable-constructor-parameter.php create mode 100644 phpunit/code/immutable-destructuring-write.php create mode 100644 phpunit/code/immutable-generator-context.php create mode 100644 phpunit/code/immutable-method-calls-mutable.php create mode 100644 phpunit/code/immutable-method-override-drops-contract.php create mode 100644 phpunit/code/immutable-method-property-write.php create mode 100644 phpunit/code/immutable-mutable-extension-method.php create mode 100644 phpunit/code/immutable-mutable-property-hook.php create mode 100644 phpunit/code/immutable-object-alias-mutation.php create mode 100644 phpunit/code/immutable-object-parameter-calls-mutable.php create mode 100644 phpunit/code/immutable-object-passed-to-mutable-parameter.php create mode 100644 phpunit/code/immutable-object-return-escape.php create mode 100644 phpunit/code/immutable-object-storage-escape.php create mode 100644 phpunit/code/immutable-parameter-override-drops-contract.php create mode 100644 phpunit/code/immutable-parameter-reassign.php create mode 100644 phpunit/code/immutable-property-byref-builtin.php create mode 100644 phpunit/code/immutable-reference.php create mode 100644 phpunit/code/immutable-unset.php create mode 100644 phpunit/code/immutable-write-forms.php create mode 100644 phpunit/src/Immutable/ImmutableValidationTest.php create mode 100644 src/Immutable/ImmutableSupportTrait.php create mode 100644 tests/compiler/attribute/immutable.phpt diff --git a/docs/IMMUTABLE.md b/docs/IMMUTABLE.md new file mode 100644 index 00000000..d424feb0 --- /dev/null +++ b/docs/IMMUTABLE.md @@ -0,0 +1,114 @@ +# `#[Immutable]` compile-time effect checking + +## Purpose + +`#[Immutable]` is a TypePHP compile-time annotation modelled after C++ `const`. +It prevents accidental mutation in statically compiled code without adding a +wrapper object, Zend metadata, runtime branch, or ABI change. + +It is intentionally a best-effort static tool rather than a security boundary. +Calls whose target is deliberately made dynamic are an escape hatch and do not +receive a runtime guard. + +## Supported targets + +```php +#[Immutable] +public function name(): string +{ + return $this->name; +} + +function inspect(#[Immutable] User $user): string +{ + return $user->name(); +} +``` + +The attribute is valid on methods and on function, method, and closure +parameters. On an instance method it makes `$this` immutable. On a parameter it +makes the binding immutable and, when it can contain an object, treats the +referenced object as immutable as well. + +## Rejected operations + +For an immutable root such as `$this` or `$user`, the compiler rejects: + +- assignment, destructuring, and array-element or object-property writes; +- compound assignment, `++`, `--`, `unset()`, taking a reference, and + `foreach (... as &$value)`; +- a statically named method call unless the resolved method is also marked + `#[Immutable]`; +- a mutating value extension such as `$array->sort()`; read-only array/string + methods remain available; +- passing an object to a statically resolved parameter that is not itself + `#[Immutable]`; +- passing any immutable value to a mutable by-reference parameter, including + extension functions such as `sort()`; +- storing an immutable object identity in an object property, array, + global/static variable, or returning/yielding it as a mutable value. + +An immutable by-reference parameter is supported. It acts like a C++ `const &`: +the reference is accepted because the callee is checked against mutation. + +`#[MethodsFor]` follows the same contract. An object extension is callable on +an immutable receiver only when its receiver parameter is marked +`#[Immutable]`. + +## Aliases, closures, generators, and inheritance + +Local aliases of immutable objects remain immutable: + +```php +$alias = $user; +$alias->rename('new'); // compile-time error +``` + +`clone` creates a distinct mutable object and therefore intentionally drops the +annotation. Captured variables, arrow functions, closure `$this`, and Fiber +generator bodies carry immutable metadata into their generated function +contexts. + +An overriding class or interface method may strengthen an ordinary contract by +adding `#[Immutable]`, but it cannot remove `#[Immutable]` from an inherited +method or parameter. + +## Value versus object semantics + +Scalar values and PHP copy-on-write values can be read and copied normally. For +example, `count($values)` and `$copy = $values` do not modify an immutable array. +The compiler propagates immutability through an expression only when object +identity is possible. + +## Explicit escape hatches + +The following intentionally bypass static method-effect checking: + +```php +$method = 'rename'; +$user->$method('new'); + +$callable = getRuntimeCallable(); +$callable($user); +``` + +The same applies to other runtime-only mechanisms that hide the target from the +compiler, including reflection and dynamic ZendVM code. TypePHP neither inserts +a runtime read-only proxy nor attempts to recover the escaped value later. + +This boundary is deliberate: `#[Immutable]` should cost nothing in generated +code and should not complicate PHPX/ZendVM object semantics. + +## Property hooks and magic access + +Property-hook reads are lowered to generated method calls. Consequently, a hook +used through an immutable receiver must itself carry an `#[Immutable]` method +contract; otherwise the generated call is rejected. Fully dynamic magic access +is covered by the same escape-hatch rule as other runtime-only behavior. + +## Implementation boundaries + +The implementation is isolated in `src/Immutable/ImmutableSupportTrait.php`. +`FunctionDef` and `ArgInfo` retain only compile-time effect bits, while each +`FunctionContext` stores the immutable roots and object aliases relevant to that +body. Checks run during AST lowering and emit no C++ code when successful. diff --git a/docs/README.md b/docs/README.md index 51495cb3..15ad121b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ - [编译期函数](COMPILE_TIME_FUNCTIONS.md):`any()`、`refval()`、`objval()`、`expected()`、`unexpected()` 和关键词方法。 - [原生类型](NATIVE_TYPES.md)、[高精度类型](HIGH_PRECISION_TYPES.md)、[Std 容器](STD_CONTAINERS.md)。 - [通用与扩展方法](UNIVERSAL_METHODS.md)、[Generator](YIELD_GENERATOR.md)。 +- [`#[Immutable]` 编译期只读契约](IMMUTABLE.md):方法、参数、别名、调用边界与动态逃逸规则。 - [类继承](CLASS_INHERITANCE.md)、[混合 C++/PHP](MIXED_CPP_PHP.md)。 ## 架构与维护 diff --git a/examples/attributes/Immutable.php b/examples/attributes/Immutable.php new file mode 100644 index 00000000..7ec9c4df --- /dev/null +++ b/examples/attributes/Immutable.php @@ -0,0 +1,9 @@ +name = 'hello'; + } + + function bar( + #[Immutable] + string $name): void + { + // 允许,方法不是 Immutable 的 + $this->name = 'hello'; + // 不允许,$name 是 Immutable 的,不可修改 + $name = 'world'; + } +} \ No newline at end of file diff --git a/phpunit/code/compiler_api/library_import_php.php b/phpunit/code/compiler_api/library_import_php.php index 99be112d..1043461c 100644 --- a/phpunit/code/compiler_api/library_import_php.php +++ b/phpunit/code/compiler_api/library_import_php.php @@ -8,6 +8,7 @@ use \Constructor; use \Validate; use \Getter; use \Hot; +use \Immutable; use \NotNull; use \NoExport as Internal; use \Override; @@ -40,6 +41,12 @@ class Counter return $this->value; } + #[Immutable] + public function current(): int + { + return $this->value; + } + #[MustUse, Cold] public function label(#[NotNull, Validate(FILTER_VALIDATE_EMAIL)] string $value): string { @@ -78,6 +85,11 @@ function twice(int $value): int return $value * 2; } +function inspect(#[Immutable] Counter $counter): int +{ + return $counter->current(); +} + #[Internal] function internal_twice(int $value = 2): int { diff --git a/phpunit/code/immutable-array-byref-builtin.php b/phpunit/code/immutable-array-byref-builtin.php new file mode 100644 index 00000000..66445af2 --- /dev/null +++ b/phpunit/code/immutable-array-byref-builtin.php @@ -0,0 +1,5 @@ +sort(); +} diff --git a/phpunit/code/immutable-chain-alias.php b/phpunit/code/immutable-chain-alias.php new file mode 100644 index 00000000..dd0e749b --- /dev/null +++ b/phpunit/code/immutable-chain-alias.php @@ -0,0 +1,12 @@ +mutate(); +} diff --git a/phpunit/code/immutable-closure-capture-mutation.php b/phpunit/code/immutable-closure-capture-mutation.php new file mode 100644 index 00000000..7aaf801d --- /dev/null +++ b/phpunit/code/immutable-closure-capture-mutation.php @@ -0,0 +1,14 @@ +value = 2; + }; + $callback(); + } +} diff --git a/phpunit/code/immutable-closure-this.php b/phpunit/code/immutable-closure-this.php new file mode 100644 index 00000000..10a58592 --- /dev/null +++ b/phpunit/code/immutable-closure-this.php @@ -0,0 +1,14 @@ +mutate(); + }; + } +} diff --git a/phpunit/code/immutable-compound-write.php b/phpunit/code/immutable-compound-write.php new file mode 100644 index 00000000..580eecc8 --- /dev/null +++ b/phpunit/code/immutable-compound-write.php @@ -0,0 +1,6 @@ +mutate(); +} diff --git a/phpunit/code/immutable-method-calls-mutable.php b/phpunit/code/immutable-method-calls-mutable.php new file mode 100644 index 00000000..22631756 --- /dev/null +++ b/phpunit/code/immutable-method-calls-mutable.php @@ -0,0 +1,11 @@ +mutate(); + } +} diff --git a/phpunit/code/immutable-method-override-drops-contract.php b/phpunit/code/immutable-method-override-drops-contract.php new file mode 100644 index 00000000..c07cac16 --- /dev/null +++ b/phpunit/code/immutable-method-override-drops-contract.php @@ -0,0 +1,18 @@ +value = 'changed'; + return $this->value; + } +} diff --git a/phpunit/code/immutable-mutable-extension-method.php b/phpunit/code/immutable-mutable-extension-method.php new file mode 100644 index 00000000..b650e584 --- /dev/null +++ b/phpunit/code/immutable-mutable-extension-method.php @@ -0,0 +1,14 @@ +touch(); +} diff --git a/phpunit/code/immutable-mutable-property-hook.php b/phpunit/code/immutable-mutable-property-hook.php new file mode 100644 index 00000000..887fef92 --- /dev/null +++ b/phpunit/code/immutable-mutable-property-hook.php @@ -0,0 +1,14 @@ + $this->value; + } + + #[Immutable] + public function read(): string + { + return $this->value; + } +} diff --git a/phpunit/code/immutable-object-alias-mutation.php b/phpunit/code/immutable-object-alias-mutation.php new file mode 100644 index 00000000..9cbfe1a1 --- /dev/null +++ b/phpunit/code/immutable-object-alias-mutation.php @@ -0,0 +1,11 @@ +mutate(); +} diff --git a/phpunit/code/immutable-object-parameter-calls-mutable.php b/phpunit/code/immutable-object-parameter-calls-mutable.php new file mode 100644 index 00000000..0b9cf5d9 --- /dev/null +++ b/phpunit/code/immutable-object-parameter-calls-mutable.php @@ -0,0 +1,11 @@ +mutate(); +} diff --git a/phpunit/code/immutable-object-passed-to-mutable-parameter.php b/phpunit/code/immutable-object-passed-to-mutable-parameter.php new file mode 100644 index 00000000..a0c89d73 --- /dev/null +++ b/phpunit/code/immutable-object-passed-to-mutable-parameter.php @@ -0,0 +1,10 @@ +stored = $value; + } +} diff --git a/phpunit/code/immutable-parameter-override-drops-contract.php b/phpunit/code/immutable-parameter-override-drops-contract.php new file mode 100644 index 00000000..9c845838 --- /dev/null +++ b/phpunit/code/immutable-parameter-override-drops-contract.php @@ -0,0 +1,13 @@ +values); + } +} diff --git a/phpunit/code/immutable-reference.php b/phpunit/code/immutable-reference.php new file mode 100644 index 00000000..7ad12b62 --- /dev/null +++ b/phpunit/code/immutable-reference.php @@ -0,0 +1,6 @@ +name); +} diff --git a/phpunit/code/immutable-write-forms.php b/phpunit/code/immutable-write-forms.php new file mode 100644 index 00000000..daf70f6e --- /dev/null +++ b/phpunit/code/immutable-write-forms.php @@ -0,0 +1,8 @@ +assertSame($expected, CompileTimeAttributeRegistry::names()); @@ -26,7 +26,7 @@ final class CompileTimeAttributeRegistryTest extends TestCase $this->assertContains('Getter', CompileTimeAttributeRegistry::names(true)); $this->assertContains('Override', CompileTimeAttributeRegistry::names(true)); $this->assertSame( - ['Override', 'MustUse', 'Hot', 'Cold'], + ['Override', 'MustUse', 'Immutable', 'Hot', 'Cold'], CompileTimeAttributeRegistry::namesForPhase(CompileTimeAttributeRegistry::PHASE_ENTER), ); } diff --git a/phpunit/src/CompilerBaseApiTest.php b/phpunit/src/CompilerBaseApiTest.php index d51f4275..b6d1a86d 100644 --- a/phpunit/src/CompilerBaseApiTest.php +++ b/phpunit/src/CompilerBaseApiTest.php @@ -1175,6 +1175,11 @@ YAML); $this->assertStringContainsString('#[\MustUse, \Cold]', $stub); $this->assertStringContainsString('#[\MustUse, \Hot]', $stub); $this->assertStringContainsString('#[\Override]', $stub); + $this->assertStringContainsString('#[\Immutable]', $stub); + $this->assertMatchesRegularExpression( + '/function inspect\(\s*#\[\\\\Immutable\]\s*\\\\LibraryApi\\\\Counter \$counter\s*\): int/s', + $stub, + ); $this->assertMatchesRegularExpression( '/public int \$doubled\s*\{\s*get\s*\{\s*\}\s*set\(int \$value\)\s*\{\s*\}\s*\}/s', $stub, diff --git a/phpunit/src/Immutable/ImmutableValidationTest.php b/phpunit/src/Immutable/ImmutableValidationTest.php new file mode 100644 index 00000000..4d65456d --- /dev/null +++ b/phpunit/src/Immutable/ImmutableValidationTest.php @@ -0,0 +1,183 @@ +expectException(TestError::class); + $this->expectExceptionMessage('Cannot modify immutable value `$this`'); + $this->compile('immutable-method-property-write.php'); + } + + public function testRejectsImmutableParameterReassignment(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Cannot modify immutable value `$value`'); + $this->compile('immutable-parameter-reassign.php'); + } + + public function testRejectsMutableMethodCallOnImmutableThis(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Cannot call mutable method `ImmutableMethodCallsMutable::mutate()` on immutable value `$this`'); + $this->compile('immutable-method-calls-mutable.php'); + } + + public function testRejectsMutableMethodCallOnImmutableObjectParameter(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Cannot call mutable method `ImmutableObjectParameterTarget::mutate()` on immutable value `$target`'); + $this->compile('immutable-object-parameter-calls-mutable.php'); + } + + public function testRejectsImmutableObjectPassedToMutableParameter(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Immutable object `$value` requires an #[Immutable] parameter'); + $this->compile('immutable-object-passed-to-mutable-parameter.php'); + } + + public function testRejectsImmutablePropertyPassedToBuiltinReferenceParameter(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Cannot pass immutable value `$this` to reference parameter 1 of sort()'); + $this->compile('immutable-property-byref-builtin.php'); + } + + public function testRejectsImmutableArrayPassedToBuiltinReferenceParameter(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Cannot pass immutable value `$values` to reference parameter 1 of sort()'); + $this->compile('immutable-array-byref-builtin.php'); + } + + public function testImmutableObjectAliasesRemainImmutable(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Cannot call mutable method `ImmutableAliasTarget::mutate()` on immutable value `$alias`'); + $this->compile('immutable-object-alias-mutation.php'); + } + + public function testMethodOverrideCannotDropImmutableContract(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Declaration of `ImmutableOverrideChild::read()` must be compatible'); + $this->compile('immutable-method-override-drops-contract.php'); + } + + public function testParameterOverrideCannotDropImmutableContract(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Declaration of `ImmutableParameterChild::inspect()` must be compatible'); + $this->compile('immutable-parameter-override-drops-contract.php'); + } + + public function testClosureCapturePreservesImmutableBinding(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Cannot modify immutable value `$target`'); + $this->compile('immutable-closure-capture-mutation.php'); + } + + public function testForeachByReferenceCannotEscapeImmutableArray(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Cannot modify immutable value `$values`'); + $this->compile('immutable-write-forms.php'); + } + + /** @dataProvider immutableWriteProvider */ + public function testRejectsAdditionalImmutableWriteForms(string $fixture, string $variable): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage("Cannot modify immutable value `\${$variable}`"); + $this->compile($fixture); + } + + public static function immutableWriteProvider(): array + { + return [ + 'compound array write' => ['immutable-compound-write.php', 'values'], + 'unset property' => ['immutable-unset.php', 'value'], + 'take reference' => ['immutable-reference.php', 'values'], + 'destructuring assignment' => ['immutable-destructuring-write.php', 'values'], + ]; + } + + public function testRightAssociativeObjectAliasesRemainImmutable(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Cannot call mutable method `ImmutableChainAliasTarget::mutate()` on immutable value `$first`'); + $this->compile('immutable-chain-alias.php'); + } + + public function testImmutableObjectCannotBeStoredInMutableProperty(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Immutable object `$value` cannot be stored in mutable state'); + $this->compile('immutable-object-storage-escape.php'); + } + + public function testImmutableObjectCannotEscapeAsMutableReturnValue(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Immutable object `$value` cannot escape through a return value'); + $this->compile('immutable-object-return-escape.php'); + } + + public function testGeneratorBodyPreservesImmutableParameters(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Cannot call mutable method `ImmutableGeneratorTarget::mutate()` on immutable value `$target`'); + $this->compile('immutable-generator-context.php'); + } + + public function testClosureInImmutableMethodPreservesImmutableThis(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Cannot call mutable method `ImmutableClosureThis::mutate()` on immutable value `$this`'); + $this->compile('immutable-closure-this.php'); + } + + public function testPropertyHookMustDeclareImmutableContract(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Cannot call mutable method'); + $this->compile('immutable-mutable-property-hook.php'); + } + + public function testConstructorMustAcceptImmutableObjectContract(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Immutable object `$value` requires an #[Immutable] parameter'); + $this->compile('immutable-constructor-parameter.php'); + } + + public function testRejectsMutatingArrayExtensionMethod(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Cannot call mutating method `sort()` on immutable value `$values`'); + $this->compile('immutable-array-mutating-method.php'); + } + + public function testExtensionReceiverMustDeclareImmutableContract(): void + { + $this->expectException(TestError::class); + $this->expectExceptionMessage('Cannot call mutable method `ImmutableExtensionTarget::touch()`'); + $this->compile('immutable-mutable-extension-method.php'); + } +} diff --git a/src/CompilerBase.php b/src/CompilerBase.php index 80ad89c2..68436f65 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -84,6 +84,7 @@ use TypePhp\TypeSystem\CompositeTypeCheckerTrait; use TypePhp\TypeSystem\NativeTypeCompatibilityTrait; use TypePhp\NativeClass\NativeClassSupportTrait; use TypePhp\NativeClass\NativeGlobalTypeResolver; +use TypePhp\Immutable\ImmutableSupportTrait; use PhpParser\Modifiers; use PhpParser\Node; use PhpParser\Node\ArrayItem; @@ -105,6 +106,7 @@ class CompilerBase implements PropertyAccessContext use CompilationStateTrait; use NativeTypeCompatibilityTrait; use NativeClassSupportTrait; + use ImmutableSupportTrait; use NativeBuildConfigurationTrait; use PythonModuleTrait; use DeclarationSymbolTrait; @@ -2223,6 +2225,9 @@ class CompilerBase implements PropertyAccessContext protected function parseReturn(Node\Stmt\Return_ $v): string { + if ($v->expr !== null) { + $this->assertImmutableObjectDoesNotEscape($v->expr, 'a return value'); + } if ($v->expr !== null && $this->isVarExpr($v->expr)) { $this->assertStdContainerDoesNotEscapeNativeObjects( $v, @@ -3202,6 +3207,7 @@ class CompilerBase implements PropertyAccessContext protected function parsePreInc(Expr\PreInc $expr): string { + $this->assertImmutableMutationTarget($expr->var); $this->assertNativeArrayAccessDirectWrite($expr->var, false); $this->assertNativeObjectOperatorOperandSupported($expr->var, $expr, '++'); $this->assertNotNullsafeWriteContext($expr->var); @@ -3591,6 +3597,7 @@ class CompilerBase implements PropertyAccessContext protected function parsePostOp(Expr\PostDec|Expr\PostInc $expr, string $op): string { + $this->assertImmutableMutationTarget($expr->var); $this->assertNativeArrayAccessDirectWrite($expr->var, false); $this->assertNativeObjectOperatorOperandSupported($expr->var, $expr, str_repeat($op, 2)); $this->assertNotNullsafeWriteContext($expr->var); @@ -3642,6 +3649,7 @@ class CompilerBase implements PropertyAccessContext protected function parsePreDec(Expr\PreDec $expr): string { + $this->assertImmutableMutationTarget($expr->var); $this->assertNativeArrayAccessDirectWrite($expr->var, false); $this->assertNativeObjectOperatorOperandSupported($expr->var, $expr, '--'); $this->assertNotNullsafeWriteContext($expr->var); @@ -3699,6 +3707,7 @@ class CompilerBase implements PropertyAccessContext protected function parseNew(Expr\New_ $expr): string { + $this->validateImmutableCall($expr); if (!$expr->class instanceof Node\Stmt\Class_ && !$this->isNameExpr($expr->class)) { $this->assertNotNativeObjectDynamicClassTarget($expr->class, $expr); } diff --git a/src/Context/FunctionContext.php b/src/Context/FunctionContext.php index 767c6018..721a884f 100644 --- a/src/Context/FunctionContext.php +++ b/src/Context/FunctionContext.php @@ -72,6 +72,10 @@ class FunctionContext public bool $needsUserCodeCallableScope = false; public int $tmpVarIndex = 0; public array $arguments = []; + /** @var array Bindings protected by #[Immutable]. */ + public array $immutableVars = []; + /** @var array Immutable bindings which may contain object identity. */ + public array $immutableObjectVars = []; /** True while parsing a breakable loop or switch. */ public bool $inLoop = false; /** True while parsing a for/foreach/while/do-while body. */ @@ -102,6 +106,8 @@ class FunctionContext $this->localVars = []; $this->staticVars = []; $this->arguments = []; + $this->immutableVars = []; + $this->immutableObjectVars = []; $this->objects = []; $this->nativeObjects = []; $this->nonNullNativeObjects = []; diff --git a/src/Entity/ArgInfo.php b/src/Entity/ArgInfo.php index 76f3926f..a7d9038d 100644 --- a/src/Entity/ArgInfo.php +++ b/src/Entity/ArgInfo.php @@ -42,6 +42,8 @@ class ArgInfo public bool $undeclared = false; public bool $explicitMixed = false; public bool $property = false; + /** This parameter binding and any referenced object are read-only in the callee. */ + public bool $immutable = false; /** * Each element: ['kind' => 'isInt'|'isFloat'|...|'instanceof', 'class' => ''] diff --git a/src/Entity/FunctionDef.php b/src/Entity/FunctionDef.php index 8db7cb0d..90aaad82 100644 --- a/src/Entity/FunctionDef.php +++ b/src/Entity/FunctionDef.php @@ -42,6 +42,8 @@ class FunctionDef public bool $generator = false; /** The call result must not be discarded as a statement expression. */ public bool $mustUse = false; + /** This instance method may not mutate its receiver. */ + public bool $immutable = false; /** The method must override an inherited class or interface method. */ public bool $overrideRequired = false; /** Prefer optimizing this function for frequently executed paths. */ diff --git a/src/Generator/ClosureGenerator.php b/src/Generator/ClosureGenerator.php index ea7f9bc5..12a602a9 100644 --- a/src/Generator/ClosureGenerator.php +++ b/src/Generator/ClosureGenerator.php @@ -12,6 +12,7 @@ use TypePhp\Type; use TypePhp\Entity\ArgInfo; use TypePhp\Context\FunctionContext; +use TypePhp\Transform\CompileTimeAttribute; use PhpParser\Node; use PhpParser\Node\Expr; use PhpParser\Node\IntersectionType; @@ -201,6 +202,9 @@ trait ClosureGenerator $code .= $this->getIndent() . '}' . PHP_EOL; $code .= $this->genExtraNamedVariadicArgs($var); $this->addArgument($var, Type::ARRAY); + if (CompileTimeAttribute::consume($param, 'Immutable')) { + $this->context->immutableVars[$var] = true; + } $code .= $this->genClosureParamTypeCheck($param, $var, $phpName, $i, true); continue; } @@ -209,6 +213,18 @@ trait ClosureGenerator : 'php::getCallArg(' . $i . ', ' . $this->parseParamDefaultValue($param->default) . ')'; $code .= $this->getIndent() . 'auto ' . $var . ' = ' . $argExpr . ';' . PHP_EOL; $this->addArgument($var, Type::VAR); + if (CompileTimeAttribute::consume($param, 'Immutable')) { + $this->context->immutableVars[$var] = true; + if ($this->immutableTypeNodeMayBeObject($param->type)) { + $this->context->immutableObjectVars[$var] = true; + } + if ($param->type !== null) { + [, $parameterClass] = $this->resolveTypeDecl($param->type, self::DECL_TYPE_OF_PARAM); + if ($parameterClass !== '') { + $this->addObject($var, $parameterClass); + } + } + } $code .= $this->genClosureParamTypeCheck($param, $var, $phpName, $i, false); } @@ -216,10 +232,20 @@ trait ClosureGenerator $var = $this->parseIdentifier($useItem->var); $code .= 'auto ' . $var . ' = vars_.get(' . $i . ');' . PHP_EOL; $this->addArgument($var, Type::VAR); + if (isset($oriContext->immutableVars[$var])) { + $this->context->immutableVars[$var] = true; + if (isset($oriContext->immutableObjectVars[$var])) { + $this->context->immutableObjectVars[$var] = true; + } + } } if ($this->methodDef && !$expr->static) { $this->addArgument('this_', Type::OBJECT); + if (isset($oriContext->immutableVars['this_'])) { + $this->context->immutableVars['this_'] = true; + $this->context->immutableObjectVars['this_'] = true; + } } $body = $isGenerator diff --git a/src/Generator/FiberGenerator.php b/src/Generator/FiberGenerator.php index bd9bfa9a..68d1307c 100644 --- a/src/Generator/FiberGenerator.php +++ b/src/Generator/FiberGenerator.php @@ -185,6 +185,7 @@ trait FiberGenerator private function materializeYieldOperand(Node $expr, bool $force = false): string { + $this->assertImmutableObjectDoesNotEscape($expr, 'a yielded value'); if ($this->isNativeObjectClass($this->detectClassOfExpr($expr))) { // Yield payloads are stored in a Zend array and cross the Fiber / // Generator object boundary. A Native pointer has no zval form. @@ -281,10 +282,17 @@ trait FiberGenerator foreach ($functionDef->argInfoList as $i => $argInfo) { $code .= $this->getIndent() . Type::VAR . ' ' . $argInfo->name . ' = vars_.get(' . $i . ');' . PHP_EOL; $this->addArgument($argInfo->name, Type::VAR); + $argumentClass = $argInfo->declaredClass ?: $argInfo->class; + if ($argumentClass !== '') { + $this->addObject($argInfo->name, $argumentClass); + } } if ($this->class) { $this->addArgument('this_', Type::OBJECT); } + // The Fiber body has its own FunctionContext. Reapply compile-time + // effect metadata so suspension does not erase Immutable guarantees. + $this->initializeImmutableFunctionContext(); $body = ''; $this->indentLevel++; diff --git a/src/Immutable/ImmutableSupportTrait.php b/src/Immutable/ImmutableSupportTrait.php new file mode 100644 index 00000000..46d200cc --- /dev/null +++ b/src/Immutable/ImmutableSupportTrait.php @@ -0,0 +1,365 @@ +immutableTypeNodeMayBeObject($type->type); + } + if ($type instanceof Node\UnionType || $type instanceof Node\IntersectionType) { + foreach ($type->types as $member) { + if ($this->immutableTypeNodeMayBeObject($member)) { + return true; + } + } + return false; + } + if ($type instanceof Node\Name) { + return true; + } + if ($type instanceof Node\Identifier) { + return in_array(strtolower($type->toString()), ['mixed', 'object', 'iterable', 'callable'], true); + } + return false; + } + + protected function initializeImmutableFunctionContext(): void + { + if ($this->functionDef?->immutable && $this->methodDef !== null) { + $this->context->immutableVars['this_'] = true; + $this->context->immutableObjectVars['this_'] = true; + } + foreach ($this->functionDef?->argInfoList ?? [] as $argument) { + if (!$argument->immutable) { + continue; + } + $this->context->immutableVars[$argument->name] = true; + if ($argument->type === Type::OBJECT || $argument->type === Type::VAR) { + $this->context->immutableObjectVars[$argument->name] = true; + } + } + } + + protected function immutableRootName(NodeAbstract $expression): ?string + { + if ($this->context->immutableVars === []) { + return null; + } + if ($expression instanceof Node\Expr\ErrorSuppress) { + return $this->immutableRootName($expression->expr); + } + if ($expression instanceof Node\Expr\Variable && is_string($expression->name)) { + $name = $this->parseVariable($expression); + return isset($this->context->immutableVars[$name]) ? $name : null; + } + if ($expression instanceof Node\Expr\PropertyFetch + || $expression instanceof Node\Expr\NullsafePropertyFetch + || $expression instanceof Node\Expr\ArrayDimFetch + ) { + return $this->immutableRootName($expression->var); + } + if (($expression instanceof Node\Expr\MethodCall + || $expression instanceof Node\Expr\NullsafeMethodCall) + && $this->immutableRootName($expression->var) !== null + && $this->immutableCalledMethod($expression)?->immutable + ) { + return $this->immutableRootName($expression->var); + } + return null; + } + + protected function immutableDisplayName(string $name): string + { + return $name === 'this_' ? '$this' : '$' . $this->unescapeVarName($name); + } + + protected function immutableValueMayBeObject(NodeAbstract $expression): bool + { + $root = $this->immutableRootName($expression); + if ($root === null) { + return false; + } + $type = $this->detectTypeOfExpr($expression); + // An immutable receiver may produce an ordinary scalar/COW value. + // Preserve constness only when object identity is possible. + if ($type !== Type::OBJECT && $type !== Type::VAR && $type !== Type::REF) { + return false; + } + if (isset($this->context->immutableObjectVars[$root])) { + return true; + } + return $this->detectClassOfExpr($expression) !== '' + || $type === Type::OBJECT; + } + + protected function assertImmutableMutationTarget(NodeAbstract $target): void + { + if ($target instanceof Node\Expr\List_ || $target instanceof Node\Expr\Array_) { + foreach ($target->items as $item) { + if ($item !== null) { + $this->assertImmutableMutationTarget($item->value); + } + } + return; + } + $root = $this->immutableRootName($target); + if ($root !== null) { + $this->fatalError( + $target, + 'Cannot modify immutable value `' . $this->immutableDisplayName($root) . '`', + ); + } + } + + protected function recordImmutableAlias(NodeAbstract $left, NodeAbstract $right): void + { + if ($right instanceof Node\Expr\Clone_) { + return; + } + $root = $this->immutableRootName($right); + if ($root === null || !$this->immutableValueMayBeObject($right)) { + return; + } + if (!$left instanceof Node\Expr\Variable || !is_string($left->name)) { + $this->fatalError( + $right, + 'Immutable object `' . $this->immutableDisplayName($root) + . '` cannot be stored in mutable state', + ); + } + $name = $this->parseVariable($left); + if ($this->hasScopeGlobalVar($name) || $this->hasStaticVar($name)) { + $this->fatalError( + $right, + 'Immutable object `' . $this->immutableDisplayName($root) + . '` cannot be stored in mutable state', + ); + } + $this->context->immutableVars[$name] = true; + $this->context->immutableObjectVars[$name] = true; + $class = $this->detectClassOfExpr($right); + if ($class !== '') { + $this->addObject($name, $class); + } + } + + protected function assertImmutableObjectDoesNotEscape(NodeAbstract $expression, string $destination): void + { + $root = $this->immutableRootName($expression); + if ($root !== null && $this->immutableValueMayBeObject($expression)) { + $this->fatalError( + $expression, + 'Immutable object `' . $this->immutableDisplayName($root) + . '` cannot escape through ' . $destination, + ); + } + } + + protected function assertImmutableValueMethodDoesNotMutate( + Node\Expr\MethodCall|Node\Expr\NullsafeMethodCall $call, + string $root, + ): void { + if (!$call->name instanceof Node\Identifier) { + return; + } + $type = $this->detectTypeOfExpr($call->var); + $method = $call->name->toString(); + $definition = self::UNIVERSAL_METHODS[$type][$method] ?? null; + if ($definition !== null && in_array($definition['handler'], self::MUTATING_HANDLERS, true)) { + $this->fatalError( + $call, + "Cannot call mutating method `{$method}()` on immutable value `" + . $this->immutableDisplayName($root) . '`', + ); + } + } + + protected function immutableExtensionAcceptsReceiver( + Node\Expr\MethodCall|Node\Expr\NullsafeMethodCall $call, + ): bool { + if (!$call->name instanceof Node\Identifier) { + return false; + } + $method = $call->name->toString(); + $class = $this->detectClassOfExpr($call->var); + if ($class !== '') { + $definition = $this->findObjectExtensionMethod($class, $method, true); + } else { + $definition = $this->findExtensionMethod($this->detectTypeOfExpr($call->var), $method); + } + $definition ??= $this->findKeywordExtensionMethod($method); + return (bool) ($definition['receiver_immutable'] ?? false); + } + + protected function immutableCalledMethod( + Node\Expr\MethodCall|Node\Expr\NullsafeMethodCall $call, + ): ?FunctionDef { + $ordinary = $call instanceof Node\Expr\NullsafeMethodCall + ? new Node\Expr\MethodCall($call->var, $call->name, $call->args, $call->getAttributes()) + : $call; + return $this->resolveCalledFunctionDef($ordinary); + } + + protected function immutableClassName(Node\Name $name): string + { + $class = $this->parseIdentifier($name); + if ($class === 'self' || $class === 'static') { + return $this->getFullClassName(); + } + if ($class === 'parent') { + return $this->classDef?->extends ?? ''; + } + return $this->getNamespacedClassName($class); + } + + protected function immutableArgInfo( + FunctionDef $function, + Node\Arg $argument, + int $index, + ): ?ArgInfo { + if ($argument->name === null) { + return $this->getArgInfoByIndex($function, $index); + } + if (!$argument->name instanceof Node\Identifier) { + return null; + } + $name = $argument->name->toString(); + $variadic = null; + foreach ($function->argInfoList as $info) { + if ($info->variadic) { + $variadic = $info; + } + if (($info->phpName ?: $this->unescapeVarName($info->name)) === $name) { + return $info; + } + } + return $variadic; + } + + /** @return array{string, string} function/method name and class name */ + protected function immutableCallableName(Node\Expr\CallLike $call): array + { + if ($call instanceof Node\Expr\FuncCall && $call->name instanceof Node\Name) { + return [ltrim($this->parseIdentifier($call->name), '\\'), '']; + } + if (($call instanceof Node\Expr\MethodCall || $call instanceof Node\Expr\NullsafeMethodCall) + && $call->name instanceof Node\Identifier + ) { + $class = $this->detectClassOfExpr($call->var); + if ($class === '' && $call->var instanceof Node\Expr\Variable && is_string($call->var->name)) { + $name = $this->parseVariable($call->var); + $class = $name === 'this_' ? $this->getFullClassName() : $this->getDeclaredObjectType($name); + } + return [$call->name->toString(), $class]; + } + if ($call instanceof Node\Expr\StaticCall + && $call->class instanceof Node\Name + && $call->name instanceof Node\Identifier + ) { + $class = $this->immutableClassName($call->class); + return [$call->name->toString(), $class]; + } + if ($call instanceof Node\Expr\New_ && $call->class instanceof Node\Name) { + return ['__construct', $this->immutableClassName($call->class)]; + } + return ['', self::DYNAMIC_CALLED_CLASS]; + } + + protected function validateImmutableCall(Node\Expr\CallLike $call): void + { + if ($this->context->immutableVars === []) { + return; + } + if ($call->getAttribute('typephpImmutableValidated', false)) { + return; + } + $call->setAttribute('typephpImmutableValidated', true); + + $function = $this->resolveCalledFunctionDef($call); + if ($call instanceof Node\Expr\NullsafeMethodCall) { + $function = $this->immutableCalledMethod($call); + } elseif ($call instanceof Node\Expr\New_ && $call->class instanceof Node\Name) { + $class = $this->immutableClassName($call->class); + $function = $class === '' ? null : $this->findAotMethodFunctionDef($class, '__construct'); + } + + if (($call instanceof Node\Expr\MethodCall || $call instanceof Node\Expr\NullsafeMethodCall) + && $call->name instanceof Node\Identifier + && ($root = $this->immutableRootName($call->var)) !== null + ) { + // A named call must be proven immutable. A variable method name + // is an explicit escape hatch, similar to const_cast in C++; + // #[Immutable] deliberately has no runtime component. + if (!$this->immutableValueMayBeObject($call->var)) { + $this->assertImmutableValueMethodDoesNotMutate($call, $root); + } elseif (($function === null || !$function->immutable) + && !$this->immutableExtensionAcceptsReceiver($call) + ) { + $method = $call->name->toString(); + $class = $this->detectClassOfExpr($call->var) ?: 'object'; + $this->fatalError( + $call, + "Cannot call mutable method `{$class}::{$method}()` on immutable value `" + . $this->immutableDisplayName($root) . '`', + ); + } + } + + [$callable, $class] = $this->immutableCallableName($call); + $staticallyResolved = $function !== null + || ($call instanceof Node\Expr\FuncCall && $callable !== '') + || ($callable !== '' && $class !== '' && $class !== self::DYNAMIC_CALLED_CLASS); + if (!$staticallyResolved) { + return; + } + foreach ($call->args as $index => $argument) { + if ($argument instanceof Node\VariadicPlaceholder) { + continue; + } + $root = $this->immutableRootName($argument->value); + if ($root === null) { + continue; + } + $info = $function === null ? null : $this->immutableArgInfo($function, $argument, $index); + $byRef = $info?->byRef ?? false; + if ($function === null) { + $byRef = $argument->name instanceof Node\Identifier + ? $this->isReferenceNamedArgument($callable, $class, $argument->name->toString()) + : $this->isReferenceArgument($callable, $class, $index); + } + if ($byRef && !($info?->immutable ?? false)) { + $this->fatalError( + $argument, + 'Cannot pass immutable value `' . $this->immutableDisplayName($root) + . '` to reference parameter ' . ($index + 1) . ' of ' . $callable . '()', + ); + } + if ($this->immutableValueMayBeObject($argument->value) && !($info?->immutable ?? false)) { + $this->fatalError( + $argument, + 'Immutable object `' . $this->immutableDisplayName($root) + . '` requires an #[Immutable] parameter', + ); + } + } + } +} diff --git a/src/Parser/AssignOpTrait.php b/src/Parser/AssignOpTrait.php index 6d74b3f2..a088b23c 100644 --- a/src/Parser/AssignOpTrait.php +++ b/src/Parser/AssignOpTrait.php @@ -130,6 +130,10 @@ trait AssignOpTrait $rightVar = new Variable($tmpVar); foreach ($chain as $var) { $list[] = $this->parseAssignFinally($var, $rightVar); + // The synthetic temporary has no PHP-level binding metadata. Use + // the original RHS to retain immutable object identity across a + // right-associative assignment chain after validating the write. + $this->recordImmutableAlias($var, $next); } return '(' . implode(', ', $list) . ')'; @@ -232,6 +236,8 @@ trait AssignOpTrait protected function parseAssignFinally(Expr $left, Expr $right): string { + $this->assertImmutableMutationTarget($left); + $this->recordImmutableAlias($left, $right); $this->assertNotNullsafeWriteContext($left); $this->assertNativeArrayAccessDirectWrite($left, true); if ($left instanceof Expr\ArrayDimFetch @@ -739,6 +745,7 @@ trait AssignOpTrait protected function parseAssignOp(Expr\AssignOp $node, string $op): string { + $this->assertImmutableMutationTarget($node->var); $this->assertNativeArrayAccessDirectWrite($node->var, false); $this->assertNativeObjectOperatorOperandSupported($node->var, $node, $op); $this->assertNotNullsafeWriteContext($node->var); @@ -1072,6 +1079,8 @@ trait AssignOpTrait protected function parseAssignRef(Expr\AssignRef $expr): string { + $this->assertImmutableMutationTarget($expr->var); + $this->assertImmutableMutationTarget($expr->expr); $this->assertNativeArrayAccessReferenceForbidden($expr->var); $this->assertNativeArrayAccessReferenceForbidden($expr->expr); $this->assertNotNullsafeWriteContext($expr->var); @@ -1218,6 +1227,7 @@ trait AssignOpTrait protected function parseAssignOpCoalesce(Expr\AssignOp\Coalesce $expr): string { + $this->assertImmutableMutationTarget($expr->var); $this->assertNativeArrayAccessDirectWrite($expr->var, false); $this->checkLeftValue($expr->var); diff --git a/src/Parser/ForeachTrait.php b/src/Parser/ForeachTrait.php index c77efe28..41f9bb97 100644 --- a/src/Parser/ForeachTrait.php +++ b/src/Parser/ForeachTrait.php @@ -171,6 +171,9 @@ trait ForeachTrait protected function parseForeach(Foreach_ $node): string { + if ($node->byRef) { + $this->assertImmutableMutationTarget($node->expr); + } $nativeClass = $this->detectClassOfExpr($node->expr); if ($this->isNativeObjectClass($nativeClass)) { if ($this->nativeClassImplementsInterface($nativeClass, 'Iterator')) { diff --git a/src/Parser/FunctionCallTrait.php b/src/Parser/FunctionCallTrait.php index 3b39777e..601ead67 100644 --- a/src/Parser/FunctionCallTrait.php +++ b/src/Parser/FunctionCallTrait.php @@ -71,6 +71,7 @@ trait FunctionCallTrait protected function parseFuncCall(Expr\FuncCall $expr): string { + $this->validateImmutableCall($expr); $pythonCall = $this->parsePythonFunctionCall($expr); if ($pythonCall !== null) { return $pythonCall; diff --git a/src/Parser/MethodCallTrait.php b/src/Parser/MethodCallTrait.php index daa5018f..17564a7b 100644 --- a/src/Parser/MethodCallTrait.php +++ b/src/Parser/MethodCallTrait.php @@ -361,6 +361,7 @@ trait MethodCallTrait protected function parseMethodCall(Expr\MethodCall $expr): string { + $this->validateImmutableCall($expr); if ($this->containsNullsafeChain($expr->var)) { return $this->parseNullsafeExpr($expr); } @@ -752,6 +753,7 @@ trait MethodCallTrait protected function parseStaticCall(Expr\StaticCall $expr): string { + $this->validateImmutableCall($expr); if (!$this->isNameExpr($expr->class)) { $this->assertNotNativeObjectDynamicClassTarget($expr->class, $expr); } diff --git a/src/Parser/PropertyAccessTrait.php b/src/Parser/PropertyAccessTrait.php index 7e4b2d3f..e7162a72 100644 --- a/src/Parser/PropertyAccessTrait.php +++ b/src/Parser/PropertyAccessTrait.php @@ -785,6 +785,7 @@ trait PropertyAccessTrait $vars = $node->vars; $lines = []; foreach ($vars as $var) { + $this->assertImmutableMutationTarget($var); $this->assertNotNullsafeWriteContext($var); $this->assertNativePropertyHookDirectWriteTarget($var); if ($this->isArrayDimFetch($var)) { diff --git a/src/Parser/UniversalMethodCall.php b/src/Parser/UniversalMethodCall.php index 967c5886..f7dd13e5 100644 --- a/src/Parser/UniversalMethodCall.php +++ b/src/Parser/UniversalMethodCall.php @@ -397,6 +397,7 @@ trait UniversalMethodCall 'return_type' => $function->returnType, 'min_args' => max(0, $function->argCountRequired - 1), 'max_args' => $function->hasVariadicArg() ? -1 : count($function->argInfoList) - 1, + 'receiver_immutable' => $receiver->immutable, ]; } } diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 4e78d37c..9e22650e 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -640,6 +640,7 @@ class Preprocessor extends CompilerBase $argInfo->byRef = $param->byRef; $argInfo->variadic = $param->variadic; $argInfo->property = $param->isPromoted(); + $argInfo->immutable = \TypePhp\Transform\CompileTimeAttribute::consume($param, 'Immutable'); if ($param->type === null || $param->type instanceof NullableType) { $argInfo->nullable = true; } @@ -746,6 +747,7 @@ class Preprocessor extends CompilerBase $functionDef = new FunctionDef($fnName, $returnType, $this->namespace); $functionDef->mustUse = (bool) $v->getAttribute(FunctionAttributeLowering::MUST_USE_ATTRIBUTE, false); + $functionDef->immutable = (bool) $v->getAttribute(FunctionAttributeLowering::IMMUTABLE_ATTRIBUTE, false); $functionDef->overrideRequired = (bool) $v->getAttribute(FunctionAttributeLowering::OVERRIDE_ATTRIBUTE, false); $functionDef->hot = (bool) $v->getAttribute(FunctionAttributeLowering::HOT_ATTRIBUTE, false); $functionDef->cold = (bool) $v->getAttribute(FunctionAttributeLowering::COLD_ATTRIBUTE, false); diff --git a/src/Transform/CompileTimeAttribute.php b/src/Transform/CompileTimeAttribute.php index e8bc272c..3342d427 100644 --- a/src/Transform/CompileTimeAttribute.php +++ b/src/Transform/CompileTimeAttribute.php @@ -189,6 +189,10 @@ final class CompileTimeAttribute && $node instanceof Node\Stmt\ClassMethod) { return true; } + if (in_array(CompileTimeAttributeRegistry::TARGET_PROPERTY_HOOK, $targets, true) + && $node instanceof Node\PropertyHook) { + return true; + } if (in_array(CompileTimeAttributeRegistry::TARGET_PROPERTY, $targets, true) && ($node instanceof Node\Stmt\Property || ($node instanceof Node\Param && $node->isPromoted()))) { return true; diff --git a/src/Transform/CompileTimeAttributeRegistry.php b/src/Transform/CompileTimeAttributeRegistry.php index 79f481c4..5885b325 100644 --- a/src/Transform/CompileTimeAttributeRegistry.php +++ b/src/Transform/CompileTimeAttributeRegistry.php @@ -15,6 +15,7 @@ final class CompileTimeAttributeRegistry public const TARGET_CLASS_LIKE = 'class_like'; public const TARGET_FUNCTION = 'function'; public const TARGET_METHOD = 'method'; + public const TARGET_PROPERTY_HOOK = 'property_hook'; public const TARGET_PROPERTY = 'property'; public const TARGET_DECLARED_PROPERTY = 'declared_property'; public const TARGET_PARAMETER = 'parameter'; @@ -88,6 +89,7 @@ final class CompileTimeAttributeRegistry $add('Validate', [self::TARGET_PARAMETER], 'Validate can only be applied to function or method parameters', self::ARGUMENTS_VALIDATE, self::PHASE_FUNCTION_LEAVE); $add('Override', [self::TARGET_METHOD], 'Override can only be applied to methods', self::ARGUMENTS_NONE, self::PHASE_ENTER); $add('MustUse', [self::TARGET_FUNCTION, self::TARGET_METHOD], 'MustUse can only be applied to functions or methods', self::ARGUMENTS_NONE, self::PHASE_ENTER); + $add('Immutable', [self::TARGET_METHOD, self::TARGET_PROPERTY_HOOK, self::TARGET_PARAMETER], 'Immutable can only be applied to methods, property hooks, or function parameters', self::ARGUMENTS_NONE, self::PHASE_ENTER); $add('Hot', [self::TARGET_FUNCTION, self::TARGET_METHOD], 'Hot can only be applied to functions or methods', self::ARGUMENTS_NONE, self::PHASE_ENTER, true, ['Cold']); $add('Cold', [self::TARGET_FUNCTION, self::TARGET_METHOD], 'Cold can only be applied to functions or methods', self::ARGUMENTS_NONE, self::PHASE_ENTER, true, ['Hot']); $add('Constructor', [self::TARGET_DECLARED_PROPERTY], 'Constructor can only be applied to instance properties', self::ARGUMENTS_NONE, self::PHASE_CLASS_LEAVE); diff --git a/src/Transform/FunctionAttributeLowering.php b/src/Transform/FunctionAttributeLowering.php index 3e426347..92351e17 100644 --- a/src/Transform/FunctionAttributeLowering.php +++ b/src/Transform/FunctionAttributeLowering.php @@ -15,6 +15,7 @@ use TypePhp\Exception\SyntaxError; final class FunctionAttributeLowering { public const MUST_USE_ATTRIBUTE = 'typephpMustUse'; + public const IMMUTABLE_ATTRIBUTE = 'typephpImmutable'; public const OVERRIDE_ATTRIBUTE = 'typephpOverride'; public const HOT_ATTRIBUTE = 'typephpHot'; public const COLD_ATTRIBUTE = 'typephpCold'; @@ -25,6 +26,17 @@ final class FunctionAttributeLowering if (!CompileTimeAttribute::has($node, $name)) { continue; } + if ($name === 'Immutable' && $node instanceof Node\Param) { + // Parameter metadata is consumed while building ArgInfo. + continue; + } + if ($name === 'Immutable' && $node instanceof Node\PropertyHook) { + // Property hooks are later lowered to generated ClassMethod + // nodes. Carry the effect bit through the source attributes. + CompileTimeAttribute::consume($node, $name); + $node->setAttribute(self::IMMUTABLE_ATTRIBUTE, true); + continue; + } if (!$node instanceof Stmt\Function_ && !$node instanceof Stmt\ClassMethod) { throw new SyntaxError($name . ' can only be applied to functions or methods'); } diff --git a/src/Translator.php b/src/Translator.php index 19893c87..d208e275 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -3753,6 +3753,7 @@ CODE; $this->markNativeObjectNonNull($argInfo->name); } } + $this->initializeImmutableFunctionContext(); if ($this->functionDef->generator) { try { @@ -3984,6 +3985,13 @@ CODE; )); } + // Immutable is an effect contract. Code compiled against the parent + // may pass a read-only object or call the method through a read-only + // receiver, so an override must not silently regain write access. + if ($parentFuncDef->immutable && !$childFuncDef->immutable) { + $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); + } + if (!$this->isReturnTypeOverrideCompatible( $childFuncDef, $parentFuncDef, @@ -4008,6 +4016,9 @@ CODE; $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); } $childArg = $childFuncDef->argInfoList[$i]; + if ($parentArg->immutable && !$childArg->immutable) { + $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); + } if (!$this->isParameterTypeOverrideCompatible($childArg, $parentArg)) { $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); } diff --git a/src/polyfills.php b/src/polyfills.php index b0e72271..a673ce4f 100644 --- a/src/polyfills.php +++ b/src/polyfills.php @@ -95,6 +95,11 @@ final readonly class MustUse { } +#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_PARAMETER)] +final readonly class Immutable +{ +} + #[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD)] final readonly class Hot { diff --git a/tests/compiler/attribute/immutable.phpt b/tests/compiler/attribute/immutable.phpt new file mode 100644 index 00000000..c0476655 --- /dev/null +++ b/tests/compiler/attribute/immutable.phpt @@ -0,0 +1,137 @@ +--TEST-- +Immutable compile-time attribute preserves read-only methods and parameters +--FILE-- +label(); +} + +function sumImmutable(#[Immutable] array $values): int +{ + return count($values) + $values->count() + $values[0] + $values[1]; +} + +function inspectImmutableReference(#[Immutable] ImmutableUser &$user): string +{ + return $user->name(); +} + +trait ImmutableNameTrait +{ + #[Immutable] + public function traitName(): string + { + return $this->name(); + } +} + +class ImmutableUser +{ + use ImmutableNameTrait; + + private string $name = 'Rango'; + + #[Immutable] + public function name(): string + { + return $this->name; + } + + #[Immutable] + public function describe(): string + { + return inspectImmutable($this) . ':' . $this->name(); + } + + public function rename(string $name): void + { + $this->name = $name; + } +} + +class ImmutableHookedValue +{ + public string $value = 'hook' { + #[Immutable] + get => strtoupper($this->value); + } + + #[Immutable] + public function read(): string + { + return $this->value; + } +} + +class ImmutableReader +{ + public function __construct(#[Immutable] ImmutableUser $user) + { + echo $user->name(), PHP_EOL; + } +} + +#[MethodsFor(ImmutableUser::class)] +class ImmutableUserMethods +{ + public static function label(#[Immutable] ImmutableUser $user): string + { + return $user->name(); + } +} + +function cloneImmutable(#[Immutable] ImmutableUser $user): string +{ + $copy = clone $user; + $copy->rename('Clone'); + return $copy->name(); +} + +function deliberatelyEscapeImmutableCheck(#[Immutable] ImmutableUser $user): string +{ + $method = 'rename'; + $user->$method('Dynamic'); + return $user->name(); +} + +function closureImmutableParameter(ImmutableUser $user): string +{ + $callback = function (#[Immutable] ImmutableUser $value): string { + return $value->name(); + }; + return $callback($user); +} + +function dynamicTargetEscape(mixed $target, #[Immutable] ImmutableUser $user): void +{ + // The runtime receiver hides the parameter contract from the compiler. + $target->accept($user); +} + +function main(): void +{ + $user = new ImmutableUser(); + echo $user->describe(), PHP_EOL; + echo $user->traitName(), PHP_EOL; + echo sumImmutable([2, 3]), PHP_EOL; + echo cloneImmutable($user), PHP_EOL; + echo inspectImmutableReference($user), PHP_EOL; + echo deliberatelyEscapeImmutableCheck($user), PHP_EOL; + echo (new ImmutableHookedValue())->read(), PHP_EOL; + echo closureImmutableParameter($user), PHP_EOL; + new ImmutableReader($user); +} +?> +--EXPECT-- +Rango:Rango +Rango +9 +Clone +Rango +Dynamic +HOOK +Dynamic +Dynamic