mirror of
https://github.com/php/frankenphp.git
synced 2026-03-24 00:52:11 +01:00
1291 lines
35 KiB
Go
1291 lines
35 KiB
Go
package extgen
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"os"
|
|
"path/filepath"
|
|
"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())
|
|
|
|
sourceStillExists := filepath.Join(tmpDir, "test.go")
|
|
require.FileExists(t, sourceStillExists)
|
|
sourceStillContent, err := readFile(sourceStillExists)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, sourceContent, sourceStillContent, "Source file should not be modified")
|
|
|
|
generatedFile := filepath.Join(tmpDir, "test_generated.go")
|
|
require.FileExists(t, generatedFile)
|
|
|
|
generatedContent, err := readFile(generatedFile)
|
|
require.NoError(t, err)
|
|
|
|
testGeneratedFileBasicStructure(t, generatedContent, "main", "test")
|
|
testGeneratedFileWrappers(t, generatedContent, generator.Functions)
|
|
}
|
|
|
|
func TestGoFileGenerator_BuildContent(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
baseName string
|
|
sourceFile string
|
|
functions []phpFunction
|
|
classes []phpClass
|
|
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 main",
|
|
`#include "simple.h"`,
|
|
`import "C"`,
|
|
"func init()",
|
|
"frankenphp.RegisterExtension(",
|
|
"//export go_test",
|
|
"func go_test()",
|
|
"test()", // wrapper calls original function
|
|
},
|
|
},
|
|
{
|
|
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 main",
|
|
"//export go_process",
|
|
`"C"`,
|
|
"process(", // wrapper calls original function
|
|
},
|
|
},
|
|
{
|
|
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{
|
|
"//export go_publicFunc",
|
|
"func go_publicFunc()",
|
|
"publicFunc()", // wrapper calls original function
|
|
},
|
|
notContains: []string{
|
|
"func internalFunc1() string",
|
|
"func internalFunc2(data string)",
|
|
},
|
|
},
|
|
{
|
|
name: "runtime/cgo blank import without classes",
|
|
baseName: "no_classes",
|
|
sourceFile: createTempSourceFile(t, `package main
|
|
|
|
//export_php: getValue(): string
|
|
func getValue() string {
|
|
return "test"
|
|
}`),
|
|
functions: []phpFunction{
|
|
{
|
|
Name: "getValue",
|
|
ReturnType: phpString,
|
|
GoFunction: `func getValue() string {
|
|
return "test"
|
|
}`,
|
|
},
|
|
},
|
|
classes: nil,
|
|
contains: []string{
|
|
`_ "runtime/cgo"`,
|
|
"func init()",
|
|
"frankenphp.RegisterExtension(",
|
|
},
|
|
notContains: []string{
|
|
"cgo.NewHandle",
|
|
"registerGoObject",
|
|
"getGoObject",
|
|
"removeGoObject",
|
|
},
|
|
},
|
|
{
|
|
name: "runtime/cgo normal import with classes",
|
|
baseName: "with_classes",
|
|
sourceFile: createTempSourceFile(t, `package main
|
|
|
|
//export_php:class TestClass
|
|
type TestStruct struct {
|
|
value string
|
|
}
|
|
|
|
//export_php:method TestClass::getValue(): string
|
|
func (ts *TestStruct) GetValue() string {
|
|
return ts.value
|
|
}`),
|
|
functions: []phpFunction{},
|
|
classes: []phpClass{
|
|
{
|
|
Name: "TestClass",
|
|
GoStruct: "TestStruct",
|
|
Methods: []phpClassMethod{
|
|
{
|
|
Name: "GetValue",
|
|
ReturnType: phpString,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
contains: []string{
|
|
`"runtime/cgo"`,
|
|
"cgo.NewHandle",
|
|
"func registerGoObject",
|
|
"func getGoObject",
|
|
},
|
|
notContains: []string{
|
|
`_ "runtime/cgo"`,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
generator := &Generator{
|
|
BaseName: tt.baseName,
|
|
SourceFile: tt.sourceFile,
|
|
Functions: tt.functions,
|
|
Classes: tt.classes,
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
for _, notExpected := range tt.notContains {
|
|
assert.NotContains(t, content, notExpected, "Generated Go content should NOT contain %q", notExpected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGoFileGenerator_PackageNameSanitization(t *testing.T) {
|
|
tests := []struct {
|
|
baseName string
|
|
expectedPackage string
|
|
}{
|
|
{"simple", "main"},
|
|
{"my-extension", "main"},
|
|
{"ext.with.dots", "main"},
|
|
{"123invalid", "main"},
|
|
{"valid_name", "main"},
|
|
}
|
|
|
|
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_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 example", "Package name should match source package")
|
|
|
|
for _, fn := range functions {
|
|
exportDirective := "//export go_" + fn.Name
|
|
assert.Contains(t, content, exportDirective, "Generated content should contain export directive: %s", exportDirective)
|
|
}
|
|
}
|
|
|
|
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_Idempotency(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
sourceContent := `package main
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
//export_php: greet(name string): string
|
|
func greet(name *go_string) *go_value {
|
|
return String("Hello " + CStringToGoString(name))
|
|
}
|
|
|
|
func internalHelper(data string) string {
|
|
return strings.ToUpper(data)
|
|
}`
|
|
|
|
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 String("Hello " + CStringToGoString(name))
|
|
}`,
|
|
},
|
|
},
|
|
}
|
|
|
|
goGen := GoFileGenerator{generator}
|
|
require.NoError(t, goGen.generate(), "First generation should succeed")
|
|
|
|
generatedFile := filepath.Join(tmpDir, "test_generated.go")
|
|
require.FileExists(t, generatedFile, "Generated file should exist after first run")
|
|
|
|
firstRunContent, err := os.ReadFile(generatedFile)
|
|
require.NoError(t, err)
|
|
firstRunSourceContent, err := os.ReadFile(sourceFile)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, goGen.generate(), "Second generation should succeed")
|
|
|
|
secondRunContent, err := os.ReadFile(generatedFile)
|
|
require.NoError(t, err)
|
|
secondRunSourceContent, err := os.ReadFile(sourceFile)
|
|
require.NoError(t, err)
|
|
|
|
assert.True(t, bytes.Equal(firstRunContent, secondRunContent), "Generated file content should be identical between runs")
|
|
assert.True(t, bytes.Equal(firstRunSourceContent, secondRunSourceContent), "Source file should remain unchanged after both runs")
|
|
assert.Equal(t, sourceContent, string(secondRunSourceContent), "Source file content should match original")
|
|
}
|
|
|
|
func TestGoFileGenerator_HeaderComments(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
sourceContent := `package main
|
|
|
|
//export_php: test(): void
|
|
func test() {
|
|
// simple function
|
|
}`
|
|
|
|
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: "test",
|
|
ReturnType: phpVoid,
|
|
GoFunction: "func test() {\n\t// simple function\n}",
|
|
},
|
|
},
|
|
}
|
|
|
|
goGen := GoFileGenerator{generator}
|
|
require.NoError(t, goGen.generate())
|
|
|
|
generatedFile := filepath.Join(tmpDir, "test_generated.go")
|
|
require.FileExists(t, generatedFile)
|
|
|
|
assertContainsHeaderComment(t, generatedFile)
|
|
}
|
|
|
|
func TestExtractGoFunctionName(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "simple function",
|
|
input: "func test() {}",
|
|
expected: "test",
|
|
},
|
|
{
|
|
name: "function with params",
|
|
input: "func calculate(a int, b int) int {}",
|
|
expected: "calculate",
|
|
},
|
|
{
|
|
name: "function with complex params",
|
|
input: "func process(data *go_string, opts *go_nullable) *go_value {}",
|
|
expected: "process",
|
|
},
|
|
{
|
|
name: "function with whitespace",
|
|
input: "func spacedName () {}",
|
|
expected: "spacedName",
|
|
},
|
|
{
|
|
name: "no func keyword",
|
|
input: "test() {}",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "empty string",
|
|
input: "",
|
|
expected: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := extractGoFunctionName(tt.input)
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractGoFunctionSignatureParams(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "no parameters",
|
|
input: "func test() {}",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "single parameter",
|
|
input: "func test(name string) {}",
|
|
expected: "name string",
|
|
},
|
|
{
|
|
name: "multiple parameters",
|
|
input: "func test(a int, b string, c bool) {}",
|
|
expected: "a int, b string, c bool",
|
|
},
|
|
{
|
|
name: "pointer parameters",
|
|
input: "func test(data *go_string) {}",
|
|
expected: "data *go_string",
|
|
},
|
|
{
|
|
name: "nested parentheses",
|
|
input: "func test(fn func(int) string) {}",
|
|
expected: "fn func(int) string",
|
|
},
|
|
{
|
|
name: "variadic parameters",
|
|
input: "func test(args ...string) {}",
|
|
expected: "args ...string",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := extractGoFunctionSignatureParams(tt.input)
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractGoFunctionSignatureReturn(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "no return type",
|
|
input: "func test() {}",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "single return type",
|
|
input: "func test() string {}",
|
|
expected: "string",
|
|
},
|
|
{
|
|
name: "pointer return type",
|
|
input: "func test() *go_value {}",
|
|
expected: "*go_value",
|
|
},
|
|
{
|
|
name: "multiple return types",
|
|
input: "func test() (string, error) {}",
|
|
expected: "(string, error)",
|
|
},
|
|
{
|
|
name: "named return values",
|
|
input: "func test() (result string, err error) {}",
|
|
expected: "(result string, err error)",
|
|
},
|
|
{
|
|
name: "complex return type",
|
|
input: "func test() unsafe.Pointer {}",
|
|
expected: "unsafe.Pointer",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := extractGoFunctionSignatureReturn(tt.input)
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractGoFunctionCallParams(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "no parameters",
|
|
input: "func test() {}",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "single parameter",
|
|
input: "func test(name string) {}",
|
|
expected: "name",
|
|
},
|
|
{
|
|
name: "multiple parameters",
|
|
input: "func test(a int, b string, c bool) {}",
|
|
expected: "a, b, c",
|
|
},
|
|
{
|
|
name: "pointer parameters",
|
|
input: "func test(data *go_string, opts *go_nullable) {}",
|
|
expected: "data, opts",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := extractGoFunctionCallParams(tt.input)
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGoFileGenerator_SourceFilePreservation(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
sourceContent := `package main
|
|
|
|
import "fmt"
|
|
|
|
//export_php: greet(name string): string
|
|
func greet(name *go_string) *go_value {
|
|
return String(fmt.Sprintf("Hello, %s!", CStringToGoString(name)))
|
|
}
|
|
|
|
func internalHelper() {
|
|
fmt.Println("internal")
|
|
}`
|
|
|
|
sourceFile := filepath.Join(tmpDir, "test.go")
|
|
require.NoError(t, os.WriteFile(sourceFile, []byte(sourceContent), 0644))
|
|
|
|
hashBefore := computeFileHash(t, sourceFile)
|
|
|
|
generator := &Generator{
|
|
BaseName: "test",
|
|
SourceFile: sourceFile,
|
|
BuildDir: tmpDir,
|
|
Functions: []phpFunction{
|
|
{
|
|
Name: "greet",
|
|
ReturnType: phpString,
|
|
GoFunction: `func greet(name *go_string) *go_value {
|
|
return String(fmt.Sprintf("Hello, %s!", CStringToGoString(name)))
|
|
}`,
|
|
},
|
|
},
|
|
}
|
|
|
|
goGen := GoFileGenerator{generator}
|
|
require.NoError(t, goGen.generate())
|
|
|
|
hashAfter := computeFileHash(t, sourceFile)
|
|
|
|
assert.Equal(t, hashBefore, hashAfter, "Source file hash should remain unchanged after generation")
|
|
|
|
contentAfter, err := os.ReadFile(sourceFile)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, sourceContent, string(contentAfter), "Source file content should be byte-for-byte identical")
|
|
}
|
|
|
|
func TestGoFileGenerator_WrapperParameterForwarding(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
sourceContent := `package main
|
|
|
|
import "fmt"
|
|
|
|
//export_php: process(name string, count int): string
|
|
func process(name *go_string, count long) *go_value {
|
|
n := CStringToGoString(name)
|
|
return String(fmt.Sprintf("%s: %d", n, count))
|
|
}
|
|
|
|
//export_php: simple(): void
|
|
func simple() {}`
|
|
|
|
sourceFile := filepath.Join(tmpDir, "test.go")
|
|
require.NoError(t, os.WriteFile(sourceFile, []byte(sourceContent), 0644))
|
|
|
|
functions := []phpFunction{
|
|
{
|
|
Name: "process",
|
|
ReturnType: phpString,
|
|
GoFunction: `func process(name *go_string, count long) *go_value {
|
|
n := CStringToGoString(name)
|
|
return String(fmt.Sprintf("%s: %d", n, count))
|
|
}`,
|
|
},
|
|
{
|
|
Name: "simple",
|
|
ReturnType: phpVoid,
|
|
GoFunction: "func simple() {}",
|
|
},
|
|
}
|
|
|
|
generator := &Generator{
|
|
BaseName: "wrapper_test",
|
|
SourceFile: sourceFile,
|
|
BuildDir: tmpDir,
|
|
Functions: functions,
|
|
}
|
|
|
|
goGen := GoFileGenerator{generator}
|
|
content, err := goGen.buildContent()
|
|
require.NoError(t, err)
|
|
|
|
assert.Contains(t, content, "//export go_process", "Should have wrapper export directive")
|
|
assert.Contains(t, content, "func go_process(", "Should have wrapper function")
|
|
assert.Contains(t, content, "process(", "Wrapper should call original function")
|
|
|
|
assert.Contains(t, content, "//export go_simple", "Should have simple wrapper export directive")
|
|
assert.Contains(t, content, "func go_simple()", "Should have simple wrapper function")
|
|
assert.Contains(t, content, "simple()", "Simple wrapper should call original function")
|
|
}
|
|
|
|
func TestGoFileGenerator_MalformedSource(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
sourceContent string
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "missing package declaration",
|
|
sourceContent: "func test() {}",
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "syntax error",
|
|
sourceContent: "package main\nfunc test( {}",
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "incomplete function",
|
|
sourceContent: "package main\nfunc test() {",
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "valid minimal source",
|
|
sourceContent: "package main\nfunc test() {}",
|
|
expectError: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
sourceFile := filepath.Join(tmpDir, "test.go")
|
|
require.NoError(t, os.WriteFile(sourceFile, []byte(tt.sourceContent), 0644))
|
|
|
|
generator := &Generator{
|
|
BaseName: "test",
|
|
SourceFile: sourceFile,
|
|
BuildDir: tmpDir,
|
|
}
|
|
|
|
goGen := GoFileGenerator{generator}
|
|
_, err := goGen.buildContent()
|
|
|
|
if tt.expectError {
|
|
assert.Error(t, err, "Expected error for malformed source")
|
|
} else {
|
|
assert.NoError(t, err, "Should not error for valid source")
|
|
}
|
|
|
|
contentAfter, readErr := os.ReadFile(sourceFile)
|
|
require.NoError(t, readErr)
|
|
assert.Equal(t, tt.sourceContent, string(contentAfter), "Source file should remain unchanged even on error")
|
|
})
|
|
}
|
|
}
|
|
|
|
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 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 testGeneratedFileBasicStructure(t *testing.T, content, expectedPackage, baseName string) {
|
|
requiredElements := []string{
|
|
"package " + expectedPackage,
|
|
"// #include <stdlib.h>",
|
|
`// #include "` + baseName + `.h"`,
|
|
`import "C"`,
|
|
"func init() {",
|
|
"frankenphp.RegisterExtension(",
|
|
"}",
|
|
}
|
|
|
|
for _, element := range requiredElements {
|
|
assert.Contains(t, content, element, "Generated file should contain: %s", element)
|
|
}
|
|
|
|
assert.NotContains(t, content, "func internalHelper", "Generated file should not contain internal functions from source")
|
|
assert.NotContains(t, content, "func anotherHelper", "Generated file should not contain internal functions from source")
|
|
}
|
|
|
|
func testGeneratedFileWrappers(t *testing.T, content string, functions []phpFunction) {
|
|
for _, fn := range functions {
|
|
exportDirective := "//export go_" + fn.Name
|
|
assert.Contains(t, content, exportDirective, "Generated file should contain export directive: %s", exportDirective)
|
|
|
|
wrapperFunc := "func go_" + fn.Name + "("
|
|
assert.Contains(t, content, wrapperFunc, "Generated file should contain wrapper function: %s", wrapperFunc)
|
|
|
|
funcName := extractGoFunctionName(fn.GoFunction)
|
|
if funcName != "" {
|
|
assert.Contains(t, content, funcName+"(", "Generated wrapper should call original function: %s", funcName)
|
|
}
|
|
}
|
|
}
|
|
|
|
// computeFileHash returns SHA256 hash of file
|
|
func computeFileHash(t *testing.T, filename string) string {
|
|
content, err := os.ReadFile(filename)
|
|
require.NoError(t, err)
|
|
hash := sha256.Sum256(content)
|
|
return hex.EncodeToString(hash[:])
|
|
}
|
|
|
|
// assertContainsHeaderComment verifies file has autogenerated header
|
|
func assertContainsHeaderComment(t *testing.T, filename string) {
|
|
content, err := os.ReadFile(filename)
|
|
require.NoError(t, err)
|
|
|
|
headerSection := string(content[:min(len(content), 500)])
|
|
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")
|
|
}
|