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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user