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:
Steve Yegge
2025-12-25 17:48:35 -08:00
parent 258d183e25
commit 030838cfde
5 changed files with 906 additions and 14 deletions

View File

@@ -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))

View File

@@ -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
View 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
}

View 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)
}
})
}
}

View File

@@ -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"`
}