From 0063e7934bbd466ea2331ade02f1a69ff2ac231a Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Fri, 7 Aug 2026 09:52:14 +0200 Subject: [PATCH 01/17] fix(extgen): count grouped Go parameters individually A single ast.Field declares every parameter that shares a type, so "func add(a, b int64)" was counted as one parameter. Any exported function or method using grouped parameters was rejected with a bogus count mismatch and silently dropped from the generated extension, and the per-parameter type check compared PHP parameters against the wrong Go types. Flatten the field list into one entry per parameter before counting and indexing. --- internal/extgen/astutil.go | 18 +++++++++ internal/extgen/validator.go | 41 ++++++------------- internal/extgen/validator_test.go | 65 ++++++++++++++++++++++++++++++- 3 files changed, 93 insertions(+), 31 deletions(-) diff --git a/internal/extgen/astutil.go b/internal/extgen/astutil.go index ce96da93c7..d630be6e16 100644 --- a/internal/extgen/astutil.go +++ b/internal/extgen/astutil.go @@ -33,6 +33,24 @@ func extractNodeSource(src []byte, fset *token.FileSet, node ast.Node) string { return string(src[start:end]) } +// flattenParamTypes expands a parameter list into one entry per parameter. +// A single ast.Field can declare several parameters at once ("func f(a, b int)"), +// so the field list length is not the parameter count. +func flattenParamTypes(params *ast.FieldList) []ast.Expr { + if params == nil { + return nil + } + + var types []ast.Expr + for _, field := range params.List { + for range max(len(field.Names), 1) { + types = append(types, field.Type) + } + } + + return types +} + // checkOrphanDirectives returns an error for the first comment that matches re // but whose source line was not consumed by a declaration. func checkOrphanDirectives(file *ast.File, fset *token.FileSet, re *regexp.Regexp, consumed map[int]bool, directiveLabel string) error { diff --git a/internal/extgen/validator.go b/internal/extgen/validator.go index f9b59763de..b4805627ee 100644 --- a/internal/extgen/validator.go +++ b/internal/extgen/validator.go @@ -142,45 +142,26 @@ func (v *Validator) validateGoFunctionSignatureWithOptions(phpFunc phpFunction, return fmt.Errorf("no function declaration found in Go function") } - goParamCount := 0 - if goFunc.Type.Params != nil { - goParamCount = len(goFunc.Type.Params.List) - } + goParamTypes := flattenParamTypes(goFunc.Type.Params) - hasReceiver := goFunc.Recv != nil && len(goFunc.Recv.List) > 0 paramOffset := 0 - effectiveGoParamCount := goParamCount - - if hasReceiver { - paramOffset = 0 - effectiveGoParamCount = goParamCount - } else if isMethod && goParamCount > 0 { + hasReceiver := goFunc.Recv != nil && len(goFunc.Recv.List) > 0 + if !hasReceiver && isMethod && len(goParamTypes) > 0 { // this is a method-like function, first parameter should be the struct paramOffset = 1 - effectiveGoParamCount = goParamCount - 1 } - 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) + effectiveGoParamCount := len(goParamTypes) - paramOffset + 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) } - if goFunc.Type.Params != nil && len(phpFunc.Params) > 0 { - for i, phpParam := range phpFunc.Params { - goParamIndex := i + paramOffset - - if goParamIndex >= len(goFunc.Type.Params.List) { - break - } - - goParam := goFunc.Type.Params.List[goParamIndex] - expectedGoType := v.phpTypeToGoType(phpParam.PhpType, phpParam.IsNullable) - actualGoType := v.goTypeToString(goParam.Type) + for i, phpParam := range phpFunc.Params { + expectedGoType := v.phpTypeToGoType(phpParam.PhpType, phpParam.IsNullable) + actualGoType := v.goTypeToString(goParamTypes[i+paramOffset]) - if !v.isCompatibleGoType(expectedGoType, actualGoType) { - return fmt.Errorf("parameter %d type mismatch: PHP %q requires Go type %q but found %q", i+1, phpParam.PhpType, expectedGoType, actualGoType) - } + if !v.isCompatibleGoType(expectedGoType, actualGoType) { + return fmt.Errorf("parameter %d type mismatch: PHP %q requires Go type %q but found %q", i+1, phpParam.PhpType, expectedGoType, actualGoType) } } diff --git a/internal/extgen/validator_test.go b/internal/extgen/validator_test.go index 7be0f953e4..e36d1efe82 100644 --- a/internal/extgen/validator_test.go +++ b/internal/extgen/validator_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestValidateFunction(t *testing.T) { @@ -686,7 +687,7 @@ func TestValidateGoFunctionSignature(t *testing.T) { }`, }, expectError: true, - errorMsg: "parameter count mismatch: PHP function has 2 parameters (expecting 2 Go parameters) but Go function has 1", + errorMsg: "parameter count mismatch: PHP function has 2 parameters but Go function has 1", }, { name: "parameter type mismatch", @@ -937,3 +938,65 @@ func TestIsCompatibleGoType(t *testing.T) { }) } } + +func TestValidateGoFunctionSignature_GroupedParameters(t *testing.T) { + validator := Validator{} + + tests := []struct { + name string + phpFunc phpFunction + wantErrMsg string + }{ + { + name: "grouped parameters sharing a type are counted individually", + phpFunc: phpFunction{ + Name: "add", + ReturnType: phpInt, + Params: []phpParameter{ + {Name: "a", PhpType: phpInt}, + {Name: "b", PhpType: phpInt}, + }, + GoFunction: "func add(a, b int64) int64 { return a + b }", + }, + }, + { + name: "grouped parameters of the wrong type are still rejected", + phpFunc: phpFunction{ + Name: "concat", + ReturnType: phpInt, + Params: []phpParameter{ + {Name: "a", PhpType: phpInt}, + {Name: "b", PhpType: phpInt}, + }, + GoFunction: "func concat(a, b string) int64 { return 0 }", + }, + wantErrMsg: `parameter 1 type mismatch`, + }, + { + name: "grouped parameters declaring too many arguments are rejected", + phpFunc: phpFunction{ + Name: "add", + ReturnType: phpInt, + Params: []phpParameter{ + {Name: "a", PhpType: phpInt}, + }, + GoFunction: "func add(a, b int64) int64 { return a + b }", + }, + wantErrMsg: "parameter count mismatch: PHP function has 1 parameters but Go function has 2", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validator.validateGoFunctionSignatureWithOptions(tt.phpFunc, false) + if tt.wantErrMsg == "" { + require.NoError(t, err) + + return + } + + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErrMsg) + }) + } +} From 060c02c835020635caec5ce749f19d73a6defa05 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Fri, 7 Aug 2026 09:52:34 +0200 Subject: [PATCH 02/17] fix(extgen): keep nullable mixed parameters a single pointer phpTypeToGoType exempts string, array and callable from the extra pointer level it adds for nullable types, but not mixed, so "?mixed $v" was validated as **C.zval while the generated C declares "zval *v = NULL" and passes a single pointer. Writing the correct *C.zval got the function dropped with a type mismatch; obeying the validator produced a cgo pointer mismatch. A zval already carries IS_NULL, so mixed belongs with the other pointer types. --- internal/extgen/validator.go | 4 +++- internal/extgen/validator_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/internal/extgen/validator.go b/internal/extgen/validator.go index b4805627ee..8e438ea235 100644 --- a/internal/extgen/validator.go +++ b/internal/extgen/validator.go @@ -196,7 +196,9 @@ func (v *Validator) phpTypeToGoType(t phpType, isNullable bool) string { baseType = "any" } - if isNullable && t != phpString && t != phpArray && t != phpCallable { + // Types already passed as a pointer carry PHP null in the pointer itself, + // so they never gain an extra level of indirection when nullable. + if isNullable && t != phpString && t != phpArray && t != phpMixed && t != phpCallable { return "*" + baseType } diff --git a/internal/extgen/validator_test.go b/internal/extgen/validator_test.go index e36d1efe82..aed31d34e9 100644 --- a/internal/extgen/validator_test.go +++ b/internal/extgen/validator_test.go @@ -1000,3 +1000,27 @@ func TestValidateGoFunctionSignature_GroupedParameters(t *testing.T) { }) } } + +func TestPhpTypeToGoType_NullablePointerTypes(t *testing.T) { + // Types PHP already hands over as a pointer carry null in the pointer itself, + // so they must not gain a second level of indirection when nullable. + tests := []struct { + phpType phpType + expected string + }{ + {phpString, "*C.zend_string"}, + {phpArray, "*C.zend_array"}, + {phpMixed, "*C.zval"}, + {phpCallable, "*C.zval"}, + {phpInt, "*int64"}, + {phpFloat, "*float64"}, + {phpBool, "*bool"}, + } + + validator := Validator{} + for _, tt := range tests { + t.Run(string(tt.phpType), func(t *testing.T) { + assert.Equal(t, tt.expected, validator.phpTypeToGoType(tt.phpType, true)) + }) + } +} From a33dec0b8d81504d78e4b97999cb832150c5d97b Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Fri, 7 Aug 2026 09:52:50 +0200 Subject: [PATCH 03/17] fix(extgen): initialize generated callable parameters to NULL The callable local was the only generated declaration without an initializer. ZEND_PARSE_PARAMETERS leaves the variable untouched when an optional argument is omitted, so "?callable $cb = null" handed an indeterminate pointer to the Go export, which frankenphp.CallPHPCallable then dereferences. Also switch the surrounding case to the phpCallable constant used by every sibling branch, so a rename cannot silently disable it. --- internal/extgen/paramparser.go | 4 ++-- internal/extgen/paramparser_test.go | 26 ++++++++++++++++++----- internal/extgen/templates/extension.c.tpl | 2 +- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/internal/extgen/paramparser.go b/internal/extgen/paramparser.go index 6dcc365e3a..a9f40c3054 100644 --- a/internal/extgen/paramparser.go +++ b/internal/extgen/paramparser.go @@ -72,8 +72,8 @@ func (pp *ParameterParser) generateSingleParamDeclaration(param phpParameter) [] 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)) + case phpCallable: + decls = append(decls, fmt.Sprintf("zval *%s_callback = NULL;", param.Name)) } return decls diff --git a/internal/extgen/paramparser_test.go b/internal/extgen/paramparser_test.go index 8d19e53f00..c53a55874d 100644 --- a/internal/extgen/paramparser_test.go +++ b/internal/extgen/paramparser_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestParameterParser_AnalyzeParameters(t *testing.T) { @@ -182,14 +183,14 @@ func TestParameterParser_GenerateParamDeclarations(t *testing.T) { params: []phpParameter{ {Name: "callback", PhpType: phpCallable, HasDefault: false}, }, - expected: " zval *callback_callback;", + expected: " zval *callback_callback = NULL;", }, { name: "nullable callable parameter", params: []phpParameter{ {Name: "callback", PhpType: phpCallable, HasDefault: false, IsNullable: true}, }, - expected: " zval *callback_callback;", + expected: " zval *callback_callback = NULL;", }, { name: "mixed types with callable", @@ -198,7 +199,7 @@ func TestParameterParser_GenerateParamDeclarations(t *testing.T) { {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;", + expected: " zend_array *data = NULL;\n zval *callback_callback = NULL;\n zend_long options = 0;", }, } @@ -625,12 +626,12 @@ func TestParameterParser_GenerateSingleParamDeclaration(t *testing.T) { { name: "callable parameter", param: phpParameter{Name: "callback", PhpType: "callable", HasDefault: false}, - expected: []string{"zval *callback_callback;"}, + expected: []string{"zval *callback_callback = NULL;"}, }, { name: "nullable callable parameter", param: phpParameter{Name: "callback", PhpType: "callable", HasDefault: false, IsNullable: true}, - expected: []string{"zval *callback_callback;"}, + expected: []string{"zval *callback_callback = NULL;"}, }, } @@ -672,3 +673,18 @@ func TestParameterParser_Integration(t *testing.T) { goCallParams := pp.generateGoCallParams(params) assert.Equal(t, "name, (long) count, (int) enabled", goCallParams) } + +func TestParameterParser_OptionalCallableIsInitialized(t *testing.T) { + pp := &ParameterParser{} + + // An omitted optional argument leaves the local untouched, so it must start + // out NULL rather than holding whatever was on the stack. + decls := pp.generateSingleParamDeclaration(phpParameter{ + Name: "cb", + PhpType: phpCallable, + IsNullable: true, + HasDefault: true, + }) + + require.Equal(t, []string{"zval *cb_callback = NULL;"}, decls) +} diff --git a/internal/extgen/templates/extension.c.tpl b/internal/extgen/templates/extension.c.tpl index fd7911d812..0765417c3c 100644 --- a/internal/extgen/templates/extension.c.tpl +++ b/internal/extgen/templates/extension.c.tpl @@ -114,7 +114,7 @@ PHP_METHOD({{namespacedClassName $.Namespace .ClassName}}, {{.PhpName}}) { {{- else if eq $param.PhpType "array"}} zend_array *{{$param.Name}} = NULL; {{- else if eq $param.PhpType "callable"}} - zval *{{$param.Name}}_callback; + zval *{{$param.Name}}_callback = NULL; {{- end}} {{- end}} From b9e0889176f9bc39c613f002c66268c5822e8cbd Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Fri, 7 Aug 2026 09:54:02 +0200 Subject: [PATCH 04/17] fix(extgen): qualify method wrappers with their class name Method wrappers were exported as "_wrapper", so two exported classes declaring a method with the same PHP name emitted the same cgo symbol. An extension with User::getName() and Group::getName() failed to build with "getName_wrapper redeclared in this block". Prefix the symbol with the PHP class name, which the validator already constrains to a valid identifier. --- internal/extgen/gofile_test.go | 61 +++++++++++++++++----- internal/extgen/templates/extension.c.tpl | 12 ++--- internal/extgen/templates/extension.go.tpl | 4 +- 3 files changed, 57 insertions(+), 20 deletions(-) diff --git a/internal/extgen/gofile_test.go b/internal/extgen/gofile_test.go index a64504a422..878457e82f 100644 --- a/internal/extgen/gofile_test.go +++ b/internal/extgen/gofile_test.go @@ -509,13 +509,13 @@ func (ts *TestStruct) ProcessData(name string, count *int64, enabled *bool) stri content, err := goGen.buildContent() require.NoError(t, err) - expectedWrapperSignature := "func ProcessData_wrapper(handle C.uintptr_t, name *C.zend_string, count *int64, enabled *bool)" + expectedWrapperSignature := "func TestClass_ProcessData_wrapper(handle C.uintptr_t, name *C.zend_string, count *int64, enabled *bool)" assert.Contains(t, content, expectedWrapperSignature, "Generated content should contain wrapper with nullable pointer types: %s", expectedWrapperSignature) expectedCall := "structObj.ProcessData(name, count, enabled)" assert.Contains(t, content, expectedCall, "Generated content should contain correct method call: %s", expectedCall) - exportDirective := "//export ProcessData_wrapper" + exportDirective := "//export TestClass_ProcessData_wrapper" assert.Contains(t, content, exportDirective, "Generated content should contain export directive: %s", exportDirective) } @@ -604,10 +604,10 @@ func (as *ArrayStruct) FilterData(data frankenphp.AssociativeArray, filter strin content, err := goGen.buildContent() require.NoError(t, err) - expectedArrayWrapperSignature := "func ProcessArray_wrapper(handle C.uintptr_t, items *C.zval) unsafe.Pointer" + expectedArrayWrapperSignature := "func ArrayClass_ProcessArray_wrapper(handle C.uintptr_t, items *C.zval) unsafe.Pointer" assert.Contains(t, content, expectedArrayWrapperSignature, "Generated content should contain array wrapper signature: %s", expectedArrayWrapperSignature) - expectedMixedWrapperSignature := "func FilterData_wrapper(handle C.uintptr_t, data *C.zval, filter *C.zend_string) unsafe.Pointer" + expectedMixedWrapperSignature := "func ArrayClass_FilterData_wrapper(handle C.uintptr_t, data *C.zval, filter *C.zend_string) unsafe.Pointer" assert.Contains(t, content, expectedMixedWrapperSignature, "Generated content should contain mixed wrapper signature: %s", expectedMixedWrapperSignature) expectedArrayCall := "structObj.ProcessArray(items)" @@ -616,8 +616,8 @@ func (as *ArrayStruct) FilterData(data frankenphp.AssociativeArray, filter strin expectedMixedCall := "structObj.FilterData(data, filter)" assert.Contains(t, content, expectedMixedCall, "Generated content should contain mixed method call: %s", expectedMixedCall) - assert.Contains(t, content, "//export ProcessArray_wrapper", "Generated content should contain ProcessArray export directive") - assert.Contains(t, content, "//export FilterData_wrapper", "Generated content should contain FilterData export directive") + assert.Contains(t, content, "//export ArrayClass_ProcessArray_wrapper", "Generated content should contain ProcessArray export directive") + assert.Contains(t, content, "//export ArrayClass_FilterData_wrapper", "Generated content should contain FilterData export directive") } func TestGoFileGenerator_Idempotency(t *testing.T) { @@ -1100,13 +1100,13 @@ func (nas *NullableArrayStruct) ProcessOptionalArray(items frankenphp.Associativ content, err := goGen.buildContent() require.NoError(t, err) - expectedWrapperSignature := "func ProcessOptionalArray_wrapper(handle C.uintptr_t, items *C.zval, name *C.zend_string) unsafe.Pointer" + expectedWrapperSignature := "func NullableArrayClass_ProcessOptionalArray_wrapper(handle C.uintptr_t, items *C.zval, name *C.zend_string) unsafe.Pointer" assert.Contains(t, content, expectedWrapperSignature, "Generated content should contain nullable array wrapper signature: %s", expectedWrapperSignature) expectedCall := "structObj.ProcessOptionalArray(items, name)" assert.Contains(t, content, expectedCall, "Generated content should contain method call: %s", expectedCall) - assert.Contains(t, content, "//export ProcessOptionalArray_wrapper", "Generated content should contain export directive") + assert.Contains(t, content, "//export NullableArrayClass_ProcessOptionalArray_wrapper", "Generated content should contain export directive") } func createTempSourceFile(t *testing.T, content string) string { @@ -1189,10 +1189,10 @@ func (cs *CallableStruct) ProcessOptionalCallback(callback *C.zval) string { content, err := goGen.buildContent() require.NoError(t, err) - expectedCallableWrapperSignature := "func ProcessCallback_wrapper(handle C.uintptr_t, callback *C.zval) unsafe.Pointer" + expectedCallableWrapperSignature := "func CallableClass_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" + expectedOptionalCallableWrapperSignature := "func CallableClass_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)" @@ -1201,8 +1201,8 @@ func (cs *CallableStruct) ProcessOptionalCallback(callback *C.zval) string { 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") + assert.Contains(t, content, "//export CallableClass_ProcessCallback_wrapper", "Generated content should contain ProcessCallback export directive") + assert.Contains(t, content, "//export CallableClass_ProcessOptionalCallback_wrapper", "Generated content should contain ProcessOptionalCallback export directive") } func TestGoFileGenerator_phpTypeToGoType(t *testing.T) { @@ -1288,3 +1288,40 @@ func assertContainsHeaderComment(t *testing.T, filename string) { assert.Contains(t, headerSection, "AUTOGENERATED FILE - DO NOT EDIT", "File should contain autogenerated header comment") assert.Contains(t, headerSection, "FrankenPHP extension generator", "File should mention FrankenPHP extension generator") } + +func TestGoFileGenerator_MethodWrappersAreClassQualified(t *testing.T) { + tmpDir := t.TempDir() + sourceFile := filepath.Join(tmpDir, "source.go") + require.NoError(t, os.WriteFile(sourceFile, []byte("package main\n"), 0644)) + + // Both classes expose a method with the same PHP name: the exported cgo + // symbols must not collide. + newClass := func(phpName, goStruct string) phpClass { + return phpClass{ + Name: phpName, + GoStruct: goStruct, + Methods: []phpClassMethod{{ + Name: "getName", + PhpName: "getName", + ClassName: phpName, + ReturnType: phpString, + GoFunction: "func (s *" + goStruct + ") GetName() unsafe.Pointer {\n\treturn nil\n}", + }}, + } + } + + generator := &Generator{ + BaseName: "collide", + SourceFile: sourceFile, + BuildDir: tmpDir, + Classes: []phpClass{newClass("User", "UserStruct"), newClass("Group", "GroupStruct")}, + } + + goGen := GoFileGenerator{generator} + content, err := goGen.buildContent() + require.NoError(t, err) + + assert.Contains(t, content, "//export User_getName_wrapper") + assert.Contains(t, content, "//export Group_getName_wrapper") + assert.NotContains(t, content, "//export getName_wrapper") +} diff --git a/internal/extgen/templates/extension.c.tpl b/internal/extgen/templates/extension.c.tpl index 0765417c3c..118e3fe302 100644 --- a/internal/extgen/templates/extension.c.tpl +++ b/internal/extgen/templates/extension.c.tpl @@ -132,22 +132,22 @@ PHP_METHOD({{namespacedClassName $.Namespace .ClassName}}, {{.PhpName}}) { {{- if ne .ReturnType "void"}} {{- if eq .ReturnType "string"}} - zend_string* result = {{.Name}}_wrapper(intern->go_handle{{range .Params}}, {{template "methodCallArg" .}}{{end}}); + zend_string* result = {{.ClassName}}_{{.Name}}_wrapper(intern->go_handle{{range .Params}}, {{template "methodCallArg" .}}{{end}}); if (result) { RETURN_STR(result); } RETURN_EMPTY_STRING(); {{- else if eq .ReturnType "int"}} - zend_long result = {{.Name}}_wrapper(intern->go_handle{{range .Params}}, {{template "methodCallArg" .}}{{end}}); + zend_long result = {{.ClassName}}_{{.Name}}_wrapper(intern->go_handle{{range .Params}}, {{template "methodCallArg" .}}{{end}}); RETURN_LONG(result); {{- else if eq .ReturnType "float"}} - double result = {{.Name}}_wrapper(intern->go_handle{{range .Params}}, {{template "methodCallArg" .}}{{end}}); + double result = {{.ClassName}}_{{.Name}}_wrapper(intern->go_handle{{range .Params}}, {{template "methodCallArg" .}}{{end}}); RETURN_DOUBLE(result); {{- else if eq .ReturnType "bool"}} - int result = {{.Name}}_wrapper(intern->go_handle{{range .Params}}, {{template "methodCallArg" .}}{{end}}); + int result = {{.ClassName}}_{{.Name}}_wrapper(intern->go_handle{{range .Params}}, {{template "methodCallArg" .}}{{end}}); RETURN_BOOL(result); {{- else if eq .ReturnType "array"}} - void* result = {{.Name}}_wrapper(intern->go_handle{{range .Params}}, {{template "methodCallArg" .}}{{end}}); + void* result = {{.ClassName}}_{{.Name}}_wrapper(intern->go_handle{{range .Params}}, {{template "methodCallArg" .}}{{end}}); if (result != NULL) { HashTable *ht = (HashTable*)result; RETURN_ARR(ht); @@ -156,7 +156,7 @@ PHP_METHOD({{namespacedClassName $.Namespace .ClassName}}, {{.PhpName}}) { } {{- end}} {{- else}} - {{.Name}}_wrapper(intern->go_handle{{range .Params}}, {{template "methodCallArg" .}}{{end}}); + {{.ClassName}}_{{.Name}}_wrapper(intern->go_handle{{range .Params}}, {{template "methodCallArg" .}}{{end}}); {{- end}} } {{end}}{{end}} diff --git a/internal/extgen/templates/extension.go.tpl b/internal/extgen/templates/extension.go.tpl index 313ec46f3e..95c470b378 100644 --- a/internal/extgen/templates/extension.go.tpl +++ b/internal/extgen/templates/extension.go.tpl @@ -58,8 +58,8 @@ func create_{{.GoStruct}}_object() C.uintptr_t { } {{- 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 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}} { +//export {{.ClassName}}_{{.Name}}_wrapper +func {{.ClassName}}_{{.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)}} From 092ac2e5ebd1a73a968d893c201da86524bbdd49 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Fri, 7 Aug 2026 09:54:36 +0200 Subject: [PATCH 05/17] fix(extgen): type method array and mixed parameters as the C side passes them The Go wrapper template hand-rolls its own PHP-to-Go parameter mapping, and it disagreed with what the C template actually passes: an array parameter was declared *C.zval while C passes a zend_array*, and mixed had no branch at all, so the C template emitted neither a declaration nor an argument and produced "Box_filter_wrapper(intern->go_handle, )". Type arrays as *C.zend_array and give mixed the zval branches it needs on both sides, matching what paramparser.go already does for standalone functions. --- internal/extgen/gofile_test.go | 6 +++--- internal/extgen/templates/extension.c.tpl | 8 +++++--- internal/extgen/templates/extension.go.tpl | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/internal/extgen/gofile_test.go b/internal/extgen/gofile_test.go index 878457e82f..3641238c9d 100644 --- a/internal/extgen/gofile_test.go +++ b/internal/extgen/gofile_test.go @@ -604,10 +604,10 @@ func (as *ArrayStruct) FilterData(data frankenphp.AssociativeArray, filter strin content, err := goGen.buildContent() require.NoError(t, err) - expectedArrayWrapperSignature := "func ArrayClass_ProcessArray_wrapper(handle C.uintptr_t, items *C.zval) unsafe.Pointer" + expectedArrayWrapperSignature := "func ArrayClass_ProcessArray_wrapper(handle C.uintptr_t, items *C.zend_array) unsafe.Pointer" assert.Contains(t, content, expectedArrayWrapperSignature, "Generated content should contain array wrapper signature: %s", expectedArrayWrapperSignature) - expectedMixedWrapperSignature := "func ArrayClass_FilterData_wrapper(handle C.uintptr_t, data *C.zval, filter *C.zend_string) unsafe.Pointer" + expectedMixedWrapperSignature := "func ArrayClass_FilterData_wrapper(handle C.uintptr_t, data *C.zend_array, filter *C.zend_string) unsafe.Pointer" assert.Contains(t, content, expectedMixedWrapperSignature, "Generated content should contain mixed wrapper signature: %s", expectedMixedWrapperSignature) expectedArrayCall := "structObj.ProcessArray(items)" @@ -1100,7 +1100,7 @@ func (nas *NullableArrayStruct) ProcessOptionalArray(items frankenphp.Associativ content, err := goGen.buildContent() require.NoError(t, err) - expectedWrapperSignature := "func NullableArrayClass_ProcessOptionalArray_wrapper(handle C.uintptr_t, items *C.zval, name *C.zend_string) unsafe.Pointer" + expectedWrapperSignature := "func NullableArrayClass_ProcessOptionalArray_wrapper(handle C.uintptr_t, items *C.zend_array, name *C.zend_string) unsafe.Pointer" assert.Contains(t, content, expectedWrapperSignature, "Generated content should contain nullable array wrapper signature: %s", expectedWrapperSignature) expectedCall := "structObj.ProcessOptionalArray(items, name)" diff --git a/internal/extgen/templates/extension.c.tpl b/internal/extgen/templates/extension.c.tpl index 118e3fe302..73d5396b4f 100644 --- a/internal/extgen/templates/extension.c.tpl +++ b/internal/extgen/templates/extension.c.tpl @@ -9,9 +9,9 @@ {{define "methodCallArg" -}} {{- 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}} +{{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 "mixed"}}{{.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}} +{{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 "mixed"}}{{.Name}}{{else if eq .PhpType "callable"}}{{.Name}}_callback{{end}} {{- end -}} {{- end}} @@ -113,6 +113,8 @@ PHP_METHOD({{namespacedClassName $.Namespace .ClassName}}, {{.PhpName}}) { zend_bool {{$param.Name}}_is_null = 0;{{end}} {{- else if eq $param.PhpType "array"}} zend_array *{{$param.Name}} = NULL; + {{- else if eq $param.PhpType "mixed"}} + zval *{{$param.Name}} = NULL; {{- else if eq $param.PhpType "callable"}} zval *{{$param.Name}}_callback = NULL; {{- end}} @@ -123,7 +125,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_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}} + {{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 "mixed"}}Z_PARAM_ZVAL_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 "mixed"}}Z_PARAM_ZVAL({{.Name}}){{else if eq .PhpType "callable"}}Z_PARAM_ZVAL({{.Name}}_callback){{end}}{{end}} {{end -}} ZEND_PARSE_PARAMETERS_END(); {{else}} diff --git a/internal/extgen/templates/extension.go.tpl b/internal/extgen/templates/extension.go.tpl index 95c470b378..51661ba51a 100644 --- a/internal/extgen/templates/extension.go.tpl +++ b/internal/extgen/templates/extension.go.tpl @@ -59,7 +59,7 @@ func create_{{.GoStruct}}_object() C.uintptr_t { {{- range .Methods}} //export {{.ClassName}}_{{.Name}}_wrapper -func {{.ClassName}}_{{.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}} { +func {{.ClassName}}_{{.Name}}_wrapper(handle C.uintptr_t{{range .Params}}{{if eq .PhpType "string"}}, {{.Name}} *C.zend_string{{else if eq .PhpType "array"}}, {{.Name}} *C.zend_array{{else if eq .PhpType "mixed"}}, {{.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)}} From 658e6ee3f81fa6bb969758260ea28240579213c3 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Fri, 7 Aug 2026 09:55:14 +0200 Subject: [PATCH 06/17] fix(extgen): return mixed values from generated functions and methods mixed is in supportedTypes, but generateReturnCode had no case for it, so a PHP_FUNCTION declared "zval *result = go_x();" and then dropped it: PHP always received null, plus an unused-variable warning. Class methods had no mixed return branch at all. The Go side could not work either, since phpReturnTypeToGoType mapped mixed to "any", which cgo refuses to export. Map mixed returns to unsafe.Pointer like string and array, and emit RETURN_COPY_VALUE on both the function and the method path. --- internal/extgen/cfile_phpmethod_test.go | 29 ++++++++++++++++++++++ internal/extgen/gofile.go | 5 ++-- internal/extgen/phpfunc.go | 10 ++++++-- internal/extgen/phpfunc_test.go | 19 ++++++++++++++ internal/extgen/templates/extension.c.tpl | 7 ++++++ internal/extgen/templates/extension.go.tpl | 4 +-- internal/extgen/validator.go | 2 +- internal/extgen/validator_test.go | 1 + 8 files changed, 70 insertions(+), 7 deletions(-) diff --git a/internal/extgen/cfile_phpmethod_test.go b/internal/extgen/cfile_phpmethod_test.go index 7e666dcd8e..98e98b3812 100644 --- a/internal/extgen/cfile_phpmethod_test.go +++ b/internal/extgen/cfile_phpmethod_test.go @@ -3,6 +3,7 @@ package extgen import ( "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -338,3 +339,31 @@ func TestCFile_ClassMethodParamCastsByParamType(t *testing.T) { }) } } + +func TestCFileGenerator_MethodMixedParamAndReturn(t *testing.T) { + tmpDir := t.TempDir() + generator := &Generator{ + BaseName: "mixed_ext", + BuildDir: tmpDir, + Classes: []phpClass{{ + Name: "Box", + GoStruct: "Box", + Methods: []phpClassMethod{{ + Name: "swap", + PhpName: "swap", + ClassName: "Box", + ReturnType: phpMixed, + Params: []phpParameter{{Name: "value", PhpType: phpMixed}}, + }}, + }}, + } + + cGen := cFileGenerator{generator} + content, err := cGen.buildContent() + require.NoError(t, err) + + assert.Contains(t, content, "zval *value = NULL;", "mixed parameter must be declared as a zval") + assert.Contains(t, content, "Z_PARAM_ZVAL(value)", "mixed parameter must be parsed as a zval") + assert.Contains(t, content, "Box_swap_wrapper(intern->go_handle, value)", "mixed parameter must be forwarded to the Go wrapper") + assert.Contains(t, content, "RETURN_COPY_VALUE(result);", "mixed return value must reach PHP") +} diff --git a/internal/extgen/gofile.go b/internal/extgen/gofile.go index 044696a47f..03128e8a37 100644 --- a/internal/extgen/gofile.go +++ b/internal/extgen/gofile.go @@ -77,8 +77,9 @@ func (gg *GoFileGenerator) buildContent() (string, error) { func (gg *GoFileGenerator) getTemplateContent(data goTemplateData) (string, error) { funcMap := sprig.FuncMap() funcMap["phpTypeToGoType"] = gg.phpTypeToGoType - funcMap["isStringOrArray"] = func(t phpType) bool { - return t == phpString || t == phpArray + // Values PHP owns as pointers cross the cgo boundary as unsafe.Pointer. + funcMap["isPointerReturn"] = func(t phpType) bool { + return t == phpString || t == phpArray || t == phpMixed } funcMap["isVoid"] = func(t phpType) bool { return t == phpVoid diff --git a/internal/extgen/phpfunc.go b/internal/extgen/phpfunc.go index 66a0123eeb..2e2ae9a760 100644 --- a/internal/extgen/phpfunc.go +++ b/internal/extgen/phpfunc.go @@ -78,7 +78,7 @@ func (pfg *PHPFuncGenerator) generateReturnCode(returnType phpType) string { RETURN_STR(result); } - RETURN_EMPTY_STRING();` + RETURN_EMPTY_STRING();` case phpInt: return ` RETURN_LONG(result);` case phpFloat: @@ -90,7 +90,13 @@ func (pfg *PHPFuncGenerator) generateReturnCode(returnType phpType) string { RETURN_ARR(result); } - RETURN_EMPTY_ARRAY();` + RETURN_EMPTY_ARRAY();` + case phpMixed: + return ` if (result) { + RETURN_COPY_VALUE(result); + } + + RETURN_NULL();` default: return "" } diff --git a/internal/extgen/phpfunc_test.go b/internal/extgen/phpfunc_test.go index 3a0365ccd5..354447b004 100644 --- a/internal/extgen/phpfunc_test.go +++ b/internal/extgen/phpfunc_test.go @@ -419,3 +419,22 @@ func TestPHPFunctionGenerator_AnalyzeParameters(t *testing.T) { }) } } + +func TestPHPFunctionGenerator_MixedReturn(t *testing.T) { + generator := PHPFuncGenerator{paramParser: &ParameterParser{}} + + t.Run("mixed return value is forwarded to PHP", func(t *testing.T) { + result := generator.generate(phpFunction{Name: "pick", ReturnType: phpMixed}) + + assert.Contains(t, result, "zval *result = go_pick();") + assert.Contains(t, result, "RETURN_COPY_VALUE(result);") + assert.Contains(t, result, "RETURN_NULL();") + }) + + t.Run("void return declares no result", func(t *testing.T) { + result := generator.generate(phpFunction{Name: "run", ReturnType: phpVoid}) + + assert.Contains(t, result, "go_run();") + assert.NotContains(t, result, "result") + }) +} diff --git a/internal/extgen/templates/extension.c.tpl b/internal/extgen/templates/extension.c.tpl index 73d5396b4f..9a03bace2e 100644 --- a/internal/extgen/templates/extension.c.tpl +++ b/internal/extgen/templates/extension.c.tpl @@ -156,6 +156,13 @@ PHP_METHOD({{namespacedClassName $.Namespace .ClassName}}, {{.PhpName}}) { } else { RETURN_NULL(); } + {{- else if eq .ReturnType "mixed"}} + zval* result = {{.ClassName}}_{{.Name}}_wrapper(intern->go_handle{{range .Params}}, {{template "methodCallArg" .}}{{end}}); + if (result != NULL) { + RETURN_COPY_VALUE(result); + } else { + RETURN_NULL(); + } {{- end}} {{- else}} {{.ClassName}}_{{.Name}}_wrapper(intern->go_handle{{range .Params}}, {{template "methodCallArg" .}}{{end}}); diff --git a/internal/extgen/templates/extension.go.tpl b/internal/extgen/templates/extension.go.tpl index 51661ba51a..e3ecdbdeca 100644 --- a/internal/extgen/templates/extension.go.tpl +++ b/internal/extgen/templates/extension.go.tpl @@ -59,11 +59,11 @@ func create_{{.GoStruct}}_object() C.uintptr_t { {{- range .Methods}} //export {{.ClassName}}_{{.Name}}_wrapper -func {{.ClassName}}_{{.Name}}_wrapper(handle C.uintptr_t{{range .Params}}{{if eq .PhpType "string"}}, {{.Name}} *C.zend_string{{else if eq .PhpType "array"}}, {{.Name}} *C.zend_array{{else if eq .PhpType "mixed"}}, {{.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}} { +func {{.ClassName}}_{{.Name}}_wrapper(handle C.uintptr_t{{range .Params}}{{if eq .PhpType "string"}}, {{.Name}} *C.zend_string{{else if eq .PhpType "array"}}, {{.Name}} *C.zend_array{{else if eq .PhpType "mixed"}}, {{.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 isPointerReturn .ReturnType}} unsafe.Pointer{{else}} {{phpTypeToGoType .ReturnType}}{{end}}{{end}} { obj := getGoObject(handle) if obj == nil { {{- if not (isVoid .ReturnType)}} -{{- if isStringOrArray .ReturnType}} +{{- if isPointerReturn .ReturnType}} return nil {{- else}} var zero {{phpTypeToGoType .ReturnType}} diff --git a/internal/extgen/validator.go b/internal/extgen/validator.go index 8e438ea235..8c496a78d1 100644 --- a/internal/extgen/validator.go +++ b/internal/extgen/validator.go @@ -233,7 +233,7 @@ func (v *Validator) phpReturnTypeToGoType(phpReturnType phpType) string { return "float64" case phpBool: return "bool" - case phpArray: + case phpArray, phpMixed: return "unsafe.Pointer" default: return "any" diff --git a/internal/extgen/validator_test.go b/internal/extgen/validator_test.go index aed31d34e9..8bea76821a 100644 --- a/internal/extgen/validator_test.go +++ b/internal/extgen/validator_test.go @@ -901,6 +901,7 @@ func TestPhpReturnTypeToGoType(t *testing.T) { {"bool", "bool"}, {"array", "unsafe.Pointer"}, {"array", "unsafe.Pointer"}, + {"mixed", "unsafe.Pointer"}, {"unknown", "any"}, } From 35a9c25b8756b6033001e518a4c39b24f725de97 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Fri, 7 Aug 2026 09:55:49 +0200 Subject: [PATCH 07/17] fix(extgen): call the Go method actually backing the PHP method The wrapper called structObj.{{.Name | title}}, guessing the Go method name from the PHP one, while nothing ever compares the two names. A method exported as Cache::get_value() backed by (*Cache).GetValue() generated structObj.Get_Value() -- sprig's title capitalizes after every non-letter -- and the extension failed to build. Use the Go source already captured on the method, and teach extractGoFunctionName to skip a receiver so it works for methods. --- internal/extgen/gofile.go | 21 +++++++---- internal/extgen/gofile_test.go | 43 ++++++++++++++++++++++ internal/extgen/templates/extension.go.tpl | 2 +- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/internal/extgen/gofile.go b/internal/extgen/gofile.go index 03128e8a37..1d0c254f02 100644 --- a/internal/extgen/gofile.go +++ b/internal/extgen/gofile.go @@ -129,25 +129,30 @@ func (gg *GoFileGenerator) phpTypeToGoType(phpT phpType) string { return "any" } -// extractGoFunctionName extracts the Go function name from a Go function signature string. +// extractGoFunctionName extracts the Go function or method name from a Go +// function signature string. func extractGoFunctionName(goFunction string) string { idx := strings.Index(goFunction, "func ") if idx == -1 { return "" } - start := idx + len("func ") - - end := start - for end < len(goFunction) && goFunction[end] != '(' { - end++ + rest := strings.TrimLeft(goFunction[idx+len("func "):], " \t") + if strings.HasPrefix(rest, "(") { + // method: skip the receiver so the name, not the receiver, is returned + closing := strings.IndexByte(rest, ')') + if closing == -1 { + return "" + } + rest = rest[closing+1:] } - if end >= len(goFunction) { + end := strings.IndexByte(rest, '(') + if end == -1 { return "" } - return strings.TrimSpace(goFunction[start:end]) + return strings.TrimSpace(rest[:end]) } // extractGoFunctionSignatureParams extracts the parameters from a Go function signature. diff --git a/internal/extgen/gofile_test.go b/internal/extgen/gofile_test.go index 3641238c9d..557f0f1e2e 100644 --- a/internal/extgen/gofile_test.go +++ b/internal/extgen/gofile_test.go @@ -242,6 +242,7 @@ func (ts *TestStruct) GetValue() string { { Name: "GetValue", ReturnType: phpString, + GoFunction: "func (ts *TestStruct) GetValue() unsafe.Pointer {\n\treturn nil\n}", }, }, }, @@ -741,6 +742,16 @@ func TestExtractGoFunctionName(t *testing.T) { input: "func spacedName () {}", expected: "spacedName", }, + { + name: "method with a pointer receiver", + input: "func (s *MyStruct) getValue() string {}", + expected: "getValue", + }, + { + name: "method with a value receiver", + input: "func (s MyStruct) get_value() string {}", + expected: "get_value", + }, { name: "no func keyword", input: "test() {}", @@ -1325,3 +1336,35 @@ func TestGoFileGenerator_MethodWrappersAreClassQualified(t *testing.T) { assert.Contains(t, content, "//export Group_getName_wrapper") assert.NotContains(t, content, "//export getName_wrapper") } + +func TestGoFileGenerator_MethodWrapperUsesActualGoName(t *testing.T) { + tmpDir := t.TempDir() + sourceFile := filepath.Join(tmpDir, "source.go") + require.NoError(t, os.WriteFile(sourceFile, []byte("package main\n"), 0644)) + + generator := &Generator{ + BaseName: "snake_test", + SourceFile: sourceFile, + BuildDir: tmpDir, + Classes: []phpClass{{ + Name: "SnakeClass", + GoStruct: "SnakeStruct", + Methods: []phpClassMethod{ + { + Name: "get_value", + PhpName: "get_value", + ClassName: "SnakeClass", + ReturnType: phpInt, + GoFunction: "func (s *SnakeStruct) GetValue() int64 {\n\treturn 42\n}", + }, + }, + }}, + } + + goGen := GoFileGenerator{generator} + content, err := goGen.buildContent() + require.NoError(t, err) + + assert.Contains(t, content, "return structObj.GetValue()") + assert.NotContains(t, content, "structObj.Get_Value(") +} diff --git a/internal/extgen/templates/extension.go.tpl b/internal/extgen/templates/extension.go.tpl index e3ecdbdeca..6dfd0d3ac1 100644 --- a/internal/extgen/templates/extension.go.tpl +++ b/internal/extgen/templates/extension.go.tpl @@ -74,7 +74,7 @@ func {{.ClassName}}_{{.Name}}_wrapper(handle C.uintptr_t{{range .Params}}{{if eq {{- end}} } structObj := obj.(*{{$class.GoStruct}}) - {{if not (isVoid .ReturnType)}}return {{end}}structObj.{{.Name | title}}({{range $i, $param := .Params}}{{if $i}}, {{end}}{{$param.Name}}{{end}}) + {{if not (isVoid .ReturnType)}}return {{end}}structObj.{{extractGoFunctionName .GoFunction}}({{range $i, $param := .Params}}{{if $i}}, {{end}}{{$param.Name}}{{end}}) } {{end}} {{- end}} From 107bab5d218577fdefbb140024d69185bb72ee4d Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Fri, 7 Aug 2026 09:56:21 +0200 Subject: [PATCH 08/17] refactor(extgen): derive generated Go signatures from the validator Four places mapped PHP types to Go types: phpToGoTypeMap, the validator's phpTypeToGoType and phpReturnTypeToGoType, and a per-type if-chain in the wrapper template that shadowed the map for exactly the types where they disagreed. That drift is what typed array parameters as *C.zval and left mixed with no branch at all. Call the validator's mapping straight from the template, so the generator cannot emit a signature the validator would reject. phpToGoTypeMap goes away with it, along with the test that pinned its "string" and "*frankenphp.Array" entries -- neither ever appeared in generated output. --- internal/extgen/gofile.go | 40 +++------------------- internal/extgen/gofile_test.go | 32 ----------------- internal/extgen/templates/extension.go.tpl | 12 +++---- 3 files changed, 9 insertions(+), 75 deletions(-) diff --git a/internal/extgen/gofile.go b/internal/extgen/gofile.go index 1d0c254f02..952c60bfde 100644 --- a/internal/extgen/gofile.go +++ b/internal/extgen/gofile.go @@ -76,11 +76,11 @@ func (gg *GoFileGenerator) buildContent() (string, error) { func (gg *GoFileGenerator) getTemplateContent(data goTemplateData) (string, error) { funcMap := sprig.FuncMap() - funcMap["phpTypeToGoType"] = gg.phpTypeToGoType - // Values PHP owns as pointers cross the cgo boundary as unsafe.Pointer. - funcMap["isPointerReturn"] = func(t phpType) bool { - return t == phpString || t == phpArray || t == phpMixed - } + // Reuse the validator's mapping so the signatures the generator emits cannot + // drift from the ones it accepts. + validator := &Validator{} + funcMap["goParamType"] = validator.phpTypeToGoType + funcMap["goReturnType"] = validator.phpReturnTypeToGoType funcMap["isVoid"] = func(t phpType) bool { return t == phpVoid } @@ -99,36 +99,6 @@ func (gg *GoFileGenerator) getTemplateContent(data goTemplateData) (string, erro return buf.String(), nil } -type GoMethodSignature struct { - MethodName string - Params []GoParameter - ReturnType string -} - -type GoParameter struct { - Name string - Type string -} - -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 { - if goType, exists := phpToGoTypeMap[phpT]; exists { - return goType - } - - return "any" -} - // extractGoFunctionName extracts the Go function or method name from a Go // function signature string. func extractGoFunctionName(goFunction string) string { diff --git a/internal/extgen/gofile_test.go b/internal/extgen/gofile_test.go index 557f0f1e2e..196ca4214f 100644 --- a/internal/extgen/gofile_test.go +++ b/internal/extgen/gofile_test.go @@ -1216,38 +1216,6 @@ func (cs *CallableStruct) ProcessOptionalCallback(callback *C.zval) string { assert.Contains(t, content, "//export CallableClass_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 testGeneratedFileBasicStructure(t *testing.T, content, expectedPackage, baseName string) { requiredElements := []string{ "package " + expectedPackage, diff --git a/internal/extgen/templates/extension.go.tpl b/internal/extgen/templates/extension.go.tpl index 6dfd0d3ac1..d5d8889fc8 100644 --- a/internal/extgen/templates/extension.go.tpl +++ b/internal/extgen/templates/extension.go.tpl @@ -59,18 +59,14 @@ func create_{{.GoStruct}}_object() C.uintptr_t { {{- range .Methods}} //export {{.ClassName}}_{{.Name}}_wrapper -func {{.ClassName}}_{{.Name}}_wrapper(handle C.uintptr_t{{range .Params}}{{if eq .PhpType "string"}}, {{.Name}} *C.zend_string{{else if eq .PhpType "array"}}, {{.Name}} *C.zend_array{{else if eq .PhpType "mixed"}}, {{.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 isPointerReturn .ReturnType}} unsafe.Pointer{{else}} {{phpTypeToGoType .ReturnType}}{{end}}{{end}} { +func {{.ClassName}}_{{.Name}}_wrapper(handle C.uintptr_t{{range .Params}}, {{.Name}} {{goParamType .PhpType .IsNullable}}{{end}}){{if not (isVoid .ReturnType)}} {{goReturnType .ReturnType}}{{end}} { obj := getGoObject(handle) if obj == nil { -{{- if not (isVoid .ReturnType)}} -{{- if isPointerReturn .ReturnType}} - return nil +{{- if isVoid .ReturnType}} + return {{- else}} - var zero {{phpTypeToGoType .ReturnType}} + var zero {{goReturnType .ReturnType}} return zero -{{- end}} -{{- else}} - return {{- end}} } structObj := obj.(*{{$class.GoStruct}}) From 34282f4397a119b64470bd9df275f2c1e7e482b4 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Fri, 7 Aug 2026 09:56:52 +0200 Subject: [PATCH 09/17] fix(extgen): normalize Go-only integer literals for the C output CValue only rewrote the "0o" prefix, so digit separators and binary literals reached the generated C verbatim: "const MAX = 1_000_000" emitted #define MAX 1_000_000 and REGISTER_LONG_CONSTANT("MAX", 1_000_000, ...), both syntax errors. determineConstantType accepts them via ParseInt base 0, so they were typed as int and passed straight through. Reformat the spellings C cannot read as decimal; hexadecimal and C octal are left as written. --- internal/extgen/constparser_test.go | 35 +++++++++++++++++++++++++++++ internal/extgen/nodes.go | 17 ++++++++------ 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/internal/extgen/constparser_test.go b/internal/extgen/constparser_test.go index bf3ba2d9bb..57d5ee0984 100644 --- a/internal/extgen/constparser_test.go +++ b/internal/extgen/constparser_test.go @@ -744,3 +744,38 @@ func TestPHPConstantCValue(t *testing.T) { }) } } + +func TestConstantParserGoOnlyIntLiterals(t *testing.T) { + input := `package main + +//export_php:const +const UNDERSCORED = 1_000_000 + +//export_php:const +const BINARY = 0b1010 + +//export_php:const +const HEXADECIMAL = 0xFF` + + tmpFile := filepath.Join(t.TempDir(), "literals.go") + require.NoError(t, os.WriteFile(tmpFile, []byte(input), 0644)) + + parser := &ConstantParser{} + constants, err := parser.parse(tmpFile) + require.NoError(t, err) + require.Len(t, constants, 3) + + byName := make(map[string]phpConstant, len(constants)) + for _, c := range constants { + byName[c.Name] = c + } + + // PHP understands Go's digit separators and base prefixes, C does not. + assert.Equal(t, "1_000_000", byName["UNDERSCORED"].Value) + assert.Equal(t, "1000000", byName["UNDERSCORED"].CValue()) + assert.Equal(t, "0b1010", byName["BINARY"].Value) + assert.Equal(t, "10", byName["BINARY"].CValue()) + + // hexadecimal is valid C and is left alone + assert.Equal(t, "0xFF", byName["HEXADECIMAL"].CValue()) +} diff --git a/internal/extgen/nodes.go b/internal/extgen/nodes.go index 5afd1e38a0..3017bc51f1 100644 --- a/internal/extgen/nodes.go +++ b/internal/extgen/nodes.go @@ -1,8 +1,8 @@ package extgen import ( + "regexp" "strconv" - "strings" ) // phpType represents a PHP type @@ -77,17 +77,20 @@ type phpConstant struct { ClassName string // empty for global constants, set for class constants } +// goOnlyIntLiteral matches the integer spellings Go accepts but C does not: +// the "0o"/"0b" base prefixes and digit separators. +var goOnlyIntLiteral = regexp.MustCompile(`^[+-]?0[oObB]|_`) + // CValue returns the constant value in C-compatible format func (c phpConstant) CValue() string { - if c.PhpType != phpInt { + if c.PhpType != phpInt || !goOnlyIntLiteral.MatchString(c.Value) { return c.Value } - if strings.HasPrefix(c.Value, "0o") { - if val, err := strconv.ParseInt(c.Value, 0, 64); err == nil { - return strconv.FormatInt(val, 10) - } + val, err := strconv.ParseInt(c.Value, 0, 64) + if err != nil { + return c.Value } - return c.Value + return strconv.FormatInt(val, 10) } From bcba07f0e8fa73659ac273fc84de1a69f15f3209 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Fri, 7 Aug 2026 09:57:17 +0200 Subject: [PATCH 10/17] fix(extgen): re-quote raw string constants determineConstantType classifies a backquoted Go raw string as phpString, but the literal was stored and emitted unchanged, giving REGISTER_STRING_CONSTANT("GREETING", `hi`, ...) in C -- backticks are not a string delimiter there -- and a PHP stub that gen_stub.php cannot parse. Re-quote raw strings as regular double-quoted strings, which both languages accept. --- internal/extgen/constparser.go | 20 ++++++++++++++++++-- internal/extgen/constparser_test.go | 17 +++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/internal/extgen/constparser.go b/internal/extgen/constparser.go index 00a562519c..7df3455899 100644 --- a/internal/extgen/constparser.go +++ b/internal/extgen/constparser.go @@ -84,7 +84,7 @@ func (cp *ConstantParser) parse(filename string) (constants []phpConstant, err e matches := constDeclRegex.FindStringSubmatch(line) if len(matches) == 3 { name := matches[1] - value := strings.TrimSpace(matches[2]) + value := normalizeConstValue(strings.TrimSpace(matches[2])) constant := phpConstant{ Name: name, @@ -113,7 +113,7 @@ func (cp *ConstantParser) parse(filename string) (constants []phpConstant, err e } else if inConstBlock && (expectConstDecl || expectClassConstDecl || exportAllInBlock) { if matches := constBlockDeclRegex.FindStringSubmatch(line); len(matches) == 3 { name := matches[1] - value := strings.TrimSpace(matches[2]) + value := normalizeConstValue(strings.TrimSpace(matches[2])) constant := phpConstant{ Name: name, @@ -177,6 +177,22 @@ func (cp *ConstantParser) parse(filename string) (constants []phpConstant, err e return constants, scanner.Err() } +// normalizeConstValue rewrites Go literals that neither C nor PHP understand. +// Only raw string literals qualify: they are re-quoted as regular double-quoted +// strings, which both languages accept. +func normalizeConstValue(value string) string { + if !strings.HasPrefix(value, "`") || !strings.HasSuffix(value, "`") || len(value) < 2 { + return value + } + + unquoted, err := strconv.Unquote(value) + if err != nil { + return value + } + + return strconv.Quote(unquoted) +} + // determineConstantType analyzes the value and determines its type func determineConstantType(value string) phpType { value = strings.TrimSpace(value) diff --git a/internal/extgen/constparser_test.go b/internal/extgen/constparser_test.go index 57d5ee0984..de21f8a21a 100644 --- a/internal/extgen/constparser_test.go +++ b/internal/extgen/constparser_test.go @@ -779,3 +779,20 @@ const HEXADECIMAL = 0xFF` // hexadecimal is valid C and is left alone assert.Equal(t, "0xFF", byName["HEXADECIMAL"].CValue()) } + +func TestConstantParserRawStringConstant(t *testing.T) { + input := "package main\n\n//export_php:const\nconst RAW = `raw \"quoted\" value`" + + tmpFile := filepath.Join(t.TempDir(), "raw.go") + require.NoError(t, os.WriteFile(tmpFile, []byte(input), 0644)) + + parser := &ConstantParser{} + constants, err := parser.parse(tmpFile) + require.NoError(t, err) + require.Len(t, constants, 1) + + // backticks delimit a string in Go only: both C and PHP need double quotes + assert.Equal(t, phpString, constants[0].PhpType) + assert.Equal(t, `"raw \"quoted\" value"`, constants[0].Value) + assert.Equal(t, `"raw \"quoted\" value"`, constants[0].CValue()) +} From 27d3447e4926668bbcc595646f837237e10968f0 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Fri, 7 Aug 2026 09:57:41 +0200 Subject: [PATCH 11/17] fix(extgen): accept typed constant declarations Both constant regexes required the name to be followed directly by "=", so a constant declared with an explicit Go type matched neither. "//export_php:const" above "const Perm os.FileMode = 0o755" aborted the whole generation with "invalid constant declaration at line N", and inside a const block "SMALL int64 = 1" was dropped without a warning. Allow an optional type between the name and "=". --- internal/extgen/constparser.go | 7 +++++-- internal/extgen/constparser_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/internal/extgen/constparser.go b/internal/extgen/constparser.go index 7df3455899..744683164b 100644 --- a/internal/extgen/constparser.go +++ b/internal/extgen/constparser.go @@ -12,8 +12,11 @@ import ( var constRegex = regexp.MustCompile(`//\s*export_php:const$`) var classConstRegex = regexp.MustCompile(`//\s*export_php:classconst\s+(\w+)$`) -var constDeclRegex = regexp.MustCompile(`const\s+(\w+)\s*=\s*(.+)`) -var constBlockDeclRegex = regexp.MustCompile(`^(\w+)\s*=\s*(.+)$`) + +// The optional group before "=" is the explicit Go type of a typed constant +// declaration such as "const Perm os.FileMode = 0o755". +var constDeclRegex = regexp.MustCompile(`const\s+(\w+)(?:\s+[\w.\[\]*]+)?\s*=\s*(.+)`) +var constBlockDeclRegex = regexp.MustCompile(`^(\w+)(?:\s+[\w.\[\]*]+)?\s*=\s*(.+)$`) var constNameRegex = regexp.MustCompile(`^(\w+)$`) type ConstantParser struct{} diff --git a/internal/extgen/constparser_test.go b/internal/extgen/constparser_test.go index de21f8a21a..6019a8f3ef 100644 --- a/internal/extgen/constparser_test.go +++ b/internal/extgen/constparser_test.go @@ -796,3 +796,31 @@ func TestConstantParserRawStringConstant(t *testing.T) { assert.Equal(t, `"raw \"quoted\" value"`, constants[0].Value) assert.Equal(t, `"raw \"quoted\" value"`, constants[0].CValue()) } + +func TestConstantParserTypedConstants(t *testing.T) { + input := `package main + +//export_php:const +const PERM os.FileMode = 0o755 + +//export_php:const +const ( + SMALL int64 = 1 + LARGE = 2 +)` + + tmpFile := filepath.Join(t.TempDir(), "typed.go") + require.NoError(t, os.WriteFile(tmpFile, []byte(input), 0644)) + + parser := &ConstantParser{} + constants, err := parser.parse(tmpFile) + require.NoError(t, err) + require.Len(t, constants, 3) + + assert.Equal(t, "PERM", constants[0].Name) + assert.Equal(t, "493", constants[0].CValue()) + assert.Equal(t, "SMALL", constants[1].Name) + assert.Equal(t, "1", constants[1].Value) + assert.Equal(t, "LARGE", constants[2].Name) + assert.Equal(t, "2", constants[2].Value) +} From 17aa48234ab364d43a6b69e5941658007e9d70c7 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Fri, 7 Aug 2026 09:58:12 +0200 Subject: [PATCH 12/17] fix(extgen): reject methods targeting an unexported class A method is only attached to a class whose name matches an //export_php:class directive; a method naming any other class was dropped without a warning, while an orphan class directive is already a hard error. Renaming a struct's exported class and forgetting one //export_php:method produced an extension where that method simply did not exist, and generation still reported success. Match methods against the declared class directives, which also puts the directive's class name -- collected but never read until now -- to use. --- internal/extgen/classparser.go | 10 ++++++ internal/extgen/classparser_test.go | 51 +++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/internal/extgen/classparser.go b/internal/extgen/classparser.go index 52894daa2a..f8214da05f 100644 --- a/internal/extgen/classparser.go +++ b/internal/extgen/classparser.go @@ -7,6 +7,7 @@ import ( "go/token" "os" "regexp" + "slices" "strings" ) @@ -97,6 +98,15 @@ func (cp *classParser) parse(filename string) (classes []phpClass, err error) { } } + // Match against the declared directives rather than the classes that survived + // validation, so an invalid class does not turn its methods into a misleading + // "never exported" error. + for _, method := range methods { + if !slices.ContainsFunc(exportDirectives, func(d exportDirective) bool { return d.className == method.ClassName }) { + return nil, fmt.Errorf("//export_php:method directive at line %d targets class %q, which is not exported by any //export_php:class directive", method.lineNumber, method.ClassName) + } + } + return classes, nil } diff --git a/internal/extgen/classparser_test.go b/internal/extgen/classparser_test.go index 35df47d96e..743252ffbc 100644 --- a/internal/extgen/classparser_test.go +++ b/internal/extgen/classparser_test.go @@ -639,3 +639,54 @@ func validFloat(tc *TestClass, value float64) float64 { }) } } + +func TestClassParserMethodOnUnknownClass(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + { + name: "method targeting an exported class is accepted", + input: `package main + +//export_php:class Known +type Known struct{} + +//export_php:method Known::getName(): string +func (k *Known) getName() unsafe.Pointer { return nil }`, + }, + { + name: "method targeting a class nobody exports is rejected", + input: `package main + +//export_php:class Known +type Known struct{} + +//export_php:method Unknown::getName(): string +func (u *Unknown) getName() unsafe.Pointer { return nil }`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "class.go") + require.NoError(t, os.WriteFile(tmpFile, []byte(tt.input), 0644)) + + parser := classParser{} + classes, err := parser.parse(tmpFile) + + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), `targets class "Unknown"`) + + return + } + + require.NoError(t, err) + require.Len(t, classes, 1) + assert.Len(t, classes[0].Methods, 1) + }) + } +} From 0a3e190a5c1ca536dcc504b8bb24a9f7b4ebb4d7 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Fri, 7 Aug 2026 09:58:45 +0200 Subject: [PATCH 13/17] refactor(extgen): parse the source once in the class parser parse built a full AST, then parseMethods read the file again and built a second AST of the same source with its own FileSet, so line numbers from the two halves were not comparable by construction. Pass the source and the AST down instead. While there, drop the double regexp pass over the directive comment: findDirective's capture was discarded and the comment re-scanned with findMatchingComment. Returning the *ast.Comment gives both the payload and the position in one match. --- internal/extgen/classparser.go | 40 +++++++++++++++------------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/internal/extgen/classparser.go b/internal/extgen/classparser.go index f8214da05f..847df938e9 100644 --- a/internal/extgen/classparser.go +++ b/internal/extgen/classparser.go @@ -26,8 +26,13 @@ func (cp *classParser) Parse(filename string) ([]phpClass, error) { } func (cp *classParser) parse(filename string) (classes []phpClass, err error) { + src, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + fset := token.NewFileSet() - node, err := parser.ParseFile(fset, filename, nil, parser.ParseComments) + node, err := parser.ParseFile(fset, filename, src, parser.ParseComments) if err != nil { return nil, fmt.Errorf("parsing file: %w", err) } @@ -35,7 +40,7 @@ func (cp *classParser) parse(filename string) (classes []phpClass, err error) { validator := Validator{} exportDirectives := cp.collectExportDirectives(node, fset) - methods, err := cp.parseMethods(filename) + methods, err := cp.parseMethods(src, node, fset) if err != nil { return nil, fmt.Errorf("parsing methods: %w", err) } @@ -211,18 +216,7 @@ func (cp *classParser) goTypeToPHPType(goType string) phpType { return phpMixed } -func (cp *classParser) parseMethods(filename string) ([]phpClassMethod, error) { - src, err := os.ReadFile(filename) - if err != nil { - return nil, err - } - - fset := token.NewFileSet() - file, err := parser.ParseFile(fset, filename, src, parser.ParseComments) - if err != nil { - return nil, fmt.Errorf("parsing file: %w", err) - } - +func (cp *classParser) parseMethods(src []byte, file *ast.File, fset *token.FileSet) ([]phpClassMethod, error) { validator := Validator{} var methods []phpClassMethod consumed := make(map[int]bool) @@ -233,16 +227,18 @@ func (cp *classParser) parseMethods(filename string) ([]phpClassMethod, error) { continue } - directive, directiveLine := findDirective(funcDecl.Doc, fset, phpMethodRegex) - if directive == "" { + comment := findMatchingComment(funcDecl.Doc, phpMethodRegex) + if comment == nil { continue } - rawMatch := phpMethodRegex.FindStringSubmatch(findMatchingComment(funcDecl.Doc, phpMethodRegex)) + + rawMatch := phpMethodRegex.FindStringSubmatch(comment.Text) if len(rawMatch) != 3 { continue } className := strings.TrimSpace(rawMatch[1]) signature := strings.TrimSpace(rawMatch[2]) + directiveLine := fset.Position(comment.Pos()).Line consumed[directiveLine] = true method, err := cp.parseMethodSignature(className, signature) @@ -282,17 +278,17 @@ func (cp *classParser) parseMethods(filename string) ([]phpClassMethod, error) { return methods, nil } -// findMatchingComment returns the raw comment text whose line matches re. -func findMatchingComment(group *ast.CommentGroup, re *regexp.Regexp) string { +// findMatchingComment returns the first comment of the group matching re. +func findMatchingComment(group *ast.CommentGroup, re *regexp.Regexp) *ast.Comment { if group == nil { - return "" + return nil } for _, comment := range group.List { if re.MatchString(comment.Text) { - return comment.Text + return comment } } - return "" + return nil } func (cp *classParser) parseMethodSignature(className, signature string) (*phpClassMethod, error) { From 46fcde21bef5663902dbe1d5845c16876cbe6765 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Fri, 7 Aug 2026 09:59:10 +0200 Subject: [PATCH 14/17] refactor(extgen): route class parser warnings through warnf Four warning sites wrote to os.Stderr directly instead of the package's warnf helper, which exists so tests can capture warnings via warnOut. The whole warn-and-skip path of the class parser was therefore untestable, and its warnings landed on a different stream than the function parser's. --- internal/extgen/classparser.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/extgen/classparser.go b/internal/extgen/classparser.go index 847df938e9..db9d0933a7 100644 --- a/internal/extgen/classparser.go +++ b/internal/extgen/classparser.go @@ -89,7 +89,7 @@ func (cp *classParser) parse(filename string) (classes []phpClass, err error) { } if err := validator.validateClass(class); err != nil { - fmt.Fprintf(os.Stderr, "Warning: Invalid class '%s': %v\n", class.Name, err) + warnf("Warning: Invalid class %q: %v\n", class.Name, err) continue } @@ -243,7 +243,7 @@ func (cp *classParser) parseMethods(src []byte, file *ast.File, fset *token.File method, err := cp.parseMethodSignature(className, signature) if err != nil { - fmt.Fprintf(os.Stderr, "Warning: Error parsing method signature %q: %v\n", signature, err) + warnf("Warning: Error parsing method signature %q: %v\n", signature, err) continue } @@ -255,7 +255,7 @@ func (cp *classParser) parseMethods(src []byte, file *ast.File, fset *token.File IsReturnNullable: method.isReturnNullable, } if err := validator.validateTypes(phpFunc); err != nil { - fmt.Fprintf(os.Stderr, "Warning: Method \"%s::%s\" uses unsupported types: %v\n", className, method.Name, err) + warnf("Warning: Method \"%s::%s\" uses unsupported types: %v\n", className, method.Name, err) continue } @@ -264,7 +264,7 @@ func (cp *classParser) parseMethods(src []byte, file *ast.File, fset *token.File phpFunc.GoFunction = method.GoFunction if err := validator.validateGoFunctionSignatureWithOptions(phpFunc, true); err != nil { - fmt.Fprintf(os.Stderr, "Warning: Go method signature mismatch for '%s::%s': %v\n", method.ClassName, method.Name, err) + warnf("Warning: Go method signature mismatch for %q: %v\n", method.ClassName+"::"+method.Name, err) continue } From 6e9564e4559fde678ae22125f454133b52629cf6 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Fri, 7 Aug 2026 09:59:23 +0200 Subject: [PATCH 15/17] refactor(extgen): remove the unused classParser.Parse wrapper Parse forwarded verbatim to parse and had no caller: parser.go and every test use the unexported one. --- internal/extgen/classparser.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/extgen/classparser.go b/internal/extgen/classparser.go index db9d0933a7..cc8778f576 100644 --- a/internal/extgen/classparser.go +++ b/internal/extgen/classparser.go @@ -21,10 +21,6 @@ type exportDirective struct { type classParser struct{} -func (cp *classParser) Parse(filename string) ([]phpClass, error) { - return cp.parse(filename) -} - func (cp *classParser) parse(filename string) (classes []phpClass, err error) { src, err := os.ReadFile(filename) if err != nil { From 8548a4d75b19843556ceb6afc04f8b546af1abd2 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Fri, 7 Aug 2026 09:59:49 +0200 Subject: [PATCH 16/17] refactor(extgen): drop the dead source analyzer extraction extractVariables and extractInternalFunctions fed goTemplateData.Variables, InternalFunctions and Constants, none of which extension.go.tpl ever references: roughly 110 lines of hand-rolled brace and paren counting -- which mis-parses braces inside strings and comments, and only looks back five lines for a directive -- computed and thrown away on every run, plus 450 lines of tests. The generated file lives in the same package as the source, so copying those declarations would be a redeclaration anyway. analyze now returns just the package name it is actually used for, and the defensive copy of Classes goes with it since the template only reads the slice. --- internal/extgen/gofile.go | 13 +- internal/extgen/srcanalyzer.go | 131 +------- internal/extgen/srcanalyzer_test.go | 498 ++-------------------------- 3 files changed, 39 insertions(+), 603 deletions(-) diff --git a/internal/extgen/gofile.go b/internal/extgen/gofile.go index 952c60bfde..81ce18958b 100644 --- a/internal/extgen/gofile.go +++ b/internal/extgen/gofile.go @@ -23,9 +23,6 @@ type goTemplateData struct { PackageName string BaseName string SanitizedBaseName string - Constants []phpConstant - Variables []string - InternalFunctions []string Functions []phpFunction Classes []phpClass } @@ -43,23 +40,17 @@ func (gg *GoFileGenerator) generate() error { func (gg *GoFileGenerator) buildContent() (string, error) { sourceAnalyzer := SourceAnalyzer{} - packageName, variables, internalFunctions, err := sourceAnalyzer.analyze(gg.generator.SourceFile) + packageName, err := sourceAnalyzer.analyze(gg.generator.SourceFile) if err != nil { return "", fmt.Errorf("analyzing source file: %w", err) } - classes := make([]phpClass, len(gg.generator.Classes)) - copy(classes, gg.generator.Classes) - templateContent, err := gg.getTemplateContent(goTemplateData{ PackageName: packageName, BaseName: gg.generator.BaseName, SanitizedBaseName: SanitizePackageName(gg.generator.BaseName), - Constants: gg.generator.Constants, - Variables: variables, - InternalFunctions: internalFunctions, Functions: gg.generator.Functions, - Classes: classes, + Classes: gg.generator.Classes, }) if err != nil { diff --git a/internal/extgen/srcanalyzer.go b/internal/extgen/srcanalyzer.go index 32ebff4040..299a2f8f76 100644 --- a/internal/extgen/srcanalyzer.go +++ b/internal/extgen/srcanalyzer.go @@ -4,138 +4,15 @@ import ( "fmt" "go/parser" "go/token" - "os" - "strings" ) type SourceAnalyzer struct{} -func (sa *SourceAnalyzer) analyze(filename string) (packageName string, variables []string, internalFunctions []string, err error) { - fset := token.NewFileSet() - node, err := parser.ParseFile(fset, filename, nil, parser.ParseComments) +func (sa *SourceAnalyzer) analyze(filename string) (packageName string, err error) { + node, err := parser.ParseFile(token.NewFileSet(), filename, nil, parser.SkipObjectResolution) if err != nil { - return "", nil, nil, fmt.Errorf("parsing file: %w", err) + return "", fmt.Errorf("parsing file: %w", err) } - packageName = node.Name.Name - - sourceContent, err := os.ReadFile(filename) - if err != nil { - return "", nil, nil, fmt.Errorf("reading source file: %w", err) - } - - variables = sa.extractVariables(string(sourceContent)) - internalFunctions = sa.extractInternalFunctions(string(sourceContent)) - - return packageName, variables, internalFunctions, nil -} - -func (sa *SourceAnalyzer) extractVariables(content string) []string { - lines := strings.Split(content, "\n") - var ( - variables []string - currentVar strings.Builder - inVarBlock bool - parenCount int - ) - - for _, line := range lines { - trimmedLine := strings.TrimSpace(line) - - if strings.HasPrefix(trimmedLine, "var ") && !inVarBlock { - if strings.Contains(trimmedLine, "(") { - inVarBlock = true - parenCount = 1 - currentVar.Reset() - currentVar.WriteString(line + "\n") - } else { - variables = append(variables, strings.TrimSpace(line)) - } - } else if inVarBlock { - currentVar.WriteString(line + "\n") - - for _, char := range line { - switch char { - case '(': - parenCount++ - case ')': - parenCount-- - } - } - - if parenCount == 0 { - varContent := currentVar.String() - variables = append(variables, strings.TrimSpace(varContent)) - inVarBlock = false - currentVar.Reset() - } - } - } - - return variables -} - -func (sa *SourceAnalyzer) extractInternalFunctions(content string) []string { - lines := strings.Split(content, "\n") - var ( - functions []string - currentFunc strings.Builder - inFunction, hasPHPFunc bool - braceCount int - ) - - for i, line := range lines { - trimmedLine := strings.TrimSpace(line) - - if strings.HasPrefix(trimmedLine, "func ") && !inFunction { - inFunction = true - braceCount = 0 - hasPHPFunc = false - currentFunc.Reset() - - // look backwards for export_php comment - for j := i - 1; j >= 0 && j >= i-5; j-- { - prevLine := strings.TrimSpace(lines[j]) - if prevLine == "" { - continue - } - - if strings.Contains(prevLine, "export_php:") { - hasPHPFunc = true - - break - } - - if !strings.HasPrefix(prevLine, "//") { - break - } - } - } - - if inFunction { - currentFunc.WriteString(line + "\n") - - for _, char := range line { - switch char { - case '{': - braceCount++ - case '}': - braceCount-- - } - } - - if braceCount == 0 && strings.Contains(line, "}") { - funcContent := currentFunc.String() - - if !hasPHPFunc { - functions = append(functions, strings.TrimSpace(funcContent)) - } - - inFunction = false - currentFunc.Reset() - } - } - } - - return functions + return node.Name.Name, nil } diff --git a/internal/extgen/srcanalyzer_test.go b/internal/extgen/srcanalyzer_test.go index 74207b1b7c..e9f16b1ff5 100644 --- a/internal/extgen/srcanalyzer_test.go +++ b/internal/extgen/srcanalyzer_test.go @@ -5,243 +5,46 @@ import ( "path/filepath" "testing" - "github.com/stretchr/testify/require" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestSourceAnalyzer_Analyze(t *testing.T) { tests := []struct { - name string - sourceContent string - expectedImports []string - expectedVariables []string - expectedFunctions []string - expectError bool + name string + sourceContent string + expectedPackage string }{ { - name: "simple file with imports and functions", + name: "package main", sourceContent: `package main -import ( - "fmt" - "strings" -) +import "fmt" -func regularFunction() { - fmt.Println("hello") -} +var globalVar = "test" -//export_php:function -func exportedFunction() string { - return "exported" -}`, - expectedImports: []string{`"fmt"`, `"strings"`}, - expectedVariables: nil, - expectedFunctions: []string{ - `func regularFunction() { - fmt.Println("hello") +func helper() { + fmt.Println("helper") }`, - }, - expectError: false, + expectedPackage: "main", }, { - name: "file with named imports", - sourceContent: `package main - -import ( - custom "fmt" - . "strings" - _ "os" -) - -func test() {}`, - expectedImports: []string{`custom "fmt"`, `. "strings"`, `_ "os"`}, - expectedVariables: nil, - expectedFunctions: []string{ - `func test() {}`, - }, - expectError: false, + name: "custom package name", + sourceContent: "package myextension\n", + expectedPackage: "myextension", }, - { - name: "file with multiple functions and export comments", - sourceContent: `package main - -func internalOne() { - // some code -} - -// This function is exported to PHP -//export_php:function -func exportedOne() int { - return 42 -} - -func internalTwo() string { - return "internal" -} - -// Another exported function -//export_php:function -func exportedTwo() bool { - return true -}`, - expectedImports: []string{}, - expectedVariables: nil, - expectedFunctions: []string{ - `func internalOne() { - // some code -}`, - `func internalTwo() string { - return "internal" -}`, - }, - expectError: false, - }, - { - name: "file with nested braces", - sourceContent: `package main - -func complexFunction() { - if true { - for i := 0; i < 10; i++ { - if i%2 == 0 { - fmt.Println(i) - } - } - } -} - -//export_php:function -func exportedComplex() { - obj := struct{ - field string - }{ - field: "value", - } - fmt.Println(obj) -}`, - expectedImports: []string{}, - expectedVariables: nil, - expectedFunctions: []string{ - `func complexFunction() { - if true { - for i := 0; i < 10; i++ { - if i%2 == 0 { - fmt.Println(i) - } - } } -}`, - }, - expectError: false, - }, - { - name: "empty file", - sourceContent: `package main`, - expectedImports: []string{}, - expectedFunctions: []string{}, - expectError: false, - }, - { - name: "file with only exported functions", - sourceContent: `package main - -//export_php:function -func onlyExported() {} -//export_php:function -func anotherExported() string { - return "test" -}`, - expectedImports: []string{}, - expectedFunctions: []string{}, - expectError: false, - }, - { - name: "file with export comment not immediately before function", - sourceContent: `package main - -//export_php:function -// Some other comment -func shouldNotBeExported() {} - -func normalFunction() { - //export_php:function inside function should not count -}`, - expectedImports: []string{}, - expectedVariables: nil, - expectedFunctions: []string{ - `func normalFunction() { - //export_php:function inside function should not count -}`, - }, - expectError: false, - }, - { - name: "file with variable blocks", - sourceContent: `package main - -import ( - "sync" -) - -var ( - mu sync.RWMutex - store = map[string]struct { - val string - expires int64 - }{} -) - -var singleVar = "test" - -func testFunction() { - // test function -}`, - expectedImports: []string{`"sync"`}, - expectedVariables: []string{ - `var ( - mu sync.RWMutex - store = map[string]struct { - val string - expires int64 - }{} -)`, - `var singleVar = "test"`, - }, - expectedFunctions: []string{ - `func testFunction() { - // test function -}`, - }, - expectError: false, - }, - } + analyzer := &SourceAnalyzer{} for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - tempDir := t.TempDir() - filename := filepath.Join(tempDir, "test.go") - + filename := filepath.Join(t.TempDir(), "source.go") require.NoError(t, os.WriteFile(filename, []byte(tt.sourceContent), 0644)) - analyzer := &SourceAnalyzer{} - _, variables, functions, err := analyzer.analyze(filename) - - if tt.expectError { - assert.Error(t, err, "expected error") - return - } - - assert.NoError(t, err, "unexpected error") - - assert.Equal(t, tt.expectedVariables, variables, "variables mismatch") - assert.Len(t, functions, len(tt.expectedFunctions), "function count mismatch") - - for i, expected := range tt.expectedFunctions { - assert.Equal(t, expected, functions[i], "function %d mismatch", i) - } + packageName, err := analyzer.analyze(filename) + require.NoError(t, err) + assert.Equal(t, tt.expectedPackage, packageName) }) } } @@ -250,13 +53,12 @@ func TestSourceAnalyzer_Analyze_InvalidFile(t *testing.T) { analyzer := &SourceAnalyzer{} t.Run("nonexistent file", func(t *testing.T) { - _, _, _, err := analyzer.analyze("/nonexistent/file.go") + _, err := analyzer.analyze("/nonexistent/file.go") assert.Error(t, err, "expected error for nonexistent file") }) t.Run("invalid Go syntax", func(t *testing.T) { - tempDir := t.TempDir() - filename := filepath.Join(tempDir, "invalid.go") + filename := filepath.Join(t.TempDir(), "invalid.go") invalidContent := `package main func incomplete( { @@ -265,242 +67,28 @@ func TestSourceAnalyzer_Analyze_InvalidFile(t *testing.T) { require.NoError(t, os.WriteFile(filename, []byte(invalidContent), 0644)) - _, _, _, err := analyzer.analyze(filename) + _, err := analyzer.analyze(filename) assert.Error(t, err, "expected error for invalid syntax") }) -} - -func TestSourceAnalyzer_ExtractInternalFunctions(t *testing.T) { - tests := []struct { - name string - content string - expected []string - }{ - { - name: "single function without export", - content: `func test() { - fmt.Println("test") -}`, - expected: []string{ - `func test() { - fmt.Println("test") -}`, - }, - }, - { - name: "function with export comment", - content: `//export_php:function -func exported() {}`, - expected: []string{}, - }, - { - name: "mixed functions", - content: `func internal() {} - -//export_php:function -func exported() {} - -func anotherInternal() { - return "test" -}`, - expected: []string{ - "func internal() {}", - `func anotherInternal() { - return "test" -}`, - }, - }, - { - name: "export comment with spacing", - content: `//export_php:function -func exported1() {} -//export_php:function -func exported2() {} + t.Run("missing package clause", func(t *testing.T) { + filename := filepath.Join(t.TempDir(), "orphan.go") + require.NoError(t, os.WriteFile(filename, []byte("func orphan() {}\n"), 0644)) -// export_php:function -func exported3() {}`, - expected: []string{}, - }, - { - name: "complex function with nested braces", - content: `func complex() { - if true { - for { - switch x { - case 1: - { - // nested block - } - } - } - } -}`, - expected: []string{ - `func complex() { - if true { - for { - switch x { - case 1: - { - // nested block - } - } - } - } -}`, - }, - }, - { - name: "empty content", - content: "", - expected: []string{}, - }, - { - name: "no functions", - content: `package main - -import "fmt" - -var x = 10`, - expected: []string{}, - }, - } - - analyzer := &SourceAnalyzer{} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := analyzer.extractInternalFunctions(tt.content) - - assert.Len(t, result, len(tt.expected), "function count mismatch") - - for i, expected := range tt.expected { - assert.Equal(t, expected, result[i], "function %d mismatch", i) - } - }) - } -} - -func TestSourceAnalyzer_InternalFunctionPreservation(t *testing.T) { - tmpDir := t.TempDir() - - sourceContent := `package main - -import ( - "fmt" - "strings" -) - -//export_php: exported1(): string -func exported1() *go_value { - return String(internal1()) -} - -func internal1() string { - return "helper1" -} - -//export_php: exported2(): void -func exported2() { - internal2() -} - -func internal2() { - fmt.Println("helper2") -} - -func internal3(data string) string { - return strings.ToUpper(data) -}` - - sourceFile := filepath.Join(tmpDir, "test.go") - require.NoError(t, os.WriteFile(sourceFile, []byte(sourceContent), 0644)) - - analyzer := &SourceAnalyzer{} - packageName, variables, internalFuncs, err := analyzer.analyze(sourceFile) - require.NoError(t, err) - - assert.Equal(t, "main", packageName) - - assert.Len(t, internalFuncs, 3, "Should extract exactly 3 internal functions") - - expectedInternalFuncs := []string{ - `func internal1() string { - return "helper1" -}`, - `func internal2() { - fmt.Println("helper2") -}`, - `func internal3(data string) string { - return strings.ToUpper(data) -}`, - } - - for i, expected := range expectedInternalFuncs { - assert.Equal(t, expected, internalFuncs[i], "Internal function %d should match", i) - } - - assert.Empty(t, variables, "Should not have variables") -} - -func TestSourceAnalyzer_VariableBlockPreservation(t *testing.T) { - tmpDir := t.TempDir() - - sourceContent := `package main - -import ( - "sync" -) - -var ( - mu sync.RWMutex - cache = make(map[string]string) -) - -var globalCounter int = 0 - -//export_php: test(): void -func test() {}` - - sourceFile := filepath.Join(tmpDir, "test.go") - require.NoError(t, os.WriteFile(sourceFile, []byte(sourceContent), 0644)) - - analyzer := &SourceAnalyzer{} - packageName, variables, internalFuncs, err := analyzer.analyze(sourceFile) - require.NoError(t, err) - - assert.Equal(t, "main", packageName) - - assert.Len(t, variables, 2, "Should extract exactly 2 variable declarations") - - expectedVar1 := `var ( - mu sync.RWMutex - cache = make(map[string]string) -)` - expectedVar2 := `var globalCounter int = 0` - - assert.Equal(t, expectedVar1, variables[0], "First variable block should match") - assert.Equal(t, expectedVar2, variables[1], "Second variable declaration should match") - - assert.Empty(t, internalFuncs, "Should not have internal functions (only exported function)") + _, err := analyzer.analyze(filename) + assert.Error(t, err, "expected error for a file without a package clause") + }) } func BenchmarkSourceAnalyzer_Analyze(b *testing.B) { content := `package main -import ( - "fmt" - "strings" - "os" -) +import "fmt" -func internalOne() { - fmt.Println("test") -} +var globalVar = "test" -//export_php:function -func exported() string { - return "exported" +func internalOne() { + fmt.Println("one") } func internalTwo() { @@ -511,33 +99,13 @@ func internalTwo() { } }` - tempDir := b.TempDir() - filename := filepath.Join(tempDir, "bench.go") - + filename := filepath.Join(b.TempDir(), "bench.go") require.NoError(b, os.WriteFile(filename, []byte(content), 0644)) analyzer := &SourceAnalyzer{} for b.Loop() { - _, _, _, err := analyzer.analyze(filename) + _, err := analyzer.analyze(filename) require.NoError(b, err) } } - -func BenchmarkSourceAnalyzer_ExtractInternalFunctions(b *testing.B) { - content := `func test1() { fmt.Println("1") } -func test2() { fmt.Println("2") } -//export_php:function -func exported() {} -func test3() { - for i := 0; i < 10; i++ { - fmt.Println(i) - } -}` - - analyzer := &SourceAnalyzer{} - - for b.Loop() { - analyzer.extractInternalFunctions(content) - } -} From cdc5ec2111defcf55a5e1c4fa09acf7bb837ec80 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Fri, 7 Aug 2026 10:00:02 +0200 Subject: [PATCH 17/17] docs: correct the extension type table for mixed and callable The "Class methods support" column marked mixed and callable as unsupported. callable already worked in class methods before this branch and mixed works now, so the table steered users away from working features. The Go column also gave `any` for mixed where the signature must be *C.zval, unlike the string and callable rows which give the real signature type. The French and Brazilian Portuguese tables predated the callable row entirely, so it is added there; pt-br also had a duplicated `?bool` row. --- docs/cn/extensions.md | 6 +++--- docs/es/extensions.md | 6 +++--- docs/extensions.md | 6 +++--- docs/fr/extensions.md | 5 +++-- docs/it/extensions.md | 6 +++--- docs/ja/extensions.md | 6 +++--- docs/pt-br/extensions.md | 8 ++++---- docs/ru/extensions.md | 6 +++--- docs/tr/extensions.md | 6 +++--- 9 files changed, 28 insertions(+), 27 deletions(-) diff --git a/docs/cn/extensions.md b/docs/cn/extensions.md index cb5522f479..835b2e5c00 100644 --- a/docs/cn/extensions.md +++ b/docs/cn/extensions.md @@ -156,15 +156,15 @@ echo $processor->process('Hello World', StringProcessor::MODE_UPPERCASE); // "H | `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()` | ❌ | +| `mixed` | `*C.zval` | ❌ | `frankenphp.GoValue()` | `frankenphp.PHPValue()` | ✅ | +| `callable` | `*C.zval` | ❌ | - | `frankenphp.CallPHPCallable()` | ✅ | | `object` | `struct` | ❌ | _尚未实现_ | _尚未实现_ | ❌ | > [!NOTE] > > 此表尚不详尽,将随着 FrankenPHP 类型 API 变得更加完整而完善。 > -> 特别是对于类方法,目前支持原始类型和数组。对象尚不能用作方法参数或返回类型。 +> 特别是对于类方法,目前支持原始类型、数组、`mixed` 和 `callable`。对象尚不能用作方法参数或返回类型。 如果你参考上一节的代码片段,你可以看到助手用于转换第一个参数和返回值。我们的 `repeat_this()` 函数的第二和第三个参数不需要转换,因为底层类型的内存表示对于 C 和 Go 都是相同的。 diff --git a/docs/es/extensions.md b/docs/es/extensions.md index c4ab714800..3d87f3b0ee 100644 --- a/docs/es/extensions.md +++ b/docs/es/extensions.md @@ -99,15 +99,15 @@ Esta tabla resume lo que necesitas saber: | `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() | ❌ | +| `mixed` | `*C.zval` | ❌ | `frankenphp.GoValue()` | `frankenphp.PHPValue()` | ✅ | +| `callable` | `*C.zval` | ❌ | - | frankenphp.CallPHPCallable() | ✅ | | `object` | `struct` | ❌ | _Aún no implementado_ | _Aún no implementado_ | ❌ | > [!NOTE] > > Esta tabla aún no es exhaustiva y se completará a medida que la API de tipos de FrankenPHP se vuelva más completa. > -> Para métodos de clase específicamente, los tipos primitivos y los arrays están actualmente soportados. Los objetos aún no pueden usarse como parámetros de métodos o tipos de retorno. +> Para métodos de clase específicamente, los tipos primitivos, los arrays, `mixed` y `callable` están actualmente soportados. Los objetos aún no pueden usarse como parámetros de métodos o tipos de retorno. Si te refieres al fragmento de código de la sección anterior, puedes ver que se usan helpers para convertir el primer parámetro y el valor de retorno. El segundo y tercer parámetro de nuestra función `repeat_this()` no necesitan ser convertidos ya que la representación en memoria de los tipos subyacentes es la misma para C y Go. diff --git a/docs/extensions.md b/docs/extensions.md index b08f730520..c495fec713 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -163,15 +163,15 @@ This table summarizes what you need to know: | `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() | ❌ | +| `mixed` | `*C.zval` | ❌ | `frankenphp.GoValue()` | `frankenphp.PHPValue()` | ✅ | +| `callable` | `*C.zval` | ❌ | - | frankenphp.CallPHPCallable() | ✅ | | `object` | `struct` | ❌ | _Not yet implemented_ | _Not yet implemented_ | ❌ | > [!NOTE] > > This table is not exhaustive yet and will be completed as the FrankenPHP types API gets more complete. > -> For class methods specifically, primitive types and arrays are currently supported. Objects cannot be used as method parameters or return types yet. +> For class methods specifically, primitive types, arrays, `mixed` and `callable` are currently supported. Objects cannot be used as method parameters or return types yet. If you refer to the code snippet of the previous section, you can see that helpers are used to convert the first parameter and the return value. The second and third parameters of our `repeat_this()` function don't need to be converted, as the memory representation of the underlying types is the same for both C and Go. diff --git a/docs/fr/extensions.md b/docs/fr/extensions.md index 6aba81fd0d..c40fead436 100644 --- a/docs/fr/extensions.md +++ b/docs/fr/extensions.md @@ -98,13 +98,14 @@ Bien que certains types de variables aient la même représentation mémoire ent | `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()` | ❌ | +| `mixed` | `*C.zval` | ❌ | `frankenphp.GoValue()` | `frankenphp.PHPValue()` | ✅ | +| `callable` | `*C.zval` | ❌ | - | frankenphp.CallPHPCallable() | ✅ | | `object` | `struct` | ❌ | _Pas encore implémenté_ | _Pas encore implémenté_ | ❌ | > [!NOTE] > Ce tableau n'est pas encore exhaustif et sera complété au fur et à mesure que l'API de types FrankenPHP deviendra plus complète. > -> Pour les méthodes de classe spécifiquement, les types primitifs et les tableaux sont supportés. Les objets ne peuvent pas encore être utilisés comme paramètres de méthode ou types de retour. +> Pour les méthodes de classe spécifiquement, les types primitifs, les tableaux, `mixed` et `callable` sont supportés. Les objets ne peuvent pas encore être utilisés comme paramètres de méthode ou types de retour. Si vous vous référez à l'extrait de code de la section précédente, vous pouvez voir que des assistants sont utilisés pour convertir le premier paramètre et la valeur de retour. Les deuxième et troisième paramètres de notre fonction `repeat_this()` n'ont pas besoin d'être convertis car la représentation mémoire des types sous-jacents est la même pour C et Go. diff --git a/docs/it/extensions.md b/docs/it/extensions.md index 76e0414595..f4f8ce0552 100644 --- a/docs/it/extensions.md +++ b/docs/it/extensions.md @@ -158,15 +158,15 @@ Questa tabella riassume il necessario: | `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() | ❌| +| `mixed` | `*C.zval` | ❌| `frankenphp.GoValue()` | `frankenphp.PHPValue()` | ✅| +| `callable` | `*C.zval` | ❌| - | frankenphp.CallPHPCallable() | ✅| | `object` | `struct` | ❌| _Non ancora implementato_ | _Non ancora implementato_ | ❌| > [!NOTE] > > Questa tabella non è ancora esaustiva e verrà completata man mano che l'API dei tipi FrankenPHP diventerà più completa. > -> Per i metodi di classe, in particolare, sono attualmente supportati i tipi primitivi e gli array. Gli oggetti non possono ancora essere utilizzati come parametri di metodo o tipi di ritorno. +> Per i metodi di classe, in particolare, sono attualmente supportati i tipi primitivi, gli array, `mixed` e `callable`. Gli oggetti non possono ancora essere utilizzati come parametri di metodo o tipi di ritorno. Facendo riferimento allo snippet di codice della sezione precedente, si può vedere che gli helper vengono utilizzati per convertire il primo parametro e il valore restituito. Non è necessario convertire il secondo e il terzo parametro della nostra funzione `repeat_this()`, poiché la rappresentazione in memoria dei tipi sottostanti è la stessa sia per C sia per Go. diff --git a/docs/ja/extensions.md b/docs/ja/extensions.md index 1dd221ac4e..91e4973be5 100644 --- a/docs/ja/extensions.md +++ b/docs/ja/extensions.md @@ -98,15 +98,15 @@ C/PHPとGoの間でメモリ表現が同じ変数型もありますが、直接 | `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()` | ❌ | +| `mixed` | `*C.zval` | ❌ | `frankenphp.GoValue()` | `frankenphp.PHPValue()` | ✅ | +| `callable` | `*C.zval` | ❌ | - | `frankenphp.CallPHPCallable()` | ✅ | | `object` | `struct` | ❌ | _未実装_ | _未実装_ | ❌ | > [!NOTE] > > この表はまだ完全ではなく、FrankenPHPの型APIがより完全になるにつれて完成されます。 > -> クラスメソッドについては、現在プリミティブ型と配列がサポートされています。オブジェクトはまだメソッドパラメータや戻り値の型として使用できません。 +> クラスメソッドについては、現在プリミティブ型、配列、`mixed`、`callable` がサポートされています。オブジェクトはまだメソッドパラメータや戻り値の型として使用できません。 前のセクションのコードスニペットを参照すると、最初のパラメータと戻り値の変換にヘルパーが使用されていることがわかります。 `repeat_this()`関数の2番目と3番目の引数は、基礎となる型のメモリ表現がCとGoで同じであるため、変換する必要がありません。 diff --git a/docs/pt-br/extensions.md b/docs/pt-br/extensions.md index 880c7833cb..a874c4ebe9 100644 --- a/docs/pt-br/extensions.md +++ b/docs/pt-br/extensions.md @@ -139,20 +139,20 @@ Esta tabela resume o que você precisa saber: | `?float` | `*float64` | ✅ | - | - | ✅ | | `bool` | `bool` | ✅ | - | - | ✅ | | `?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()` | ❌ | +| `mixed` | `*C.zval` | ❌ | `frankenphp.GoValue()` | `frankenphp.PHPValue()` | ✅ | +| `callable` | `*C.zval` | ❌ | - | frankenphp.CallPHPCallable() | ✅ | | `object` | `struct` | ❌ | _Ainda não implementado_ | _Ainda não implementado_ | ❌ | > [!NOTE] > Esta tabela ainda não é exaustiva e será completada à medida que a API de > tipos do FrankenPHP se tornar mais completa. > -> Tipos primitivos e arrays são suportados atualmente, especificamente para -> métodos de classe. +> Tipos primitivos, arrays, `mixed` e `callable` são suportados atualmente, +> especificamente para métodos de classe. > Objetos ainda não podem ser usados como parâmetros de métodos ou tipos de > retorno. diff --git a/docs/ru/extensions.md b/docs/ru/extensions.md index 5921c327d4..f6ce497e49 100644 --- a/docs/ru/extensions.md +++ b/docs/ru/extensions.md @@ -157,15 +157,15 @@ echo $processor->process('Hello World', StringProcessor::MODE_UPPERCASE); // "H | `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()` | ❌ | +| `mixed` | `*C.zval` | ❌ | `frankenphp.GoValue()` | `frankenphp.PHPValue()` | ✅ | +| `callable` | `*C.zval` | ❌ | - | `frankenphp.CallPHPCallable()` | ✅ | | `object` | `struct` | ❌ | _Пока не реализовано_ | _Пока не реализовано_ | ❌ | > [!NOTE] > > Эта таблица еще не исчерпывающая и будет пополняться по мере доработки API типов FrankenPHP. > -> Для методов классов, в частности, в настоящее время поддерживаются примитивные типы и массивы. Объекты пока не могут использоваться в качестве параметров методов или возвращаемых типов. +> Для методов классов, в частности, в настоящее время поддерживаются примитивные типы, массивы, `mixed` и `callable`. Объекты пока не могут использоваться в качестве параметров методов или возвращаемых типов. Если вы обратитесь к фрагменту кода из предыдущего раздела, вы увидите, что для преобразования первого параметра и возвращаемого значения используются вспомогательные функции. Второй и третий параметры нашей функции `repeat_this()` не требуют преобразования, так как представление в памяти базовых типов одинаково как для C, так и для Go. diff --git a/docs/tr/extensions.md b/docs/tr/extensions.md index de4f5057b0..7c01282900 100644 --- a/docs/tr/extensions.md +++ b/docs/tr/extensions.md @@ -157,15 +157,15 @@ Bu tablo, bilmeniz gerekenleri özetler: | `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() | ❌ | +| `mixed` | `*C.zval` | ❌ | `frankenphp.GoValue()` | `frankenphp.PHPValue()` | ✅ | +| `callable` | `*C.zval` | ❌ | - | frankenphp.CallPHPCallable() | ✅ | | `object` | `struct` | ❌ | _Henüz uygulanmadı_ | _Henüz uygulanmadı_ | ❌ | > [!NOTE] > > Bu tablo henüz kapsamlı değildir ve FrankenPHP tür API'si daha eksiksiz hale geldikçe tamamlanacaktır. > -> Özellikle sınıf metotları için, ilkel türler ve diziler şu anda desteklenmektedir. Nesneler henüz metot parametresi veya dönüş türü olarak kullanılamaz. +> Özellikle sınıf metotları için, ilkel türler, diziler, `mixed` ve `callable` şu anda desteklenmektedir. Nesneler henüz metot parametresi veya dönüş türü olarak kullanılamaz. Önceki bölümdeki kod parçacığına bakarsanız, ilk parametreyi ve dönüş değerini dönüştürmek için yardımcıların kullanıldığını görebilirsiniz. `repeat_this()` işlevimizin ikinci ve üçüncü parametrelerinin dönüştürülmesi gerekmez, çünkü temel türlerin bellek gösterimi hem C hem de Go için aynıdır.