From c3d56616c243c51551aafd73ef479b0a52bfa838 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Fri, 4 Jul 2025 11:10:09 +0200 Subject: [PATCH 1/3] 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 0c4d6d3037..7d4190234b 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.zend_array) 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 3114c8e583..853965a1ab 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,22 +115,22 @@ 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}}); if (result) { RETURN_STR(result); } RETURN_EMPTY_STRING(); {{- 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}} ? Z_ARRVAL_P({{.Name}}) : NULL{{end}}{{else}}{{if eq .PhpType "array"}}Z_ARRVAL_P({{.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}} ? Z_ARRVAL_P({{.Name}}) : NULL{{else if eq .PhpType "callable"}}{{.Name}}_callback{{end}}{{else}}{{if eq .PhpType "array"}}Z_ARRVAL_P({{.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}} ? Z_ARRVAL_P({{.Name}}) : NULL{{end}}{{else}}{{if eq .PhpType "array"}}Z_ARRVAL_P({{.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}} ? Z_ARRVAL_P({{.Name}}) : NULL{{else if eq .PhpType "callable"}}{{.Name}}_callback{{end}}{{else}}{{if eq .PhpType "array"}}Z_ARRVAL_P({{.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}} ? Z_ARRVAL_P({{.Name}}) : NULL{{end}}{{else}}{{if eq .PhpType "array"}}Z_ARRVAL_P({{.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}} ? Z_ARRVAL_P({{.Name}}) : NULL{{else if eq .PhpType "callable"}}{{.Name}}_callback{{end}}{{else}}{{if eq .PhpType "array"}}Z_ARRVAL_P({{.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}} ? Z_ARRVAL_P({{.Name}}) : NULL{{end}}{{else}}{{if eq .PhpType "array"}}Z_ARRVAL_P({{.Name}}){{else}}{{.Name}}{{end}}{{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}} ? Z_ARRVAL_P({{.Name}}) : NULL{{else if eq .PhpType "callable"}}{{.Name}}_callback{{end}}{{else}}{{if eq .PhpType "array"}}Z_ARRVAL_P({{.Name}}){{else}}{{if eq .PhpType "callable"}}{{.Name}}_callback{{else}}{{.Name}}{{end}}{{end}}{{end}}{{end}}{{end}}); if (result != NULL) { HashTable *ht = (HashTable*)result; RETURN_ARR(ht); @@ -137,7 +139,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}} ? Z_ARRVAL_P({{.Name}}) : NULL{{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"}}Z_ARRVAL_P({{.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}} ? Z_ARRVAL_P({{.Name}}) : NULL{{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"}}Z_ARRVAL_P({{.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 4d9b8e3fa7..7605d7c8ad 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 { @@ -207,11 +209,13 @@ func (v *Validator) phpTypeToGoType(t phpType, isNullable bool) string { baseType = "*C.zend_array" case 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 df004f967a..544e353046 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.zend_array, 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.zend_array"}, {"array", true, "*C.zend_array"}, + {"callable", false, "*C.zval"}, + {"callable", true, "*C.zval"}, {"unknown", false, "any"}, } diff --git a/types.c b/types.c index ce3835fb38..5ab842a42a 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); @@ -36,3 +38,11 @@ void __zval_empty_string__(zval *zv) { ZVAL_EMPTY_STRING(zv); } 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 2e79d6da16..161eac746e 100644 --- a/types.go +++ b/types.go @@ -456,3 +456,47 @@ func zendHashDestroy(p unsafe.Pointer) { ht := (*C.zend_array)(p) C.zend_hash_destroy(ht) } + +// 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 72442cf303..552ddfe7fa 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 09ee39356ed9d2b85b040acccd1f9f1256d83f57 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Fri, 14 Nov 2025 12:18:15 +0100 Subject: [PATCH 2/3] fix the types thing --- internal/extgen/paramparser.go | 8 +- internal/extgen/templates/extension.c.tpl | 16 +- testdata/integration/README.md | 208 ++++++++++++++++++++++ types.go | 43 +++-- 4 files changed, 249 insertions(+), 26 deletions(-) create mode 100644 testdata/integration/README.md diff --git a/internal/extgen/paramparser.go b/internal/extgen/paramparser.go index e9bad92138..bd7bb3532a 100644 --- a/internal/extgen/paramparser.go +++ b/internal/extgen/paramparser.go @@ -68,7 +68,9 @@ func (pp *ParameterParser) generateSingleParamDeclaration(param phpParameter) [] if param.IsNullable { decls = append(decls, fmt.Sprintf("zend_bool %s_is_null = 0;", param.Name)) } - case phpArray, phpMixed: + case phpArray: + decls = append(decls, fmt.Sprintf("zend_array *%s = NULL;", param.Name)) + case phpMixed: decls = append(decls, fmt.Sprintf("zval *%s = NULL;", param.Name)) case "callable": decls = append(decls, fmt.Sprintf("zval *%s_callback;", param.Name)) @@ -120,7 +122,7 @@ func (pp *ParameterParser) generateParamParsingMacro(param phpParameter) string case phpBool: return fmt.Sprintf("\n Z_PARAM_BOOL_OR_NULL(%s, %s_is_null)", param.Name, param.Name) case phpArray: - return fmt.Sprintf("\n Z_PARAM_ARRAY_OR_NULL(%s)", param.Name) + return fmt.Sprintf("\n Z_PARAM_ARRAY_HT_OR_NULL(%s)", param.Name) case phpMixed: return fmt.Sprintf("\n Z_PARAM_ZVAL_OR_NULL(%s)", param.Name) case phpCallable: @@ -139,7 +141,7 @@ func (pp *ParameterParser) generateParamParsingMacro(param phpParameter) string case phpBool: return fmt.Sprintf("\n Z_PARAM_BOOL(%s)", param.Name) case phpArray: - return fmt.Sprintf("\n Z_PARAM_ARRAY(%s)", param.Name) + return fmt.Sprintf("\n Z_PARAM_ARRAY_HT(%s)", param.Name) case phpMixed: return fmt.Sprintf("\n Z_PARAM_ZVAL(%s)", param.Name) case phpCallable: diff --git a/internal/extgen/templates/extension.c.tpl b/internal/extgen/templates/extension.c.tpl index 853965a1ab..f511a1baf0 100644 --- a/internal/extgen/templates/extension.c.tpl +++ b/internal/extgen/templates/extension.c.tpl @@ -95,7 +95,7 @@ PHP_METHOD({{namespacedClassName $.Namespace .ClassName}}, {{.PhpName}}) { zend_bool {{$param.Name}} = {{if $param.HasDefault}}{{if eq $param.DefaultValue "true"}}1{{else}}0{{end}}{{else}}0{{end}};{{if $param.IsNullable}} zend_bool {{$param.Name}}_is_null = 0;{{end}} {{- else if eq $param.PhpType "array"}} - zval *{{$param.Name}} = NULL; + zend_array *{{$param.Name}} = NULL; {{- else if eq $param.PhpType "callable"}} zval *{{$param.Name}}_callback; {{- end}} @@ -106,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}}){{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}} + {{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_HT_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_HT({{.Name}}){{else if eq .PhpType "callable"}}Z_PARAM_ZVAL({{.Name}}_callback){{end}}{{end}} {{end -}} ZEND_PARSE_PARAMETERS_END(); {{else}} @@ -115,22 +115,22 @@ 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}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{end}}{{else}}{{if eq .PhpType "callable"}}{{.Name}}_callback{{else}}{{.Name}}{{end}}{{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 "array"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{else}}{{.Name}}{{end}}{{end}}{{end}}{{end}}); if (result) { RETURN_STR(result); } RETURN_EMPTY_STRING(); {{- 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}} ? Z_ARRVAL_P({{.Name}}) : NULL{{else if eq .PhpType "callable"}}{{.Name}}_callback{{end}}{{else}}{{if eq .PhpType "array"}}Z_ARRVAL_P({{.Name}}){{else if eq .PhpType "callable"}}{{.Name}}_callback{{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}} ? Z_ARRVAL_P({{.Name}}) : NULL{{else if eq .PhpType "callable"}}{{.Name}}_callback{{end}}{{else}}{{if eq .PhpType "array"}}Z_ARRVAL_P({{.Name}}){{else if eq .PhpType "callable"}}{{.Name}}_callback{{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}} ? Z_ARRVAL_P({{.Name}}) : NULL{{else if eq .PhpType "callable"}}{{.Name}}_callback{{end}}{{else}}{{if eq .PhpType "array"}}Z_ARRVAL_P({{.Name}}){{else if eq .PhpType "callable"}}{{.Name}}_callback{{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}} ? Z_ARRVAL_P({{.Name}}) : NULL{{else if eq .PhpType "callable"}}{{.Name}}_callback{{end}}{{else}}{{if eq .PhpType "array"}}Z_ARRVAL_P({{.Name}}){{else}}{{if eq .PhpType "callable"}}{{.Name}}_callback{{else}}{{.Name}}{{end}}{{end}}{{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 "array"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{else}}{{.Name}}{{end}}{{end}}{{end}}{{end}}); if (result != NULL) { HashTable *ht = (HashTable*)result; RETURN_ARR(ht); @@ -139,7 +139,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}} ? Z_ARRVAL_P({{.Name}}) : NULL{{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"}}Z_ARRVAL_P({{.Name}}){{else if eq .PhpType "callable"}}{{.Name}}_callback{{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/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)) } } @@ -494,6 +505,8 @@ func CallPHPCallable(cb unsafe.Pointer, params []interface{}) interface{} { } goResult, err := goValue[any](&retval) + C.zval_ptr_dtor(&retval) + if err != nil { return nil } From 10403ddc879f0105d030a331407e9d5600ca3cc9 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Mon, 15 Dec 2025 09:41:48 +0100 Subject: [PATCH 3/3] add integration test --- docs/extensions.md | 38 ++--- docs/fr/extensions.md | 42 ++---- internal/extgen/integration_test.go | 110 +++++++++++++++ internal/extgen/paramparser_test.go | 16 +-- internal/extgen/phpfunc_test.go | 12 +- internal/extgen/validator_test.go | 2 +- testdata/integration/README.md | 208 ---------------------------- testdata/integration/callable.go | 64 +++++++++ 8 files changed, 217 insertions(+), 275 deletions(-) delete mode 100644 testdata/integration/README.md create mode 100644 testdata/integration/callable.go diff --git a/docs/extensions.md b/docs/extensions.md index 7d4190234b..595b0424df 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -221,23 +221,19 @@ To showcase this, let's create our own `array_map()` function that takes a calla ```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) +func my_array_map(arr *C.zend_array, callback *C.zval) unsafe.Pointer { + goSlice, err := frankenphp.GoPackedArray[any](unsafe.Pointer(arr)) + if err != nil { + panic(err) + } - callbackResult := frankenphp.CallPHPCallable(unsafe.Pointer(callback), []interface{}{value}) + result := make([]any, len(goSlice)) - if key.Type == frankenphp.PHPIntKey { - result.SetInt(key.Int, callbackResult) - } else { - result.SetString(key.Str, callbackResult) - } + for index, value := range goSlice { + result[index] = frankenphp.CallPHPCallable(unsafe.Pointer(callback), []interface{}{value}) } - return frankenphp.PHPArray(result) + return frankenphp.PHPPackedArray(result) } ``` @@ -246,19 +242,11 @@ Notice how we use `frankenphp.CallPHPCallable()` to call the PHP callable passed ```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; - }); - } +$result = my_array_map([1, 2, 3], function($x) { return $x * 2; }); +// $result will be [2, 4, 6] - return $item * 3; -}); // $result will be [3, 6, 9, 12, [10, 12]] +$result = my_array_map(['hello', 'world'], 'strtoupper'); +// $result will be ['HELLO', 'WORLD'] ``` ### Declaring a Native PHP Class diff --git a/docs/fr/extensions.md b/docs/fr/extensions.md index 7d517da4fb..1e03fbc540 100644 --- a/docs/fr/extensions.md +++ b/docs/fr/extensions.md @@ -212,50 +212,38 @@ func process_data_packed(arr *C.zval) unsafe.Pointer { ### 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. +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) +func my_array_map(arr *C.zend_array, callback *C.zval) unsafe.Pointer { + goSlice, err := frankenphp.GoPackedArray[any](unsafe.Pointer(arr)) + if err != nil { + panic(err) + } - callbackResult := frankenphp.CallPHPCallable(unsafe.Pointer(callback), []interface{}{value}) + result := make([]any, len(goSlice)) - if key.Type == frankenphp.PHPIntKey { - result.SetInt(key.Int, callbackResult) - } else { - result.SetString(key.Str, callbackResult) - } + for index, value := range goSlice { + result[index] = frankenphp.CallPHPCallable(unsafe.Pointer(callback), []interface{}{value}) } - return frankenphp.PHPArray(result) + return frankenphp.PHPPackedArray(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_ : +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; - }); - } +$result = my_array_map([1, 2, 3], function($x) { return $x * 2; }); +// $result vaudra [2, 4, 6] - return $item * 3; -}); // $result vaudra [3, 6, 9, 12, [10, 12]] +$result = my_array_map(['hello', 'world'], 'strtoupper'); +// $result vaudra ['HELLO', 'WORLD'] ``` ### Déclarer une Classe PHP Native diff --git a/internal/extgen/integration_test.go b/internal/extgen/integration_test.go index 6e40d6ef44..86723fc395 100644 --- a/internal/extgen/integration_test.go +++ b/internal/extgen/integration_test.go @@ -118,6 +118,30 @@ func (s *IntegrationTestSuite) runExtensionInit(sourceFile string) error { return nil } +// cleanupGeneratedFiles removes generated files from the original source directory +func (s *IntegrationTestSuite) cleanupGeneratedFiles(originalSourceFile string) { + s.t.Helper() + + sourceDir := filepath.Dir(originalSourceFile) + baseName := SanitizePackageName(strings.TrimSuffix(filepath.Base(originalSourceFile), ".go")) + + generatedFiles := []string{ + baseName + ".stub.php", + baseName + "_arginfo.h", + baseName + ".h", + baseName + ".c", + baseName + ".go", + "README.md", + } + + for _, file := range generatedFiles { + fullPath := filepath.Join(sourceDir, file) + if _, err := os.Stat(fullPath); err == nil { + os.Remove(fullPath) + } + } +} + // compileFrankenPHP compiles FrankenPHP with the generated extension func (s *IntegrationTestSuite) compileFrankenPHP(moduleDir string) (string, error) { s.t.Helper() @@ -250,6 +274,7 @@ func TestBasicFunction(t *testing.T) { sourceFile := filepath.Join("..", "..", "testdata", "integration", "basic_function.go") sourceFile, err := filepath.Abs(sourceFile) require.NoError(t, err) + defer suite.cleanupGeneratedFiles(sourceFile) targetFile, err := suite.createGoModule(sourceFile) require.NoError(t, err) @@ -326,6 +351,7 @@ func TestClassMethodsIntegration(t *testing.T) { sourceFile := filepath.Join("..", "..", "testdata", "integration", "class_methods.go") sourceFile, err := filepath.Abs(sourceFile) require.NoError(t, err) + defer suite.cleanupGeneratedFiles(sourceFile) targetFile, err := suite.createGoModule(sourceFile) require.NoError(t, err) @@ -437,6 +463,7 @@ func TestConstants(t *testing.T) { sourceFile := filepath.Join("..", "..", "testdata", "integration", "constants.go") sourceFile, err := filepath.Abs(sourceFile) require.NoError(t, err) + defer suite.cleanupGeneratedFiles(sourceFile) targetFile, err := suite.createGoModule(sourceFile) require.NoError(t, err) @@ -536,6 +563,7 @@ func TestNamespace(t *testing.T) { sourceFile := filepath.Join("..", "..", "testdata", "integration", "namespace.go") sourceFile, err := filepath.Abs(sourceFile) require.NoError(t, err) + defer suite.cleanupGeneratedFiles(sourceFile) targetFile, err := suite.createGoModule(sourceFile) require.NoError(t, err) @@ -625,6 +653,7 @@ func TestInvalidSignature(t *testing.T) { sourceFile := filepath.Join("..", "..", "testdata", "integration", "invalid_signature.go") sourceFile, err := filepath.Abs(sourceFile) require.NoError(t, err) + defer suite.cleanupGeneratedFiles(sourceFile) targetFile, err := suite.createGoModule(sourceFile) require.NoError(t, err) @@ -640,6 +669,7 @@ func TestTypeMismatch(t *testing.T) { sourceFile := filepath.Join("..", "..", "testdata", "integration", "type_mismatch.go") sourceFile, err := filepath.Abs(sourceFile) require.NoError(t, err) + defer suite.cleanupGeneratedFiles(sourceFile) targetFile, err := suite.createGoModule(sourceFile) require.NoError(t, err) @@ -681,3 +711,83 @@ func dummy() {} assert.Error(t, err, "should fail when gen_stub.php is missing") assert.Contains(t, err.Error(), "gen_stub.php", "error should mention missing script") } + +func TestCallable(t *testing.T) { + suite := setupTest(t) + + sourceFile := filepath.Join("..", "..", "testdata", "integration", "callable.go") + sourceFile, err := filepath.Abs(sourceFile) + require.NoError(t, err) + defer suite.cleanupGeneratedFiles(sourceFile) + + 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) + + err = suite.verifyPHPSymbols( + []string{"my_array_map", "my_filter"}, + []string{"Processor"}, + []string{}, + ) + require.NoError(t, err, "all functions and classes should be accessible from PHP") + + err = suite.verifyFunctionBehavior(`transform('hello', function($s) { return strtoupper($s); }); +if ($result !== 'HELLO') { + echo "FAIL: Processor::transform with closure expected 'HELLO', got '$result'"; + exit(1); +} + +$result = $processor->transform('world', 'strtoupper'); +if ($result !== 'WORLD') { + echo "FAIL: Processor::transform with function name expected 'WORLD', got '$result'"; + exit(1); +} + +$result = $processor->transform(' test ', 'trim'); +if ($result !== 'test') { + echo "FAIL: Processor::transform with trim expected 'test', got '$result'"; + exit(1); +} + +echo "OK"; +`, "OK") + require.NoError(t, err, "all callable tests should pass") +} diff --git a/internal/extgen/paramparser_test.go b/internal/extgen/paramparser_test.go index 88f696c0cb..b0cdf6b9bb 100644 --- a/internal/extgen/paramparser_test.go +++ b/internal/extgen/paramparser_test.go @@ -145,14 +145,14 @@ func TestParameterParser_GenerateParamDeclarations(t *testing.T) { params: []phpParameter{ {Name: "items", PhpType: phpArray, HasDefault: false}, }, - expected: " zval *items = NULL;", + expected: " zend_array *items = NULL;", }, { name: "nullable array parameter", params: []phpParameter{ {Name: "items", PhpType: phpArray, HasDefault: false, IsNullable: true}, }, - expected: " zval *items = NULL;", + expected: " zend_array *items = NULL;", }, { name: "mixed types with array", @@ -161,7 +161,7 @@ func TestParameterParser_GenerateParamDeclarations(t *testing.T) { {Name: "items", PhpType: phpArray, HasDefault: false}, {Name: "count", PhpType: phpInt, HasDefault: true, DefaultValue: "5"}, }, - expected: " zend_string *name = NULL;\n zval *items = NULL;\n zend_long count = 5;", + expected: " zend_string *name = NULL;\n zend_array *items = NULL;\n zend_long count = 5;", }, { name: "mixed parameter", @@ -198,7 +198,7 @@ func TestParameterParser_GenerateParamDeclarations(t *testing.T) { {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;", + expected: " zend_array *data = NULL;\n zval *callback_callback;\n zend_long options = 0;", }, } @@ -399,12 +399,12 @@ func TestParameterParser_GenerateParamParsingMacro(t *testing.T) { { name: "array parameter", param: phpParameter{Name: "items", PhpType: phpArray}, - expected: "\n Z_PARAM_ARRAY(items)", + expected: "\n Z_PARAM_ARRAY_HT(items)", }, { name: "nullable array parameter", param: phpParameter{Name: "items", PhpType: phpArray, IsNullable: true}, - expected: "\n Z_PARAM_ARRAY_OR_NULL(items)", + expected: "\n Z_PARAM_ARRAY_HT_OR_NULL(items)", }, { name: "mixed parameter", @@ -617,12 +617,12 @@ func TestParameterParser_GenerateSingleParamDeclaration(t *testing.T) { { name: "array parameter", param: phpParameter{Name: "items", PhpType: phpArray, HasDefault: false}, - expected: []string{"zval *items = NULL;"}, + expected: []string{"zend_array *items = NULL;"}, }, { name: "nullable array parameter", param: phpParameter{Name: "items", PhpType: phpArray, HasDefault: false, IsNullable: true}, - expected: []string{"zval *items = NULL;"}, + expected: []string{"zend_array *items = NULL;"}, }, { name: "callable parameter", diff --git a/internal/extgen/phpfunc_test.go b/internal/extgen/phpfunc_test.go index 36a532f500..9725dc6bdc 100644 --- a/internal/extgen/phpfunc_test.go +++ b/internal/extgen/phpfunc_test.go @@ -107,8 +107,8 @@ func TestPHPFunctionGenerator_Generate(t *testing.T) { }, contains: []string{ "PHP_FUNCTION(process_array)", - "zval *input = NULL;", - "Z_PARAM_ARRAY(input)", + "zend_array *input = NULL;", + "Z_PARAM_ARRAY_HT(input)", "zend_array *result = process_array(input);", "RETURN_ARR(result)", }, @@ -126,10 +126,10 @@ func TestPHPFunctionGenerator_Generate(t *testing.T) { }, contains: []string{ "PHP_FUNCTION(filter_array)", - "zval *data = NULL;", + "zend_array *data = NULL;", "zend_string *key = NULL;", "zend_long limit = 10;", - "Z_PARAM_ARRAY(data)", + "Z_PARAM_ARRAY_HT(data)", "Z_PARAM_STR(key)", "Z_PARAM_LONG(limit)", "ZEND_PARSE_PARAMETERS_START(2, 3)", @@ -201,7 +201,7 @@ func TestPHPFunctionGenerator_GenerateParamDeclarations(t *testing.T) { {Name: "items", PhpType: phpArray}, }, contains: []string{ - "zval *items = NULL;", + "zend_array *items = NULL;", }, }, { @@ -213,7 +213,7 @@ func TestPHPFunctionGenerator_GenerateParamDeclarations(t *testing.T) { }, contains: []string{ "zend_string *name = NULL;", - "zval *data = NULL;", + "zend_array *data = NULL;", "zend_long count = 0;", }, }, diff --git a/internal/extgen/validator_test.go b/internal/extgen/validator_test.go index 544e353046..b95351519a 100644 --- a/internal/extgen/validator_test.go +++ b/internal/extgen/validator_test.go @@ -830,7 +830,7 @@ func TestValidateGoFunctionSignature(t *testing.T) { {Name: "callback", PhpType: phpCallable}, {Name: "options", PhpType: "int"}, }, - GoFunction: `func mixedCallableFunc(data *C.zval, callback *C.zval, options int64) unsafe.Pointer { + GoFunction: `func mixedCallableFunc(data *C.zend_array, callback *C.zval, options int64) unsafe.Pointer { return nil }`, }, diff --git a/testdata/integration/README.md b/testdata/integration/README.md deleted file mode 100644 index c034dcddf6..0000000000 --- a/testdata/integration/README.md +++ /dev/null @@ -1,208 +0,0 @@ -# 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 := ` +import "C" +import ( + "unsafe" + + "github.com/dunglas/frankenphp" +) + +// export_php:function my_array_map(array $data, callable $callback): array +func my_array_map(arr *C.zend_array, callback *C.zval) unsafe.Pointer { + goArray, err := frankenphp.GoPackedArray[any](unsafe.Pointer(arr)) + if err != nil { + return nil + } + + result := make([]any, len(goArray)) + for i, item := range goArray { + callResult := frankenphp.CallPHPCallable(unsafe.Pointer(callback), []any{item}) + result[i] = callResult + } + + return frankenphp.PHPPackedArray[any](result) +} + +// export_php:function my_filter(array $data, ?callable $callback): array +func my_filter(arr *C.zend_array, callback *C.zval) unsafe.Pointer { + goArray, err := frankenphp.GoPackedArray[any](unsafe.Pointer(arr)) + if err != nil { + return nil + } + + if callback == nil { + return unsafe.Pointer(arr) + } + + result := make([]any, 0) + for _, item := range goArray { + callResult := frankenphp.CallPHPCallable(unsafe.Pointer(callback), []any{item}) + if boolResult, ok := callResult.(bool); ok && boolResult { + result = append(result, item) + } + } + + return frankenphp.PHPPackedArray[any](result) +} + +// export_php:class Processor +type Processor struct{} + +// export_php:method Processor::transform(string $input, callable $transformer): string +func (p *Processor) Transform(input *C.zend_string, callback *C.zval) unsafe.Pointer { + goInput := frankenphp.GoString(unsafe.Pointer(input)) + + callResult := frankenphp.CallPHPCallable(unsafe.Pointer(callback), []any{goInput}) + + resultStr, ok := callResult.(string) + if !ok { + return unsafe.Pointer(input) + } + + return frankenphp.PHPString(resultStr, false) +}