feat(extgen): support for mixed type (#1913)

* feat(extgent): support for mixed type

* refactor: use unsafe.Pointer

* Revert "refactor: use unsafe.Pointer"

This reverts commit 8a0b9c1beb.

* fix docs

* fix docs

* cleanup template

* fix template

* fix tests
This commit is contained in:
Kévin Dunglas
2025-10-09 14:10:45 +02:00
committed by GitHub
parent c42d287138
commit 5514491a18
30 changed files with 303 additions and 253 deletions

View File

@@ -2,12 +2,13 @@ package caddy
import (
"errors"
"github.com/dunglas/frankenphp/internal/extgen"
"log"
"os"
"path/filepath"
"strings"
"github.com/dunglas/frankenphp/internal/extgen"
caddycmd "github.com/caddyserver/caddy/v2/cmd"
"github.com/spf13/cobra"
)
@@ -27,27 +28,21 @@ Initializes a PHP extension from a Go file. This command generates the necessary
})
}
func cmdInitExtension(fs caddycmd.Flags) (int, error) {
func cmdInitExtension(_ caddycmd.Flags) (int, error) {
if len(os.Args) < 3 {
return 1, errors.New("the path to the Go source is required")
}
sourceFile := os.Args[2]
baseName := extgen.SanitizePackageName(strings.TrimSuffix(filepath.Base(sourceFile), ".go"))
baseName := strings.TrimSuffix(filepath.Base(sourceFile), ".go")
baseName = extgen.SanitizePackageName(baseName)
sourceDir := filepath.Dir(sourceFile)
buildDir := filepath.Join(sourceDir, "build")
generator := extgen.Generator{BaseName: baseName, SourceFile: sourceFile, BuildDir: buildDir}
generator := extgen.Generator{BaseName: baseName, SourceFile: sourceFile, BuildDir: filepath.Dir(sourceFile)}
if err := generator.Generate(); err != nil {
return 1, err
}
log.Printf("PHP extension %q initialized successfully in %q", baseName, generator.BuildDir)
log.Printf("PHP extension %q initialized successfully in directory %q", baseName, generator.BuildDir)
return 0, nil
}

View File

@@ -146,11 +146,11 @@ func process_data(arr *C.zval) unsafe.Pointer {
**可用方法:**
- `SetInt(key int64, value interface{})` - 使用整数键设置值
- `SetString(key string, value interface{})` - 使用字符串键设置值
- `Append(value interface{})` - 使用下一个可用整数键添加值
- `SetInt(key int64, value any)` - 使用整数键设置值
- `SetString(key string, value any)` - 使用字符串键设置值
- `Append(value any)` - 使用下一个可用整数键添加值
- `Len() uint32` - 获取元素数量
- `At(index uint32) (PHPKey, interface{})` - 获取索引处的键值对
- `At(index uint32) (PHPKey, any)` - 获取索引处的键值对
- `frankenphp.PHPArray(arr *frankenphp.Array) unsafe.Pointer` - 转换为 PHP 数组
### 声明原生 PHP 类

View File

@@ -33,7 +33,7 @@ As covered in the manual implementation section below as well, you need to [get
The first step to writing a PHP extension in Go is to create a new Go module. You can use the following command for this:
```console
go mod init github.com/my-account/my-module
go mod init example.com/example
```
The second step is to [get the PHP sources](https://www.php.net/downloads.php) for the next steps. Once you have them, decompress them into the directory of your choice, not inside your Go module:
@@ -47,10 +47,14 @@ tar xf php-*
Everything is now setup to write your native function in Go. Create a new file named `stringext.go`. Our first function will take a string as an argument, the number of times to repeat it, a boolean to indicate whether to reverse the string, and return the resulting string. This should look like this:
```go
package example
// #include <Zend/zend_types.h>
import "C"
import (
"C"
"github.com/dunglas/frankenphp"
"strings"
"github.com/dunglas/frankenphp"
)
//export_php:function repeat_this(string $str, int $count, bool $reverse): string
@@ -98,6 +102,7 @@ This table summarizes what you need to know:
| `object` | `struct` | ❌ | _Not yet implemented_ | _Not yet implemented_ | ❌ |
> [!NOTE]
>
> This table is not exhaustive yet and will be completed as the FrankenPHP types API gets more complete.
>
> For class methods specifically, primitive types and arrays are currently supported. Objects cannot be used as method parameters or return types yet.
@@ -115,6 +120,16 @@ If order or association are not needed, it's also possible to directly convert t
**Creating and manipulating arrays in Go:**
```go
package example
// #include <Zend/zend_types.h>
import "C"
import (
"unsafe"
"github.com/dunglas/frankenphp"
)
// export_php:function process_data_ordered(array $input): array
func process_data_ordered_map(arr *C.zval) unsafe.Pointer {
// Convert PHP associative array to Go while keeping the order
@@ -128,7 +143,7 @@ func process_data_ordered_map(arr *C.zval) unsafe.Pointer {
// return an ordered array
// if 'Order' is not empty, only the key-value pairs in 'Order' will be respected
return frankenphp.PHPAssociativeArray(AssociativeArray{
return frankenphp.PHPAssociativeArray(frankenphp.AssociativeArray{
Map: map[string]any{
"key1": "value1",
"key2": "value2",
@@ -192,6 +207,8 @@ func process_data_packed(arr *C.zval) unsafe.Pointer {
The generator supports declaring **opaque classes** as Go structs, which can be used to create PHP objects. You can use the `//export_php:class` directive comment to define a PHP class. For example:
```go
package example
//export_php:class User
type UserStruct struct {
Name string
@@ -216,6 +233,16 @@ This approach provides better encapsulation and prevents PHP code from accidenta
Since properties are not directly accessible, you **must define methods** to interact with your opaque classes. Use the `//export_php:method` directive to define behavior:
```go
package example
// #include <Zend/zend_types.h>
import "C"
import (
"unsafe"
"github.com/dunglas/frankenphp"
)
//export_php:class User
type UserStruct struct {
Name string
@@ -248,6 +275,16 @@ func (us *UserStruct) SetNamePrefix(prefix *C.zend_string) {
The generator supports nullable parameters using the `?` prefix in PHP signatures. When a parameter is nullable, it becomes a pointer in your Go function, allowing you to check if the value was `null` in PHP:
```go
package example
// #include <Zend/zend_types.h>
import "C"
import (
"unsafe"
"github.com/dunglas/frankenphp"
)
//export_php:method User::updateInfo(?string $name, ?int $age, ?bool $active): void
func (us *UserStruct) UpdateInfo(name *C.zend_string, age *int64, active *bool) {
// Check if name was provided (not null)
@@ -275,6 +312,7 @@ func (us *UserStruct) UpdateInfo(name *C.zend_string, age *int64, active *bool)
- **PHP `null` becomes Go `nil`** - when PHP passes `null`, your Go function receives a `nil` pointer
> [!WARNING]
>
> Currently, class methods have the following limitations. **Objects are not supported** as parameter types or return types. **Arrays are fully supported** for both parameters and return types. Supported types: `string`, `int`, `float`, `bool`, `array`, and `void` (for return type). **Nullable parameter types are fully supported** for all scalar types (`?string`, `?int`, `?float`, `?bool`).
After generating the extension, you will be allowed to use the class and its methods in PHP. Note that you **cannot access properties directly**:
@@ -311,6 +349,8 @@ The generator supports exporting Go constants to PHP using two directives: `//ex
Use the `//export_php:const` directive to create global PHP constants:
```go
package example
//export_php:const
const MAX_CONNECTIONS = 100
@@ -329,6 +369,8 @@ const STATUS_ERROR = iota
Use the `//export_php:classconstant ClassName` directive to create constants that belong to a specific PHP class:
```go
package example
//export_php:classconstant User
const STATUS_ACTIVE = 1
@@ -368,10 +410,15 @@ The directive supports various value types including strings, integers, booleans
You can use constants just like you are used to in the Go code. For example, let's take the `repeat_this()` function we declared earlier and change the last argument to an integer:
```go
package example
// #include <Zend/zend_types.h>
import "C"
import (
"C"
"github.com/dunglas/frankenphp"
"strings"
"strings"
"unsafe"
"github.com/dunglas/frankenphp"
)
//export_php:const
@@ -388,37 +435,37 @@ const MODE_UPPERCASE = 2
//export_php:function repeat_this(string $str, int $count, int $mode): string
func repeat_this(s *C.zend_string, count int64, mode int) unsafe.Pointer {
str := frankenphp.GoString(unsafe.Pointer(s))
str := frankenphp.GoString(unsafe.Pointer(s))
result := strings.Repeat(str, int(count))
if mode == STR_REVERSE {
// reverse the string
}
result := strings.Repeat(str, int(count))
if mode == STR_REVERSE {
// reverse the string
}
if mode == STR_NORMAL {
// no-op, just to showcase the constant
}
if mode == STR_NORMAL {
// no-op, just to showcase the constant
}
return frankenphp.PHPString(result, false)
return frankenphp.PHPString(result, false)
}
//export_php:class StringProcessor
type StringProcessorStruct struct {
// internal fields
// internal fields
}
//export_php:method StringProcessor::process(string $input, int $mode): string
func (sp *StringProcessorStruct) Process(input *C.zend_string, mode int64) unsafe.Pointer {
str := frankenphp.GoString(unsafe.Pointer(input))
str := frankenphp.GoString(unsafe.Pointer(input))
switch mode {
case MODE_LOWERCASE:
str = strings.ToLower(str)
case MODE_UPPERCASE:
str = strings.ToUpper(str)
}
switch mode {
case MODE_LOWERCASE:
str = strings.ToLower(str)
case MODE_UPPERCASE:
str = strings.ToUpper(str)
}
return frankenphp.PHPString(str, false)
return frankenphp.PHPString(str, false)
}
```
@@ -432,9 +479,13 @@ Use the `//export_php:namespace` directive at the top of your Go file to place a
```go
//export_php:namespace My\Extension
package main
package example
import "C"
import (
"unsafe"
"github.com/dunglas/frankenphp"
)
//export_php:function hello(): string
func hello() string {
@@ -537,25 +588,26 @@ We'll see how to write a simple PHP extension in Go that defines a new native fu
In your module, you need to define a new native function that will be called from PHP. To do this, create a file with the name you want, for example, `extension.go`, and add the following code:
```go
package ext_go
package example
//#include "extension.h"
// #include "extension.h"
import "C"
import (
"unsafe"
"github.com/caddyserver/caddy/v2"
"github.com/dunglas/frankenphp"
"log/slog"
"unsafe"
"github.com/dunglas/frankenphp"
)
func init() {
frankenphp.RegisterExtension(unsafe.Pointer(&C.ext_module_entry))
frankenphp.RegisterExtension(unsafe.Pointer(&C.ext_module_entry))
}
//export go_print_something
func go_print_something() {
go func() {
caddy.Log().Info("Hello from a goroutine!")
}()
go func() {
slog.Info("Hello from a goroutine!")
}()
}
```
@@ -731,7 +783,16 @@ There's only one thing left to do: implement the `go_upper` function in Go.
Our Go function will take a `*C.zend_string` as a parameter, convert it to a Go string using FrankenPHP's helper function, process it, and return the result as a new `*C.zend_string`. The helper functions handle all the memory management and conversion complexity for us.
```go
import "strings"
package example
// #include <Zend/zend_types.h>
import "C"
import (
"unsafe"
"strings"
"github.com/dunglas/frankenphp"
)
//export go_upper
func go_upper(s *C.zend_string) *C.zend_string {
@@ -743,9 +804,12 @@ func go_upper(s *C.zend_string) *C.zend_string {
}
```
This approach is much cleaner and safer than manual memory management. FrankenPHP's helper functions handle the conversion between PHP's `zend_string` format and Go strings automatically. The `false` parameter in `PHPString()` indicates that we want to create a new non-persistent string (freed at the end of the request).
This approach is much cleaner and safer than manual memory management.
FrankenPHP's helper functions handle the conversion between PHP's `zend_string` format and Go strings automatically.
The `false` parameter in `PHPString()` indicates that we want to create a new non-persistent string (freed at the end of the request).
> [!TIP]
>
> In this example, we don't perform any error handling, but you should always check that pointers are not `nil` and that the data is valid before using it in your Go functions.
### Integrating the Extension into FrankenPHP

View File

@@ -146,11 +146,11 @@ func process_data(arr *C.zval) unsafe.Pointer {
**Méthodes disponibles :**
- `SetInt(key int64, value interface{})` - Définir une valeur avec une clé entière
- `SetString(key string, value interface{})` - Définir une valeur avec une clé chaîne
- `Append(value interface{})` - Ajouter une valeur avec la prochaine clé entière disponible
- `SetInt(key int64, value any)` - Définir une valeur avec une clé entière
- `SetString(key string, value any)` - Définir une valeur avec une clé chaîne
- `Append(value any)` - Ajouter une valeur avec la prochaine clé entière disponible
- `Len() uint32` - Obtenir le nombre d'éléments
- `At(index uint32) (PHPKey, interface{})` - Obtenir la paire clé-valeur à l'index
- `At(index uint32) (PHPKey, any)` - Obtenir la paire clé-valeur à l'index
- `frankenphp.PHPArray(arr *frankenphp.Array) unsafe.Pointer` - Convertir vers un tableau PHP
### Déclarer une Classe PHP Native

View File

@@ -1,10 +1,11 @@
package extgen
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNamespacedClassName(t *testing.T) {

View File

@@ -1,8 +1,9 @@
package extgen
import (
"github.com/stretchr/testify/require"
"testing"
"github.com/stretchr/testify/require"
)
func TestCFile_NamespacedPHPMethods(t *testing.T) {

View File

@@ -1,12 +1,12 @@
package extgen
import (
"github.com/stretchr/testify/require"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCFileGenerator_Generate(t *testing.T) {

View File

@@ -177,22 +177,23 @@ func (cp *classParser) typeToString(expr ast.Expr) string {
case *ast.MapType:
return "map[" + cp.typeToString(t.Key) + "]" + cp.typeToString(t.Value)
default:
return "interface{}"
return "any"
}
}
var goToPhpTypeMap = map[string]phpType{
"string": phpString,
"int": phpInt, "int64": phpInt, "int32": phpInt, "int16": phpInt, "int8": phpInt,
"uint": phpInt, "uint64": phpInt, "uint32": phpInt, "uint16": phpInt, "uint8": phpInt,
"float64": phpFloat, "float32": phpFloat,
"bool": phpBool,
"any": phpMixed,
}
func (cp *classParser) goTypeToPHPType(goType string) phpType {
goType = strings.TrimPrefix(goType, "*")
typeMap := map[string]phpType{
"string": phpString,
"int": phpInt, "int64": phpInt, "int32": phpInt, "int16": phpInt, "int8": phpInt,
"uint": phpInt, "uint64": phpInt, "uint32": phpInt, "uint16": phpInt, "uint8": phpInt,
"float64": phpFloat, "float32": phpFloat,
"bool": phpBool,
}
if phpType, exists := typeMap[goType]; exists {
if phpType, exists := goToPhpTypeMap[goType]; exists {
return phpType
}
@@ -244,7 +245,7 @@ func (cp *classParser) parseMethods(filename string) (methods []phpClassMethod,
IsReturnNullable: method.isReturnNullable,
}
if err := validator.validateScalarTypes(phpFunc); err != nil {
if err := validator.validateTypes(phpFunc); err != nil {
fmt.Printf("Warning: Method \"%s::%s\" uses unsupported types: %v\n", className, method.Name, err)
continue

View File

@@ -1,12 +1,12 @@
package extgen
import (
"github.com/stretchr/testify/require"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestClassParser(t *testing.T) {
@@ -282,7 +282,7 @@ func TestGoTypeToPHPType(t *testing.T) {
{"[]string", phpArray},
{"map[string]int", phpArray},
{"*[]int", phpArray},
{"interface{}", phpMixed},
{"any", phpMixed},
{"CustomType", phpMixed},
}
@@ -335,7 +335,7 @@ type NullableStruct struct {
type CollectionStruct struct {
StringSlice []string
IntMap map[string]int
MixedSlice []interface{}
MixedSlice []any
}`,
expected: []phpType{phpArray, phpArray, phpArray},
},
@@ -381,7 +381,7 @@ type TestClass struct {
}
//export_php:method TestClass::arrayMethod(array $data): string
func (tc *TestClass) arrayMethod(data interface{}) unsafe.Pointer {
func (tc *TestClass) arrayMethod(data any) unsafe.Pointer {
return nil
}`,
expectedClasses: 1,
@@ -398,7 +398,7 @@ type TestClass struct {
}
//export_php:method TestClass::objectMethod(object $obj): string
func (tc *TestClass) objectMethod(obj interface{}) unsafe.Pointer {
func (tc *TestClass) objectMethod(obj any) unsafe.Pointer {
return nil
}`,
expectedClasses: 1,
@@ -415,7 +415,7 @@ type TestClass struct {
}
//export_php:method TestClass::mixedMethod(mixed $value): string
func (tc *TestClass) mixedMethod(value interface{}) unsafe.Pointer {
func (tc *TestClass) mixedMethod(value any) unsafe.Pointer {
return nil
}`,
expectedClasses: 1,
@@ -432,7 +432,7 @@ type TestClass struct {
}
//export_php:method TestClass::arrayReturn(string $name): array
func (tc *TestClass) arrayReturn(name *C.zend_string) interface{} {
func (tc *TestClass) arrayReturn(name *C.zend_string) any {
return []string{"result"}
}`,
expectedClasses: 1,
@@ -449,8 +449,8 @@ type TestClass struct {
}
//export_php:method TestClass::objectReturn(string $name): object
func (tc *TestClass) objectReturn(name *C.zend_string) interface{} {
return map[string]interface{}{"key": "value"}
func (tc *TestClass) objectReturn(name *C.zend_string) any {
return map[string]any{"key": "value"}
}`,
expectedClasses: 1,
expectedMethods: 0,

View File

@@ -1,12 +1,12 @@
package extgen
import (
"github.com/stretchr/testify/require"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConstantsIntegration(t *testing.T) {

View File

@@ -1,12 +1,12 @@
package extgen
import (
"github.com/stretchr/testify/require"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConstantParser(t *testing.T) {

View File

@@ -1,12 +1,12 @@
package extgen
import (
"github.com/stretchr/testify/require"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDocumentationGenerator_Generate(t *testing.T) {

View File

@@ -50,7 +50,7 @@ func (fp *FuncParser) parse(filename string) (functions []phpFunction, err error
continue
}
if err := validator.validateScalarTypes(*phpFunc); err != nil {
if err := validator.validateTypes(*phpFunc); err != nil {
fmt.Printf("Warning: Function '%s' uses unsupported types: %v\n", phpFunc.Name, err)
continue

View File

@@ -1,12 +1,12 @@
package extgen
import (
"github.com/stretchr/testify/require"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFunctionParser(t *testing.T) {
@@ -306,7 +306,7 @@ func TestFunctionParserUnsupportedTypes(t *testing.T) {
input: `package main
//export_php:function arrayFunc(array $data): string
func arrayFunc(data interface{}) unsafe.Pointer {
func arrayFunc(data any) unsafe.Pointer {
return String("processed")
}`,
expected: 0,
@@ -317,7 +317,7 @@ func arrayFunc(data interface{}) unsafe.Pointer {
input: `package main
//export_php:function objectFunc(object $obj): string
func objectFunc(obj interface{}) unsafe.Pointer {
func objectFunc(obj any) unsafe.Pointer {
return String("processed")
}`,
expected: 0,
@@ -328,7 +328,7 @@ func objectFunc(obj interface{}) unsafe.Pointer {
input: `package main
//export_php:function mixedFunc(mixed $value): string
func mixedFunc(value interface{}) unsafe.Pointer {
func mixedFunc(value any) unsafe.Pointer {
return String("processed")
}`,
expected: 0,
@@ -339,7 +339,7 @@ func mixedFunc(value interface{}) unsafe.Pointer {
input: `package main
//export_php:function arrayReturnFunc(string $name): array
func arrayReturnFunc(name *C.zend_string) interface{} {
func arrayReturnFunc(name *C.zend_string) any {
return []string{"result"}
}`,
expected: 0,
@@ -350,8 +350,8 @@ func arrayReturnFunc(name *C.zend_string) interface{} {
input: `package main
//export_php:function objectReturnFunc(string $name): object
func objectReturnFunc(name *C.zend_string) interface{} {
return map[string]interface{}{"key": "value"}
func objectReturnFunc(name *C.zend_string) any {
return map[string]any{"key": "value"}
}`,
expected: 0,
hasWarning: true,

View File

@@ -5,8 +5,6 @@ import (
"os"
)
const BuildDir = "build"
type Generator struct {
BaseName string
SourceFile string

View File

@@ -47,7 +47,7 @@ func (gg *GoFileGenerator) buildContent() (string, error) {
filteredImports := make([]string, 0, len(imports))
for _, imp := range imports {
if imp != `"C"` {
if imp != `"C"` && imp != `"unsafe"` && imp != `"github.com/dunglas/frankenphp"` {
filteredImports = append(filteredImports, imp)
}
}
@@ -104,20 +104,20 @@ type GoParameter struct {
Type string
}
func (gg *GoFileGenerator) phpTypeToGoType(phpT phpType) string {
typeMap := map[phpType]string{
phpString: "string",
phpInt: "int64",
phpFloat: "float64",
phpBool: "bool",
phpArray: "*frankenphp.Array",
phpMixed: "interface{}",
phpVoid: "",
}
var phpToGoTypeMap = map[phpType]string{
phpString: "string",
phpInt: "int64",
phpFloat: "float64",
phpBool: "bool",
phpArray: "*frankenphp.Array",
phpMixed: "any",
phpVoid: "",
}
if goType, exists := typeMap[phpT]; exists {
func (gg *GoFileGenerator) phpTypeToGoType(phpT phpType) string {
if goType, exists := phpToGoTypeMap[phpT]; exists {
return goType
}
return "interface{}"
return "any"
}

View File

@@ -1,13 +1,13 @@
package extgen
import (
"github.com/stretchr/testify/require"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGoFileGenerator_Generate(t *testing.T) {
@@ -109,7 +109,7 @@ func test() {
contains: []string{
"package simple",
`#include "simple.h"`,
"import \"C\"",
`import "C"`,
"func init()",
"frankenphp.RegisterExtension(",
"//export test",
@@ -143,11 +143,11 @@ func process(data *go_string) *go_value {
},
contains: []string{
"package complex",
`import "fmt"`,
`import "strings"`,
`import "encoding/json"`,
`"fmt"`,
`"strings"`,
`"encoding/json"`,
"//export process",
`import "C"`,
`"C"`,
},
},
{
@@ -193,7 +193,7 @@ func internalFunc2(data string) {
require.NoError(t, err)
for _, expected := range tt.contains {
assert.Contains(t, content, expected, "Generated Go content should contain '%s'", expected)
assert.Contains(t, content, expected, "Generated Go content should contain %q", expected)
}
})
}
@@ -305,9 +305,9 @@ func test() {}`
require.NoError(t, err)
expectedImports := []string{
`import "fmt"`,
`import "strings"`,
`import "github.com/other/package"`,
`"fmt"`,
`"strings"`,
`"github.com/other/package"`,
}
for _, imp := range expectedImports {
@@ -315,10 +315,10 @@ func test() {}`
}
forbiddenImports := []string{
`import "C"`,
`"C"`,
}
cImportCount := strings.Count(content, `import "C"`)
cImportCount := strings.Count(content, `"C"`)
assert.Equal(t, 1, cImportCount, "Expected exactly 1 occurrence of 'import \"C\"'")
for _, imp := range forbiddenImports[1:] {
@@ -340,7 +340,7 @@ import (
func processData(input *go_string, options *go_nullable) *go_value {
data := CStringToGoString(input)
processed := internalProcess(data)
return types.Array([]interface{}{processed})
return types.Array([]any{processed})
}
//export_php: validateInput(data string): bool
@@ -358,7 +358,7 @@ func validateFormat(input string) bool {
return !strings.Contains(input, "invalid")
}
func jsonHelper(data interface{}) ([]byte, error) {
func jsonHelper(data any) ([]byte, error) {
return json.Marshal(data)
}
@@ -375,7 +375,7 @@ func debugPrint(msg string) {
GoFunction: `func processData(input *go_string, options *go_nullable) *go_value {
data := CStringToGoString(input)
processed := internalProcess(data)
return Array([]interface{}{processed})
return Array([]any{processed})
}`,
},
{
@@ -403,7 +403,7 @@ func debugPrint(msg string) {
internalFuncs := []string{
"func internalProcess(data string) string",
"func validateFormat(input string) bool",
"func jsonHelper(data interface{}) ([]byte, error)",
"func jsonHelper(data any) ([]byte, error)",
"func debugPrint(msg string)",
}
@@ -510,7 +510,7 @@ import "fmt"
//export_php:class ArrayClass
type ArrayStruct struct {
data []interface{}
data []any
}
//export_php:method ArrayClass::processArray(array $items): array
@@ -675,10 +675,8 @@ func createTempSourceFile(t *testing.T, content string) string {
func testGoFileBasicStructure(t *testing.T, content, baseName string) {
requiredElements := []string{
"package " + SanitizePackageName(baseName),
"/*",
"#include <stdlib.h>",
`#include "` + baseName + `.h"`,
"*/",
"// #include <stdlib.h>",
`// #include "` + baseName + `.h"`,
`import "C"`,
"func init() {",
"frankenphp.RegisterExtension(",
@@ -691,7 +689,7 @@ func testGoFileBasicStructure(t *testing.T, content, baseName string) {
}
func testGoFileImports(t *testing.T, content string) {
cImportCount := strings.Count(content, `import "C"`)
cImportCount := strings.Count(content, `"C"`)
assert.Equal(t, 1, cImportCount, "Expected exactly 1 C import")
}

View File

@@ -5,9 +5,8 @@ import (
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHeaderGenerator_Generate(t *testing.T) {

View File

@@ -1,10 +1,11 @@
package extgen
import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNamespaceParser(t *testing.T) {

View File

@@ -68,7 +68,7 @@ func (pp *ParameterParser) generateSingleParamDeclaration(param phpParameter) []
if param.IsNullable {
decls = append(decls, fmt.Sprintf("zend_bool %s_is_null = 0;", param.Name))
}
case phpArray:
case phpArray, phpMixed:
decls = append(decls, fmt.Sprintf("zval *%s = NULL;", param.Name))
}
@@ -119,6 +119,8 @@ func (pp *ParameterParser) generateParamParsingMacro(param phpParameter) string
return fmt.Sprintf("\n Z_PARAM_BOOL_OR_NULL(%s, %s_is_null)", param.Name, param.Name)
case phpArray:
return fmt.Sprintf("\n Z_PARAM_ARRAY_OR_NULL(%s)", param.Name)
case phpMixed:
return fmt.Sprintf("\n Z_PARAM_ZVAL_OR_NULL(%s)", param.Name)
default:
return ""
}
@@ -134,6 +136,8 @@ func (pp *ParameterParser) generateParamParsingMacro(param phpParameter) string
return fmt.Sprintf("\n Z_PARAM_BOOL(%s)", param.Name)
case phpArray:
return fmt.Sprintf("\n Z_PARAM_ARRAY(%s)", param.Name)
case phpMixed:
return fmt.Sprintf("\n Z_PARAM_ZVAL(%s)", param.Name)
default:
return ""
}
@@ -164,25 +168,19 @@ func (pp *ParameterParser) generateSingleGoCallParam(param phpParameter) string
return fmt.Sprintf("%s_is_null ? NULL : &%s", param.Name, param.Name)
case phpBool:
return fmt.Sprintf("%s_is_null ? NULL : &%s", param.Name, param.Name)
case phpArray:
return param.Name
default:
return param.Name
}
} else {
switch param.PhpType {
case phpString:
return param.Name
case phpInt:
return fmt.Sprintf("(long) %s", param.Name)
case phpFloat:
return fmt.Sprintf("(double) %s", param.Name)
case phpBool:
return fmt.Sprintf("(int) %s", param.Name)
case phpArray:
return param.Name
default:
return param.Name
}
}
switch param.PhpType {
case phpInt:
return fmt.Sprintf("(long) %s", param.Name)
case phpFloat:
return fmt.Sprintf("(double) %s", param.Name)
case phpBool:
return fmt.Sprintf("(int) %s", param.Name)
default:
return param.Name
}
}

View File

@@ -163,6 +163,20 @@ func TestParameterParser_GenerateParamDeclarations(t *testing.T) {
},
expected: " zend_string *name = NULL;\n zval *items = NULL;\n zend_long count = 5;",
},
{
name: "mixed parameter",
params: []phpParameter{
{Name: "m", PhpType: phpMixed, HasDefault: false},
},
expected: " zval *m = NULL;",
},
{
name: "nullable mixed parameter",
params: []phpParameter{
{Name: "m", PhpType: phpMixed, HasDefault: false, IsNullable: true},
},
expected: " zval *m = NULL;",
},
}
for _, tt := range tests {
@@ -346,6 +360,16 @@ func TestParameterParser_GenerateParamParsingMacro(t *testing.T) {
param: phpParameter{Name: "items", PhpType: phpArray, IsNullable: true},
expected: "\n Z_PARAM_ARRAY_OR_NULL(items)",
},
{
name: "mixed parameter",
param: phpParameter{Name: "m", PhpType: phpMixed},
expected: "\n Z_PARAM_ZVAL(m)",
},
{
name: "nullable mixed parameter",
param: phpParameter{Name: "m", PhpType: phpMixed, IsNullable: true},
expected: "\n Z_PARAM_ZVAL_OR_NULL(m)",
},
{
name: "unknown type",
param: phpParameter{Name: "unknown", PhpType: phpType("unknown")},

View File

@@ -50,21 +50,21 @@ func (pfg *PHPFuncGenerator) generateGoCall(fn phpFunction) string {
return fmt.Sprintf(" zend_array *result = %s(%s);", fn.Name, callParams)
}
if fn.ReturnType == phpMixed {
return fmt.Sprintf(" zval *result = %s(%s);", fn.Name, callParams)
}
return fmt.Sprintf(" %s result = %s(%s);", pfg.getCReturnType(fn.ReturnType), fn.Name, callParams)
}
func (pfg *PHPFuncGenerator) getCReturnType(returnType phpType) string {
switch returnType {
case phpString:
return "zend_string*"
case phpInt:
return "long"
case phpFloat:
return "double"
case phpBool:
return "int"
case phpArray:
return "zend_array*"
default:
return "void"
}

View File

@@ -1,8 +1,9 @@
package extgen
import (
"github.com/stretchr/testify/require"
"testing"
"github.com/stretchr/testify/require"
)
func TestPHPFuncGenerator_NamespacedFunctions(t *testing.T) {

View File

@@ -1,11 +1,12 @@
package extgen
import (
"github.com/stretchr/testify/require"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/assert"
)

View File

@@ -156,7 +156,7 @@ void register_all_classes() {
PHP_MINIT_FUNCTION({{.BaseName}}) {
{{ if .Classes}}register_all_classes();{{end}}
{{- range .Constants}}
{{- if eq .ClassName ""}}
{{if .IsIota}}REGISTER_LONG_CONSTANT("{{.Name}}", {{.Name}}, CONST_CS | CONST_PERSISTENT);
@@ -180,4 +180,3 @@ zend_module_entry {{.BaseName}}_module_entry = {STANDARD_MODULE_HEADER,
NULL, /* MINFO */
"1.0.0", /* Version */
STANDARD_MODULE_PROPERTIES};

View File

@@ -1,52 +1,55 @@
package {{.PackageName}}
/*
#include <stdlib.h>
#include "{{.BaseName}}.h"
*/
// #include <stdlib.h>
// #include "{{.BaseName}}.h"
import "C"
import (
"unsafe"
"github.com/dunglas/frankenphp"
{{- range .Imports}}
import {{.}}
{{.}}
{{- end}}
)
func init() {
frankenphp.RegisterExtension(unsafe.Pointer(&C.{{.BaseName}}_module_entry))
}
{{- range .Constants}}
{{ range .Constants}}
const {{.Name}} = {{.Value}}
{{- end}}
{{ range .Variables}}
{{- end}}
{{- range .Variables}}
{{.}}
{{- end}}
{{range .InternalFunctions}}
{{- range .InternalFunctions}}
{{.}}
{{- end}}
{{- end}}
{{- range .Functions}}
//export {{.Name}}
{{.GoFunction}}
{{- end}}
{{- end}}
{{- range .Classes}}
type {{.GoStruct}} struct {
{{- range .Properties}}
{{.Name}} {{.GoType}}
{{- end}}
}
{{- end}}
{{- if .Classes}}
//export registerGoObject
func registerGoObject(obj interface{}) C.uintptr_t {
func registerGoObject(obj any) C.uintptr_t {
handle := cgo.NewHandle(obj)
return C.uintptr_t(handle)
}
//export getGoObject
func getGoObject(handle C.uintptr_t) interface{} {
func getGoObject(handle C.uintptr_t) any {
h := cgo.Handle(handle)
return h.Value()
}
@@ -58,7 +61,6 @@ func removeGoObject(handle C.uintptr_t) {
}
{{- end}}
{{- range $class := .Classes}}
//export create_{{.GoStruct}}_object
func create_{{.GoStruct}}_object() C.uintptr_t {
@@ -70,8 +72,8 @@ func create_{{.GoStruct}}_object() C.uintptr_t {
{{- if .GoFunction}}
{{.GoFunction}}
{{- end}}
{{- 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}}, {{.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,8 +1,9 @@
package extgen
import (
"github.com/stretchr/testify/require"
"testing"
"github.com/stretchr/testify/require"
)
func TestNamespacedName(t *testing.T) {

View File

@@ -10,26 +10,17 @@ import (
"strings"
)
func scalarTypes() []phpType {
return []phpType{phpString, phpInt, phpFloat, phpBool, phpArray}
}
var (
paramTypes = []phpType{phpString, phpInt, phpFloat, phpBool, phpArray, phpObject, phpMixed}
returnTypes = []phpType{phpVoid, phpString, phpInt, phpFloat, phpBool, phpArray, phpObject, phpMixed, phpNull, phpTrue, phpFalse}
propTypes = []phpType{phpString, phpInt, phpFloat, phpBool, phpArray, phpObject, phpMixed}
supportedTypes = []phpType{phpString, phpInt, phpFloat, phpBool, phpArray, phpMixed}
func paramTypes() []phpType {
return []phpType{phpString, phpInt, phpFloat, phpBool, phpArray, phpObject, phpMixed}
}
func returnTypes() []phpType {
return []phpType{phpVoid, phpString, phpInt, phpFloat, phpBool, phpArray, phpObject, phpMixed, phpNull, phpTrue, phpFalse}
}
func propTypes() []phpType {
return []phpType{phpString, phpInt, phpFloat, phpBool, phpArray, phpObject, phpMixed}
}
var functionNameRegex = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
var parameterNameRegex = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
var classNameRegex = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
var propNameRegex = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
functionNameRegex = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
parameterNameRegex = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
classNameRegex = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
propNameRegex = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
)
type Validator struct{}
@@ -64,8 +55,7 @@ func (v *Validator) validateParameter(param phpParameter) error {
return fmt.Errorf("invalid parameter name: %s", param.Name)
}
validTypes := paramTypes()
if !v.isValidPHPType(param.PhpType, validTypes) {
if !slices.Contains(paramTypes, param.PhpType) {
return fmt.Errorf("invalid parameter type: %s", param.PhpType)
}
@@ -73,8 +63,7 @@ func (v *Validator) validateParameter(param phpParameter) error {
}
func (v *Validator) validateReturnType(returnType phpType) error {
validReturnTypes := returnTypes()
if !v.isValidPHPType(returnType, validReturnTypes) {
if !slices.Contains(returnTypes, returnType) {
return fmt.Errorf("invalid return type: %s", returnType)
}
return nil
@@ -107,43 +96,32 @@ func (v *Validator) validateClassProperty(prop phpClassProperty) error {
return fmt.Errorf("invalid property name: %s", prop.Name)
}
validTypes := propTypes()
if !v.isValidPHPType(prop.PhpType, validTypes) {
if !slices.Contains(propTypes, prop.PhpType) {
return fmt.Errorf("invalid property type: %s", prop.PhpType)
}
return nil
}
func (v *Validator) isValidPHPType(phpType phpType, validTypes []phpType) bool {
return slices.Contains(validTypes, phpType)
}
// validateScalarTypes checks if PHP signature contains only supported scalar types
func (v *Validator) validateScalarTypes(fn phpFunction) error {
supportedTypes := scalarTypes()
// validateTypes checks if PHP signature contains only supported types
func (v *Validator) validateTypes(fn phpFunction) error {
for i, param := range fn.Params {
if !v.isScalarPHPType(param.PhpType, supportedTypes) {
return fmt.Errorf("parameter %d (%s) has unsupported type '%s'. Only scalar types (string, int, float, bool, array) and their nullable variants are supported", i+1, param.Name, param.PhpType)
if !slices.Contains(supportedTypes, param.PhpType) {
return fmt.Errorf("parameter %d %q has unsupported type %q, supported typed: string, int, float, bool, array and mixed, can be nullable", i+1, param.Name, param.PhpType)
}
}
if fn.ReturnType != phpVoid && !v.isScalarPHPType(fn.ReturnType, supportedTypes) {
return fmt.Errorf("return type '%s' is not supported. Only scalar types (string, int, float, bool, array), void, and their nullable variants are supported", fn.ReturnType)
if fn.ReturnType != phpVoid && !slices.Contains(supportedTypes, fn.ReturnType) {
return fmt.Errorf("return type %q is not supported, supported typed: string, int, float, bool, array and mixed, can be nullable", fn.ReturnType)
}
return nil
}
func (v *Validator) isScalarPHPType(phpType phpType, supportedTypes []phpType) bool {
return slices.Contains(supportedTypes, phpType)
}
// validateGoFunctionSignatureWithOptions validates with option for method vs function
func (v *Validator) validateGoFunctionSignatureWithOptions(phpFunc phpFunction, isMethod bool) error {
if phpFunc.GoFunction == "" {
return fmt.Errorf("no Go function found for PHP function '%s'", phpFunc.Name)
return fmt.Errorf("no Go function found for PHP function %q", phpFunc.Name)
}
fset := token.NewFileSet()
@@ -199,7 +177,7 @@ func (v *Validator) validateGoFunctionSignatureWithOptions(phpFunc phpFunction,
actualGoType := v.goTypeToString(goParam.Type)
if !v.isCompatibleGoType(expectedGoType, actualGoType) {
return fmt.Errorf("parameter %d type mismatch: PHP '%s' requires Go type '%s' but found '%s'", i+1, phpParam.PhpType, expectedGoType, actualGoType)
return fmt.Errorf("parameter %d type mismatch: PHP %q requires Go type %q but found %q", i+1, phpParam.PhpType, expectedGoType, actualGoType)
}
}
}
@@ -208,7 +186,7 @@ func (v *Validator) validateGoFunctionSignatureWithOptions(phpFunc phpFunction,
actualGoReturnType := v.goReturnTypeToString(goFunc.Type.Results)
if !v.isCompatibleGoType(expectedGoReturnType, actualGoReturnType) {
return fmt.Errorf("return type mismatch: PHP '%s' requires Go return type '%s' but found '%s'", phpFunc.ReturnType, expectedGoReturnType, actualGoReturnType)
return fmt.Errorf("return type mismatch: PHP %q requires Go return type %q but found %q", phpFunc.ReturnType, expectedGoReturnType, actualGoReturnType)
}
return nil
@@ -225,10 +203,10 @@ func (v *Validator) phpTypeToGoType(t phpType, isNullable bool) string {
baseType = "float64"
case phpBool:
baseType = "bool"
case phpArray:
case phpArray, phpMixed:
baseType = "*C.zval"
default:
baseType = "interface{}"
baseType = "any"
}
if isNullable && t != phpString && t != phpArray {
@@ -271,7 +249,7 @@ func (v *Validator) phpReturnTypeToGoType(phpReturnType phpType) string {
case phpArray:
return "unsafe.Pointer"
default:
return "interface{}"
return "any"
}
}

View File

@@ -417,7 +417,7 @@ func TestValidateClass(t *testing.T) {
}
}
func TestValidateScalarTypes(t *testing.T) {
func TestValidateTypes(t *testing.T) {
tests := []struct {
name string
function phpFunction
@@ -494,19 +494,7 @@ func TestValidateScalarTypes(t *testing.T) {
},
},
expectError: true,
errorMsg: "parameter 1 (objectParam) has unsupported type 'object'",
},
{
name: "invalid mixed parameter",
function: phpFunction{
Name: "mixedFunction",
ReturnType: phpString,
Params: []phpParameter{
{Name: "mixedParam", PhpType: phpMixed},
},
},
expectError: true,
errorMsg: "parameter 1 (mixedParam) has unsupported type 'mixed'",
errorMsg: `parameter 1 "objectParam" has unsupported type "object"`,
},
{
name: "invalid object return type",
@@ -518,7 +506,7 @@ func TestValidateScalarTypes(t *testing.T) {
},
},
expectError: true,
errorMsg: "return type 'object' is not supported",
errorMsg: `return type "object" is not supported`,
},
{
name: "mixed scalar and invalid parameters",
@@ -532,20 +520,20 @@ func TestValidateScalarTypes(t *testing.T) {
},
},
expectError: true,
errorMsg: "parameter 2 (invalidParam) has unsupported type 'object'",
errorMsg: `parameter 2 "invalidParam" has unsupported type "object"`,
},
}
validator := Validator{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validator.validateScalarTypes(tt.function)
err := validator.validateTypes(tt.function)
if tt.expectError {
assert.Error(t, err, "validateScalarTypes() should return an error for function %s", tt.function.Name)
assert.Error(t, err, "validateTypes() should return an error for function %s", tt.function.Name)
assert.Contains(t, err.Error(), tt.errorMsg, "Error message should contain expected text")
} else {
assert.NoError(t, err, "validateScalarTypes() should not return an error for function %s", tt.function.Name)
assert.NoError(t, err, "validateTypes() should not return an error for function %s", tt.function.Name)
}
})
}
@@ -628,7 +616,7 @@ func TestValidateGoFunctionSignature(t *testing.T) {
}`,
},
expectError: true,
errorMsg: "parameter 2 type mismatch: PHP 'int' requires Go type 'int64' but found 'string'",
errorMsg: `parameter 2 type mismatch: PHP "int" requires Go type "int64" but found "string"`,
},
{
name: "return type mismatch",
@@ -643,7 +631,7 @@ func TestValidateGoFunctionSignature(t *testing.T) {
}`,
},
expectError: true,
errorMsg: "return type mismatch: PHP 'int' requires Go return type 'int64' but found 'string'",
errorMsg: `return type mismatch: PHP "int" requires Go return type "int64" but found "string"`,
},
{
name: "valid bool parameter and return",
@@ -751,7 +739,7 @@ func TestPhpTypeToGoType(t *testing.T) {
{"bool", true, "*bool"},
{"array", false, "*C.zval"},
{"array", true, "*C.zval"},
{"unknown", false, "interface{}"},
{"unknown", false, "any"},
}
validator := Validator{}
@@ -780,7 +768,7 @@ func TestPhpReturnTypeToGoType(t *testing.T) {
{"bool", "bool"},
{"array", "unsafe.Pointer"},
{"array", "unsafe.Pointer"},
{"unknown", "interface{}"},
{"unknown", "any"},
}
validator := Validator{}

10
types.h
View File

@@ -1,11 +1,11 @@
#ifndef TYPES_H
#define TYPES_H
#include <zend.h>
#include <zend_API.h>
#include <zend_alloc.h>
#include <zend_hash.h>
#include <zend_types.h>
#include <Zend/zend.h>
#include <Zend/zend_API.h>
#include <Zend/zend_alloc.h>
#include <Zend/zend_hash.h>
#include <Zend/zend_types.h>
zval *get_ht_packed_data(HashTable *, uint32_t index);
Bucket *get_ht_bucket_data(HashTable *, uint32_t index);