Files
frankenphp/internal/extgen/gofile_test.go
2025-12-15 12:50:50 +01:00

844 lines
23 KiB
Go

package extgen
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGoFileGenerator_Generate(t *testing.T) {
tmpDir := t.TempDir()
sourceContent := `package main
import (
"fmt"
"strings"
"github.com/dunglas/frankenphp/internal/extensions/types"
)
//export_php: greet(name string): string
func greet(name *go_string) *go_value {
return types.String("Hello " + CStringToGoString(name))
}
//export_php: calculate(a int, b int): int
func calculate(a long, b long) *go_value {
result := a + b
return types.Int(result)
}
func internalHelper(data string) string {
return strings.ToUpper(data)
}
func anotherHelper() {
fmt.Println("Internal helper")
}`
sourceFile := filepath.Join(tmpDir, "test.go")
require.NoError(t, os.WriteFile(sourceFile, []byte(sourceContent), 0644))
generator := &Generator{
BaseName: "test",
SourceFile: sourceFile,
BuildDir: tmpDir,
Functions: []phpFunction{
{
Name: "greet",
ReturnType: phpString,
GoFunction: `func greet(name *go_string) *go_value {
return types.String("Hello " + CStringToGoString(name))
}`,
},
{
Name: "calculate",
ReturnType: phpInt,
GoFunction: `func calculate(a long, b long) *go_value {
result := a + b
return types.Int(result)
}`,
},
},
}
goGen := GoFileGenerator{generator}
require.NoError(t, goGen.generate())
expectedFile := filepath.Join(tmpDir, "test.go")
require.FileExists(t, expectedFile)
content, err := readFile(expectedFile)
require.NoError(t, err)
testGoFileBasicStructure(t, content, "test")
testGoFileImports(t, content)
testGoFileExportedFunctions(t, content, generator.Functions)
testGoFileInternalFunctions(t, content)
}
func TestGoFileGenerator_BuildContent(t *testing.T) {
tests := []struct {
name string
baseName string
sourceFile string
functions []phpFunction
contains []string
notContains []string
}{
{
name: "simple extension",
baseName: "simple",
sourceFile: createTempSourceFile(t, `package main
//export_php: test(): void
func test() {
// simple function
}`),
functions: []phpFunction{
{
Name: "test",
ReturnType: phpVoid,
GoFunction: "func test() {\n\t// simple function\n}",
},
},
contains: []string{
"package simple",
`#include "simple.h"`,
`import "C"`,
"func init()",
"frankenphp.RegisterExtension(",
"//export test",
"func test()",
},
},
{
name: "extension with complex imports",
baseName: "complex",
sourceFile: createTempSourceFile(t, `package main
import (
"fmt"
"strings"
"encoding/json"
"github.com/dunglas/frankenphp/internal/extensions/types"
)
//export_php: process(data string): string
func process(data *go_string) *go_value {
return types.String(fmt.Sprintf("processed: %s", CStringToGoString(data)))
}`),
functions: []phpFunction{
{
Name: "process",
ReturnType: phpString,
GoFunction: `func process(data *go_string) *go_value {
return String(fmt.Sprintf("processed: %s", CStringToGoString(data)))
}`,
},
},
contains: []string{
"package complex",
`"fmt"`,
`"strings"`,
`"encoding/json"`,
"//export process",
`"C"`,
},
},
{
name: "extension with internal functions",
baseName: "internal",
sourceFile: createTempSourceFile(t, `package main
//export_php: publicFunc(): void
func publicFunc() {}
func internalFunc1() string {
return "internal"
}
func internalFunc2(data string) {
// process data internally
}`),
functions: []phpFunction{
{
Name: "publicFunc",
ReturnType: phpVoid,
GoFunction: "func publicFunc() {}",
},
},
contains: []string{
"func internalFunc1() string",
"func internalFunc2(data string)",
"//export publicFunc",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
generator := &Generator{
BaseName: tt.baseName,
SourceFile: tt.sourceFile,
Functions: tt.functions,
}
goGen := GoFileGenerator{generator}
content, err := goGen.buildContent()
require.NoError(t, err)
for _, expected := range tt.contains {
assert.Contains(t, content, expected, "Generated Go content should contain %q", expected)
}
})
}
}
func TestGoFileGenerator_PackageNameSanitization(t *testing.T) {
tests := []struct {
baseName string
expectedPackage string
}{
{"simple", "simple"},
{"my-extension", "my_extension"},
{"ext.with.dots", "ext_with_dots"},
{"123invalid", "_123invalid"},
{"valid_name", "valid_name"},
}
for _, tt := range tests {
t.Run(tt.baseName, func(t *testing.T) {
sourceFile := createTempSourceFile(t, "package main\n//export_php: test(): void\nfunc test() {}")
generator := &Generator{
BaseName: tt.baseName,
SourceFile: sourceFile,
Functions: []phpFunction{
{Name: "test", ReturnType: phpVoid, GoFunction: "func test() {}"},
},
}
goGen := GoFileGenerator{generator}
content, err := goGen.buildContent()
require.NoError(t, err)
expectedPackage := "package " + tt.expectedPackage
assert.Contains(t, content, expectedPackage, "Generated content should contain '%s'", expectedPackage)
})
}
}
func TestGoFileGenerator_ErrorHandling(t *testing.T) {
tests := []struct {
name string
sourceFile string
expectErr bool
}{
{
name: "nonexistent file",
sourceFile: "/nonexistent/file.go",
expectErr: true,
},
{
name: "invalid Go syntax",
sourceFile: createTempSourceFile(t, "invalid go syntax here"),
expectErr: true,
},
{
name: "valid file",
sourceFile: createTempSourceFile(t, "package main\nfunc test() {}"),
expectErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
generator := &Generator{
BaseName: "test",
SourceFile: tt.sourceFile,
}
goGen := GoFileGenerator{generator}
_, err := goGen.buildContent()
if tt.expectErr {
assert.Error(t, err, "Expected error but got none")
} else {
assert.NoError(t, err, "Unexpected error")
}
})
}
}
func TestGoFileGenerator_ImportFiltering(t *testing.T) {
sourceContent := `package main
import (
"C"
"fmt"
"strings"
"github.com/dunglas/frankenphp/internal/extensions/types"
"github.com/other/package"
originalPkg "github.com/test/original"
)
//export_php: test(): void
func test() {}`
sourceFile := createTempSourceFile(t, sourceContent)
generator := &Generator{
BaseName: "importtest",
SourceFile: sourceFile,
Functions: []phpFunction{
{Name: "test", ReturnType: phpVoid, GoFunction: "func test() {}"},
},
}
goGen := GoFileGenerator{generator}
content, err := goGen.buildContent()
require.NoError(t, err)
expectedImports := []string{
`"fmt"`,
`"strings"`,
`"github.com/other/package"`,
}
for _, imp := range expectedImports {
assert.Contains(t, content, imp, "Generated content should contain import: %s", imp)
}
forbiddenImports := []string{
`"C"`,
}
cImportCount := strings.Count(content, `"C"`)
assert.Equal(t, 1, cImportCount, "Expected exactly 1 occurrence of 'import \"C\"'")
for _, imp := range forbiddenImports[1:] {
assert.NotContains(t, content, imp, "Generated content should NOT contain import: %s", imp)
}
}
func TestGoFileGenerator_ComplexScenario(t *testing.T) {
sourceContent := `package example
import (
"fmt"
"strings"
"encoding/json"
"github.com/dunglas/frankenphp/internal/extensions/types"
)
//export_php: processData(input string, options array): array
func processData(input *go_string, options *go_nullable) *go_value {
data := CStringToGoString(input)
processed := internalProcess(data)
return types.Array([]any{processed})
}
//export_php: validateInput(data string): bool
func validateInput(data *go_string) *go_value {
input := CStringToGoString(data)
isValid := len(input) > 0 && validateFormat(input)
return types.Bool(isValid)
}
func internalProcess(data string) string {
return strings.ToUpper(data)
}
func validateFormat(input string) bool {
return !strings.Contains(input, "invalid")
}
func jsonHelper(data any) ([]byte, error) {
return json.Marshal(data)
}
func debugPrint(msg string) {
fmt.Printf("DEBUG: %s\n", msg)
}`
sourceFile := createTempSourceFile(t, sourceContent)
functions := []phpFunction{
{
Name: "processData",
ReturnType: phpArray,
GoFunction: `func processData(input *go_string, options *go_nullable) *go_value {
data := CStringToGoString(input)
processed := internalProcess(data)
return Array([]any{processed})
}`,
},
{
Name: "validateInput",
ReturnType: phpBool,
GoFunction: `func validateInput(data *go_string) *go_value {
input := CStringToGoString(data)
isValid := len(input) > 0 && validateFormat(input)
return Bool(isValid)
}`,
},
}
generator := &Generator{
BaseName: "complex-example",
SourceFile: sourceFile,
Functions: functions,
}
goGen := GoFileGenerator{generator}
content, err := goGen.buildContent()
require.NoError(t, err)
assert.Contains(t, content, "package complex_example", "Package name should be sanitized")
internalFuncs := []string{
"func internalProcess(data string) string",
"func validateFormat(input string) bool",
"func jsonHelper(data any) ([]byte, error)",
"func debugPrint(msg string)",
}
for _, fn := range internalFuncs {
assert.Contains(t, content, fn, "Generated content should contain internal function: %s", fn)
}
for _, fn := range functions {
exportDirective := "//export " + fn.Name
assert.Contains(t, content, exportDirective, "Generated content should contain export directive: %s", exportDirective)
}
assert.False(t, strings.Contains(content, "types.Array") || strings.Contains(content, "types.Bool"), "Types should be replaced (types.* should not appear)")
assert.True(t, strings.Contains(content, "return Array(") && strings.Contains(content, "return Bool("), "Replaced types should appear without types prefix")
}
func TestGoFileGenerator_MethodWrapperWithNullableParams(t *testing.T) {
tmpDir := t.TempDir()
sourceContent := `package main
import "fmt"
//export_php:class TestClass
type TestStruct struct {
name string
}
//export_php:method TestClass::processData(string $name, ?int $count, ?bool $enabled): string
func (ts *TestStruct) ProcessData(name string, count *int64, enabled *bool) string {
result := fmt.Sprintf("name=%s", name)
if count != nil {
result += fmt.Sprintf(", count=%d", *count)
}
if enabled != nil {
result += fmt.Sprintf(", enabled=%t", *enabled)
}
return result
}`
sourceFile := filepath.Join(tmpDir, "test.go")
require.NoError(t, os.WriteFile(sourceFile, []byte(sourceContent), 0644))
methods := []phpClassMethod{
{
Name: "ProcessData",
PhpName: "processData",
ClassName: "TestClass",
Signature: "processData(string $name, ?int $count, ?bool $enabled): string",
ReturnType: phpString,
Params: []phpParameter{
{Name: "name", PhpType: phpString, IsNullable: false},
{Name: "count", PhpType: phpInt, IsNullable: true},
{Name: "enabled", PhpType: phpBool, IsNullable: true},
},
GoFunction: `func (ts *TestStruct) ProcessData(name string, count *int64, enabled *bool) string {
result := fmt.Sprintf("name=%s", name)
if count != nil {
result += fmt.Sprintf(", count=%d", *count)
}
if enabled != nil {
result += fmt.Sprintf(", enabled=%t", *enabled)
}
return result
}`,
},
}
classes := []phpClass{
{
Name: "TestClass",
GoStruct: "TestStruct",
Methods: methods,
},
}
generator := &Generator{
BaseName: "nullable_test",
SourceFile: sourceFile,
Classes: classes,
BuildDir: tmpDir,
}
goGen := GoFileGenerator{generator}
content, err := goGen.buildContent()
require.NoError(t, err)
expectedWrapperSignature := "func 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"
assert.Contains(t, content, exportDirective, "Generated content should contain export directive: %s", exportDirective)
}
func TestGoFileGenerator_MethodWrapperWithArrayParams(t *testing.T) {
tmpDir := t.TempDir()
sourceContent := `package main
import "fmt"
//export_php:class ArrayClass
type ArrayStruct struct {
data []any
}
//export_php:method ArrayClass::processArray(array $items): array
func (as *ArrayStruct) ProcessArray(items frankenphp.AssociativeArray) frankenphp.AssociativeArray {
result := frankenphp.AssociativeArray{}
for key, value := range items.Map {
result.Set("processed_"+key, value)
}
return result
}
//export_php:method ArrayClass::filterData(array $data, string $filter): array
func (as *ArrayStruct) FilterData(data frankenphp.AssociativeArray, filter string) frankenphp.AssociativeArray {
result := frankenphp.AssociativeArray{}
// Filter logic here
return result
}`
sourceFile := filepath.Join(tmpDir, "test.go")
require.NoError(t, os.WriteFile(sourceFile, []byte(sourceContent), 0644))
methods := []phpClassMethod{
{
Name: "ProcessArray",
PhpName: "processArray",
ClassName: "ArrayClass",
Signature: "processArray(array $items): array",
ReturnType: phpArray,
Params: []phpParameter{
{Name: "items", PhpType: phpArray, IsNullable: false},
},
GoFunction: `func (as *ArrayStruct) ProcessArray(items frankenphp.AssociativeArray) frankenphp.AssociativeArray {
result := frankenphp.AssociativeArray{}
for key, value := range items.Entries() {
result.Set("processed_"+key, value)
}
return result
}`,
},
{
Name: "FilterData",
PhpName: "filterData",
ClassName: "ArrayClass",
Signature: "filterData(array $data, string $filter): array",
ReturnType: phpArray,
Params: []phpParameter{
{Name: "data", PhpType: phpArray, IsNullable: false},
{Name: "filter", PhpType: phpString, IsNullable: false},
},
GoFunction: `func (as *ArrayStruct) FilterData(data frankenphp.AssociativeArray, filter string) frankenphp.AssociativeArray {
result := frankenphp.AssociativeArray{}
return result
}`,
},
}
classes := []phpClass{
{
Name: "ArrayClass",
GoStruct: "ArrayStruct",
Methods: methods,
},
}
generator := &Generator{
BaseName: "array_test",
SourceFile: sourceFile,
Classes: classes,
BuildDir: tmpDir,
}
goGen := GoFileGenerator{generator}
content, err := goGen.buildContent()
require.NoError(t, err)
expectedArrayWrapperSignature := "func 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"
assert.Contains(t, content, expectedMixedWrapperSignature, "Generated content should contain mixed wrapper signature: %s", expectedMixedWrapperSignature)
expectedArrayCall := "structObj.ProcessArray(items)"
assert.Contains(t, content, expectedArrayCall, "Generated content should contain array method call: %s", expectedArrayCall)
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")
}
func TestGoFileGenerator_MethodWrapperWithNullableArrayParams(t *testing.T) {
tmpDir := t.TempDir()
sourceContent := `package main
//export_php:class NullableArrayClass
type NullableArrayStruct struct{}
//export_php:method NullableArrayClass::processOptionalArray(?array $items, string $name): string
func (nas *NullableArrayStruct) ProcessOptionalArray(items frankenphp.AssociativeArray, name string) string {
return fmt.Sprintf("Processing %d items for %s", len(items.Map), name)
}`
sourceFile := filepath.Join(tmpDir, "test.go")
require.NoError(t, os.WriteFile(sourceFile, []byte(sourceContent), 0644))
methods := []phpClassMethod{
{
Name: "ProcessOptionalArray",
PhpName: "processOptionalArray",
ClassName: "NullableArrayClass",
Signature: "processOptionalArray(?array $items, string $name): string",
ReturnType: phpString,
Params: []phpParameter{
{Name: "items", PhpType: phpArray, IsNullable: true},
{Name: "name", PhpType: phpString, IsNullable: false},
},
GoFunction: `func (nas *NullableArrayStruct) ProcessOptionalArray(items frankenphp.AssociativeArray, name string) string {
return fmt.Sprintf("Processing %d items for %s", len(items.Map), name)
}`,
},
}
classes := []phpClass{
{
Name: "NullableArrayClass",
GoStruct: "NullableArrayStruct",
Methods: methods,
},
}
generator := &Generator{
BaseName: "nullable_array_test",
SourceFile: sourceFile,
Classes: classes,
BuildDir: tmpDir,
}
goGen := GoFileGenerator{generator}
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"
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")
}
func createTempSourceFile(t *testing.T, content string) string {
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "source.go")
require.NoError(t, os.WriteFile(tmpFile, []byte(content), 0644))
return tmpFile
}
func testGoFileBasicStructure(t *testing.T, content, baseName string) {
requiredElements := []string{
"package " + SanitizePackageName(baseName),
"// #include <stdlib.h>",
`// #include "` + baseName + `.h"`,
`import "C"`,
"func init() {",
"frankenphp.RegisterExtension(",
"}",
}
for _, element := range requiredElements {
assert.Contains(t, content, element, "Go file should contain: %s", element)
}
}
func testGoFileImports(t *testing.T, content string) {
cImportCount := strings.Count(content, `"C"`)
assert.Equal(t, 1, cImportCount, "Expected exactly 1 C import")
}
func testGoFileExportedFunctions(t *testing.T, content string, functions []phpFunction) {
for _, fn := range functions {
exportDirective := "//export " + fn.Name
assert.Contains(t, content, exportDirective, "Go file should contain export directive: %s", exportDirective)
funcStart := "func " + fn.Name + "("
assert.Contains(t, content, funcStart, "Go file should contain function definition: %s", funcStart)
}
}
func TestGoFileGenerator_MethodWrapperWithCallableParams(t *testing.T) {
tmpDir := t.TempDir()
sourceContent := `package main
import "C"
//export_php:class CallableClass
type CallableStruct struct{}
//export_php:method CallableClass::processCallback(callable $callback): string
func (cs *CallableStruct) ProcessCallback(callback *C.zval) string {
return "processed"
}
//export_php:method CallableClass::processOptionalCallback(?callable $callback): string
func (cs *CallableStruct) ProcessOptionalCallback(callback *C.zval) string {
return "processed_optional"
}`
sourceFile := filepath.Join(tmpDir, "test.go")
require.NoError(t, os.WriteFile(sourceFile, []byte(sourceContent), 0644))
methods := []phpClassMethod{
{
Name: "ProcessCallback",
PhpName: "processCallback",
ClassName: "CallableClass",
Signature: "processCallback(callable $callback): string",
ReturnType: phpString,
Params: []phpParameter{
{Name: "callback", PhpType: phpCallable, IsNullable: false},
},
GoFunction: `func (cs *CallableStruct) ProcessCallback(callback *C.zval) string {
return "processed"
}`,
},
{
Name: "ProcessOptionalCallback",
PhpName: "processOptionalCallback",
ClassName: "CallableClass",
Signature: "processOptionalCallback(?callable $callback): string",
ReturnType: phpString,
Params: []phpParameter{
{Name: "callback", PhpType: phpCallable, IsNullable: true},
},
GoFunction: `func (cs *CallableStruct) ProcessOptionalCallback(callback *C.zval) string {
return "processed_optional"
}`,
},
}
classes := []phpClass{
{
Name: "CallableClass",
GoStruct: "CallableStruct",
Methods: methods,
},
}
generator := &Generator{
BaseName: "callable_test",
SourceFile: sourceFile,
Classes: classes,
BuildDir: tmpDir,
}
goGen := GoFileGenerator{generator}
content, err := goGen.buildContent()
require.NoError(t, err)
expectedCallableWrapperSignature := "func ProcessCallback_wrapper(handle C.uintptr_t, callback *C.zval) unsafe.Pointer"
assert.Contains(t, content, expectedCallableWrapperSignature, "Generated content should contain callable wrapper signature: %s", expectedCallableWrapperSignature)
expectedOptionalCallableWrapperSignature := "func ProcessOptionalCallback_wrapper(handle C.uintptr_t, callback *C.zval) unsafe.Pointer"
assert.Contains(t, content, expectedOptionalCallableWrapperSignature, "Generated content should contain optional callable wrapper signature: %s", expectedOptionalCallableWrapperSignature)
expectedCallableCall := "structObj.ProcessCallback(callback)"
assert.Contains(t, content, expectedCallableCall, "Generated content should contain callable method call: %s", expectedCallableCall)
expectedOptionalCallableCall := "structObj.ProcessOptionalCallback(callback)"
assert.Contains(t, content, expectedOptionalCallableCall, "Generated content should contain optional callable method call: %s", expectedOptionalCallableCall)
assert.Contains(t, content, "//export ProcessCallback_wrapper", "Generated content should contain ProcessCallback export directive")
assert.Contains(t, content, "//export ProcessOptionalCallback_wrapper", "Generated content should contain ProcessOptionalCallback export directive")
}
func TestGoFileGenerator_phpTypeToGoType(t *testing.T) {
generator := &Generator{}
goGen := GoFileGenerator{generator}
tests := []struct {
phpType phpType
expected string
}{
{phpString, "string"},
{phpInt, "int64"},
{phpFloat, "float64"},
{phpBool, "bool"},
{phpArray, "*frankenphp.Array"},
{phpMixed, "any"},
{phpVoid, ""},
{phpCallable, "*C.zval"},
}
for _, tt := range tests {
t.Run(string(tt.phpType), func(t *testing.T) {
result := goGen.phpTypeToGoType(tt.phpType)
assert.Equal(t, tt.expected, result, "phpTypeToGoType(%s) should return %s", tt.phpType, tt.expected)
})
}
t.Run("unknown_type", func(t *testing.T) {
unknownType := phpType("unknown")
result := goGen.phpTypeToGoType(unknownType)
assert.Equal(t, "any", result, "phpTypeToGoType should fallback to interface{} for unknown types")
})
}
func testGoFileInternalFunctions(t *testing.T, content string) {
internalIndicators := []string{
"func internalHelper",
"func anotherHelper",
}
foundInternal := false
for _, indicator := range internalIndicators {
if strings.Contains(content, indicator) {
foundInternal = true
break
}
}
if !foundInternal {
t.Log("No internal functions found (this may be expected)")
}
}