fix(extgen): correctly handle const blocks to declare iota constants (#2086)

While continuing the work on #2011, I realized that constant
declarations have a problem when using `iota`. I mean, it technically
works, but const *blocks* we not supported which means that setting all
constants to `iota` as shown in the documentation was non-sensical, as
`iota` resets every time outside of const blocks.

So, this is between the bug fix and the feature. To me, it's a bug fix
as the behavior wasn't the one intended when creating extgen.
This commit is contained in:
Alexandre Daubois
2026-01-12 15:44:46 +01:00
committed by GitHub
parent ecad5ec0a0
commit c6b2b02277
6 changed files with 301 additions and 32 deletions

View File

@@ -34,6 +34,10 @@ func (cp *ConstantParser) parse(filename string) (constants []phpConstant, err e
expectClassConstDecl := false
currentClassName := ""
currentConstantValue := 0
inConstBlock := false
exportAllInBlock := false
lastConstValue := ""
lastConstWasIota := false
for scanner.Scan() {
lineNumber++
@@ -55,7 +59,26 @@ func (cp *ConstantParser) parse(filename string) (constants []phpConstant, err e
continue
}
if (expectConstDecl || expectClassConstDecl) && strings.HasPrefix(line, "const ") {
if strings.HasPrefix(line, "const (") {
inConstBlock = true
if expectConstDecl || expectClassConstDecl {
exportAllInBlock = true
}
continue
}
if inConstBlock && line == ")" {
inConstBlock = false
exportAllInBlock = false
expectConstDecl = false
expectClassConstDecl = false
currentClassName = ""
lastConstValue = ""
lastConstWasIota = false
continue
}
if (expectConstDecl || expectClassConstDecl) && strings.HasPrefix(line, "const ") && !inConstBlock {
matches := constDeclRegex.FindStringSubmatch(line)
if len(matches) == 3 {
name := matches[1]
@@ -72,10 +95,11 @@ func (cp *ConstantParser) parse(filename string) (constants []phpConstant, err e
constant.PhpType = determineConstantType(value)
if constant.IsIota {
// affect a default value because user didn't give one
constant.Value = fmt.Sprintf("%d", currentConstantValue)
constant.PhpType = phpInt
currentConstantValue++
lastConstWasIota = true
lastConstValue = constant.Value
}
constants = append(constants, constant)
@@ -84,7 +108,65 @@ func (cp *ConstantParser) parse(filename string) (constants []phpConstant, err e
}
expectConstDecl = false
expectClassConstDecl = false
} else if (expectConstDecl || expectClassConstDecl) && !strings.HasPrefix(line, "//") && line != "" {
} else if inConstBlock && (expectConstDecl || expectClassConstDecl || exportAllInBlock) {
constBlockDeclRegex := regexp.MustCompile(`^(\w+)\s*=\s*(.+)$`)
if matches := constBlockDeclRegex.FindStringSubmatch(line); len(matches) == 3 {
name := matches[1]
value := strings.TrimSpace(matches[2])
constant := phpConstant{
Name: name,
Value: value,
IsIota: value == "iota",
lineNumber: lineNumber,
ClassName: currentClassName,
}
constant.PhpType = determineConstantType(value)
if constant.IsIota {
constant.Value = fmt.Sprintf("%d", currentConstantValue)
constant.PhpType = phpInt
currentConstantValue++
lastConstWasIota = true
lastConstValue = constant.Value
} else {
lastConstWasIota = false
lastConstValue = value
}
constants = append(constants, constant)
expectConstDecl = false
expectClassConstDecl = false
} else {
constNameRegex := regexp.MustCompile(`^(\w+)$`)
if matches := constNameRegex.FindStringSubmatch(line); len(matches) == 2 {
name := matches[1]
constant := phpConstant{
Name: name,
Value: "",
IsIota: lastConstWasIota,
lineNumber: lineNumber,
ClassName: currentClassName,
}
if lastConstWasIota {
constant.Value = fmt.Sprintf("%d", currentConstantValue)
constant.PhpType = phpInt
currentConstantValue++
lastConstValue = constant.Value
} else {
constant.Value = lastConstValue
constant.PhpType = determineConstantType(lastConstValue)
}
constants = append(constants, constant)
expectConstDecl = false
expectClassConstDecl = false
}
}
} else if (expectConstDecl || expectClassConstDecl) && !strings.HasPrefix(line, "//") && line != "" && !inConstBlock {
// we expected a const declaration but found something else, reset
expectConstDecl = false
expectClassConstDecl = false

View File

@@ -221,7 +221,7 @@ func TestConstantParserIotaSequence(t *testing.T) {
//export_php:const
const FirstIota = iota
//export_php:const
//export_php:const
const SecondIota = iota
//export_php:const
@@ -244,6 +244,179 @@ const ThirdIota = iota`
}
}
func TestConstantParserConstBlock(t *testing.T) {
input := `package main
const (
// export_php:const
STATUS_PENDING = iota
// export_php:const
STATUS_PROCESSING
// export_php:const
STATUS_COMPLETED
)`
tmpDir := t.TempDir()
fileName := filepath.Join(tmpDir, "test.go")
require.NoError(t, os.WriteFile(fileName, []byte(input), 0644))
parser := &ConstantParser{}
constants, err := parser.parse(fileName)
assert.NoError(t, err, "parse() error")
assert.Len(t, constants, 3, "Expected 3 constants")
expectedNames := []string{"STATUS_PENDING", "STATUS_PROCESSING", "STATUS_COMPLETED"}
expectedValues := []string{"0", "1", "2"}
for i, c := range constants {
assert.Equal(t, expectedNames[i], c.Name, "Expected constant %d name to be '%s'", i, expectedNames[i])
assert.True(t, c.IsIota, "Expected constant %d to be iota", i)
assert.Equal(t, expectedValues[i], c.Value, "Expected constant %d value to be '%s'", i, expectedValues[i])
assert.Equal(t, phpInt, c.PhpType, "Expected constant %d to be phpInt type", i)
}
}
func TestConstantParserConstBlockWithBlockLevelDirective(t *testing.T) {
input := `package main
// export_php:const
const (
STATUS_PENDING = iota
STATUS_PROCESSING
STATUS_COMPLETED
)`
tmpDir := t.TempDir()
fileName := filepath.Join(tmpDir, "test.go")
require.NoError(t, os.WriteFile(fileName, []byte(input), 0644))
parser := &ConstantParser{}
constants, err := parser.parse(fileName)
assert.NoError(t, err, "parse() error")
assert.Len(t, constants, 3, "Expected 3 constants")
expectedNames := []string{"STATUS_PENDING", "STATUS_PROCESSING", "STATUS_COMPLETED"}
expectedValues := []string{"0", "1", "2"}
for i, c := range constants {
assert.Equal(t, expectedNames[i], c.Name, "Expected constant %d name to be '%s'", i, expectedNames[i])
assert.True(t, c.IsIota, "Expected constant %d to be iota", i)
assert.Equal(t, expectedValues[i], c.Value, "Expected constant %d value to be '%s'", i, expectedValues[i])
assert.Equal(t, phpInt, c.PhpType, "Expected constant %d to be phpInt type", i)
}
}
func TestConstantParserMixedConstBlockAndIndividual(t *testing.T) {
input := `package main
// export_php:const
const INDIVIDUAL = 42
const (
// export_php:const
BLOCK_ONE = iota
// export_php:const
BLOCK_TWO
)
// export_php:const
const ANOTHER_INDIVIDUAL = "test"`
tmpDir := t.TempDir()
fileName := filepath.Join(tmpDir, "test.go")
require.NoError(t, os.WriteFile(fileName, []byte(input), 0644))
parser := &ConstantParser{}
constants, err := parser.parse(fileName)
assert.NoError(t, err, "parse() error")
assert.Len(t, constants, 4, "Expected 4 constants")
assert.Equal(t, "INDIVIDUAL", constants[0].Name)
assert.Equal(t, "42", constants[0].Value)
assert.Equal(t, phpInt, constants[0].PhpType)
assert.Equal(t, "BLOCK_ONE", constants[1].Name)
assert.Equal(t, "0", constants[1].Value)
assert.True(t, constants[1].IsIota)
assert.Equal(t, "BLOCK_TWO", constants[2].Name)
assert.Equal(t, "1", constants[2].Value)
assert.True(t, constants[2].IsIota)
assert.Equal(t, "ANOTHER_INDIVIDUAL", constants[3].Name)
assert.Equal(t, `"test"`, constants[3].Value)
assert.Equal(t, phpString, constants[3].PhpType)
}
func TestConstantParserClassConstBlock(t *testing.T) {
input := `package main
// export_php:classconst Config
const (
MODE_DEBUG = 1
MODE_PRODUCTION = 2
MODE_TEST = 3
)`
tmpDir := t.TempDir()
fileName := filepath.Join(tmpDir, "test.go")
require.NoError(t, os.WriteFile(fileName, []byte(input), 0644))
parser := &ConstantParser{}
constants, err := parser.parse(fileName)
assert.NoError(t, err, "parse() error")
assert.Len(t, constants, 3, "Expected 3 class constants")
expectedNames := []string{"MODE_DEBUG", "MODE_PRODUCTION", "MODE_TEST"}
expectedValues := []string{"1", "2", "3"}
for i, c := range constants {
assert.Equal(t, expectedNames[i], c.Name, "Expected constant %d name to be '%s'", i, expectedNames[i])
assert.Equal(t, "Config", c.ClassName, "Expected constant %d to belong to Config class", i)
assert.Equal(t, expectedValues[i], c.Value, "Expected constant %d value to be '%s'", i, expectedValues[i])
assert.Equal(t, phpInt, c.PhpType, "Expected constant %d to be phpInt type", i)
}
}
func TestConstantParserClassConstBlockWithIota(t *testing.T) {
input := `package main
// export_php:classconst Status
const (
STATUS_PENDING = iota
STATUS_ACTIVE
STATUS_COMPLETED
)`
tmpDir := t.TempDir()
fileName := filepath.Join(tmpDir, "test.go")
require.NoError(t, os.WriteFile(fileName, []byte(input), 0644))
parser := &ConstantParser{}
constants, err := parser.parse(fileName)
assert.NoError(t, err, "parse() error")
assert.Len(t, constants, 3, "Expected 3 class constants")
expectedNames := []string{"STATUS_PENDING", "STATUS_ACTIVE", "STATUS_COMPLETED"}
expectedValues := []string{"0", "1", "2"}
for i, c := range constants {
assert.Equal(t, expectedNames[i], c.Name, "Expected constant %d name to be '%s'", i, expectedNames[i])
assert.Equal(t, "Status", c.ClassName, "Expected constant %d to belong to Status class", i)
assert.True(t, c.IsIota, "Expected constant %d to be iota", i)
assert.Equal(t, expectedValues[i], c.Value, "Expected constant %d value to be '%s'", i, expectedValues[i])
assert.Equal(t, phpInt, c.PhpType, "Expected constant %d to be phpInt type", i)
}
}
func TestConstantParserTypeDetection(t *testing.T) {
tests := []struct {
name string

View File

@@ -480,6 +480,7 @@ func TestConstants(t *testing.T) {
[]string{
"TEST_MAX_RETRIES", "TEST_API_VERSION", "TEST_ENABLED", "TEST_PI",
"STATUS_PENDING", "STATUS_PROCESSING", "STATUS_COMPLETED",
"ONE", "TWO",
},
)
require.NoError(t, err, "all constants, functions, and classes should be accessible from PHP")