From 030838cfde53bbdd6fa6cc0a30520ad2ddd99465 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Thu, 25 Dec 2025 17:48:35 -0800 Subject: [PATCH] Implement computed range expansion for loops (gt-8tmz.27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/formula/controlflow.go | 112 +++++++- internal/formula/controlflow_test.go | 156 +++++++++++ internal/formula/range.go | 381 +++++++++++++++++++++++++++ internal/formula/range_test.go | 256 ++++++++++++++++++ internal/formula/types.go | 15 +- 5 files changed, 906 insertions(+), 14 deletions(-) create mode 100644 internal/formula/range.go create mode 100644 internal/formula/range_test.go diff --git a/internal/formula/controlflow.go b/internal/formula/controlflow.go index b786b5ad..a83034f0 100644 --- a/internal/formula/controlflow.go +++ b/internal/formula/controlflow.go @@ -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)) diff --git a/internal/formula/controlflow_test.go b/internal/formula/controlflow_test.go index 0230c3ea..f5e5e403 100644 --- a/internal/formula/controlflow_test.go +++ b/internal/formula/controlflow_test.go @@ -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) + } + }) + } +} diff --git a/internal/formula/range.go b/internal/formula/range.go new file mode 100644 index 00000000..bc66cf3f --- /dev/null +++ b/internal/formula/range.go @@ -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 +} diff --git a/internal/formula/range_test.go b/internal/formula/range_test.go new file mode 100644 index 00000000..2d43151a --- /dev/null +++ b/internal/formula/range_test.go @@ -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) + } + }) + } +} diff --git a/internal/formula/types.go b/internal/formula/types.go index 58d5d375..d7821595 100644 --- a/internal/formula/types.go +++ b/internal/formula/types.go @@ -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"` }