Files
frankenphp/internal/extgen/classparser.go
2025-08-15 00:22:44 +02:00

391 lines
9.8 KiB
Go

package extgen
import (
"bufio"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"regexp"
"strings"
)
var phpClassRegex = regexp.MustCompile(`//\s*export_php:class\s+(\w+)`)
var phpMethodRegex = regexp.MustCompile(`//\s*export_php:method\s+(\w+)::([^{}\n]+)(?:\s*{\s*})?`)
var methodSignatureRegex = regexp.MustCompile(`(\w+)\s*\(([^)]*)\)\s*:\s*(\??[\w|]+)`)
var methodParamTypeNameRegex = regexp.MustCompile(`(\??[\w|]+)\s+\$?(\w+)`)
type exportDirective struct {
line int
className string
}
type classParser struct{}
func (cp *classParser) Parse(filename string) ([]phpClass, error) {
return cp.parse(filename)
}
func (cp *classParser) parse(filename string) (classes []phpClass, err error) {
fset := token.NewFileSet()
node, err := parser.ParseFile(fset, filename, nil, parser.ParseComments)
if err != nil {
return nil, fmt.Errorf("parsing file: %w", err)
}
validator := Validator{}
exportDirectives := cp.collectExportDirectives(node, fset)
methods, err := cp.parseMethods(filename)
if err != nil {
return nil, fmt.Errorf("parsing methods: %w", err)
}
// match structs to directives
matchedDirectives := make(map[int]bool)
var genDecl *ast.GenDecl
var ok bool
for _, decl := range node.Decls {
if genDecl, ok = decl.(*ast.GenDecl); !ok || genDecl.Tok != token.TYPE {
continue
}
for _, spec := range genDecl.Specs {
var typeSpec *ast.TypeSpec
if typeSpec, ok = spec.(*ast.TypeSpec); !ok {
continue
}
var structType *ast.StructType
if structType, ok = typeSpec.Type.(*ast.StructType); !ok {
continue
}
var phpCl string
var directiveLine int
if phpCl, directiveLine = cp.extractPHPClassCommentWithLine(genDecl.Doc, fset); phpCl == "" {
continue
}
matchedDirectives[directiveLine] = true
class := phpClass{
Name: phpCl,
GoStruct: typeSpec.Name.Name,
}
class.Properties = cp.parseStructFields(structType.Fields.List)
// associate methods with this class
for _, method := range methods {
if method.ClassName == phpCl {
class.Methods = append(class.Methods, method)
}
}
if err := validator.validateClass(class); err != nil {
fmt.Printf("Warning: Invalid class '%s': %v\n", class.Name, err)
continue
}
classes = append(classes, class)
}
}
for _, directive := range exportDirectives {
if !matchedDirectives[directive.line] {
return nil, fmt.Errorf("//export_php class directive at line %d is not followed by a struct declaration", directive.line)
}
}
return classes, nil
}
func (cp *classParser) collectExportDirectives(node *ast.File, fset *token.FileSet) []exportDirective {
var directives []exportDirective
for _, commentGroup := range node.Comments {
for _, comment := range commentGroup.List {
if matches := phpClassRegex.FindStringSubmatch(comment.Text); matches != nil {
pos := fset.Position(comment.Pos())
directives = append(directives, exportDirective{
line: pos.Line,
className: matches[1],
})
}
}
}
return directives
}
func (cp *classParser) extractPHPClassCommentWithLine(commentGroup *ast.CommentGroup, fset *token.FileSet) (string, int) {
if commentGroup == nil {
return "", 0
}
for _, comment := range commentGroup.List {
if matches := phpClassRegex.FindStringSubmatch(comment.Text); matches != nil {
pos := fset.Position(comment.Pos())
return matches[1], pos.Line
}
}
return "", 0
}
func (cp *classParser) parseStructFields(fields []*ast.Field) []phpClassProperty {
var properties []phpClassProperty
for _, field := range fields {
for _, name := range field.Names {
prop := cp.parseStructField(name.Name, field)
properties = append(properties, prop)
}
}
return properties
}
func (cp *classParser) parseStructField(fieldName string, field *ast.Field) phpClassProperty {
prop := phpClassProperty{Name: fieldName}
// check if field is a pointer (nullable)
if starExpr, isPointer := field.Type.(*ast.StarExpr); isPointer {
prop.IsNullable = true
prop.GoType = cp.typeToString(starExpr.X)
} else {
prop.IsNullable = false
prop.GoType = cp.typeToString(field.Type)
}
prop.PhpType = cp.goTypeToPHPType(prop.GoType)
return prop
}
func (cp *classParser) typeToString(expr ast.Expr) string {
switch t := expr.(type) {
case *ast.Ident:
return t.Name
case *ast.StarExpr:
return "*" + cp.typeToString(t.X)
case *ast.ArrayType:
return "[]" + cp.typeToString(t.Elt)
case *ast.MapType:
return "map[" + cp.typeToString(t.Key) + "]" + cp.typeToString(t.Value)
default:
return "interface{}"
}
}
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 {
return phpType
}
if strings.HasPrefix(goType, "[]") || strings.HasPrefix(goType, "map[") {
return phpArray
}
return phpMixed
}
func (cp *classParser) parseMethods(filename string) (methods []phpClassMethod, 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)
var currentMethod *phpClassMethod
lineNumber := 0
for scanner.Scan() {
lineNumber++
line := strings.TrimSpace(scanner.Text())
if matches := phpMethodRegex.FindStringSubmatch(line); matches != nil {
className := strings.TrimSpace(matches[1])
signature := strings.TrimSpace(matches[2])
method, err := cp.parseMethodSignature(className, signature)
if err != nil {
fmt.Printf("Warning: Error parsing method signature %q: %v\n", signature, err)
continue
}
validator := Validator{}
phpFunc := phpFunction{
Name: method.Name,
Signature: method.Signature,
Params: method.Params,
ReturnType: method.ReturnType,
IsReturnNullable: method.isReturnNullable,
}
if err := validator.validateScalarTypes(phpFunc); err != nil {
fmt.Printf("Warning: Method \"%s::%s\" uses unsupported types: %v\n", className, method.Name, err)
continue
}
method.lineNumber = lineNumber
currentMethod = method
}
if currentMethod != nil && strings.HasPrefix(line, "func ") {
goFunc, err := cp.extractGoMethodFunction(scanner, line)
if err != nil {
return nil, fmt.Errorf("extracting Go method function: %w", err)
}
currentMethod.GoFunction = goFunc
validator := Validator{}
phpFunc := phpFunction{
Name: currentMethod.Name,
Signature: currentMethod.Signature,
GoFunction: currentMethod.GoFunction,
Params: currentMethod.Params,
ReturnType: currentMethod.ReturnType,
IsReturnNullable: currentMethod.isReturnNullable,
}
if err := validator.validateGoFunctionSignatureWithOptions(phpFunc, true); err != nil {
fmt.Printf("Warning: Go method signature mismatch for '%s::%s': %v\n", currentMethod.ClassName, currentMethod.Name, err)
currentMethod = nil
continue
}
methods = append(methods, *currentMethod)
currentMethod = nil
}
}
if currentMethod != nil {
return nil, fmt.Errorf("//export_php:method directive at line %d is not followed by a function declaration", currentMethod.lineNumber)
}
return methods, scanner.Err()
}
func (cp *classParser) parseMethodSignature(className, signature string) (*phpClassMethod, error) {
matches := methodSignatureRegex.FindStringSubmatch(signature)
if len(matches) != 4 {
return nil, fmt.Errorf("invalid method signature format")
}
methodName := matches[1]
paramsStr := strings.TrimSpace(matches[2])
returnTypeStr := strings.TrimSpace(matches[3])
isReturnNullable := strings.HasPrefix(returnTypeStr, "?")
returnType := strings.TrimPrefix(returnTypeStr, "?")
var params []phpParameter
if paramsStr != "" {
paramParts := strings.SplitSeq(paramsStr, ",")
for part := range paramParts {
param, err := cp.parseMethodParameter(strings.TrimSpace(part))
if err != nil {
return nil, fmt.Errorf("parsing parameter '%s': %w", part, err)
}
params = append(params, param)
}
}
return &phpClassMethod{
Name: methodName,
PhpName: methodName,
ClassName: className,
Signature: signature,
Params: params,
ReturnType: phpType(returnType),
isReturnNullable: isReturnNullable,
}, nil
}
func (cp *classParser) parseMethodParameter(paramStr string) (phpParameter, error) {
parts := strings.Split(paramStr, "=")
typePart := strings.TrimSpace(parts[0])
param := phpParameter{HasDefault: len(parts) > 1}
if param.HasDefault {
param.DefaultValue = cp.sanitizeDefaultValue(strings.TrimSpace(parts[1]))
}
matches := methodParamTypeNameRegex.FindStringSubmatch(typePart)
if len(matches) < 3 {
return phpParameter{}, fmt.Errorf("invalid parameter format: %s", paramStr)
}
typeStr := strings.TrimSpace(matches[1])
param.Name = strings.TrimSpace(matches[2])
param.IsNullable = strings.HasPrefix(typeStr, "?")
param.PhpType = phpType(strings.TrimPrefix(typeStr, "?"))
return param, nil
}
func (cp *classParser) sanitizeDefaultValue(value string) string {
if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") {
return value
}
if strings.ToLower(value) == "null" {
return "null"
}
return strings.Trim(value, `'"`)
}
func (cp *classParser) extractGoMethodFunction(scanner *bufio.Scanner, firstLine string) (string, error) {
goFunc := firstLine + "\n"
braceCount := 1
for scanner.Scan() {
line := scanner.Text()
goFunc += line + "\n"
for _, char := range line {
switch char {
case '{':
braceCount++
case '}':
braceCount--
}
}
if braceCount == 0 {
break
}
}
return goFunc, nil
}