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>
382 lines
8.6 KiB
Go
382 lines
8.6 KiB
Go
// 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
|
|
}
|