diff --git a/cli.php b/cli.php index a8460894..bf5b6081 100755 --- a/cli.php +++ b/cli.php @@ -1,5 +1,4 @@ #!/usr/bin/env php foo(); + } +} \ No newline at end of file diff --git a/phpunit/code/arrayable-dynamic-field.php b/phpunit/code/arrayable-dynamic-field.php new file mode 100644 index 00000000..987cea96 --- /dev/null +++ b/phpunit/code/arrayable-dynamic-field.php @@ -0,0 +1,7 @@ + $this->name]; + } +} diff --git a/phpunit/code/arrayable-invalid-argument.php b/phpunit/code/arrayable-invalid-argument.php new file mode 100644 index 00000000..0119e1fc --- /dev/null +++ b/phpunit/code/arrayable-invalid-argument.php @@ -0,0 +1,7 @@ + 8]; + } +} + +function main(): void +{ + $user = new ArrayableUser(); + $data = $user->toArray(); + echo $data['name']; + echo $data['baseId']; + echo count($data); + echo $user; + echo $user->toString(); + + $defaults = (new ArrayableDefaults())->toArray(); + echo $defaults['baseId']; + echo $defaults['name']; + echo count($defaults); + echo count((new EmptyArrayable())->toArray()); + echo (new LateFieldArrayable())->toArray()['lateId']; + echo (new LateCustomArrayable())->toArray()['custom']; +} diff --git a/phpunit/code/compile-time-attribute-duplicate.php b/phpunit/code/compile-time-attribute-duplicate.php new file mode 100644 index 00000000..a9a7cbfd --- /dev/null +++ b/phpunit/code/compile-time-attribute-duplicate.php @@ -0,0 +1,10 @@ + true]; + } +} + +#[Arrayable] +class CustomArrayableChild extends CustomArrayableBase +{ + public int $value = 1; +} + +#[Arrayable] +class DefaultArrayable extends PrintableBase +{ + public string $name = 'default'; + protected string $hidden = 'hidden'; + public static int $shared = 1; +} + #[Printer] class CustomPrinterChild extends CustomPrinterBase { @@ -53,7 +79,7 @@ class LatePrinterChild extends LatePrinterBase class LatePrinterBase { - public function toString(): string + public function __toString(): string { return 'late'; } @@ -73,21 +99,65 @@ function requireValue(#[NotNull] int $value): int return $value; } +function requireEmail( + #[NotEmpty] + #[Validate(FILTER_VALIDATE_EMAIL, message: 'Invalid email')] + string $email, +): string { + return $email; +} + +function requirePort( + #[Validate( + FILTER_VALIDATE_INT, + options: ['options' => ['min_range' => 1, 'max_range' => 65535]], + )] + int $port, +): int { + return $port; +} + +function requireBoolean(#[Validate(FILTER_VALIDATE_BOOLEAN)] bool $value): bool +{ + return $value; +} + function main(): void { $requireName = function (#[NotNull] string $name): string { return $name; }; + $requireNonEmptyName = function (#[NotEmpty] string $name): string { + return $name; + }; + $requireValidEmail = function ( + #[Validate(FILTER_VALIDATE_EMAIL)] string $email, + ): string { + return $email; + }; $user = new User(); $user->setNickname('php'); $copy = $user->withNickname('cpp'); echo $user->getNickname(); echo $copy->getNickname(); - echo $user->toString(); - echo (new CustomPrinterChild())->toString(); - echo (new LatePrinterChild())->toString(); + echo $user; + $userData = $user->toArray(); + echo $userData['baseId']; + echo $userData['name']; + $defaultData = (new DefaultArrayable())->toArray(); + echo $defaultData['baseId']; + echo $defaultData['name']; + echo (new CustomArrayableChild())->toArray()['custom']; + echo new CustomPrinterChild(); + echo new LatePrinterChild(); echo requireValue(1); echo $requireName('typephp'); + echo $requireNonEmptyName('typephp'); + echo $requireValidEmail('typephp@example.com'); + echo requireEmail('user@example.com'); + echo requirePort(9501); + echo requireBoolean(false); + echo $requireName('typephp'); $promoted = new PromotedProperties(1); $promoted->setValue(2); diff --git a/phpunit/code/compiler_api/library_import_php.php b/phpunit/code/compiler_api/library_import_php.php index ad74a2dc..99be112d 100644 --- a/phpunit/code/compiler_api/library_import_php.php +++ b/phpunit/code/compiler_api/library_import_php.php @@ -2,20 +2,28 @@ namespace LibraryApi; -use \ExtensionProvider as Provider; +use \Arrayable; +use \MethodsFor as Provider; +use \Constructor; +use \Validate; use \Getter; +use \Hot; use \NotNull; use \NoExport as Internal; +use \Override; +use \MustUse; use \Printer; +use \Cold; use \Setter; use \Type; use \With; -#[Printer] +#[Printer(fields: ['value', 'doubled'])] +#[Arrayable(['value'])] class Counter { public const int STEP = 2; - #[Getter, Setter, With] + #[Constructor, Getter, Setter, With] public int $value = 1; public int $doubled { get { @@ -32,7 +40,8 @@ class Counter return $this->value; } - public function label(#[NotNull] string $value): string + #[MustUse, Cold] + public function label(#[NotNull, Validate(FILTER_VALIDATE_EMAIL)] string $value): string { return $value; } @@ -63,6 +72,7 @@ class InternalStringExtension } } +#[MustUse, Hot] function twice(int $value): int { return $value * 2; @@ -73,3 +83,20 @@ function internal_twice(int $value = 2): int { return $value * 2; } + +class LibraryParent +{ + public function version(): int + { + return 1; + } +} + +class LibraryChild extends LibraryParent +{ + #[Override] + public function version(): int + { + return 2; + } +} diff --git a/phpunit/code/constructor-existing.php b/phpunit/code/constructor-existing.php new file mode 100644 index 00000000..2d0576eb --- /dev/null +++ b/phpunit/code/constructor-existing.php @@ -0,0 +1,11 @@ +name; + } +} diff --git a/phpunit/code/generated-method-final-parent-conflict.php b/phpunit/code/generated-method-final-parent-conflict.php new file mode 100644 index 00000000..29c905b5 --- /dev/null +++ b/phpunit/code/generated-method-final-parent-conflict.php @@ -0,0 +1,15 @@ +run(1); + echo $worker->fail('test'); +} + +} diff --git a/phpunit/code/hot-invalid-target.php b/phpunit/code/hot-invalid-target.php new file mode 100644 index 00000000..5b7b5a50 --- /dev/null +++ b/phpunit/code/hot-invalid-target.php @@ -0,0 +1,6 @@ +name); + } +} + +function main(): void +{ + $value = calculate(1); + $user = new User(1); + $name = $user->displayName(); + echo $value; + echo $name; + echo nullable(0); + echo nonEmpty('0'); +} diff --git a/phpunit/code/methods-for-inheritance.php b/phpunit/code/methods-for-inheritance.php new file mode 100644 index 00000000..2b34792e --- /dev/null +++ b/phpunit/code/methods-for-inheritance.php @@ -0,0 +1,106 @@ +keywordWins(); + echo $child->realWins(); + echo $child->inheritedExtension(); + echo $child->nearestExtension(); + echo $base->nearestExtension(); + echo $child->objectFallback(); + echo $contract->declaredMethod(); + echo $contract->objectFallback(); + echo $object->objectFallback(); +} + +function hierarchy_nullable_call(?HierarchyChild $child): void +{ + // A nullable receiver is not statically guaranteed to be an object, so it + // must not use the Type::Object fallback. + echo $child->objectFallback(); +} diff --git a/phpunit/code/methods-for-interface-target.php b/phpunit/code/methods-for-interface-target.php new file mode 100644 index 00000000..df3a343d --- /dev/null +++ b/phpunit/code/methods-for-interface-target.php @@ -0,0 +1,19 @@ +inspect(); +} diff --git a/phpunit/code/methods-for-keyword-conflict-reversed.php b/phpunit/code/methods-for-keyword-conflict-reversed.php new file mode 100644 index 00000000..cb978662 --- /dev/null +++ b/phpunit/code/methods-for-keyword-conflict-reversed.php @@ -0,0 +1,24 @@ +inspect(); +} diff --git a/phpunit/code/methods-for-keyword-conflict.php b/phpunit/code/methods-for-keyword-conflict.php new file mode 100644 index 00000000..53fd0d15 --- /dev/null +++ b/phpunit/code/methods-for-keyword-conflict.php @@ -0,0 +1,24 @@ +inspect(); +} diff --git a/phpunit/code/methods-for.php b/phpunit/code/methods-for.php new file mode 100644 index 00000000..42be8544 --- /dev/null +++ b/phpunit/code/methods-for.php @@ -0,0 +1,42 @@ +name; + } +} + +} + +namespace { + +function main(): void +{ + $name = 'TypePHP'; + echo $name->surround('<', '>'); + echo (new \MethodsForAttribute\User())->displayName(); +} + +} diff --git a/phpunit/code/must-use-discard.php b/phpunit/code/must-use-discard.php new file mode 100644 index 00000000..f921a5b0 --- /dev/null +++ b/phpunit/code/must-use-discard.php @@ -0,0 +1,12 @@ +create(); +} diff --git a/phpunit/code/not-empty-arrow-function.php b/phpunit/code/not-empty-arrow-function.php new file mode 100644 index 00000000..f8eca727 --- /dev/null +++ b/phpunit/code/not-empty-arrow-function.php @@ -0,0 +1,3 @@ + $value; diff --git a/phpunit/code/not-null-arrow-function.php b/phpunit/code/not-null-arrow-function.php new file mode 100644 index 00000000..7192dd13 --- /dev/null +++ b/phpunit/code/not-null-arrow-function.php @@ -0,0 +1,3 @@ + $value; diff --git a/phpunit/code/not-null-nullable-warning.php b/phpunit/code/not-null-nullable-warning.php new file mode 100644 index 00000000..c474a839 --- /dev/null +++ b/phpunit/code/not-null-nullable-warning.php @@ -0,0 +1,19 @@ +name; + } +} diff --git a/phpunit/code/printer-parent-private-field.php b/phpunit/code/printer-parent-private-field.php new file mode 100644 index 00000000..63165b88 --- /dev/null +++ b/phpunit/code/printer-parent-private-field.php @@ -0,0 +1,11 @@ + $value; diff --git a/phpunit/code/validate-compatible-types.php b/phpunit/code/validate-compatible-types.php new file mode 100644 index 00000000..eef1a105 --- /dev/null +++ b/phpunit/code/validate-compatible-types.php @@ -0,0 +1,13 @@ +compile('getter-function.php'); } + public function testCompileTimeAttributeRejectsAliasesOfTheSameAttributeRepeated(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage('Getter cannot be repeated on the same declaration'); + $this->compile('compile-time-attribute-duplicate.php'); + } + + public function testGetterSupportsReadonlyProperties(): void + { + $this->compile('getter-readonly-property.php'); + } + + public function testGetterRejectsHookProperties(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage('Getter cannot be applied to properties with hooks'); + $this->compile('getter-hook-property.php'); + } + + public function testSetterRejectsReadonlyProperties(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage('Setter cannot be applied to readonly properties'); + $this->compile('setter-readonly-property.php'); + } + + public function testSetterRejectsHookProperties(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage('Setter cannot be applied to properties with hooks'); + $this->compile('setter-hook-property.php'); + } + + public function testWithRejectsReadonlyPropertiesIncludingReadonlyClasses(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage('With cannot be applied to readonly properties'); + $this->compile('with-readonly-property.php'); + } + + public function testWithRejectsHookProperties(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage('With cannot be applied to properties with hooks'); + $this->compile('with-hook-property.php'); + } + + public function testGeneratedMethodRejectsDeclaredMethodConflictCaseInsensitively(): void + { + $this->exec('Duplicate method `getName`', 'generated-method-declared-conflict.php'); + } + + public function testGeneratedMethodConflictDiagnosticPointsToAttributeAndDeclaration(): void + { + try { + $this->compile('generated-method-declared-conflict.php'); + $this->fail('Expected generated Getter conflict'); + } catch (\TypePhp\Exception\TestError $error) { + $file = realpath(__DIR__ . '/../code/generated-method-declared-conflict.php'); + $this->assertNotFalse($file); + $message = $error->getMessage(); + $this->assertStringContainsString('compile-time attribute: #[Getter]', $message); + $this->assertStringContainsString('target: property $name', $message); + $this->assertStringContainsString('source: ' . $file . ':5', $message); + $this->assertStringContainsString('conflict source: declaration at ' . $file . ':8', $message); + } + } + + public function testGeneratedMethodsRejectEachOtherCaseInsensitively(): void + { + $this->exec('Duplicate method `getName`', 'generated-method-generated-conflict.php'); + } + + public function testPrinterGeneratedMethodUsesNormalDuplicateMethodValidation(): void + { + $this->exec('Duplicate method `__toString`', 'printer-generated-method-conflict.php'); + } + + public function testArrayableGeneratedMethodUsesNormalDuplicateMethodValidation(): void + { + $this->exec('Duplicate method `toArray`', 'arrayable-generated-method-conflict.php'); + } + + public function testGeneratedMethodMayOverrideCompatibleParentMethod(): void + { + $this->compile('generated-method-parent-conflict.php'); + } + + public function testGeneratedMethodObeysFinalParentMethodRule(): void + { + $this->exec( + 'Cannot override final method `GeneratedMethodFinalConflictParent::withName()`', + 'generated-method-final-parent-conflict.php' + ); + } + public function testCompileTimeGeneratedPropertyMethodsPrinterAndNotNull(): void { $this->compile('compile_time_attributes.php'); } + public function testArrayableAndPrinterFieldSelection(): void + { + $this->compile('arrayable.php'); + } + + public function testArrayableRejectsNonClassTargets(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage('Arrayable can only be applied to named classes'); + $this->compile('arrayable-invalid-target.php'); + } + + public function testArrayableAcceptsExplicitFieldsWithoutVisibilityFiltering(): void + { + $this->compile('arrayable-explicit-fields.php'); + } + + public function testArrayableRejectsDynamicFields(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage( + 'Arrayable field `missing` must be a declared instance property accessible from the class' + ); + $this->compile('arrayable-dynamic-field.php'); + } + + public function testArrayableAcceptsPublicAndProtectedParentFields(): void + { + $this->compile('arrayable-parent-visible-fields.php'); + } + + public function testPrinterAcceptsPrivateSelectedFields(): void + { + $this->compile('printer-private-fields.php'); + } + + public function testPrinterRejectsPrivateParentFields(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage( + 'Printer field `secret` must be a declared instance property accessible from the class' + ); + $this->compile('printer-parent-private-field.php'); + } + + public function testPrinterRejectsStaticFields(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage( + 'Printer field `shared` must be a declared instance property accessible from the class' + ); + $this->compile('printer-static-field.php'); + } + + public function testPrinterConvertsNonStringFieldsAndArrayablePreservesValues(): void + { + global $translator; + $compiler = \TypePhp\CompilerTest::create(ROOT_PATH); + $translator = $compiler; + $testFile = __DIR__ . '/../code/printer-arrayable-field-types.php'; + $compiler->addFiles([$testFile]); + $compiler->prepareFile($testFile); + $cppFile = $compiler->convertFile($testFile); + $code = file_get_contents($cppFile); + + $printerStart = strpos($code, 'php_printerarrayablefieldtypes____tostring'); + $arrayableStart = strpos($code, 'php_printerarrayablefieldtypes__toarray'); + $this->assertNotFalse($printerStart); + $this->assertNotFalse($arrayableStart); + $printerCode = substr($code, $printerStart, $arrayableStart - $printerStart); + $arrayableCode = substr($code, $arrayableStart); + $this->assertSame(4, substr_count($printerCode, 'php::toString(')); + $this->assertStringNotContainsString('php::toString(', $arrayableCode); + } + + public function testArrayableRejectsNonArrayFields(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage('Arrayable $fields must be an array literal'); + $this->compile('arrayable-invalid-argument.php'); + } + public function testNotNullRejectsNonParameterTargets(): void { $this->expectException(\TypePhp\Exception\SyntaxError::class); @@ -33,6 +211,360 @@ class ClassTest extends \BaseTest $this->compile('not-null-invalid-target.php'); } + public function testNotNullRejectsArrowFunctionParameters(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage('NotNull is not supported on arrow function parameters'); + $this->compile('not-null-arrow-function.php'); + } + + public function testNotNullWarnsForExplicitlyNullableParameters(): void + { + global $translator; + $compiler = \TypePhp\CompilerTest::create(ROOT_PATH); + $translator = $compiler; + $reporter = new class implements \TypePhp\Diagnostics\DiagnosticReporter { + /** @var list */ + public array $warnings = []; + + public function fatal(string $message): never + { + throw new \TypePhp\Exception\TestError($message); + } + + public function warning(\PhpParser\Node $node, string $file, string $message): void + { + $this->warnings[] = $message; + } + }; + $compiler->setDiagnosticReporter($reporter); + $testFile = __DIR__ . '/../code/not-null-nullable-warning.php'; + $compiler->addFiles([$testFile]); + $compiler->prepareFile($testFile); + + $this->assertSame([ + 'NotNull is applied to nullable parameter `$value`', + 'NotNull is applied to nullable parameter `$value`', + 'NotNull is applied to nullable parameter `$value`', + ], $reporter->warnings); + } + + public function testParameterValidationUsesFixedSemanticOrder(): void + { + $parser = (new \PhpParser\ParserFactory())->createForHostVersion(); + $ast = $parser->parse(file_get_contents(__DIR__ . '/../code/parameter-validation-order.php')); + $traverser = new \PhpParser\NodeTraverser(); + $traverser->addVisitor(new \PhpParser\NodeVisitor\NameResolver(null, ['replaceNodes' => false])); + $traverser->addVisitor(new \TypePhp\Transform\Visitor()); + $stmts = $traverser->traverse($ast); + $function = $stmts[0]; + + $this->assertInstanceOf(\PhpParser\Node\Stmt\Function_::class, $function); + $this->assertInstanceOf(\PhpParser\Node\Expr\BinaryOp\Identical::class, $function->stmts[0]->cond); + $this->assertInstanceOf(\PhpParser\Node\Expr\Empty_::class, $function->stmts[1]->cond); + $this->assertInstanceOf(\PhpParser\Node\Expr\BinaryOp\Identical::class, $function->stmts[2]->cond); + $this->assertInstanceOf(\PhpParser\Node\Expr\FuncCall::class, $function->stmts[2]->cond->left); + $this->assertSame('filter_var', strtolower($function->stmts[2]->cond->left->name->toString())); + } + + public function testNotEmptyRejectsArrowFunctionParameters(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage('NotEmpty is not supported on arrow function parameters'); + $this->compile('not-empty-arrow-function.php'); + } + + public function testValidateRejectsArrowFunctionParameters(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage('Validate is not supported on arrow function parameters'); + $this->compile('validate-arrow-function.php'); + } + + public function testValidateRejectsSanitizeFilters(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage('Validate only accepts FILTER_VALIDATE_* filters'); + $this->compile('validate-sanitize.php'); + } + + public function testValidateRejectsProvablyIncompatibleScalarType(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage( + 'Validate filter FILTER_VALIDATE_EMAIL is incompatible with parameter `$email` declared as `int`' + ); + $this->compile('validate-incompatible-email-int.php'); + } + + public function testValidateRejectsArrayWithoutArrayMode(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage( + 'Validate filter FILTER_VALIDATE_INT is incompatible with parameter `$values` declared as `array`' + ); + $this->compile('validate-incompatible-array.php'); + } + + public function testValidateAllowsCompatibleUnionAndExplicitArrayMode(): void + { + $this->compile('validate-compatible-types.php'); + } + + public function testValidateRejectsNonParameterTargets(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage('Validate can only be applied to function or method parameters'); + $this->compile('validate-invalid-target.php'); + } + + public function testValidateUsesCentralDuplicateAttributeValidation(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage('Validate cannot be repeated on the same declaration'); + $this->compile('validate-duplicate.php'); + } + + public function testCompileTimeAttributeDiagnosticsContainTargetAndBothConflictSources(): void + { + try { + $this->compile('validate-duplicate.php'); + $this->fail('Expected duplicate Validate diagnostic'); + } catch (\TypePhp\Exception\SyntaxError $error) { + $message = $error->getMessage(); + $file = realpath(__DIR__ . '/../code/validate-duplicate.php'); + $this->assertNotFalse($file); + $this->assertStringContainsString('target: parameter $value', $message); + $this->assertStringContainsString('source: ' . $file . ':4', $message); + $this->assertStringContainsString( + 'conflict source: #[Validate] at ' . $file . ':5', + $message, + ); + } + } + + public function testLanguageCompileTimeAttributes(): void + { + $this->compile('language_attributes.php'); + } + + public function testMethodsForSupportsAliasesAndObjectTargets(): void + { + $this->compile('methods-for.php'); + } + + public function testMethodsForUsesKeywordClassHierarchyAndObjectFallbackPriority(): void + { + global $translator; + $compiler = \TypePhp\CompilerTest::create(ROOT_PATH); + $translator = $compiler; + $testFile = __DIR__ . '/../code/methods-for-inheritance.php'; + $compiler->addFiles([$testFile]); + $compiler->prepareFile($testFile); + $cppFile = $compiler->convertFile($testFile); + $cpp = file_get_contents($cppFile); + + $this->assertStringContainsString('php_hierarchykeywordmethods__keywordwins(', $cpp); + $this->assertStringContainsString('php::echo(php_hierarchybase__realwins(child));', $cpp); + $this->assertStringNotContainsString( + 'php::echo(php::toString(php_hierarchyobjectmethods__realwins(', + $cpp, + ); + $this->assertStringNotContainsString( + 'php::echo(php::toString(php_hierarchyobjectmethods__declaredmethod(', + $cpp, + ); + $this->assertStringContainsString('php_hierarchybasemethods__inheritedextension(', $cpp); + $this->assertMatchesRegularExpression( + '/php_hierarchychildmethods__nearestextension\([^,\n]+, child\)/', + $cpp, + ); + $this->assertMatchesRegularExpression( + '/php_hierarchybasemethods__nearestextension\([^,\n]+, base\)/', + $cpp, + ); + $this->assertSame( + 3, + substr_count($cpp, 'php::echo(php::toString(php_hierarchyobjectmethods__objectfallback('), + ); + } + + public function testMethodsForRejectsKeywordAndTargetSpecificNameConflict(): void + { + $this->expectException(\TypePhp\Exception\TestError::class); + $this->expectExceptionMessage( + 'conflicts with keyword extension method *::inspect()' + ); + $this->compile('methods-for-keyword-conflict.php'); + } + + public function testMethodsForKeywordConflictDoesNotDependOnDeclarationOrder(): void + { + $this->expectException(\TypePhp\Exception\TestError::class); + $this->expectExceptionMessage( + 'Keyword extension method *::inspect() conflicts with extension method php::str::inspect()' + ); + $this->compile('methods-for-keyword-conflict-reversed.php'); + } + + public function testMethodsForRejectsInterfaceTargets(): void + { + $this->expectException(\TypePhp\Exception\TestError::class); + $this->expectExceptionMessage( + 'MethodsFor target InvalidMethodsForContract must be a class; interfaces are not supported' + ); + $this->compile('methods-for-interface-target.php'); + } + + public function testHotAndColdFunctionAttributes(): void + { + $this->compile('hot-cold.php'); + } + + public function testHotAndColdCannotBeCombined(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage('Hot and Cold cannot be applied to the same function or method'); + $this->compile('hot-cold-conflict.php'); + } + + public function testHotRejectsNonFunctionTargets(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage('Hot can only be applied to functions or methods'); + $this->compile('hot-invalid-target.php'); + } + + public function testMustUseRejectsDiscardedReturnValue(): void + { + $this->exec('must be used', 'must-use-discard.php'); + } + + public function testMustUseRejectsDiscardedMethodReturnValue(): void + { + $this->exec('must be used', 'must-use-method-discard.php'); + } + + public function testOverrideAcceptsParentInterfaceTraitAndNamespaceAliasMatches(): void + { + $this->compile('override-valid.php'); + } + + public function testOverrideRequiresMatchingParentMethod(): void + { + $this->exec( + 'OverrideMissing::missing() has #[\\Override] attribute, but no matching parent method exists', + 'override-missing.php', + ); + } + + public function testOverrideNeverMatchesConstructor(): void + { + $this->exec( + 'OverrideConstructorChild::__construct() has #[\\Override] attribute, but no matching parent method exists', + 'override-constructor.php', + ); + } + + public function testOverrideDoesNotMatchPrivateParentMethod(): void + { + $this->exec( + 'OverridePrivateChild::value() has #[\\Override] attribute, but no matching parent method exists', + 'override-attribute-private-parent.php', + ); + } + + public function testOverrideOnTraitMethodIsValidatedAtUseSite(): void + { + $this->exec( + 'OverrideTraitConsumer::missing() has #[\\Override] attribute, but no matching parent method exists', + 'override-trait-missing.php', + ); + } + + public function testOverrideOnRootInterfaceRequiresParentMethod(): void + { + $this->exec( + 'OverrideInterfaceMissing::missing() has #[\\Override] attribute, but no matching parent method exists', + 'override-interface-missing.php', + ); + } + + public function testOverrideRejectsNonMethodTargets(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage('Override can only be applied to methods'); + $this->compile('override-invalid-target.php'); + } + + public function testOverrideRejectsArguments(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage('Override does not accept arguments'); + $this->compile('override-arguments.php'); + } + + public function testOverrideCannotBeRepeated(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage('Override cannot be repeated on the same declaration'); + $this->compile('override-duplicate.php'); + } + + public function testConstructorRejectsExistingConstructor(): void + { + $this->exec('Duplicate method `__construct`', 'constructor-existing.php'); + } + + public function testConstructorRejectsStaticProperties(): void + { + $this->expectException(\TypePhp\Exception\SyntaxError::class); + $this->expectExceptionMessage('Constructor can only be applied to instance properties'); + $this->compile('constructor-static.php'); + } + + public function testConstructorCallsParentConstructorWithoutRequiredArguments(): void + { + global $translator; + $compiler = \TypePhp\CompilerTest::create(ROOT_PATH); + $translator = $compiler; + $testFile = __DIR__ . '/../code/constructor-parent-optional.php'; + $compiler->addFiles([$testFile]); + $compiler->prepareFile($testFile); + $cppFile = $compiler->convertFile($testFile); + + $this->assertStringContainsString( + '// Stmt_Expression(Expr_StaticCall)', + file_get_contents($cppFile), + ); + } + + public function testConstructorAllowsParentWithoutConstructor(): void + { + $this->compile('constructor-parent-none.php'); + } + + public function testConstructorRejectsParentConstructorWithRequiredArguments(): void + { + $this->exec( + 'parent constructor `ConstructorRequiredParent::__construct()` requires 1 argument(s)', + 'constructor-parent-required.php' + ); + } + + public function testConstructorDoesNotCallPrivateParentConstructor(): void + { + $this->compile('constructor-parent-private.php'); + } + + public function testConstructorRejectsFinalParentConstructor(): void + { + $this->exec( + 'Cannot override final method `ConstructorFinalParent::__construct()`', + 'constructor-parent-final.php' + ); + } + public function testReAssignThis() { $this->exec('Cannot re-assign $this', 're-assign-this.php'); diff --git a/phpunit/src/CompileTimeAttributeRegistryTest.php b/phpunit/src/CompileTimeAttributeRegistryTest.php new file mode 100644 index 00000000..09395985 --- /dev/null +++ b/phpunit/src/CompileTimeAttributeRegistryTest.php @@ -0,0 +1,47 @@ +assertSame($expected, CompileTimeAttributeRegistry::names()); + + foreach (CompileTimeAttributeRegistry::all() as $definition) { + $this->assertNotEmpty($definition['targets']); + $this->assertNotSame('', $definition['argument_parser']); + $this->assertNotSame('', $definition['phase']); + $this->assertIsBool($definition['preserve_in_library_stub']); + $this->assertFalse($definition['repeatable']); + } + $this->assertNotContains('NoExport', CompileTimeAttributeRegistry::names(true)); + $this->assertContains('Getter', CompileTimeAttributeRegistry::names(true)); + $this->assertContains('Override', CompileTimeAttributeRegistry::names(true)); + $this->assertSame( + ['Override', 'MustUse', 'Hot', 'Cold'], + CompileTimeAttributeRegistry::namesForPhase(CompileTimeAttributeRegistry::PHASE_ENTER), + ); + } + + public function testRegistryMatchesPublicCompileTimeAttributeDeclarations(): void + { + $source = file_get_contents(ROOT_PATH . '/src/polyfills.php'); + $this->assertNotFalse($source); + preg_match_all( + '/#\[Attribute\([^\]]+\)\]\s+final readonly class ([A-Za-z_][A-Za-z0-9_]*)/', + $source, + $matches, + ); + $declaredByTypePhp = array_values(array_filter( + CompileTimeAttributeRegistry::names(), + static fn (string $name): bool => $name !== 'Override', + )); + $this->assertSame($declaredByTypePhp, $matches[1]); + } +} diff --git a/phpunit/src/CompilerBaseApiTest.php b/phpunit/src/CompilerBaseApiTest.php index 99f06401..a2d22078 100644 --- a/phpunit/src/CompilerBaseApiTest.php +++ b/phpunit/src/CompilerBaseApiTest.php @@ -831,6 +831,22 @@ YAML); $this->assertStringNotContainsString('property_map = {}', $code); } + public function testArrayableKeywordConversionCallsGeneratedMethodAtRuntime(): void + { + global $translator; + $translator = $this->compiler; + + $testFile = ROOT_PATH . '/phpunit/code/arrayable.php'; + $this->compiler->addFiles([$testFile]); + $this->compiler->prepareFile($testFile); + $cppFile = $this->compiler->convertFile($testFile); + $cpp = file_get_contents($cppFile); + + $this->assertStringContainsString('data = php::toArray(user);', $cpp); + $this->assertStringContainsString('php::Array php_arrayableuser__toarray(', $cpp); + $this->assertStringContainsString('php::Str php_arrayableuser____tostring(', $cpp); + } + public function testLibraryFunctionHeaderExportsDefaultValueHelpersWithoutLiteralStorage(): void { global $translator; @@ -848,8 +864,13 @@ YAML); $header = file_get_contents($headerFile); $this->assertStringContainsString('#pragma once', $header); - $this->assertStringContainsString('TYPEPHP_PRIME2_API __declspec(dllexport)', $header); - $this->assertStringContainsString('TYPEPHP_PRIME2_API __declspec(dllimport)', $header); + $this->assertStringContainsString('#include ', $header); + $this->assertStringContainsString('# define TYPEPHP_PRIME2_API TYPEPHP_SYMBOL_EXPORT', $header); + $this->assertStringContainsString('# define TYPEPHP_PRIME2_API TYPEPHP_SYMBOL_IMPORT', $header); + $this->assertStringNotContainsString('__declspec(', $header); + $this->assertStringNotContainsString('__attribute__(', $header); + $this->assertStringNotContainsString('defined(_WIN32)', $header); + $this->assertStringNotContainsString('defined(__GNUC__)', $header); $this->assertStringContainsString( 'TYPEPHP_PRIME2_API php::Str php_exported_defaults_arg_0_default_value();', $header @@ -899,8 +920,8 @@ YAML); $this->compiler->genFunctionDeclarations($headerFile); $header = file_get_contents($headerFile); - $this->assertStringContainsString('TYPEPHP_PRIME2_IMPORT __declspec(dllimport)', $header); - $this->assertStringContainsString('TYPEPHP_PRIME2_API __declspec(dllexport)', $header); + $this->assertStringContainsString('#define TYPEPHP_PRIME2_IMPORT TYPEPHP_SYMBOL_IMPORT', $header); + $this->assertStringContainsString('# define TYPEPHP_PRIME2_API TYPEPHP_SYMBOL_EXPORT', $header); $this->assertStringContainsString( 'TYPEPHP_PRIME2_IMPORT php::Array php_exported_defaults(', $header @@ -926,9 +947,10 @@ YAML); ROOT_PATH . '/phpunit/code/compiler_api/library_import_global.php', ]; $this->compiler->addFiles($files); + $cppFiles = []; foreach ($files as $file) { $this->compiler->prepareFile($file); - $this->compiler->convertFile($file); + $cppFiles[$file] = $this->compiler->convertFile($file); $this->assertStringNotContainsString( 'NoExport', file_get_contents($this->compiler->getArgInfoHeaderFile($file)), @@ -937,15 +959,24 @@ YAML); 'Getter', file_get_contents($this->compiler->getArgInfoHeaderFile($file)), ); - foreach (['NotNull', 'Printer', 'Setter', 'With'] as $attribute) { + foreach (\TypePhp\Transform\CompileTimeAttributeRegistry::names() as $attribute) { $this->assertStringNotContainsString( $attribute, file_get_contents($this->compiler->getArgInfoHeaderFile($file)), ); } } + $phpCpp = file_get_contents($cppFiles[$files[0]]); + $this->assertStringContainsString( + 'TYPEPHP_HOT_ATTRIBUTE php::Int php_libraryapi__twice(', + $phpCpp, + ); + $this->assertStringContainsString( + 'TYPEPHP_COLD_ATTRIBUTE php::Str php_libraryapi__counter__label(', + $phpCpp, + ); $provider = $this->invokeMethod('getClass', 'LibraryApi\\InternalStringExtension'); - $this->assertSame(Type::STR, $provider->extensionProviderTarget); + $this->assertSame(Type::STR, $provider->methodsForTarget); $stubFile = $this->compiler->genLibraryImportStub($files); $stub = file_get_contents($stubFile); @@ -955,16 +986,20 @@ YAML); $this->assertStringContainsString('class Counter', $stub); $this->assertStringContainsString('public const int STEP = 2;', $stub); $this->assertStringContainsString('public int $value = 1;', $stub); - $this->assertStringContainsString('#[\Getter, \Setter, \With]', $stub); - $this->assertStringContainsString('#[\Printer]', $stub); - $this->assertStringContainsString('#[\NotNull]', $stub); + $this->assertStringContainsString('#[\Constructor, \Getter, \Setter, \With]', $stub); + $this->assertStringContainsString("#[\Printer(fields: ['value', 'doubled'])]", $stub); + $this->assertStringContainsString("#[\Arrayable(['value'])]", $stub); + $this->assertStringContainsString('#[\NotNull, \Validate(FILTER_VALIDATE_EMAIL)]', $stub); + $this->assertStringContainsString('#[\MustUse, \Cold]', $stub); + $this->assertStringContainsString('#[\MustUse, \Hot]', $stub); + $this->assertStringContainsString('#[\Override]', $stub); $this->assertMatchesRegularExpression( '/public int \$doubled\s*\{\s*get\s*\{\s*\}\s*set\(int \$value\)\s*\{\s*\}\s*\}/s', $stub, ); $this->assertStringContainsString('function add(int $amount = self::STEP): int', $stub); $this->assertMatchesRegularExpression( - '/function label\(\s*#\[\\\\NotNull\]\s*string \$value\s*\): string/s', + '/function label\(\s*#\[\\\\NotNull, \\\\Validate\(FILTER_VALIDATE_EMAIL\)\]\s*string \$value\s*\): string/s', $stub, ); $this->assertStringContainsString('function twice(int $value): int', $stub); @@ -986,13 +1021,25 @@ YAML); $this->compiler->genFunctionDeclarations($libraryHeaderFile); $libraryHeader = file_get_contents($libraryHeaderFile); $this->assertStringContainsString( - 'TYPEPHP_PRIME2_API php::Int php_libraryapi__twice(', + 'TYPEPHP_PRIME2_API TYPEPHP_HOT_ATTRIBUTE php::Int php_libraryapi__twice(', + $libraryHeader, + ); + $this->assertStringContainsString( + 'TYPEPHP_PRIME2_API TYPEPHP_COLD_ATTRIBUTE php::Str php_libraryapi__counter__label(', $libraryHeader, ); $this->assertStringContainsString( 'TYPEPHP_PRIME2_API php::Int php_libraryapi__counter__getvalue(', $libraryHeader, ); + $this->assertStringContainsString( + 'TYPEPHP_PRIME2_API php::Array php_libraryapi__counter__toarray(', + $libraryHeader, + ); + $this->assertStringContainsString( + 'TYPEPHP_PRIME2_API php::Str php_libraryapi__counter____tostring(', + $libraryHeader, + ); $this->assertStringContainsString( 'extern php::Int php_libraryapi__internal_twice(', $libraryHeader, @@ -1047,7 +1094,15 @@ YAML); $header, ); $this->assertStringContainsString( - 'TYPEPHP_PRIME2_IMPORT php::Int php_libraryapi__twice(', + 'TYPEPHP_PRIME2_IMPORT php::Array php_libraryapi__counter__toarray(', + $header, + ); + $this->assertStringContainsString( + 'TYPEPHP_PRIME2_IMPORT php::Str php_libraryapi__counter____tostring(', + $header, + ); + $this->assertStringContainsString( + 'TYPEPHP_PRIME2_IMPORT TYPEPHP_HOT_ATTRIBUTE php::Int php_libraryapi__twice(', $header, ); $this->assertStringContainsString( diff --git a/phpunit/src/Entity/FunctionDefTest.php b/phpunit/src/Entity/FunctionDefTest.php index f4aebc1f..be11170a 100644 --- a/phpunit/src/Entity/FunctionDefTest.php +++ b/phpunit/src/Entity/FunctionDefTest.php @@ -17,6 +17,8 @@ class FunctionDefTest extends TestCase $this->assertEquals('', $fn->namespace); $this->assertFalse($fn->method); $this->assertFalse($fn->stub); + $this->assertFalse($fn->hot); + $this->assertFalse($fn->cold); $this->assertEmpty($fn->argInfoList); $this->assertEquals(0, $fn->argCountRequired); $this->assertEquals('', $fn->params); diff --git a/src/CompilerBase.php b/src/CompilerBase.php index d1ac79c4..845b8cf7 100644 --- a/src/CompilerBase.php +++ b/src/CompilerBase.php @@ -16,6 +16,7 @@ use TypePhp\Entity\ArgInfo; use TypePhp\Context\FunctionContext; use TypePhp\Context\CompilationStateTrait; use TypePhp\Diagnostics\CompilerDiagnosticTrait; +use TypePhp\Diagnostics\CompileTimeAttributeDiagnostic; use TypePhp\Diagnostics\CliDiagnosticReporter; use TypePhp\Diagnostics\DiagnosticReporter; use TypePhp\Diagnostics\ThrowingDiagnosticReporter; @@ -1527,6 +1528,7 @@ class CompilerBase implements PropertyAccessContext switch ($class) { case 'Stmt_Expression': $v->expr->setAttribute(self::ATTR_STATEMENT_EXPRESSION, true); + $this->assertMustUseResultIsConsumed($v->expr); if ($this->inGeneratorBody && $v->expr instanceof Expr\Yield_) { $result = $this->parseYieldStmt($v->expr); } elseif ($this->inGeneratorBody && $v->expr instanceof Expr\YieldFrom) { @@ -1634,6 +1636,54 @@ class CompilerBase implements PropertyAccessContext return $code; } + protected function assertMustUseResultIsConsumed(NodeAbstract $expr): void + { + $functionDef = $this->resolveCalledFunctionDef($expr); + if ($functionDef?->mustUse) { + $target = ($functionDef->method ? 'method ' : 'function ') . $functionDef->name . '()'; + $this->error(CompileTimeAttributeDiagnostic::formatPositions( + 'The return value of `' . $functionDef->name . '()` must be used', + 'MustUse', + $target, + $functionDef->sourceFile, + $functionDef->startLine, + 'discarded call', + $this->file, + $expr->getStartLine(), + )); + } + } + + protected function resolveCalledFunctionDef(NodeAbstract $expr): ?FunctionDef + { + if ($expr instanceof Expr\FuncCall && $expr->name instanceof Node\Name) { + $name = $this->parseIdentifier($expr->name); + $native = $this->findNativeFunction($name); + return $native ? $this->getFunction($native) : null; + } + if ($expr instanceof Expr\MethodCall && $expr->name instanceof Node\Identifier) { + $class = $this->detectClassOfExpr($expr->var); + if ($class === '' && $expr->var instanceof Expr\Variable && is_string($expr->var->name)) { + $var = $this->parseVariable($expr->var); + $class = $var === 'this_' ? $this->getFullClassName() : $this->getDeclaredObjectType($var); + } + return $class === '' ? null : $this->findAotMethodFunctionDef($class, $expr->name->toString()); + } + if ($expr instanceof Expr\StaticCall && $expr->class instanceof Node\Name + && $expr->name instanceof Node\Identifier) { + $class = $this->parseIdentifier($expr->class); + if ($class === 'self' || $class === 'static') { + $class = $this->getFullClassName(); + } elseif ($class === 'parent') { + $class = $this->classDef?->extends ?? ''; + } else { + $class = $this->getNamespacedClassName($class); + } + return $class === '' ? null : $this->findAotMethodFunctionDef($class, $expr->name->toString()); + } + return null; + } + protected function parseEcho(mixed $v): string { $lines = []; diff --git a/src/Diagnostics/CompileTimeAttributeDiagnostic.php b/src/Diagnostics/CompileTimeAttributeDiagnostic.php new file mode 100644 index 00000000..2f44a7c5 --- /dev/null +++ b/src/Diagnostics/CompileTimeAttributeDiagnostic.php @@ -0,0 +1,93 @@ +getStartLine(); + if ($conflictSource !== null) { + $context .= $conflictAttribute === null + ? '; conflict source: declaration at ' . $file . ':' . $conflictSource->getStartLine() + : '; conflict source: #[' . $conflictAttribute . '] at ' . + $file . ':' . $conflictSource->getStartLine(); + } + return $message . ' ' . $context . ']'; + } + + public static function markGenerated(Node $generated, string $attribute, Node $target): void + { + $generated->setAttribute(self::GENERATED_BY, $attribute); + $generated->setAttribute(self::GENERATED_TARGET, $target); + } + + public static function formatPositions( + string $message, + string $attribute, + string $target, + string $sourceFile, + int $sourceLine, + ?string $conflictLabel = null, + ?string $conflictFile = null, + ?int $conflictLine = null, + ): string { + $context = '[compile-time attribute: #[' . $attribute . ']; target: ' . $target . + '; source: ' . $sourceFile . ':' . $sourceLine; + if ($conflictFile !== null && $conflictLine !== null) { + $context .= '; conflict source: ' . ($conflictLabel ?? 'declaration') . ' at ' . + $conflictFile . ':' . $conflictLine; + } + return $message . ' ' . $context . ']'; + } + + public static function describeTarget(Node $node): string + { + if ($node instanceof Stmt\Function_) { + return 'function ' . $node->name->toString() . '()'; + } + if ($node instanceof Stmt\ClassMethod) { + return 'method ' . $node->name->toString() . '()'; + } + if ($node instanceof Stmt\Property) { + return 'property ' . implode(', ', array_map( + static fn (Node\PropertyItem $property): string => '$' . $property->name->toString(), + $node->props, + )); + } + if ($node instanceof Node\Param && is_string($node->var->name)) { + return ($node->isPromoted() ? 'promoted property/parameter ' : 'parameter ') . '$' . $node->var->name; + } + if ($node instanceof Stmt\ClassLike) { + return strtolower(str_replace('Stmt_', '', $node->getType())) . ' ' . ($node->name?->toString() ?? 'anonymous'); + } + if ($node instanceof Node\Expr\Closure) { + return 'anonymous function'; + } + if ($node instanceof Node\Expr\ArrowFunction) { + return 'arrow function'; + } + return $node->getType(); + } +} diff --git a/src/Diagnostics/CompilerDiagnosticTrait.php b/src/Diagnostics/CompilerDiagnosticTrait.php index bc58dfc0..2f995537 100644 --- a/src/Diagnostics/CompilerDiagnosticTrait.php +++ b/src/Diagnostics/CompilerDiagnosticTrait.php @@ -32,6 +32,25 @@ trait CompilerDiagnosticTrait $this->getDiagnosticReporter()->warning($node, $this->file, $msg); } + protected function fatalCompileTimeAttribute( + Node $target, + string $attribute, + string $message, + ?Node $source = null, + ?string $conflictAttribute = null, + ?Node $conflictSource = null, + ): never { + $this->error(CompileTimeAttributeDiagnostic::format( + $message, + $attribute, + $target, + $this->file, + $source, + $conflictAttribute, + $conflictSource, + )); + } + protected function errorUndefinedVariable(Variable $node): never { $this->fatalError($node, "The variable `\${$node->name}` is undefined"); diff --git a/src/Entity/ClassDef.php b/src/Entity/ClassDef.php index e43e7216..ad9754c3 100644 --- a/src/Entity/ClassDef.php +++ b/src/Entity/ClassDef.php @@ -34,9 +34,15 @@ class ClassDef extends ClassLikeDef public bool $enum = false; /** Whether this class and its methods are part of the public ABI of a library build. */ public bool $exported = true; - public ?string $extensionProviderTarget = null; - /** Whether #[Printer] generated this class's own toString() method. */ + public ?string $methodsForTarget = null; + /** Whether #[Printer] generated this class's own __toString() method. */ public bool $printerGenerated = false; + /** @var list|null Explicit fields, or null to include every public instance property. */ + public ?array $printerFields = null; + /** Whether #[Arrayable] generated this class's own toArray() method. */ + public bool $arrayableGenerated = false; + /** @var list|null Explicit fields, or null to include every public instance property. */ + public ?array $arrayableFields = null; /** * Backing type for backed enums ('int' or 'string'), null for pure enums. diff --git a/src/Entity/FunctionDef.php b/src/Entity/FunctionDef.php index 53929c3a..664738ba 100644 --- a/src/Entity/FunctionDef.php +++ b/src/Entity/FunctionDef.php @@ -32,6 +32,14 @@ class FunctionDef public bool $returnTypeUndeclared = false; public bool $returnsByRef = false; public bool $generator = false; + /** The call result must not be discarded as a statement expression. */ + public bool $mustUse = false; + /** The method must override an inherited class or interface method. */ + public bool $overrideRequired = false; + /** Prefer optimizing this function for frequently executed paths. */ + public bool $hot = false; + /** Prefer optimizing this function for rarely executed paths. */ + public bool $cold = false; /** Number of fixed positional values returned through the internal tuple fast path. */ public int $multiReturnCount = 0; /** Source file containing this function definition. */ diff --git a/src/Exception/CompileTimeAttributeError.php b/src/Exception/CompileTimeAttributeError.php new file mode 100644 index 00000000..4548f077 --- /dev/null +++ b/src/Exception/CompileTimeAttributeError.php @@ -0,0 +1,26 @@ +getFunctionOptimizationAttribute($functionDef) + . Type::VAR . ' ' . self::PREFIX . $nativeName . '('; if ($this->class) { $functionDeclCode .= Type::OBJECT . ' &this_'; if ($functionDef->params) { diff --git a/src/Generator/LibraryImportStubGenerator.php b/src/Generator/LibraryImportStubGenerator.php index 3cffb546..2b793b16 100644 --- a/src/Generator/LibraryImportStubGenerator.php +++ b/src/Generator/LibraryImportStubGenerator.php @@ -14,6 +14,8 @@ use PhpParser\NodeTraverser; use PhpParser\NodeVisitor\NameResolver; use PhpParser\Parser; use PhpParser\PrettyPrinter; +use TypePhp\Transform\CompileTimeAttribute; +use TypePhp\Transform\CompileTimeAttributeRegistry; final class LibraryImportStubGenerator { @@ -110,8 +112,12 @@ final class LibraryImportStubGenerator ) !== 1, ); $stmt->setAttribute('comments', array_values($comments)); + $this->filterAttributesForLibraryStub($stmt); if ($stmt instanceof Node\Stmt\Function_) { + foreach ($stmt->params as $param) { + $this->filterAttributesForLibraryStub($param); + } $stmt->stmts = []; return $stmt; } @@ -127,10 +133,15 @@ final class LibraryImportStubGenerator && !($stmt instanceof Node\Stmt\Interface_)) { $member->stmts = []; } + $this->filterAttributesForLibraryStub($member); + foreach ($member->params as $param) { + $this->filterAttributesForLibraryStub($param); + } $members[] = $member; continue; } if ($member instanceof Node\Stmt\Property) { + $this->filterAttributesForLibraryStub($member); foreach ($member->hooks as $hook) { if ($hook->body !== null) { $hook->body = []; @@ -159,8 +170,7 @@ final class LibraryImportStubGenerator } foreach ($node->attrGroups as $group) { foreach ($group->attrs as $attribute) { - $parts = $attribute->name->getParts(); - if (count($parts) === 1 && strcasecmp($parts[0], 'NoExport') === 0) { + if (CompileTimeAttribute::is($attribute, 'NoExport')) { return true; } } @@ -168,4 +178,24 @@ final class LibraryImportStubGenerator return false; } + + private function filterAttributesForLibraryStub(Node $node): void + { + if (!property_exists($node, 'attrGroups')) { + return; + } + foreach ($node->attrGroups as $groupIndex => $group) { + foreach ($group->attrs as $attributeIndex => $attribute) { + $definition = CompileTimeAttributeRegistry::get(CompileTimeAttribute::resolvedName($attribute)); + if ($definition !== null && !$definition['preserve_in_library_stub']) { + unset($group->attrs[$attributeIndex]); + } + } + $group->attrs = array_values($group->attrs); + if ($group->attrs === []) { + unset($node->attrGroups[$groupIndex]); + } + } + $node->attrGroups = array_values($node->attrGroups); + } } diff --git a/src/Parser/MethodCallTrait.php b/src/Parser/MethodCallTrait.php index 4a8eeef6..7b2b43e0 100644 --- a/src/Parser/MethodCallTrait.php +++ b/src/Parser/MethodCallTrait.php @@ -362,6 +362,11 @@ trait MethodCallTrait } return $this->genToConvertCall($object, $methodName, $receiverType); } + // MethodsFor('*') extensions apply to every receiver type. + $kwExt = $this->findKeywordExtensionMethod($methodName); + if ($kwExt) { + return $this->parseUniversalMethodCall($expr, $object, $methodName, $kwExt, $this->isVarExpr($expr->var)); + } // A provider targeting Type::Any only applies when // the receiver's static type is actually mixed/any. if ($receiverType === Type::VAR) { @@ -370,11 +375,6 @@ trait MethodCallTrait return $this->parseUniversalMethodCall($expr, $object, $methodName, $anyExtension, $this->isVarExpr($expr->var)); } } - // ExtensionProvider('*') extensions apply to every receiver type. - $kwExt = $this->findKeywordExtensionMethod($methodName); - if ($kwExt) { - return $this->parseUniversalMethodCall($expr, $object, $methodName, $kwExt, $this->isVarExpr($expr->var)); - } } // 可转为原生调用的 MethodCall @@ -412,14 +412,22 @@ trait MethodCallTrait } } } catch (DynamicCall) { - $extension = $this->findObjectExtensionMethod($class, $methodName); + $extension = $this->findObjectExtensionMethod( + $class, + $methodName, + $this->isDefinitelyObjectReceiver($expr->var, $object, $class, $type), + ); if ($extension !== null) { return $this->parseUniversalMethodCall($expr, $object, $methodName, $extension); } $magicMethod = true; } if (!$nativeFunc) { - $extension = $this->findObjectExtensionMethod($class, $methodName); + $extension = $this->findObjectExtensionMethod( + $class, + $methodName, + $this->isDefinitelyObjectReceiver($expr->var, $object, $class, $type), + ); if ($extension !== null) { return $this->parseUniversalMethodCall($expr, $object, $methodName, $extension); } @@ -450,7 +458,11 @@ trait MethodCallTrait } $extensionClass = $this->detectClassOfExpr($expr->var); - $extension = $this->findObjectExtensionMethod($extensionClass, $methodName); + $extension = $this->findObjectExtensionMethod( + $extensionClass, + $methodName, + $this->isDefinitelyObjectReceiver($expr->var, $object, $extensionClass, $type), + ); if ($extension !== null) { return $this->parseUniversalMethodCall($expr, $object, $methodName, $extension, false); } @@ -483,6 +495,55 @@ trait MethodCallTrait } } + private function isDefinitelyObjectReceiver( + Expr $receiver, + string $object, + string $class, + string $type, + ): bool { + if ($type !== Type::OBJECT && $class === '') { + return false; + } + + if ($this->isVarExpr($receiver)) { + foreach ($this->functionDef?->argInfoList ?? [] as $argument) { + if ($argument->name === $object && $argument->nullable) { + return false; + } + } + } + + if ($this->isPropertyFetch($receiver) && $this->getNativePropertyDef($receiver)?->nullable) { + return false; + } + + $calledFunction = $this->resolveCalledFunctionDef($receiver); + if ($calledFunction !== null && $this->typeNodeAllowsNull($calledFunction->returnTypeNode)) { + return false; + } + + return true; + } + + private function typeNodeAllowsNull(?Node $type): bool + { + if ($type instanceof Node\NullableType) { + return true; + } + if (!$type instanceof Node\UnionType) { + return false; + } + foreach ($type->types as $member) { + if ($member instanceof Node\Identifier && strtolower($member->name) === 'null') { + return true; + } + if ($member instanceof Node\Name && strtolower($member->toString()) === 'null') { + return true; + } + } + return false; + } + protected function parseStaticCall(Expr\StaticCall $expr): string { diff --git a/src/Parser/UniversalMethodCall.php b/src/Parser/UniversalMethodCall.php index f3a47341..967c5886 100644 --- a/src/Parser/UniversalMethodCall.php +++ b/src/Parser/UniversalMethodCall.php @@ -13,7 +13,7 @@ use PhpParser\NodeAbstract; trait UniversalMethodCall { - private ?array $extensionProviderMethods = null; + private ?array $methodsForRegistry = null; protected const array UNIVERSAL_METHODS = [ Type::INT => [ 'add' => ['handler' => 'calc_op', 'op' => '+', 'return_type' => Type::INT, 'min_args' => 1, 'max_args' => 1], @@ -338,17 +338,20 @@ trait UniversalMethodCall return $ext ? $ext['return_type'] : null; } - private function getExtensionProviderMethods(): array + private function getMethodsForRegistry(): array { - if ($this->extensionProviderMethods !== null) { - return $this->extensionProviderMethods; + if ($this->methodsForRegistry !== null) { + return $this->methodsForRegistry; } $registry = []; foreach ($this->symbols->classes() as $provider) { - $target = $provider->extensionProviderTarget; + $target = $provider->methodsForTarget; if ($target === null) { continue; } + if (!$this->isBuiltinMethodsForTarget($target) && $this->isInterface($target)) { + $this->error("MethodsFor target {$target} must be a class; interfaces are not supported"); + } foreach ($provider->methods as $method) { if (str_starts_with($method->name, '__')) { continue; @@ -357,21 +360,36 @@ trait UniversalMethodCall continue; } if (!($method->flags & \PhpParser\Modifiers::STATIC)) { - $this->error("Extension provider method {$provider->getNamespacedName(false)}::{$method->name}() must be static"); + $this->error("MethodsFor method {$provider->getNamespacedName(false)}::{$method->name}() must be static"); } $function = $method->functionDef; if ($function === null || empty($function->argInfoList)) { - $this->error("Extension provider method {$provider->getNamespacedName(false)}::{$method->name}() must declare a receiver parameter"); + $this->error("MethodsFor method {$provider->getNamespacedName(false)}::{$method->name}() must declare a receiver parameter"); } $receiver = $function->argInfoList[0]; if ($receiver->byRef || !$this->extensionReceiverMatchesTarget($receiver, $target)) { - $this->error("Invalid receiver parameter for extension provider method {$provider->getNamespacedName(false)}::{$method->name}()"); + $this->error("Invalid receiver parameter for MethodsFor method {$provider->getNamespacedName(false)}::{$method->name}()"); } $targetKey = strtolower(ltrim($target, '\\')); $methodKey = strtolower($method->name); if (isset($registry[$targetKey][$methodKey])) { $this->error("Duplicate extension method {$target}::{$method->name}()"); } + if ($target === '*') { + foreach ($registry as $registeredTarget => $registeredMethods) { + if ($registeredTarget !== '*' && isset($registeredMethods[$methodKey])) { + $this->error( + "Keyword extension method *::{$method->name}() conflicts with extension method " + . "{$registeredTarget}::{$method->name}()" + ); + } + } + } elseif (isset($registry['*'][$methodKey])) { + $this->error( + "Extension method {$target}::{$method->name}() conflicts with keyword extension method " + . "*::{$method->name}()" + ); + } $registry[$targetKey][$methodKey] = [ 'handler' => 'provider_extension', 'fn' => $this->getNativeName($method->name, $provider->namespace, $provider->name), @@ -382,7 +400,16 @@ trait UniversalMethodCall ]; } } - return $this->extensionProviderMethods = $registry; + return $this->methodsForRegistry = $registry; + } + + private function isBuiltinMethodsForTarget(string $target): bool + { + return $target === '*' || in_array($target, [ + Type::VAR, Type::INT, Type::FLOAT, Type::BOOL, Type::STR, + Type::ARRAY, Type::OBJECT, Type::STREAM, Type::BIGINT, + Type::BIGFLOAT, Type::DECIMAL, Type::BOX, + ], true); } private function extensionReceiverMatchesTarget($receiver, string $target): bool @@ -390,12 +417,7 @@ trait UniversalMethodCall if ($target === '*') { return $receiver->type === Type::VAR; } - $builtinTargets = [ - Type::VAR, Type::INT, Type::FLOAT, Type::BOOL, Type::STR, - Type::ARRAY, Type::OBJECT, Type::STREAM, Type::BIGINT, - Type::BIGFLOAT, Type::DECIMAL, Type::BOX, - ]; - if (!in_array($target, $builtinTargets, true)) { + if (!$this->isBuiltinMethodsForTarget($target)) { return $receiver->type === Type::OBJECT && $this->isSameClassName($receiver->declaredClass, $target); } return $receiver->type === $target; @@ -403,7 +425,7 @@ trait UniversalMethodCall private function findProviderExtension(string $target, string $method): ?array { - return $this->getExtensionProviderMethods()[strtolower(ltrim($target, '\\'))][strtolower($method)] ?? null; + return $this->getMethodsForRegistry()[strtolower(ltrim($target, '\\'))][strtolower($method)] ?? null; } protected const array TO_CONVERT_FN = [ @@ -445,21 +467,95 @@ trait UniversalMethodCall } /** - * Look up a statically compiled object extension in the object's own - * namespace. The class prefix and method suffix must match the declared - * names without converting between camelCase and snake_case. This lookup - * is only used by the named MethodCall AST path; dynamic method names and - * StaticCall nodes deliberately do not use it. - * Real methods are resolved before this fallback, while __call() is used - * only if no valid extension exists. + * Look up an object extension using the receiver's static class hierarchy. + * Real methods always win. Extensions are searched on the exact static + * class, then its parents from nearest to farthest, and finally Type::Object + * when the receiver is statically known to be an object. __call() remains + * the last fallback in the caller. */ - protected function findObjectExtensionMethod(string $class, string $method): ?array - { + protected function findObjectExtensionMethod( + string $class, + string $method, + bool $receiverIsDefinitelyObject, + ): ?array { $class = ltrim($class, '\\'); - if ($class === '' || !$this->hasClass($class)) { + if ($class !== '' && $this->objectTypeDeclaresMethod($class, $method)) { return null; } - return $this->findProviderExtension($class, $method); + + $visited = []; + $current = $class; + while ($current !== '') { + $key = strtolower(ltrim($current, '\\')); + if (isset($visited[$key])) { + break; + } + $visited[$key] = true; + + $extension = $this->findProviderExtension($current, $method); + if ($extension !== null) { + return $extension; + } + + $classDef = $this->getClassDef($current); + if ($classDef !== null) { + $current = $classDef->extends; + continue; + } + $reflection = Reflection::getClass($current); + $parent = $reflection?->getParentClass(); + $current = $parent === false || $parent === null ? '' : $parent->getName(); + } + + return $receiverIsDefinitelyObject + ? $this->findProviderExtension(Type::OBJECT, $method) + : null; + } + + private function objectTypeDeclaresMethod(string $class, string $method): bool + { + return $this->classOrInterfaceDeclaresMethod($class, $method, []); + } + + /** @param array $visited */ + private function classOrInterfaceDeclaresMethod(string $type, string $method, array $visited): bool + { + $type = ltrim($type, '\\'); + $key = strtolower($type); + if ($type === '' || isset($visited[$key])) { + return false; + } + $visited[$key] = true; + + $classDef = $this->getClassDef($type); + if ($classDef !== null) { + if ($classDef->hasMethod($method) || $classDef->hasAbstractMethod($method)) { + return true; + } + foreach ($classDef->implements as $interface) { + if ($this->classOrInterfaceDeclaresMethod($interface, $method, $visited)) { + return true; + } + } + return $classDef->extends !== '' + && $this->classOrInterfaceDeclaresMethod($classDef->extends, $method, $visited); + } + + if ($this->hasInterface($type)) { + $interfaceDef = $this->getInterface($type); + if ($interfaceDef->hasMethod($method)) { + return true; + } + foreach ($interfaceDef->extendsList ?: ($interfaceDef->extends ? [$interfaceDef->extends] : []) as $parent) { + if ($this->classOrInterfaceDeclaresMethod($parent, $method, $visited)) { + return true; + } + } + return false; + } + + $reflection = Reflection::getClass($type); + return $reflection?->hasMethod($method) ?? false; } /** diff --git a/src/Preprocessor.php b/src/Preprocessor.php index 03eedbdb..ef7300a9 100644 --- a/src/Preprocessor.php +++ b/src/Preprocessor.php @@ -17,9 +17,13 @@ use TypePhp\Entity\FunctionDef; use TypePhp\Entity\InterfaceDef; use TypePhp\Entity\MethodDef; use TypePhp\Entity\PropertyDef; +use TypePhp\Diagnostics\CompileTimeAttributeDiagnostic; use TypePhp\Exception\SyntaxError; use TypePhp\Transform\PropertyHookLowering; use TypePhp\Transform\PrinterLowering; +use TypePhp\Transform\ArrayableLowering; +use TypePhp\Transform\ClassFieldSelection; +use TypePhp\Transform\FunctionAttributeLowering; use TypePhp\Transform\Visitor; use PhpParser\Modifiers; use PhpParser\ConstExprEvaluator; @@ -139,7 +143,10 @@ class Preprocessor extends CompilerBase $traverser = new NodeTraverser(); $traverser->addVisitor(new NameResolver(null, ['replaceNodes' => false])); - $traverser->addVisitor(new Visitor()); + $traverser->addVisitor(new Visitor( + fn (Node $node, string $message) => $this->warning($node, $message), + $this->file, + )); $stmts = $traverser->traverse($ast); $this->validateUnsupportedAttributeArguments($stmts); @@ -227,7 +234,12 @@ class Preprocessor extends CompilerBase continue; } if ($attribute->args !== []) { - $this->fatalError($attribute, 'NoExport does not accept arguments'); + $this->fatalCompileTimeAttribute( + $node, + 'NoExport', + 'NoExport does not accept arguments', + $attribute, + ); } return true; } @@ -534,6 +546,17 @@ class Preprocessor extends CompilerBase } $functionDef = new FunctionDef($fnName, $returnType, $this->namespace); + $functionDef->mustUse = (bool) $v->getAttribute(FunctionAttributeLowering::MUST_USE_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); + if ($functionDef->mustUse && $returnType === Type::VOID) { + $this->fatalCompileTimeAttribute( + $v, + 'MustUse', + 'MustUse cannot be applied to a function or method returning void', + ); + } $functionDef->exported = !($this->classDef?->exported === false || $this->hasNoExportAttribute($v)); $functionDef->returnClass = $class; // Record late-bound return type keywords so they can be re-resolved to @@ -663,7 +686,7 @@ class Preprocessor extends CompilerBase $this->classDef = new ClassDef($this->class, $flags, $this->namespace); $this->classDef->exported = !$this->hasNoExportAttribute($class); - $this->classDef->extensionProviderTarget = $this->parseExtensionProviderTarget($class); + $this->classDef->methodsForTarget = $this->parseMethodsForTarget($class); $this->addClass($fullClassName, $this->classDef); if (!empty($class->extends)) { @@ -696,20 +719,37 @@ class Preprocessor extends CompilerBase $this->symbolDeclInFile[$fullClassNameLower] = $this->file; if ($class instanceof Node\Stmt\Class_) { - $generatedPrinter = false; + $generatedPrinter = null; + $generatedArrayable = null; foreach ($class->getMethods() as $method) { if ($method->getAttribute(PrinterLowering::GENERATED_ATTRIBUTE)) { - $generatedPrinter = true; - break; + $generatedPrinter = $method; + } + if ($method->getAttribute(ArrayableLowering::GENERATED_ATTRIBUTE)) { + $generatedArrayable = $method; } } - if ($generatedPrinter && $this->parentHasMethod($this->classDef->extends, 'toString')) { - PrinterLowering::removeGeneratedMethod($class); - } elseif ($generatedPrinter) { + if ($generatedPrinter !== null) { $this->classDef->printerGenerated = true; + $this->classDef->printerFields = $generatedPrinter->getAttribute(PrinterLowering::FIELDS_ATTRIBUTE); + $properties = $this->classDef->printerFields + ?? [...$this->parentPublicProperties($this->classDef->extends), ...ClassFieldSelection::ownPublicProperties($class)]; PrinterLowering::rebuildGeneratedMethod( $class, - [...$this->parentPublicProperties($this->classDef->extends), ...PrinterLowering::ownPublicProperties($class)], + $properties, + $this->classDef->printerFields, + $this->classStringProperties($this->classDef), + ); + } + if ($generatedArrayable !== null) { + $this->classDef->arrayableGenerated = true; + $this->classDef->arrayableFields = $generatedArrayable->getAttribute(ArrayableLowering::FIELDS_ATTRIBUTE); + $properties = $this->classDef->arrayableFields + ?? [...$this->parentPublicProperties($this->classDef->extends), ...ClassFieldSelection::ownPublicProperties($class)]; + ArrayableLowering::rebuildGeneratedMethod( + $class, + $properties, + $this->classDef->arrayableFields, ); } } @@ -774,30 +814,6 @@ class Preprocessor extends CompilerBase return $code; } - public function shouldGeneratePrinter(string $class): bool - { - $classDef = $this->getClassDef(ltrim($class, '\\')); - if ($classDef === null) { - return true; - } - // A child may be discovered before its parent during the initial - // project scan. Reconcile the provisional method once every class is - // available, before conversion and arginfo generation begin. - if ($classDef->printerGenerated && $this->parentHasMethod($classDef->extends, 'toString')) { - $generated = $classDef->removeMethod('toString'); - if ($generated?->functionDef !== null) { - foreach ($this->symbols->functions() as $name => $functionDef) { - if ($functionDef === $generated->functionDef) { - $this->symbols->removeFunction($name); - break; - } - } - } - $classDef->printerGenerated = false; - } - return $classDef->printerGenerated; - } - /** @return list */ protected function parentPublicProperties(string $parent): array { @@ -817,35 +833,75 @@ class Preprocessor extends CompilerBase return array_values(array_unique($properties)); } - protected function parentHasMethod(string $parent, string $method): bool + /** @return list */ + protected function selectableProperties(ClassDef $classDef): array { + $properties = []; + $parent = $classDef->extends; while ($parent !== '') { - $classDef = $this->getClassDef($parent); - if ($classDef === null) { - return $this->isInternalClass($parent) && method_exists($parent, $method); + $parentDef = $this->getClassDef($parent); + if ($parentDef === null) { + break; } - if ($classDef->hasMethod($method) || $classDef->hasAbstractMethod($method)) { - return true; + foreach ($parentDef->properties as $property) { + if (!$property->isStatic() && !$property->isPrivate()) { + $properties[] = $property->name; + } } - $parent = $classDef->extends; + $parent = $parentDef->extends; } - return false; + foreach ($classDef->properties as $property) { + if (!$property->isStatic()) { + $properties[] = $property->name; + } + } + return array_values(array_unique($properties)); + } + + /** @return list */ + protected function classStringProperties(ClassDef $classDef): array + { + $types = []; + if ($classDef->extends !== '') { + $parent = $this->getClassDef($classDef->extends); + if ($parent !== null) { + foreach ($this->classStringProperties($parent) as $property) { + $types[$property] = true; + } + } + } + foreach ($classDef->properties as $property) { + if (!$property->isStatic()) { + $types[$property->name] = $property->type === Type::STR; + } + } + return array_keys(array_filter($types)); } - protected function parseExtensionProviderTarget(Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum_ $class): ?string + protected function parseMethodsForTarget(Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum_ $class): ?string { foreach ($class->attrGroups as $groupIndex => $group) { foreach ($group->attrs as $attributeIndex => $attribute) { - if (!$this->isRootCompileTimeAttribute($attribute, 'ExtensionProvider')) { + if (!$this->isRootCompileTimeAttribute($attribute, 'MethodsFor')) { continue; } if (!$class instanceof Node\Stmt\Class_) { - $this->fatalError($class, 'ExtensionProvider can only be applied to classes'); + $this->fatalCompileTimeAttribute( + $class, + 'MethodsFor', + 'MethodsFor can only be applied to classes', + $attribute, + ); } if (count($attribute->args) !== 1) { - $this->fatalError($attribute, 'ExtensionProvider expects exactly one target'); + $this->fatalCompileTimeAttribute( + $class, + 'MethodsFor', + 'MethodsFor expects exactly one target', + $attribute, + ); } - $target = $this->parseExtensionProviderTargetValue($attribute->args[0]->value, $attribute); + $target = $this->parseMethodsForTargetValue($attribute->args[0]->value, $attribute, $class); unset($group->attrs[$attributeIndex]); $group->attrs = array_values($group->attrs); if (empty($group->attrs)) { @@ -858,7 +914,11 @@ class Preprocessor extends CompilerBase return null; } - private function parseExtensionProviderTargetValue(Node\Expr $value, NodeAbstract $errorNode): string + private function parseMethodsForTargetValue( + Node\Expr $value, + NodeAbstract $errorNode, + Node\Stmt\Class_ $class, + ): string { if ($value instanceof Node\Scalar\String_ && $value->value === '*') { return '*'; @@ -889,7 +949,12 @@ class Preprocessor extends CompilerBase return $targets[$constant]; } } - $this->fatalError($errorNode, "ExtensionProvider target must be '*', Type::*, or ClassName::class"); + $this->fatalCompileTimeAttribute( + $class, + 'MethodsFor', + "MethodsFor target must be '*', Type::*, or ClassName::class", + $errorNode, + ); } protected function buildLiteralArrayInitPlan(Node\Expr\Array_ $defaultNode): ArrayInitPlan @@ -1316,7 +1381,20 @@ class Preprocessor extends CompilerBase if (!$abstract) { $this->methodDef = new MethodDef($flags, $name); + $this->methodDef->node = $v; if ($this->classDef->hasMethod($name)) { + $generatedBy = $v->getAttribute(CompileTimeAttributeDiagnostic::GENERATED_BY); + $generatedTarget = $v->getAttribute(CompileTimeAttributeDiagnostic::GENERATED_TARGET); + if (is_string($generatedBy) && $generatedTarget instanceof Node) { + $this->fatalCompileTimeAttribute( + $generatedTarget, + $generatedBy, + "Duplicate method `{$this->method}`", + $generatedTarget, + null, + $this->classDef->getMethod($name)->node, + ); + } $this->fatalError($v, "Duplicate method `{$this->method}`"); } $this->prepareFunction($v); @@ -1330,6 +1408,7 @@ class Preprocessor extends CompilerBase $this->fatalError($v, "Non-abstract class {$this->class} contains abstract method {$v->name}"); } $this->methodDef = new MethodDef($flags, $name); + $this->methodDef->node = $v; $this->methodDef->functionDef = $this->parseFunctionDecl($v); $this->methodDef->functionDef->method = true; $this->checkRequiredArgNum($name, $this->methodDef, $v); @@ -1449,6 +1528,7 @@ class Preprocessor extends CompilerBase } $this->method = $methodName; $methodDef = new MethodDef($this->parseModifiers($stmt->flags), $methodName); + $methodDef->node = $stmt; $methodDef->functionDef = $this->parseFunctionDecl($stmt); $methodDef->functionDef->method = true; $this->interfaceDef->addMethod($methodDef); diff --git a/src/Transform/ArrayableLowering.php b/src/Transform/ArrayableLowering.php new file mode 100644 index 00000000..64004c68 --- /dev/null +++ b/src/Transform/ArrayableLowering.php @@ -0,0 +1,80 @@ + $properties + * @param list|null $fields + */ + public static function rebuildGeneratedMethod(Stmt\Class_ $class, array $properties, ?array $fields): void + { + self::removeGeneratedMethod($class); + self::appendGeneratedMethod($class, array_values(array_unique($properties)), $fields); + } + + public static function removeGeneratedMethod(Stmt\Class_ $class): void + { + foreach ($class->stmts as $index => $stmt) { + if ($stmt instanceof Stmt\ClassMethod && $stmt->getAttribute(self::GENERATED_ATTRIBUTE)) { + unset($class->stmts[$index]); + } + } + $class->stmts = array_values($class->stmts); + } + + /** + * @param list $properties + * @param list|null $fields + */ + private static function appendGeneratedMethod(Stmt\Class_ $class, array $properties, ?array $fields): void + { + $items = []; + foreach ($properties as $property) { + $items[] = new Expr\ArrayItem( + new Expr\PropertyFetch(new Expr\Variable('this'), $property), + new Node\Scalar\String_($property), + ); + } + $method = new Stmt\ClassMethod('toArray', [ + 'flags' => Modifiers::PUBLIC, + 'returnType' => new Node\Identifier('array'), + 'stmts' => [new Stmt\Return_(new Expr\Array_($items))], + ]); + $method->setAttribute(self::GENERATED_ATTRIBUTE, true); + $method->setAttribute(self::FIELDS_ATTRIBUTE, $fields); + CompileTimeAttributeDiagnostic::markGenerated($method, 'Arrayable', $class); + $class->stmts[] = $method; + } +} diff --git a/src/Transform/ClassFieldSelection.php b/src/Transform/ClassFieldSelection.php new file mode 100644 index 00000000..e5084638 --- /dev/null +++ b/src/Transform/ClassFieldSelection.php @@ -0,0 +1,96 @@ +|null Null selects every public instance property. + */ + public static function parse(Node\Attribute $attribute, string $name): ?array + { + if ($attribute->args === []) { + return null; + } + if (count($attribute->args) !== 1) { + throw new SyntaxError($name . ' accepts only the optional $fields argument'); + } + + $argument = $attribute->args[0]; + if ($argument->name !== null && $argument->name->toString() !== 'fields') { + throw new SyntaxError($name . ' has an unknown argument $' . $argument->name->toString()); + } + if ($argument->unpack || !$argument->value instanceof Expr\Array_) { + throw new SyntaxError($name . ' $fields must be an array literal of property names'); + } + + $fields = []; + foreach ($argument->value->items as $item) { + if ($item === null || $item->unpack || $item->key !== null + || !$item->value instanceof Node\Scalar\String_) { + throw new SyntaxError($name . ' $fields must be a list of property-name strings'); + } + $field = $item->value->value; + if (in_array($field, $fields, true)) { + throw new SyntaxError($name . ' field `' . $field . '` is specified more than once'); + } + $fields[] = $field; + } + return $fields; + } + + /** @return list */ + public static function ownPublicProperties(Stmt\Class_ $class): array + { + $properties = []; + foreach ($class->stmts as $stmt) { + if ($stmt instanceof Stmt\Property && $stmt->isPublic() && !$stmt->isStatic()) { + foreach ($stmt->props as $property) { + $properties[] = $property->name->toString(); + } + } + if ($stmt instanceof Stmt\ClassMethod && $stmt->name->toLowerString() === '__construct') { + foreach ($stmt->params as $param) { + if ($param->isPromoted() && ($param->flags & Modifiers::PUBLIC) && is_string($param->var->name)) { + $properties[] = $param->var->name; + } + } + } + } + return array_values(array_unique($properties)); + } + + /** + * @param list|null $selected + * @param list $available + * @return list + */ + public static function resolve(?array $selected, array $available, string $name): array + { + $available = array_values(array_unique($available)); + if ($selected === null) { + return $available; + } + foreach ($selected as $field) { + if (!in_array($field, $available, true)) { + throw new SyntaxError( + $name . ' field `' . $field . '` must be a declared instance property accessible from the class' + ); + } + } + return $selected; + } + +} diff --git a/src/Transform/CompileTimeAttribute.php b/src/Transform/CompileTimeAttribute.php index f962066a..e8bc272c 100644 --- a/src/Transform/CompileTimeAttribute.php +++ b/src/Transform/CompileTimeAttribute.php @@ -9,10 +9,84 @@ namespace TypePhp\Transform; use PhpParser\Node; +use TypePhp\Exception\CompileTimeAttributeError; use TypePhp\Exception\SyntaxError; final class CompileTimeAttribute { + public static function validateNode(Node $node): void + { + if (!property_exists($node, 'attrGroups')) { + return; + } + + $found = []; + foreach ($node->attrGroups as $group) { + foreach ($group->attrs as $attribute) { + $definition = CompileTimeAttributeRegistry::get(self::resolvedName($attribute)); + if ($definition === null) { + continue; + } + $key = strtolower($definition['name']); + $found[$key][] = $attribute; + if (!self::matchesTarget($node, $definition['targets'])) { + throw new CompileTimeAttributeError( + $definition['target_error'], + $node, + $definition['name'], + $attribute, + ); + } + self::validateArguments($attribute, $definition['name']); + } + } + + foreach ($found as $attributes) { + $definition = CompileTimeAttributeRegistry::get(self::resolvedName($attributes[0])); + if (!$definition['repeatable'] && count($attributes) > 1) { + throw new CompileTimeAttributeError( + $definition['name'] . ' cannot be repeated on the same declaration', + $node, + $definition['name'], + $attributes[0], + $definition['name'], + $attributes[1], + ); + } + foreach ($definition['conflicts'] as $conflict) { + if (isset($found[strtolower($conflict)])) { + $target = $definition['targets'] === [ + CompileTimeAttributeRegistry::TARGET_FUNCTION, + CompileTimeAttributeRegistry::TARGET_METHOD, + ] ? 'function or method' : 'declaration'; + throw new CompileTimeAttributeError( + $definition['name'] . ' and ' . $conflict . ' cannot be applied to the same ' . $target, + $node, + $definition['name'], + $attributes[0], + $conflict, + $found[strtolower($conflict)][0], + ); + } + } + } + } + + public static function find(Node $node, string $name): ?Node\Attribute + { + if (!property_exists($node, 'attrGroups')) { + return null; + } + foreach ($node->attrGroups as $group) { + foreach ($group->attrs as $attribute) { + if (self::is($attribute, $name)) { + return $attribute; + } + } + } + return null; + } + public static function has(Node $node, string $name): bool { if (!property_exists($node, 'attrGroups')) { @@ -50,19 +124,80 @@ final class CompileTimeAttribute return $found; } + public static function remove(Node $node, string $name): bool + { + $found = false; + foreach ($node->attrGroups as $groupIndex => $group) { + foreach ($group->attrs as $attributeIndex => $attribute) { + if (self::is($attribute, $name)) { + $found = true; + unset($group->attrs[$attributeIndex]); + } + } + $group->attrs = array_values($group->attrs); + if ($group->attrs === []) { + unset($node->attrGroups[$groupIndex]); + } + } + $node->attrGroups = array_values($node->attrGroups); + return $found; + } + public static function is(Node\Attribute $attribute, string $name): bool + { + return strcasecmp(self::resolvedName($attribute), ltrim($name, '\\')) === 0; + } + + public static function resolvedName(Node\Attribute $attribute): string { $resolvedName = $attribute->name->getAttribute('resolvedName') ?? $attribute->name->getAttribute('namespacedName') ?? $attribute->name; - - return strcasecmp(ltrim($resolvedName->toString(), '\\'), $name) === 0; + return ltrim($resolvedName->toString(), '\\'); } private static function validateArguments(Node\Attribute $attribute, string $name): void { - if ($attribute->args !== []) { + $definition = CompileTimeAttributeRegistry::get($name); + if ($definition !== null + && $definition['argument_parser'] === CompileTimeAttributeRegistry::ARGUMENTS_NONE + && $attribute->args !== []) { throw new SyntaxError($name . ' does not accept arguments'); } } + + /** @param list $targets */ + private static function matchesTarget(Node $node, array $targets): bool + { + if (in_array(CompileTimeAttributeRegistry::TARGET_CLASS, $targets, true) + && $node instanceof Node\Stmt\Class_) { + return true; + } + if (in_array(CompileTimeAttributeRegistry::TARGET_NAMED_CLASS, $targets, true) + && $node instanceof Node\Stmt\Class_ && $node->name !== null) { + return true; + } + if (in_array(CompileTimeAttributeRegistry::TARGET_CLASS_LIKE, $targets, true) + && $node instanceof Node\Stmt\ClassLike) { + return true; + } + if (in_array(CompileTimeAttributeRegistry::TARGET_FUNCTION, $targets, true) + && $node instanceof Node\Stmt\Function_) { + return true; + } + if (in_array(CompileTimeAttributeRegistry::TARGET_METHOD, $targets, true) + && $node instanceof Node\Stmt\ClassMethod) { + return true; + } + if (in_array(CompileTimeAttributeRegistry::TARGET_PROPERTY, $targets, true) + && ($node instanceof Node\Stmt\Property || ($node instanceof Node\Param && $node->isPromoted()))) { + return true; + } + if (in_array(CompileTimeAttributeRegistry::TARGET_DECLARED_PROPERTY, $targets, true) + && $node instanceof Node\Stmt\Property) { + return true; + } + return in_array(CompileTimeAttributeRegistry::TARGET_PARAMETER, $targets, true) + && $node instanceof Node\Param; + } } diff --git a/src/Transform/CompileTimeAttributeRegistry.php b/src/Transform/CompileTimeAttributeRegistry.php new file mode 100644 index 00000000..1c2db082 --- /dev/null +++ b/src/Transform/CompileTimeAttributeRegistry.php @@ -0,0 +1,123 @@ +, + * target_error: string, + * repeatable: bool, + * argument_parser: string, + * conflicts: list, + * phase: string, + * preserve_in_library_stub: bool + * }> + */ + public static function all(): array + { + static $definitions = null; + if ($definitions !== null) { + return $definitions; + } + + $definitions = []; + $add = static function ( + string $name, + array $targets, + string $targetError, + string $argumentParser, + string $phase, + bool $preserveInLibraryStub = true, + array $conflicts = [], + bool $repeatable = false, + ) use (&$definitions): void { + $definitions[strtolower($name)] = [ + 'name' => $name, + 'targets' => $targets, + 'target_error' => $targetError, + 'repeatable' => $repeatable, + 'argument_parser' => $argumentParser, + 'conflicts' => $conflicts, + 'phase' => $phase, + 'preserve_in_library_stub' => $preserveInLibraryStub, + ]; + }; + + $add('MethodsFor', [self::TARGET_NAMED_CLASS], 'MethodsFor can only be applied to classes', self::ARGUMENTS_METHODS_FOR, self::PHASE_PREPROCESS); + $add('NoExport', [self::TARGET_CLASS_LIKE, self::TARGET_FUNCTION, self::TARGET_METHOD], 'NoExport can only be applied to classes, functions, or methods', self::ARGUMENTS_NONE, self::PHASE_PREPROCESS, false); + foreach (['Getter', 'Setter', 'With'] as $name) { + $add($name, [self::TARGET_PROPERTY], $name . ' can only be applied to instance properties', self::ARGUMENTS_NONE, self::PHASE_CLASS_LEAVE); + } + foreach (['Printer', 'Arrayable'] as $name) { + $add($name, [self::TARGET_NAMED_CLASS], $name . ' can only be applied to named classes', self::ARGUMENTS_FIELDS, self::PHASE_CLASS_LEAVE); + } + foreach (['NotNull', 'NotEmpty'] as $name) { + $add($name, [self::TARGET_PARAMETER], $name . ' can only be applied to function or method parameters', self::ARGUMENTS_NONE, self::PHASE_FUNCTION_LEAVE); + } + $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('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); + + return $definitions; + } + + public static function get(string $name): ?array + { + return self::all()[strtolower(ltrim($name, '\\'))] ?? null; + } + + /** @return list */ + public static function names(bool $preservedInLibraryStubOnly = false): array + { + $names = []; + foreach (self::all() as $definition) { + if (!$preservedInLibraryStubOnly || $definition['preserve_in_library_stub']) { + $names[] = $definition['name']; + } + } + return $names; + } + + /** @return list */ + public static function namesForPhase(string $phase): array + { + $names = []; + foreach (self::all() as $definition) { + if ($definition['phase'] === $phase) { + $names[] = $definition['name']; + } + } + return $names; + } +} diff --git a/src/Transform/ConstructorLowering.php b/src/Transform/ConstructorLowering.php new file mode 100644 index 00000000..e6160679 --- /dev/null +++ b/src/Transform/ConstructorLowering.php @@ -0,0 +1,102 @@ +isStatic()) { + throw new SyntaxError('Constructor can only be applied to instance properties'); + } + } + + public static function lowerClassLike(Stmt\Class_|Stmt\Trait_|Stmt\Enum_ $class): void + { + $properties = []; + $target = null; + foreach ($class->stmts as $stmt) { + if (!$stmt instanceof Stmt\Property || !CompileTimeAttribute::has($stmt, 'Constructor')) { + continue; + } + if (!$class instanceof Stmt\Class_) { + throw new SyntaxError('Constructor properties can only be declared in classes'); + } + $attribute = CompileTimeAttribute::find($stmt, 'Constructor'); + CompileTimeAttribute::consume($stmt, 'Constructor'); + $target ??= $stmt; + foreach ($stmt->props as $property) { + $properties[] = [ + $property->name->toString(), + $stmt->type, + $property->default, + $stmt->getAttributes(), + $stmt, + $attribute, + ]; + } + } + if ($properties === []) { + return; + } + $params = []; + $stmts = []; + $optionalSeen = false; + $optionalTarget = null; + $optionalAttribute = null; + foreach ($properties as [$name, $type, $default, $attributes, $propertyTarget, $attributeSource]) { + if ($default !== null) { + $optionalSeen = true; + $optionalTarget ??= $propertyTarget; + $optionalAttribute ??= $attributeSource; + } elseif ($optionalSeen) { + throw new CompileTimeAttributeError( + 'Constructor required properties cannot follow properties with default values', + $propertyTarget, + 'Constructor', + $attributeSource, + 'Constructor', + $optionalAttribute ?? $optionalTarget, + ); + } + $params[] = new Param( + new Expr\Variable($name), + default: $default === null ? null : clone $default, + type: $type === null ? null : clone $type, + attributes: $attributes, + ); + $stmts[] = new Stmt\Expression(new Expr\Assign( + new Expr\PropertyFetch(new Expr\Variable('this'), $name), + new Expr\Variable($name), + )); + } + $constructor = new Stmt\ClassMethod('__construct', [ + 'flags' => Modifiers::PUBLIC, + 'params' => $params, + 'stmts' => $stmts, + ]); + $constructor->setAttribute(self::GENERATED_ATTRIBUTE, true); + CompileTimeAttributeDiagnostic::markGenerated($constructor, 'Constructor', $target ?? $class); + $class->stmts[] = $constructor; + } +} diff --git a/src/Transform/FunctionAttributeLowering.php b/src/Transform/FunctionAttributeLowering.php new file mode 100644 index 00000000..3e426347 --- /dev/null +++ b/src/Transform/FunctionAttributeLowering.php @@ -0,0 +1,35 @@ +setAttribute('typephp' . $name, true); + } + } +} diff --git a/src/Transform/GetterLowering.php b/src/Transform/GetterLowering.php index cdaf5601..2f690434 100644 --- a/src/Transform/GetterLowering.php +++ b/src/Transform/GetterLowering.php @@ -14,6 +14,7 @@ use PhpParser\Node\Expr; use PhpParser\Node\Param; use PhpParser\Node\Stmt; use TypePhp\Exception\SyntaxError; +use TypePhp\Diagnostics\CompileTimeAttributeDiagnostic; final class GetterLowering { @@ -40,6 +41,9 @@ final class GetterLowering /** @return list */ public static function lowerProperty(Stmt\Property $property): array { + if (CompileTimeAttribute::has($property, 'Getter') && $property->hooks !== []) { + throw new SyntaxError('Getter cannot be applied to properties with hooks'); + } if (!CompileTimeAttribute::consume($property, 'Getter')) { return []; } @@ -49,7 +53,7 @@ final class GetterLowering $methods[] = self::createGetter( $prop->name->toString(), $property->type, - $property->getAttributes(), + $property, ); } return $methods; @@ -57,23 +61,28 @@ final class GetterLowering public static function lowerPromotedProperty(Param $param): ?Stmt\ClassMethod { + if (CompileTimeAttribute::has($param, 'Getter') && $param->hooks !== []) { + throw new SyntaxError('Getter cannot be applied to properties with hooks'); + } if (!$param->isPromoted() || !is_string($param->var->name) || !CompileTimeAttribute::consume($param, 'Getter')) { return null; } - return self::createGetter($param->var->name, $param->type, $param->getAttributes()); + return self::createGetter($param->var->name, $param->type, $param); } - private static function createGetter(string $property, ?Node $type, array $attributes): Stmt\ClassMethod + private static function createGetter(string $property, ?Node $type, Node $target): Stmt\ClassMethod { - return new Stmt\ClassMethod('get' . ucfirst($property), [ + $method = new Stmt\ClassMethod('get' . ucfirst($property), [ 'flags' => Modifiers::PUBLIC, 'returnType' => $type === null ? null : clone $type, 'stmts' => [new Stmt\Return_(new Expr\PropertyFetch( new Expr\Variable('this'), $property, ))], - ], $attributes); + ], $target->getAttributes()); + CompileTimeAttributeDiagnostic::markGenerated($method, 'Getter', $target); + return $method; } } diff --git a/src/Transform/NotEmptyLowering.php b/src/Transform/NotEmptyLowering.php new file mode 100644 index 00000000..a07ea2d4 --- /dev/null +++ b/src/Transform/NotEmptyLowering.php @@ -0,0 +1,26 @@ + [new Stmt\Expression(new Expr\Throw_(new Expr\New_( + new Node\Name\FullyQualified('ValueError'), + [new Node\Arg(new Node\Scalar\String_('Parameter $' . $name . ' must not be empty'))], + )))], + ]); + } +} diff --git a/src/Transform/NotNullLowering.php b/src/Transform/NotNullLowering.php index a1d41b29..2006215e 100644 --- a/src/Transform/NotNullLowering.php +++ b/src/Transform/NotNullLowering.php @@ -10,48 +10,20 @@ namespace TypePhp\Transform; use PhpParser\Node; use PhpParser\Node\Expr; -use PhpParser\Node\Param; use PhpParser\Node\Stmt; -use TypePhp\Exception\SyntaxError; final class NotNullLowering { - public static function validateTarget(Node $node): void + public static function createCheck(string $name): Stmt\If_ { - if (CompileTimeAttribute::has($node, 'NotNull') && !$node instanceof Param) { - throw new SyntaxError('NotNull can only be applied to function or method parameters'); - } - } - - public static function lowerFunction(Stmt\Function_|Stmt\ClassMethod|Expr\Closure $function): void - { - $checks = []; - foreach ($function->params as $param) { - if (!CompileTimeAttribute::consume($param, 'NotNull')) { - continue; - } - if ($function->stmts === null || !is_string($param->var->name)) { - throw new SyntaxError('NotNull requires a concrete function or method parameter'); - } - $name = $param->var->name; - $checks[] = new Stmt\If_(new Expr\Empty_(new Expr\Variable($name)), [ - 'stmts' => [new Stmt\Expression(new Expr\Throw_(new Expr\New_( - new Node\Name\FullyQualified('ValueError'), - [new Node\Arg(new Node\Scalar\String_('Parameter $' . $name . ' must not be empty'))], - )))], - ]); - } - if ($checks !== []) { - $function->stmts = [...$checks, ...$function->stmts]; - } - } - - public static function rejectArrowFunction(Expr\ArrowFunction $function): void - { - foreach ($function->params as $param) { - if (CompileTimeAttribute::has($param, 'NotNull')) { - throw new SyntaxError('NotNull is not supported on arrow function parameters'); - } - } + return new Stmt\If_(new Expr\BinaryOp\Identical( + new Expr\Variable($name), + new Expr\ConstFetch(new Node\Name('null')), + ), [ + 'stmts' => [new Stmt\Expression(new Expr\Throw_(new Expr\New_( + new Node\Name\FullyQualified('ValueError'), + [new Node\Arg(new Node\Scalar\String_('Parameter $' . $name . ' must not be null'))], + )))], + ]); } } diff --git a/src/Transform/ParameterValidationLowering.php b/src/Transform/ParameterValidationLowering.php new file mode 100644 index 00000000..84809b7e --- /dev/null +++ b/src/Transform/ParameterValidationLowering.php @@ -0,0 +1,117 @@ +params as $param) { + if (!is_string($param->var->name)) { + continue; + } + $name = $param->var->name; + $notNull = CompileTimeAttribute::find($param, 'NotNull'); + if ($notNull !== null) { + if ($notNull->args !== []) { + throw new CompileTimeAttributeError( + 'NotNull does not accept arguments', + $param, + 'NotNull', + $notNull, + ); + } + if ($warning !== null && self::isExplicitlyNullable($param)) { + $warning($param, 'NotNull is applied to nullable parameter `$' . $name . '`'); + } + $checks[] = NotNullLowering::createCheck($name); + } + $notEmpty = CompileTimeAttribute::find($param, 'NotEmpty'); + if ($notEmpty !== null) { + if ($notEmpty->args !== []) { + throw new CompileTimeAttributeError( + 'NotEmpty does not accept arguments', + $param, + 'NotEmpty', + $notEmpty, + ); + } + $checks[] = NotEmptyLowering::createCheck($name); + } + $validate = CompileTimeAttribute::find($param, 'Validate'); + if ($validate !== null) { + try { + $checks[] = ValidateLowering::createCheck($param, $validate); + } catch (SyntaxError $error) { + throw new CompileTimeAttributeError( + $error->getMessage(), + $param, + 'Validate', + $validate, + previous: $error, + ); + } + } + CompileTimeAttribute::remove($param, 'NotNull'); + CompileTimeAttribute::remove($param, 'NotEmpty'); + CompileTimeAttribute::remove($param, 'Validate'); + } + if ($checks !== []) { + if ($function->stmts === null) { + throw new SyntaxError('Parameter validation requires a concrete function or method'); + } + $function->stmts = [...$checks, ...$function->stmts]; + } + } + + private static function isExplicitlyNullable(Param $param): bool + { + if ($param->type instanceof NullableType) { + return true; + } + if (!$param->type instanceof UnionType) { + return false; + } + foreach ($param->type->types as $type) { + if (strcasecmp($type->toString(), 'null') === 0) { + return true; + } + } + return false; + } + + public static function rejectArrowFunction(Expr\ArrowFunction $function): void + { + foreach ($function->params as $param) { + foreach (['NotNull', 'NotEmpty', 'Validate'] as $name) { + $attribute = CompileTimeAttribute::find($param, $name); + if ($attribute !== null) { + throw new CompileTimeAttributeError( + $name . ' is not supported on arrow function parameters', + $param, + $name, + $attribute, + ); + } + } + } + } +} diff --git a/src/Transform/PrinterLowering.php b/src/Transform/PrinterLowering.php index da21d915..3b861159 100644 --- a/src/Transform/PrinterLowering.php +++ b/src/Transform/PrinterLowering.php @@ -12,41 +12,47 @@ use PhpParser\Modifiers; use PhpParser\Node; use PhpParser\Node\Expr; use PhpParser\Node\Stmt; -use TypePhp\Exception\SyntaxError; +use TypePhp\Diagnostics\CompileTimeAttributeDiagnostic; final class PrinterLowering { public const GENERATED_ATTRIBUTE = 'typephpPrinterGenerated'; + public const FIELDS_ATTRIBUTE = 'typephpPrinterFields'; - public static function validateTarget(Node $node): void + public static function lowerClass(Stmt\Class_ $class): void { - if (!CompileTimeAttribute::has($node, 'Printer')) { + $attribute = CompileTimeAttribute::find($class, 'Printer'); + if ($attribute === null) { return; } - if (!$node instanceof Stmt\Class_ || $node->name === null) { - throw new SyntaxError('Printer can only be applied to named classes'); - } + $fields = ClassFieldSelection::parse($attribute, 'Printer'); + CompileTimeAttribute::remove($class, 'Printer'); + self::appendGeneratedMethod( + $class, + $fields ?? ClassFieldSelection::ownPublicProperties($class), + $fields, + self::ownStringProperties($class), + ); } - public static function lowerClass(Stmt\Class_ $class, bool $generate = true): void - { - if (!CompileTimeAttribute::consume($class, 'Printer') || !$generate) { - return; - } - foreach ($class->getMethods() as $method) { - if ($method->name->toLowerString() === 'tostring') { - return; - } - } - - self::appendGeneratedMethod($class, self::ownPublicProperties($class)); - } - - /** @param list $properties */ - public static function rebuildGeneratedMethod(Stmt\Class_ $class, array $properties): void + /** + * @param list $properties + * @param list|null $fields + */ + public static function rebuildGeneratedMethod( + Stmt\Class_ $class, + array $properties, + ?array $fields, + array $stringProperties = [], + ): void { self::removeGeneratedMethod($class); - self::appendGeneratedMethod($class, array_values(array_unique($properties))); + self::appendGeneratedMethod( + $class, + array_values(array_unique($properties)), + $fields, + $stringProperties, + ); } public static function removeGeneratedMethod(Stmt\Class_ $class): void @@ -59,19 +65,55 @@ final class PrinterLowering $class->stmts = array_values($class->stmts); } + /** + * @param list $properties + * @param list|null $fields + * @param list $stringProperties + */ + private static function appendGeneratedMethod( + Stmt\Class_ $class, + array $properties, + ?array $fields, + array $stringProperties, + ): void + { + $expression = new Node\Scalar\String_($class->name->toString() . '('); + foreach ($properties as $index => $property) { + $prefix = ($index === 0 ? '' : ', ') . $property . '='; + $value = new Expr\PropertyFetch(new Expr\Variable('this'), $property); + if (!in_array($property, $stringProperties, true)) { + $value = new Expr\MethodCall($value, new Node\Identifier('toString')); + } + $expression = new Expr\BinaryOp\Concat( + new Expr\BinaryOp\Concat($expression, new Node\Scalar\String_($prefix)), + $value, + ); + } + $expression = new Expr\BinaryOp\Concat($expression, new Node\Scalar\String_(')')); + $method = new Stmt\ClassMethod('__toString', [ + 'flags' => Modifiers::PUBLIC, + 'returnType' => new Node\Identifier('string'), + 'stmts' => [new Stmt\Return_($expression)], + ]); + $method->setAttribute(self::GENERATED_ATTRIBUTE, true); + $method->setAttribute(self::FIELDS_ATTRIBUTE, $fields); + CompileTimeAttributeDiagnostic::markGenerated($method, 'Printer', $class); + $class->stmts[] = $method; + } + /** @return list */ - public static function ownPublicProperties(Stmt\Class_ $class): array + private static function ownStringProperties(Stmt\Class_ $class): array { $properties = []; foreach ($class->stmts as $stmt) { - if ($stmt instanceof Stmt\Property && $stmt->isPublic() && !$stmt->isStatic()) { + if ($stmt instanceof Stmt\Property && self::isStringType($stmt->type)) { foreach ($stmt->props as $property) { $properties[] = $property->name->toString(); } } if ($stmt instanceof Stmt\ClassMethod && $stmt->name->toLowerString() === '__construct') { foreach ($stmt->params as $param) { - if ($param->isPromoted() && ($param->flags & Modifiers::PUBLIC) && is_string($param->var->name)) { + if ($param->isPromoted() && is_string($param->var->name) && self::isStringType($param->type)) { $properties[] = $param->var->name; } } @@ -80,24 +122,8 @@ final class PrinterLowering return $properties; } - /** @param list $properties */ - private static function appendGeneratedMethod(Stmt\Class_ $class, array $properties): void + private static function isStringType(?Node $type): bool { - $expression = new Node\Scalar\String_($class->name->toString() . '('); - foreach ($properties as $index => $property) { - $prefix = ($index === 0 ? '' : ', ') . $property . '='; - $expression = new Expr\BinaryOp\Concat( - new Expr\BinaryOp\Concat($expression, new Node\Scalar\String_($prefix)), - new Expr\PropertyFetch(new Expr\Variable('this'), $property), - ); - } - $expression = new Expr\BinaryOp\Concat($expression, new Node\Scalar\String_(')')); - $method = new Stmt\ClassMethod('toString', [ - 'flags' => Modifiers::PUBLIC, - 'returnType' => new Node\Identifier('string'), - 'stmts' => [new Stmt\Return_($expression)], - ]); - $method->setAttribute(self::GENERATED_ATTRIBUTE, true); - $class->stmts[] = $method; + return $type instanceof Node\Identifier && strtolower($type->name) === 'string'; } } diff --git a/src/Transform/PropertyMethodLowering.php b/src/Transform/PropertyMethodLowering.php index 2b921624..2efeefb6 100644 --- a/src/Transform/PropertyMethodLowering.php +++ b/src/Transform/PropertyMethodLowering.php @@ -14,6 +14,7 @@ use PhpParser\Node\Expr; use PhpParser\Node\Param; use PhpParser\Node\Stmt; use TypePhp\Exception\SyntaxError; +use TypePhp\Diagnostics\CompileTimeAttributeDiagnostic; final class PropertyMethodLowering { @@ -36,8 +37,9 @@ final class PropertyMethodLowering } /** @return list */ - public static function lowerProperty(Stmt\Property $property): array + public static function lowerProperty(Stmt\Property $property, bool $classReadonly = false): array { + self::validatePropertySemantics($property, $property->hooks !== [], $property->isReadonly() || $classReadonly); $setter = CompileTimeAttribute::consume($property, 'Setter'); $with = CompileTimeAttribute::consume($property, 'With'); if (!$setter && !$with) { @@ -48,21 +50,22 @@ final class PropertyMethodLowering foreach ($property->props as $prop) { $name = $prop->name->toString(); if ($setter) { - $methods[] = self::createSetter($name, $property->type, $property->getAttributes()); + $methods[] = self::createSetter($name, $property->type, $property); } if ($with) { - $methods[] = self::createWith($name, $property->type, $property->getAttributes()); + $methods[] = self::createWith($name, $property->type, $property); } } return $methods; } /** @return list */ - public static function lowerPromotedProperty(Param $param): array + public static function lowerPromotedProperty(Param $param, bool $classReadonly = false): array { if (!$param->isPromoted() || !is_string($param->var->name)) { return []; } + self::validatePropertySemantics($param, $param->hooks !== [], $param->isReadonly() || $classReadonly); $setter = CompileTimeAttribute::consume($param, 'Setter'); $with = CompileTimeAttribute::consume($param, 'With'); if (!$setter && !$with) { @@ -71,17 +74,32 @@ final class PropertyMethodLowering $methods = []; if ($setter) { - $methods[] = self::createSetter($param->var->name, $param->type, $param->getAttributes()); + $methods[] = self::createSetter($param->var->name, $param->type, $param); } if ($with) { - $methods[] = self::createWith($param->var->name, $param->type, $param->getAttributes()); + $methods[] = self::createWith($param->var->name, $param->type, $param); } return $methods; } - private static function createSetter(string $property, ?Node $type, array $attributes): Stmt\ClassMethod + private static function validatePropertySemantics(Node $node, bool $hasHooks, bool $readonly): void { - return new Stmt\ClassMethod('set' . ucfirst($property), [ + foreach (self::ATTRIBUTES as $attribute) { + if (!CompileTimeAttribute::has($node, $attribute)) { + continue; + } + if ($hasHooks) { + throw new SyntaxError($attribute . ' cannot be applied to properties with hooks'); + } + if ($readonly) { + throw new SyntaxError($attribute . ' cannot be applied to readonly properties'); + } + } + } + + private static function createSetter(string $property, ?Node $type, Node $target): Stmt\ClassMethod + { + $method = new Stmt\ClassMethod('set' . ucfirst($property), [ 'flags' => Modifiers::PUBLIC, 'params' => [new Param(new Expr\Variable($property), type: $type === null ? null : clone $type)], 'returnType' => new Node\Identifier('void'), @@ -89,12 +107,14 @@ final class PropertyMethodLowering new Expr\PropertyFetch(new Expr\Variable('this'), $property), new Expr\Variable($property), ))], - ], $attributes); + ], $target->getAttributes()); + CompileTimeAttributeDiagnostic::markGenerated($method, 'Setter', $target); + return $method; } - private static function createWith(string $property, ?Node $type, array $attributes): Stmt\ClassMethod + private static function createWith(string $property, ?Node $type, Node $target): Stmt\ClassMethod { - return new Stmt\ClassMethod('with' . ucfirst($property), [ + $method = new Stmt\ClassMethod('with' . ucfirst($property), [ 'flags' => Modifiers::PUBLIC, 'params' => [new Param(new Expr\Variable($property), type: $type === null ? null : clone $type)], 'returnType' => new Node\Name('static'), @@ -109,6 +129,8 @@ final class PropertyMethodLowering )), new Stmt\Return_(new Expr\Variable('clone')), ], - ], $attributes); + ], $target->getAttributes()); + CompileTimeAttributeDiagnostic::markGenerated($method, 'With', $target); + return $method; } } diff --git a/src/Transform/ValidateLowering.php b/src/Transform/ValidateLowering.php new file mode 100644 index 00000000..10827912 --- /dev/null +++ b/src/Transform/ValidateLowering.php @@ -0,0 +1,219 @@ +var->name) ? $param->var->name : ''; + [$filter, $options, $message] = self::parseArguments($parameter, $attribute); + self::assertParameterTypeCompatible($param, $filter, $options); + $call = new Expr\FuncCall(new Node\Name\FullyQualified('filter_var'), [ + new Node\Arg(new Expr\Variable($parameter)), + new Node\Arg(new Node\Scalar\Int_($filter)), + new Node\Arg(self::withNullOnFailure($options)), + ]); + + return new Stmt\If_(new Expr\BinaryOp\Identical($call, new Expr\ConstFetch(new Node\Name('null'))), [ + 'stmts' => [new Stmt\Expression(new Expr\Throw_(new Expr\New_( + new Node\Name\FullyQualified('ValueError'), + [new Node\Arg($message)], + )))], + ]); + } + + /** @return array{int, Expr, Expr} */ + private static function parseArguments(string $parameter, Node\Attribute $attribute): array + { + $values = []; + $positions = ['filter', 'options', 'message']; + foreach ($attribute->args as $index => $arg) { + $name = $arg->name?->toString() ?? ($positions[$index] ?? null); + if ($name === null || !in_array($name, $positions, true)) { + throw new SyntaxError('Validate has an unknown argument'); + } + if (isset($values[$name])) { + throw new SyntaxError('Validate argument $' . $name . ' is specified more than once'); + } + $values[$name] = $arg->value; + } + if (!isset($values['filter'])) { + throw new SyntaxError('Validate requires the $filter argument'); + } + + $filter = self::resolveFilter($values['filter']); + if (!in_array($filter, self::validationFilters(), true)) { + throw new SyntaxError('Validate only accepts FILTER_VALIDATE_* filters'); + } + $options = isset($values['options']) ? clone $values['options'] : new Node\Scalar\Int_(0); + if (!$options instanceof Node\Scalar\Int_ && !$options instanceof Expr\Array_ + && !$options instanceof Expr\ConstFetch + && !$options instanceof Expr\BinaryOp\BitwiseOr) { + throw new SyntaxError('Validate $options must be an integer flag or an array literal'); + } + $defaultMessage = new Node\Scalar\String_('Parameter $' . $parameter . ' is invalid'); + $message = isset($values['message']) ? clone $values['message'] : $defaultMessage; + if ($message instanceof Expr\ConstFetch && $message->name->toLowerString() === 'null') { + $message = $defaultMessage; + } + if (!$message instanceof Node\Scalar\String_ && !$message instanceof Expr\ConstFetch + && !$message instanceof Expr\ClassConstFetch) { + throw new SyntaxError('Validate $message must be a string or null'); + } + + return [$filter, $options, $message]; + } + + private static function assertParameterTypeCompatible(Param $param, int $filter, Expr $options): void + { + if ($param->type === null || self::typeMayPassFilter($param->type, $filter, self::resolveFlags($options))) { + return; + } + $name = is_string($param->var->name) ? $param->var->name : ''; + throw new SyntaxError( + 'Validate filter ' . self::filterName($filter) . ' is incompatible with parameter `$' . + $name . '` declared as `' . $param->type->toString() . '`', + ); + } + + private static function typeMayPassFilter(Node $type, int $filter, ?int $flags): bool + { + if ($type instanceof NullableType) { + return self::typeMayPassFilter($type->type, $filter, $flags); + } + if ($type instanceof UnionType || $type instanceof IntersectionType) { + foreach ($type->types as $member) { + if (self::typeMayPassFilter($member, $filter, $flags)) { + return true; + } + } + return false; + } + if (!$type instanceof Node\Identifier) { + // Named object types may implement __toString(); without full class + // resolution they are not provably incompatible with filter_var(). + return true; + } + + $name = strtolower($type->name); + if ($name === 'array') { + return $flags === null + || (bool) ($flags & (FILTER_REQUIRE_ARRAY | FILTER_FORCE_ARRAY)); + } + if (!in_array($name, ['int', 'float', 'bool', 'true', 'false', 'null'], true)) { + return true; + } + return !in_array($filter, self::stringShapeFilters(), true); + } + + /** @return list */ + private static function stringShapeFilters(): array + { + return array_values(array_filter([ + defined('FILTER_VALIDATE_EMAIL') ? FILTER_VALIDATE_EMAIL : null, + defined('FILTER_VALIDATE_URL') ? FILTER_VALIDATE_URL : null, + defined('FILTER_VALIDATE_IP') ? FILTER_VALIDATE_IP : null, + defined('FILTER_VALIDATE_MAC') ? FILTER_VALIDATE_MAC : null, + ], static fn ($value): bool => is_int($value))); + } + + private static function resolveFlags(Expr $options): ?int + { + if ($options instanceof Node\Scalar\Int_) { + return $options->value; + } + if ($options instanceof Expr\ConstFetch) { + $name = ltrim($options->name->toString(), '\\'); + return defined($name) && is_int(constant($name)) ? constant($name) : null; + } + if ($options instanceof Expr\BinaryOp\BitwiseOr) { + $left = self::resolveFlags($options->left); + $right = self::resolveFlags($options->right); + return $left === null || $right === null ? null : $left | $right; + } + if (!$options instanceof Expr\Array_) { + return null; + } + foreach ($options->items as $item) { + if ($item?->key instanceof Node\Scalar\String_ && $item->key->value === 'flags') { + return self::resolveFlags($item->value); + } + } + return 0; + } + + private static function filterName(int $filter): string + { + foreach (get_defined_constants(true)['filter'] ?? [] as $name => $value) { + if ($value === $filter && str_starts_with($name, 'FILTER_VALIDATE_')) { + return $name; + } + } + return (string) $filter; + } + + private static function resolveFilter(Expr $expr): int + { + if ($expr instanceof Node\Scalar\Int_) { + return $expr->value; + } + if ($expr instanceof Expr\ConstFetch) { + $name = ltrim($expr->name->toString(), '\\'); + if (defined($name) && is_int(constant($name))) { + return constant($name); + } + } + throw new SyntaxError('Validate $filter must be a FILTER_VALIDATE_* constant'); + } + + /** @return list */ + private static function validationFilters(): array + { + $filters = []; + foreach (get_defined_constants(true)['filter'] ?? [] as $name => $value) { + if (str_starts_with($name, 'FILTER_VALIDATE_') && is_int($value)) { + $filters[] = $value; + } + } + return array_values(array_unique($filters)); + } + + private static function withNullOnFailure(Expr $options): Expr + { + $flag = new Node\Scalar\Int_(FILTER_NULL_ON_FAILURE); + if (!$options instanceof Expr\Array_) { + return new Expr\BinaryOp\BitwiseOr($options, $flag); + } + $flagsItem = null; + foreach ($options->items as $item) { + if ($item?->unpack) { + throw new SyntaxError('Validate $options does not support array unpacking'); + } + if ($item !== null && $item->key instanceof Node\Scalar\String_ && $item->key->value === 'flags') { + $flagsItem = $item; + } + } + if ($flagsItem !== null) { + $flagsItem->value = new Expr\BinaryOp\BitwiseOr($flagsItem->value, $flag); + return $options; + } + $options->items[] = new Expr\ArrayItem($flag, new Node\Scalar\String_('flags')); + return $options; + } +} diff --git a/src/Transform/Visitor.php b/src/Transform/Visitor.php index f61131c1..c097143d 100644 --- a/src/Transform/Visitor.php +++ b/src/Transform/Visitor.php @@ -8,32 +8,42 @@ namespace TypePhp\Transform; +use Closure; use PhpParser\Node; use PhpParser\Node\Stmt; use PhpParser\NodeVisitorAbstract; +use TypePhp\Exception\CompileTimeAttributeError; +use TypePhp\Exception\SyntaxError; +use TypePhp\Diagnostics\CompileTimeAttributeDiagnostic; class Visitor extends NodeVisitorAbstract { - /** @param null|callable(Stmt\Class_): bool $printerPredicate */ - public function __construct(private $printerPredicate = null) - { + /** @param null|Closure(Node, string): void $warning */ + public function __construct( + private readonly ?Closure $warning = null, + private readonly string $sourceFile = '', + ) { } public function enterNode(Node $node): null { - GetterLowering::validateTarget($node); - PropertyMethodLowering::validateTarget($node); - NotNullLowering::validateTarget($node); - PrinterLowering::validateTarget($node); + $this->guard($node, static fn () => CompileTimeAttribute::validateNode($node)); + $this->guard($node, static fn () => FunctionAttributeLowering::lower($node)); + $this->guard($node, static fn () => GetterLowering::validateTarget($node), 'Getter'); + $this->guard($node, static fn () => PropertyMethodLowering::validateTarget($node)); + $this->guard($node, static fn () => ConstructorLowering::validateTarget($node), 'Constructor'); return null; } public function leaveNode(Node $node): null { if ($node instanceof Stmt\Function_ || $node instanceof Stmt\ClassMethod || $node instanceof Node\Expr\Closure) { - NotNullLowering::lowerFunction($node); + $this->guard( + $node, + fn () => ParameterValidationLowering::lowerFunction($node, $this->warning), + ); } elseif ($node instanceof Node\Expr\ArrowFunction) { - NotNullLowering::rejectArrowFunction($node); + $this->guard($node, static fn () => ParameterValidationLowering::rejectArrowFunction($node)); } if (!$node instanceof Stmt\Class_ && !$node instanceof Stmt\Trait_ && !$node instanceof Stmt\Enum_) { @@ -41,34 +51,107 @@ class Visitor extends NodeVisitorAbstract } $methods = []; + $classReadonly = $node instanceof Stmt\Class_ && $node->isReadonly(); foreach ($node->stmts as $stmt) { if ($stmt instanceof Stmt\Property) { array_push($methods, ...PropertyHookLowering::lowerProperty($stmt)); - array_push($methods, ...GetterLowering::lowerProperty($stmt)); - array_push($methods, ...PropertyMethodLowering::lowerProperty($stmt)); + array_push($methods, ...$this->guard( + $stmt, + static fn () => GetterLowering::lowerProperty($stmt), + 'Getter', + )); + array_push($methods, ...$this->guard( + $stmt, + static fn () => PropertyMethodLowering::lowerProperty($stmt, $classReadonly), + )); } elseif ($stmt instanceof Stmt\ClassMethod && $stmt->name->toLowerString() === '__construct') { foreach ($stmt->params as $param) { $marker = PropertyHookLowering::lowerPromotedProperty($param); if ($marker !== null) { $methods[] = $marker; } - $getter = GetterLowering::lowerPromotedProperty($param); + $getter = $this->guard( + $param, + static fn () => GetterLowering::lowerPromotedProperty($param), + 'Getter', + ); if ($getter !== null) { $methods[] = $getter; } - array_push($methods, ...PropertyMethodLowering::lowerPromotedProperty($param)); + array_push($methods, ...$this->guard( + $param, + static fn () => PropertyMethodLowering::lowerPromotedProperty($param, $classReadonly), + )); } } } if ($methods !== []) { array_push($node->stmts, ...$methods); } + $this->guard($node, static fn () => ConstructorLowering::lowerClassLike($node), 'Constructor'); if ($node instanceof Stmt\Class_) { - if (CompileTimeAttribute::has($node, 'Printer')) { - $generate = $this->printerPredicate === null || ($this->printerPredicate)($node); - PrinterLowering::lowerClass($node, $generate); + if (CompileTimeAttribute::find($node, 'Printer') !== null) { + $this->guard($node, static fn () => PrinterLowering::lowerClass($node), 'Printer'); + } + if (CompileTimeAttribute::find($node, 'Arrayable') !== null) { + $this->guard($node, static fn () => ArrayableLowering::lowerClass($node), 'Arrayable'); } } return null; } + + private function guard(Node $target, Closure $operation, ?string $attribute = null): mixed + { + try { + return $operation(); + } catch (SyntaxError $error) { + if (str_contains($error->getMessage(), '[compile-time attribute:')) { + throw $error; + } + $source = $target; + $conflictAttribute = null; + $conflictSource = null; + if ($error instanceof CompileTimeAttributeError) { + $target = $error->target; + $attribute = $error->attribute ?? $attribute; + $source = $error->attributeSource ?? $target; + $conflictAttribute = $error->conflictAttribute; + $conflictSource = $error->conflictSource; + } else { + [$detected, $attributeSource] = $this->detectAttribute($target); + $attribute ??= $detected; + $source = $attributeSource ?? $target; + } + + $attribute ??= 'unknown'; + $file = $this->sourceFile !== '' ? $this->sourceFile : ''; + throw new SyntaxError(CompileTimeAttributeDiagnostic::format( + $error->getMessage(), + $attribute, + $target, + $file, + $source, + $conflictAttribute, + $conflictSource, + ), 0, $error); + } + } + + /** @return array{?string, ?Node} */ + private function detectAttribute(Node $node): array + { + if (!property_exists($node, 'attrGroups')) { + return [null, null]; + } + foreach ($node->attrGroups as $group) { + foreach ($group->attrs as $attribute) { + $definition = CompileTimeAttributeRegistry::get(CompileTimeAttribute::resolvedName($attribute)); + if ($definition !== null) { + return [$definition['name'], $attribute]; + } + } + } + return [null, null]; + } + } diff --git a/src/Translator.php b/src/Translator.php index f1b8ce3e..e4b2e6d4 100644 --- a/src/Translator.php +++ b/src/Translator.php @@ -19,6 +19,7 @@ use TypePhp\Build\NativeBuilder; use TypePhp\Build\PrecompiledHeaderManager; use TypePhp\Build\SourcePipelineTrait; use TypePhp\Config\ProjectYamlLoader; +use TypePhp\Diagnostics\CompileTimeAttributeDiagnostic; use TypePhp\Build\ResourceCompilationTrait; use TypePhp\Entity\ArgInfo; use TypePhp\Entity\ClassDef; @@ -30,6 +31,7 @@ use TypePhp\Entity\MethodDef; use TypePhp\Entity\PropertyDef; use TypePhp\Exception\Redo; use TypePhp\Exception\Skip; +use TypePhp\Exception\SyntaxError; use TypePhp\Generator\DefaultArgumentGenerator; use TypePhp\Generator\LibraryImportStubGenerator; use TypePhp\Generator\Symbol; @@ -39,6 +41,7 @@ use TypePhp\Platform\Windows; use TypePhp\Resolver\Reflection; use TypePhp\Resolver\ClassConstantValueTrait; use TypePhp\Transform\Visitor; +use TypePhp\Transform\ConstructorLowering; use PhpParser\Modifiers; use PhpParser\Node; use PhpParser\NodeAbstract; @@ -1561,7 +1564,9 @@ CODE; { $code = '#pragma once' . PHP_EOL . PHP_EOL; $code .= '#include ' . PHP_EOL; + $code .= '#include ' . PHP_EOL; $code .= '#include ' . PHP_EOL; + $code .= PHP_EOL; if ($this->isBuildModeLib()) { $code .= $this->genLibraryApiMacro($this->targetName); @@ -1600,10 +1605,11 @@ CODE; } } $params = implode(', ', $list); - $code .= $functionDeclarationPrefix . ($func->returnsByRef ? Type::REF : $func->returnType) . ' ' . self::PREFIX . $name . '(' . $params . ');' . PHP_EOL; + $functionAttribute = $this->getFunctionOptimizationAttribute($func); + $code .= $functionDeclarationPrefix . $functionAttribute . ($func->returnsByRef ? Type::REF : $func->returnType) . ' ' . self::PREFIX . $name . '(' . $params . ');' . PHP_EOL; if ($func->hasMultiReturn()) { $code .= 'namespace ' . self::MULTI_RETURN_NAMESPACE . ' {' . PHP_EOL; - $code .= $functionDeclarationPrefix . $func->getMultiReturnCppType() . ' ' . self::PREFIX . $name . '(' . $params . ');' . PHP_EOL; + $code .= $functionDeclarationPrefix . $functionAttribute . $func->getMultiReturnCppType() . ' ' . self::PREFIX . $name . '(' . $params . ');' . PHP_EOL; $code .= '}' . PHP_EOL; } } @@ -1620,29 +1626,17 @@ CODE; { $apiMacro = $this->getNamedLibraryApiMacroName($library); $exportsMacro = $this->getNamedLibraryExportsMacroName($library); - $code = "#if defined(_WIN32)\n"; - $code .= "# if defined({$exportsMacro})\n"; - $code .= "# define {$apiMacro} __declspec(dllexport)\n"; - $code .= "# else\n"; - $code .= "# define {$apiMacro} __declspec(dllimport)\n"; - $code .= "# endif\n"; - $code .= "#elif defined(__GNUC__) && __GNUC__ >= 4\n"; - $code .= "# define {$apiMacro} __attribute__((visibility(\"default\")))\n"; + $code = "#if defined({$exportsMacro})\n"; + $code .= "# define {$apiMacro} TYPEPHP_SYMBOL_EXPORT\n"; $code .= "#else\n"; - $code .= "# define {$apiMacro}\n"; + $code .= "# define {$apiMacro} TYPEPHP_SYMBOL_IMPORT\n"; return $code . "#endif\n\n"; } protected function genLibraryImportMacro(string $library): string { $importMacro = $this->getNamedLibraryImportMacroName($library); - $code = "#if defined(_WIN32)\n"; - $code .= "# define {$importMacro} __declspec(dllimport)\n"; - $code .= "#elif defined(__GNUC__) && __GNUC__ >= 4\n"; - $code .= "# define {$importMacro} __attribute__((visibility(\"default\")))\n"; - $code .= "#else\n"; - $code .= "# define {$importMacro}\n"; - return $code . "#endif\n\n"; + return "#define {$importMacro} TYPEPHP_SYMBOL_IMPORT\n\n"; } protected function getFunctionDeclarationPrefix(FunctionDef $function): string @@ -1656,6 +1650,17 @@ CODE; return 'extern '; } + protected function getFunctionOptimizationAttribute(FunctionDef $function): string + { + if ($function->hot) { + return 'TYPEPHP_HOT_ATTRIBUTE '; + } + if ($function->cold) { + return 'TYPEPHP_COLD_ATTRIBUTE '; + } + return ''; + } + protected function isImportedFunction(FunctionDef $function): bool { return $function->importLibrary !== ''; @@ -2287,10 +2292,7 @@ CODE; $ast = $this->parser->parse($phpCode); $traverser = new NodeTraverser(); $traverser->addVisitor(new NameResolver(null, ['replaceNodes' => false])); - $traverser->addVisitor(new Visitor(function (Node\Stmt\Class_ $class): bool { - $name = isset($class->namespacedName) ? $class->namespacedName->toString() : $class->name->toString(); - return $this->shouldGeneratePrinter($name); - })); + $traverser->addVisitor(new Visitor(sourceFile: $this->file)); $stmts = $traverser->traverse($ast); @@ -2328,6 +2330,7 @@ CODE; $this->parseConstDef($v); break; case 'Stmt_Interface': + $this->validateInterfaceOverrideAttributes($v); break; case 'Stmt_Nop': break; @@ -2490,6 +2493,7 @@ CODE; $this->parseGroupUse($v2); break; case 'Stmt_Interface': + $this->validateInterfaceOverrideAttributes($v2); break; default: abort($v2); @@ -2864,6 +2868,109 @@ CODE; return $typeNode ? $this->typeNodeToString($typeNode) : null; } + private function configureGeneratedConstructorParentCall(Node\Stmt\Class_ $class): void + { + $constructor = null; + foreach ($class->getMethods() as $method) { + if ($method->getAttribute(ConstructorLowering::GENERATED_ATTRIBUTE, false)) { + $constructor = $method; + break; + } + } + if ($constructor === null || $this->classDef->extends === '') { + return; + } + + $parent = $this->classDef->extends; + while ($parent !== '') { + $parentDef = $this->getClassDef($parent); + if ($parentDef === null) { + $reflection = Reflection::getClass($parent); + $parentConstructor = $reflection?->getConstructor(); + if ($parentConstructor === null) { + return; + } + $owner = $parentConstructor->getDeclaringClass()->getName(); + $this->applyGeneratedConstructorParentRule( + $constructor, + $owner, + $parentConstructor->getModifiers(), + $parentConstructor->getNumberOfRequiredParameters(), + $parentConstructor->isAbstract(), + ); + return; + } + + if ($parentDef->hasMethod('__construct')) { + $parentConstructor = $parentDef->getMethod('__construct'); + $this->applyGeneratedConstructorParentRule( + $constructor, + $parent, + $parentConstructor->flags, + $parentConstructor->functionDef?->argCountRequired ?? 0, + false, + ); + return; + } + if ($parentDef->hasAbstractMethod('__construct')) { + $parentConstructor = $parentDef->getAbstractMethod('__construct'); + $this->applyGeneratedConstructorParentRule( + $constructor, + $parent, + $parentConstructor->flags, + $parentConstructor->functionDef?->argCountRequired ?? 0, + true, + ); + return; + } + $parent = $parentDef->extends; + } + } + + private function applyGeneratedConstructorParentRule( + Node\Stmt\ClassMethod $constructor, + string $parent, + int $flags, + int $requiredArguments, + bool $abstract, + ): void { + $attributeTarget = $constructor->getAttribute( + \TypePhp\Diagnostics\CompileTimeAttributeDiagnostic::GENERATED_TARGET, + $constructor, + ); + if (!$attributeTarget instanceof Node) { + $attributeTarget = $constructor; + } + if ($flags & Modifiers::FINAL) { + $this->fatalCompileTimeAttribute( + $attributeTarget, + 'Constructor', + "Cannot override final method `{$parent}::__construct()`", + $attributeTarget, + ); + } + if ($flags & Modifiers::PRIVATE) { + return; + } + if ($requiredArguments > 0) { + $this->fatalCompileTimeAttribute( + $attributeTarget, + 'Constructor', + "Constructor cannot be generated because parent constructor `{$parent}::__construct()` " . + "requires {$requiredArguments} argument(s); declare `__construct()` explicitly", + $attributeTarget, + ); + } + if ($abstract) { + return; + } + + array_unshift($constructor->stmts, new Node\Stmt\Expression(new Node\Expr\StaticCall( + new Node\Name('parent'), + new Node\Identifier('__construct'), + ))); + } + protected function parseClass(Node\Stmt\Class_|Node\Stmt\Trait_|Node\Stmt\Enum_ $class): string { $this->class = $this->parseIdentifier($class->name); @@ -2872,12 +2979,59 @@ CODE; $this->fatalError($class, "class {$fullName} not found"); } $this->classDef = $this->getClass($fullName); - $this->parseExtensionProviderTarget($class); + $this->parseMethodsForTarget($class); + + if ($class instanceof Node\Stmt\Class_) { + $this->configureGeneratedConstructorParentCall($class); + } if ($class instanceof Node\Stmt\Class_ && $this->classDef->printerGenerated) { + $available = [...$this->parentPublicProperties($this->classDef->extends), ...\TypePhp\Transform\ClassFieldSelection::ownPublicProperties($class)]; + try { + $properties = \TypePhp\Transform\ClassFieldSelection::resolve( + $this->classDef->printerFields, + $this->classDef->printerFields === null + ? $available + : $this->selectableProperties($this->classDef), + 'Printer', + ); + } catch (SyntaxError $error) { + throw new SyntaxError(CompileTimeAttributeDiagnostic::format( + $error->getMessage(), + 'Printer', + $class, + $this->file, + ), 0, $error); + } \TypePhp\Transform\PrinterLowering::rebuildGeneratedMethod( $class, - [...$this->parentPublicProperties($this->classDef->extends), ...\TypePhp\Transform\PrinterLowering::ownPublicProperties($class)], + $properties, + $this->classDef->printerFields, + $this->classStringProperties($this->classDef), + ); + } + if ($class instanceof Node\Stmt\Class_ && $this->classDef->arrayableGenerated) { + $available = [...$this->parentPublicProperties($this->classDef->extends), ...\TypePhp\Transform\ClassFieldSelection::ownPublicProperties($class)]; + try { + $properties = \TypePhp\Transform\ClassFieldSelection::resolve( + $this->classDef->arrayableFields, + $this->classDef->arrayableFields === null + ? $available + : $this->selectableProperties($this->classDef), + 'Arrayable', + ); + } catch (SyntaxError $error) { + throw new SyntaxError(CompileTimeAttributeDiagnostic::format( + $error->getMessage(), + 'Arrayable', + $class, + $this->file, + ), 0, $error); + } + \TypePhp\Transform\ArrayableLowering::rebuildGeneratedMethod( + $class, + $properties, + $this->classDef->arrayableFields, ); } @@ -2932,6 +3086,7 @@ CODE; } } if (!$class instanceof Node\Stmt\Trait_) { + $this->validateOverrideAttributes($class); $this->checkInterfaceImplementations($class); $this->checkInheritedAbstractMethodsAreImplemented($class); } @@ -3284,7 +3439,8 @@ CODE; ? $this->functionDef->getMultiReturnCppType() : ($this->functionDef->returnsByRef ? Type::REF : $this->getReturnType()); $nativeName = self::PREFIX . $name; - $functionDeclCode = $cppReturnType . ' ' . ($multiReturn ? $this->getMultiReturnImplName($name) : $nativeName) . '('; + $functionAttribute = $this->getFunctionOptimizationAttribute($this->functionDef); + $functionDeclCode = $functionAttribute . $cppReturnType . ' ' . ($multiReturn ? $this->getMultiReturnImplName($name) : $nativeName) . '('; if ($this->class) { $functionDeclCode .= Type::OBJECT . ' &this_'; if ($this->classDef?->trait !== null && $this->methodDef?->parentMethodCalls) { @@ -3340,7 +3496,7 @@ CODE; : $argInfo->name, $this->functionDef->argInfoList, )); - $code .= Type::ARRAY . ' ' . $nativeName . '(' . $this->functionDef->params . ') {' . PHP_EOL; + $code .= $functionAttribute . Type::ARRAY . ' ' . $nativeName . '(' . $this->functionDef->params . ') {' . PHP_EOL; $this->indentLevel++; $code .= $this->getIndent() . 'return ' . Type::ARRAY . '(' . $this->getMultiReturnImplName($name) . '(' . $forwardArgs . '));' . PHP_EOL; $this->indentLevel--; @@ -3384,15 +3540,17 @@ CODE; $methodDef = $classDef->getMethod($name); if ($methodDef->flags & Modifiers::PRIVATE) { _error: + $message = 'Cannot override private method `' . $extends . '::' . $name . '()`'; + $this->fatalGeneratedMethodAttributeIfAny($v, $message, $extends, $name); $this->fatalError($v, - 'Cannot override private method `' . - $extends . '::' . $name . '()`'); + $message); } if ($methodDef->flags & Modifiers::FINAL) { _final_error: + $message = 'Cannot override final method `' . $extends . '::' . $name . '()`'; + $this->fatalGeneratedMethodAttributeIfAny($v, $message, $extends, $name); $this->fatalError($v, - 'Cannot override final method `' . - $extends . '::' . $name . '()`'); + $message); } $this->validateMethodOverrideSignature($v, $name, $this->methodDef, $methodDef, $extends); break; @@ -3429,6 +3587,24 @@ CODE; return; } + // MustUse is part of the callable contract. An override may strengthen + // this guarantee, but it must not drop one promised by a parent class + // or interface. + if ($parentFuncDef->mustUse && !$childFuncDef->mustUse) { + $message = "Declaration of `{$className}::{$methodName}()` must be compatible with " . + "`{$parentClass}::{$methodName}()`"; + $this->error(CompileTimeAttributeDiagnostic::formatPositions( + $message, + 'MustUse', + "method {$parentClass}::{$methodName}()", + $parentFuncDef->sourceFile, + $parentFuncDef->startLine, + 'override drops MustUse contract', + $this->file, + $v->getStartLine(), + )); + } + if (!$this->isReturnTypeOverrideCompatible($childFuncDef, $parentFuncDef)) { $this->fatalMethodOverrideIncompatible($v, $className, $methodName, $parentClass); } @@ -3474,9 +3650,44 @@ CODE; string $methodName, string $parentClass ): void { - $this->fatalError($v, - "Declaration of `{$className}::{$methodName}()` must be compatible " . - "with `{$parentClass}::{$methodName}()`"); + $message = "Declaration of `{$className}::{$methodName}()` must be compatible " . + "with `{$parentClass}::{$methodName}()`"; + $this->fatalGeneratedMethodAttributeIfAny($v, $message, $parentClass, $methodName); + $this->fatalError($v, $message); + } + + private function fatalGeneratedMethodAttributeIfAny( + NodeAbstract $method, + string $message, + string $parentClass, + string $methodName, + ): void { + $attribute = $method->getAttribute(CompileTimeAttributeDiagnostic::GENERATED_BY); + $target = $method->getAttribute(CompileTimeAttributeDiagnostic::GENERATED_TARGET); + if (!is_string($attribute) || !$target instanceof Node) { + return; + } + + $parentFunction = null; + $parentDef = $this->getClassDef($parentClass); + if ($parentDef instanceof ClassDef) { + if ($parentDef->hasMethod($methodName)) { + $parentFunction = $parentDef->getMethod($methodName)->functionDef; + } elseif ($parentDef->hasAbstractMethod($methodName) + && isset($parentDef->abstractMethodDefs[strtolower($methodName)])) { + $parentFunction = $parentDef->getAbstractMethod($methodName)->functionDef; + } + } + $this->error(CompileTimeAttributeDiagnostic::formatPositions( + $message, + $attribute, + CompileTimeAttributeDiagnostic::describeTarget($target), + $this->file, + $target->getStartLine(), + $parentFunction === null ? null : 'parent method', + $parentFunction?->sourceFile, + $parentFunction?->startLine, + )); } private function isReturnTypeOverrideCompatible(FunctionDef $childFuncDef, FunctionDef $parentFuncDef): bool @@ -3628,6 +3839,130 @@ CODE; } } + private function validateOverrideAttributes(Node\Stmt\Class_|Node\Stmt\Enum_ $classStmt): void + { + $methods = [...$this->classDef->methods, ...$this->classDef->abstractMethodDefs]; + foreach ($methods as $methodDef) { + if (!$methodDef->functionDef?->overrideRequired) { + continue; + } + if ($this->hasMatchingOverrideDeclaration($this->classDef, $methodDef->name)) { + continue; + } + $this->fatalMissingOverride( + $methodDef->node ?? $classStmt, + $this->classDef->getNamespacedName(false), + $methodDef->name, + ); + } + } + + private function hasMatchingOverrideDeclaration(ClassDef $classDef, string $methodName): bool + { + if (strtolower($methodName) === '__construct') { + return false; + } + + $current = $classDef; + while ($current->extends !== '') { + $parentName = $current->extends; + if ($current->inheritedFromInternalClass || $this->isInternalClass($parentName)) { + $modifiers = Reflection::getClassMethodModifiers($parentName, $methodName); + if ($modifiers !== null && !($modifiers & \ReflectionMethod::IS_PRIVATE)) { + return true; + } + break; + } + if (!$this->hasClass($parentName)) { + break; + } + $current = $this->getClass($parentName); + if ($current->hasMethod($methodName)) { + if (!($current->getMethod($methodName)->flags & Modifiers::PRIVATE)) { + return true; + } + } elseif ($current->hasAbstractMethod($methodName)) { + if (!($current->getMethodFlags($methodName) & Modifiers::PRIVATE)) { + return true; + } + } + } + + foreach ($this->getClassImplementedInterfaces($classDef) as $interfaceName) { + if ($this->isInternalInterface($interfaceName)) { + if (Reflection::hasMethod($interfaceName, $methodName)) { + return true; + } + continue; + } + if ($this->hasInterface($interfaceName) && $this->getInterface($interfaceName)->hasMethod($methodName)) { + return true; + } + } + return false; + } + + private function validateInterfaceOverrideAttributes(Node\Stmt\Interface_ $interfaceStmt): void + { + $name = $this->parseIdentifier($interfaceStmt->name); + $interfaceName = $this->namespace === '' ? $name : $this->namespace . '\\' . $name; + if (!$this->hasInterface($interfaceName)) { + return; + } + + $interfaceDef = $this->getInterface($interfaceName); + foreach ($interfaceDef->methods as $methodDef) { + if (!$methodDef->functionDef?->overrideRequired) { + continue; + } + $visited = []; + if ($this->interfaceParentsHaveMethod($interfaceDef, $methodDef->name, $visited)) { + continue; + } + $this->fatalMissingOverride($methodDef->node ?? $interfaceStmt, $interfaceName, $methodDef->name); + } + } + + private function fatalMissingOverride(NodeAbstract $node, string $className, string $methodName): never + { + $this->fatalCompileTimeAttribute( + $node, + 'Override', + "{$className}::{$methodName}() has #[\\Override] attribute, " . + 'but no matching parent method exists', + ); + } + + /** @param array $visited */ + private function interfaceParentsHaveMethod( + InterfaceDef $interfaceDef, + string $methodName, + array &$visited, + ): bool { + foreach ($interfaceDef->extendsList ?: ($interfaceDef->extends ? [$interfaceDef->extends] : []) as $parentName) { + $key = strtolower($parentName); + if (isset($visited[$key])) { + continue; + } + $visited[$key] = true; + if ($this->isInternalInterface($parentName)) { + if (Reflection::hasMethod($parentName, $methodName)) { + return true; + } + continue; + } + if (!$this->hasInterface($parentName)) { + continue; + } + $parent = $this->getInterface($parentName); + if ($parent->hasMethod($methodName) + || $this->interfaceParentsHaveMethod($parent, $methodName, $visited)) { + return true; + } + } + return false; + } + private function checkInterfaceImplementation(NodeAbstract $node, ClassDef $classDef, string $interfaceName): void { if ($this->isInternalInterface($interfaceName)) { @@ -3843,6 +4178,11 @@ CODE; // Keep the AST node so trait-composed methods can report accurate // line numbers when validated for override compatibility later. $this->methodDef->node = $v; + if ($this->classDef->trait === null + && $this->methodDef->functionDef?->overrideRequired + && !$this->hasMatchingOverrideDeclaration($this->classDef, $name)) { + $this->fatalMissingOverride($v, $this->classDef->getNamespacedName(false), $name); + } // 预处理阶段没有父类的信息,只能在实现阶段检查 $this->checkParentMethodCanBeOverridden($v, $name); $methodCodes[$name] = $this->parseFunction($v); diff --git a/src/gen_stub.php b/src/gen_stub.php index e77a3b58..343879e7 100755 --- a/src/gen_stub.php +++ b/src/gen_stub.php @@ -3584,9 +3584,7 @@ class AttributeInfo { foreach ($attrGroup->attrs as $attr) { $parts = $attr->name->getParts(); $compileTimeAttribute = count($parts) === 1 - && in_array(strtolower($parts[0]), [ - 'extensionprovider', 'getter', 'noexport', 'notnull', 'printer', 'setter', 'with', - ], true); + && TypePhp\Transform\CompileTimeAttributeRegistry::get($parts[0]) !== null; if ($compileTimeAttribute) { continue; } @@ -4513,13 +4511,7 @@ class FileInfo { null, ['preserveOriginalNames' => true] )); - $nodeTraverser->addVisitor(new TypePhp\Transform\Visitor(static function (Stmt\Class_ $class): bool { - if (!isset($GLOBALS['translator'])) { - return true; - } - $name = isset($class->namespacedName) ? $class->namespacedName->toString() : $class->name->toString(); - return getTranslator()->shouldGeneratePrinter($name); - })); + $nodeTraverser->addVisitor(new TypePhp\Transform\Visitor()); $prettyPrinter = new class extends Standard { protected function pName_FullyQualified(PhpParser\Node\Name\FullyQualified $node): string { return implode('\\', $node->getParts()); diff --git a/src/polyfills.php b/src/polyfills.php index c3d37e51..2919f6b5 100644 --- a/src/polyfills.php +++ b/src/polyfills.php @@ -7,7 +7,7 @@ */ #[Attribute(Attribute::TARGET_CLASS)] -final readonly class ExtensionProvider +final readonly class MethodsFor { public function __construct(public string $target) { @@ -37,6 +37,17 @@ final readonly class With #[Attribute(Attribute::TARGET_CLASS)] final readonly class Printer { + public function __construct(public ?array $fields = null) + { + } +} + +#[Attribute(Attribute::TARGET_CLASS)] +final readonly class Arrayable +{ + public function __construct(public ?array $fields = null) + { + } } #[Attribute(Attribute::TARGET_PARAMETER)] @@ -44,8 +55,44 @@ final readonly class NotNull { } +#[Attribute(Attribute::TARGET_PARAMETER)] +final readonly class NotEmpty +{ +} + +#[Attribute(Attribute::TARGET_PARAMETER)] +final readonly class Validate +{ + public function __construct( + public int $filter, + public int|array $options = 0, + public ?string $message = null, + ) { + } +} + +#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD)] +final readonly class MustUse +{ +} + +#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD)] +final readonly class Hot +{ +} + +#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD)] +final readonly class Cold +{ +} + +#[Attribute(Attribute::TARGET_PROPERTY)] +final readonly class Constructor +{ +} + /** - * Public compile-time type symbols shared by extension providers and std containers. + * Public compile-time type symbols shared by MethodsFor providers and std containers. * This root class is deliberately distinct from the compiler-internal TypePhp\Type. */ final class Type