Files
beads/internal/formula/parser.go
Steve Yegge df5d4b384c Add TOML support for formulas (gt-xmyha)
- Add TOML parsing using BurntSushi/toml
- Update formula loader to try .toml first, fall back to .json
- Add `bd formula convert` command for JSON→TOML migration
- Multi-line string support for readable descriptions
- Cache git worktree/reporoot checks for performance

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 21:59:19 -08:00

461 lines
12 KiB
Go

package formula
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/BurntSushi/toml"
)
// Formula file extensions. TOML is preferred, JSON is legacy fallback.
const (
FormulaExtTOML = ".formula.toml"
FormulaExtJSON = ".formula.json"
FormulaExt = FormulaExtJSON // Legacy alias for backwards compatibility
)
// Parser handles loading and resolving formulas.
//
// NOTE: Parser is NOT thread-safe. Create a new Parser per goroutine or
// synchronize access externally. The cache and resolving maps have no
// internal synchronization.
type Parser struct {
// searchPaths are directories to search for formulas (in order).
searchPaths []string
// cache stores loaded formulas by name.
cache map[string]*Formula
// resolvingSet tracks formulas currently being resolved (for cycle detection).
resolvingSet map[string]bool
// resolvingChain tracks the order of formulas being resolved (for error messages).
resolvingChain []string
}
// NewParser creates a new formula parser.
// searchPaths are directories to search for formulas when resolving extends.
// Default paths are: .beads/formulas, ~/.beads/formulas, ~/gt/.beads/formulas
func NewParser(searchPaths ...string) *Parser {
paths := searchPaths
if len(paths) == 0 {
paths = defaultSearchPaths()
}
return &Parser{
searchPaths: paths,
cache: make(map[string]*Formula),
resolvingSet: make(map[string]bool),
resolvingChain: nil,
}
}
// defaultSearchPaths returns the default formula search paths.
func defaultSearchPaths() []string {
var paths []string
// Project-level formulas
if cwd, err := os.Getwd(); err == nil {
paths = append(paths, filepath.Join(cwd, ".beads", "formulas"))
}
// User-level formulas
if home, err := os.UserHomeDir(); err == nil {
paths = append(paths, filepath.Join(home, ".beads", "formulas"))
// Gas Town formulas
paths = append(paths, filepath.Join(home, "gt", ".beads", "formulas"))
}
return paths
}
// ParseFile parses a formula from a file path.
// Detects format from extension: .formula.toml or .formula.json
func (p *Parser) ParseFile(path string) (*Formula, error) {
// Check cache first
absPath, err := filepath.Abs(path)
if err != nil {
return nil, fmt.Errorf("resolve path: %w", err)
}
if cached, ok := p.cache[absPath]; ok {
return cached, nil
}
// Read and parse the file
// #nosec G304 -- absPath comes from controlled search paths or explicit user input
data, err := os.ReadFile(absPath)
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
// Detect format from extension
var formula *Formula
if strings.HasSuffix(path, FormulaExtTOML) {
formula, err = p.ParseTOML(data)
} else {
formula, err = p.Parse(data)
}
if err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
formula.Source = absPath
// Set source tracing info on all steps (gt-8tmz.18)
SetSourceInfo(formula)
p.cache[absPath] = formula
// Also cache by name for extends resolution
p.cache[formula.Formula] = formula
return formula, nil
}
// Parse parses a formula from JSON bytes.
func (p *Parser) Parse(data []byte) (*Formula, error) {
var formula Formula
if err := json.Unmarshal(data, &formula); err != nil {
return nil, fmt.Errorf("json: %w", err)
}
// Set defaults
if formula.Version == 0 {
formula.Version = 1
}
if formula.Type == "" {
formula.Type = TypeWorkflow
}
return &formula, nil
}
// ParseTOML parses a formula from TOML bytes.
func (p *Parser) ParseTOML(data []byte) (*Formula, error) {
var formula Formula
if err := toml.Unmarshal(data, &formula); err != nil {
return nil, fmt.Errorf("toml: %w", err)
}
// Set defaults
if formula.Version == 0 {
formula.Version = 1
}
if formula.Type == "" {
formula.Type = TypeWorkflow
}
return &formula, nil
}
// Resolve fully resolves a formula, processing extends and expansions.
// Returns a new formula with all inheritance applied.
func (p *Parser) Resolve(formula *Formula) (*Formula, error) {
// Check for cycles
if p.resolvingSet[formula.Formula] {
// Build the cycle chain for a clear error message
chain := append(p.resolvingChain, formula.Formula)
return nil, fmt.Errorf("circular extends detected: %s", strings.Join(chain, " -> "))
}
p.resolvingSet[formula.Formula] = true
p.resolvingChain = append(p.resolvingChain, formula.Formula)
defer func() {
delete(p.resolvingSet, formula.Formula)
p.resolvingChain = p.resolvingChain[:len(p.resolvingChain)-1]
}()
// If no extends, just validate and return
if len(formula.Extends) == 0 {
if err := formula.Validate(); err != nil {
return nil, err
}
return formula, nil
}
// Build merged formula from parents
merged := &Formula{
Formula: formula.Formula,
Description: formula.Description,
Version: formula.Version,
Type: formula.Type,
Source: formula.Source,
Vars: make(map[string]*VarDef),
Steps: nil,
Compose: nil,
}
// Apply each parent in order
for _, parentName := range formula.Extends {
parent, err := p.loadFormula(parentName)
if err != nil {
return nil, fmt.Errorf("extends %s: %w", parentName, err)
}
// Resolve parent recursively
parent, err = p.Resolve(parent)
if err != nil {
return nil, fmt.Errorf("resolve parent %s: %w", parentName, err)
}
// Merge parent vars (parent vars are inherited, child overrides)
for name, varDef := range parent.Vars {
if _, exists := merged.Vars[name]; !exists {
merged.Vars[name] = varDef
}
}
// Merge parent steps (append, child steps come after)
merged.Steps = append(merged.Steps, parent.Steps...)
// Merge parent compose rules
merged.Compose = mergeComposeRules(merged.Compose, parent.Compose)
}
// Apply child overrides
for name, varDef := range formula.Vars {
merged.Vars[name] = varDef
}
merged.Steps = append(merged.Steps, formula.Steps...)
merged.Compose = mergeComposeRules(merged.Compose, formula.Compose)
// Use child description if set
if formula.Description != "" {
merged.Description = formula.Description
}
if err := merged.Validate(); err != nil {
return nil, err
}
return merged, nil
}
// loadFormula loads a formula by name from search paths.
// Tries TOML first (.formula.toml), then falls back to JSON (.formula.json).
func (p *Parser) loadFormula(name string) (*Formula, error) {
// Check cache first
if cached, ok := p.cache[name]; ok {
return cached, nil
}
// Search for the formula file - try TOML first, then JSON
extensions := []string{FormulaExtTOML, FormulaExtJSON}
for _, dir := range p.searchPaths {
for _, ext := range extensions {
path := filepath.Join(dir, name+ext)
if _, err := os.Stat(path); err == nil {
return p.ParseFile(path)
}
}
}
return nil, fmt.Errorf("formula %q not found in search paths", name)
}
// LoadByName loads a formula by name from search paths.
// This is the public API for loading formulas used by expansion operators.
func (p *Parser) LoadByName(name string) (*Formula, error) {
return p.loadFormula(name)
}
// mergeComposeRules merges two compose rule sets.
func mergeComposeRules(base, overlay *ComposeRules) *ComposeRules {
if overlay == nil {
return base
}
if base == nil {
return overlay
}
result := &ComposeRules{
BondPoints: append([]*BondPoint{}, base.BondPoints...),
Hooks: append([]*Hook{}, base.Hooks...),
Expand: append([]*ExpandRule{}, base.Expand...),
Map: append([]*MapRule{}, base.Map...),
}
// Add overlay bond points (override by ID)
existingBP := make(map[string]int)
for i, bp := range result.BondPoints {
existingBP[bp.ID] = i
}
for _, bp := range overlay.BondPoints {
if idx, exists := existingBP[bp.ID]; exists {
result.BondPoints[idx] = bp
} else {
result.BondPoints = append(result.BondPoints, bp)
}
}
// Add overlay hooks (append, no override)
result.Hooks = append(result.Hooks, overlay.Hooks...)
// Add overlay expand rules (append, no override)
result.Expand = append(result.Expand, overlay.Expand...)
// Add overlay map rules (append, no override)
result.Map = append(result.Map, overlay.Map...)
return result
}
// varPattern matches {{variable}} placeholders.
var varPattern = regexp.MustCompile(`\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}`)
// ExtractVariables finds all {{variable}} references in a formula.
func ExtractVariables(formula *Formula) []string {
seen := make(map[string]bool)
var vars []string
// Helper to extract vars from a string
extract := func(s string) {
matches := varPattern.FindAllStringSubmatch(s, -1)
for _, match := range matches {
if len(match) >= 2 && !seen[match[1]] {
seen[match[1]] = true
vars = append(vars, match[1])
}
}
}
// Extract from formula fields
extract(formula.Description)
// Extract from steps
var extractFromStep func(*Step)
extractFromStep = func(step *Step) {
extract(step.Title)
extract(step.Description)
extract(step.Assignee)
extract(step.Condition)
for _, child := range step.Children {
extractFromStep(child)
}
}
for _, step := range formula.Steps {
extractFromStep(step)
}
return vars
}
// Substitute replaces {{variable}} placeholders with values.
func Substitute(s string, vars map[string]string) string {
return varPattern.ReplaceAllStringFunc(s, func(match string) string {
// Extract variable name from {{name}}
name := match[2 : len(match)-2]
if val, ok := vars[name]; ok {
return val
}
return match // Keep unresolved placeholders
})
}
// ValidateVars checks that all required variables are provided
// and all values pass their constraints.
func ValidateVars(formula *Formula, values map[string]string) error {
var errs []string
for name, def := range formula.Vars {
val, provided := values[name]
// Check required
if def.Required && !provided {
errs = append(errs, fmt.Sprintf("variable %q is required", name))
continue
}
// Use default if not provided
if !provided && def.Default != "" {
val = def.Default
}
// Skip further validation if no value
if val == "" {
continue
}
// Check enum constraint
if len(def.Enum) > 0 {
found := false
for _, allowed := range def.Enum {
if val == allowed {
found = true
break
}
}
if !found {
errs = append(errs, fmt.Sprintf("variable %q: value %q not in allowed values %v", name, val, def.Enum))
}
}
// Check pattern constraint
if def.Pattern != "" {
re, err := regexp.Compile(def.Pattern)
if err != nil {
errs = append(errs, fmt.Sprintf("variable %q: invalid pattern %q: %v", name, def.Pattern, err))
} else if !re.MatchString(val) {
errs = append(errs, fmt.Sprintf("variable %q: value %q does not match pattern %q", name, val, def.Pattern))
}
}
}
if len(errs) > 0 {
return fmt.Errorf("variable validation failed:\n - %s", strings.Join(errs, "\n - "))
}
return nil
}
// ApplyDefaults returns a new map with default values filled in.
func ApplyDefaults(formula *Formula, values map[string]string) map[string]string {
result := make(map[string]string)
// Copy provided values
for k, v := range values {
result[k] = v
}
// Apply defaults for missing values
for name, def := range formula.Vars {
if _, exists := result[name]; !exists && def.Default != "" {
result[name] = def.Default
}
}
return result
}
// SetSourceInfo sets SourceFormula and SourceLocation on all steps in a formula.
// Called after parsing to enable source tracing during cooking (gt-8tmz.18).
func SetSourceInfo(formula *Formula) {
setSourceInfoRecursive(formula.Steps, formula.Formula, "steps")
// Also set source info on template steps for expansion formulas
setSourceInfoRecursive(formula.Template, formula.Formula, "template")
}
// setSourceInfoRecursive recursively sets source info on steps.
func setSourceInfoRecursive(steps []*Step, formulaName, pathPrefix string) {
for i, step := range steps {
step.SourceFormula = formulaName
step.SourceLocation = fmt.Sprintf("%s[%d]", pathPrefix, i)
if len(step.Children) > 0 {
childPath := fmt.Sprintf("%s[%d].children", pathPrefix, i)
setSourceInfoRecursive(step.Children, formulaName, childPath)
}
// Handle loop body steps
if step.Loop != nil && len(step.Loop.Body) > 0 {
bodyPath := fmt.Sprintf("%s[%d].loop.body", pathPrefix, i)
setSourceInfoRecursive(step.Loop.Body, formulaName, bodyPath)
}
}
}