Skip to content

Commit 2caf932

Browse files
feat(extgen): make the generator idempotent and avoid touching the original source
1 parent c6cadf3 commit 2caf932

File tree

11 files changed

+252
-68
lines changed

11 files changed

+252
-68
lines changed

docs/extensions.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -549,7 +549,18 @@ GEN_STUB_SCRIPT=php-src/build/gen_stub.php frankenphp extension-init my_extensio
549549
> [!NOTE]
550550
> 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.
551551
552-
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.
552+
If everything went well, your project directory should contain the following files for your extension:
553+
554+
- **`my_extension.go`** - Your original source file (remains unchanged)
555+
- **`my_extension_generated.go`** - Generated file with CGO wrappers that call your functions
556+
- **`my_extension.stub.php`** - PHP stub file for IDE autocompletion
557+
- **`my_extension_arginfo.h`** - PHP argument information
558+
- **`my_extension.h`** - C header file
559+
- **`my_extension.c`** - C implementation file
560+
- **`README.md`** - Documentation
561+
562+
> [!IMPORTANT]
563+
> **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.
553564
554565
### Integrating the Generated Extension into FrankenPHP
555566

internal/extgen/gofile.go

Lines changed: 123 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"bytes"
55
_ "embed"
66
"fmt"
7-
"os"
87
"path/filepath"
98
"text/template"
109

@@ -30,16 +29,7 @@ type goTemplateData struct {
3029
}
3130

