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)
|
||||
}
|
||||
|
||||
if loop.Count > 0 && loop.Until != "" {
|
||||
return fmt.Errorf("loop %q: cannot have both count and until", stepID)
|
||||
// Count the number of loop types specified
|
||||
loopTypes := 0
|
||||
if loop.Count > 0 {
|
||||
loopTypes++
|
||||
}
|
||||
if loop.Until != "" {
|
||||
loopTypes++
|
||||
}
|
||||
if loop.Range != "" {
|
||||
loopTypes++
|
||||
}
|
||||
|
||||
if loop.Count == 0 && loop.Until == "" {
|
||||
return fmt.Errorf("loop %q: either count or until is required", stepID)
|
||||
if loopTypes == 0 {
|
||||
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 {
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// expandLoop expands a loop step into its constituent steps.
|
||||
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
|
||||
|
||||
if step.Loop.Count > 0 {
|
||||
// Fixed-count loop: expand body N times
|
||||
for i := 1; i <= step.Loop.Count; i++ {
|
||||
iterSteps, err := expandLoopIteration(step, i)
|
||||
iterSteps, err := expandLoopIteration(step, i, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -115,10 +139,50 @@ func expandLoop(step *Step) ([]*Step, error) {
|
||||
if step.Loop.Count > 1 {
|
||||
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 {
|
||||
// Conditional loop: expand once with loop metadata
|
||||
// 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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -147,7 +211,8 @@ func expandLoop(step *Step) ([]*Step, error) {
|
||||
|
||||
// expandLoopIteration expands a single iteration of a loop.
|
||||
// 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))
|
||||
|
||||
// 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
|
||||
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{
|
||||
ID: iterID,
|
||||
Title: bodyStep.Title,
|
||||
Description: bodyStep.Description,
|
||||
Title: title,
|
||||
Description: description,
|
||||
Type: bodyStep.Type,
|
||||
Priority: bodyStep.Priority,
|
||||
Assignee: bodyStep.Assignee,
|
||||
@@ -170,16 +239,20 @@ func expandLoopIteration(step *Step, iteration int) ([]*Step, error) {
|
||||
Gate: bodyStep.Gate,
|
||||
Loop: cloneLoopSpec(bodyStep.Loop), // Support nested loops (gt-zn35j)
|
||||
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
|
||||
}
|
||||
|
||||
// Clone ExpandVars if present
|
||||
if len(bodyStep.ExpandVars) > 0 {
|
||||
clone.ExpandVars = make(map[string]string, len(bodyStep.ExpandVars))
|
||||
// Clone ExpandVars if present, adding loop vars (gt-8tmz.27)
|
||||
if len(bodyStep.ExpandVars) > 0 || len(iterVars) > 0 {
|
||||
clone.ExpandVars = make(map[string]string)
|
||||
for k, v := range bodyStep.ExpandVars {
|
||||
clone.ExpandVars[k] = v
|
||||
}
|
||||
// Add loop variables to ExpandVars for nested expansion
|
||||
for k, v := range iterVars {
|
||||
clone.ExpandVars[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Clone labels
|
||||
@@ -203,6 +276,17 @@ func expandLoopIteration(step *Step, iteration int) ([]*Step, error) {
|
||||
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).
|
||||
func collectBodyStepIDs(body []*Step) map[string]bool {
|
||||
ids := make(map[string]bool)
|
||||
@@ -521,6 +605,8 @@ func cloneLoopSpec(loop *LoopSpec) *LoopSpec {
|
||||
Count: loop.Count,
|
||||
Until: loop.Until,
|
||||
Max: loop.Max,
|
||||
Range: loop.Range, // gt-8tmz.27
|
||||
Var: loop.Var, // gt-8tmz.27
|
||||
}
|
||||
if len(loop.Body) > 0 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
// Either Count or Until must be specified (not both).
|
||||
// One of Count, Until, or Range must be specified.
|
||||
type LoopSpec struct {
|
||||
// Count is the fixed number of iterations.
|
||||
// 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.
|
||||
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 []*Step `json:"body"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user