From c3a257127e33250a9e05a5b48d38eec5606d72ff Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Fri, 4 Jul 2025 11:10:09 +0200 Subject: [PATCH 1/2] feat(extgen): add support for callable in parameters --- docs/extensions.md | 75 ++++++++++-- docs/fr/extensions.md | 48 ++++++++ internal/extgen/gofile.go | 17 +-- internal/extgen/gofile_test.go | 119 ++++++++++++++++++ internal/extgen/nodes.go | 23 ++-- internal/extgen/paramparser.go | 10 ++ internal/extgen/paramparser_test.go | 76 ++++++++++++ internal/extgen/templates/extension.c.tpl | 16 +-- internal/extgen/templates/extension.go.tpl | 2 +- internal/extgen/validator.go | 14 ++- internal/extgen/validator_test.go | 134 ++++++++++++++++++++- types.c | 10 ++ types.go | 44 +++++++ types.h | 5 + 14 files changed, 547 insertions(+), 46 deletions(-) diff --git a/docs/extensions.md b/docs/extensions.md index 2b91688809..f119ae5848 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -88,19 +88,20 @@ While some variable types have the same memory representation between C/PHP and This table summarizes what you need to know: | PHP type | Go type | Direct conversion | C to Go helper | Go to C helper | Class Methods Support | -| ------------------ | ----------------------------- | ----------------- | --------------------------------- | ---------------------------------- | --------------------- | -| `int` | `int64` | ✅ | - | - | ✅ | -| `?int` | `*int64` | ✅ | - | - | ✅ | -| `float` | `float64` | ✅ | - | - | ✅ | -| `?float` | `*float64` | ✅ | - | - | ✅ | -| `bool` | `bool` | ✅ | - | - | ✅ | -| `?bool` | `*bool` | ✅ | - | - | ✅ | -| `string`/`?string` | `*C.zend_string` | ❌ | `frankenphp.GoString()` | `frankenphp.PHPString()` | ✅ | -| `array` | `frankenphp.AssociativeArray` | ❌ | `frankenphp.GoAssociativeArray()` | `frankenphp.PHPAssociativeArray()` | ✅ | -| `array` | `map[string]any` | ❌ | `frankenphp.GoMap()` | `frankenphp.PHPMap()` | ✅ | -| `array` | `[]any` | ❌ | `frankenphp.GoPackedArray()` | `frankenphp.PHPPackedArray()` | ✅ | -| `mixed` | `any` | ❌ | `GoValue()` | `PHPValue()` | ❌ | -| `object` | `struct` | ❌ | _Not yet implemented_ | _Not yet implemented_ | ❌ | +|--------------------|-------------------------------|-------------------|-----------------------------------|------------------------------------|-----------------------| +| `int` | `int64` | ✅ | - | - | ✅ | +| `?int` | `*int64` | ✅ | - | - | ✅ | +| `float` | `float64` | ✅ | - | - | ✅ | +| `?float` | `*float64` | ✅ | - | - | ✅ | +| `bool` | `bool` | ✅ | - | - | ✅ | +| `?bool` | `*bool` | ✅ | - | - | ✅ | +| `string`/`?string` | `*C.zend_string` | ❌ | `frankenphp.GoString()` | `frankenphp.PHPString()` | ✅ | +| `array` | `frankenphp.AssociativeArray` | ❌ | `frankenphp.GoAssociativeArray()` | `frankenphp.PHPAssociativeArray()` | ✅ | +| `array` | `map[string]any` | ❌ | `frankenphp.GoMap()` | `frankenphp.PHPMap()` | ✅ | +| `array` | `[]any` | ❌ | `frankenphp.GoPackedArray()` | `frankenphp.PHPPackedArray()` | ✅ | +| `mixed` | `any` | ❌ | `GoValue()` | `PHPValue()` | ❌ | +| `callable` | `*C.zval` | ❌ | - | frankenphp.CallPHPCallable() | ❌ | +| `object` | `struct` | ❌ | _Not yet implemented_ | _Not yet implemented_ | ❌ | > [!NOTE] > @@ -212,6 +213,54 @@ func process_data_packed(arr *C.zval) unsafe.Pointer { - `frankenphp.GoMap(arr unsafe.Pointer) map[string]any` - Convert a PHP array to an unordered Go map - `frankenphp.GoPackedArray(arr unsafe.Pointer) []any` - Convert a PHP array to a Go slice +### Working with Callables + +FrankenPHP provides a way to work with PHP callables using the `frankenphp.CallPHPCallable` helper. This allows you to call PHP functions or methods from Go code. + +To showcase this, let's create our own `array_map()` function that takes a callable and an array, applies the callable to each element of the array, and returns a new array with the results: + +```go +// export_php:function my_array_map(array $data, callable $callback): array +func my_array_map(arr *C.zval, callback *C.zval) unsafe.Pointer { + goArr := frankenphp.GoArray(unsafe.Pointer(arr)) + result := &frankenphp.Array{} + + for i := uint32(0); i < goArr.Len(); i++ { + key, value := goArr.At(i) + + callbackResult := frankenphp.CallPHPCallable(unsafe.Pointer(callback), []interface{}{value}) + + if key.Type == frankenphp.PHPIntKey { + result.SetInt(key.Int, callbackResult) + } else { + result.SetString(key.Str, callbackResult) + } + } + + return frankenphp.PHPArray(result) +} +``` + +Notice how we use `frankenphp.CallPHPCallable()` to call the PHP callable passed as a parameter. This function takes a pointer to the callable and an array of arguments, and it returns the result of the callable execution. You can use the callable syntax you're used to: + +```php + 'hello', 'b' => 'world', 'c' => 'php']; +$result = my_array_map($strArray, 'strtoupper'); // $result will be ['a' => 'HELLO', 'b' => 'WORLD', 'c' => 'PHP'] + +$arr = [1, 2, 3, 4, [5, 6]]; +$result = my_array_map($arr, function($item) { + if (\is_array($item)) { + return my_array_map($item, function($subItem) { + return $subItem * 2; + }); + } + + return $item * 3; +}); // $result will be [3, 6, 9, 12, [10, 12]] +``` + ### Declaring a Native PHP Class The generator supports declaring **opaque classes** as Go structs, which can be used to create PHP objects. You can use the `//export_php:class` directive comment to define a PHP class. For example: diff --git a/docs/fr/extensions.md b/docs/fr/extensions.md index 01f2ad3e98..7d517da4fb 100644 --- a/docs/fr/extensions.md +++ b/docs/fr/extensions.md @@ -210,6 +210,54 @@ func process_data_packed(arr *C.zval) unsafe.Pointer { - `frankenphp.GoMap(arr unsafe.Pointer) map[string]any` - Convertir un tableau PHP vers une map Go non ordonnée - `frankenphp.GoPackedArray(arr unsafe.Pointer) []any` - Convertir un tableau PHP vers un slice Go +### Travailler avec des Callables + +FrankenPHP propose un moyen de travailler avec les _callables_ PHP grâce au helper `frankenphp.CallPHPCallable()`. Cela permet d’appeler des fonctions ou des méthodes PHP depuis du code Go. + +Pour illustrer cela, créons notre propre fonction `array_map()` qui prend un _callable_ et un tableau, applique le _callable_ à chaque élément du tableau, et retourne un nouveau tableau avec les résultats : + +```go +// export_php:function my_array_map(array $data, callable $callback): array +func my_array_map(arr *C.zval, callback *C.zval) unsafe.Pointer { + goArr := frankenphp.GoArray(unsafe.Pointer(arr)) + result := &frankenphp.Array{} + + for i := uint32(0); i < goArr.Len(); i++ { + key, value := goArr.At(i) + + callbackResult := frankenphp.CallPHPCallable(unsafe.Pointer(callback), []interface{}{value}) + + if key.Type == frankenphp.PHPIntKey { + result.SetInt(key.Int, callbackResult) + } else { + result.SetString(key.Str, callbackResult) + } + } + + return frankenphp.PHPArray(result) +} +``` + +Remarquez comment nous utilisons `frankenphp.CallPHPCallable()` pour appeler le _callable_ PHP passé en paramètre. Cette fonction prend un pointeur vers le _callable_ et un tableau d’arguments, et elle retourne le résultat de l’exécution du _callable_. Vous pouvez utiliser la syntaxe habituelle des _callables_ : + +```php + 'hello', 'b' => 'world', 'c' => 'php']; +$result = my_array_map($strArray, 'strtoupper'); // $result vaudra ['a' => 'HELLO', 'b' => 'WORLD', 'c' => 'PHP'] + +$arr = [1, 2, 3, 4, [5, 6]]; +$result = my_array_map($arr, function($item) { + if (\is_array($item)) { + return my_array_map($item, function($subItem) { + return $subItem * 2; + }); + } + + return $item * 3; +}); // $result vaudra [3, 6, 9, 12, [10, 12]] +``` + ### Déclarer une Classe PHP Native Le générateur prend en charge la déclaration de **classes opaques** comme structures Go, qui peuvent être utilisées pour créer des objets PHP. Vous pouvez utiliser la directive `//export_php:class` pour définir une classe PHP. Par exemple : diff --git a/internal/extgen/gofile.go b/internal/extgen/gofile.go index aa95bbcdc7..da015fe461 100644 --- a/internal/extgen/gofile.go +++ b/internal/extgen/gofile.go @@ -128,14 +128,15 @@ type GoParameter struct { Type string } -var phpToGoTypeMap = map[phpType]string{ - phpString: "string", - phpInt: "int64", - phpFloat: "float64", - phpBool: "bool", - phpArray: "*frankenphp.Array", - phpMixed: "any", - phpVoid: "", +var phpToGoTypeMap= map[phpType]string{ + phpString: "string", + phpInt: "int64", + phpFloat: "float64", + phpBool: "bool", + phpArray: "*frankenphp.Array", + phpMixed: "any", + phpVoid: "", + phpCallable: "*C.zval", } func (gg *GoFileGenerator) phpTypeToGoType(phpT phpType) string { diff --git a/internal/extgen/gofile_test.go b/internal/extgen/gofile_test.go index baca290aa7..754d95c0e1 100644 --- a/internal/extgen/gofile_test.go +++ b/internal/extgen/gofile_test.go @@ -703,6 +703,125 @@ func testGoFileExportedFunctions(t *testing.T, content string, functions []phpFu } } +func TestGoFileGenerator_MethodWrapperWithCallableParams(t *testing.T) { + tmpDir := t.TempDir() + + sourceContent := `package main + +import "C" + +//export_php:class CallableClass +type CallableStruct struct{} + +//export_php:method CallableClass::processCallback(callable $callback): string +func (cs *CallableStruct) ProcessCallback(callback *C.zval) string { + return "processed" +} + +//export_php:method CallableClass::processOptionalCallback(?callable $callback): string +func (cs *CallableStruct) ProcessOptionalCallback(callback *C.zval) string { + return "processed_optional" +}` + + sourceFile := filepath.Join(tmpDir, "test.go") + require.NoError(t, os.WriteFile(sourceFile, []byte(sourceContent), 0644)) + + methods := []phpClassMethod{ + { + Name: "ProcessCallback", + PhpName: "processCallback", + ClassName: "CallableClass", + Signature: "processCallback(callable $callback): string", + ReturnType: phpString, + Params: []phpParameter{ + {Name: "callback", PhpType: phpCallable, IsNullable: false}, + }, + GoFunction: `func (cs *CallableStruct) ProcessCallback(callback *C.zval) string { + return "processed" +}`, + }, + { + Name: "ProcessOptionalCallback", + PhpName: "processOptionalCallback", + ClassName: "CallableClass", + Signature: "processOptionalCallback(?callable $callback): string", + ReturnType: phpString, + Params: []phpParameter{ + {Name: "callback", PhpType: phpCallable, IsNullable: true}, + }, + GoFunction: `func (cs *CallableStruct) ProcessOptionalCallback(callback *C.zval) string { + return "processed_optional" +}`, + }, + } + + classes := []phpClass{ + { + Name: "CallableClass", + GoStruct: "CallableStruct", + Methods: methods, + }, + } + + generator := &Generator{ + BaseName: "callable_test", + SourceFile: sourceFile, + Classes: classes, + BuildDir: tmpDir, + } + + goGen := GoFileGenerator{generator} + content, err := goGen.buildContent() + require.NoError(t, err) + + expectedCallableWrapperSignature := "func ProcessCallback_wrapper(handle C.uintptr_t, callback *C.zval) unsafe.Pointer" + assert.Contains(t, content, expectedCallableWrapperSignature, "Generated content should contain callable wrapper signature: %s", expectedCallableWrapperSignature) + + expectedOptionalCallableWrapperSignature := "func ProcessOptionalCallback_wrapper(handle C.uintptr_t, callback *C.zval) unsafe.Pointer" + assert.Contains(t, content, expectedOptionalCallableWrapperSignature, "Generated content should contain optional callable wrapper signature: %s", expectedOptionalCallableWrapperSignature) + + expectedCallableCall := "structObj.ProcessCallback(callback)" + assert.Contains(t, content, expectedCallableCall, "Generated content should contain callable method call: %s", expectedCallableCall) + + expectedOptionalCallableCall := "structObj.ProcessOptionalCallback(callback)" + assert.Contains(t, content, expectedOptionalCallableCall, "Generated content should contain optional callable method call: %s", expectedOptionalCallableCall) + + assert.Contains(t, content, "//export ProcessCallback_wrapper", "Generated content should contain ProcessCallback export directive") + assert.Contains(t, content, "//export ProcessOptionalCallback_wrapper", "Generated content should contain ProcessOptionalCallback export directive") +} + +func TestGoFileGenerator_phpTypeToGoType(t *testing.T) { + generator := &Generator{} + goGen := GoFileGenerator{generator} + + tests := []struct { + phpType phpType + expected string + }{ + {phpString, "string"}, + {phpInt, "int64"}, + {phpFloat, "float64"}, + {phpBool, "bool"}, + {phpArray, "*frankenphp.Array"}, + {phpMixed, "any"}, + {phpVoid, ""}, + {phpCallable, "*C.zval"}, + } + + for _, tt := range tests { + t.Run(string(tt.phpType), func(t *testing.T) { + result := goGen.phpTypeToGoType(tt.phpType) + assert.Equal(t, tt.expected, result, "phpTypeToGoType(%s) should return %s", tt.phpType, tt.expected) + }) + } + + t.Run("unknown_type", func(t *testing.T) { + unknownType := phpType("unknown") + result := goGen.phpTypeToGoType(unknownType) + assert.Equal(t, "any", result, "phpTypeToGoType should fallback to interface{} for unknown types") + }) +} + func testGoFileInternalFunctions(t *testing.T, content string) { internalIndicators := []string{ "func internalHelper", diff --git a/internal/extgen/nodes.go b/internal/extgen/nodes.go index c57e595e5e..5afd1e38a0 100644 --- a/internal/extgen/nodes.go +++ b/internal/extgen/nodes.go @@ -9,17 +9,18 @@ import ( type phpType string const ( - phpString phpType = "string" - phpInt phpType = "int" - phpFloat phpType = "float" - phpBool phpType = "bool" - phpArray phpType = "array" - phpObject phpType = "object" - phpMixed phpType = "mixed" - phpVoid phpType = "void" - phpNull phpType = "null" - phpTrue phpType = "true" - phpFalse phpType = "false" + phpString phpType = "string" + phpInt phpType = "int" + phpFloat phpType = "float" + phpBool phpType = "bool" + phpArray phpType = "array" + phpObject phpType = "object" + phpMixed phpType = "mixed" + phpVoid phpType = "void" + phpNull phpType = "null" + phpTrue phpType = "true" + phpFalse phpType = "false" + phpCallable phpType = "callable" ) type phpFunction struct { diff --git a/internal/extgen/paramparser.go b/internal/extgen/paramparser.go index 8da8895e33..e9bad92138 100644 --- a/internal/extgen/paramparser.go +++ b/internal/extgen/paramparser.go @@ -70,6 +70,8 @@ func (pp *ParameterParser) generateSingleParamDeclaration(param phpParameter) [] } case phpArray, phpMixed: decls = append(decls, fmt.Sprintf("zval *%s = NULL;", param.Name)) + case "callable": + decls = append(decls, fmt.Sprintf("zval *%s_callback;", param.Name)) } return decls @@ -121,6 +123,8 @@ func (pp *ParameterParser) generateParamParsingMacro(param phpParameter) string return fmt.Sprintf("\n Z_PARAM_ARRAY_OR_NULL(%s)", param.Name) case phpMixed: return fmt.Sprintf("\n Z_PARAM_ZVAL_OR_NULL(%s)", param.Name) + case phpCallable: + return fmt.Sprintf("\n Z_PARAM_ZVAL_OR_NULL(%s_callback)", param.Name) default: return "" } @@ -138,6 +142,8 @@ func (pp *ParameterParser) generateParamParsingMacro(param phpParameter) string return fmt.Sprintf("\n Z_PARAM_ARRAY(%s)", param.Name) case phpMixed: return fmt.Sprintf("\n Z_PARAM_ZVAL(%s)", param.Name) + case phpCallable: + return fmt.Sprintf("\n Z_PARAM_ZVAL(%s_callback)", param.Name) default: return "" } @@ -168,6 +174,8 @@ func (pp *ParameterParser) generateSingleGoCallParam(param phpParameter) string return fmt.Sprintf("%s_is_null ? NULL : &%s", param.Name, param.Name) case phpBool: return fmt.Sprintf("%s_is_null ? NULL : &%s", param.Name, param.Name) + case phpCallable: + return fmt.Sprintf("%s_callback", param.Name) default: return param.Name } @@ -180,6 +188,8 @@ func (pp *ParameterParser) generateSingleGoCallParam(param phpParameter) string return fmt.Sprintf("(double) %s", param.Name) case phpBool: return fmt.Sprintf("(int) %s", param.Name) + case phpCallable: + return fmt.Sprintf("%s_callback", param.Name) default: return param.Name } diff --git a/internal/extgen/paramparser_test.go b/internal/extgen/paramparser_test.go index 5752c3a5c4..88f696c0cb 100644 --- a/internal/extgen/paramparser_test.go +++ b/internal/extgen/paramparser_test.go @@ -177,6 +177,29 @@ func TestParameterParser_GenerateParamDeclarations(t *testing.T) { }, expected: " zval *m = NULL;", }, + { + name: "callable parameter", + params: []phpParameter{ + {Name: "callback", PhpType: phpCallable, HasDefault: false}, + }, + expected: " zval *callback_callback;", + }, + { + name: "nullable callable parameter", + params: []phpParameter{ + {Name: "callback", PhpType: phpCallable, HasDefault: false, IsNullable: true}, + }, + expected: " zval *callback_callback;", + }, + { + name: "mixed types with callable", + params: []phpParameter{ + {Name: "data", PhpType: phpArray, HasDefault: false}, + {Name: "callback", PhpType: phpCallable, HasDefault: false}, + {Name: "options", PhpType: phpInt, HasDefault: true, DefaultValue: "0"}, + }, + expected: " zval *data = NULL;\n zval *callback_callback;\n zend_long options = 0;", + }, } for _, tt := range tests { @@ -292,6 +315,29 @@ func TestParameterParser_GenerateGoCallParams(t *testing.T) { }, expected: "name, items, (long) count", }, + { + name: "callable parameter", + params: []phpParameter{ + {Name: "callback", PhpType: "callable"}, + }, + expected: "callback_callback", + }, + { + name: "nullable callable parameter", + params: []phpParameter{ + {Name: "callback", PhpType: "callable", IsNullable: true}, + }, + expected: "callback_callback", + }, + { + name: "mixed parameters with callable", + params: []phpParameter{ + {Name: "data", PhpType: "array"}, + {Name: "callback", PhpType: "callable"}, + {Name: "limit", PhpType: "int"}, + }, + expected: "data, callback_callback, (long) limit", + }, } for _, tt := range tests { @@ -370,6 +416,16 @@ func TestParameterParser_GenerateParamParsingMacro(t *testing.T) { param: phpParameter{Name: "m", PhpType: phpMixed, IsNullable: true}, expected: "\n Z_PARAM_ZVAL_OR_NULL(m)", }, + { + name: "callable parameter", + param: phpParameter{Name: "callback", PhpType: phpCallable}, + expected: "\n Z_PARAM_ZVAL(callback_callback)", + }, + { + name: "nullable callable parameter", + param: phpParameter{Name: "callback", PhpType: phpCallable, IsNullable: true}, + expected: "\n Z_PARAM_ZVAL_OR_NULL(callback_callback)", + }, { name: "unknown type", param: phpParameter{Name: "unknown", PhpType: phpType("unknown")}, @@ -480,6 +536,16 @@ func TestParameterParser_GenerateSingleGoCallParam(t *testing.T) { param: phpParameter{Name: "items", PhpType: phpArray, IsNullable: true}, expected: "items", }, + { + name: "callable parameter", + param: phpParameter{Name: "callback", PhpType: "callable"}, + expected: "callback_callback", + }, + { + name: "nullable callable parameter", + param: phpParameter{Name: "callback", PhpType: "callable", IsNullable: true}, + expected: "callback_callback", + }, { name: "unknown type", param: phpParameter{Name: "unknown", PhpType: phpType("unknown")}, @@ -558,6 +624,16 @@ func TestParameterParser_GenerateSingleParamDeclaration(t *testing.T) { param: phpParameter{Name: "items", PhpType: phpArray, HasDefault: false, IsNullable: true}, expected: []string{"zval *items = NULL;"}, }, + { + name: "callable parameter", + param: phpParameter{Name: "callback", PhpType: "callable", HasDefault: false}, + expected: []string{"zval *callback_callback;"}, + }, + { + name: "nullable callable parameter", + param: phpParameter{Name: "callback", PhpType: "callable", HasDefault: false, IsNullable: true}, + expected: []string{"zval *callback_callback;"}, + }, } for _, tt := range tests { diff --git a/internal/extgen/templates/extension.c.tpl b/internal/extgen/templates/extension.c.tpl index a9b1d2ae36..de076f5941 100644 --- a/internal/extgen/templates/extension.c.tpl +++ b/internal/extgen/templates/extension.c.tpl @@ -96,6 +96,8 @@ PHP_METHOD({{namespacedClassName $.Namespace .ClassName}}, {{.PhpName}}) { zend_bool {{$param.Name}}_is_null = 0;{{end}} {{- else if eq $param.PhpType "array"}} zval *{{$param.Name}} = NULL; + {{- else if eq $param.PhpType "callable"}} + zval *{{$param.Name}}_callback; {{- end}} {{- end}} @@ -104,7 +106,7 @@ PHP_METHOD({{namespacedClassName $.Namespace .ClassName}}, {{.PhpName}}) { {{$optionalStarted := false}}{{range .Params}}{{if .HasDefault}}{{if not $optionalStarted -}} Z_PARAM_OPTIONAL {{$optionalStarted = true}}{{end}}{{end -}} - {{if .IsNullable}}{{if eq .PhpType "string"}}Z_PARAM_STR_OR_NULL({{.Name}}, {{.Name}}_is_null){{else if eq .PhpType "int"}}Z_PARAM_LONG_OR_NULL({{.Name}}, {{.Name}}_is_null){{else if eq .PhpType "float"}}Z_PARAM_DOUBLE_OR_NULL({{.Name}}, {{.Name}}_is_null){{else if eq .PhpType "bool"}}Z_PARAM_BOOL_OR_NULL({{.Name}}, {{.Name}}_is_null){{else if eq .PhpType "array"}}Z_PARAM_ARRAY_OR_NULL({{.Name}}){{end}}{{else}}{{if eq .PhpType "string"}}Z_PARAM_STR({{.Name}}){{else if eq .PhpType "int"}}Z_PARAM_LONG({{.Name}}){{else if eq .PhpType "float"}}Z_PARAM_DOUBLE({{.Name}}){{else if eq .PhpType "bool"}}Z_PARAM_BOOL({{.Name}}){{else if eq .PhpType "array"}}Z_PARAM_ARRAY({{.Name}}){{end}}{{end}} + {{if .IsNullable}}{{if eq .PhpType "string"}}Z_PARAM_STR_OR_NULL({{.Name}}, {{.Name}}_is_null){{else if eq .PhpType "int"}}Z_PARAM_LONG_OR_NULL({{.Name}}, {{.Name}}_is_null){{else if eq .PhpType "float"}}Z_PARAM_DOUBLE_OR_NULL({{.Name}}, {{.Name}}_is_null){{else if eq .PhpType "bool"}}Z_PARAM_BOOL_OR_NULL({{.Name}}, {{.Name}}_is_null){{else if eq .PhpType "array"}}Z_PARAM_ARRAY_OR_NULL({{.Name}}){{else if eq .PhpType "callable"}}Z_PARAM_ZVAL_OR_NULL({{.Name}}_callback){{end}}{{else}}{{if eq .PhpType "string"}}Z_PARAM_STR({{.Name}}){{else if eq .PhpType "int"}}Z_PARAM_LONG({{.Name}}){{else if eq .PhpType "float"}}Z_PARAM_DOUBLE({{.Name}}){{else if eq .PhpType "bool"}}Z_PARAM_BOOL({{.Name}}){{else if eq .PhpType "array"}}Z_PARAM_ARRAY({{.Name}}){{else if eq .PhpType "callable"}}Z_PARAM_ZVAL({{.Name}}_callback){{end}}{{end}} {{end -}} ZEND_PARSE_PARAMETERS_END(); {{else}} @@ -113,19 +115,19 @@ PHP_METHOD({{namespacedClassName $.Namespace .ClassName}}, {{.PhpName}}) { {{- if ne .ReturnType "void"}} {{- if eq .ReturnType "string"}} - zend_string* result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{if .IsNullable}}{{if eq .PhpType "string"}}{{.Name}}_is_null ? NULL : {{.Name}}{{else if eq .PhpType "int"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "float"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "bool"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{end}}{{else}}{{.Name}}{{end}}{{end}}{{end}}); + zend_string* result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{if .IsNullable}}{{if eq .PhpType "string"}}{{.Name}}_is_null ? NULL : {{.Name}}{{else if eq .PhpType "int"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "float"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "bool"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{end}}{{else}}{{if eq .PhpType "callable"}}{{.Name}}_callback{{else}}{{.Name}}{{end}}{{end}}{{end}}{{end}}); RETURN_STR(result); {{- else if eq .ReturnType "int"}} - zend_long result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{if .IsNullable}}{{if eq .PhpType "string"}}{{.Name}}_is_null ? NULL : {{.Name}}{{else if eq .PhpType "int"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "float"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "bool"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{end}}{{else}}{{if eq .PhpType "array"}}{{.Name}}{{else}}(long){{.Name}}{{end}}{{end}}{{end}}{{end}}); + zend_long result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{if .IsNullable}}{{if eq .PhpType "string"}}{{.Name}}_is_null ? NULL : {{.Name}}{{else if eq .PhpType "int"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "float"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "bool"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{end}}{{else}}{{if eq .PhpType "array"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{else}}(long){{.Name}}{{end}}{{end}}{{end}}{{end}}); RETURN_LONG(result); {{- else if eq .ReturnType "float"}} - double result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{if .IsNullable}}{{if eq .PhpType "string"}}{{.Name}}_is_null ? NULL : {{.Name}}{{else if eq .PhpType "int"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "float"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "bool"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{end}}{{else}}{{if eq .PhpType "array"}}{{.Name}}{{else}}(double){{.Name}}{{end}}{{end}}{{end}}{{end}}); + double result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{if .IsNullable}}{{if eq .PhpType "string"}}{{.Name}}_is_null ? NULL : {{.Name}}{{else if eq .PhpType "int"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "float"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "bool"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{end}}{{else}}{{if eq .PhpType "array"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{else}}(double){{.Name}}{{end}}{{end}}{{end}}{{end}}); RETURN_DOUBLE(result); {{- else if eq .ReturnType "bool"}} - int result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{if .IsNullable}}{{if eq .PhpType "string"}}{{.Name}}_is_null ? NULL : {{.Name}}{{else if eq .PhpType "int"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "float"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "bool"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{end}}{{else}}{{if eq .PhpType "array"}}{{.Name}}{{else}}(int){{.Name}}{{end}}{{end}}{{end}}{{end}}); + int result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{if .IsNullable}}{{if eq .PhpType "string"}}{{.Name}}_is_null ? NULL : {{.Name}}{{else if eq .PhpType "int"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "float"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "bool"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{end}}{{else}}{{if eq .PhpType "array"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{else}}(int){{.Name}}{{end}}{{end}}{{end}}{{end}}); RETURN_BOOL(result); {{- else if eq .ReturnType "array"}} - void* result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{if .IsNullable}}{{if eq .PhpType "string"}}{{.Name}}_is_null ? NULL : {{.Name}}{{else if eq .PhpType "int"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "float"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "bool"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{end}}{{else}}{{.Name}}{{end}}{{end}}{{end}}); + void* result = {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{if .IsNullable}}{{if eq .PhpType "string"}}{{.Name}}_is_null ? NULL : {{.Name}}{{else if eq .PhpType "int"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "float"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "bool"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{end}}{{else}}{{if eq .PhpType "callable"}}{{.Name}}_callback{{else}}{{.Name}}{{end}}{{end}}{{end}}{{end}}); if (result != NULL) { HashTable *ht = (HashTable*)result; RETURN_ARR(ht); @@ -134,7 +136,7 @@ PHP_METHOD({{namespacedClassName $.Namespace .ClassName}}, {{.PhpName}}) { } {{- end}} {{- else}} - {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{if .IsNullable}}{{if eq .PhpType "string"}}{{.Name}}_is_null ? NULL : {{.Name}}{{else if eq .PhpType "int"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "float"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "bool"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{end}}{{else}}{{if eq .PhpType "string"}}{{.Name}}{{else if eq .PhpType "int"}}(long){{.Name}}{{else if eq .PhpType "float"}}(double){{.Name}}{{else if eq .PhpType "bool"}}(int){{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{end}}{{end}}{{end}}{{end}}); + {{.Name}}_wrapper(intern->go_handle{{if .Params}}{{range .Params}}, {{if .IsNullable}}{{if eq .PhpType "string"}}{{.Name}}_is_null ? NULL : {{.Name}}{{else if eq .PhpType "int"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "float"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "bool"}}{{.Name}}_is_null ? NULL : &{{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{end}}{{else}}{{if eq .PhpType "string"}}{{.Name}}{{else if eq .PhpType "int"}}(long){{.Name}}{{else if eq .PhpType "float"}}(double){{.Name}}{{else if eq .PhpType "bool"}}(int){{.Name}}{{else if eq .PhpType "array"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{end}}{{end}}{{end}}{{end}}); {{- end}} } {{end}}{{end}} diff --git a/internal/extgen/templates/extension.go.tpl b/internal/extgen/templates/extension.go.tpl index dc65b2fb5d..24b665700e 100644 --- a/internal/extgen/templates/extension.go.tpl +++ b/internal/extgen/templates/extension.go.tpl @@ -76,7 +76,7 @@ func create_{{.GoStruct}}_object() C.uintptr_t { {{- end}} {{- range .Methods}} //export {{.Name}}_wrapper -func {{.Name}}_wrapper(handle C.uintptr_t{{range .Params}}{{if eq .PhpType "string"}}, {{.Name}} *C.zend_string{{else if eq .PhpType "array"}}, {{.Name}} *C.zval{{else}}, {{.Name}} {{if .IsNullable}}*{{end}}{{phpTypeToGoType .PhpType}}{{end}}{{end}}){{if not (isVoid .ReturnType)}}{{if isStringOrArray .ReturnType}} unsafe.Pointer{{else}} {{phpTypeToGoType .ReturnType}}{{end}}{{end}} { +func {{.Name}}_wrapper(handle C.uintptr_t{{range .Params}}{{if eq .PhpType "string"}}, {{.Name}} *C.zend_string{{else if eq .PhpType "array"}}, {{.Name}} *C.zval{{else if eq .PhpType "callable"}}, {{.Name}} *C.zval{{else}}, {{.Name}} {{if .IsNullable}}*{{end}}{{phpTypeToGoType .PhpType}}{{end}}{{end}}){{if not (isVoid .ReturnType)}}{{if isStringOrArray .ReturnType}} unsafe.Pointer{{else}} {{phpTypeToGoType .ReturnType}}{{end}}{{end}} { obj := getGoObject(handle) if obj == nil { {{- if not (isVoid .ReturnType)}} diff --git a/internal/extgen/validator.go b/internal/extgen/validator.go index 44fd138023..b82afbf899 100644 --- a/internal/extgen/validator.go +++ b/internal/extgen/validator.go @@ -11,10 +11,10 @@ import ( ) var ( - paramTypes = []phpType{phpString, phpInt, phpFloat, phpBool, phpArray, phpObject, phpMixed} + paramTypes = []phpType{phpString, phpInt, phpFloat, phpBool, phpArray, phpObject, phpMixed, phpCallable} returnTypes = []phpType{phpVoid, phpString, phpInt, phpFloat, phpBool, phpArray, phpObject, phpMixed, phpNull, phpTrue, phpFalse} propTypes = []phpType{phpString, phpInt, phpFloat, phpBool, phpArray, phpObject, phpMixed} - supportedTypes = []phpType{phpString, phpInt, phpFloat, phpBool, phpArray, phpMixed} + supportedTypes = []phpType{phpString, phpInt, phpFloat, phpBool, phpArray, phpMixed, phpCallable} functionNameRegex = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) parameterNameRegex = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) @@ -160,8 +160,10 @@ func (v *Validator) validateGoFunctionSignatureWithOptions(phpFunc phpFunction, effectiveGoParamCount = goParamCount - 1 } - if len(phpFunc.Params) != effectiveGoParamCount { - return fmt.Errorf("parameter count mismatch: PHP function has %d parameters but Go function has %d", len(phpFunc.Params), effectiveGoParamCount) + expectedGoParams := len(phpFunc.Params) + + if expectedGoParams != effectiveGoParamCount { + return fmt.Errorf("parameter count mismatch: PHP function has %d parameters (expecting %d Go parameters) but Go function has %d", len(phpFunc.Params), expectedGoParams, effectiveGoParamCount) } if goFunc.Type.Params != nil && len(phpFunc.Params) > 0 { @@ -205,11 +207,13 @@ func (v *Validator) phpTypeToGoType(t phpType, isNullable bool) string { baseType = "bool" case phpArray, phpMixed: baseType = "*C.zval" + case phpCallable: + baseType = "*C.zval" default: baseType = "any" } - if isNullable && t != phpString && t != phpArray { + if isNullable && t != phpString && t != phpArray && t != phpCallable { return "*" + baseType } diff --git a/internal/extgen/validator_test.go b/internal/extgen/validator_test.go index bfd232a118..dce885cb3e 100644 --- a/internal/extgen/validator_test.go +++ b/internal/extgen/validator_test.go @@ -60,6 +60,53 @@ func TestValidateFunction(t *testing.T) { }, expectError: false, }, + { + name: "valid function with array parameter", + function: phpFunction{ + Name: "arrayFunction", + ReturnType: "array", + Params: []phpParameter{ + {Name: "items", PhpType: phpArray}, + {Name: "filter", PhpType: phpString}, + }, + }, + expectError: false, + }, + { + name: "valid function with nullable array parameter", + function: phpFunction{ + Name: "nullableArrayFunction", + ReturnType: "string", + Params: []phpParameter{ + {Name: "items", PhpType: phpArray, IsNullable: true}, + {Name: "name", PhpType: phpString}, + }, + }, + expectError: false, + }, + { + name: "valid function with callable parameter", + function: phpFunction{ + Name: "callableFunction", + ReturnType: "array", + Params: []phpParameter{ + {Name: "data", PhpType: phpArray}, + {Name: "callback", PhpType: phpCallable}, + }, + }, + expectError: false, + }, + { + name: "valid function with nullable callable parameter", + function: phpFunction{ + Name: "nullableCallableFunction", + ReturnType: "string", + Params: []phpParameter{ + {Name: "callback", PhpType: phpCallable, IsNullable: true}, + }, + }, + expectError: false, + }, { name: "empty function name", function: phpFunction{ @@ -304,6 +351,23 @@ func TestValidateParameter(t *testing.T) { }, expectError: false, }, + { + name: "valid callable parameter", + param: phpParameter{ + Name: "callbackParam", + PhpType: phpCallable, + }, + expectError: false, + }, + { + name: "valid nullable callable parameter", + param: phpParameter{ + Name: "nullableCallbackParam", + PhpType: "callable", + IsNullable: true, + }, + expectError: false, + }, { name: "empty parameter name", param: phpParameter{ @@ -484,6 +548,28 @@ func TestValidateTypes(t *testing.T) { }, expectError: false, }, + { + name: "valid callable parameter", + function: phpFunction{ + Name: "callableFunction", + ReturnType: "array", + Params: []phpParameter{ + {Name: "callbackParam", PhpType: phpCallable}, + }, + }, + expectError: false, + }, + { + name: "valid nullable callable parameter", + function: phpFunction{ + Name: "nullableCallableFunction", + ReturnType: "string", + Params: []phpParameter{ + {Name: "callbackParam", PhpType: phpCallable, IsNullable: true}, + }, + }, + expectError: false, + }, { name: "invalid object parameter", function: phpFunction{ @@ -600,7 +686,7 @@ func TestValidateGoFunctionSignature(t *testing.T) { }`, }, expectError: true, - errorMsg: "parameter count mismatch: PHP function has 2 parameters but Go function has 1", + errorMsg: "parameter count mismatch: PHP function has 2 parameters (expecting 2 Go parameters) but Go function has 1", }, { name: "parameter type mismatch", @@ -702,6 +788,50 @@ func TestValidateGoFunctionSignature(t *testing.T) { }, GoFunction: `func mixedFunc(data *C.zval, filter *C.zend_string, limit int64) unsafe.Pointer { return nil +}`, + }, + expectError: false, + }, + { + name: "valid callable parameter", + phpFunc: phpFunction{ + Name: "callableFunc", + ReturnType: "array", + Params: []phpParameter{ + {Name: "callback", PhpType: phpCallable}, + }, + GoFunction: `func callableFunc(callback *C.zval) unsafe.Pointer { + return nil +}`, + }, + expectError: false, + }, + { + name: "valid nullable callable parameter", + phpFunc: phpFunction{ + Name: "nullableCallableFunc", + ReturnType: "string", + Params: []phpParameter{ + {Name: "callback", PhpType: phpCallable, IsNullable: true}, + }, + GoFunction: `func nullableCallableFunc(callback *C.zval) unsafe.Pointer { + return nil +}`, + }, + expectError: false, + }, + { + name: "mixed callable and other parameters", + phpFunc: phpFunction{ + Name: "mixedCallableFunc", + ReturnType: "array", + Params: []phpParameter{ + {Name: "data", PhpType: phpArray}, + {Name: "callback", PhpType: phpCallable}, + {Name: "options", PhpType: "int"}, + }, + GoFunction: `func mixedCallableFunc(data *C.zval, callback *C.zval, options int64) unsafe.Pointer { + return nil }`, }, expectError: false, @@ -739,6 +869,8 @@ func TestPhpTypeToGoType(t *testing.T) { {"bool", true, "*bool"}, {"array", false, "*C.zval"}, {"array", true, "*C.zval"}, + {"callable", false, "*C.zval"}, + {"callable", true, "*C.zval"}, {"unknown", false, "any"}, } diff --git a/types.c b/types.c index 9c4887b237..5f3b5c7407 100644 --- a/types.c +++ b/types.c @@ -16,6 +16,8 @@ Bucket *get_ht_bucket_data(HashTable *ht, uint32_t index) { void *__emalloc__(size_t size) { return emalloc(size); } +void __efree__(void *ptr) { efree(ptr); } + void __zend_hash_init__(HashTable *ht, uint32_t nSize, dtor_func_t pDestructor, bool persistent) { zend_hash_init(ht, nSize, NULL, pDestructor, persistent); @@ -34,3 +36,11 @@ void __zval_string__(zval *zv, zend_string *str) { ZVAL_STR(zv, str); } void __zval_arr__(zval *zv, zend_array *arr) { ZVAL_ARR(zv, arr); } zend_array *__zend_new_array__(uint32_t size) { return zend_new_array(size); } + +int __zend_is_callable__(zval *cb) { return zend_is_callable(cb, 0, NULL); } + +int __call_user_function__(zval *function_name, zval *retval, + uint32_t param_count, zval params[]) { + return call_user_function(CG(function_table), NULL, function_name, retval, + param_count, params); +} diff --git a/types.go b/types.go index 4e4bbbbed8..4d5a9703cf 100644 --- a/types.go +++ b/types.go @@ -435,3 +435,47 @@ func extractZvalValue(zval *C.zval, expectedType C.uint8_t) (unsafe.Pointer, err return nil, fmt.Errorf("unsupported zval type %d", expectedType) } + +// EXPERIMENTAL: CallPHPCallable executes a PHP callable with the given parameters. +// Returns the result of the callable as a Go interface{}, or nil if the call failed. +func CallPHPCallable(cb unsafe.Pointer, params []interface{}) interface{} { + if cb == nil { + return nil + } + + callback := (*C.zval)(cb) + if callback == nil { + return nil + } + + if C.__zend_is_callable__(callback) == 0 { + return nil + } + + paramCount := len(params) + var paramStorage *C.zval + if paramCount > 0 { + paramStorage = (*C.zval)(C.__emalloc__(C.size_t(paramCount) * C.size_t(unsafe.Sizeof(C.zval{})))) + defer C.__efree__(unsafe.Pointer(paramStorage)) + + for i, param := range params { + targetZval := (*C.zval)(unsafe.Pointer(uintptr(unsafe.Pointer(paramStorage)) + uintptr(i)*unsafe.Sizeof(C.zval{}))) + sourceZval := phpValue(param) + *targetZval = *sourceZval + } + } + + var retval C.zval + + result := C.__call_user_function__(callback, &retval, C.uint32_t(paramCount), paramStorage) + if result != C.SUCCESS { + return nil + } + + goResult, err := goValue[any](&retval) + if err != nil { + return nil + } + + return goResult +} diff --git a/types.h b/types.h index c82f479d43..6de2cd6a05 100644 --- a/types.h +++ b/types.h @@ -11,9 +11,14 @@ zval *get_ht_packed_data(HashTable *, uint32_t index); Bucket *get_ht_bucket_data(HashTable *, uint32_t index); void *__emalloc__(size_t size); +void __efree__(void *ptr); void __zend_hash_init__(HashTable *ht, uint32_t nSize, dtor_func_t pDestructor, bool persistent); +int __zend_is_callable__(zval *cb); +int __call_user_function__(zval *function_name, zval *retval, + uint32_t param_count, zval params[]); + void __zval_null__(zval *zv); void __zval_bool__(zval *zv, bool val); void __zval_long__(zval *zv, zend_long val); From 57d442240cef7701dcdf10cdc79223084858a031 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Fri, 14 Nov 2025 12:18:15 +0100 Subject: [PATCH 2/2] fix the types thing --- testdata/integration/README.md | 208 +++++++++++++++++++++++++++++++++ types.go | 48 +++++--- 2 files changed, 237 insertions(+), 19 deletions(-) create mode 100644 testdata/integration/README.md diff --git a/testdata/integration/README.md b/testdata/integration/README.md new file mode 100644 index 0000000000..c034dcddf6 --- /dev/null +++ b/testdata/integration/README.md @@ -0,0 +1,208 @@ +# Integration Test Fixtures + +This directory contains Go source files used as test fixtures for the FrankenPHP extension-init integration tests. + +## Overview + +These fixtures test the full end-to-end workflow of the extension-init command: +1. Generating extension files from Go source code +2. Compiling FrankenPHP with the generated extension +3. Executing PHP code that uses the extension +4. Verifying the output + +## Test Fixtures + +### Happy Path Tests + +#### `basic_function.go` +Tests basic function generation with primitive types: +- `test_uppercase(string): string` - String parameter and return +- `test_add_numbers(int, int): int` - Integer parameters +- `test_multiply(float, float): float` - Float parameters +- `test_is_enabled(bool): bool` - Boolean parameter + +**What it tests:** +- Function parsing and generation +- Type conversion for all primitive types +- C/Go bridge code generation +- PHP stub file generation + +#### `class_methods.go` +Tests opaque class generation with methods: +- `Counter` class - Integer counter with increment/decrement operations +- `StringHolder` class - String storage and manipulation + +**What it tests:** +- Class declaration with `//export_php:class` +- Method declaration with `//export_php:method` +- Object lifecycle (creation and destruction) +- Method calls with various parameter and return types +- Nullable parameters (`?int`) +- Opaque object encapsulation (no direct property access) + +#### `constants.go` +Tests constant generation and usage: +- Global constants (int, string, bool, float) +- Iota sequences for enumerations +- Class constants +- Functions using constants + +**What it tests:** +- `//export_php:const` directive +- `//export_php:classconstant` directive +- Constant type detection and conversion +- Iota sequence handling +- Integration of constants with functions and classes + +#### `namespace.go` +Tests namespace support: +- Functions in namespace `TestIntegration\Extension` +- Classes in namespace +- Constants in namespace + +**What it tests:** +- `//export_php:namespace` directive +- Namespace declaration in stub files +- C name mangling for namespaces +- Proper scoping of functions, classes, and constants + +### Error Case Tests + +#### `invalid_signature.go` +Tests error handling for invalid function signatures: +- Function with unsupported return type + +**What it tests:** +- Validation of return types +- Clear error messages for unsupported types +- Graceful failure during generation + +#### `type_mismatch.go` +Tests error handling for type mismatches: +- PHP signature declares `int` but Go function expects `string` +- Method return type mismatch + +**What it tests:** +- Parameter type validation +- Return type validation +- Type compatibility checking between PHP and Go + +## Running Integration Tests Locally + +Integration tests are tagged with `//go:build integration` and are skipped by default because they require: +1. PHP development headers (`php-config`) +2. PHP sources (for `gen_stub.php` script) +3. xcaddy (for building FrankenPHP) + +### Prerequisites + +1. **Install PHP development headers:** + ```bash + # Ubuntu/Debian + sudo apt-get install php-dev + + # macOS + brew install php + ``` + +2. **Download PHP sources:** + ```bash + wget https://www.php.net/distributions/php-8.4.0.tar.gz + tar xzf php-8.4.0.tar.gz + export GEN_STUB_SCRIPT=$PWD/php-8.4.0/build/gen_stub.php + ``` + +3. **Install xcaddy:** + ```bash + go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest + ``` + +### Running the Tests + +```bash +cd internal/extgen +go test -tags integration -v -timeout 30m +``` + +The timeout is set to 30 minutes because: +- Each test compiles a full FrankenPHP binary with xcaddy +- Multiple test scenarios are run sequentially +- Compilation can be slow on CI runners + +### Skipping Tests + +If any of the prerequisites are not met, the tests will be skipped automatically with a clear message: +- Missing `GEN_STUB_SCRIPT`: "Integration tests require PHP sources" +- Missing `xcaddy`: "Integration tests require xcaddy to build FrankenPHP" +- Missing `php-config`: "Integration tests require PHP development headers" + +## CI Integration + +Integration tests run automatically in CI on: +- Pull requests to `main` branch +- Pushes to `main` branch +- PHP versions: 8.3, 8.4 +- Platform: Linux (Ubuntu) + +The CI workflow (`.github/workflows/tests.yaml`) automatically: +1. Sets up Go and PHP +2. Installs xcaddy +3. Downloads PHP sources +4. Sets `GEN_STUB_SCRIPT` environment variable +5. Runs integration tests with 30-minute timeout + +## Adding New Test Fixtures + +To add a new integration test fixture: + +1. **Create a new Go file** in this directory with your test code +2. **Use export_php directives** to declare functions, classes, or constants +3. **Add a new test function** in `internal/extgen/integration_test.go`: + ```go + func TestYourFeature(t *testing.T) { + suite := setupTest(t) + + sourceFile := filepath.Join("..", "..", "testdata", "integration", "your_file.go") + sourceFile, err := filepath.Abs(sourceFile) + require.NoError(t, err) + + targetFile, err := suite.createGoModule(sourceFile) + require.NoError(t, err) + + err = suite.runExtensionInit(targetFile) + require.NoError(t, err) + + _, err = suite.compileFrankenPHP(filepath.Dir(targetFile)) + require.NoError(t, err) + + phpCode := ` 0 { paramStorage = (*C.zval)(C.__emalloc__(C.size_t(paramCount) * C.size_t(unsafe.Sizeof(C.zval{})))) - defer C.__efree__(unsafe.Pointer(paramStorage)) + defer func() { + for i := 0; i < paramCount; i++ { + targetZval := (*C.zval)(unsafe.Pointer(uintptr(unsafe.Pointer(paramStorage)) + uintptr(i)*unsafe.Sizeof(C.zval{}))) + C.zval_ptr_dtor(targetZval) + } + C.__efree__(unsafe.Pointer(paramStorage)) + }() for i, param := range params { targetZval := (*C.zval)(unsafe.Pointer(uintptr(unsafe.Pointer(paramStorage)) + uintptr(i)*unsafe.Sizeof(C.zval{}))) sourceZval := phpValue(param) *targetZval = *sourceZval + C.__efree__(unsafe.Pointer(sourceZval)) } } @@ -473,6 +481,8 @@ func CallPHPCallable(cb unsafe.Pointer, params []interface{}) interface{} { } goResult, err := goValue[any](&retval) + C.zval_ptr_dtor(&retval) + if err != nil { return nil }