Implement computed range expansion for loops (gt-8tmz.27)
Add support for for-each expansion over computed ranges:
loop:
range: "1..2^{disks}-1" # Evaluated at cook time
var: move_num
body: [...]
Features:
- Range field in LoopSpec for computed iterations
- Var field to expose iteration value to body steps
- Expression evaluator supporting + - * / ^ and parentheses
- Variable substitution in range expressions using {name} syntax
- Title/description variable substitution in expanded steps
Example use case: Towers of Hanoi formula where step count is 2^n-1
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -59,12 +59,23 @@ func validateLoopSpec(loop *LoopSpec, stepID string) error {
|
|||||||
return fmt.Errorf("loop %q: body is required", stepID)
|
return fmt.Errorf("loop %q: body is required", stepID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if loop.Count > 0 && loop.Until != "" {
|
// Count the number of loop types specified
|
||||||
return fmt.Errorf("loop %q: cannot have both count and until", stepID)
|
loopTypes := 0
|
||||||
|
if loop.Count > 0 {
|
||||||
|
loopTypes++
|
||||||
|
}
|
||||||
|
if loop.Until != "" {
|
||||||
|
loopTypes++
|
||||||
|
}
|
||||||
|
if loop.Range != "" {
|
||||||
|
loopTypes++
|
||||||
}
|
}
|
||||||
|
|
||||||
if loop.Count == 0 && loop.Until == "" {
|
if loopTypes == 0 {
|
||||||
return fmt.Errorf("loop %q: either count or until is required", stepID)
|
return fmt.Errorf("loop %q: one of count, until, or range is required", stepID)
|
||||||
|
}
|
||||||
|
if loopTypes > 1 {
|
||||||
|
return fmt.Errorf("loop %q: only one of count, until, or range can be specified", stepID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if loop.Until != "" && loop.Max == 0 {
|
if loop.Until != "" && loop.Max == 0 {
|
||||||
@@ -86,17 +97,30 @@ func validateLoopSpec(loop *LoopSpec, stepID string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate range syntax if present (gt-8tmz.27)
|
||||||
|
if loop.Range != "" {
|
||||||
|
if err := ValidateRange(loop.Range); err != nil {
|
||||||
|
return fmt.Errorf("loop %q: invalid range %q: %w", stepID, loop.Range, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// expandLoop expands a loop step into its constituent steps.
|
// expandLoop expands a loop step into its constituent steps.
|
||||||
func expandLoop(step *Step) ([]*Step, error) {
|
func expandLoop(step *Step) ([]*Step, error) {
|
||||||
|
return expandLoopWithVars(step, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandLoopWithVars expands a loop step using the given variable context.
|
||||||
|
// The vars map is used to resolve range expressions with variables.
|
||||||
|
func expandLoopWithVars(step *Step, vars map[string]string) ([]*Step, error) {
|
||||||
var result []*Step
|
var result []*Step
|
||||||
|
|
||||||
if step.Loop.Count > 0 {
|
if step.Loop.Count > 0 {
|
||||||
// Fixed-count loop: expand body N times
|
// Fixed-count loop: expand body N times
|
||||||
for i := 1; i <= step.Loop.Count; i++ {
|
for i := 1; i <= step.Loop.Count; i++ {
|
||||||
iterSteps, err := expandLoopIteration(step, i)
|
iterSteps, err := expandLoopIteration(step, i, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -115,10 +139,50 @@ func expandLoop(step *Step) ([]*Step, error) {
|
|||||||
if step.Loop.Count > 1 {
|
if step.Loop.Count > 1 {
|
||||||
result = chainExpandedIterations(result, step.ID, step.Loop.Count)
|
result = chainExpandedIterations(result, step.ID, step.Loop.Count)
|
||||||
}
|
}
|
||||||
|
} else if step.Loop.Range != "" {
|
||||||
|
// Range loop (gt-8tmz.27): expand body for each value in the computed range
|
||||||
|
rangeSpec, err := ParseRange(step.Loop.Range, vars)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("loop %q: %w", step.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate range
|
||||||
|
if rangeSpec.End < rangeSpec.Start {
|
||||||
|
return nil, fmt.Errorf("loop %q: range end (%d) is less than start (%d)",
|
||||||
|
step.ID, rangeSpec.End, rangeSpec.Start)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand body for each value in range
|
||||||
|
count := rangeSpec.End - rangeSpec.Start + 1
|
||||||
|
iterNum := 0
|
||||||
|
for val := rangeSpec.Start; val <= rangeSpec.End; val++ {
|
||||||
|
iterNum++
|
||||||
|
// Build iteration vars: include the loop variable if specified
|
||||||
|
iterVars := make(map[string]string)
|
||||||
|
if step.Loop.Var != "" {
|
||||||
|
iterVars[step.Loop.Var] = fmt.Sprintf("%d", val)
|
||||||
|
}
|
||||||
|
iterSteps, err := expandLoopIteration(step, iterNum, iterVars)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, iterSteps...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively expand any nested loops FIRST (gt-zn35j)
|
||||||
|
result, err = ApplyLoops(result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// THEN chain iterations on the expanded result
|
||||||
|
if count > 1 {
|
||||||
|
result = chainExpandedIterations(result, step.ID, count)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Conditional loop: expand once with loop metadata
|
// Conditional loop: expand once with loop metadata
|
||||||
// The runtime executor will re-run until condition is met or max reached
|
// The runtime executor will re-run until condition is met or max reached
|
||||||
iterSteps, err := expandLoopIteration(step, 1)
|
iterSteps, err := expandLoopIteration(step, 1, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -147,7 +211,8 @@ func expandLoop(step *Step) ([]*Step, error) {
|
|||||||
|
|
||||||
// expandLoopIteration expands a single iteration of a loop.
|
// expandLoopIteration expands a single iteration of a loop.
|
||||||
// The iteration index is used to generate unique step IDs.
|
// The iteration index is used to generate unique step IDs.
|
||||||
func expandLoopIteration(step *Step, iteration int) ([]*Step, error) {
|
// The iterVars map contains loop variable bindings for this iteration (gt-8tmz.27).
|
||||||
|
func expandLoopIteration(step *Step, iteration int, iterVars map[string]string) ([]*Step, error) {
|
||||||
result := make([]*Step, 0, len(step.Loop.Body))
|
result := make([]*Step, 0, len(step.Loop.Body))
|
||||||
|
|
||||||
// Build set of step IDs within the loop body (for dependency rewriting)
|
// Build set of step IDs within the loop body (for dependency rewriting)
|
||||||
@@ -157,10 +222,14 @@ func expandLoopIteration(step *Step, iteration int) ([]*Step, error) {
|
|||||||
// Create unique ID for this iteration
|
// Create unique ID for this iteration
|
||||||
iterID := fmt.Sprintf("%s.iter%d.%s", step.ID, iteration, bodyStep.ID)
|
iterID := fmt.Sprintf("%s.iter%d.%s", step.ID, iteration, bodyStep.ID)
|
||||||
|
|
||||||
|
// Substitute loop variables in title and description (gt-8tmz.27)
|
||||||
|
title := substituteLoopVars(bodyStep.Title, iterVars)
|
||||||
|
description := substituteLoopVars(bodyStep.Description, iterVars)
|
||||||
|
|
||||||
clone := &Step{
|
clone := &Step{
|
||||||
ID: iterID,
|
ID: iterID,
|
||||||
Title: bodyStep.Title,
|
Title: title,
|
||||||
Description: bodyStep.Description,
|
Description: description,
|
||||||
Type: bodyStep.Type,
|
Type: bodyStep.Type,
|
||||||
Priority: bodyStep.Priority,
|
Priority: bodyStep.Priority,
|
||||||
Assignee: bodyStep.Assignee,
|
Assignee: bodyStep.Assignee,
|
||||||
@@ -170,16 +239,20 @@ func expandLoopIteration(step *Step, iteration int) ([]*Step, error) {
|
|||||||
Gate: bodyStep.Gate,
|
Gate: bodyStep.Gate,
|
||||||
Loop: cloneLoopSpec(bodyStep.Loop), // Support nested loops (gt-zn35j)
|
Loop: cloneLoopSpec(bodyStep.Loop), // Support nested loops (gt-zn35j)
|
||||||
OnComplete: cloneOnComplete(bodyStep.OnComplete),
|
OnComplete: cloneOnComplete(bodyStep.OnComplete),
|
||||||
SourceFormula: bodyStep.SourceFormula, // Preserve source (gt-8tmz.18)
|
SourceFormula: bodyStep.SourceFormula, // Preserve source (gt-8tmz.18)
|
||||||
SourceLocation: fmt.Sprintf("%s.iter%d", bodyStep.SourceLocation, iteration), // Track iteration
|
SourceLocation: fmt.Sprintf("%s.iter%d", bodyStep.SourceLocation, iteration), // Track iteration
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clone ExpandVars if present
|
// Clone ExpandVars if present, adding loop vars (gt-8tmz.27)
|
||||||
if len(bodyStep.ExpandVars) > 0 {
|
if len(bodyStep.ExpandVars) > 0 || len(iterVars) > 0 {
|
||||||
clone.ExpandVars = make(map[string]string, len(bodyStep.ExpandVars))
|
clone.ExpandVars = make(map[string]string)
|
||||||
for k, v := range bodyStep.ExpandVars {
|
for k, v := range bodyStep.ExpandVars {
|
||||||
clone.ExpandVars[k] = v
|
clone.ExpandVars[k] = v
|
||||||
}
|
}
|
||||||
|
// Add loop variables to ExpandVars for nested expansion
|
||||||
|
for k, v := range iterVars {
|
||||||
|
clone.ExpandVars[k] = v
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clone labels
|
// Clone labels
|
||||||
@@ -203,6 +276,17 @@ func expandLoopIteration(step *Step, iteration int) ([]*Step, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// substituteLoopVars replaces {varname} placeholders with values from vars map.
|
||||||
|
func substituteLoopVars(s string, vars map[string]string) string {
|
||||||
|
if vars == nil || s == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
for k, v := range vars {
|
||||||
|
s = strings.ReplaceAll(s, "{"+k+"}", v)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// collectBodyStepIDs collects all step IDs within a loop body (including nested children).
|
// collectBodyStepIDs collects all step IDs within a loop body (including nested children).
|
||||||
func collectBodyStepIDs(body []*Step) map[string]bool {
|
func collectBodyStepIDs(body []*Step) map[string]bool {
|
||||||
ids := make(map[string]bool)
|
ids := make(map[string]bool)
|
||||||
@@ -521,6 +605,8 @@ func cloneLoopSpec(loop *LoopSpec) *LoopSpec {
|
|||||||
Count: loop.Count,
|
Count: loop.Count,
|
||||||
Until: loop.Until,
|
Until: loop.Until,
|
||||||
Max: loop.Max,
|
Max: loop.Max,
|
||||||
|
Range: loop.Range, // gt-8tmz.27
|
||||||
|
Var: loop.Var, // gt-8tmz.27
|
||||||
}
|
}
|
||||||
if len(loop.Body) > 0 {
|
if len(loop.Body) > 0 {
|
||||||
clone.Body = make([]*Step, len(loop.Body))
|
clone.Body = make([]*Step, len(loop.Body))
|
||||||
|
|||||||
@@ -822,3 +822,159 @@ func TestApplyGates_Immutability(t *testing.T) {
|
|||||||
t.Errorf("Result deploy step should have gate label, got %v", deployResult.Labels)
|
t.Errorf("Result deploy step should have gate label, got %v", deployResult.Labels)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestApplyLoops_Range tests computed range expansion (gt-8tmz.27).
|
||||||
|
func TestApplyLoops_Range(t *testing.T) {
|
||||||
|
// Create a step with a range loop
|
||||||
|
steps := []*Step{
|
||||||
|
{
|
||||||
|
ID: "moves",
|
||||||
|
Title: "Tower moves",
|
||||||
|
Loop: &LoopSpec{
|
||||||
|
Range: "1..3",
|
||||||
|
Var: "move_num",
|
||||||
|
Body: []*Step{
|
||||||
|
{ID: "move", Title: "Move {move_num}"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ApplyLoops(steps)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyLoops failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have 3 steps (range 1..3 = 3 iterations)
|
||||||
|
if len(result) != 3 {
|
||||||
|
t.Errorf("Expected 3 steps, got %d", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check step IDs and titles
|
||||||
|
expectedIDs := []string{
|
||||||
|
"moves.iter1.move",
|
||||||
|
"moves.iter2.move",
|
||||||
|
"moves.iter3.move",
|
||||||
|
}
|
||||||
|
expectedTitles := []string{
|
||||||
|
"Move 1",
|
||||||
|
"Move 2",
|
||||||
|
"Move 3",
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, expected := range expectedIDs {
|
||||||
|
if i >= len(result) {
|
||||||
|
t.Errorf("Missing step %d: %s", i, expected)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if result[i].ID != expected {
|
||||||
|
t.Errorf("Step %d: expected ID %s, got %s", i, expected, result[i].ID)
|
||||||
|
}
|
||||||
|
if result[i].Title != expectedTitles[i] {
|
||||||
|
t.Errorf("Step %d: expected Title %q, got %q", i, expectedTitles[i], result[i].Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestApplyLoops_RangeComputed tests computed range with expressions.
|
||||||
|
func TestApplyLoops_RangeComputed(t *testing.T) {
|
||||||
|
// Create a step with a computed range loop (like Towers of Hanoi)
|
||||||
|
steps := []*Step{
|
||||||
|
{
|
||||||
|
ID: "hanoi",
|
||||||
|
Title: "Hanoi moves",
|
||||||
|
Loop: &LoopSpec{
|
||||||
|
Range: "1..2^3-1", // 1..7 (2^3-1 moves for 3 disks)
|
||||||
|
Var: "step_num",
|
||||||
|
Body: []*Step{
|
||||||
|
{ID: "step", Title: "Step {step_num}"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ApplyLoops(steps)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ApplyLoops failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have 7 steps (2^3-1 = 7)
|
||||||
|
if len(result) != 7 {
|
||||||
|
t.Errorf("Expected 7 steps, got %d", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check first and last step
|
||||||
|
if len(result) >= 1 {
|
||||||
|
if result[0].Title != "Step 1" {
|
||||||
|
t.Errorf("First step title: expected 'Step 1', got %q", result[0].Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(result) >= 7 {
|
||||||
|
if result[6].Title != "Step 7" {
|
||||||
|
t.Errorf("Last step title: expected 'Step 7', got %q", result[6].Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateLoopSpec_Range tests validation of range loops.
|
||||||
|
func TestValidateLoopSpec_Range(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
loop *LoopSpec
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid range",
|
||||||
|
loop: &LoopSpec{
|
||||||
|
Range: "1..10",
|
||||||
|
Body: []*Step{{ID: "step"}},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid computed range",
|
||||||
|
loop: &LoopSpec{
|
||||||
|
Range: "1..2^3",
|
||||||
|
Var: "n",
|
||||||
|
Body: []*Step{{ID: "step"}},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid - both count and range",
|
||||||
|
loop: &LoopSpec{
|
||||||
|
Count: 5,
|
||||||
|
Range: "1..10",
|
||||||
|
Body: []*Step{{ID: "step"}},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid - both until and range",
|
||||||
|
loop: &LoopSpec{
|
||||||
|
Until: "step.status == 'complete'",
|
||||||
|
Max: 10,
|
||||||
|
Range: "1..10",
|
||||||
|
Body: []*Step{{ID: "step"}},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid range syntax",
|
||||||
|
loop: &LoopSpec{
|
||||||
|
Range: "invalid",
|
||||||
|
Body: []*Step{{ID: "step"}},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := validateLoopSpec(tt.loop, "test")
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("validateLoopSpec() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
381
internal/formula/range.go
Normal file
381
internal/formula/range.go
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
// Package formula provides range expression evaluation for computed loops.
|
||||||
|
//
|
||||||
|
// Range expressions enable loops with computed bounds (gt-8tmz.27):
|
||||||
|
//
|
||||||
|
// range: "1..10" // Simple integer range
|
||||||
|
// range: "1..2^{disks}" // Expression with variable
|
||||||
|
// range: "{start}..{end}" // Variable bounds
|
||||||
|
//
|
||||||
|
// Supports: + - * / ^ (power) and parentheses.
|
||||||
|
// Variables use {name} syntax and are substituted from the vars map.
|
||||||
|
package formula
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RangeSpec represents a parsed range expression.
|
||||||
|
type RangeSpec struct {
|
||||||
|
Start int // Evaluated start value (inclusive)
|
||||||
|
End int // Evaluated end value (inclusive)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rangePattern matches "start..end" format.
|
||||||
|
var rangePattern = regexp.MustCompile(`^(.+)\.\.(.+)$`)
|
||||||
|
|
||||||
|
// rangeVarPattern matches {varname} placeholders in range expressions.
|
||||||
|
var rangeVarPattern = regexp.MustCompile(`\{(\w+)\}`)
|
||||||
|
|
||||||
|
// ParseRange parses a range expression and evaluates it using the given variables.
|
||||||
|
// Returns the start and end values of the range.
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
//
|
||||||
|
// ParseRange("1..10", nil) -> {1, 10}
|
||||||
|
// ParseRange("1..2^3", nil) -> {1, 8}
|
||||||
|
// ParseRange("1..2^{n}", {"n":"3"}) -> {1, 8}
|
||||||
|
func ParseRange(expr string, vars map[string]string) (*RangeSpec, error) {
|
||||||
|
expr = strings.TrimSpace(expr)
|
||||||
|
if expr == "" {
|
||||||
|
return nil, fmt.Errorf("empty range expression")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse start..end format
|
||||||
|
m := rangePattern.FindStringSubmatch(expr)
|
||||||
|
if m == nil {
|
||||||
|
return nil, fmt.Errorf("invalid range format %q: expected start..end", expr)
|
||||||
|
}
|
||||||
|
|
||||||
|
startExpr := strings.TrimSpace(m[1])
|
||||||
|
endExpr := strings.TrimSpace(m[2])
|
||||||
|
|
||||||
|
// Evaluate start expression
|
||||||
|
start, err := EvaluateExpr(startExpr, vars)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("evaluating range start %q: %w", startExpr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate end expression
|
||||||
|
end, err := EvaluateExpr(endExpr, vars)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("evaluating range end %q: %w", endExpr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &RangeSpec{Start: start, End: end}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvaluateExpr evaluates a mathematical expression with variable substitution.
|
||||||
|
// Supports: + - * / ^ (power) and parentheses.
|
||||||
|
// Variables use {name} syntax.
|
||||||
|
func EvaluateExpr(expr string, vars map[string]string) (int, error) {
|
||||||
|
// Substitute variables first
|
||||||
|
expr = substituteVars(expr, vars)
|
||||||
|
|
||||||
|
// Tokenize and parse
|
||||||
|
tokens, err := tokenize(expr)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := parseExpr(tokens)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return int(result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// substituteVars replaces {varname} with values from vars map.
|
||||||
|
func substituteVars(expr string, vars map[string]string) string {
|
||||||
|
if vars == nil {
|
||||||
|
return expr
|
||||||
|
}
|
||||||
|
return rangeVarPattern.ReplaceAllStringFunc(expr, func(match string) string {
|
||||||
|
name := match[1 : len(match)-1] // Remove { and }
|
||||||
|
if val, ok := vars[name]; ok {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
return match // Leave unresolved
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token types for expression parsing.
|
||||||
|
type tokenType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
tokNumber tokenType = iota
|
||||||
|
tokPlus
|
||||||
|
tokMinus
|
||||||
|
tokMul
|
||||||
|
tokDiv
|
||||||
|
tokPow
|
||||||
|
tokLParen
|
||||||
|
tokRParen
|
||||||
|
tokEOF
|
||||||
|
)
|
||||||
|
|
||||||
|
type token struct {
|
||||||
|
typ tokenType
|
||||||
|
val float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenize converts expression string to tokens.
|
||||||
|
func tokenize(expr string) ([]token, error) {
|
||||||
|
var tokens []token
|
||||||
|
i := 0
|
||||||
|
|
||||||
|
for i < len(expr) {
|
||||||
|
ch := expr[i]
|
||||||
|
|
||||||
|
// Skip whitespace
|
||||||
|
if unicode.IsSpace(rune(ch)) {
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number
|
||||||
|
if unicode.IsDigit(rune(ch)) {
|
||||||
|
j := i
|
||||||
|
for j < len(expr) && (unicode.IsDigit(rune(expr[j])) || expr[j] == '.') {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
val, err := strconv.ParseFloat(expr[i:j], 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid number %q", expr[i:j])
|
||||||
|
}
|
||||||
|
tokens = append(tokens, token{tokNumber, val})
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operators
|
||||||
|
switch ch {
|
||||||
|
case '+':
|
||||||
|
tokens = append(tokens, token{tokPlus, 0})
|
||||||
|
case '-':
|
||||||
|
// Could be unary minus or subtraction
|
||||||
|
// If previous token is not a number or right paren, it's unary
|
||||||
|
if len(tokens) == 0 || (tokens[len(tokens)-1].typ != tokNumber && tokens[len(tokens)-1].typ != tokRParen) {
|
||||||
|
// Unary minus: parse the number with the minus
|
||||||
|
j := i + 1
|
||||||
|
for j < len(expr) && (unicode.IsDigit(rune(expr[j])) || expr[j] == '.') {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
if j > i+1 {
|
||||||
|
val, err := strconv.ParseFloat(expr[i:j], 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid number %q", expr[i:j])
|
||||||
|
}
|
||||||
|
tokens = append(tokens, token{tokNumber, val})
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokens = append(tokens, token{tokMinus, 0})
|
||||||
|
case '*':
|
||||||
|
tokens = append(tokens, token{tokMul, 0})
|
||||||
|
case '/':
|
||||||
|
tokens = append(tokens, token{tokDiv, 0})
|
||||||
|
case '^':
|
||||||
|
tokens = append(tokens, token{tokPow, 0})
|
||||||
|
case '(':
|
||||||
|
tokens = append(tokens, token{tokLParen, 0})
|
||||||
|
case ')':
|
||||||
|
tokens = append(tokens, token{tokRParen, 0})
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unexpected character %q in expression", ch)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens = append(tokens, token{tokEOF, 0})
|
||||||
|
return tokens, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parser state
|
||||||
|
type exprParser struct {
|
||||||
|
tokens []token
|
||||||
|
pos int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *exprParser) current() token {
|
||||||
|
if p.pos >= len(p.tokens) {
|
||||||
|
return token{tokEOF, 0}
|
||||||
|
}
|
||||||
|
return p.tokens[p.pos]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *exprParser) advance() {
|
||||||
|
p.pos++
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseExpr parses an expression using recursive descent.
|
||||||
|
// Handles operator precedence: + - < * / < ^
|
||||||
|
func parseExpr(tokens []token) (float64, error) {
|
||||||
|
p := &exprParser{tokens: tokens}
|
||||||
|
result, err := p.parseAddSub()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if p.current().typ != tokEOF {
|
||||||
|
return 0, fmt.Errorf("unexpected token after expression")
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAddSub handles + and - (lowest precedence)
|
||||||
|
func (p *exprParser) parseAddSub() (float64, error) {
|
||||||
|
left, err := p.parseMulDiv()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
switch p.current().typ {
|
||||||
|
case tokPlus:
|
||||||
|
p.advance()
|
||||||
|
right, err := p.parseMulDiv()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
left += right
|
||||||
|
case tokMinus:
|
||||||
|
p.advance()
|
||||||
|
right, err := p.parseMulDiv()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
left -= right
|
||||||
|
default:
|
||||||
|
return left, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMulDiv handles * and /
|
||||||
|
func (p *exprParser) parseMulDiv() (float64, error) {
|
||||||
|
left, err := p.parsePow()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
switch p.current().typ {
|
||||||
|
case tokMul:
|
||||||
|
p.advance()
|
||||||
|
right, err := p.parsePow()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
left *= right
|
||||||
|
case tokDiv:
|
||||||
|
p.advance()
|
||||||
|
right, err := p.parsePow()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if right == 0 {
|
||||||
|
return 0, fmt.Errorf("division by zero")
|
||||||
|
}
|
||||||
|
left /= right
|
||||||
|
default:
|
||||||
|
return left, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePow handles ^ (power, highest binary precedence, right-associative)
|
||||||
|
func (p *exprParser) parsePow() (float64, error) {
|
||||||
|
base, err := p.parseUnary()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.current().typ == tokPow {
|
||||||
|
p.advance()
|
||||||
|
exp, err := p.parsePow() // Right-associative
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return math.Pow(base, exp), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return base, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseUnary handles unary minus
|
||||||
|
func (p *exprParser) parseUnary() (float64, error) {
|
||||||
|
if p.current().typ == tokMinus {
|
||||||
|
p.advance()
|
||||||
|
val, err := p.parseUnary()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return -val, nil
|
||||||
|
}
|
||||||
|
return p.parsePrimary()
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePrimary handles numbers and parentheses
|
||||||
|
func (p *exprParser) parsePrimary() (float64, error) {
|
||||||
|
switch p.current().typ {
|
||||||
|
case tokNumber:
|
||||||
|
val := p.current().val
|
||||||
|
p.advance()
|
||||||
|
return val, nil
|
||||||
|
case tokLParen:
|
||||||
|
p.advance()
|
||||||
|
val, err := p.parseAddSub()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if p.current().typ != tokRParen {
|
||||||
|
return 0, fmt.Errorf("expected closing parenthesis")
|
||||||
|
}
|
||||||
|
p.advance()
|
||||||
|
return val, nil
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("unexpected token in expression")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateRange validates a range expression without evaluating it.
|
||||||
|
// Useful for syntax checking during formula validation.
|
||||||
|
func ValidateRange(expr string) error {
|
||||||
|
expr = strings.TrimSpace(expr)
|
||||||
|
if expr == "" {
|
||||||
|
return fmt.Errorf("empty range expression")
|
||||||
|
}
|
||||||
|
|
||||||
|
m := rangePattern.FindStringSubmatch(expr)
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("invalid range format: expected start..end")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that expressions parse (with placeholder vars)
|
||||||
|
placeholderVars := make(map[string]string)
|
||||||
|
rangeVarPattern.ReplaceAllStringFunc(expr, func(match string) string {
|
||||||
|
name := match[1 : len(match)-1]
|
||||||
|
placeholderVars[name] = "1" // Use 1 as placeholder
|
||||||
|
return "1"
|
||||||
|
})
|
||||||
|
|
||||||
|
startExpr := strings.TrimSpace(m[1])
|
||||||
|
startExpr = substituteVars(startExpr, placeholderVars)
|
||||||
|
if _, err := tokenize(startExpr); err != nil {
|
||||||
|
return fmt.Errorf("invalid start expression: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
endExpr := strings.TrimSpace(m[2])
|
||||||
|
endExpr = substituteVars(endExpr, placeholderVars)
|
||||||
|
if _, err := tokenize(endExpr); err != nil {
|
||||||
|
return fmt.Errorf("invalid end expression: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
256
internal/formula/range_test.go
Normal file
256
internal/formula/range_test.go
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
package formula
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEvaluateExpr(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
expr string
|
||||||
|
vars map[string]string
|
||||||
|
want int
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple integer",
|
||||||
|
expr: "10",
|
||||||
|
want: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "addition",
|
||||||
|
expr: "2+3",
|
||||||
|
want: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subtraction",
|
||||||
|
expr: "10-3",
|
||||||
|
want: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiplication",
|
||||||
|
expr: "4*5",
|
||||||
|
want: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "division",
|
||||||
|
expr: "20/4",
|
||||||
|
want: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "power",
|
||||||
|
expr: "2^3",
|
||||||
|
want: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "power of 2",
|
||||||
|
expr: "2^10",
|
||||||
|
want: 1024,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complex expression",
|
||||||
|
expr: "2+3*4",
|
||||||
|
want: 14, // 2+(3*4) = 14, not (2+3)*4 = 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parentheses",
|
||||||
|
expr: "(2+3)*4",
|
||||||
|
want: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nested parentheses",
|
||||||
|
expr: "((2+3)*(4+1))",
|
||||||
|
want: 25,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "variable substitution",
|
||||||
|
expr: "{n}",
|
||||||
|
vars: map[string]string{"n": "5"},
|
||||||
|
want: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "power with variable",
|
||||||
|
expr: "2^{n}",
|
||||||
|
vars: map[string]string{"n": "4"},
|
||||||
|
want: 16,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple variables",
|
||||||
|
expr: "{a}+{b}",
|
||||||
|
vars: map[string]string{"a": "10", "b": "20"},
|
||||||
|
want: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "towers of hanoi pattern",
|
||||||
|
expr: "2^{disks}-1",
|
||||||
|
vars: map[string]string{"disks": "3"},
|
||||||
|
want: 7, // 2^3-1 = 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative result",
|
||||||
|
expr: "1-10",
|
||||||
|
want: -9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "division by zero",
|
||||||
|
expr: "10/0",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid expression",
|
||||||
|
expr: "2++3",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := EvaluateExpr(tt.expr, tt.vars)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("EvaluateExpr(%q) error = %v, wantErr %v", tt.expr, err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !tt.wantErr && got != tt.want {
|
||||||
|
t.Errorf("EvaluateExpr(%q) = %v, want %v", tt.expr, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRange(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
expr string
|
||||||
|
vars map[string]string
|
||||||
|
wantStart int
|
||||||
|
wantEnd int
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple range",
|
||||||
|
expr: "1..10",
|
||||||
|
wantStart: 1,
|
||||||
|
wantEnd: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single value range",
|
||||||
|
expr: "5..5",
|
||||||
|
wantStart: 5,
|
||||||
|
wantEnd: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "computed end",
|
||||||
|
expr: "1..2^3",
|
||||||
|
wantStart: 1,
|
||||||
|
wantEnd: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "computed start and end",
|
||||||
|
expr: "2*2..3*3",
|
||||||
|
wantStart: 4,
|
||||||
|
wantEnd: 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "variables in range",
|
||||||
|
expr: "1..{n}",
|
||||||
|
vars: map[string]string{"n": "10"},
|
||||||
|
wantStart: 1,
|
||||||
|
wantEnd: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "towers of hanoi moves",
|
||||||
|
expr: "1..2^{disks}-1",
|
||||||
|
vars: map[string]string{"disks": "3"},
|
||||||
|
wantStart: 1,
|
||||||
|
wantEnd: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both variables",
|
||||||
|
expr: "{start}..{end}",
|
||||||
|
vars: map[string]string{"start": "5", "end": "15"},
|
||||||
|
wantStart: 5,
|
||||||
|
wantEnd: 15,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty expression",
|
||||||
|
expr: "",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing separator",
|
||||||
|
expr: "1 10",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid start expression",
|
||||||
|
expr: "abc..10",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ParseRange(tt.expr, tt.vars)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ParseRange(%q) error = %v, wantErr %v", tt.expr, err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !tt.wantErr {
|
||||||
|
if got.Start != tt.wantStart {
|
||||||
|
t.Errorf("ParseRange(%q).Start = %v, want %v", tt.expr, got.Start, tt.wantStart)
|
||||||
|
}
|
||||||
|
if got.End != tt.wantEnd {
|
||||||
|
t.Errorf("ParseRange(%q).End = %v, want %v", tt.expr, got.End, tt.wantEnd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRange(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
expr string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid simple range",
|
||||||
|
expr: "1..10",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid computed range",
|
||||||
|
expr: "1..2^{n}",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid complex range",
|
||||||
|
expr: "{start}..{end}*2",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
expr: "",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no separator",
|
||||||
|
expr: "10",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid character",
|
||||||
|
expr: "1..@10",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := ValidateRange(tt.expr)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("ValidateRange(%q) error = %v, wantErr %v", tt.expr, err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -215,7 +215,7 @@ type Gate struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LoopSpec defines iteration over a body of steps (gt-8tmz.4).
|
// LoopSpec defines iteration over a body of steps (gt-8tmz.4).
|
||||||
// Either Count or Until must be specified (not both).
|
// One of Count, Until, or Range must be specified.
|
||||||
type LoopSpec struct {
|
type LoopSpec struct {
|
||||||
// Count is the fixed number of iterations.
|
// Count is the fixed number of iterations.
|
||||||
// When set, the loop body is expanded Count times.
|
// When set, the loop body is expanded Count times.
|
||||||
@@ -229,6 +229,19 @@ type LoopSpec struct {
|
|||||||
// Required when Until is set, to prevent unbounded loops.
|
// Required when Until is set, to prevent unbounded loops.
|
||||||
Max int `json:"max,omitempty"`
|
Max int `json:"max,omitempty"`
|
||||||
|
|
||||||
|
// Range specifies a computed range for iteration (gt-8tmz.27).
|
||||||
|
// Format: "start..end" where start and end can be:
|
||||||
|
// - Integers: "1..10"
|
||||||
|
// - Expressions: "1..2^{disks}" (evaluated at cook time)
|
||||||
|
// - Variables: "{start}..{count}" (substituted from Vars)
|
||||||
|
// Supports: + - * / ^ (power) and parentheses.
|
||||||
|
Range string `json:"range,omitempty"`
|
||||||
|
|
||||||
|
// Var is the variable name exposed to body steps (gt-8tmz.27).
|
||||||
|
// For Range loops, this is set to the current iteration value.
|
||||||
|
// Example: var: "move_num" with range: "1..7" exposes {move_num}=1,2,...,7
|
||||||
|
Var string `json:"var,omitempty"`
|
||||||
|
|
||||||
// Body contains the steps to repeat.
|
// Body contains the steps to repeat.
|
||||||
Body []*Step `json:"body"`
|
Body []*Step `json:"body"`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user