Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,18 @@ GEN_STUB_SCRIPT=php-src/build/gen_stub.php frankenphp extension-init my_extensio
> [!NOTE]
> Don't forget to set the `GEN_STUB_SCRIPT` environment variable to the path of the `gen_stub.php` file in the PHP sources you downloaded earlier. This is the same `gen_stub.php` script mentioned in the manual implementation section.

If everything went well, a new directory named `build` should have been created. This directory contains the generated files for your extension, including the `my_extension.go` file with the generated PHP function stubs.
If everything went well, your project directory should contain the following files for your extension:

- **`my_extension.go`** - Your original source file (remains unchanged)
- **`my_extension_generated.go`** - Generated file with CGO wrappers that call your functions
- **`my_extension.stub.php`** - PHP stub file for IDE autocompletion
- **`my_extension_arginfo.h`** - PHP argument information
- **`my_extension.h`** - C header file
- **`my_extension.c`** - C implementation file
- **`README.md`** - Documentation

> [!IMPORTANT]
> **Your source file (`my_extension.go`) is never modified.** The generator creates a separate `_generated.go` file containing CGO wrappers that call your original functions. This means you can safely version control your source file without worrying about generated code polluting it.

### Integrating the Generated Extension into FrankenPHP

Expand Down
136 changes: 123 additions & 13 deletions internal/extgen/gofile.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"bytes"
_ "embed"
"fmt"
"os"
"path/filepath"
"text/template"

Expand All @@ -30,16 +29,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 {
Expand All @@ -51,7 +41,7 @@ func (gg *GoFileGenerator) generate() error {

func (gg *GoFileGenerator) buildContent() (string, error) {
sourceAnalyzer := SourceAnalyzer{}
imports, variables, internalFunctions, err := sourceAnalyzer.analyze(gg.generator.SourceFile)
packageName, imports, variables, internalFunctions, err := sourceAnalyzer.analyze(gg.generator.SourceFile)
if err != nil {
return "", fmt.Errorf("analyzing source file: %w", err)
}
Expand Down Expand Up @@ -80,7 +70,7 @@ func (gg *GoFileGenerator) buildContent() (string, error) {
}

templateContent, err := gg.getTemplateContent(goTemplateData{
PackageName: SanitizePackageName(gg.generator.BaseName),
PackageName: packageName,
BaseName: gg.generator.BaseName,
Imports: filteredImports,
Constants: gg.generator.Constants,
Expand All @@ -106,6 +96,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))

Expand Down Expand Up @@ -145,3 +139,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 {
start := 0
funcBytes := []byte(goFunction)
if idx := bytes.Index(funcBytes, []byte("func ")); idx != -1 {
start = idx + len("func ")
} else {
return ""
}
Comment on lines +145 to +151
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
start := 0
funcBytes := []byte(goFunction)
if idx := bytes.Index(funcBytes, []byte("func ")); idx != -1 {
start = idx + len("func ")
} else {
return ""
}
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 string(bytes.TrimSpace(funcBytes[start:end]))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return string(bytes.TrimSpace(funcBytes[start:end]))
return strings.TrimSpace(goFunction[start:end]))

}

// extractGoFunctionSignatureParams extracts the parameters from a Go function signature.
func extractGoFunctionSignatureParams(goFunction string) string {
start := bytes.IndexByte([]byte(goFunction), '(')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
start := bytes.IndexByte([]byte(goFunction), '(')
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 string(bytes.TrimSpace([]byte(goFunction[start:end])))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return string(bytes.TrimSpace([]byte(goFunction[start:end])))
return strings.TrimSpace(goFunction[start:end]))

}

// extractGoFunctionSignatureReturn extracts the return type from a Go function signature.
func extractGoFunctionSignatureReturn(goFunction string) string {
start := bytes.IndexByte([]byte(goFunction), '(')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
start := bytes.IndexByte([]byte(goFunction), '(')
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 := bytes.IndexByte([]byte(goFunction[pos:]), '{')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
end := bytes.IndexByte([]byte(goFunction[pos:]), '{')
end := strings.IndexByte(goFunction[pos:], '{')

if end == -1 {
return ""
}
end += pos

returnType := string(bytes.TrimSpace([]byte(goFunction[pos:end])))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
returnType := string(bytes.TrimSpace([]byte(goFunction[pos:end])))
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 := bytes.Split([]byte(params), []byte(","))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
parts := bytes.Split([]byte(params), []byte(","))
parts := strings.Split(params, ",")

for _, part := range parts {
part = bytes.TrimSpace(part)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
part = bytes.TrimSpace(part)
part = strings.TrimSpace(part)

if len(part) == 0 {
continue
}

words := bytes.Fields(part)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
words := bytes.Fields(part)
words := strings.Fields(part)

if len(words) > 0 {
names = append(names, string(words[0]))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
names = append(names, string(words[0]))
names = append(names, words[0])

}
}

var result []byte
for i, name := range names {
if i > 0 {
result = append(result, []byte(", ")...)
}
result = append(result, []byte(name)...)
}

return string(result)
Comment on lines +248 to +256
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
var result []byte
for i, name := range names {
if i > 0 {
result = append(result, []byte(", ")...)
}
result = append(result, []byte(name)...)
}
return string(result)
var result strings.Builder
for i, name := range names {
if i > 0 {
result.WriteString(", ")
}
result.WriteString(name)
}
return result.String()

}
70 changes: 59 additions & 11 deletions internal/extgen/gofile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,20 @@
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")

generatedFile := filepath.Join(tmpDir, "test_generated.go")
require.FileExists(t, generatedFile)

content, err := readFile(expectedFile)
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) {
Expand Down Expand Up @@ -112,8 +116,9 @@
`import "C"`,
"func init()",
"frankenphp.RegisterExtension(",
"//export test",
"func test()",
"//export go_test",
"func go_test()",
"test()", // wrapper calls original function
},
},
{
Expand Down Expand Up @@ -146,8 +151,9 @@
`"fmt"`,
`"strings"`,
`"encoding/json"`,
"//export process",
"//export go_process",
`"C"`,
"process(", // wrapper calls original function
},
},
{
Expand All @@ -173,9 +179,13 @@
},
},
contains: []string{
"//export go_publicFunc",
"func go_publicFunc()",
"publicFunc()", // wrapper calls original function
},
notContains: []string{
"func internalFunc1() string",
"func internalFunc2(data string)",
"//export publicFunc",
},
},
}
Expand All @@ -195,6 +205,10 @@
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)
}
})
}
}
Expand Down Expand Up @@ -672,7 +686,7 @@
return tmpFile
}