3231
func (gg *GoFileGenerator) generate() error {
33-
filename := filepath.Join(gg.generator.BuildDir, gg.generator.BaseName+".go")
34-
35-
if _, err := os.Stat(filename); err == nil {
36-
backupFilename := filename + ".bak"
37-
if err := os.Rename(filename, backupFilename); err != nil {
38-
return fmt.Errorf("backing up existing Go file: %w", err)
39-
}
40-
41-
gg.generator.SourceFile = backupFilename
42-
}
32+
filename := filepath.Join(gg.generator.BuildDir, gg.generator.BaseName+"_generated.go")
4333

4434
content, err := gg.buildContent()
4535
if err != nil {
@@ -51,7 +41,7 @@ func (gg *GoFileGenerator) generate() error {
5141

5242
func (gg *GoFileGenerator) buildContent() (string, error) {
5343
sourceAnalyzer := SourceAnalyzer{}
54-
imports, variables, internalFunctions, err := sourceAnalyzer.analyze(gg.generator.SourceFile)
44+
packageName, imports, variables, internalFunctions, err := sourceAnalyzer.analyze(gg.generator.SourceFile)
5545
if err != nil {
5646
return "", fmt.Errorf("analyzing source file: %w", err)
5747
}
@@ -80,7 +70,7 @@ func (gg *GoFileGenerator) buildContent() (string, error) {
8070
}
8171

8272
templateContent, err := gg.getTemplateContent(goTemplateData{
83-
PackageName: SanitizePackageName(gg.generator.BaseName),
73+
PackageName: packageName,
8474
BaseName: gg.generator.BaseName,
8575
Imports: filteredImports,
8676
Constants: gg.generator.Constants,
@@ -106,6 +96,10 @@ func (gg *GoFileGenerator) getTemplateContent(data goTemplateData) (string, erro
10696
funcMap["isVoid"] = func(t phpType) bool {
10797
return t == phpVoid
10898
}
99+
funcMap["extractGoFunctionName"] = extractGoFunctionName
100+
funcMap["extractGoFunctionSignatureParams"] = extractGoFunctionSignatureParams
101+
funcMap["extractGoFunctionSignatureReturn"] = extractGoFunctionSignatureReturn
102+
funcMap["extractGoFunctionCallParams"] = extractGoFunctionCallParams
109103

110104
tmpl := template.Must(template.New("gofile").Funcs(funcMap).Parse(goFileContent))
111105

@@ -145,3 +139,119 @@ func (gg *GoFileGenerator) phpTypeToGoType(phpT phpType) string {
145139

146140
return "any"
147141
}
142+
143+
// extractGoFunctionName extracts the Go function name from a Go function signature string.
144+
func extractGoFunctionName(goFunction string) string {
145+
start := 0
146+
funcBytes := []byte(goFunction)
147+
if idx := bytes.Index(funcBytes, []byte("func ")); idx != -1 {
148+
start = idx + len("func ")
149+
} else {
150+
return ""
151+
}
152+
153+
end := start
154+
for end < len(goFunction) && goFunction[end] != '(' {
155+
end++
156+
}
157+
158+
if end >= len(goFunction) {
159+
return ""
160+
}
161+
162+
return string(bytes.TrimSpace(funcBytes[start:end]))
163+
}
164+
165+
// extractGoFunctionSignatureParams extracts the parameters from a Go function signature.
166+
func extractGoFunctionSignatureParams(goFunction string) string {
167+
start := bytes.IndexByte([]byte(goFunction), '(')
168+
if start == -1 {
169+
return ""
170+
}
171+
start++
172+
173+
depth := 1
174+
end := start
175+
for end < len(goFunction) && depth > 0 {
176+
switch goFunction[end] {
177+
case '(':
178+
depth++
179+
case ')':
180+
depth--
181+
}
182+
if depth > 0 {
183+
end++
184+
}
185+
}
186+
187+
if end >= len(goFunction) {
188+
return ""
189+
}
190+
191+
return string(bytes.TrimSpace([]byte(goFunction[start:end])))
192+
}
193+
194+
// extractGoFunctionSignatureReturn extracts the return type from a Go function signature.
195+
func extractGoFunctionSignatureReturn(goFunction string) string {
196+
start := bytes.IndexByte([]byte(goFunction), '(')
197+
if start == -1 {
198+
return ""
199+
}
200+
201+
depth := 1
202+
pos := start + 1
203+
for pos < len(goFunction) && depth > 0 {
204+
switch goFunction[pos] {
205+
case '(':
206+
depth++
207+
case ')':
208+
depth--
209+
}
210+
pos++
211+
}
212+
213+
if pos >= len(goFunction) {
214+
return ""
215+
}
216+
217+
end := bytes.IndexByte([]byte(goFunction[pos:]), '{')
218+
if end == -1 {
219+
return ""
220+
}
221+
end += pos
222+
223+
returnType := string(bytes.TrimSpace([]byte(goFunction[pos:end])))
224+
return returnType
225+
}
226+
227+
// extractGoFunctionCallParams extracts just the parameter names for calling a function.
228+
func extractGoFunctionCallParams(goFunction string) string {
229+
params := extractGoFunctionSignatureParams(goFunction)
230+
if params == "" {
231+
return ""
232+
}
233+
234+
var names []string
235+
parts := bytes.Split([]byte(params), []byte(","))
236+
for _, part := range parts {
237+
part = bytes.TrimSpace(part)
238+
if len(part) == 0 {
239+
continue
240+
}
241+
242+
words := bytes.Fields(part)
243+
if len(words) > 0 {
244+
names = append(names, string(words[0]))
245+
}
246+
}
247+
248+
var result []byte
249+
for i, name := range names {
250+
if i > 0 {
251+
result = append(result, []byte(", ")...)
252+
}
253+
result = append(result, []byte(name)...)
254+
}
255+
256+
return string(result)
257+
}

internal/extgen/gofile_test.go

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -69,16 +69,20 @@ func anotherHelper() {
6969
goGen := GoFileGenerator{generator}
7070
require.NoError(t, goGen.generate())
7171

72-
expectedFile := filepath.Join(tmpDir, "test.go")
73-
require.FileExists(t, expectedFile)
72+
sourceStillExists := filepath.Join(tmpDir, "test.go")
73+
require.FileExists(t, sourceStillExists)
74+
sourceStillContent, err := readFile(sourceStillExists)
75+
require.NoError(t, err)
76+
assert.Equal(t, sourceContent, sourceStillContent, "Source file should not be modified")
77+
78+
generatedFile := filepath.Join(tmpDir, "test_generated.go")
79+
require.FileExists(t, generatedFile)
7480

75-
content, err := readFile(expectedFile)
81+
generatedContent, err := readFile(generatedFile)
7682
require.NoError(t, err)
7783

78-
testGoFileBasicStructure(t, content, "test")
79-
testGoFileImports(t, content)
80-
testGoFileExportedFunctions(t, content, generator.Functions)
81-
testGoFileInternalFunctions(t, content)
84+
testGeneratedFileBasicStructure(t, generatedContent, "main", "test")
85+
testGeneratedFileWrappers(t, generatedContent, generator.Functions)
8286
}
8387

8488
func TestGoFileGenerator_BuildContent(t *testing.T) {
@@ -112,8 +116,9 @@ func test() {
112116
`import "C"`,
113117
"func init()",
114118
"frankenphp.RegisterExtension(",
115-
"//export test",
116-
"func test()",
119+
"//export go_test",
120+
"func go_test()",
121+
"test()", // wrapper calls original function
117122
},
118123
},
119124
{
@@ -146,8 +151,9 @@ func process(data *go_string) *go_value {
146151
`"fmt"`,
147152
`"strings"`,
148153
`"encoding/json"`,
149-
"//export process",
154+
"//export go_process",
150155
`"C"`,
156+
"process(", // wrapper calls original function
151157
},
152158
},
153159
{
@@ -173,9 +179,13 @@ func internalFunc2(data string) {
173179
},
174180
},
175181
contains: []string{
182+
"//export go_publicFunc",
183+
"func go_publicFunc()",
184+
"publicFunc()", // wrapper calls original function
185+
},
186+
notContains: []string{
176187
"func internalFunc1() string",
177188
"func internalFunc2(data string)",
178-
"//export publicFunc",
179189
},
180190
},
181191
}
@@ -195,6 +205,10 @@ func internalFunc2(data string) {
195205
for _, expected := range tt.contains {
196206
assert.Contains(t, content, expected, "Generated Go content should contain %q", expected)
197207
}
208+
209+
for _, notExpected := range tt.notContains {
210+
assert.NotContains(t, content, notExpected, "Generated Go content should NOT contain %q", notExpected)
211+
}
198212
})
199213
}
200214
}
@@ -722,3 +736,37 @@ func testGoFileInternalFunctions(t *testing.T, content string) {
722736
t.Log("No internal functions found (this may be expected)")
723737
}
724738
}
739+
740+
func testGeneratedFileBasicStructure(t *testing.T, content, expectedPackage, baseName string) {
741+
requiredElements := []string{
742+
"package " + expectedPackage,
743+
"// #include <stdlib.h>",
744+
`// #include "` + baseName + `.h"`,
745+
`import "C"`,
746+
"func init() {",
747+
"frankenphp.RegisterExtension(",
748+
"}",
749+
}
750+
751+
for _, element := range requiredElements {
752+
assert.Contains(t, content, element, "Generated file should contain: %s", element)
753+
}
754+
755+
assert.NotContains(t, content, "func internalHelper", "Generated file should not contain internal functions from source")
756+
assert.NotContains(t, content, "func anotherHelper", "Generated file should not contain internal functions from source")
757+
}
758+
759+
func testGeneratedFileWrappers(t *testing.T, content string, functions []phpFunction) {
760+
for _, fn := range functions {
761+
exportDirective := "//export go_" + fn.Name
762+
assert.Contains(t, content, exportDirective, "Generated file should contain export directive: %s", exportDirective)
763+
764+
wrapperFunc := "func go_" + fn.Name + "("
765+
assert.Contains(t, content, wrapperFunc, "Generated file should contain wrapper function: %s", wrapperFunc)
766+
767+
funcName := extractGoFunctionName(fn.GoFunction)
768+
if funcName != "" {
769+
assert.Contains(t, content, funcName+"(", "Generated wrapper should call original function: %s", funcName)
770+
}
771+
}
772+
}

internal/extgen/phpfunc.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,24 +37,25 @@ func (pfg *PHPFuncGenerator) generate(fn phpFunction) string {
3737

3838
func (pfg *PHPFuncGenerator) generateGoCall(fn phpFunction) string {
3939
callParams := pfg.paramParser.generateGoCallParams(fn.Params)
40+
goFuncName := "go_" + fn.Name
4041

4142
if fn.ReturnType == phpVoid {
42-
return fmt.Sprintf(" %s(%s);", fn.Name, callParams)
43+
return fmt.Sprintf(" %s(%s);", goFuncName, callParams)
4344
}
4445

4546
if fn.ReturnType == phpString {
46-
return fmt.Sprintf(" zend_string *result = %s(%s);", fn.Name, callParams)
47+
return fmt.Sprintf(" zend_string *result = %s(%s);", goFuncName, callParams)
4748
}
4849

4950
if fn.ReturnType == phpArray {
50-
return fmt.Sprintf(" zend_array *result = %s(%s);", fn.Name, callParams)
51+
return fmt.Sprintf(" zend_array *result = %s(%s);", goFuncName, callParams)
5152
}
5253

5354
if fn.ReturnType == phpMixed {
54-
return fmt.Sprintf(" zval *result = %s(%s);", fn.Name, callParams)
55+
return fmt.Sprintf(" zval *result = %s(%s);", goFuncName, callParams)
5556
}
5657

57-
return fmt.Sprintf(" %s result = %s(%s);", pfg.getCReturnType(fn.ReturnType), fn.Name, callParams)
58+
return fmt.Sprintf(" %s result = %s(%s);", pfg.getCReturnType(fn.ReturnType), goFuncName, callParams)
5859
}
5960

6061
func (pfg *PHPFuncGenerator) getCReturnType(returnType phpType) string {

internal/extgen/phpfunc_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ func TestPHPFunctionGenerator_Generate(t *testing.T) {
2626
"PHP_FUNCTION(greet)",
2727
"zend_string *name = NULL;",
2828
"Z_PARAM_STR(name)",
29-
"zend_string *result = greet(name);",
29+
"zend_string *result = go_greet(name);",
3030
"RETURN_STR(result)",
3131
},
3232
},
@@ -61,7 +61,7 @@ func TestPHPFunctionGenerator_Generate(t *testing.T) {
6161
},
6262
contains: []string{
6363
"PHP_FUNCTION(doSomething)",
64-
"doSomething(action);",
64+
"go_doSomething(action);",
6565
},
6666
},
6767
{
@@ -109,7 +109,7 @@ func TestPHPFunctionGenerator_Generate(t *testing.T) {
109109
"PHP_FUNCTION(process_array)",
110110
"zval *input = NULL;",
111111
"Z_PARAM_ARRAY(input)",
112-
"zend_array *result = process_array(input);",
112+
"zend_array *result = go_process_array(input);",
113113
"RETURN_ARR(result)",
114114
},
115115
},

0 commit comments

Comments
 (0)