From 76dbcfb1f6006c3fbaa72043c6530d56379d7266 Mon Sep 17 00:00:00 2001 From: ascandone Date: Thu, 2 Jul 2026 14:58:49 +0200 Subject: [PATCH 01/12] refactor: extract expr evaluation --- internal/interpreter/evaluate_expr.go | 72 ++++++++++---------- internal/interpreter/function_exprs.go | 87 +++++++++++++++++++------ internal/interpreter/infix_overloads.go | 26 ++++---- internal/interpreter/interpreter.go | 78 +++++++++------------- 4 files changed, 146 insertions(+), 117 deletions(-) diff --git a/internal/interpreter/evaluate_expr.go b/internal/interpreter/evaluate_expr.go index 5fb5c7d3..48d4775f 100644 --- a/internal/interpreter/evaluate_expr.go +++ b/internal/interpreter/evaluate_expr.go @@ -9,7 +9,14 @@ import ( "github.com/formancehq/numscript/internal/utils" ) -func (st *programState) evaluateExpr(expr parser.ValueExpr) (Value, InterpreterError) { +type expressionEnv interface { + getVariable(name string) Value + checkFeatureFlag(flag string) InterpreterError + getBalance(account AccountAddress, asset Asset) (*big.Int, InterpreterError) + getMetadata(account AccountAddress, key string) (value string, ok bool, err InterpreterError) +} + +func evaluateExpr(env expressionEnv, expr parser.ValueExpr) (Value, InterpreterError) { switch expr := expr.(type) { case *parser.AssetLiteral: return Asset(expr.Asset), nil @@ -20,12 +27,12 @@ func (st *programState) evaluateExpr(expr parser.ValueExpr) (Value, InterpreterE case parser.AccountTextPart: parts = append(parts, part.Name) case *parser.Variable: - err := st.checkFeatureFlag(flags.ExperimentalAccountInterpolationFlag) + err := env.checkFeatureFlag(flags.ExperimentalAccountInterpolationFlag) if err != nil { return nil, err } - value, err := st.evaluateExpr(part) + value, err := evaluateExpr(env, part) if err != nil { return nil, err } @@ -46,12 +53,12 @@ func (st *programState) evaluateExpr(expr parser.ValueExpr) (Value, InterpreterE case *parser.NumberLiteral: return MonetaryInt(*expr.Number), nil case *parser.MonetaryLiteral: - asset, err := evaluateExprAs(st, expr.Asset, expectAsset) + asset, err := evaluateExprAs(env, expr.Asset, expectAsset) if err != nil { return nil, err } - amount, err := evaluateExprAs(st, expr.Amount, expectNumber) + amount, err := evaluateExprAs(env, expr.Amount, expectNumber) if err != nil { return nil, err } @@ -59,8 +66,8 @@ func (st *programState) evaluateExpr(expr parser.ValueExpr) (Value, InterpreterE return Monetary{Asset: asset, Amount: amount}, nil case *parser.Variable: - value, ok := st.ParsedVars[expr.Name] - if !ok { + value := env.getVariable(expr.Name) + if value == nil { return nil, UnboundVariableErr{ Name: expr.Name, Range: expr.Range, @@ -71,13 +78,13 @@ func (st *programState) evaluateExpr(expr parser.ValueExpr) (Value, InterpreterE case *parser.BinaryInfix: switch expr.Operator { case parser.InfixOperatorPlus: - return st.plusOp(expr.Left, expr.Right) + return plusOp(env, expr.Left, expr.Right) case parser.InfixOperatorMinus: - return st.subOp(expr.Left, expr.Right) + return subOp(env, expr.Left, expr.Right) case parser.InfixOperatorDiv: - return st.divOp(expr.Range, expr.Left, expr.Right) + return divOp(env, expr.Range, expr.Left, expr.Right) default: utils.NonExhaustiveMatchPanic[any](expr.Operator) @@ -87,7 +94,7 @@ func (st *programState) evaluateExpr(expr parser.ValueExpr) (Value, InterpreterE case *parser.Prefix: switch expr.Operator { case parser.PrefixOperatorMinus: - return st.unaryNegOp(expr.Expr) + return unaryNegOp(env, expr.Expr) default: utils.NonExhaustiveMatchPanic[any](expr.Operator) @@ -95,11 +102,8 @@ func (st *programState) evaluateExpr(expr parser.ValueExpr) (Value, InterpreterE } case *parser.FnCall: - - if err := st.checkFeatureFlag(flags.ExperimentalMidScriptFunctionCall); err != nil { - return nil, err - } - return st.handleFnCall(nil, *expr) + // nil type: not a direct var origin, hence a mid-script call. + return evaluateFnCall(env, nil, *expr) default: utils.NonExhaustiveMatchPanic[any](expr) @@ -107,17 +111,17 @@ func (st *programState) evaluateExpr(expr parser.ValueExpr) (Value, InterpreterE } } -func evaluateOptExprAs[T any](st *programState, expr parser.ValueExpr, expect func(Value, parser.Range) (T, InterpreterError)) (T, InterpreterError) { +func evaluateOptExprAs[T any](env expressionEnv, expr parser.ValueExpr, expect func(Value, parser.Range) (T, InterpreterError)) (T, InterpreterError) { var t T if expr == nil { return t, nil } - return evaluateExprAs(st, expr, expect) + return evaluateExprAs(env, expr, expect) } -func evaluateExprAs[T any](st *programState, expr parser.ValueExpr, expect func(Value, parser.Range) (T, InterpreterError)) (T, InterpreterError) { +func evaluateExprAs[T any](env expressionEnv, expr parser.ValueExpr, expect func(Value, parser.Range) (T, InterpreterError)) (T, InterpreterError) { var default_ T - value, err := st.evaluateExpr(expr) + value, err := evaluateExpr(env, expr) if err != nil { return default_, err } @@ -130,10 +134,10 @@ func evaluateExprAs[T any](st *programState, expr parser.ValueExpr, expect func( return res, nil } -func (st *programState) evaluateExpressions(literals []parser.ValueExpr) ([]Value, InterpreterError) { +func evaluateExpressions(env expressionEnv, literals []parser.ValueExpr) ([]Value, InterpreterError) { var values []Value for _, argLit := range literals { - value, err := st.evaluateExpr(argLit) + value, err := evaluateExpr(env, argLit) if err != nil { return nil, err } @@ -159,9 +163,9 @@ func (s *programState) evaluateColor(colorExpr parser.ValueExpr) (String, Interp return color, nil } -func (st *programState) plusOp(left parser.ValueExpr, right parser.ValueExpr) (Value, InterpreterError) { +func plusOp(env expressionEnv, left parser.ValueExpr, right parser.ValueExpr) (Value, InterpreterError) { - leftValue, err := evaluateExprAs(st, left, expectOneOf( + leftValue, err := evaluateExprAs(env, left, expectOneOf( expectMapped(expectMonetary, func(m Monetary) opAdd { return m }), @@ -176,11 +180,11 @@ func (st *programState) plusOp(left parser.ValueExpr, right parser.ValueExpr) (V return nil, err } - return leftValue.evalAdd(st, right) + return leftValue.evalAdd(env, right) } -func (st *programState) subOp(left parser.ValueExpr, right parser.ValueExpr) (Value, InterpreterError) { - leftValue, err := evaluateExprAs(st, left, expectOneOf( +func subOp(env expressionEnv, left parser.ValueExpr, right parser.ValueExpr) (Value, InterpreterError) { + leftValue, err := evaluateExprAs(env, left, expectOneOf( expectMapped(expectMonetary, func(m Monetary) opSub { return m }), @@ -193,16 +197,16 @@ func (st *programState) subOp(left parser.ValueExpr, right parser.ValueExpr) (Va return nil, err } - return leftValue.evalSub(st, right) + return leftValue.evalSub(env, right) } -func (st *programState) divOp(rng parser.Range, left parser.ValueExpr, right parser.ValueExpr) (Value, InterpreterError) { - leftValue, err := evaluateExprAs(st, left, expectNumber) +func divOp(env expressionEnv, rng parser.Range, left parser.ValueExpr, right parser.ValueExpr) (Value, InterpreterError) { + leftValue, err := evaluateExprAs(env, left, expectNumber) if err != nil { return nil, err } - rightValue, err := evaluateExprAs(st, right, expectNumber) + rightValue, err := evaluateExprAs(env, right, expectNumber) if err != nil { return nil, err } @@ -221,8 +225,8 @@ func (st *programState) divOp(rng parser.Range, left parser.ValueExpr, right par return Portion(*rat), nil } -func (st *programState) unaryNegOp(expr parser.ValueExpr) (Value, InterpreterError) { - evExpr, err := evaluateExprAs(st, expr, expectOneOf( +func unaryNegOp(env expressionEnv, expr parser.ValueExpr) (Value, InterpreterError) { + evExpr, err := evaluateExprAs(env, expr, expectOneOf( expectMapped(expectMonetary, func(m Monetary) opNeg { return m }), @@ -237,7 +241,7 @@ func (st *programState) unaryNegOp(expr parser.ValueExpr) (Value, InterpreterErr return nil, err } - return evExpr.evalNeg(st) + return evExpr.evalNeg(env) } func castToString(v Value, rng parser.Range) (string, InterpreterError) { diff --git a/internal/interpreter/function_exprs.go b/internal/interpreter/function_exprs.go index 8d523b40..f9787e5c 100644 --- a/internal/interpreter/function_exprs.go +++ b/internal/interpreter/function_exprs.go @@ -3,16 +3,67 @@ package interpreter import ( "math/big" + "github.com/formancehq/numscript/internal/analysis" "github.com/formancehq/numscript/internal/flags" "github.com/formancehq/numscript/internal/parser" ) +func evaluateFnCall(env expressionEnv, type_ *string, fnCall parser.FnCall) (Value, InterpreterError) { + if type_ == nil { + if err := env.checkFeatureFlag(flags.ExperimentalMidScriptFunctionCall); err != nil { + return nil, err + } + } + + args, err := evaluateExpressions(env, fnCall.Args) + if err != nil { + return nil, err + } + + switch fnCall.Caller.Name { + case analysis.FnVarOriginMeta: + if type_ == nil { + return nil, InvalidNestedMeta{} + } + + rawValue, err := meta(env, fnCall.Range, args) + if err != nil { + return nil, err + } + return parseVar(*type_, rawValue, fnCall.Range) + + case analysis.FnVarOriginBalance: + monetary, err := balance(env, fnCall.Range, args) + if err != nil { + return nil, err + } + return monetary, nil + + case analysis.FnVarOriginOverdraft: + monetary, err := overdraft(env, fnCall.Range, args) + if err != nil { + return nil, err + } + return monetary, nil + + case analysis.FnVarOriginGetAsset: + return getAsset(env, fnCall.Range, args) + case analysis.FnVarOriginGetAmount: + return getAmount(env, fnCall.Range, args) + case analysis.FnVarOriginScoped: + return scoped(env, fnCall.Range, args) + + default: + return nil, UnboundFunctionErr{Name: fnCall.Caller.Name} + } +} + func overdraft( - s *programState, + env expressionEnv, r parser.Range, args []Value, ) (Monetary, InterpreterError) { - err := s.checkFeatureFlag(flags.ExperimentalOverdraftFunctionFeatureFlag) + err := env.checkFeatureFlag(flags.ExperimentalOverdraftFunctionFeatureFlag) if err != nil { return Monetary{}, err } @@ -27,7 +78,7 @@ func overdraft( } // overdraft call doesn't handle colors - balance_, err := getBalance(s, account, asset) + balance_, err := env.getBalance(account, asset) if err != nil { return Monetary{}, err } @@ -48,7 +99,7 @@ func overdraft( } func meta( - s *programState, + env expressionEnv, rng parser.Range, args []Value, ) (string, InterpreterError) { @@ -61,16 +112,10 @@ func meta( return "", err } - meta, fetchMetaErr := s.Store.GetAccountsMetadata(s.ctx, MetadataQuery{ - {Account: account.Name, Scope: account.Scope, Keys: []string{string(key)}}, - }) - if fetchMetaErr != nil { - return "", QueryMetadataError{WrappedError: fetchMetaErr} + value, ok, err := env.getMetadata(account, string(key)) + if err != nil { + return "", err } - s.CachedAccountsMeta = FromAccountsMetadataRows(meta) - - // body - value, ok := s.CachedAccountsMeta.Get(account.Name, account.Scope, string(key)) if !ok { return "", MetadataNotFound{ @@ -85,7 +130,7 @@ func meta( } func balance( - s *programState, + env expressionEnv, r parser.Range, args []Value, ) (Monetary, InterpreterError) { @@ -101,7 +146,7 @@ func balance( // body // balance call doesn't handle colors - balance, err := getBalance(s, account, asset) + balance, err := env.getBalance(account, asset) if err != nil { return Monetary{}, err } @@ -124,11 +169,11 @@ func balance( } func getAsset( - s *programState, + env expressionEnv, r parser.Range, args []Value, ) (Value, InterpreterError) { - err := s.checkFeatureFlag(flags.ExperimentalGetAssetFunctionFeatureFlag) + err := env.checkFeatureFlag(flags.ExperimentalGetAssetFunctionFeatureFlag) if err != nil { return nil, err } @@ -144,11 +189,11 @@ func getAsset( } func getAmount( - s *programState, + env expressionEnv, r parser.Range, args []Value, ) (Value, InterpreterError) { - err := s.checkFeatureFlag(flags.ExperimentalGetAmountFunctionFeatureFlag) + err := env.checkFeatureFlag(flags.ExperimentalGetAmountFunctionFeatureFlag) if err != nil { return nil, err } @@ -164,11 +209,11 @@ func getAmount( } func scoped( - s *programState, + env expressionEnv, r parser.Range, args []Value, ) (Value, InterpreterError) { - err := s.checkFeatureFlag(flags.ExperimentalScopedFunction) + err := env.checkFeatureFlag(flags.ExperimentalScopedFunction) if err != nil { return nil, err } diff --git a/internal/interpreter/infix_overloads.go b/internal/interpreter/infix_overloads.go index e7a47cf2..23cfe051 100644 --- a/internal/interpreter/infix_overloads.go +++ b/internal/interpreter/infix_overloads.go @@ -7,15 +7,15 @@ import ( ) type opAdd interface { - evalAdd(st *programState, other parser.ValueExpr) (Value, InterpreterError) + evalAdd(env expressionEnv, other parser.ValueExpr) (Value, InterpreterError) } var _ opAdd = (*MonetaryInt)(nil) var _ opAdd = (*Monetary)(nil) -func (m MonetaryInt) evalAdd(st *programState, other parser.ValueExpr) (Value, InterpreterError) { +func (m MonetaryInt) evalAdd(env expressionEnv, other parser.ValueExpr) (Value, InterpreterError) { leftBi := big.Int(m) - m2, err := evaluateExprAs(st, other, expectNumber) + m2, err := evaluateExprAs(env, other, expectNumber) if err != nil { return nil, err } @@ -25,8 +25,8 @@ func (m MonetaryInt) evalAdd(st *programState, other parser.ValueExpr) (Value, I return MonetaryInt(*sum), nil } -func (m Monetary) evalAdd(st *programState, other parser.ValueExpr) (Value, InterpreterError) { - m2, err := evaluateExprAs(st, other, expectMonetary) +func (m Monetary) evalAdd(env expressionEnv, other parser.ValueExpr) (Value, InterpreterError) { + m2, err := evaluateExprAs(env, other, expectMonetary) if err != nil { return nil, err } @@ -46,15 +46,15 @@ func (m Monetary) evalAdd(st *programState, other parser.ValueExpr) (Value, Inte } type opSub interface { - evalSub(st *programState, other parser.ValueExpr) (Value, InterpreterError) + evalSub(env expressionEnv, other parser.ValueExpr) (Value, InterpreterError) } var _ opSub = (*MonetaryInt)(nil) var _ opSub = (*Monetary)(nil) -func (m MonetaryInt) evalSub(st *programState, other parser.ValueExpr) (Value, InterpreterError) { +func (m MonetaryInt) evalSub(env expressionEnv, other parser.ValueExpr) (Value, InterpreterError) { leftBi := big.Int(m) - m2, err := evaluateExprAs(st, other, expectNumber) + m2, err := evaluateExprAs(env, other, expectNumber) if err != nil { return nil, err } @@ -63,8 +63,8 @@ func (m MonetaryInt) evalSub(st *programState, other parser.ValueExpr) (Value, I return MonetaryInt(*sum), nil } -func (m Monetary) evalSub(st *programState, other parser.ValueExpr) (Value, InterpreterError) { - m2, err := evaluateExprAs(st, other, expectMonetary) +func (m Monetary) evalSub(env expressionEnv, other parser.ValueExpr) (Value, InterpreterError) { + m2, err := evaluateExprAs(env, other, expectMonetary) if err != nil { return nil, err } @@ -84,19 +84,19 @@ func (m Monetary) evalSub(st *programState, other parser.ValueExpr) (Value, Inte } type opNeg interface { - evalNeg(st *programState) (Value, InterpreterError) + evalNeg(env expressionEnv) (Value, InterpreterError) } var _ opNeg = (*MonetaryInt)(nil) var _ opNeg = (*Monetary)(nil) -func (m MonetaryInt) evalNeg(st *programState) (Value, InterpreterError) { +func (m MonetaryInt) evalNeg(env expressionEnv) (Value, InterpreterError) { m1 := big.Int(m) neg := new(big.Int).Neg(&m1) return MonetaryInt(*neg), nil } -func (m Monetary) evalNeg(st *programState) (Value, InterpreterError) { +func (m Monetary) evalNeg(env expressionEnv) (Value, InterpreterError) { m1 := big.Int(m.Amount) neg := new(big.Int).Neg(&m1) return Monetary{ diff --git a/internal/interpreter/interpreter.go b/internal/interpreter/interpreter.go index df5bf5d1..31bfda81 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -113,55 +113,27 @@ func parseVar(type_ string, rawValue string, r parser.Range) (Value, Interpreter func (s *programState) evaluateVarOrigin(type_ string, expr parser.ValueExpr) (Value, InterpreterError) { if fnCall, ok := expr.(*parser.FnCall); ok { - return s.handleFnCall(&type_, *fnCall) + return evaluateFnCall(s, &type_, *fnCall) } - return s.evaluateExpr(expr) + return evaluateExpr(s, expr) } -func (s *programState) handleFnCall(type_ *string, fnCall parser.FnCall) (Value, InterpreterError) { - args, err := s.evaluateExpressions(fnCall.Args) - if err != nil { - return nil, err - } - - switch fnCall.Caller.Name { - case analysis.FnVarOriginMeta: - if type_ == nil { - return nil, InvalidNestedMeta{} - } - - rawValue, err := meta(s, fnCall.Range, args) - if err != nil { - return nil, err - } - return parseVar(*type_, rawValue, fnCall.Range) - - case analysis.FnVarOriginBalance: - monetary, err := balance(s, fnCall.Range, args) - if err != nil { - return nil, err - } - return monetary, nil - - case analysis.FnVarOriginOverdraft: - monetary, err := overdraft(s, fnCall.Range, args) - if err != nil { - return nil, err - } - return monetary, nil - - case analysis.FnVarOriginGetAsset: - return getAsset(s, fnCall.Range, args) - case analysis.FnVarOriginGetAmount: - return getAmount(s, fnCall.Range, args) - case analysis.FnVarOriginScoped: - return scoped(s, fnCall.Range, args) +func (s *programState) getVariable(name string) Value { + return s.ParsedVars[name] +} - default: - return nil, UnboundFunctionErr{Name: fnCall.Caller.Name} +func (s *programState) getMetadata(account AccountAddress, key string) (string, bool, InterpreterError) { + rows, fetchMetaErr := s.Store.GetAccountsMetadata(s.ctx, MetadataQuery{ + {Account: account.Name, Scope: account.Scope, Keys: []string{key}}, + }) + if fetchMetaErr != nil { + return "", false, QueryMetadataError{WrappedError: fetchMetaErr} } + s.CachedAccountsMeta = FromAccountsMetadataRows(rows) + value, ok := s.CachedAccountsMeta.Get(account.Name, account.Scope, key) + return value, ok, nil } func (s *programState) parseVars(varDeclrs []parser.VarDeclaration, rawVars map[string]string) InterpreterError { @@ -403,7 +375,7 @@ func (st *programState) pushReceiver(name AccountAddress, monetary *big.Int) { func (st *programState) runStatement(statement parser.Statement) InterpreterError { switch statement := statement.(type) { case *parser.FnCall: - args, err := st.evaluateExpressions(statement.Args) + args, err := evaluateExpressions(st, statement.Args) if err != nil { return err } @@ -1033,8 +1005,8 @@ func (s *programState) makeAllotment(monetary *big.Int, items []parser.Allotment } // Utility function to get the balance -func getBalance( - s *programState, +// getBalance implements expressionEnv: the raw (possibly negative) balance. +func (s *programState) getBalance( account AccountAddress, asset Asset, ) (*big.Int, InterpreterError) { @@ -1116,12 +1088,20 @@ func ParsePortionSpecific(input string) (*big.Rat, InterpreterError) { } func (s programState) checkFeatureFlag(flag string) InterpreterError { - _, ok := s.FeatureFlags[flag] - if ok { + if hasFeatureFlag(s.FeatureFlags, flag) { return nil - } else { - return ExperimentalFeature{FlagName: flag} } + return ExperimentalFeature{FlagName: flag} +} + +// hasFeatureFlag reports whether flag is enabled. A nil set enables every +// feature (used e.g. by dependency resolution, which doesn't gate features). +func hasFeatureFlag(featureFlags map[string]struct{}, flag string) bool { + if featureFlags == nil { + return true + } + _, ok := featureFlags[flag] + return ok } /* From 5e0ba4a3de08d63d3952fec34e7bec9e9a7bf1be Mon Sep 17 00:00:00 2001 From: ascandone Date: Thu, 2 Jul 2026 15:53:31 +0200 Subject: [PATCH 02/12] feat: claude-generated visitor --- internal/interpreter/ast_visitor.go | 393 ++++++++++++++++++++++++++++ internal/interpreter/interpreter.go | 8 +- 2 files changed, 397 insertions(+), 4 deletions(-) create mode 100644 internal/interpreter/ast_visitor.go diff --git a/internal/interpreter/ast_visitor.go b/internal/interpreter/ast_visitor.go new file mode 100644 index 00000000..04652c66 --- /dev/null +++ b/internal/interpreter/ast_visitor.go @@ -0,0 +1,393 @@ +package interpreter + +import ( + "errors" + + "github.com/formancehq/numscript/internal/analysis" + "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/utils" +) + +// errStopWalk is a sentinel that a hook can return to abort the walk without it +// being treated as a failure by the caller. +var errStopWalk = errors.New("stop walk") + +type astVisitor struct { + // Any hook may be nil (then it is skipped). Returning a non-nil error + // aborts the whole walk and that error is propagated to walkProgram's + // caller. Hooks fire pre-order (parent before children). + OnVariable func(*parser.Variable) error + OnAsset func(*parser.AssetLiteral) error + OnNumber func(*parser.NumberLiteral) error + OnString func(*parser.StringLiteral) error + OnPercentage func(*parser.PercentageLiteral) error + OnMonetary func(*parser.MonetaryLiteral) error + OnAccount func(*parser.AccountInterpLiteral) error + OnBinaryInfix func(*parser.BinaryInfix) error + OnPrefix func(*parser.Prefix) error + OnFnCall func(*parser.FnCall) error + + // OnMeta fires for meta() calls in place of OnFnCall. originType is the + // declared var type when the call is directly a var origin (the only place + // meta() is valid), and nil when meta() appears anywhere else — i.e. nested + // in a sub-expression or in a statement. + OnMeta func(originType *string, fnCall *parser.FnCall) error + + OnSource func(parser.Source) error + OnDestination func(parser.Destination) error + OnStatement func(parser.Statement) error + OnSaveStatement func(*parser.SaveStatement) error + OnVarDecl func(parser.VarDeclaration) error +} + +func (v astVisitor) walkProgram(program parser.Program) error { + if program.Vars != nil { + for _, decl := range program.Vars.Declarations { + if err := v.walkVarDecl(decl); err != nil { + return err + } + } + } + for _, stmt := range program.Statements { + if err := v.walkStatement(stmt); err != nil { + return err + } + } + return nil +} + +func (v astVisitor) walkVarDecl(decl parser.VarDeclaration) error { + if v.OnVarDecl != nil { + if err := v.OnVarDecl(decl); err != nil { + return err + } + } + if decl.Origin == nil { + return nil + } + origin := *decl.Origin + + // A meta() that is directly the var origin is the only valid meta position: + // route it to OnMeta with the declared type and walk its args, bypassing + // the generic (nil-type) meta dispatch in walkFnCall. + if fnCall, ok := origin.(*parser.FnCall); ok && fnCall.Caller.Name == analysis.FnVarOriginMeta { + if v.OnMeta != nil { + if err := v.OnMeta(&decl.Type.Name, fnCall); err != nil { + return err + } + } + return v.walkFnCallArgs(fnCall) + } + + return v.walkValueExpr(origin) +} + +func (v astVisitor) walkStatement(statement parser.Statement) error { + if v.OnStatement != nil { + if err := v.OnStatement(statement); err != nil { + return err + } + } + switch statement := statement.(type) { + case *parser.FnCall: + return v.walkFnCall(statement) + + case *parser.SendStatement: + if err := v.walkSentValue(statement.SentValue); err != nil { + return err + } + if err := v.walkSource(statement.Source); err != nil { + return err + } + return v.walkDestination(statement.Destination) + + case *parser.SaveStatement: + if v.OnSaveStatement != nil { + if err := v.OnSaveStatement(statement); err != nil { + return err + } + } + if err := v.walkSentValue(statement.SentValue); err != nil { + return err + } + return v.walkValueExpr(statement.Account) + + default: + utils.NonExhaustiveMatchPanic[any](statement) + return nil + } +} + +func (v astVisitor) walkSentValue(sentValue parser.SentValue) error { + switch sentValue := sentValue.(type) { + case *parser.SentValueLiteral: + return v.walkValueExpr(sentValue.Monetary) + case *parser.SentValueAll: + return v.walkValueExpr(sentValue.Asset) + default: + utils.NonExhaustiveMatchPanic[any](sentValue) + return nil + } +} + +func (v astVisitor) walkSource(source parser.Source) error { + if v.OnSource != nil { + if err := v.OnSource(source); err != nil { + return err + } + } + switch source := source.(type) { + case *parser.SourceAccount: + if source.Color != nil { + if err := v.walkValueExpr(source.Color); err != nil { + return err + } + } + return v.walkValueExpr(source.ValueExpr) + + case *parser.SourceInorder: + for _, sub := range source.Sources { + if err := v.walkSource(sub); err != nil { + return err + } + } + return nil + + case *parser.SourceOneof: + for _, sub := range source.Sources { + if err := v.walkSource(sub); err != nil { + return err + } + } + return nil + + case *parser.SourceAllotment: + for _, item := range source.Items { + if err := v.walkAllotmentValue(item.Allotment); err != nil { + return err + } + if err := v.walkSource(item.From); err != nil { + return err + } + } + return nil + + case *parser.SourceCapped: + if err := v.walkValueExpr(source.Cap); err != nil { + return err + } + return v.walkSource(source.From) + + case *parser.SourceOverdraft: + if source.Color != nil { + if err := v.walkValueExpr(source.Color); err != nil { + return err + } + } + if err := v.walkValueExpr(source.Address); err != nil { + return err + } + if source.Bounded != nil { + return v.walkValueExpr(*source.Bounded) + } + return nil + + case *parser.SourceWithScaling: + if err := v.walkValueExpr(source.Address); err != nil { + return err + } + return v.walkValueExpr(source.Through) + + default: + utils.NonExhaustiveMatchPanic[any](source) + return nil + } +} + +func (v astVisitor) walkDestination(destination parser.Destination) error { + if v.OnDestination != nil { + if err := v.OnDestination(destination); err != nil { + return err + } + } + switch destination := destination.(type) { + case *parser.DestinationAccount: + return v.walkValueExpr(destination.ValueExpr) + + case *parser.DestinationInorder: + for _, clause := range destination.Clauses { + if err := v.walkCappedKeptOrDestination(clause); err != nil { + return err + } + } + return v.walkKeptOrDestination(destination.Remaining) + + case *parser.DestinationOneof: + for _, clause := range destination.Clauses { + if err := v.walkCappedKeptOrDestination(clause); err != nil { + return err + } + } + return v.walkKeptOrDestination(destination.Remaining) + + case *parser.DestinationAllotment: + for _, item := range destination.Items { + if err := v.walkAllotmentValue(item.Allotment); err != nil { + return err + } + if err := v.walkKeptOrDestination(item.To); err != nil { + return err + } + } + return nil + + default: + utils.NonExhaustiveMatchPanic[any](destination) + return nil + } +} + +func (v astVisitor) walkCappedKeptOrDestination(clause parser.CappedKeptOrDestination) error { + if err := v.walkValueExpr(clause.Cap); err != nil { + return err + } + return v.walkKeptOrDestination(clause.To) +} + +func (v astVisitor) walkKeptOrDestination(keptOrDestination parser.KeptOrDestination) error { + switch keptOrDestination := keptOrDestination.(type) { + case *parser.DestinationKept: + return nil + case *parser.DestinationTo: + return v.walkDestination(keptOrDestination.Destination) + default: + utils.NonExhaustiveMatchPanic[any](keptOrDestination) + return nil + } +} + +func (v astVisitor) walkAllotmentValue(allotmentValue parser.AllotmentValue) error { + switch allotmentValue := allotmentValue.(type) { + case *parser.ValueExprAllotment: + return v.walkValueExpr(allotmentValue.Value) + case *parser.RemainingAllotment: + return nil + default: + utils.NonExhaustiveMatchPanic[any](allotmentValue) + return nil + } +} + +func (v astVisitor) walkValueExpr(valueExpr parser.ValueExpr) error { + switch valueExpr := valueExpr.(type) { + case *parser.Variable: + if v.OnVariable != nil { + return v.OnVariable(valueExpr) + } + return nil + + case *parser.AssetLiteral: + if v.OnAsset != nil { + return v.OnAsset(valueExpr) + } + return nil + + case *parser.NumberLiteral: + if v.OnNumber != nil { + return v.OnNumber(valueExpr) + } + return nil + + case *parser.StringLiteral: + if v.OnString != nil { + return v.OnString(valueExpr) + } + return nil + + case *parser.PercentageLiteral: + if v.OnPercentage != nil { + return v.OnPercentage(valueExpr) + } + return nil + + case *parser.MonetaryLiteral: + if v.OnMonetary != nil { + if err := v.OnMonetary(valueExpr); err != nil { + return err + } + } + if err := v.walkValueExpr(valueExpr.Asset); err != nil { + return err + } + return v.walkValueExpr(valueExpr.Amount) + + case *parser.AccountInterpLiteral: + if v.OnAccount != nil { + if err := v.OnAccount(valueExpr); err != nil { + return err + } + } + // account name parts can only be text or *Variable; the variables are + // worth descending into so OnVariable fires for interpolations. + for _, part := range valueExpr.Parts { + if variable, ok := part.(*parser.Variable); ok && v.OnVariable != nil { + if err := v.OnVariable(variable); err != nil { + return err + } + } + } + return nil + + case *parser.BinaryInfix: + if v.OnBinaryInfix != nil { + if err := v.OnBinaryInfix(valueExpr); err != nil { + return err + } + } + if err := v.walkValueExpr(valueExpr.Left); err != nil { + return err + } + return v.walkValueExpr(valueExpr.Right) + + case *parser.Prefix: + if v.OnPrefix != nil { + if err := v.OnPrefix(valueExpr); err != nil { + return err + } + } + return v.walkValueExpr(valueExpr.Expr) + + case *parser.FnCall: + return v.walkFnCall(valueExpr) + + default: + utils.NonExhaustiveMatchPanic[any](valueExpr) + return nil + } +} + +func (v astVisitor) walkFnCall(fnCall *parser.FnCall) error { + if fnCall.Caller.Name == analysis.FnVarOriginMeta { + // Reached through the generic walk, so this meta() is never a top-level + // var origin (those are intercepted in walkVarDecl): its type is nil. + if v.OnMeta != nil { + if err := v.OnMeta(nil, fnCall); err != nil { + return err + } + } + } else if v.OnFnCall != nil { + if err := v.OnFnCall(fnCall); err != nil { + return err + } + } + return v.walkFnCallArgs(fnCall) +} + +func (v astVisitor) walkFnCallArgs(fnCall *parser.FnCall) error { + for _, arg := range fnCall.Args { + if err := v.walkValueExpr(arg); err != nil { + return err + } + } + return nil +} diff --git a/internal/interpreter/interpreter.go b/internal/interpreter/interpreter.go index 31bfda81..c88b3b53 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -111,12 +111,12 @@ func parseVar(type_ string, rawValue string, r parser.Range) (Value, Interpreter } -func (s *programState) evaluateVarOrigin(type_ string, expr parser.ValueExpr) (Value, InterpreterError) { +func evaluateVarOrigin(env expressionEnv, type_ string, expr parser.ValueExpr) (Value, InterpreterError) { if fnCall, ok := expr.(*parser.FnCall); ok { - return evaluateFnCall(s, &type_, *fnCall) + return evaluateFnCall(env, &type_, *fnCall) } - return evaluateExpr(s, expr) + return evaluateExpr(env, expr) } func (s *programState) getVariable(name string) Value { @@ -150,7 +150,7 @@ func (s *programState) parseVars(varDeclrs []parser.VarDeclaration, rawVars map[ } s.ParsedVars[varsDecl.Name.Name] = parsed } else { - value, err := s.evaluateVarOrigin(varsDecl.Type.Name, *varsDecl.Origin) + value, err := evaluateVarOrigin(s, varsDecl.Type.Name, *varsDecl.Origin) if err != nil { return err } From f1f70450b183191d7f49f430e4d20fa2a1139e5e Mon Sep 17 00:00:00 2001 From: ascandone Date: Thu, 2 Jul 2026 18:23:26 +0200 Subject: [PATCH 03/12] refactor: reworked evalEnv --- internal/interpreter/batch_balances_query.go | 16 +- internal/interpreter/evaluate_expr.go | 111 +++++++++++-- internal/interpreter/function_exprs.go | 14 +- internal/interpreter/infix_overloads.go | 18 +-- internal/interpreter/interpreter.go | 155 +++++-------------- 5 files changed, 160 insertions(+), 154 deletions(-) diff --git a/internal/interpreter/batch_balances_query.go b/internal/interpreter/batch_balances_query.go index 15cb2070..33f5e1fe 100644 --- a/internal/interpreter/batch_balances_query.go +++ b/internal/interpreter/batch_balances_query.go @@ -15,7 +15,7 @@ func (st *programState) findBalancesQueriesInStatement(statement parser.Statemen return nil case *parser.SaveStatement: - asset, _, err := st.evaluateSentAmt(statement.SentValue) + asset, _, err := evaluateSentAmt(&st.evalEnv, statement.SentValue) if err != nil { return err } @@ -27,7 +27,7 @@ func (st *programState) findBalancesQueriesInStatement(statement parser.Statemen // // this would mean that the "save" statement was not needed in the first place, // so preventing this query would hardly be an useful optimization - account, err := evaluateExprAs(st, statement.Account, expectAccount) + account, err := evaluateExprAs(&st.evalEnv, statement.Account, expectAccount) if err != nil { return err } @@ -35,7 +35,7 @@ func (st *programState) findBalancesQueriesInStatement(statement parser.Statemen return nil case *parser.SendStatement: - asset, _, err := st.evaluateSentAmt(statement.SentValue) + asset, _, err := evaluateSentAmt(&st.evalEnv, statement.SentValue) if err != nil { return err } @@ -90,12 +90,12 @@ func (st *programState) runBalancesQuery() error { func (st *programState) findBalancesQueries(source parser.Source) InterpreterError { switch source := source.(type) { case *parser.SourceAccount: - account, err := evaluateExprAs(st, source.ValueExpr, expectAccount) + account, err := evaluateExprAs(&st.evalEnv, source.ValueExpr, expectAccount) if err != nil { return err } - color, err := evaluateOptExprAs(st, source.Color, expectString) + color, err := evaluateOptExprAs(&st.evalEnv, source.Color, expectString) if err != nil { return err } @@ -104,7 +104,7 @@ func (st *programState) findBalancesQueries(source parser.Source) InterpreterErr return nil case *parser.SourceWithScaling: - account, err := evaluateExprAs(st, source.Address, expectAccount) + account, err := evaluateExprAs(&st.evalEnv, source.Address, expectAccount) if err != nil { return err } @@ -119,11 +119,11 @@ func (st *programState) findBalancesQueries(source parser.Source) InterpreterErr return nil } - account, err := evaluateExprAs(st, source.Address, expectAccount) + account, err := evaluateExprAs(&st.evalEnv, source.Address, expectAccount) if err != nil { return err } - color, err := evaluateOptExprAs(st, source.Color, expectString) + color, err := evaluateOptExprAs(&st.evalEnv, source.Color, expectString) if err != nil { return err } diff --git a/internal/interpreter/evaluate_expr.go b/internal/interpreter/evaluate_expr.go index 48d4775f..aa025f29 100644 --- a/internal/interpreter/evaluate_expr.go +++ b/internal/interpreter/evaluate_expr.go @@ -1,6 +1,7 @@ package interpreter import ( + "context" "math/big" "strings" @@ -9,14 +10,94 @@ import ( "github.com/formancehq/numscript/internal/utils" ) -type expressionEnv interface { - getVariable(name string) Value - checkFeatureFlag(flag string) InterpreterError - getBalance(account AccountAddress, asset Asset) (*big.Int, InterpreterError) - getMetadata(account AccountAddress, key string) (value string, ok bool, err InterpreterError) +type evalEnv struct { + ctx context.Context + Store Store + FeatureFlags map[string]struct{} + + vars map[string]Value + CachedBalances InternalBalances + CachedAccountsMeta InternalAccountsMetadata +} + +func newEvalEnv(ctx context.Context, store Store, featureFlags map[string]struct{}, varDecls *parser.VarDeclarations, rawVars map[string]string) (evalEnv, InterpreterError) { + env := evalEnv{ + ctx: ctx, + Store: store, + FeatureFlags: featureFlags, + vars: map[string]Value{}, + CachedBalances: InternalBalances{}, + CachedAccountsMeta: InternalAccountsMetadata{}, + } + if err := bindVars(&env, varDecls, rawVars); err != nil { + return evalEnv{}, err + } + return env, nil +} + +func (env *evalEnv) checkFeatureFlag(flag string) InterpreterError { + // a nil set enables every feature (e.g. dependency resolution) + if env.FeatureFlags == nil { + return nil + } + if _, ok := env.FeatureFlags[flag]; ok { + return nil + } + return ExperimentalFeature{FlagName: flag} +} + +func (env *evalEnv) getBalance(account AccountAddress, asset Asset) (*big.Int, InterpreterError) { + color := String("") + if account.Name != "world" && !env.CachedBalances.has(account, string(asset), string(color)) { + rows, err := env.Store.GetBalances(env.ctx, BalanceQuery{ + {Account: account.Name, Asset: string(asset), Color: string(color), Scope: account.Scope}, + }) + if err != nil { + return nil, QueryBalanceError{WrappedError: err} + } + env.CachedBalances.Merge(rows) + } + return env.CachedBalances.fetchBalance(account, asset, color), nil +} + +func (env *evalEnv) getMetadata(account AccountAddress, key string) (string, bool, InterpreterError) { + rows, err := env.Store.GetAccountsMetadata(env.ctx, MetadataQuery{ + {Account: account.Name, Scope: account.Scope, Keys: []string{key}}, + }) + if err != nil { + return "", false, QueryMetadataError{WrappedError: err} + } + env.CachedAccountsMeta = FromAccountsMetadataRows(rows) + + value, ok := env.CachedAccountsMeta.Get(account.Name, account.Scope, key) + return value, ok, nil +} + +func bindVars(env *evalEnv, varDecls *parser.VarDeclarations, rawVars map[string]string) InterpreterError { + if varDecls == nil { + return nil + } + for _, decl := range varDecls.Declarations { + var value Value + var err InterpreterError + if decl.Origin == nil { + raw, ok := rawVars[decl.Name.Name] + if !ok { + return MissingVariableErr{Name: decl.Name.Name} + } + value, err = parseVar(decl.Type.Name, raw, decl.Type.Range) + } else { + value, err = evaluateVarOrigin(env, decl.Type.Name, *decl.Origin) + } + if err != nil { + return err + } + env.vars[decl.Name.Name] = value + } + return nil } -func evaluateExpr(env expressionEnv, expr parser.ValueExpr) (Value, InterpreterError) { +func evaluateExpr(env *evalEnv, expr parser.ValueExpr) (Value, InterpreterError) { switch expr := expr.(type) { case *parser.AssetLiteral: return Asset(expr.Asset), nil @@ -66,7 +147,7 @@ func evaluateExpr(env expressionEnv, expr parser.ValueExpr) (Value, InterpreterE return Monetary{Asset: asset, Amount: amount}, nil case *parser.Variable: - value := env.getVariable(expr.Name) + value := env.vars[expr.Name] if value == nil { return nil, UnboundVariableErr{ Name: expr.Name, @@ -111,7 +192,7 @@ func evaluateExpr(env expressionEnv, expr parser.ValueExpr) (Value, InterpreterE } } -func evaluateOptExprAs[T any](env expressionEnv, expr parser.ValueExpr, expect func(Value, parser.Range) (T, InterpreterError)) (T, InterpreterError) { +func evaluateOptExprAs[T any](env *evalEnv, expr parser.ValueExpr, expect func(Value, parser.Range) (T, InterpreterError)) (T, InterpreterError) { var t T if expr == nil { return t, nil @@ -119,7 +200,7 @@ func evaluateOptExprAs[T any](env expressionEnv, expr parser.ValueExpr, expect f return evaluateExprAs(env, expr, expect) } -func evaluateExprAs[T any](env expressionEnv, expr parser.ValueExpr, expect func(Value, parser.Range) (T, InterpreterError)) (T, InterpreterError) { +func evaluateExprAs[T any](env *evalEnv, expr parser.ValueExpr, expect func(Value, parser.Range) (T, InterpreterError)) (T, InterpreterError) { var default_ T value, err := evaluateExpr(env, expr) if err != nil { @@ -134,7 +215,7 @@ func evaluateExprAs[T any](env expressionEnv, expr parser.ValueExpr, expect func return res, nil } -func evaluateExpressions(env expressionEnv, literals []parser.ValueExpr) ([]Value, InterpreterError) { +func evaluateExpressions(env *evalEnv, literals []parser.ValueExpr) ([]Value, InterpreterError) { var values []Value for _, argLit := range literals { value, err := evaluateExpr(env, argLit) @@ -147,7 +228,7 @@ func evaluateExpressions(env expressionEnv, literals []parser.ValueExpr) ([]Valu } func (s *programState) evaluateColor(colorExpr parser.ValueExpr) (String, InterpreterError) { - color, err := evaluateOptExprAs(s, colorExpr, expectString) + color, err := evaluateOptExprAs(&s.evalEnv, colorExpr, expectString) if err != nil { return "", err } @@ -163,7 +244,7 @@ func (s *programState) evaluateColor(colorExpr parser.ValueExpr) (String, Interp return color, nil } -func plusOp(env expressionEnv, left parser.ValueExpr, right parser.ValueExpr) (Value, InterpreterError) { +func plusOp(env *evalEnv, left parser.ValueExpr, right parser.ValueExpr) (Value, InterpreterError) { leftValue, err := evaluateExprAs(env, left, expectOneOf( expectMapped(expectMonetary, func(m Monetary) opAdd { @@ -183,7 +264,7 @@ func plusOp(env expressionEnv, left parser.ValueExpr, right parser.ValueExpr) (V return leftValue.evalAdd(env, right) } -func subOp(env expressionEnv, left parser.ValueExpr, right parser.ValueExpr) (Value, InterpreterError) { +func subOp(env *evalEnv, left parser.ValueExpr, right parser.ValueExpr) (Value, InterpreterError) { leftValue, err := evaluateExprAs(env, left, expectOneOf( expectMapped(expectMonetary, func(m Monetary) opSub { return m @@ -200,7 +281,7 @@ func subOp(env expressionEnv, left parser.ValueExpr, right parser.ValueExpr) (Va return leftValue.evalSub(env, right) } -func divOp(env expressionEnv, rng parser.Range, left parser.ValueExpr, right parser.ValueExpr) (Value, InterpreterError) { +func divOp(env *evalEnv, rng parser.Range, left parser.ValueExpr, right parser.ValueExpr) (Value, InterpreterError) { leftValue, err := evaluateExprAs(env, left, expectNumber) if err != nil { return nil, err @@ -225,7 +306,7 @@ func divOp(env expressionEnv, rng parser.Range, left parser.ValueExpr, right par return Portion(*rat), nil } -func unaryNegOp(env expressionEnv, expr parser.ValueExpr) (Value, InterpreterError) { +func unaryNegOp(env *evalEnv, expr parser.ValueExpr) (Value, InterpreterError) { evExpr, err := evaluateExprAs(env, expr, expectOneOf( expectMapped(expectMonetary, func(m Monetary) opNeg { return m diff --git a/internal/interpreter/function_exprs.go b/internal/interpreter/function_exprs.go index f9787e5c..73efeb11 100644 --- a/internal/interpreter/function_exprs.go +++ b/internal/interpreter/function_exprs.go @@ -8,7 +8,7 @@ import ( "github.com/formancehq/numscript/internal/parser" ) -func evaluateFnCall(env expressionEnv, type_ *string, fnCall parser.FnCall) (Value, InterpreterError) { +func evaluateFnCall(env *evalEnv, type_ *string, fnCall parser.FnCall) (Value, InterpreterError) { if type_ == nil { if err := env.checkFeatureFlag(flags.ExperimentalMidScriptFunctionCall); err != nil { return nil, err @@ -59,7 +59,7 @@ func evaluateFnCall(env expressionEnv, type_ *string, fnCall parser.FnCall) (Val } func overdraft( - env expressionEnv, + env *evalEnv, r parser.Range, args []Value, ) (Monetary, InterpreterError) { @@ -99,7 +99,7 @@ func overdraft( } func meta( - env expressionEnv, + env *evalEnv, rng parser.Range, args []Value, ) (string, InterpreterError) { @@ -130,7 +130,7 @@ func meta( } func balance( - env expressionEnv, + env *evalEnv, r parser.Range, args []Value, ) (Monetary, InterpreterError) { @@ -169,7 +169,7 @@ func balance( } func getAsset( - env expressionEnv, + env *evalEnv, r parser.Range, args []Value, ) (Value, InterpreterError) { @@ -189,7 +189,7 @@ func getAsset( } func getAmount( - env expressionEnv, + env *evalEnv, r parser.Range, args []Value, ) (Value, InterpreterError) { @@ -209,7 +209,7 @@ func getAmount( } func scoped( - env expressionEnv, + env *evalEnv, r parser.Range, args []Value, ) (Value, InterpreterError) { diff --git a/internal/interpreter/infix_overloads.go b/internal/interpreter/infix_overloads.go index 23cfe051..411f7e62 100644 --- a/internal/interpreter/infix_overloads.go +++ b/internal/interpreter/infix_overloads.go @@ -7,13 +7,13 @@ import ( ) type opAdd interface { - evalAdd(env expressionEnv, other parser.ValueExpr) (Value, InterpreterError) + evalAdd(env *evalEnv, other parser.ValueExpr) (Value, InterpreterError) } var _ opAdd = (*MonetaryInt)(nil) var _ opAdd = (*Monetary)(nil) -func (m MonetaryInt) evalAdd(env expressionEnv, other parser.ValueExpr) (Value, InterpreterError) { +func (m MonetaryInt) evalAdd(env *evalEnv, other parser.ValueExpr) (Value, InterpreterError) { leftBi := big.Int(m) m2, err := evaluateExprAs(env, other, expectNumber) if err != nil { @@ -25,7 +25,7 @@ func (m MonetaryInt) evalAdd(env expressionEnv, other parser.ValueExpr) (Value, return MonetaryInt(*sum), nil } -func (m Monetary) evalAdd(env expressionEnv, other parser.ValueExpr) (Value, InterpreterError) { +func (m Monetary) evalAdd(env *evalEnv, other parser.ValueExpr) (Value, InterpreterError) { m2, err := evaluateExprAs(env, other, expectMonetary) if err != nil { return nil, err @@ -46,13 +46,13 @@ func (m Monetary) evalAdd(env expressionEnv, other parser.ValueExpr) (Value, Int } type opSub interface { - evalSub(env expressionEnv, other parser.ValueExpr) (Value, InterpreterError) + evalSub(env *evalEnv, other parser.ValueExpr) (Value, InterpreterError) } var _ opSub = (*MonetaryInt)(nil) var _ opSub = (*Monetary)(nil) -func (m MonetaryInt) evalSub(env expressionEnv, other parser.ValueExpr) (Value, InterpreterError) { +func (m MonetaryInt) evalSub(env *evalEnv, other parser.ValueExpr) (Value, InterpreterError) { leftBi := big.Int(m) m2, err := evaluateExprAs(env, other, expectNumber) if err != nil { @@ -63,7 +63,7 @@ func (m MonetaryInt) evalSub(env expressionEnv, other parser.ValueExpr) (Value, return MonetaryInt(*sum), nil } -func (m Monetary) evalSub(env expressionEnv, other parser.ValueExpr) (Value, InterpreterError) { +func (m Monetary) evalSub(env *evalEnv, other parser.ValueExpr) (Value, InterpreterError) { m2, err := evaluateExprAs(env, other, expectMonetary) if err != nil { return nil, err @@ -84,19 +84,19 @@ func (m Monetary) evalSub(env expressionEnv, other parser.ValueExpr) (Value, Int } type opNeg interface { - evalNeg(env expressionEnv) (Value, InterpreterError) + evalNeg(env *evalEnv) (Value, InterpreterError) } var _ opNeg = (*MonetaryInt)(nil) var _ opNeg = (*Monetary)(nil) -func (m MonetaryInt) evalNeg(env expressionEnv) (Value, InterpreterError) { +func (m MonetaryInt) evalNeg(env *evalEnv) (Value, InterpreterError) { m1 := big.Int(m) neg := new(big.Int).Neg(&m1) return MonetaryInt(*neg), nil } -func (m Monetary) evalNeg(env expressionEnv) (Value, InterpreterError) { +func (m Monetary) evalNeg(env *evalEnv) (Value, InterpreterError) { m1 := big.Int(m.Amount) neg := new(big.Int).Neg(&m1) return Monetary{ diff --git a/internal/interpreter/interpreter.go b/internal/interpreter/interpreter.go index c88b3b53..0caf6773 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -111,7 +111,7 @@ func parseVar(type_ string, rawValue string, r parser.Range) (Value, Interpreter } -func evaluateVarOrigin(env expressionEnv, type_ string, expr parser.ValueExpr) (Value, InterpreterError) { +func evaluateVarOrigin(env *evalEnv, type_ string, expr parser.ValueExpr) (Value, InterpreterError) { if fnCall, ok := expr.(*parser.FnCall); ok { return evaluateFnCall(env, &type_, *fnCall) } @@ -119,47 +119,6 @@ func evaluateVarOrigin(env expressionEnv, type_ string, expr parser.ValueExpr) ( return evaluateExpr(env, expr) } -func (s *programState) getVariable(name string) Value { - return s.ParsedVars[name] -} - -func (s *programState) getMetadata(account AccountAddress, key string) (string, bool, InterpreterError) { - rows, fetchMetaErr := s.Store.GetAccountsMetadata(s.ctx, MetadataQuery{ - {Account: account.Name, Scope: account.Scope, Keys: []string{key}}, - }) - if fetchMetaErr != nil { - return "", false, QueryMetadataError{WrappedError: fetchMetaErr} - } - s.CachedAccountsMeta = FromAccountsMetadataRows(rows) - - value, ok := s.CachedAccountsMeta.Get(account.Name, account.Scope, key) - return value, ok, nil -} - -func (s *programState) parseVars(varDeclrs []parser.VarDeclaration, rawVars map[string]string) InterpreterError { - for _, varsDecl := range varDeclrs { - if varsDecl.Origin == nil { - raw, ok := rawVars[varsDecl.Name.Name] - if !ok { - return MissingVariableErr{Name: varsDecl.Name.Name} - } - - parsed, err := parseVar(varsDecl.Type.Name, raw, varsDecl.Type.Range) - if err != nil { - return err - } - s.ParsedVars[varsDecl.Name.Name] = parsed - } else { - value, err := evaluateVarOrigin(s, varsDecl.Type.Name, *varsDecl.Origin) - if err != nil { - return err - } - s.ParsedVars[varsDecl.Name.Name] = value - } - } - return nil -} - const accountSegmentRegex = "[a-zA-Z0-9_-]+" var accountNameRegex = regexp.MustCompile("^" + accountSegmentRegex + "(:" + accountSegmentRegex + ")*$") @@ -209,25 +168,10 @@ func RunProgram( featureFlags map[string]struct{}, ) (*ExecutionResult, InterpreterError) { - st := programState{ - ParsedVars: make(map[string]Value), - TxMeta: make(map[string]Value), - CachedAccountsMeta: InternalAccountsMetadata{}, - CachedBalances: InternalBalances{}, - SetAccountsMeta: internalSetAccountsMeta{}, - Store: store, - Postings: make([]Posting, 0), - fundsQueue: newFundsQueue(nil), - - CurrentBalanceQuery: BalanceQuery{}, - ctx: ctx, - FeatureFlags: maps.Clone(featureFlags), - } - - if st.FeatureFlags == nil { - st.FeatureFlags = make(map[string]struct{}, len(program.Flags)) + flagSet := maps.Clone(featureFlags) + if flagSet == nil { + flagSet = make(map[string]struct{}, len(program.Flags)) } - for _, flag := range program.Flags { index := slices.Index(flags.AllFlags, flag.String) if index == -1 { @@ -235,15 +179,21 @@ func RunProgram( Feature: flag.String, } } + flagSet[flag.String] = struct{}{} + } - st.FeatureFlags[flag.String] = struct{}{} + env, err := newEvalEnv(ctx, store, flagSet, program.Vars, vars) + if err != nil { + return nil, err } - if program.Vars != nil { - err := st.parseVars(program.Vars.Declarations, vars) - if err != nil { - return nil, err - } + st := programState{ + evalEnv: env, + TxMeta: make(map[string]Value), + SetAccountsMeta: internalSetAccountsMeta{}, + Postings: make([]Posting, 0), + fundsQueue: newFundsQueue(nil), + CurrentBalanceQuery: BalanceQuery{}, } // preload balances before executing the script @@ -282,28 +232,20 @@ func RunProgram( } type programState struct { - ctx context.Context + evalEnv // Asset of the send statement currently being executed. // // its value is undefined outside of send statements execution CurrentAsset Asset - ParsedVars map[string]Value TxMeta map[string]Value Postings []Posting fundsQueue fundsQueue - Store Store - SetAccountsMeta internalSetAccountsMeta - CachedAccountsMeta InternalAccountsMetadata - CachedBalances InternalBalances - CurrentBalanceQuery BalanceQuery - - FeatureFlags map[string]struct{} } func (st *programState) pushSender(name AccountAddress, monetary MonetaryInt, color String) { @@ -375,7 +317,7 @@ func (st *programState) pushReceiver(name AccountAddress, monetary *big.Int) { func (st *programState) runStatement(statement parser.Statement) InterpreterError { switch statement := statement.(type) { case *parser.FnCall: - args, err := evaluateExpressions(st, statement.Args) + args, err := evaluateExpressions(&st.evalEnv, statement.Args) if err != nil { return err } @@ -402,12 +344,12 @@ func (st *programState) runStatement(statement parser.Statement) InterpreterErro } func (st *programState) runSaveStatement(saveStatement parser.SaveStatement) InterpreterError { - asset, amt, err := st.evaluateSentAmt(saveStatement.SentValue) + asset, amt, err := evaluateSentAmt(&st.evalEnv, saveStatement.SentValue) if err != nil { return err } - account, err := evaluateExprAs(st, saveStatement.Account, expectAccount) + account, err := evaluateExprAs(&st.evalEnv, saveStatement.Account, expectAccount) if err != nil { return err } @@ -441,7 +383,7 @@ func (st *programState) runSaveStatement(saveStatement parser.SaveStatement) Int func (st *programState) runSendStatement(statement parser.SendStatement) InterpreterError { switch sentValue := statement.SentValue.(type) { case *parser.SentValueAll: - asset, err := evaluateExprAs(st, sentValue.Asset, expectAsset) + asset, err := evaluateExprAs(&st.evalEnv, sentValue.Asset, expectAsset) if err != nil { return err } @@ -453,7 +395,7 @@ func (st *programState) runSendStatement(statement parser.SendStatement) Interpr return st.sendTo(statement.Destination, sentAmt) case *parser.SentValueLiteral: - monetary, err := evaluateExprAs(st, sentValue.Monetary, expectMonetary) + monetary, err := evaluateExprAs(&st.evalEnv, sentValue.Monetary, expectMonetary) if err != nil { return err } @@ -485,7 +427,7 @@ func (s *programState) takeAllFromAccount(accountLiteral parser.ValueExpr, overd } } - account, err := evaluateExprAs(s, accountLiteral, expectAccount) + account, err := evaluateExprAs(&s.evalEnv, accountLiteral, expectAccount) if err != nil { return nil, err } @@ -520,7 +462,7 @@ func (s *programState) takeAll(source parser.Source) (*big.Int, InterpreterError case *parser.SourceOverdraft: var cap *big.Int if source.Bounded != nil { - bounded, err := evaluateExprAs(s, *source.Bounded, expectMonetaryOfAsset(s.CurrentAsset)) + bounded, err := evaluateExprAs(&s.evalEnv, *source.Bounded, expectMonetaryOfAsset(s.CurrentAsset)) if err != nil { return nil, err } @@ -535,12 +477,12 @@ func (s *programState) takeAll(source parser.Source) (*big.Int, InterpreterError return nil, err } - account, err := evaluateExprAs(s, source.Address, expectAccount) + account, err := evaluateExprAs(&s.evalEnv, source.Address, expectAccount) if err != nil { return nil, err } - scalingAccount, err := evaluateExprAs(s, source.Through, expectAccount) + scalingAccount, err := evaluateExprAs(&s.evalEnv, source.Through, expectAccount) if err != nil { return nil, err } @@ -597,7 +539,7 @@ func (s *programState) takeAll(source parser.Source) (*big.Int, InterpreterError return s.takeAll(first) case *parser.SourceCapped: - monetary, err := evaluateExprAs(s, source.Cap, expectMonetaryOfAsset(s.CurrentAsset)) + monetary, err := evaluateExprAs(&s.evalEnv, source.Cap, expectMonetaryOfAsset(s.CurrentAsset)) if err != nil { return nil, err } @@ -643,7 +585,7 @@ func (s *programState) tryTakingFromAccount(accountLiteral parser.ValueExpr, amo } } - account, err := evaluateExprAs(s, accountLiteral, expectAccount) + account, err := evaluateExprAs(&s.evalEnv, accountLiteral, expectAccount) if err != nil { return nil, err } @@ -698,11 +640,11 @@ func (s *programState) tryTakingUpTo(source parser.Source, amount *big.Int) (*bi return nil, err } - account, err := evaluateExprAs(s, source.Address, expectAccount) + account, err := evaluateExprAs(&s.evalEnv, source.Address, expectAccount) if err != nil { return nil, err } - scalingAccount, err := evaluateExprAs(s, source.Through, expectAccount) + scalingAccount, err := evaluateExprAs(&s.evalEnv, source.Through, expectAccount) if err != nil { return nil, err } @@ -741,7 +683,7 @@ func (s *programState) tryTakingUpTo(source parser.Source, amount *big.Int) (*bi case *parser.SourceOverdraft: var cap *big.Int if source.Bounded != nil { - upTo, err := evaluateExprAs(s, *source.Bounded, expectMonetaryOfAsset(s.CurrentAsset)) + upTo, err := evaluateExprAs(&s.evalEnv, *source.Bounded, expectMonetaryOfAsset(s.CurrentAsset)) if err != nil { return nil, err } @@ -808,7 +750,7 @@ func (s *programState) tryTakingUpTo(source parser.Source, amount *big.Int) (*bi return amount, nil case *parser.SourceCapped: - cap, err := evaluateExprAs(s, source.Cap, expectMonetaryOfAsset(s.CurrentAsset)) + cap, err := evaluateExprAs(&s.evalEnv, source.Cap, expectMonetaryOfAsset(s.CurrentAsset)) if err != nil { return nil, err } @@ -828,7 +770,7 @@ func (s *programState) tryTakingUpTo(source parser.Source, amount *big.Int) (*bi func (s *programState) sendTo(destination parser.Destination, amount *big.Int) InterpreterError { switch destination := destination.(type) { case *parser.DestinationAccount: - account, err := evaluateExprAs(s, destination.ValueExpr, expectAccount) + account, err := evaluateExprAs(&s.evalEnv, destination.ValueExpr, expectAccount) if err != nil { return err } @@ -880,7 +822,7 @@ func (s *programState) sendTo(destination parser.Destination, amount *big.Int) I break } - cap, err := evaluateExprAs(s, destinationClause.Cap, expectMonetaryOfAsset(s.CurrentAsset)) + cap, err := evaluateExprAs(&s.evalEnv, destinationClause.Cap, expectMonetaryOfAsset(s.CurrentAsset)) if err != nil { return err } @@ -905,7 +847,7 @@ func (s *programState) sendTo(destination parser.Destination, amount *big.Int) I return err } for _, destinationClause := range destination.Clauses { - cap, err := evaluateExprAs(s, destinationClause.Cap, expectMonetaryOfAsset(s.CurrentAsset)) + cap, err := evaluateExprAs(&s.evalEnv, destinationClause.Cap, expectMonetaryOfAsset(s.CurrentAsset)) if err != nil { return err } @@ -955,7 +897,7 @@ func (s *programState) makeAllotment(monetary *big.Int, items []parser.Allotment for i, item := range items { switch allotment := item.(type) { case *parser.ValueExprAllotment: - rat, err := evaluateExprAs(s, allotment.Value, expectPortion) + rat, err := evaluateExprAs(&s.evalEnv, allotment.Value, expectPortion) if err != nil { return nil, err } @@ -1005,7 +947,7 @@ func (s *programState) makeAllotment(monetary *big.Int, items []parser.Allotment } // Utility function to get the balance -// getBalance implements expressionEnv: the raw (possibly negative) balance. +// getBalance implements *evalEnv: the raw (possibly negative) balance. func (s *programState) getBalance( account AccountAddress, asset Asset, @@ -1022,17 +964,17 @@ func (s *programState) getBalance( } -func (st *programState) evaluateSentAmt(sentValue parser.SentValue) (Asset, *big.Int, InterpreterError) { +func evaluateSentAmt(env *evalEnv, sentValue parser.SentValue) (Asset, *big.Int, InterpreterError) { switch sentValue := sentValue.(type) { case *parser.SentValueAll: - asset, err := evaluateExprAs(st, sentValue.Asset, expectAsset) + asset, err := evaluateExprAs(env, sentValue.Asset, expectAsset) if err != nil { return "", nil, err } return asset, nil, nil case *parser.SentValueLiteral: - monetary, err := evaluateExprAs(st, sentValue.Monetary, expectMonetary) + monetary, err := evaluateExprAs(env, sentValue.Monetary, expectMonetary) if err != nil { return "", nil, err } @@ -1087,23 +1029,6 @@ func ParsePortionSpecific(input string) (*big.Rat, InterpreterError) { return res, nil } -func (s programState) checkFeatureFlag(flag string) InterpreterError { - if hasFeatureFlag(s.FeatureFlags, flag) { - return nil - } - return ExperimentalFeature{FlagName: flag} -} - -// hasFeatureFlag reports whether flag is enabled. A nil set enables every -// feature (used e.g. by dependency resolution, which doesn't gate features). -func hasFeatureFlag(featureFlags map[string]struct{}, flag string) bool { - if featureFlags == nil { - return true - } - _, ok := featureFlags[flag] - return ok -} - /* PRE: ovedraft != nil, balance != nil PRE: ovedraft >= 0 From 9b19abf4e97b13826ffaac68adf1c5724a2631e0 Mon Sep 17 00:00:00 2001 From: ascandone Date: Thu, 2 Jul 2026 18:23:48 +0200 Subject: [PATCH 04/12] feat: extend visitor --- internal/interpreter/ast_visitor.go | 27 ++++++++------------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/internal/interpreter/ast_visitor.go b/internal/interpreter/ast_visitor.go index 04652c66..afc08f26 100644 --- a/internal/interpreter/ast_visitor.go +++ b/internal/interpreter/ast_visitor.go @@ -1,21 +1,13 @@ package interpreter import ( - "errors" - "github.com/formancehq/numscript/internal/analysis" "github.com/formancehq/numscript/internal/parser" "github.com/formancehq/numscript/internal/utils" ) -// errStopWalk is a sentinel that a hook can return to abort the walk without it -// being treated as a failure by the caller. -var errStopWalk = errors.New("stop walk") - +// A nil hook is skipped. Hooks fire pre-order; a non-nil error aborts the walk. type astVisitor struct { - // Any hook may be nil (then it is skipped). Returning a non-nil error - // aborts the whole walk and that error is propagated to walkProgram's - // caller. Hooks fire pre-order (parent before children). OnVariable func(*parser.Variable) error OnAsset func(*parser.AssetLiteral) error OnNumber func(*parser.NumberLiteral) error @@ -28,14 +20,13 @@ type astVisitor struct { OnFnCall func(*parser.FnCall) error // OnMeta fires for meta() calls in place of OnFnCall. originType is the - // declared var type when the call is directly a var origin (the only place - // meta() is valid), and nil when meta() appears anywhere else — i.e. nested - // in a sub-expression or in a statement. + // declared var type when meta() is directly a var origin, else nil. OnMeta func(originType *string, fnCall *parser.FnCall) error OnSource func(parser.Source) error OnDestination func(parser.Destination) error OnStatement func(parser.Statement) error + OnSendStatement func(*parser.SendStatement) error OnSaveStatement func(*parser.SaveStatement) error OnVarDecl func(parser.VarDeclaration) error } @@ -67,9 +58,6 @@ func (v astVisitor) walkVarDecl(decl parser.VarDeclaration) error { } origin := *decl.Origin - // A meta() that is directly the var origin is the only valid meta position: - // route it to OnMeta with the declared type and walk its args, bypassing - // the generic (nil-type) meta dispatch in walkFnCall. if fnCall, ok := origin.(*parser.FnCall); ok && fnCall.Caller.Name == analysis.FnVarOriginMeta { if v.OnMeta != nil { if err := v.OnMeta(&decl.Type.Name, fnCall); err != nil { @@ -93,6 +81,11 @@ func (v astVisitor) walkStatement(statement parser.Statement) error { return v.walkFnCall(statement) case *parser.SendStatement: + if v.OnSendStatement != nil { + if err := v.OnSendStatement(statement); err != nil { + return err + } + } if err := v.walkSentValue(statement.SentValue); err != nil { return err } @@ -327,8 +320,6 @@ func (v astVisitor) walkValueExpr(valueExpr parser.ValueExpr) error { return err } } - // account name parts can only be text or *Variable; the variables are - // worth descending into so OnVariable fires for interpolations. for _, part := range valueExpr.Parts { if variable, ok := part.(*parser.Variable); ok && v.OnVariable != nil { if err := v.OnVariable(variable); err != nil { @@ -368,8 +359,6 @@ func (v astVisitor) walkValueExpr(valueExpr parser.ValueExpr) error { func (v astVisitor) walkFnCall(fnCall *parser.FnCall) error { if fnCall.Caller.Name == analysis.FnVarOriginMeta { - // Reached through the generic walk, so this meta() is never a top-level - // var origin (those are intercepted in walkVarDecl): its type is nil. if v.OnMeta != nil { if err := v.OnMeta(nil, fnCall); err != nil { return err From afe3bb29c0eec42b0577cf0419082e1c8ec212e7 Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 8 Jul 2026 13:58:09 +0200 Subject: [PATCH 05/12] feat: impl resolve dependencies (no visitor) --- internal/interpreter/ast_visitor.go | 382 ----------------- internal/interpreter/evaluate_expr.go | 2 +- internal/interpreter/resolve_dependencies.go | 321 ++++++++++++++ .../interpreter/resolve_dependencies_test.go | 400 ++++++++++++++++++ 4 files changed, 722 insertions(+), 383 deletions(-) delete mode 100644 internal/interpreter/ast_visitor.go create mode 100644 internal/interpreter/resolve_dependencies.go create mode 100644 internal/interpreter/resolve_dependencies_test.go diff --git a/internal/interpreter/ast_visitor.go b/internal/interpreter/ast_visitor.go deleted file mode 100644 index afc08f26..00000000 --- a/internal/interpreter/ast_visitor.go +++ /dev/null @@ -1,382 +0,0 @@ -package interpreter - -import ( - "github.com/formancehq/numscript/internal/analysis" - "github.com/formancehq/numscript/internal/parser" - "github.com/formancehq/numscript/internal/utils" -) - -// A nil hook is skipped. Hooks fire pre-order; a non-nil error aborts the walk. -type astVisitor struct { - OnVariable func(*parser.Variable) error - OnAsset func(*parser.AssetLiteral) error - OnNumber func(*parser.NumberLiteral) error - OnString func(*parser.StringLiteral) error - OnPercentage func(*parser.PercentageLiteral) error - OnMonetary func(*parser.MonetaryLiteral) error - OnAccount func(*parser.AccountInterpLiteral) error - OnBinaryInfix func(*parser.BinaryInfix) error - OnPrefix func(*parser.Prefix) error - OnFnCall func(*parser.FnCall) error - - // OnMeta fires for meta() calls in place of OnFnCall. originType is the - // declared var type when meta() is directly a var origin, else nil. - OnMeta func(originType *string, fnCall *parser.FnCall) error - - OnSource func(parser.Source) error - OnDestination func(parser.Destination) error - OnStatement func(parser.Statement) error - OnSendStatement func(*parser.SendStatement) error - OnSaveStatement func(*parser.SaveStatement) error - OnVarDecl func(parser.VarDeclaration) error -} - -func (v astVisitor) walkProgram(program parser.Program) error { - if program.Vars != nil { - for _, decl := range program.Vars.Declarations { - if err := v.walkVarDecl(decl); err != nil { - return err - } - } - } - for _, stmt := range program.Statements { - if err := v.walkStatement(stmt); err != nil { - return err - } - } - return nil -} - -func (v astVisitor) walkVarDecl(decl parser.VarDeclaration) error { - if v.OnVarDecl != nil { - if err := v.OnVarDecl(decl); err != nil { - return err - } - } - if decl.Origin == nil { - return nil - } - origin := *decl.Origin - - if fnCall, ok := origin.(*parser.FnCall); ok && fnCall.Caller.Name == analysis.FnVarOriginMeta { - if v.OnMeta != nil { - if err := v.OnMeta(&decl.Type.Name, fnCall); err != nil { - return err - } - } - return v.walkFnCallArgs(fnCall) - } - - return v.walkValueExpr(origin) -} - -func (v astVisitor) walkStatement(statement parser.Statement) error { - if v.OnStatement != nil { - if err := v.OnStatement(statement); err != nil { - return err - } - } - switch statement := statement.(type) { - case *parser.FnCall: - return v.walkFnCall(statement) - - case *parser.SendStatement: - if v.OnSendStatement != nil { - if err := v.OnSendStatement(statement); err != nil { - return err - } - } - if err := v.walkSentValue(statement.SentValue); err != nil { - return err - } - if err := v.walkSource(statement.Source); err != nil { - return err - } - return v.walkDestination(statement.Destination) - - case *parser.SaveStatement: - if v.OnSaveStatement != nil { - if err := v.OnSaveStatement(statement); err != nil { - return err - } - } - if err := v.walkSentValue(statement.SentValue); err != nil { - return err - } - return v.walkValueExpr(statement.Account) - - default: - utils.NonExhaustiveMatchPanic[any](statement) - return nil - } -} - -func (v astVisitor) walkSentValue(sentValue parser.SentValue) error { - switch sentValue := sentValue.(type) { - case *parser.SentValueLiteral: - return v.walkValueExpr(sentValue.Monetary) - case *parser.SentValueAll: - return v.walkValueExpr(sentValue.Asset) - default: - utils.NonExhaustiveMatchPanic[any](sentValue) - return nil - } -} - -func (v astVisitor) walkSource(source parser.Source) error { - if v.OnSource != nil { - if err := v.OnSource(source); err != nil { - return err - } - } - switch source := source.(type) { - case *parser.SourceAccount: - if source.Color != nil { - if err := v.walkValueExpr(source.Color); err != nil { - return err - } - } - return v.walkValueExpr(source.ValueExpr) - - case *parser.SourceInorder: - for _, sub := range source.Sources { - if err := v.walkSource(sub); err != nil { - return err - } - } - return nil - - case *parser.SourceOneof: - for _, sub := range source.Sources { - if err := v.walkSource(sub); err != nil { - return err - } - } - return nil - - case *parser.SourceAllotment: - for _, item := range source.Items { - if err := v.walkAllotmentValue(item.Allotment); err != nil { - return err - } - if err := v.walkSource(item.From); err != nil { - return err - } - } - return nil - - case *parser.SourceCapped: - if err := v.walkValueExpr(source.Cap); err != nil { - return err - } - return v.walkSource(source.From) - - case *parser.SourceOverdraft: - if source.Color != nil { - if err := v.walkValueExpr(source.Color); err != nil { - return err - } - } - if err := v.walkValueExpr(source.Address); err != nil { - return err - } - if source.Bounded != nil { - return v.walkValueExpr(*source.Bounded) - } - return nil - - case *parser.SourceWithScaling: - if err := v.walkValueExpr(source.Address); err != nil { - return err - } - return v.walkValueExpr(source.Through) - - default: - utils.NonExhaustiveMatchPanic[any](source) - return nil - } -} - -func (v astVisitor) walkDestination(destination parser.Destination) error { - if v.OnDestination != nil { - if err := v.OnDestination(destination); err != nil { - return err - } - } - switch destination := destination.(type) { - case *parser.DestinationAccount: - return v.walkValueExpr(destination.ValueExpr) - - case *parser.DestinationInorder: - for _, clause := range destination.Clauses { - if err := v.walkCappedKeptOrDestination(clause); err != nil { - return err - } - } - return v.walkKeptOrDestination(destination.Remaining) - - case *parser.DestinationOneof: - for _, clause := range destination.Clauses { - if err := v.walkCappedKeptOrDestination(clause); err != nil { - return err - } - } - return v.walkKeptOrDestination(destination.Remaining) - - case *parser.DestinationAllotment: - for _, item := range destination.Items { - if err := v.walkAllotmentValue(item.Allotment); err != nil { - return err - } - if err := v.walkKeptOrDestination(item.To); err != nil { - return err - } - } - return nil - - default: - utils.NonExhaustiveMatchPanic[any](destination) - return nil - } -} - -func (v astVisitor) walkCappedKeptOrDestination(clause parser.CappedKeptOrDestination) error { - if err := v.walkValueExpr(clause.Cap); err != nil { - return err - } - return v.walkKeptOrDestination(clause.To) -} - -func (v astVisitor) walkKeptOrDestination(keptOrDestination parser.KeptOrDestination) error { - switch keptOrDestination := keptOrDestination.(type) { - case *parser.DestinationKept: - return nil - case *parser.DestinationTo: - return v.walkDestination(keptOrDestination.Destination) - default: - utils.NonExhaustiveMatchPanic[any](keptOrDestination) - return nil - } -} - -func (v astVisitor) walkAllotmentValue(allotmentValue parser.AllotmentValue) error { - switch allotmentValue := allotmentValue.(type) { - case *parser.ValueExprAllotment: - return v.walkValueExpr(allotmentValue.Value) - case *parser.RemainingAllotment: - return nil - default: - utils.NonExhaustiveMatchPanic[any](allotmentValue) - return nil - } -} - -func (v astVisitor) walkValueExpr(valueExpr parser.ValueExpr) error { - switch valueExpr := valueExpr.(type) { - case *parser.Variable: - if v.OnVariable != nil { - return v.OnVariable(valueExpr) - } - return nil - - case *parser.AssetLiteral: - if v.OnAsset != nil { - return v.OnAsset(valueExpr) - } - return nil - - case *parser.NumberLiteral: - if v.OnNumber != nil { - return v.OnNumber(valueExpr) - } - return nil - - case *parser.StringLiteral: - if v.OnString != nil { - return v.OnString(valueExpr) - } - return nil - - case *parser.PercentageLiteral: - if v.OnPercentage != nil { - return v.OnPercentage(valueExpr) - } - return nil - - case *parser.MonetaryLiteral: - if v.OnMonetary != nil { - if err := v.OnMonetary(valueExpr); err != nil { - return err - } - } - if err := v.walkValueExpr(valueExpr.Asset); err != nil { - return err - } - return v.walkValueExpr(valueExpr.Amount) - - case *parser.AccountInterpLiteral: - if v.OnAccount != nil { - if err := v.OnAccount(valueExpr); err != nil { - return err - } - } - for _, part := range valueExpr.Parts { - if variable, ok := part.(*parser.Variable); ok && v.OnVariable != nil { - if err := v.OnVariable(variable); err != nil { - return err - } - } - } - return nil - - case *parser.BinaryInfix: - if v.OnBinaryInfix != nil { - if err := v.OnBinaryInfix(valueExpr); err != nil { - return err - } - } - if err := v.walkValueExpr(valueExpr.Left); err != nil { - return err - } - return v.walkValueExpr(valueExpr.Right) - - case *parser.Prefix: - if v.OnPrefix != nil { - if err := v.OnPrefix(valueExpr); err != nil { - return err - } - } - return v.walkValueExpr(valueExpr.Expr) - - case *parser.FnCall: - return v.walkFnCall(valueExpr) - - default: - utils.NonExhaustiveMatchPanic[any](valueExpr) - return nil - } -} - -func (v astVisitor) walkFnCall(fnCall *parser.FnCall) error { - if fnCall.Caller.Name == analysis.FnVarOriginMeta { - if v.OnMeta != nil { - if err := v.OnMeta(nil, fnCall); err != nil { - return err - } - } - } else if v.OnFnCall != nil { - if err := v.OnFnCall(fnCall); err != nil { - return err - } - } - return v.walkFnCallArgs(fnCall) -} - -func (v astVisitor) walkFnCallArgs(fnCall *parser.FnCall) error { - for _, arg := range fnCall.Args { - if err := v.walkValueExpr(arg); err != nil { - return err - } - } - return nil -} diff --git a/internal/interpreter/evaluate_expr.go b/internal/interpreter/evaluate_expr.go index aa025f29..38acd81d 100644 --- a/internal/interpreter/evaluate_expr.go +++ b/internal/interpreter/evaluate_expr.go @@ -48,7 +48,7 @@ func (env *evalEnv) checkFeatureFlag(flag string) InterpreterError { func (env *evalEnv) getBalance(account AccountAddress, asset Asset) (*big.Int, InterpreterError) { color := String("") - if account.Name != "world" && !env.CachedBalances.has(account, string(asset), string(color)) { + if !env.CachedBalances.has(account, string(asset), string(color)) { rows, err := env.Store.GetBalances(env.ctx, BalanceQuery{ {Account: account.Name, Asset: string(asset), Color: string(color), Scope: account.Scope}, }) diff --git a/internal/interpreter/resolve_dependencies.go b/internal/interpreter/resolve_dependencies.go new file mode 100644 index 00000000..dba08cb2 --- /dev/null +++ b/internal/interpreter/resolve_dependencies.go @@ -0,0 +1,321 @@ +package interpreter + +import ( + "context" + "errors" + + "github.com/formancehq/numscript/internal/analysis" + "github.com/formancehq/numscript/internal/parser" + "github.com/formancehq/numscript/internal/utils" +) + +var ErrScalingNotSupported = errors.New("scaling is not supported") + +type AccountDependency struct { + Account string + Scope string + Color string + Asset string +} + +type MetaDependency struct { + Account string + Scope string + Key string +} + +type ResolvedDependencies struct { + AccountsReads map[AccountDependency]struct{} + AccountsWrites map[AccountDependency]struct{} + MetaReads map[MetaDependency]struct{} + MetaWrites map[MetaDependency]struct{} +} + +// recordingStore wraps a Store and records every balance/metadata it is queried +// for. Evaluating an expression through an evalEnv backed by this store is what +// makes balance()/overdraft()/meta() reads show up as dependencies. +type recordingStore struct { + inner Store + reads map[AccountDependency]struct{} + metaReads map[MetaDependency]struct{} +} + +func (s *recordingStore) GetBalances(ctx context.Context, q BalanceQuery) (Balances, error) { + for _, item := range q { + s.reads[AccountDependency{ + Account: item.Account, + Scope: item.Scope, + Color: item.Color, + Asset: item.Asset, + }] = struct{}{} + } + return s.inner.GetBalances(ctx, q) +} + +func (s *recordingStore) GetAccountsMetadata(ctx context.Context, q MetadataQuery) (AccountsMetadata, error) { + for _, item := range q { + for _, key := range item.Keys { + s.metaReads[MetaDependency{Account: item.Account, Scope: item.Scope, Key: key}] = struct{}{} + } + } + return s.inner.GetAccountsMetadata(ctx, q) +} + +type resolveDependenciesState struct { + env *evalEnv + deps ResolvedDependencies +} + +func ResolveDependencies(ctx context.Context, store Store, vars map[string]string, program parser.Program) (ResolvedDependencies, error) { + deps := ResolvedDependencies{ + AccountsReads: map[AccountDependency]struct{}{}, + AccountsWrites: map[AccountDependency]struct{}{}, + MetaReads: map[MetaDependency]struct{}{}, + MetaWrites: map[MetaDependency]struct{}{}, + } + recording := &recordingStore{inner: store, reads: deps.AccountsReads, metaReads: deps.MetaReads} + + // binding the vars evaluates their origins, so balance()/overdraft()/meta() + // origins already get recorded through the store here. + env, err := newEvalEnv(ctx, recording, nil, program.Vars, vars) + if err != nil { + return ResolvedDependencies{}, err + } + + st := &resolveDependenciesState{env: &env, deps: deps} + for _, statement := range program.Statements { + if err := st.resolveStatement(statement); err != nil { + return ResolvedDependencies{}, err + } + } + + return st.deps, nil +} + +func addAccountDep(deps map[AccountDependency]struct{}, account AccountAddress, asset Asset, color String) { + deps[AccountDependency{ + Account: account.Name, + Scope: account.Scope, + Color: string(color), + Asset: string(asset), + }] = struct{}{} +} + +func addMetaDep(deps map[MetaDependency]struct{}, account AccountAddress, key string) { + deps[MetaDependency{ + Account: account.Name, + Scope: account.Scope, + Key: key, + }] = struct{}{} +} + +// eval evaluates an expression only for its side effects: any balance()/meta() +// inside it is recorded through the store. +func (st *resolveDependenciesState) eval(expr parser.ValueExpr) error { + _, err := evaluateExpr(st.env, expr) + return err +} + +func (st *resolveDependenciesState) resolveStatement(statement parser.Statement) error { + switch statement := statement.(type) { + case *parser.SendStatement: + asset, _, err := evaluateSentAmt(st.env, statement.SentValue) + if err != nil { + return err + } + if err := st.resolveSource(statement.Source, asset); err != nil { + return err + } + return st.resolveDestination(statement.Destination, asset) + + case *parser.SaveStatement: + asset, _, err := evaluateSentAmt(st.env, statement.SentValue) + if err != nil { + return err + } + account, err := evaluateExprAs(st.env, statement.Account, expectAccount) + if err != nil { + return err + } + addAccountDep(st.deps.AccountsReads, account, asset, "") + return nil + + case *parser.FnCall: + return st.resolveFnCallStatement(statement) + + default: + utils.NonExhaustiveMatchPanic[any](statement) + return nil + } +} + +func (st *resolveDependenciesState) resolveFnCallStatement(fnCall *parser.FnCall) error { + args, err := evaluateExpressions(st.env, fnCall.Args) + if err != nil { + return err + } + + switch fnCall.Caller.Name { + case analysis.FnSetAccountMeta: + p := NewArgsParser(args) + account := parseArg(p, fnCall.Range, expectAccount) + key := parseArg(p, fnCall.Range, expectString) + _ = parseArg(p, fnCall.Range, expectAnything) + if err := p.parse(); err != nil { + return err + } + addMetaDep(st.deps.MetaWrites, account, string(key)) + return nil + + default: + return nil + } +} + +func (st *resolveDependenciesState) resolveSource(source parser.Source, asset Asset) error { + switch source := source.(type) { + case *parser.SourceAccount: + account, err := evaluateExprAs(st.env, source.ValueExpr, expectAccount) + if err != nil { + return err + } + color, err := evaluateOptExprAs(st.env, source.Color, expectString) + if err != nil { + return err + } + st.recordSource(account, asset, color, account.Name == "world") + return nil + + case *parser.SourceOverdraft: + account, err := evaluateExprAs(st.env, source.Address, expectAccount) + if err != nil { + return err + } + color, err := evaluateOptExprAs(st.env, source.Color, expectString) + if err != nil { + return err + } + st.recordSource(account, asset, color, source.Bounded == nil) + if source.Bounded != nil { + return st.eval(*source.Bounded) + } + return nil + + case *parser.SourceInorder: + for _, sub := range source.Sources { + if err := st.resolveSource(sub, asset); err != nil { + return err + } + } + return nil + + case *parser.SourceOneof: + for _, sub := range source.Sources { + if err := st.resolveSource(sub, asset); err != nil { + return err + } + } + return nil + + case *parser.SourceCapped: + if err := st.eval(source.Cap); err != nil { + return err + } + return st.resolveSource(source.From, asset) + + case *parser.SourceAllotment: + for _, item := range source.Items { + if err := st.resolveAllotment(item.Allotment); err != nil { + return err + } + if err := st.resolveSource(item.From, asset); err != nil { + return err + } + } + return nil + + case *parser.SourceWithScaling: + return ErrScalingNotSupported + + default: + utils.NonExhaustiveMatchPanic[any](source) + return nil + } +} + +// recordSource records a source account: always a write, and a read unless the +// account is unbounded (its balance is never consulted). +func (st *resolveDependenciesState) recordSource(account AccountAddress, asset Asset, color String, unbounded bool) { + if !unbounded { + addAccountDep(st.deps.AccountsReads, account, asset, color) + } + addAccountDep(st.deps.AccountsWrites, account, asset, color) +} + +func (st *resolveDependenciesState) resolveDestination(destination parser.Destination, asset Asset) error { + switch destination := destination.(type) { + case *parser.DestinationAccount: + account, err := evaluateExprAs(st.env, destination.ValueExpr, expectAccount) + if err != nil { + return err + } + addAccountDep(st.deps.AccountsWrites, account, asset, "") + return nil + + case *parser.DestinationInorder: + for _, clause := range destination.Clauses { + if err := st.eval(clause.Cap); err != nil { + return err + } + if err := st.resolveKeptOrDestination(clause.To, asset); err != nil { + return err + } + } + return st.resolveKeptOrDestination(destination.Remaining, asset) + + case *parser.DestinationOneof: + for _, clause := range destination.Clauses { + if err := st.eval(clause.Cap); err != nil { + return err + } + if err := st.resolveKeptOrDestination(clause.To, asset); err != nil { + return err + } + } + return st.resolveKeptOrDestination(destination.Remaining, asset) + + case *parser.DestinationAllotment: + for _, item := range destination.Items { + if err := st.resolveAllotment(item.Allotment); err != nil { + return err + } + if err := st.resolveKeptOrDestination(item.To, asset); err != nil { + return err + } + } + return nil + + default: + utils.NonExhaustiveMatchPanic[any](destination) + return nil + } +} + +func (st *resolveDependenciesState) resolveKeptOrDestination(kd parser.KeptOrDestination, asset Asset) error { + switch kd := kd.(type) { + case *parser.DestinationKept: + return nil + case *parser.DestinationTo: + return st.resolveDestination(kd.Destination, asset) + default: + utils.NonExhaustiveMatchPanic[any](kd) + return nil + } +} + +func (st *resolveDependenciesState) resolveAllotment(allotment parser.AllotmentValue) error { + if allotment, ok := allotment.(*parser.ValueExprAllotment); ok { + return st.eval(allotment.Value) + } + return nil +} diff --git a/internal/interpreter/resolve_dependencies_test.go b/internal/interpreter/resolve_dependencies_test.go new file mode 100644 index 00000000..517dd40e --- /dev/null +++ b/internal/interpreter/resolve_dependencies_test.go @@ -0,0 +1,400 @@ +package interpreter_test + +import ( + "context" + "testing" + + "github.com/formancehq/numscript/internal/interpreter" + "github.com/formancehq/numscript/internal/parser" + "github.com/stretchr/testify/require" +) + +func resolve(t *testing.T, src string, vars map[string]string, store interpreter.Store) interpreter.ResolvedDependencies { + parsed := parser.Parse(src) + require.Empty(t, parsed.Errors) + deps, err := interpreter.ResolveDependencies(context.Background(), store, vars, parsed.Value) + require.NoError(t, err) + return deps +} + +func TestResolveSendSourceAndDestination(t *testing.T) { + deps := resolve(t, ` + send [USD 10] ( + source = @alice + destination = @bob + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "alice", Asset: "USD"}: {}, + }, deps.AccountsReads) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "alice", Asset: "USD"}: {}, + {Account: "bob", Asset: "USD"}: {}, + }, deps.AccountsWrites) + + require.Empty(t, deps.MetaReads) +} + +func TestResolveWorldIsWrittenButNotRead(t *testing.T) { + deps := resolve(t, ` + send [USD 10] ( + source = @world + destination = @bob + ) + `, nil, interpreter.StaticStore{}) + + require.Empty(t, deps.AccountsReads) + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "world", Asset: "USD"}: {}, + {Account: "bob", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveBalanceAndOverdraftAreReads(t *testing.T) { + deps := resolve(t, ` + vars { + monetary $b = balance(@alice, USD) + monetary $o = overdraft(@carol, EUR) + } + send $b ( + source = @world + destination = @bob + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "alice", Asset: "USD"}: {}, + {Account: "carol", Asset: "EUR"}: {}, + }, deps.AccountsReads) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "world", Asset: "USD"}: {}, + {Account: "bob", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveMetaIsMetaRead(t *testing.T) { + deps := resolve(t, ` + vars { + account $dst = meta(@config, "recipient") + } + send [USD 10] ( + source = @alice + destination = @bob + ) + `, nil, interpreter.StaticStore{ + Meta: interpreter.AccountsMetadata{ + {Account: "config", Key: "recipient", Value: "bank"}, + }, + }) + + require.Equal(t, map[interpreter.MetaDependency]struct{}{ + {Account: "config", Key: "recipient"}: {}, + }, deps.MetaReads) +} + +func TestResolveMetaValueResolvesDependentAccount(t *testing.T) { + // the meta() value is fetched from the store, so a var bound to it can be + // used to resolve a downstream account dependency + deps := resolve(t, ` + vars { + account $dst = meta(@config, "recipient") + } + send [USD 10] ( + source = @world + destination = $dst + ) + `, nil, interpreter.StaticStore{ + Meta: interpreter.AccountsMetadata{ + {Account: "config", Key: "recipient", Value: "treasury"}, + }, + }) + + require.Equal(t, map[interpreter.MetaDependency]struct{}{ + {Account: "config", Key: "recipient"}: {}, + }, deps.MetaReads) + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "world", Asset: "USD"}: {}, + {Account: "treasury", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveInterpolatedAccountUsesVars(t *testing.T) { + deps := resolve(t, ` + vars { + string $id + } + send [USD 10] ( + source = @users:$id + destination = @world + ) + `, map[string]string{"id": "alice"}, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "users:alice", Asset: "USD"}: {}, + }, deps.AccountsReads) + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "users:alice", Asset: "USD"}: {}, + {Account: "world", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveUnboundedOverdraftSourceIsWriteOnly(t *testing.T) { + deps := resolve(t, ` + send [USD 10] ( + source = @alice allowing unbounded overdraft + destination = @bob + ) + `, nil, interpreter.StaticStore{}) + + // unbounded overdraft: no balance read, but still a write + require.Empty(t, deps.AccountsReads) + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "alice", Asset: "USD"}: {}, + {Account: "bob", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveInorderSource(t *testing.T) { + deps := resolve(t, ` + send [USD 30] ( + source = { + @a + @b + } + destination = @c + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + }, deps.AccountsReads) + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + {Account: "c", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveAllotmentSourceAndDestination(t *testing.T) { + deps := resolve(t, ` + send [USD 100] ( + source = { + 50% from @a + remaining from @b + } + destination = { + 50% to @x + remaining to @y + } + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + }, deps.AccountsReads) + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + {Account: "x", Asset: "USD"}: {}, + {Account: "y", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveBalanceOfWorldIsRead(t *testing.T) { + deps := resolve(t, ` + vars { + monetary $w = balance(@world, USD) + } + send [USD 10] ( + source = @a + destination = @b + ) + `, nil, interpreter.StaticStore{}) + + _, ok := deps.AccountsReads[interpreter.AccountDependency{Account: "world", Asset: "USD"}] + require.True(t, ok, "balance(@world) must be recorded as a read") +} + +func TestResolveBalanceInsideCap(t *testing.T) { + deps := resolve(t, ` + send [USD 10] ( + source = max balance(@reserve, USD) from @a + destination = @b + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "reserve", Asset: "USD"}: {}, + {Account: "a", Asset: "USD"}: {}, + }, deps.AccountsReads) + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveOneofSource(t *testing.T) { + // oneof picks one branch at runtime, but statically every branch is a dependency + deps := resolve(t, ` + send [USD 10] ( + source = oneof { + @a + @b + } + destination = @c + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + }, deps.AccountsReads) + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + {Account: "c", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveBoundedOverdraftIsRead(t *testing.T) { + deps := resolve(t, ` + send [USD 10] ( + source = @a allowing overdraft up to [USD 100] + destination = @b + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "a", Asset: "USD"}: {}, + }, deps.AccountsReads) + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveOverdraftBoundBalanceIsRead(t *testing.T) { + // a balance() inside an overdraft bound is recorded through the store + deps := resolve(t, ` + send [USD 10] ( + source = @a allowing overdraft up to balance(@reserve, USD) + destination = @b + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "a", Asset: "USD"}: {}, + {Account: "reserve", Asset: "USD"}: {}, + }, deps.AccountsReads) +} + +func TestResolveColoredSource(t *testing.T) { + deps := resolve(t, ` + send [COIN 100] ( + source = @src \ "RED" + destination = @dest + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "src", Asset: "COIN", Color: "RED"}: {}, + }, deps.AccountsReads) + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "src", Asset: "COIN", Color: "RED"}: {}, + {Account: "dest", Asset: "COIN"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveSameAccountDifferentColors(t *testing.T) { + // the same account with different colors are distinct dependencies + deps := resolve(t, ` + send [COIN 100] ( + source = { + @src \ "RED" + @src \ "BLUE" + @src + } + destination = @dest + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "src", Asset: "COIN", Color: "RED"}: {}, + {Account: "src", Asset: "COIN", Color: "BLUE"}: {}, + {Account: "src", Asset: "COIN"}: {}, + }, deps.AccountsReads) +} + +func TestResolveDeeplyNestedSources(t *testing.T) { + deps := resolve(t, ` + send [USD 100] ( + source = oneof { + max [USD 10] from @a + { + @b + @world + } + { + 50% from @c + remaining from @d allowing unbounded overdraft + } + } + destination = @dest + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + {Account: "c", Asset: "USD"}: {}, + // @world (unbounded) and @d (unbounded overdraft) are writes only + }, deps.AccountsReads) + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + {Account: "world", Asset: "USD"}: {}, + {Account: "c", Asset: "USD"}: {}, + {Account: "d", Asset: "USD"}: {}, + {Account: "dest", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveSetAccountMetaIsMetaWrite(t *testing.T) { + deps := resolve(t, ` + set_account_meta(@alice, "kyc", "verified") + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.MetaDependency]struct{}{ + {Account: "alice", Key: "kyc"}: {}, + }, deps.MetaWrites) + require.Empty(t, deps.MetaReads) +} + +func TestResolveScalingIsNotSupported(t *testing.T) { + parsed := parser.Parse(` + send [USD 10] ( + source = @alice with scaling through @pool + destination = @bob + ) + `) + require.Empty(t, parsed.Errors) + + _, err := interpreter.ResolveDependencies(context.Background(), interpreter.StaticStore{}, nil, parsed.Value) + require.ErrorIs(t, err, interpreter.ErrScalingNotSupported) +} + +func TestResolveSaveIsRead(t *testing.T) { + deps := resolve(t, ` + save [USD 10] from @alice + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "alice", Asset: "USD"}: {}, + }, deps.AccountsReads) + require.Empty(t, deps.AccountsWrites) +} From f9a2d39e04f026bbf99a37b6c6e17853457dcbf5 Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 8 Jul 2026 14:29:31 +0200 Subject: [PATCH 06/12] feat: export accounts resolution api --- numscript.go | 17 +++++++++++++++++ numscript_test.go | 20 ++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/numscript.go b/numscript.go index 3c377782..eb4dc8d7 100644 --- a/numscript.go +++ b/numscript.go @@ -78,8 +78,14 @@ type ( InterpreterError = interpreter.InterpreterError MissingFundsErr = interpreter.MissingFundsErr + + ResolvedDependencies = interpreter.ResolvedDependencies + AccountDependency = interpreter.AccountDependency + MetaDependency = interpreter.MetaDependency ) +var ErrScalingNotSupported = interpreter.ErrScalingNotSupported + func (p ParseResult) Run(ctx context.Context, vars VariablesMap, store Store) (ExecutionResult, InterpreterError) { return p.RunWithFeatureFlags(ctx, vars, store, nil) } @@ -105,6 +111,17 @@ func (p ParseResult) RunWithFeatureFlags( return *res, nil } +// ResolveDependencies statically determines which accounts and metadata the +// script reads and writes, resolving account/asset/key expressions against the +// given vars and store. +func (p ParseResult) ResolveDependencies(ctx context.Context, vars VariablesMap, store Store) (ResolvedDependencies, error) { + if len(p.parseResult.Errors) != 0 { + return ResolvedDependencies{}, p.parseResult.Errors[0] + } + + return interpreter.ResolveDependencies(ctx, store, vars, p.parseResult.Value) +} + func (p ParseResult) GetSource() string { return p.parseResult.Source } diff --git a/numscript_test.go b/numscript_test.go index e1ec7171..fa24e76a 100644 --- a/numscript_test.go +++ b/numscript_test.go @@ -557,3 +557,23 @@ func (*ErrorStore) GetBalances(ctx context.Context, q interpreter.BalanceQuery) func (*ErrorStore) GetAccountsMetadata(ctx context.Context, q interpreter.MetadataQuery) (interpreter.AccountsMetadata, error) { return nil, errors.New("Error while fetching metadata") } + +func TestResolveDependenciesPublicAPI(t *testing.T) { + parsed := numscript.Parse(` + send [USD 10] ( + source = @alice + destination = @bob + ) + `) + + deps, err := parsed.ResolveDependencies(context.Background(), nil, numscript.StaticStore{}) + require.NoError(t, err) + + require.Equal(t, map[numscript.AccountDependency]struct{}{ + {Account: "alice", Asset: "USD"}: {}, + }, deps.AccountsReads) + require.Equal(t, map[numscript.AccountDependency]struct{}{ + {Account: "alice", Asset: "USD"}: {}, + {Account: "bob", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} From 7885355ddd3343aabae828a0d9baf6f9f9fe9f07 Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 8 Jul 2026 15:56:21 +0200 Subject: [PATCH 07/12] more tests --- .../interpreter/resolve_dependencies_test.go | 266 ++++++++++++++++++ 1 file changed, 266 insertions(+) diff --git a/internal/interpreter/resolve_dependencies_test.go b/internal/interpreter/resolve_dependencies_test.go index 517dd40e..0a73e9f7 100644 --- a/internal/interpreter/resolve_dependencies_test.go +++ b/internal/interpreter/resolve_dependencies_test.go @@ -2,13 +2,180 @@ package interpreter_test import ( "context" + "math/big" "testing" + "github.com/formancehq/numscript/internal/flags" "github.com/formancehq/numscript/internal/interpreter" "github.com/formancehq/numscript/internal/parser" "github.com/stretchr/testify/require" ) +// queryLog wraps a store and records every balance/metadata actually queried, +// so a test can assert that resolution is a superset of what execution touches. +type queryLog struct { + inner interpreter.Store + balances map[interpreter.AccountDependency]struct{} + meta map[interpreter.MetaDependency]struct{} +} + +func (q *queryLog) GetBalances(ctx context.Context, query interpreter.BalanceQuery) (interpreter.Balances, error) { + for _, item := range query { + q.balances[interpreter.AccountDependency{Account: item.Account, Scope: item.Scope, Color: item.Color, Asset: item.Asset}] = struct{}{} + } + return q.inner.GetBalances(ctx, query) +} + +func (q *queryLog) GetAccountsMetadata(ctx context.Context, query interpreter.MetadataQuery) (interpreter.AccountsMetadata, error) { + for _, item := range query { + for _, key := range item.Keys { + q.meta[interpreter.MetaDependency{Account: item.Account, Scope: item.Scope, Key: key}] = struct{}{} + } + } + return q.inner.GetAccountsMetadata(ctx, query) +} + +// TestResolveDependenciesCoversRuntime checks the soundness property: for a +// script that executes successfully, every balance/metadata the interpreter +// queries is a resolved read, and every account a posting touches is a resolved +// write. Resolution may over-approximate (e.g. both branches of a oneof), so +// these are subset checks. +func TestResolveDependenciesCoversRuntime(t *testing.T) { + allFlags := map[string]struct{}{} + for _, f := range flags.AllFlags { + allFlags[f] = struct{}{} + } + + testCases := []struct { + name string + src string + vars map[string]string + balances interpreter.Balances + meta interpreter.AccountsMetadata + }{ + { + name: "simple send", + src: ` + send [USD 10] ( + source = @a + destination = @b + ) + `, + balances: interpreter.Balances{{Account: "a", Asset: "USD", Amount: big.NewInt(100)}}, + }, + { + name: "balance var origin used as sent value", + src: ` + vars { monetary $x = balance(@t, USD) } + send $x ( + source = @t + destination = @o + ) + `, + balances: interpreter.Balances{{Account: "t", Asset: "USD", Amount: big.NewInt(100)}}, + }, + { + name: "bounded overdraft", + src: ` + send [USD 50] ( + source = @a allowing overdraft up to [USD 100] + destination = @b + ) + `, + balances: interpreter.Balances{{Account: "a", Asset: "USD", Amount: big.NewInt(0)}}, + }, + { + name: "allotment source", + src: ` + send [USD 100] ( + source = { + 50% from @a + 50% from @b + } + destination = @c + ) + `, + balances: interpreter.Balances{ + {Account: "a", Asset: "USD", Amount: big.NewInt(50)}, + {Account: "b", Asset: "USD", Amount: big.NewInt(50)}, + }, + }, + { + name: "balance inside a cap", + src: ` + send [USD 5] ( + source = max balance(@r, USD) from @funder + destination = @out + ) + `, + balances: interpreter.Balances{ + {Account: "r", Asset: "USD", Amount: big.NewInt(10)}, + {Account: "funder", Asset: "USD", Amount: big.NewInt(100)}, + }, + }, + { + name: "meta-derived destination", + src: ` + vars { account $d = meta(@config, "recipient") } + send [USD 10] ( + source = @world + destination = $d + ) + `, + meta: interpreter.AccountsMetadata{{Account: "config", Key: "recipient", Value: "out"}}, + }, + { + name: "destination allotment", + src: ` + send [USD 100] ( + source = @world + destination = { + 50% to @a + remaining to @b + } + ) + `, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + parsed := parser.Parse(tc.src) + require.Empty(t, parsed.Errors) + + static := interpreter.StaticStore{Balances: tc.balances, Meta: tc.meta} + log := &queryLog{ + inner: static, + balances: map[interpreter.AccountDependency]struct{}{}, + meta: map[interpreter.MetaDependency]struct{}{}, + } + + result, runErr := interpreter.RunProgram(context.Background(), parsed.Value, tc.vars, log, allFlags) + require.Nil(t, runErr) + + deps, err := interpreter.ResolveDependencies(context.Background(), static, tc.vars, parsed.Value) + require.NoError(t, err) + + for dep := range log.balances { + require.Contains(t, deps.AccountsReads, dep, "queried balance not resolved as a read") + } + for m := range log.meta { + require.Contains(t, deps.MetaReads, m, "queried metadata not resolved as a meta read") + } + + // posting accounts must be resolved writes (color isn't statically known for destinations) + writes := map[[3]string]struct{}{} + for w := range deps.AccountsWrites { + writes[[3]string{w.Account, w.Scope, w.Asset}] = struct{}{} + } + for _, p := range result.Postings { + require.Contains(t, writes, [3]string{p.Source, p.SourceScope, p.Asset}, "posting source not resolved as a write") + require.Contains(t, writes, [3]string{p.Destination, p.DestinationScope, p.Asset}, "posting destination not resolved as a write") + } + }) + } +} + func resolve(t *testing.T, src string, vars map[string]string, store interpreter.Store) interpreter.ResolvedDependencies { parsed := parser.Parse(src) require.Empty(t, parsed.Errors) @@ -364,6 +531,105 @@ func TestResolveDeeplyNestedSources(t *testing.T) { }, deps.AccountsWrites) } +func TestResolveDestinationInorder(t *testing.T) { + deps := resolve(t, ` + send [USD 100] ( + source = @world + destination = { + max [USD 20] to @a + remaining to @b + } + ) + `, nil, interpreter.StaticStore{}) + + require.Empty(t, deps.AccountsReads) + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "world", Asset: "USD"}: {}, + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveDestinationOneof(t *testing.T) { + deps := resolve(t, ` + send [USD 100] ( + source = @world + destination = oneof { + max [USD 20] to @a + remaining to @b + } + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "world", Asset: "USD"}: {}, + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveDestinationRemainingKept(t *testing.T) { + deps := resolve(t, ` + send [USD 100] ( + source = @world + destination = { + max [USD 20] to @a + remaining kept + } + ) + `, nil, interpreter.StaticStore{}) + + // "remaining kept" holds no account + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "world", Asset: "USD"}: {}, + {Account: "a", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveBalanceInsideDestinationCap(t *testing.T) { + deps := resolve(t, ` + send [USD 10] ( + source = @funder + destination = { + max balance(@reserve, USD) to @a + remaining to @b + } + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "funder", Asset: "USD"}: {}, + {Account: "reserve", Asset: "USD"}: {}, + }, deps.AccountsReads) + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "funder", Asset: "USD"}: {}, + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveNestedDestinations(t *testing.T) { + deps := resolve(t, ` + send [USD 100] ( + source = @world + destination = { + max [USD 50] to { + max [USD 10] to @a + remaining to @b + } + remaining to @c + } + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "world", Asset: "USD"}: {}, + {Account: "a", Asset: "USD"}: {}, + {Account: "b", Asset: "USD"}: {}, + {Account: "c", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + func TestResolveSetAccountMetaIsMetaWrite(t *testing.T) { deps := resolve(t, ` set_account_meta(@alice, "kyc", "verified") From 4a93432d114e5fe1209fad7f0e4fb59b2491dc2c Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 8 Jul 2026 16:47:08 +0200 Subject: [PATCH 08/12] fix: return ALL possible colors in dest --- internal/interpreter/interpreter.go | 18 --------- internal/interpreter/resolve_dependencies.go | 40 +++++++++++-------- .../interpreter/resolve_dependencies_test.go | 26 +++++++++++- 3 files changed, 48 insertions(+), 36 deletions(-) diff --git a/internal/interpreter/interpreter.go b/internal/interpreter/interpreter.go index 0caf6773..2ad6cfdf 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -946,24 +946,6 @@ func (s *programState) makeAllotment(monetary *big.Int, items []parser.Allotment return parts, nil } -// Utility function to get the balance -// getBalance implements *evalEnv: the raw (possibly negative) balance. -func (s *programState) getBalance( - account AccountAddress, - asset Asset, -) (*big.Int, InterpreterError) { - color := String("") - - s.batchQuery(account, asset, color) - fetchBalanceErr := s.runBalancesQuery() - if fetchBalanceErr != nil { - return nil, QueryBalanceError{WrappedError: fetchBalanceErr} - } - balance := s.CachedBalances.fetchBalance(account, asset, color) - return balance, nil - -} - func evaluateSentAmt(env *evalEnv, sentValue parser.SentValue) (Asset, *big.Int, InterpreterError) { switch sentValue := sentValue.(type) { case *parser.SentValueAll: diff --git a/internal/interpreter/resolve_dependencies.go b/internal/interpreter/resolve_dependencies.go index dba08cb2..435ae712 100644 --- a/internal/interpreter/resolve_dependencies.go +++ b/internal/interpreter/resolve_dependencies.go @@ -123,10 +123,13 @@ func (st *resolveDependenciesState) resolveStatement(statement parser.Statement) if err != nil { return err } - if err := st.resolveSource(statement.Source, asset); err != nil { + // funds keep their source color all the way to the destination, so a + // destination can be credited in any of the send's source colors + srcColors := map[String]struct{}{} + if err := st.resolveSource(statement.Source, asset, srcColors); err != nil { return err } - return st.resolveDestination(statement.Destination, asset) + return st.resolveDestination(statement.Destination, asset, srcColors) case *parser.SaveStatement: asset, _, err := evaluateSentAmt(st.env, statement.SentValue) @@ -172,7 +175,7 @@ func (st *resolveDependenciesState) resolveFnCallStatement(fnCall *parser.FnCall } } -func (st *resolveDependenciesState) resolveSource(source parser.Source, asset Asset) error { +func (st *resolveDependenciesState) resolveSource(source parser.Source, asset Asset, srcColors map[String]struct{}) error { switch source := source.(type) { case *parser.SourceAccount: account, err := evaluateExprAs(st.env, source.ValueExpr, expectAccount) @@ -183,6 +186,7 @@ func (st *resolveDependenciesState) resolveSource(source parser.Source, asset As if err != nil { return err } + srcColors[color] = struct{}{} st.recordSource(account, asset, color, account.Name == "world") return nil @@ -195,6 +199,7 @@ func (st *resolveDependenciesState) resolveSource(source parser.Source, asset As if err != nil { return err } + srcColors[color] = struct{}{} st.recordSource(account, asset, color, source.Bounded == nil) if source.Bounded != nil { return st.eval(*source.Bounded) @@ -203,7 +208,7 @@ func (st *resolveDependenciesState) resolveSource(source parser.Source, asset As case *parser.SourceInorder: for _, sub := range source.Sources { - if err := st.resolveSource(sub, asset); err != nil { + if err := st.resolveSource(sub, asset, srcColors); err != nil { return err } } @@ -211,7 +216,7 @@ func (st *resolveDependenciesState) resolveSource(source parser.Source, asset As case *parser.SourceOneof: for _, sub := range source.Sources { - if err := st.resolveSource(sub, asset); err != nil { + if err := st.resolveSource(sub, asset, srcColors); err != nil { return err } } @@ -221,14 +226,14 @@ func (st *resolveDependenciesState) resolveSource(source parser.Source, asset As if err := st.eval(source.Cap); err != nil { return err } - return st.resolveSource(source.From, asset) + return st.resolveSource(source.From, asset, srcColors) case *parser.SourceAllotment: for _, item := range source.Items { if err := st.resolveAllotment(item.Allotment); err != nil { return err } - if err := st.resolveSource(item.From, asset); err != nil { + if err := st.resolveSource(item.From, asset, srcColors); err != nil { return err } } @@ -252,14 +257,17 @@ func (st *resolveDependenciesState) recordSource(account AccountAddress, asset A addAccountDep(st.deps.AccountsWrites, account, asset, color) } -func (st *resolveDependenciesState) resolveDestination(destination parser.Destination, asset Asset) error { +func (st *resolveDependenciesState) resolveDestination(destination parser.Destination, asset Asset, srcColors map[String]struct{}) error { switch destination := destination.(type) { case *parser.DestinationAccount: account, err := evaluateExprAs(st.env, destination.ValueExpr, expectAccount) if err != nil { return err } - addAccountDep(st.deps.AccountsWrites, account, asset, "") + // the destination can be credited in any of the source colors + for color := range srcColors { + addAccountDep(st.deps.AccountsWrites, account, asset, color) + } return nil case *parser.DestinationInorder: @@ -267,29 +275,29 @@ func (st *resolveDependenciesState) resolveDestination(destination parser.Destin if err := st.eval(clause.Cap); err != nil { return err } - if err := st.resolveKeptOrDestination(clause.To, asset); err != nil { + if err := st.resolveKeptOrDestination(clause.To, asset, srcColors); err != nil { return err } } - return st.resolveKeptOrDestination(destination.Remaining, asset) + return st.resolveKeptOrDestination(destination.Remaining, asset, srcColors) case *parser.DestinationOneof: for _, clause := range destination.Clauses { if err := st.eval(clause.Cap); err != nil { return err } - if err := st.resolveKeptOrDestination(clause.To, asset); err != nil { + if err := st.resolveKeptOrDestination(clause.To, asset, srcColors); err != nil { return err } } - return st.resolveKeptOrDestination(destination.Remaining, asset) + return st.resolveKeptOrDestination(destination.Remaining, asset, srcColors) case *parser.DestinationAllotment: for _, item := range destination.Items { if err := st.resolveAllotment(item.Allotment); err != nil { return err } - if err := st.resolveKeptOrDestination(item.To, asset); err != nil { + if err := st.resolveKeptOrDestination(item.To, asset, srcColors); err != nil { return err } } @@ -301,12 +309,12 @@ func (st *resolveDependenciesState) resolveDestination(destination parser.Destin } } -func (st *resolveDependenciesState) resolveKeptOrDestination(kd parser.KeptOrDestination, asset Asset) error { +func (st *resolveDependenciesState) resolveKeptOrDestination(kd parser.KeptOrDestination, asset Asset, srcColors map[String]struct{}) error { switch kd := kd.(type) { case *parser.DestinationKept: return nil case *parser.DestinationTo: - return st.resolveDestination(kd.Destination, asset) + return st.resolveDestination(kd.Destination, asset, srcColors) default: utils.NonExhaustiveMatchPanic[any](kd) return nil diff --git a/internal/interpreter/resolve_dependencies_test.go b/internal/interpreter/resolve_dependencies_test.go index 0a73e9f7..451d27e2 100644 --- a/internal/interpreter/resolve_dependencies_test.go +++ b/internal/interpreter/resolve_dependencies_test.go @@ -471,9 +471,31 @@ func TestResolveColoredSource(t *testing.T) { require.Equal(t, map[interpreter.AccountDependency]struct{}{ {Account: "src", Asset: "COIN", Color: "RED"}: {}, }, deps.AccountsReads) + // the destination is credited in the source's color require.Equal(t, map[interpreter.AccountDependency]struct{}{ - {Account: "src", Asset: "COIN", Color: "RED"}: {}, - {Account: "dest", Asset: "COIN"}: {}, + {Account: "src", Asset: "COIN", Color: "RED"}: {}, + {Account: "dest", Asset: "COIN", Color: "RED"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveMixedColorSourceWidensDestination(t *testing.T) { + // funds from a colored and an uncolored source can both reach the + // destination, so it's recorded as a write in every source color + deps := resolve(t, ` + send [COIN 10] ( + source = { + @s1 \ "C" + @s2 + } + destination = @dest + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "s1", Asset: "COIN", Color: "C"}: {}, + {Account: "s2", Asset: "COIN"}: {}, + {Account: "dest", Asset: "COIN", Color: "C"}: {}, + {Account: "dest", Asset: "COIN"}: {}, }, deps.AccountsWrites) } From cd2f15abc731ed67fc241a045f8c6d70e93b5b3a Mon Sep 17 00:00:00 2001 From: ascandone Date: Wed, 8 Jul 2026 17:45:40 +0200 Subject: [PATCH 09/12] add tests --- .../interpreter/resolve_dependencies_test.go | 67 ++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/internal/interpreter/resolve_dependencies_test.go b/internal/interpreter/resolve_dependencies_test.go index 451d27e2..7b99b592 100644 --- a/internal/interpreter/resolve_dependencies_test.go +++ b/internal/interpreter/resolve_dependencies_test.go @@ -492,8 +492,8 @@ func TestResolveMixedColorSourceWidensDestination(t *testing.T) { `, nil, interpreter.StaticStore{}) require.Equal(t, map[interpreter.AccountDependency]struct{}{ - {Account: "s1", Asset: "COIN", Color: "C"}: {}, - {Account: "s2", Asset: "COIN"}: {}, + {Account: "s1", Asset: "COIN", Color: "C"}: {}, + {Account: "s2", Asset: "COIN"}: {}, {Account: "dest", Asset: "COIN", Color: "C"}: {}, {Account: "dest", Asset: "COIN"}: {}, }, deps.AccountsWrites) @@ -652,6 +652,69 @@ func TestResolveNestedDestinations(t *testing.T) { }, deps.AccountsWrites) } +func TestResolveScopedSource(t *testing.T) { + deps := resolve(t, ` + send [USD 10] ( + source = scoped(@treasury, "reserve") + destination = @out + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "treasury", Scope: "reserve", Asset: "USD"}: {}, + }, deps.AccountsReads) + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "treasury", Scope: "reserve", Asset: "USD"}: {}, + {Account: "out", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveScopedDestination(t *testing.T) { + deps := resolve(t, ` + send [USD 10] ( + source = @world + destination = scoped(@out, "escrow") + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "world", Asset: "USD"}: {}, + {Account: "out", Scope: "escrow", Asset: "USD"}: {}, + }, deps.AccountsWrites) +} + +func TestResolveScopedBalanceRead(t *testing.T) { + deps := resolve(t, ` + vars { monetary $b = balance(scoped(@treasury, "reserve"), USD) } + send $b ( + source = @world + destination = @out + ) + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[interpreter.AccountDependency]struct{}{ + {Account: "treasury", Scope: "reserve", Asset: "USD"}: {}, + }, deps.AccountsReads) +} + +func TestResolveScopedMetaRead(t *testing.T) { + deps := resolve(t, ` + vars { account $d = meta(scoped(@config, "settings"), "recipient") } + send [USD 10] ( + source = @world + destination = @out + ) + `, nil, interpreter.StaticStore{ + Meta: interpreter.AccountsMetadata{ + {Account: "config", Scope: "settings", Key: "recipient", Value: "out"}, + }, + }) + + require.Equal(t, map[interpreter.MetaDependency]struct{}{ + {Account: "config", Scope: "settings", Key: "recipient"}: {}, + }, deps.MetaReads) +} + func TestResolveSetAccountMetaIsMetaWrite(t *testing.T) { deps := resolve(t, ` set_account_meta(@alice, "kyc", "verified") From b928a5a970b6652bcb1339ca455530982e3ace03 Mon Sep 17 00:00:00 2001 From: ascandone Date: Thu, 9 Jul 2026 18:09:15 +0200 Subject: [PATCH 10/12] feat: add tx meta --- internal/interpreter/resolve_dependencies.go | 12 ++++++++++++ internal/interpreter/resolve_dependencies_test.go | 11 +++++++++++ 2 files changed, 23 insertions(+) diff --git a/internal/interpreter/resolve_dependencies.go b/internal/interpreter/resolve_dependencies.go index 435ae712..9d853a0c 100644 --- a/internal/interpreter/resolve_dependencies.go +++ b/internal/interpreter/resolve_dependencies.go @@ -29,6 +29,7 @@ type ResolvedDependencies struct { AccountsWrites map[AccountDependency]struct{} MetaReads map[MetaDependency]struct{} MetaWrites map[MetaDependency]struct{} + TxMetaWrites map[string]struct{} } // recordingStore wraps a Store and records every balance/metadata it is queried @@ -72,6 +73,7 @@ func ResolveDependencies(ctx context.Context, store Store, vars map[string]strin AccountsWrites: map[AccountDependency]struct{}{}, MetaReads: map[MetaDependency]struct{}{}, MetaWrites: map[MetaDependency]struct{}{}, + TxMetaWrites: map[string]struct{}{}, } recording := &recordingStore{inner: store, reads: deps.AccountsReads, metaReads: deps.MetaReads} @@ -170,6 +172,16 @@ func (st *resolveDependenciesState) resolveFnCallStatement(fnCall *parser.FnCall addMetaDep(st.deps.MetaWrites, account, string(key)) return nil + case analysis.FnSetTxMeta: + p := NewArgsParser(args) + key := parseArg(p, fnCall.Range, expectString) + _ = parseArg(p, fnCall.Range, expectAnything) + if err := p.parse(); err != nil { + return err + } + st.deps.TxMetaWrites[string(key)] = struct{}{} + return nil + default: return nil } diff --git a/internal/interpreter/resolve_dependencies_test.go b/internal/interpreter/resolve_dependencies_test.go index 7b99b592..23026240 100644 --- a/internal/interpreter/resolve_dependencies_test.go +++ b/internal/interpreter/resolve_dependencies_test.go @@ -726,6 +726,17 @@ func TestResolveSetAccountMetaIsMetaWrite(t *testing.T) { require.Empty(t, deps.MetaReads) } +func TestResolveSetTxMetaIsTxMetaWrite(t *testing.T) { + deps := resolve(t, ` + set_tx_meta("priority", "high") + `, nil, interpreter.StaticStore{}) + + require.Equal(t, map[string]struct{}{ + "priority": {}, + }, deps.TxMetaWrites) + require.Empty(t, deps.MetaWrites) +} + func TestResolveScalingIsNotSupported(t *testing.T) { parsed := parser.Parse(` send [USD 10] ( From 5d119e315d44bc77c4e74c45ac0292ca5d476814 Mon Sep 17 00:00:00 2001 From: ascandone Date: Thu, 9 Jul 2026 19:38:15 +0200 Subject: [PATCH 11/12] more diligent error handling --- internal/interpreter/evaluate_expr.go | 13 +++++------ internal/interpreter/interpreter.go | 3 +-- internal/interpreter/interpreter_error.go | 16 +++++++++++++ internal/interpreter/resolve_dependencies.go | 23 +++++++++---------- .../interpreter/resolve_dependencies_test.go | 8 +++++++ 5 files changed, 42 insertions(+), 21 deletions(-) diff --git a/internal/interpreter/evaluate_expr.go b/internal/interpreter/evaluate_expr.go index 38acd81d..564ad9d8 100644 --- a/internal/interpreter/evaluate_expr.go +++ b/internal/interpreter/evaluate_expr.go @@ -7,7 +7,6 @@ import ( "github.com/formancehq/numscript/internal/flags" "github.com/formancehq/numscript/internal/parser" - "github.com/formancehq/numscript/internal/utils" ) type evalEnv struct { @@ -122,6 +121,9 @@ func evaluateExpr(env *evalEnv, expr parser.ValueExpr) (Value, InterpreterError) return nil, err } parts = append(parts, strValue) + + default: + return nil, unhandledErr(part) } } name := strings.Join(parts, "") @@ -168,8 +170,7 @@ func evaluateExpr(env *evalEnv, expr parser.ValueExpr) (Value, InterpreterError) return divOp(env, expr.Range, expr.Left, expr.Right) default: - utils.NonExhaustiveMatchPanic[any](expr.Operator) - return nil, nil + return nil, unhandledErr(expr.Operator) } case *parser.Prefix: @@ -178,8 +179,7 @@ func evaluateExpr(env *evalEnv, expr parser.ValueExpr) (Value, InterpreterError) return unaryNegOp(env, expr.Expr) default: - utils.NonExhaustiveMatchPanic[any](expr.Operator) - return nil, nil + return nil, unhandledErr(expr.Operator) } case *parser.FnCall: @@ -187,8 +187,7 @@ func evaluateExpr(env *evalEnv, expr parser.ValueExpr) (Value, InterpreterError) return evaluateFnCall(env, nil, *expr) default: - utils.NonExhaustiveMatchPanic[any](expr) - return nil, nil + return nil, unhandledErr(expr) } } diff --git a/internal/interpreter/interpreter.go b/internal/interpreter/interpreter.go index 2ad6cfdf..ed6f8c50 100644 --- a/internal/interpreter/interpreter.go +++ b/internal/interpreter/interpreter.go @@ -965,8 +965,7 @@ func evaluateSentAmt(env *evalEnv, sentValue parser.SentValue) (Asset, *big.Int, return monetary.Asset, &bi, nil default: - utils.NonExhaustiveMatchPanic[any](sentValue) - return "", nil, nil + return "", nil, unhandledErr(sentValue) } } diff --git a/internal/interpreter/interpreter_error.go b/internal/interpreter/interpreter_error.go index bd03f042..e228ceea 100644 --- a/internal/interpreter/interpreter_error.go +++ b/internal/interpreter/interpreter_error.go @@ -7,6 +7,22 @@ import ( "github.com/formancehq/numscript/internal/parser" ) +// UnhandledError is returned instead of panicking when expression evaluation or +// dependency resolution meets an AST node it doesn't handle (e.g. a language +// construct added after this code was written). +type UnhandledError struct { + parser.Range + Node string +} + +func (e UnhandledError) Error() string { + return fmt.Sprintf("internal error: unhandled ast node (%s)", e.Node) +} + +func unhandledErr(node any) UnhandledError { + return UnhandledError{Node: fmt.Sprintf("%#v", node)} +} + type InternalError struct { parser.Range Posting Posting diff --git a/internal/interpreter/resolve_dependencies.go b/internal/interpreter/resolve_dependencies.go index 9d853a0c..b77eb300 100644 --- a/internal/interpreter/resolve_dependencies.go +++ b/internal/interpreter/resolve_dependencies.go @@ -6,7 +6,6 @@ import ( "github.com/formancehq/numscript/internal/analysis" "github.com/formancehq/numscript/internal/parser" - "github.com/formancehq/numscript/internal/utils" ) var ErrScalingNotSupported = errors.New("scaling is not supported") @@ -149,8 +148,7 @@ func (st *resolveDependenciesState) resolveStatement(statement parser.Statement) return st.resolveFnCallStatement(statement) default: - utils.NonExhaustiveMatchPanic[any](statement) - return nil + return unhandledErr(statement) } } @@ -183,7 +181,7 @@ func (st *resolveDependenciesState) resolveFnCallStatement(fnCall *parser.FnCall return nil default: - return nil + return UnboundFunctionErr{Name: fnCall.Caller.Name} } } @@ -255,8 +253,7 @@ func (st *resolveDependenciesState) resolveSource(source parser.Source, asset As return ErrScalingNotSupported default: - utils.NonExhaustiveMatchPanic[any](source) - return nil + return unhandledErr(source) } } @@ -316,8 +313,7 @@ func (st *resolveDependenciesState) resolveDestination(destination parser.Destin return nil default: - utils.NonExhaustiveMatchPanic[any](destination) - return nil + return unhandledErr(destination) } } @@ -328,14 +324,17 @@ func (st *resolveDependenciesState) resolveKeptOrDestination(kd parser.KeptOrDes case *parser.DestinationTo: return st.resolveDestination(kd.Destination, asset, srcColors) default: - utils.NonExhaustiveMatchPanic[any](kd) - return nil + return unhandledErr(kd) } } func (st *resolveDependenciesState) resolveAllotment(allotment parser.AllotmentValue) error { - if allotment, ok := allotment.(*parser.ValueExprAllotment); ok { + switch allotment := allotment.(type) { + case *parser.ValueExprAllotment: return st.eval(allotment.Value) + case *parser.RemainingAllotment: + return nil + default: + return unhandledErr(allotment) } - return nil } diff --git a/internal/interpreter/resolve_dependencies_test.go b/internal/interpreter/resolve_dependencies_test.go index 23026240..94696e32 100644 --- a/internal/interpreter/resolve_dependencies_test.go +++ b/internal/interpreter/resolve_dependencies_test.go @@ -737,6 +737,14 @@ func TestResolveSetTxMetaIsTxMetaWrite(t *testing.T) { require.Empty(t, deps.MetaWrites) } +func TestResolveUnknownStatementFnErrors(t *testing.T) { + parsed := parser.Parse(`unknown_fn(@a, "x")`) + require.Empty(t, parsed.Errors) + + _, err := interpreter.ResolveDependencies(context.Background(), interpreter.StaticStore{}, nil, parsed.Value) + require.IsType(t, interpreter.UnboundFunctionErr{}, err) +} + func TestResolveScalingIsNotSupported(t *testing.T) { parsed := parser.Parse(` send [USD 10] ( From 92efd3e3a5ac9b6ed12f1af78341cdbad71f9d9d Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 10 Jul 2026 11:14:22 +0200 Subject: [PATCH 12/12] fix: use meta cache properly --- internal/interpreter/evaluate_expr.go | 6 +++++- internal/interpreter/internal_accounts_metadata.go | 7 +++++++ internal/interpreter/resolve_dependencies_test.go | 7 +++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/internal/interpreter/evaluate_expr.go b/internal/interpreter/evaluate_expr.go index 564ad9d8..98e742b4 100644 --- a/internal/interpreter/evaluate_expr.go +++ b/internal/interpreter/evaluate_expr.go @@ -60,13 +60,17 @@ func (env *evalEnv) getBalance(account AccountAddress, asset Asset) (*big.Int, I } func (env *evalEnv) getMetadata(account AccountAddress, key string) (string, bool, InterpreterError) { + if value, ok := env.CachedAccountsMeta.Get(account.Name, account.Scope, key); ok { + return value, true, nil + } + rows, err := env.Store.GetAccountsMetadata(env.ctx, MetadataQuery{ {Account: account.Name, Scope: account.Scope, Keys: []string{key}}, }) if err != nil { return "", false, QueryMetadataError{WrappedError: err} } - env.CachedAccountsMeta = FromAccountsMetadataRows(rows) + env.CachedAccountsMeta.Merge(rows) value, ok := env.CachedAccountsMeta.Get(account.Name, account.Scope, key) return value, ok, nil diff --git a/internal/interpreter/internal_accounts_metadata.go b/internal/interpreter/internal_accounts_metadata.go index 0112f827..8e861f81 100644 --- a/internal/interpreter/internal_accounts_metadata.go +++ b/internal/interpreter/internal_accounts_metadata.go @@ -28,3 +28,10 @@ func (m InternalAccountsMetadata) Get(account, scope, key string) (string, bool) value, ok := m[metadataKey{Account: account, Scope: scope, Key: key}] return value, ok } + +// Merge adds the given rows into the cache, keeping already-cached entries. +func (m InternalAccountsMetadata) Merge(rows AccountsMetadata) { + for _, row := range rows { + m[metadataKey{Account: row.Account, Scope: row.Scope, Key: row.Key}] = row.Value + } +} diff --git a/internal/interpreter/resolve_dependencies_test.go b/internal/interpreter/resolve_dependencies_test.go index 94696e32..8acd1985 100644 --- a/internal/interpreter/resolve_dependencies_test.go +++ b/internal/interpreter/resolve_dependencies_test.go @@ -136,6 +136,13 @@ func TestResolveDependenciesCoversRuntime(t *testing.T) { ) `, }, + { + name: "save", + src: ` + save [USD 10] from @a + `, + balances: interpreter.Balances{{Account: "a", Asset: "USD", Amount: big.NewInt(100)}}, + }, } for _, tc := range testCases {