diff --git a/docs/extensions.md b/docs/extensions.md index 0efc34f6d..09b4e1a00 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -267,6 +267,33 @@ $user->updateInfo(null, 25, null); // Name and active are null This design ensures that your Go code has complete control over how the object's state is accessed and modified, providing better encapsulation and type safety. +### Module Initialization and Shutdown + +The generator supports defining module initialization and shutdown functions using the `//export_php:module` directive. +This allows you to perform setup and cleanup operations when your extension is loaded and unloaded. + +To define an initialization function, tag it with `//export_php:module init`: + +```go +//export_php:module init +func initializeModule() { + // Perform initialization tasks + // For example, set up global resources, initialize data structures, etc. +} +``` + +To define a shutdown function, tag it with `//export_php:module shutdown`: + +```go +//export_php:module shutdown +func cleanupModule() { + // Perform cleanup tasks + // For example, free resources, close connections, etc. +} +``` + +You can define either one, both, or none of these functions. The initialization function will be called when the PHP module is loaded, and the shutdown function will be called when the PHP module is unloaded. + ### Declaring Constants The generator supports exporting Go constants to PHP using two directives: `//export_php:const` for global constants and `//export_php:classconstant` for class constants. This allows you to share configuration values, status codes, and other constants between Go and PHP code. diff --git a/internal/extgen/cfile.go b/internal/extgen/cfile.go index e3b7b5fd9..c8fb4c633 100644 --- a/internal/extgen/cfile.go +++ b/internal/extgen/cfile.go @@ -23,6 +23,7 @@ type cTemplateData struct { Classes []phpClass Constants []phpConstant Namespace string + Module *phpModule } func (cg *cFileGenerator) generate() error { @@ -68,6 +69,7 @@ func (cg *cFileGenerator) getTemplateContent() (string, error) { Classes: cg.generator.Classes, Constants: cg.generator.Constants, Namespace: cg.generator.Namespace, + Module: cg.generator.Module, }); err != nil { return "", err } diff --git a/internal/extgen/generator.go b/internal/extgen/generator.go index f3c31e816..93aeaa2fb 100644 --- a/internal/extgen/generator.go +++ b/internal/extgen/generator.go @@ -15,6 +15,7 @@ type Generator struct { Classes []phpClass Constants []phpConstant Namespace string + Module *phpModule } // EXPERIMENTAL @@ -86,6 +87,12 @@ func (g *Generator) parseSource() error { } g.Namespace = ns + module, err := parser.ParseModule(g.SourceFile) + if err != nil { + return fmt.Errorf("parsing module: %w", err) + } + g.Module = module + return nil } diff --git a/internal/extgen/gofile.go b/internal/extgen/gofile.go index d90883bff..36e8eb982 100644 --- a/internal/extgen/gofile.go +++ b/internal/extgen/gofile.go @@ -5,6 +5,7 @@ import ( _ "embed" "fmt" "path/filepath" + "strings" "text/template" "github.com/Masterminds/sprig/v3" @@ -25,6 +26,8 @@ type goTemplateData struct { InternalFunctions []string Functions []phpFunction Classes []phpClass + Module *phpModule + HasInitFunction bool } func (gg *GoFileGenerator) generate() error { @@ -54,6 +57,16 @@ func (gg *GoFileGenerator) buildContent() (string, error) { classes := make([]phpClass, len(gg.generator.Classes)) copy(classes, gg.generator.Classes) + // Check if there's already an init() function in the source file + hasInitFunction := false + for _, fn := range internalFunctions { + if strings.HasPrefix(fn, "func init()") { + hasInitFunction = true + fmt.Printf("Warning: An init() function already exists in the source file. Make sure to call frankenphp.RegisterExtension(unsafe.Pointer(&C.ext_module_entry)) in your init function.\n") + break + } + } + templateContent, err := gg.getTemplateContent(goTemplateData{ PackageName: SanitizePackageName(gg.generator.BaseName), BaseName: gg.generator.BaseName, @@ -62,6 +75,8 @@ func (gg *GoFileGenerator) buildContent() (string, error) { InternalFunctions: internalFunctions, Functions: gg.generator.Functions, Classes: classes, + Module: gg.generator.Module, + HasInitFunction: hasInitFunction, }) if err != nil { diff --git a/internal/extgen/gofile_test.go b/internal/extgen/gofile_test.go index 11e962f3d..41ee5395d 100644 --- a/internal/extgen/gofile_test.go +++ b/internal/extgen/gofile_test.go @@ -103,7 +103,9 @@ func test() { { Name: "test", ReturnType: phpVoid, - GoFunction: "func test() {\n\t// simple function\n}", + GoFunction: `func test() { + // simple function +}`, }, }, contains: []string{ @@ -213,7 +215,9 @@ func TestGoFileGenerator_PackageNameSanitization(t *testing.T) { for _, tt := range tests { t.Run(tt.baseName, func(t *testing.T) { - sourceFile := createTempSourceFile(t, "package main\n//export_php: test(): void\nfunc test() {}") + sourceFile := createTempSourceFile(t, `package main +//export_php: test(): void +func test() {}`) generator := &Generator{ BaseName: tt.baseName, @@ -251,7 +255,8 @@ func TestGoFileGenerator_ErrorHandling(t *testing.T) { }, { name: "valid file", - sourceFile: createTempSourceFile(t, "package main\nfunc test() {}"), + sourceFile: createTempSourceFile(t, `package main +func test() {}`), expectErr: false, }, } @@ -732,3 +737,170 @@ func testGoFileInternalFunctions(t *testing.T, content string) { t.Log("No internal functions found (this may be expected)") } } + +func TestGoFileGenerator_RuntimeCgoImportGating(t *testing.T) { + tests := []struct { + name string + baseName string + sourceFile string + functions []phpFunction + classes []phpClass + expectCgoImport bool + description string + }{ + { + name: "extension without classes should not include runtime/cgo import", + baseName: "no_classes", + sourceFile: createTempSourceFile(t, `package main + +//export_php: simpleFunc(): void +func simpleFunc() { + // simple function +}`), + functions: []phpFunction{ + { + Name: "simpleFunc", + ReturnType: phpVoid, + GoFunction: `func simpleFunc() { + // simple function +}`, + }, + }, + classes: nil, + expectCgoImport: false, + description: "Extensions with only functions should not import runtime/cgo", + }, + { + name: "extension with classes should include runtime/cgo import", + baseName: "with_classes", + sourceFile: createTempSourceFile(t, `package main + +//export_php:class TestClass +type TestStruct struct { + name string +} + +//export_php:method TestClass::getName(): string +func (ts *TestStruct) GetName() string { + return ts.name +}`), + functions: nil, + classes: []phpClass{ + { + Name: "TestClass", + GoStruct: "TestStruct", + Methods: []phpClassMethod{ + { + Name: "GetName", + PhpName: "getName", + ClassName: "TestClass", + Signature: "getName(): string", + ReturnType: phpString, + Params: []phpParameter{}, + GoFunction: `func (ts *TestStruct) GetName() string { + return ts.name +}`, + }, + }, + }, + }, + expectCgoImport: true, + description: "Extensions with classes should import runtime/cgo for handle management", + }, + { + name: "extension with functions and classes should include runtime/cgo import", + baseName: "mixed", + sourceFile: createTempSourceFile(t, `package main + +//export_php: utilFunc(): string +func utilFunc() string { + return "utility" +} + +//export_php:class MixedClass +type MixedStruct struct { + value int +} + +//export_php:method MixedClass::getValue(): int +func (ms *MixedStruct) GetValue() int { + return ms.value +}`), + functions: []phpFunction{ + { + Name: "utilFunc", + ReturnType: phpString, + GoFunction: `func utilFunc() string { + return "utility" +}`, + }, + }, + classes: []phpClass{ + { + Name: "MixedClass", + GoStruct: "MixedStruct", + Methods: []phpClassMethod{ + { + Name: "GetValue", + PhpName: "getValue", + ClassName: "MixedClass", + Signature: "getValue(): int", + ReturnType: phpInt, + Params: []phpParameter{}, + GoFunction: `func (ms *MixedStruct) GetValue() int { + return ms.value +}`, + }, + }, + }, + }, + expectCgoImport: true, + description: "Extensions with both functions and classes should import runtime/cgo", + }, + { + name: "empty extension should not include runtime/cgo import", + baseName: "empty", + sourceFile: createTempSourceFile(t, `package main + +// Empty extension for testing +`), + functions: nil, + classes: nil, + expectCgoImport: false, + description: "Empty extensions should not import 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) + + cgoImportPresent := strings.Contains(content, `import "runtime/cgo"`) + + if tt.expectCgoImport { + assert.True(t, cgoImportPresent, "Extension should import runtime/cgo: %s", tt.description) + // Verify that cgo functions are also present when import is included + assert.Contains(t, content, "cgo.NewHandle", "Should contain cgo.NewHandle usage when runtime/cgo is imported") + assert.Contains(t, content, "cgo.Handle", "Should contain cgo.Handle usage when runtime/cgo is imported") + } else { + assert.False(t, cgoImportPresent, "Extension should not import runtime/cgo: %s", tt.description) + // Verify that cgo functions are not present when import is not included + assert.NotContains(t, content, "cgo.NewHandle", "Should not contain cgo.NewHandle usage when runtime/cgo is not imported") + assert.NotContains(t, content, "cgo.Handle", "Should not contain cgo.Handle usage when runtime/cgo is not imported") + } + + // Ensure exactly one C import is always present + cImportCount := strings.Count(content, `import "C"`) + assert.Equal(t, 1, cImportCount, "Should have exactly one C import") + }) + } +} diff --git a/internal/extgen/moduleParser.go b/internal/extgen/moduleParser.go new file mode 100644 index 000000000..e3d1f3ad5 --- /dev/null +++ b/internal/extgen/moduleParser.go @@ -0,0 +1,137 @@ +package extgen + +import ( + "bufio" + "fmt" + "os" + "regexp" + "strings" +) + +var ( + phpModuleParser = regexp.MustCompile(`//\s*export_php:module\s+(init|shutdown)`) + funcNameRegex = regexp.MustCompile(`func\s+([a-zA-Z0-9_]+)`) +) + +// phpModule represents a PHP module with optional init and shutdown functions +type phpModule struct { + InitFunc string // Name of the init function + InitCode string // Code of the init function + ShutdownFunc string // Name of the shutdown function + ShutdownCode string // Code of the shutdown function +} + +// ModuleParser parses PHP module directives from Go source files +type ModuleParser struct{} + +// parse parses the source file for PHP module directives +func (mp *ModuleParser) parse(filename string) (module *phpModule, err error) { + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer func() { + e := file.Close() + if err == nil { + err = e + } + }() + + scanner := bufio.NewScanner(file) + module = &phpModule{} + var ( + currentDirective string + lineNumber int + ) + + for scanner.Scan() { + lineNumber++ + line := strings.TrimSpace(scanner.Text()) + + if matches := phpModuleParser.FindStringSubmatch(line); matches != nil { + directiveType := matches[1] + currentDirective = directiveType + continue + } + + // If we have a current directive and encounter a non-comment line + // that doesn't start with "func ", reset the current directive + if currentDirective != "" && (line == "" || (!strings.HasPrefix(line, "//") && !strings.HasPrefix(line, "func "))) { + currentDirective = "" + continue + } + + if currentDirective != "" && strings.HasPrefix(line, "func ") { + funcName, funcCode, err := mp.extractGoFunction(scanner, line) + if err != nil { + return nil, fmt.Errorf("extracting Go function at line %d: %w", lineNumber, err) + } + + switch currentDirective { + case "init": + if module.InitFunc != "" { + return nil, fmt.Errorf("duplicate init directive found at line %d: init function '%s' already defined", lineNumber, module.InitFunc) + } + module.InitFunc = funcName + module.InitCode = funcCode + case "shutdown": + if module.ShutdownFunc != "" { + return nil, fmt.Errorf("duplicate shutdown directive found at line %d: shutdown function '%s' already defined", lineNumber, module.ShutdownFunc) + } + module.ShutdownFunc = funcName + module.ShutdownCode = funcCode + } + + currentDirective = "" + } + } + + // If we found no module functions, return nil + if module.InitFunc == "" && module.ShutdownFunc == "" { + return nil, nil + } + + return module, scanner.Err() +} + +// extractGoFunction extracts the function name and code from a function declaration +func (mp *ModuleParser) extractGoFunction(scanner *bufio.Scanner, firstLine string) (string, string, error) { + // Extract function name from the first line + matches := funcNameRegex.FindStringSubmatch(firstLine) + if len(matches) < 2 { + return "", "", fmt.Errorf("could not extract function name from line: %s", firstLine) + } + funcName := matches[1] + + // Collect the function code + goFunc := firstLine + "\n" + braceCount := 0 + + // Count opening braces in the first line + for _, char := range firstLine { + if char == '{' { + braceCount++ + } + } + + // Continue reading until we find the matching closing brace + for braceCount > 0 && scanner.Scan() { + line := scanner.Text() + goFunc += line + "\n" + + for _, char := range line { + switch char { + case '{': + braceCount++ + case '}': + braceCount-- + } + } + + if braceCount == 0 { + break + } + } + + return funcName, goFunc, nil +} diff --git a/internal/extgen/moduleParser_test.go b/internal/extgen/moduleParser_test.go new file mode 100644 index 000000000..d715ac148 --- /dev/null +++ b/internal/extgen/moduleParser_test.go @@ -0,0 +1,321 @@ +package extgen + +import ( + "bufio" + "github.com/stretchr/testify/require" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestModuleParser(t *testing.T) { + tests := []struct { + name string + input string + expected *phpModule + }{ + { + name: "both init and shutdown", + input: `package main + +//export_php:module init +func initializeModule() { + // Initialization code +} + +//export_php:module shutdown +func cleanupModule() { + // Cleanup code +}`, + expected: &phpModule{ + InitFunc: "initializeModule", + ShutdownFunc: "cleanupModule", + }, + }, + { + name: "only init function", + input: `package main + +//export_php:module init +func initializeModule() { + // Initialization code +}`, + expected: &phpModule{ + InitFunc: "initializeModule", + ShutdownFunc: "", + }, + }, + { + name: "only shutdown function", + input: `package main + +//export_php:module shutdown +func cleanupModule() { + // Cleanup code +}`, + expected: &phpModule{ + InitFunc: "", + ShutdownFunc: "cleanupModule", + }, + }, + { + name: "with extra whitespace", + input: `package main + +//export_php:module init +func initModule() { + // Initialization code +} + +//export_php:module shutdown +func shutdownModule() { + // Cleanup code +}`, + expected: &phpModule{ + InitFunc: "initModule", + ShutdownFunc: "shutdownModule", + }, + }, + { + name: "no module directive", + input: `package main + +func regularFunction() { + // Just a regular Go function +}`, + expected: nil, + }, + { + name: "functions with braces", + input: `package main + +//export_php:module init +func initModule() { + if true { + // Do something + } + for i := 0; i < 10; i++ { + // Loop + } +} + +//export_php:module shutdown +func shutdownModule() { + if true { + // Do something else + } +}`, + expected: &phpModule{ + InitFunc: "initModule", + ShutdownFunc: "shutdownModule", + }, + }, + { + name: "multiple functions between directives", + input: `package main + +//export_php:module init +func initModule() { + // Init code +} + +func someOtherFunction() { + // This should be ignored +} + +//export_php:module shutdown +func shutdownModule() { + // Shutdown code +}`, + expected: &phpModule{ + InitFunc: "initModule", + ShutdownFunc: "shutdownModule", + }, + }, + { + name: "directive without function", + input: `package main + +//export_php:module init +// No function follows + +func regularFunction() { + // This should be ignored +}`, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + fileName := filepath.Join(tmpDir, tt.name+".go") + require.NoError(t, os.WriteFile(fileName, []byte(tt.input), 0644)) + + parser := &ModuleParser{} + module, err := parser.parse(fileName) + require.NoError(t, err) + + if tt.expected == nil { + assert.Nil(t, module, "parse() should return nil for no module directive") + } else { + assert.NotNil(t, module, "parse() should not return nil") + assert.Equal(t, tt.expected.InitFunc, module.InitFunc, "InitFunc mismatch") + assert.Equal(t, tt.expected.ShutdownFunc, module.ShutdownFunc, "ShutdownFunc mismatch") + + // Check that function code was extracted + if tt.expected.InitFunc != "" { + assert.Contains(t, module.InitCode, "func "+tt.expected.InitFunc, "InitCode should contain function declaration") + assert.True(t, strings.HasSuffix(module.InitCode, "}\n"), "InitCode should end with closing brace") + } + + if tt.expected.ShutdownFunc != "" { + assert.Contains(t, module.ShutdownCode, "func "+tt.expected.ShutdownFunc, "ShutdownCode should contain function declaration") + assert.True(t, strings.HasSuffix(module.ShutdownCode, "}\n"), "ShutdownCode should end with closing brace") + } + } + }) + } +} + +func TestExtractGoFunction(t *testing.T) { + tests := []struct { + name string + input string + firstLine string + expectedName string + expectedPrefix string + expectedSuffix string + }{ + { + name: "simple function", + input: "func testFunc() {\n\t// Some code\n}\n", + firstLine: "func testFunc() {", + expectedName: "testFunc", + expectedPrefix: "func testFunc() {", + expectedSuffix: "}\n", + }, + { + name: "function with parameters", + input: "func initModule(param1 string, param2 int) {\n\t// Init code\n}\n", + firstLine: "func initModule(param1 string, param2 int) {", + expectedName: "initModule", + expectedPrefix: "func initModule(param1 string, param2 int) {", + expectedSuffix: "}\n", + }, + { + name: "function with nested braces", + input: "func complexFunc() {\n\tif true {\n\t\t// Nested code\n\t}\n}\n", + firstLine: "func complexFunc() {", + expectedName: "complexFunc", + expectedPrefix: "func complexFunc() {", + expectedSuffix: "}\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parser := &ModuleParser{} + scanner := bufio.NewScanner(strings.NewReader(tt.input)) + scanner.Scan() // Read the first line + + name, code, err := parser.extractGoFunction(scanner, tt.firstLine) + + assert.NoError(t, err) + assert.Equal(t, tt.expectedName, name) + assert.True(t, strings.HasPrefix(code, tt.expectedPrefix), "Function code should start with the declaration") + assert.True(t, strings.HasSuffix(code, tt.expectedSuffix), "Function code should end with closing brace") + }) + } +} + +func TestModuleParserErrors(t *testing.T) { + tests := []struct { + name string + input string + expectedErr string + }{ + { + name: "duplicate init directives", + input: `package main + +//export_php:module init +func firstInit() { + // First init function +} + +//export_php:module init +func secondInit() { + // Second init function - should error +}`, + expectedErr: "duplicate init directive", + }, + { + name: "duplicate shutdown directives", + input: `package main + +//export_php:module shutdown +func firstShutdown() { + // First shutdown function +} + +//export_php:module shutdown +func secondShutdown() { + // Second shutdown function - should error +}`, + expectedErr: "duplicate shutdown directive", + }, + { + name: "multiple duplicates", + input: `package main + +//export_php:module init +func firstInit() { + // First init function +} + +//export_php:module init +func secondInit() { + // Duplicate init - should error +} + +//export_php:module shutdown +func firstShutdown() { + // First shutdown function +} + +//export_php:module shutdown +func secondShutdown() { + // Duplicate shutdown - should error +}`, + expectedErr: "duplicate init directive", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + fileName := filepath.Join(tmpDir, tt.name+".go") + require.NoError(t, os.WriteFile(fileName, []byte(tt.input), 0644)) + + parser := &ModuleParser{} + module, err := parser.parse(fileName) + + assert.Error(t, err, "parse() should return error for duplicate directives") + assert.Contains(t, err.Error(), tt.expectedErr, "error message should contain expected text") + assert.Nil(t, module, "parse() should return nil when there's an error") + }) + } +} + +func TestModuleParserFileErrors(t *testing.T) { + parser := &ModuleParser{} + + // Test with non-existent file + module, err := parser.parse("non_existent_file.go") + assert.Error(t, err, "parse() should return error for non-existent file") + assert.Nil(t, module, "parse() should return nil for non-existent file") +} diff --git a/internal/extgen/parser.go b/internal/extgen/parser.go index 690be2792..e579f31ef 100644 --- a/internal/extgen/parser.go +++ b/internal/extgen/parser.go @@ -2,26 +2,27 @@ package extgen type SourceParser struct{} -// EXPERIMENTAL func (p *SourceParser) ParseFunctions(filename string) ([]phpFunction, error) { functionParser := &FuncParser{} return functionParser.parse(filename) } -// EXPERIMENTAL func (p *SourceParser) ParseClasses(filename string) ([]phpClass, error) { classParser := classParser{} return classParser.parse(filename) } -// EXPERIMENTAL func (p *SourceParser) ParseConstants(filename string) ([]phpConstant, error) { constantParser := &ConstantParser{} return constantParser.parse(filename) } -// EXPERIMENTAL func (p *SourceParser) ParseNamespace(filename string) (string, error) { namespaceParser := NamespaceParser{} return namespaceParser.parse(filename) } + +func (p *SourceParser) ParseModule(filename string) (*phpModule, error) { + moduleParser := &ModuleParser{} + return moduleParser.parse(filename) +} diff --git a/internal/extgen/templates/extension.c.tpl b/internal/extgen/templates/extension.c.tpl index 6ae0b3474..7a922c8f0 100644 --- a/internal/extgen/templates/extension.c.tpl +++ b/internal/extgen/templates/extension.c.tpl @@ -167,14 +167,28 @@ PHP_MINIT_FUNCTION({{.BaseName}}) { {{- end}} {{- end}} {{- end}} + + {{if and .Module .Module.InitFunc}} + {{.Module.InitFunc}}(); + {{end}} + + return SUCCESS; +} + +{{if .Module}} +{{if .Module.ShutdownFunc}} +PHP_MSHUTDOWN_FUNCTION({{.BaseName}}) { + {{.Module.ShutdownFunc}}(); return SUCCESS; } +{{end}} +{{end}} zend_module_entry {{.BaseName}}_module_entry = {STANDARD_MODULE_HEADER, "{{.BaseName}}", ext_functions, /* Functions */ PHP_MINIT({{.BaseName}}), /* MINIT */ - NULL, /* MSHUTDOWN */ + {{if and .Module .Module.ShutdownFunc}}PHP_MSHUTDOWN({{.BaseName}}),{{else}}NULL,{{end}} /* MSHUTDOWN */ NULL, /* RINIT */ NULL, /* RSHUTDOWN */ NULL, /* MINFO */ diff --git a/internal/extgen/templates/extension.go.tpl b/internal/extgen/templates/extension.go.tpl index 39e7ae031..57dceed32 100644 --- a/internal/extgen/templates/extension.go.tpl +++ b/internal/extgen/templates/extension.go.tpl @@ -5,14 +5,18 @@ package {{.PackageName}} #include "{{.BaseName}}.h" */ import "C" +{{- if .Classes}} import "runtime/cgo" +{{- end}} {{- range .Imports}} import {{.}} {{- end}} +{{if not .HasInitFunction}} func init() { frankenphp.RegisterExtension(unsafe.Pointer(&C.ext_module_entry)) } +{{end}} {{range .Constants}} const {{.Name}} = {{.Value}} {{- end}} @@ -20,6 +24,18 @@ const {{.Name}} = {{.Value}} {{.}} {{- end}} +{{- if .Module}} +{{- if .Module.InitFunc}} +//export {{.Module.InitFunc}} +{{.Module.InitCode}} +{{- end}} + +{{- if .Module.ShutdownFunc}} +//export {{.Module.ShutdownFunc}} +{{.Module.ShutdownCode}} +{{- end}} +{{- end}} + {{- range .Functions}} //export {{.Name}} {{.GoFunction}}