Files
beads/internal/formula/range.go
Steve Yegge 030838cfde 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>
2025-12-25 17:48:35 -08:00

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
}