diff --git a/docs/extensions.md b/docs/extensions.md index 0c4d6d303..595b0424d 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,42 @@ 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.zend_array, callback *C.zval) unsafe.Pointer { + goSlice, err := frankenphp.GoPackedArray[any](unsafe.Pointer(arr)) + if err != nil { + panic(err) + } + + result := make([]any, len(goSlice)) + + for index, value := range goSlice { + result[index] = frankenphp.CallPHPCallable(unsafe.Pointer(callback), []interface{}{value}) + } + + return frankenphp.PHPPackedArray(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 +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/nodes.go b/internal/extgen/nodes.go index c57e595e5..5afd1e38a 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 8da8895e3..bd7bb3532 100644 --- a/internal/extgen/paramparser.go +++ b/internal/extgen/paramparser.go @@ -68,8 +68,12 @@ 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)) } return decls @@ -118,9 +122,11 @@ 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: + return fmt.Sprintf("\n Z_PARAM_ZVAL_OR_NULL(%s_callback)", param.Name) default: return "" } @@ -135,9 +141,11 @@ 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: + return fmt.Sprintf("\n Z_PARAM_ZVAL(%s_callback)", param.Name) default: return "" } @@ -168,6 +176,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 +190,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 5752c3a5c..b0cdf6b9b 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", @@ -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: " zend_array *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 { @@ -353,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", @@ -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")}, @@ -551,12 +617,22 @@ 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", + 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;"}, }, } diff --git a/internal/extgen/phpfunc_test.go b/internal/extgen/phpfunc_test.go index 36a532f50..9725dc6bd 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/templates/extension.c.tpl b/internal/extgen/templates/extension.c.tpl index 3114c8e58..f511a1baf 100644 --- a/internal/extgen/templates/extension.c.tpl +++ b/internal/extgen/templates/extension.c.tpl @@ -95,7 +95,9 @@ 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}} {{- 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_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}} @@ -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 "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{{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}}{{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{{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}}{{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{{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}}{{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{{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}}{{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); @@ -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}}{{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 dc65b2fb5..24b665700 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 4d9b8e3fa..7605d7c8a 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 df004f967..b95351519 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.zend_array, 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/testdata/integration/callable.go b/testdata/integration/callable.go new file mode 100644 index 000000000..e9a317352 --- /dev/null +++ b/testdata/integration/callable.go @@ -0,0 +1,64 @@ +package testintegration + +// #include +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) +} diff --git a/types.c b/types.c index ce3835fb3..5ab842a42 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 2e79d6da1..3128a7c50 100644 --- a/types.go +++ b/types.go @@ -228,12 +228,14 @@ func phpArray[T any](entries map[string]T, order []string) unsafe.Pointer { val := entries[key] zval := phpValue(val) C.zend_hash_str_update(zendArray, toUnsafeChar(key), C.size_t(len(key)), zval) + C.__efree__(unsafe.Pointer(zval)) } } else { zendArray = createNewArray((uint32)(len(entries))) for key, val := range entries { zval := phpValue(val) C.zend_hash_str_update(zendArray, toUnsafeChar(key), C.size_t(len(key)), zval) + C.__efree__(unsafe.Pointer(zval)) } } @@ -246,6 +248,7 @@ func PHPPackedArray[T any](slice []T) unsafe.Pointer { for _, val := range slice { zval := phpValue(val) C.zend_hash_next_index_insert(zendArray, zval) + C.__efree__(unsafe.Pointer(zval)) } return unsafe.Pointer(zendArray) @@ -368,42 +371,43 @@ func PHPValue(value any) unsafe.Pointer { } func phpValue(value any) *C.zval { - var zval C.zval + zval := (*C.zval)(C.__emalloc__(C.size_t(unsafe.Sizeof(C.zval{})))) if toZvalObj, ok := value.(toZval); ok { - toZvalObj.toZval(&zval) - return &zval + toZvalObj.toZval(zval) + return zval } switch v := value.(type) { case nil: - C.__zval_null__(&zval) + C.__zval_null__(zval) case bool: - C.__zval_bool__(&zval, C._Bool(v)) + C.__zval_bool__(zval, C._Bool(v)) case int: - C.__zval_long__(&zval, C.zend_long(v)) + C.__zval_long__(zval, C.zend_long(v)) case int64: - C.__zval_long__(&zval, C.zend_long(v)) + C.__zval_long__(zval, C.zend_long(v)) case float64: - C.__zval_double__(&zval, C.double(v)) + C.__zval_double__(zval, C.double(v)) case string: if v == "" { - C.__zval_empty_string__(&zval) + C.__zval_empty_string__(zval) break } str := (*C.zend_string)(PHPString(v, false)) - C.__zval_string__(&zval, str) + C.__zval_string__(zval, str) case AssociativeArray[any]: - C.__zval_arr__(&zval, (*C.zend_array)(PHPAssociativeArray[any](v))) + C.__zval_arr__(zval, (*C.zend_array)(PHPAssociativeArray[any](v))) case map[string]any: - C.__zval_arr__(&zval, (*C.zend_array)(PHPMap[any](v))) + C.__zval_arr__(zval, (*C.zend_array)(PHPMap[any](v))) case []any: - C.__zval_arr__(&zval, (*C.zend_array)(PHPPackedArray[any](v))) + C.__zval_arr__(zval, (*C.zend_array)(PHPPackedArray[any](v))) default: + C.__efree__(unsafe.Pointer(zval)) panic(fmt.Sprintf("unsupported Go type %T", v)) } - return &zval + return zval } // createNewArray creates a new zend_array with the specified size. @@ -456,3 +460,56 @@ 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 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)) + } + } + + 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) + C.zval_ptr_dtor(&retval) + + if err != nil { + return nil + } + + return goResult +} diff --git a/types.h b/types.h index 72442cf30..552ddfe7f 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);