- 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>
461 lines
12 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|