feat(extgen): make the generator idempotent and avoid touching the original source (#2011)

This commit is contained in:
Alexandre Daubois
2026-02-05 12:50:28 +01:00
committed by GitHub
parent db59edb590
commit fba79a6ac8
11 changed files with 886 additions and 229 deletions

View File

@@ -4,8 +4,9 @@ import (
"bytes"
_ "embed"
"fmt"
"os"
"go/format"
"path/filepath"
"strings"
"text/template"
"github.com/Masterminds/sprig/v3"
@@ -21,7 +22,7 @@ type GoFileGenerator struct {
type goTemplateData struct {
PackageName string
BaseName string
Imports []string
SanitizedBaseName string
Constants []phpConstant
Variables []string
InternalFunctions []string
@@ -30,16 +31,7 @@ type goTemplateData struct {
}
func (gg *GoFileGenerator) generate() error {
filename := filepath.Join(gg.generator.BuildDir, gg.generator.BaseName+".go")
if _, err := os.Stat(filename); err == nil {
backupFilename := filename + ".bak"
if err := os.Rename(filename, backupFilename); err != nil {
return fmt.Errorf("backing up existing Go file: %w", err)
}
gg.generator.SourceFile = backupFilename
}
filename := filepath.Join(gg.generator.BuildDir, gg.generator.BaseName+"_generated.go")
content, err := gg.buildContent()
if err != nil {
@@ -51,38 +43,18 @@ func (gg *GoFileGenerator) generate() error {
func (gg *GoFileGenerator) buildContent() (string, error) {
sourceAnalyzer := SourceAnalyzer{}
imports, variables, internalFunctions, err := sourceAnalyzer.analyze(gg.generator.SourceFile)
packageName, variables, internalFunctions, err := sourceAnalyzer.analyze(gg.generator.SourceFile)
if err != nil {
return "", fmt.Errorf("analyzing source file: %w", err)
}
filteredImports := make([]string, 0, len(imports))
for _, imp := range imports {
if imp != `"C"` && imp != `"unsafe"` && imp != `"github.com/dunglas/frankenphp"` && imp != `"runtime/cgo"` {
filteredImports = append(filteredImports, imp)
}
}
classes := make([]phpClass, len(gg.generator.Classes))
copy(classes, gg.generator.Classes)
if len(classes) > 0 {
hasCgo := false
for _, imp := range imports {
if imp == `"runtime/cgo"` {
hasCgo = true
break
}
}
if !hasCgo {
filteredImports = append(filteredImports, `"runtime/cgo"`)
}
}
templateContent, err := gg.getTemplateContent(goTemplateData{
PackageName: SanitizePackageName(gg.generator.BaseName),
PackageName: packageName,
BaseName: gg.generator.BaseName,
Imports: filteredImports,
SanitizedBaseName: SanitizePackageName(gg.generator.BaseName),
Constants: gg.generator.Constants,
Variables: variables,
InternalFunctions: internalFunctions,
@@ -94,7 +66,12 @@ func (gg *GoFileGenerator) buildContent() (string, error) {
return "", fmt.Errorf("executing template: %w", err)
}
return templateContent, nil
fc, err := format.Source([]byte(templateContent))
if err != nil {
return "", fmt.Errorf("formatting source: %w", err)
}
return string(fc), nil
}
func (gg *GoFileGenerator) getTemplateContent(data goTemplateData) (string, error) {
@@ -106,6 +83,10 @@ func (gg *GoFileGenerator) getTemplateContent(data goTemplateData) (string, erro
funcMap["isVoid"] = func(t phpType) bool {
return t == phpVoid
}
funcMap["extractGoFunctionName"] = extractGoFunctionName
funcMap["extractGoFunctionSignatureParams"] = extractGoFunctionSignatureParams
funcMap["extractGoFunctionSignatureReturn"] = extractGoFunctionSignatureReturn
funcMap["extractGoFunctionCallParams"] = extractGoFunctionCallParams
tmpl := template.Must(template.New("gofile").Funcs(funcMap).Parse(goFileContent))
@@ -128,7 +109,7 @@ type GoParameter struct {
Type string
}
var phpToGoTypeMap= map[phpType]string{
var phpToGoTypeMap = map[phpType]string{
phpString: "string",
phpInt: "int64",
phpFloat: "float64",
@@ -146,3 +127,119 @@ func (gg *GoFileGenerator) phpTypeToGoType(phpT phpType) string {
return "any"
}
// extractGoFunctionName extracts the Go function 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++
}
if end >= len(goFunction) {
return ""
}
return strings.TrimSpace(goFunction[start:end])
}
// extractGoFunctionSignatureParams extracts the parameters from a Go function signature.
func extractGoFunctionSignatureParams(goFunction string) string {
start := strings.IndexByte(goFunction, '(')
if start == -1 {
return ""
}
start++
depth := 1
end := start
for end < len(goFunction) && depth > 0 {
switch goFunction[end] {
case '(':
depth++
case ')':
depth--
}
if depth > 0 {
end++
}
}
if end >= len(goFunction) {
return ""
}
return strings.TrimSpace(goFunction[start:end])
}
// extractGoFunctionSignatureReturn extracts the return type from a Go function signature.
func extractGoFunctionSignatureReturn(goFunction string) string {
start := strings.IndexByte(goFunction, '(')
if start == -1 {
return ""
}
depth := 1
pos := start + 1
for pos < len(goFunction) && depth > 0 {
switch goFunction[pos] {
case '(':
depth++
case ')':
depth--
}
pos++
}
if pos >= len(goFunction) {
return ""
}
end := strings.IndexByte(goFunction[pos:], '{')
if end == -1 {
return ""
}
end += pos
returnType := strings.TrimSpace(goFunction[pos:end])
return returnType
}
// extractGoFunctionCallParams extracts just the parameter names for calling a function.
func extractGoFunctionCallParams(goFunction string) string {
params := extractGoFunctionSignatureParams(goFunction)
if params == "" {
return ""
}
var names []string
parts := strings.Split(params, ",")
for _, part := range parts {
part = strings.TrimSpace(part)
if len(part) == 0 {
continue
}
words := strings.Fields(part)
if len(words) > 0 {
names = append(names, words[0])
}
}
var result strings.Builder
for i, name := range names {
if i > 0 {
result.WriteString(", ")
}
result.WriteString(name)
}
return result.String()
}

View File

@@ -1,9 +1,11 @@
package extgen
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -69,16 +71,20 @@ func anotherHelper() {
goGen := GoFileGenerator{generator}
require.NoError(t, goGen.generate())
expectedFile := filepath.Join(tmpDir, "test.go")
require.FileExists(t, expectedFile)
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")
content, err := readFile(expectedFile)
generatedFile := filepath.Join(tmpDir, "test_generated.go")
require.FileExists(t, generatedFile)
generatedContent, err := readFile(generatedFile)
require.NoError(t, err)
testGoFileBasicStructure(t, content, "test")
testGoFileImports(t, content)
testGoFileExportedFunctions(t, content, generator.Functions)
testGoFileInternalFunctions(t, content)
testGeneratedFileBasicStructure(t, generatedContent, "main", "test")
testGeneratedFileWrappers(t, generatedContent, generator.Functions)
}
func TestGoFileGenerator_BuildContent(t *testing.T) {
@@ -87,6 +93,7 @@ func TestGoFileGenerator_BuildContent(t *testing.T) {
baseName string
sourceFile string
functions []phpFunction
classes []phpClass
contains []string
notContains []string
}{
@@ -107,13 +114,14 @@ func test() {
},
},
contains: []string{
"package simple",
"package main",
`#include "simple.h"`,
`import "C"`,
"func init()",
"frankenphp.RegisterExtension(",
"//export test",
"func test()",
"//export go_test",
"func go_test()",
"test()", // wrapper calls original function
},
},
{
@@ -142,12 +150,10 @@ func process(data *go_string) *go_value {
},
},
contains: []string{
"package complex",
`"fmt"`,
`"strings"`,
`"encoding/json"`,
"//export process",
"package main",
"//export go_process",
`"C"`,
"process(", // wrapper calls original function
},
},
{
@@ -173,9 +179,81 @@ func internalFunc2(data string) {
},
},
contains: []string{
"//export go_publicFunc",
"func go_publicFunc()",
"publicFunc()", // wrapper calls original function
},
notContains: []string{
"func internalFunc1() string",
"func internalFunc2(data string)",
"//export publicFunc",
},
},
{
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"`,
},
},
}
@@ -186,6 +264,7 @@ func internalFunc2(data string) {
BaseName: tt.baseName,
SourceFile: tt.sourceFile,
Functions: tt.functions,
Classes: tt.classes,
}
goGen := GoFileGenerator{generator}
@@ -195,6 +274,10 @@ func internalFunc2(data string) {
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)
}
})
}
}
@@ -204,11 +287,11 @@ func TestGoFileGenerator_PackageNameSanitization(t *testing.T) {
baseName string
expectedPackage string
}{
{"simple", "simple"},
{"my-extension", "my_extension"},
{"ext.with.dots", "ext_with_dots"},
{"123invalid", "_123invalid"},
{"valid_name", "valid_name"},
{"simple", "main"},
{"my-extension", "main"},
{"ext.with.dots", "main"},
{"123invalid", "main"},
{"valid_name", "main"},
}
for _, tt := range tests {
@@ -275,57 +358,6 @@ func TestGoFileGenerator_ErrorHandling(t *testing.T) {
}
}
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
@@ -398,26 +430,12 @@ func debugPrint(msg string) {
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)
}
assert.Contains(t, content, "package example", "Package name should match source package")
for _, fn := range functions {
exportDirective := "//export " + fn.Name
exportDirective := "//export go_" + 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) {
@@ -602,6 +620,434 @@ func (as *ArrayStruct) FilterData(data frankenphp.AssociativeArray, filter strin
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()
@@ -672,37 +1118,6 @@ func createTempSourceFile(t *testing.T, content string) string {
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()
@@ -822,22 +1237,54 @@ func TestGoFileGenerator_phpTypeToGoType(t *testing.T) {
})
}
func testGoFileInternalFunctions(t *testing.T, content string) {
internalIndicators := []string{
"func internalHelper",
"func anotherHelper",
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(",
"}",
}
foundInternal := false
for _, indicator := range internalIndicators {
if strings.Contains(content, indicator) {
foundInternal = true
for _, element := range requiredElements {
assert.Contains(t, content, element, "Generated file should contain: %s", element)
}
break
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)
}
}
if !foundInternal {
t.Log("No internal functions found (this may be expected)")
}
}
// 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")
}

View File

@@ -37,24 +37,25 @@ func (pfg *PHPFuncGenerator) generate(fn phpFunction) string {
func (pfg *PHPFuncGenerator) generateGoCall(fn phpFunction) string {
callParams := pfg.paramParser.generateGoCallParams(fn.Params)
goFuncName := "go_" + fn.Name
if fn.ReturnType == phpVoid {
return fmt.Sprintf(" %s(%s);", fn.Name, callParams)
return fmt.Sprintf(" %s(%s);", goFuncName, callParams)
}
if fn.ReturnType == phpString {
return fmt.Sprintf(" zend_string *result = %s(%s);", fn.Name, callParams)
return fmt.Sprintf(" zend_string *result = %s(%s);", goFuncName, callParams)
}
if fn.ReturnType == phpArray {
return fmt.Sprintf(" zend_array *result = %s(%s);", fn.Name, callParams)
return fmt.Sprintf(" zend_array *result = %s(%s);", goFuncName, callParams)
}
if fn.ReturnType == phpMixed {
return fmt.Sprintf(" zval *result = %s(%s);", fn.Name, callParams)
return fmt.Sprintf(" zval *result = %s(%s);", goFuncName, callParams)
}
return fmt.Sprintf(" %s result = %s(%s);", pfg.getCReturnType(fn.ReturnType), fn.Name, callParams)
return fmt.Sprintf(" %s result = %s(%s);", pfg.getCReturnType(fn.ReturnType), goFuncName, callParams)
}
func (pfg *PHPFuncGenerator) getCReturnType(returnType phpType) string {

View File

@@ -26,7 +26,7 @@ func TestPHPFunctionGenerator_Generate(t *testing.T) {
"PHP_FUNCTION(greet)",
"zend_string *name = NULL;",
"Z_PARAM_STR(name)",
"zend_string *result = greet(name);",
"zend_string *result = go_greet(name);",
"RETURN_STR(result)",
},
},
@@ -61,7 +61,7 @@ func TestPHPFunctionGenerator_Generate(t *testing.T) {
},
contains: []string{
"PHP_FUNCTION(doSomething)",
"doSomething(action);",
"go_doSomething(action);",
},
},
{
@@ -109,7 +109,7 @@ func TestPHPFunctionGenerator_Generate(t *testing.T) {
"PHP_FUNCTION(process_array)",
"zend_array *input = NULL;",
"Z_PARAM_ARRAY_HT(input)",
"zend_array *result = process_array(input);",
"zend_array *result = go_process_array(input);",
"RETURN_ARR(result)",
},
},

View File

@@ -10,33 +10,24 @@ import (
type SourceAnalyzer struct{}
func (sa *SourceAnalyzer) analyze(filename string) (imports []string, variables []string, internalFunctions []string, err error) {
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)
if err != nil {
return nil, nil, nil, fmt.Errorf("parsing file: %w", err)
return "", nil, nil, fmt.Errorf("parsing file: %w", err)
}
for _, imp := range node.Imports {
if imp.Path != nil {
importPath := imp.Path.Value
if imp.Name != nil {
imports = append(imports, fmt.Sprintf("%s %s", imp.Name.Name, importPath))
} else {
imports = append(imports, importPath)
}
}
}
packageName = node.Name.Name
sourceContent, err := os.ReadFile(filename)
if err != nil {
return nil, nil, nil, fmt.Errorf("reading source file: %w", err)
return "", nil, nil, fmt.Errorf("reading source file: %w", err)
}
variables = sa.extractVariables(string(sourceContent))
internalFunctions = sa.extractInternalFunctions(string(sourceContent))
return imports, variables, internalFunctions, nil
return packageName, variables, internalFunctions, nil
}
func (sa *SourceAnalyzer) extractVariables(content string) []string {

View File

@@ -227,7 +227,7 @@ func testFunction() {
require.NoError(t, os.WriteFile(filename, []byte(tt.sourceContent), 0644))
analyzer := &SourceAnalyzer{}
imports, variables, functions, err := analyzer.analyze(filename)
_, variables, functions, err := analyzer.analyze(filename)
if tt.expectError {
assert.Error(t, err, "expected error")
@@ -236,10 +236,6 @@ func testFunction() {
assert.NoError(t, err, "unexpected error")
if len(imports) != 0 && len(tt.expectedImports) != 0 {
assert.Equal(t, tt.expectedImports, imports, "imports mismatch")
}
assert.Equal(t, tt.expectedVariables, variables, "variables mismatch")
assert.Len(t, functions, len(tt.expectedFunctions), "function count mismatch")
@@ -385,6 +381,110 @@ var x = 10`,
}
}
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)")
}
func BenchmarkSourceAnalyzer_Analyze(b *testing.B) {
content := `package main

View File

@@ -1,3 +1,12 @@
// AUTOGENERATED FILE - DO NOT EDIT.
//
// This file has been automatically generated by FrankenPHP extension generator
// and should not be edited as it will be overwritten when running the
// extension generator again.
//
// You may edit the file and remove this comment if you plan to manually maintain
// this file going forward.
#include <php.h>
#include <Zend/zend_API.h>
#include <Zend/zend_hash.h>

View File

@@ -1,43 +1,32 @@
package {{.PackageName}}
// AUTOGENERATED FILE - DO NOT EDIT.
//
// This file has been automatically generated by FrankenPHP extension generator
// and should not be edited as it will be overwritten when running the
// extension generator again.
//
// You may edit the file and remove this comment if you plan to manually maintain
// this file going forward.
// #include <stdlib.h>
// #include "{{.BaseName}}.h"
import "C"
import (
{{if not .Classes}}_ {{end}}"runtime/cgo"
"unsafe"
"github.com/dunglas/frankenphp"
{{- range .Imports}}
{{.}}
{{- end}}
)
func init() {
frankenphp.RegisterExtension(unsafe.Pointer(&C.{{.BaseName}}_module_entry))
frankenphp.RegisterExtension(unsafe.Pointer(&C.{{.SanitizedBaseName}}_module_entry))
}
{{ range .Constants}}
const {{.Name}} = {{.Value}}
{{- end}}
{{- range .Variables}}
{{.}}
{{- end}}
{{- range .InternalFunctions}}
{{.}}
{{- end}}
{{- range .Functions}}
//export {{.Name}}
{{.GoFunction}}
{{- end}}
{{- range .Classes}}
type {{.GoStruct}} struct {
{{- range .Properties}}
{{.Name}} {{.GoType}}
{{- end}}
//export go_{{.Name}}
func go_{{.Name}}({{extractGoFunctionSignatureParams .GoFunction}}) {{extractGoFunctionSignatureReturn .GoFunction}} {
{{if not (isVoid .ReturnType)}}return {{end}}{{extractGoFunctionName .GoFunction}}({{extractGoFunctionCallParams .GoFunction}})
}
{{- end}}
@@ -68,12 +57,6 @@ func create_{{.GoStruct}}_object() C.uintptr_t {
return registerGoObject(obj)
}
{{- range .Methods}}
{{- if .GoFunction}}
{{.GoFunction}}
{{- end}}
{{- end}}
{{- range .Methods}}
//export {{.Name}}_wrapper
func {{.Name}}_wrapper(handle C.uintptr_t{{range .Params}}{{if eq .PhpType "string"}}, {{.Name}} *C.zend_string{{else if eq .PhpType "array"}}, {{.Name}} *C.zval{{else 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}} {

View File

@@ -1,3 +1,12 @@
// AUTOGENERATED FILE - DO NOT EDIT.
//
// This file has been automatically generated by FrankenPHP extension generator
// and should not be edited as it will be overwritten when running the
// extension generator again.
//
// You may edit the file and remove this comment if you plan to manually maintain
// this file going forward.
#ifndef _{{.HeaderGuard}}
#define _{{.HeaderGuard}}

View File

@@ -1,6 +1,15 @@
<?php
/** @generate-class-entries */
// AUTOGENERATED FILE - DO NOT EDIT.
//
// This file has been automatically generated by FrankenPHP extension generator
// and should not be edited as it will be overwritten when running the
// extension generator again.
//
// You may edit the file and remove this comment if you plan to manually maintain
// this file going forward.
{{if .Namespace}}
namespace {{.Namespace}};
{{end}}