func testGoFileBasicStructure(t *testing.T, content, baseName string) {

Check failure on line 689 in internal/extgen/gofile_test.go

View workflow job for this annotation

GitHub Actions / Tests (Linux, PHP 8.5)

func testGoFileBasicStructure is unused (unused)
requiredElements := []string{
"package " + SanitizePackageName(baseName),
"// #include <stdlib.h>",
Expand All @@ -688,12 +702,12 @@
}
}

func testGoFileImports(t *testing.T, content string) {

Check failure on line 705 in internal/extgen/gofile_test.go

View workflow job for this annotation

GitHub Actions / Tests (Linux, PHP 8.5)

func testGoFileImports is unused (unused)
cImportCount := strings.Count(content, `"C"`)
assert.Equal(t, 1, cImportCount, "Expected exactly 1 C import")
}

func testGoFileExportedFunctions(t *testing.T, content string, functions []phpFunction) {

Check failure on line 710 in internal/extgen/gofile_test.go

View workflow job for this annotation

GitHub Actions / Tests (Linux, PHP 8.5)

func testGoFileExportedFunctions is unused (unused)
for _, fn := range functions {
exportDirective := "//export " + fn.Name
assert.Contains(t, content, exportDirective, "Go file should contain export directive: %s", exportDirective)
Expand All @@ -703,7 +717,7 @@
}
}

func testGoFileInternalFunctions(t *testing.T, content string) {

Check failure on line 720 in internal/extgen/gofile_test.go

View workflow job for this annotation

GitHub Actions / Tests (Linux, PHP 8.5)

func testGoFileInternalFunctions is unused (unused)
internalIndicators := []string{
"func internalHelper",
"func anotherHelper",
Expand All @@ -722,3 +736,37 @@
t.Log("No internal functions found (this may be expected)")
}
}

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)
}
}
}
11 changes: 6 additions & 5 deletions internal/extgen/phpfunc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions internal/extgen/phpfunc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
},
},
Expand Down Expand Up @@ -61,7 +61,7 @@ func TestPHPFunctionGenerator_Generate(t *testing.T) {
},
contains: []string{
"PHP_FUNCTION(doSomething)",
"doSomething(action);",
"go_doSomething(action);",
},
},
{
Expand Down Expand Up @@ -109,7 +109,7 @@ func TestPHPFunctionGenerator_Generate(t *testing.T) {
"PHP_FUNCTION(process_array)",
"zval *input = NULL;",
"Z_PARAM_ARRAY(input)",
"zend_array *result = process_array(input);",
"zend_array *result = go_process_array(input);",
"RETURN_ARR(result)",
},
},
Expand Down
Loading
Loading