From cc8e282b1dcf4c80ba2c074b5f7a0ba719ff2ca3 Mon Sep 17 00:00:00 2001 From: ascandone Date: Thu, 18 Jun 2026 12:02:30 +0200 Subject: [PATCH 1/8] api prototype (no vars yet) --- builder/builder.go | 85 ++++++++++++++++++++++++ builder/builder_test.go | 132 +++++++++++++++++++++++++++++++++++++ builder/destination.go | 7 ++ builder/expression.go | 46 +++++++++++++ builder/expression_type.go | 26 ++++++++ builder/pool.go | 11 ++++ builder/source.go | 31 +++++++++ builder/statement.go | 19 ++++++ 8 files changed, 357 insertions(+) create mode 100644 builder/builder.go create mode 100644 builder/builder_test.go create mode 100644 builder/destination.go create mode 100644 builder/expression.go create mode 100644 builder/expression_type.go create mode 100644 builder/pool.go create mode 100644 builder/source.go create mode 100644 builder/statement.go diff --git a/builder/builder.go b/builder/builder.go new file mode 100644 index 00000000..680cfdaa --- /dev/null +++ b/builder/builder.go @@ -0,0 +1,85 @@ +// Builder to format numscript document +package builder + +import ( + "fmt" + "strings" +) + +const identStr = " " + +func (p pool[T]) getItemId(elem T) int { + previousLookup, isElemInPool := p.elems[elem] + if !isElemInPool { + elemId := len(p.elems) + p.elems[elem] = elemId + previousLookup = elemId + } + return previousLookup +} + +type env struct { + builder *strings.Builder + + accountsPool pool[string] + assetsPool pool[string] + stringsPool pool[string] + // numbersPool pool[*big.Int] +} + +func writeIndentation(env env, w int) { + if w == 0 { + return + } + + env.builder.Grow(w * len(identStr)) + for range w { + env.builder.WriteString(identStr) + } +} + +func newEnv() env { + var sb strings.Builder + return env{ + builder: &sb, + accountsPool: newPool[string](), + assetsPool: newPool[string](), + stringsPool: newPool[string](), + // numbersPool: newPool[*big.Int](), + } +} + +// The underlying type of any a pretty printing document +type render = func( + env env, + + // The current width + w int, +) + +func itemIdToName(id int, prefix string) string { + return fmt.Sprintf("$%s_%d", prefix, id) +} +func accountToName(id int) string { + return itemIdToName(id, "account") +} +func assetToName(id int) string { + return itemIdToName(id, "asset") +} +func numberToName(id int) string { + return itemIdToName(id, "number") +} +func stringToName(id int) string { + return itemIdToName(id, "string") +} + +// TODO double check this one (do we need to handle vars?) +func BuildProgram(statements ...Statement) (any, string) { + env := newEnv() + for _, stmt := range statements { + stmt(env, 0) + } + + // TODO!! vars needs to be returned + return nil, env.builder.String() +} diff --git a/builder/builder_test.go b/builder/builder_test.go new file mode 100644 index 00000000..228967eb --- /dev/null +++ b/builder/builder_test.go @@ -0,0 +1,132 @@ +package builder_test + +import ( + "math/big" + "testing" + + "github.com/formancehq/numscript/builder" + "github.com/gkampitakis/go-snaps/snaps" +) + +func TestSimpleSend(t *testing.T) { + stmt := builder.StmtSend( + builder.ExprMonetary( + builder.ExprAsset("USD/2"), + builder.ExprNumberBigInt(big.NewInt(42)), + ), + builder.SrcAccount( + builder.ExprAccount("src"), + ), + builder.DestAccount( + builder.ExprAccount("dest"), + ), + ) + + _, script := builder.BuildProgram(stmt) + snaps.MatchInlineSnapshot(t, script, snaps.Inline(`send [$asset_0 42] ( + source = $account_0 +)`)) +} + +func TestInorder(t *testing.T) { + stmt := builder.StmtSend( + builder.ExprMonetary( + builder.ExprAsset("USD/2"), + builder.ExprNumberBigInt(big.NewInt(42)), + ), + builder.SrcInorder( + builder.SrcAccount( + builder.ExprAccount("src1"), + ), + builder.SrcAccount( + builder.ExprAccount("src2"), + ), + ), + builder.DestAccount( + builder.ExprAccount("dest"), + ), + ) + + _, script := builder.BuildProgram(stmt) + snaps.MatchInlineSnapshot(t, script, snaps.Inline(`send [$asset_0 42] ( + source = { + $account_0 + $account_1 + } +)`), + ) +} + +func TestInorderNested(t *testing.T) { + stmt := builder.StmtSend( + builder.ExprMonetary( + builder.ExprAsset("USD/2"), + builder.ExprNumberBigInt(big.NewInt(42)), + ), + builder.SrcInorder( + builder.SrcAccount( + builder.ExprAccount("src1"), + ), + builder.SrcAccount( + builder.ExprAccount("src2"), + ), + builder.SrcInorder( + builder.SrcAccount( + builder.ExprAccount("src_nested1"), + ), + builder.SrcAccount( + builder.ExprAccount("src_nested2"), + ), + ), + builder.SrcAccount( + builder.ExprAccount("src_upper"), + ), + ), + builder.DestAccount( + builder.ExprAccount("dest"), + ), + ) + + _, script := builder.BuildProgram(stmt) + snaps.MatchInlineSnapshot(t, script, snaps.Inline(`send [$asset_0 42] ( + source = { + $account_0 + $account_1 + { + $account_2 + $account_3 + } + $account_4 + } +)`)) +} + +func TestInorderWithColors(t *testing.T) { + stmt := builder.StmtSend( + builder.ExprMonetary( + builder.ExprAsset("USD/2"), + builder.ExprNumberBigInt(big.NewInt(42)), + ), + builder.SrcInorder( + builder.SrcColored( + builder.ExprAccount("acc"), + builder.ExprString("col"), + ), + builder.SrcAccount( + builder.ExprAccount("src2"), + ), + ), + builder.DestAccount( + builder.ExprAccount("dest"), + ), + ) + + _, script := builder.BuildProgram(stmt) + snaps.MatchInlineSnapshot(t, script, snaps.Inline(`send [$asset_0 42] ( + source = { + $account_0 \ $string_0 + $account_1 + } +)`), + ) +} diff --git a/builder/destination.go b/builder/destination.go new file mode 100644 index 00000000..2c326d0a --- /dev/null +++ b/builder/destination.go @@ -0,0 +1,7 @@ +package builder + +type Destination render + +func DestAccount(expr Expression[ExprTypeAccount]) Destination { + return Destination(expr) +} diff --git a/builder/expression.go b/builder/expression.go new file mode 100644 index 00000000..0b9aff51 --- /dev/null +++ b/builder/expression.go @@ -0,0 +1,46 @@ +package builder + +import "math/big" + +type Expression[T ExprType] render + +func ExprAccount(name string) Expression[ExprTypeAccount] { + return func(env env, w int) { + id := env.accountsPool.getItemId(name) + env.builder.WriteString(accountToName(id)) + } +} + +func ExprAsset(name string) Expression[ExprTypeAsset] { + return func(env env, w int) { + id := env.assetsPool.getItemId(name) + env.builder.WriteString(assetToName(id)) + } +} + +func ExprString(name string) Expression[ExprTypeString] { + return func(env env, w int) { + id := env.stringsPool.getItemId(name) + env.builder.WriteString(stringToName(id)) + } +} + +func ExprNumberBigInt(amount *big.Int) Expression[ExprTypeNumber] { + // we don't risk injection with numbers so we can just pprint them right away + return func(env env, w int) { + env.builder.WriteString(amount.String()) + } +} + +func ExprMonetary( + asset Expression[ExprTypeAsset], + amount Expression[ExprTypeNumber], +) Expression[ExprTypeMonetary] { + return func(env env, w int) { + env.builder.WriteString("[") + asset(env, w) + env.builder.WriteString(" ") + amount(env, w) + env.builder.WriteString("]") + } +} diff --git a/builder/expression_type.go b/builder/expression_type.go new file mode 100644 index 00000000..f870358d --- /dev/null +++ b/builder/expression_type.go @@ -0,0 +1,26 @@ +package builder + +type ExprType interface { + exprType() +} + +type ExprTypeString interface { + ExprType + string() +} +type ExprTypeAccount interface { + ExprType + account() +} +type ExprTypeAsset interface { + ExprType + asset() +} +type ExprTypeNumber interface { + ExprType + number() +} +type ExprTypeMonetary interface { + ExprType + monetary() +} diff --git a/builder/pool.go b/builder/pool.go new file mode 100644 index 00000000..54161543 --- /dev/null +++ b/builder/pool.go @@ -0,0 +1,11 @@ +package builder + +type pool[T comparable] struct { + elems map[T]int +} + +func newPool[T comparable]() pool[T] { + return pool[T]{ + elems: make(map[T]int), + } +} diff --git a/builder/source.go b/builder/source.go new file mode 100644 index 00000000..7ae2c974 --- /dev/null +++ b/builder/source.go @@ -0,0 +1,31 @@ +package builder + +type Source render + +func SrcAccount(expr Expression[ExprTypeAccount]) Source { + return Source(expr) +} + +func SrcColored( + accountExpr Expression[ExprTypeAccount], + colorExpr Expression[ExprTypeString], +) Source { + return func(env env, w int) { + accountExpr(env, w) + env.builder.WriteString(" \\ ") + colorExpr(env, w) + } +} + +func SrcInorder(sources ...Source) Source { + return func(env env, w int) { + env.builder.WriteString("{\n") + for _, src := range sources { + writeIndentation(env, w+1) + src(env, w+1) + env.builder.WriteByte('\n') + } + writeIndentation(env, w) + env.builder.WriteByte('}') + } +} diff --git a/builder/statement.go b/builder/statement.go new file mode 100644 index 00000000..a6d2375a --- /dev/null +++ b/builder/statement.go @@ -0,0 +1,19 @@ +package builder + +type Statement render + +// A bounded send statement +func StmtSend( + monetary Expression[ExprTypeMonetary], + source Source, + destination Destination, +) Statement { + return func(env env, w int) { + env.builder.WriteString("send ") + monetary(env, 0) + env.builder.WriteString(" (") + env.builder.WriteString("\n source = ") + source(env, w+1) + env.builder.WriteString("\n)") + } +} From f835d4a4ad44f13e3ed8a032e927081d18b862f1 Mon Sep 17 00:00:00 2001 From: ascandone Date: Thu, 18 Jun 2026 23:34:50 +0200 Subject: [PATCH 2/8] impl vars block --- builder/builder.go | 44 +++++++++++++++++++++++++++++++++++++---- builder/builder_test.go | 41 ++++++++++++++++++++++++++++++-------- 2 files changed, 73 insertions(+), 12 deletions(-) diff --git a/builder/builder.go b/builder/builder.go index 680cfdaa..666ca961 100644 --- a/builder/builder.go +++ b/builder/builder.go @@ -6,7 +6,7 @@ import ( "strings" ) -const identStr = " " +const indentStr = " " func (p pool[T]) getItemId(elem T) int { previousLookup, isElemInPool := p.elems[elem] @@ -32,9 +32,9 @@ func writeIndentation(env env, w int) { return } - env.builder.Grow(w * len(identStr)) + env.builder.Grow(w * len(indentStr)) for range w { - env.builder.WriteString(identStr) + env.builder.WriteString(indentStr) } } @@ -73,6 +73,39 @@ func stringToName(id int) string { return itemIdToName(id, "string") } +func renderVars(env env) string { + var sb strings.Builder + + hasVars := false + + renderVarsTyp := func( + typ string, + pool pool[string], + getVarName func(id int) string, + ) { + for id := range len(pool.elems) { + hasVars = true + sb.WriteString(indentStr) + sb.WriteString(typ) + sb.WriteString(" ") + sb.WriteString(getVarName(id)) + sb.WriteByte('\n') + } + } + + sb.WriteString("vars {\n") + renderVarsTyp("account", env.accountsPool, accountToName) + renderVarsTyp("string", env.stringsPool, stringToName) + renderVarsTyp("asset", env.assetsPool, assetToName) + sb.WriteString("}\n\n") + + if !hasVars { + return "" + } + + return sb.String() +} + // TODO double check this one (do we need to handle vars?) func BuildProgram(statements ...Statement) (any, string) { env := newEnv() @@ -80,6 +113,9 @@ func BuildProgram(statements ...Statement) (any, string) { stmt(env, 0) } + // AFTER we've rendered the whole program, we can render the vars block + vars := renderVars(env) + // TODO!! vars needs to be returned - return nil, env.builder.String() + return nil, vars + env.builder.String() } diff --git a/builder/builder_test.go b/builder/builder_test.go index 228967eb..cb4c8969 100644 --- a/builder/builder_test.go +++ b/builder/builder_test.go @@ -23,7 +23,12 @@ func TestSimpleSend(t *testing.T) { ) _, script := builder.BuildProgram(stmt) - snaps.MatchInlineSnapshot(t, script, snaps.Inline(`send [$asset_0 42] ( + snaps.MatchInlineSnapshot(t, script, snaps.Inline(`vars { + account $account_0 + asset $asset_0 +} + +send [$asset_0 42] ( source = $account_0 )`)) } @@ -48,13 +53,18 @@ func TestInorder(t *testing.T) { ) _, script := builder.BuildProgram(stmt) - snaps.MatchInlineSnapshot(t, script, snaps.Inline(`send [$asset_0 42] ( + snaps.MatchInlineSnapshot(t, script, snaps.Inline(`vars { + account $account_0 + account $account_1 + asset $asset_0 +} + +send [$asset_0 42] ( source = { $account_0 $account_1 } -)`), - ) +)`)) } func TestInorderNested(t *testing.T) { @@ -88,7 +98,16 @@ func TestInorderNested(t *testing.T) { ) _, script := builder.BuildProgram(stmt) - snaps.MatchInlineSnapshot(t, script, snaps.Inline(`send [$asset_0 42] ( + snaps.MatchInlineSnapshot(t, script, snaps.Inline(`vars { + account $account_0 + account $account_1 + account $account_2 + account $account_3 + account $account_4 + asset $asset_0 +} + +send [$asset_0 42] ( source = { $account_0 $account_1 @@ -122,11 +141,17 @@ func TestInorderWithColors(t *testing.T) { ) _, script := builder.BuildProgram(stmt) - snaps.MatchInlineSnapshot(t, script, snaps.Inline(`send [$asset_0 42] ( + snaps.MatchInlineSnapshot(t, script, snaps.Inline(`vars { + account $account_0 + account $account_1 + string $string_0 + asset $asset_0 +} + +send [$asset_0 42] ( source = { $account_0 \ $string_0 $account_1 } -)`), - ) +)`)) } From 43dcc9e4a8a6de26fee21a569f8d46c2931d08ff Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 19 Jun 2026 00:16:44 +0200 Subject: [PATCH 3/8] handle vars --- builder/builder.go | 42 ++++++++++++++++++++++++++++++----------- builder/builder_test.go | 9 ++++++++- builder/expression.go | 3 +++ 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/builder/builder.go b/builder/builder.go index 666ca961..cb5c1c2e 100644 --- a/builder/builder.go +++ b/builder/builder.go @@ -3,6 +3,9 @@ package builder import ( "fmt" + "iter" + "maps" + "slices" "strings" ) @@ -58,7 +61,7 @@ type render = func( ) func itemIdToName(id int, prefix string) string { - return fmt.Sprintf("$%s_%d", prefix, id) + return fmt.Sprintf("%s_%d", prefix, id) } func accountToName(id int) string { return itemIdToName(id, "account") @@ -66,14 +69,27 @@ func accountToName(id int) string { func assetToName(id int) string { return itemIdToName(id, "asset") } -func numberToName(id int) string { - return itemIdToName(id, "number") -} func stringToName(id int) string { return itemIdToName(id, "string") } -func renderVars(env env) string { +func sorted[K ~string, V any](m map[K]V) iter.Seq2[K, V] { + return func(yield func(K, V) bool) { + keys := slices.Collect(maps.Keys(m)) + slices.Sort(keys) + + for _, k := range keys { + if !yield(k, m[k]) { + return + } + } + } +} + +func renderVars( + env env, + knownBindings map[string]string, +) string { var sb strings.Builder hasVars := false @@ -83,12 +99,15 @@ func renderVars(env env) string { pool pool[string], getVarName func(id int) string, ) { - for id := range len(pool.elems) { + for value, id := range sorted(pool.elems) { hasVars = true + varName := getVarName(id) + knownBindings[varName] = value + sb.WriteString(indentStr) sb.WriteString(typ) - sb.WriteString(" ") - sb.WriteString(getVarName(id)) + sb.WriteString(" $") + sb.WriteString(varName) sb.WriteByte('\n') } } @@ -113,9 +132,10 @@ func BuildProgram(statements ...Statement) (any, string) { stmt(env, 0) } + knownBindings := map[string]string{} + // AFTER we've rendered the whole program, we can render the vars block - vars := renderVars(env) + vars := renderVars(env, knownBindings) - // TODO!! vars needs to be returned - return nil, vars + env.builder.String() + return knownBindings, vars + env.builder.String() } diff --git a/builder/builder_test.go b/builder/builder_test.go index cb4c8969..2f32a292 100644 --- a/builder/builder_test.go +++ b/builder/builder_test.go @@ -6,6 +6,7 @@ import ( "github.com/formancehq/numscript/builder" "github.com/gkampitakis/go-snaps/snaps" + "github.com/stretchr/testify/require" ) func TestSimpleSend(t *testing.T) { @@ -52,7 +53,7 @@ func TestInorder(t *testing.T) { ), ) - _, script := builder.BuildProgram(stmt) + vars, script := builder.BuildProgram(stmt) snaps.MatchInlineSnapshot(t, script, snaps.Inline(`vars { account $account_0 account $account_1 @@ -65,6 +66,12 @@ send [$asset_0 42] ( $account_1 } )`)) + + require.Equal(t, map[string]string{ + "account_0": "src1", + "account_1": "src2", + "asset_0": "USD/2", + }, vars) } func TestInorderNested(t *testing.T) { diff --git a/builder/expression.go b/builder/expression.go index 0b9aff51..57f43178 100644 --- a/builder/expression.go +++ b/builder/expression.go @@ -7,6 +7,7 @@ type Expression[T ExprType] render func ExprAccount(name string) Expression[ExprTypeAccount] { return func(env env, w int) { id := env.accountsPool.getItemId(name) + env.builder.WriteByte('$') env.builder.WriteString(accountToName(id)) } } @@ -14,6 +15,7 @@ func ExprAccount(name string) Expression[ExprTypeAccount] { func ExprAsset(name string) Expression[ExprTypeAsset] { return func(env env, w int) { id := env.assetsPool.getItemId(name) + env.builder.WriteByte('$') env.builder.WriteString(assetToName(id)) } } @@ -21,6 +23,7 @@ func ExprAsset(name string) Expression[ExprTypeAsset] { func ExprString(name string) Expression[ExprTypeString] { return func(env env, w int) { id := env.stringsPool.getItemId(name) + env.builder.WriteByte('$') env.builder.WriteString(stringToName(id)) } } From d5a2fea933434fe4ca2637e7bcd5d64fb0df311d Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 19 Jun 2026 00:31:30 +0200 Subject: [PATCH 4/8] fix ordering --- builder/builder.go | 26 ++++++++++---------------- builder/builder_test.go | 9 +++++++++ builder/statement.go | 2 ++ 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/builder/builder.go b/builder/builder.go index cb5c1c2e..d08f9533 100644 --- a/builder/builder.go +++ b/builder/builder.go @@ -2,8 +2,8 @@ package builder import ( + "cmp" "fmt" - "iter" "maps" "slices" "strings" @@ -73,19 +73,6 @@ func stringToName(id int) string { return itemIdToName(id, "string") } -func sorted[K ~string, V any](m map[K]V) iter.Seq2[K, V] { - return func(yield func(K, V) bool) { - keys := slices.Collect(maps.Keys(m)) - slices.Sort(keys) - - for _, k := range keys { - if !yield(k, m[k]) { - return - } - } - } -} - func renderVars( env env, knownBindings map[string]string, @@ -99,10 +86,17 @@ func renderVars( pool pool[string], getVarName func(id int) string, ) { - for value, id := range sorted(pool.elems) { + keys := slices.Collect(maps.Keys(pool.elems)) + slices.SortFunc(keys, func(a, b string) int { + return cmp.Compare(pool.elems[a], pool.elems[b]) + }) + + for _, key := range keys { hasVars = true + id := pool.elems[key] + varName := getVarName(id) - knownBindings[varName] = value + knownBindings[varName] = key sb.WriteString(indentStr) sb.WriteString(typ) diff --git a/builder/builder_test.go b/builder/builder_test.go index 2f32a292..d05f9e4c 100644 --- a/builder/builder_test.go +++ b/builder/builder_test.go @@ -26,11 +26,13 @@ func TestSimpleSend(t *testing.T) { _, script := builder.BuildProgram(stmt) snaps.MatchInlineSnapshot(t, script, snaps.Inline(`vars { account $account_0 + account $account_1 asset $asset_0 } send [$asset_0 42] ( source = $account_0 + destination = $account_1 )`)) } @@ -57,6 +59,7 @@ func TestInorder(t *testing.T) { snaps.MatchInlineSnapshot(t, script, snaps.Inline(`vars { account $account_0 account $account_1 + account $account_2 asset $asset_0 } @@ -65,11 +68,13 @@ send [$asset_0 42] ( $account_0 $account_1 } + destination = $account_2 )`)) require.Equal(t, map[string]string{ "account_0": "src1", "account_1": "src2", + "account_2": "dest", "asset_0": "USD/2", }, vars) } @@ -111,6 +116,7 @@ func TestInorderNested(t *testing.T) { account $account_2 account $account_3 account $account_4 + account $account_5 asset $asset_0 } @@ -124,6 +130,7 @@ send [$asset_0 42] ( } $account_4 } + destination = $account_5 )`)) } @@ -151,6 +158,7 @@ func TestInorderWithColors(t *testing.T) { snaps.MatchInlineSnapshot(t, script, snaps.Inline(`vars { account $account_0 account $account_1 + account $account_2 string $string_0 asset $asset_0 } @@ -160,5 +168,6 @@ send [$asset_0 42] ( $account_0 \ $string_0 $account_1 } + destination = $account_2 )`)) } diff --git a/builder/statement.go b/builder/statement.go index a6d2375a..b95df040 100644 --- a/builder/statement.go +++ b/builder/statement.go @@ -14,6 +14,8 @@ func StmtSend( env.builder.WriteString(" (") env.builder.WriteString("\n source = ") source(env, w+1) + env.builder.WriteString("\n destination = ") + destination(env, w+1) env.builder.WriteString("\n)") } } From 39ba7937f6d90ad9cc5a22664412079d0e41b068 Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 19 Jun 2026 01:36:29 +0200 Subject: [PATCH 5/8] handle extern vars as well --- builder/builder.go | 126 ++++++++++++++++++++++------------------ builder/builder_test.go | 45 ++++++++++++++ builder/expression.go | 21 +++++-- builder/pool.go | 3 +- builder/source.go | 4 +- builder/statement.go | 2 +- builder/vars.go | 63 ++++++++++++++++++++ 7 files changed, 200 insertions(+), 64 deletions(-) create mode 100644 builder/vars.go diff --git a/builder/builder.go b/builder/builder.go index d08f9533..6d1d3d05 100644 --- a/builder/builder.go +++ b/builder/builder.go @@ -2,19 +2,23 @@ package builder import ( - "cmp" "fmt" - "maps" - "slices" + "math/big" "strings" ) const indentStr = " " -func (p pool[T]) getItemId(elem T) int { +func (p *pool[T]) getFreshId() int { + id := p.nextId + p.nextId += 1 + return id +} + +func (p *pool[T]) getItemId(elem T) int { previousLookup, isElemInPool := p.elems[elem] if !isElemInPool { - elemId := len(p.elems) + elemId := p.getFreshId() p.elems[elem] = elemId previousLookup = elemId } @@ -22,15 +26,14 @@ func (p pool[T]) getItemId(elem T) int { } type env struct { - builder *strings.Builder - + builder strings.Builder accountsPool pool[string] assetsPool pool[string] stringsPool pool[string] - // numbersPool pool[*big.Int] + numbersPool pool[*big.Int] } -func writeIndentation(env env, w int) { +func writeIndentation(env *env, w int) { if w == 0 { return } @@ -42,19 +45,17 @@ func writeIndentation(env env, w int) { } func newEnv() env { - var sb strings.Builder return env{ - builder: &sb, accountsPool: newPool[string](), assetsPool: newPool[string](), stringsPool: newPool[string](), - // numbersPool: newPool[*big.Int](), + numbersPool: newPool[*big.Int](), } } // The underlying type of any a pretty printing document type render = func( - env env, + env *env, // The current width w int, @@ -72,64 +73,79 @@ func assetToName(id int) string { func stringToName(id int) string { return itemIdToName(id, "string") } +func numberToName(id int) string { + return itemIdToName(id, "number") +} + +type varRenderState struct { + hasVars bool + sb strings.Builder + knownBindings map[string]string +} + +func renderVar[T comparable]( + st *varRenderState, + + typ string, + pool pool[T], + getVarName func(id int) string, + stringifyValue func(value T) string, +) { + for key, id := range pool.elems { + varName := getVarName(id) + st.knownBindings[varName] = stringifyValue(key) + } + + for id := range pool.nextId { + st.hasVars = true + + varName := getVarName(id) + + st.sb.WriteString(indentStr) + st.sb.WriteString(typ) + st.sb.WriteString(" $") + st.sb.WriteString(varName) + st.sb.WriteByte('\n') + } + +} + +func stringId(x string) string { return x } +func bigIntToString(bi *big.Int) string { return bi.String() } func renderVars( - env env, - knownBindings map[string]string, + st *varRenderState, + env *env, ) string { - var sb strings.Builder - - hasVars := false - - renderVarsTyp := func( - typ string, - pool pool[string], - getVarName func(id int) string, - ) { - keys := slices.Collect(maps.Keys(pool.elems)) - slices.SortFunc(keys, func(a, b string) int { - return cmp.Compare(pool.elems[a], pool.elems[b]) - }) - - for _, key := range keys { - hasVars = true - id := pool.elems[key] - - varName := getVarName(id) - knownBindings[varName] = key - - sb.WriteString(indentStr) - sb.WriteString(typ) - sb.WriteString(" $") - sb.WriteString(varName) - sb.WriteByte('\n') - } - } - sb.WriteString("vars {\n") - renderVarsTyp("account", env.accountsPool, accountToName) - renderVarsTyp("string", env.stringsPool, stringToName) - renderVarsTyp("asset", env.assetsPool, assetToName) - sb.WriteString("}\n\n") + st.sb.WriteString("vars {\n") + renderVar(st, "account", env.accountsPool, accountToName, stringId) + renderVar(st, "string", env.stringsPool, stringToName, stringId) + renderVar(st, "asset", env.assetsPool, assetToName, stringId) + renderVar(st, "number", env.numbersPool, numberToName, func(bi *big.Int) string { + return bi.String() + }) + st.sb.WriteString("}\n\n") - if !hasVars { + if !st.hasVars { return "" } - return sb.String() + return st.sb.String() } // TODO double check this one (do we need to handle vars?) func BuildProgram(statements ...Statement) (any, string) { env := newEnv() for _, stmt := range statements { - stmt(env, 0) + stmt(&env, 0) } - knownBindings := map[string]string{} - + st := varRenderState{ + knownBindings: make(map[string]string), + } // AFTER we've rendered the whole program, we can render the vars block - vars := renderVars(env, knownBindings) + vars := renderVars(&st, &env) - return knownBindings, vars + env.builder.String() + return st.knownBindings, vars + env.builder.String() } diff --git a/builder/builder_test.go b/builder/builder_test.go index d05f9e4c..a860ef1f 100644 --- a/builder/builder_test.go +++ b/builder/builder_test.go @@ -171,3 +171,48 @@ send [$asset_0 42] ( destination = $account_2 )`)) } + +func TestWithExternVar(t *testing.T) { + accVar := builder.NewAccountVar() + amtVar := builder.NewNumberVar() + + stmt := builder.StmtSend( + builder.ExprMonetary( + builder.ExprAsset("USD/2"), + builder.ExprVar(&amtVar), + ), + builder.SrcAccount( + builder.ExprVar(&accVar), + ), + builder.DestAccount( + builder.ExprAccount("dest"), + ), + ) + + vars, script := builder.BuildProgram(stmt) + + k, v := accVar.FillAccount("my_src") + require.Equal(t, "account_0", k) + require.Equal(t, "my_src", v) + + k, v = amtVar.FillNumber(big.NewInt(42)) + require.Equal(t, "number_0", k) + require.Equal(t, "42", v) + + require.Equal(t, map[string]string{ + "asset_0": "USD/2", + "account_1": "dest", + }, vars) + + snaps.MatchInlineSnapshot(t, script, snaps.Inline(`vars { + account $account_0 + account $account_1 + asset $asset_0 + number $number_0 +} + +send [$asset_0 $number_0] ( + source = $account_0 + destination = $account_1 +)`)) +} diff --git a/builder/expression.go b/builder/expression.go index 57f43178..5882ba4d 100644 --- a/builder/expression.go +++ b/builder/expression.go @@ -4,8 +4,19 @@ import "math/big" type Expression[T ExprType] render +func ExprVar[T ExprType](v *Var[T]) Expression[T] { + return func(env *env, w int) { + if !v.set { + v.name = v.alloc(env) + v.set = true + } + env.builder.WriteByte('$') + env.builder.WriteString(v.name) + } +} + func ExprAccount(name string) Expression[ExprTypeAccount] { - return func(env env, w int) { + return func(env *env, w int) { id := env.accountsPool.getItemId(name) env.builder.WriteByte('$') env.builder.WriteString(accountToName(id)) @@ -13,7 +24,7 @@ func ExprAccount(name string) Expression[ExprTypeAccount] { } func ExprAsset(name string) Expression[ExprTypeAsset] { - return func(env env, w int) { + return func(env *env, w int) { id := env.assetsPool.getItemId(name) env.builder.WriteByte('$') env.builder.WriteString(assetToName(id)) @@ -21,7 +32,7 @@ func ExprAsset(name string) Expression[ExprTypeAsset] { } func ExprString(name string) Expression[ExprTypeString] { - return func(env env, w int) { + return func(env *env, w int) { id := env.stringsPool.getItemId(name) env.builder.WriteByte('$') env.builder.WriteString(stringToName(id)) @@ -30,7 +41,7 @@ func ExprString(name string) Expression[ExprTypeString] { func ExprNumberBigInt(amount *big.Int) Expression[ExprTypeNumber] { // we don't risk injection with numbers so we can just pprint them right away - return func(env env, w int) { + return func(env *env, w int) { env.builder.WriteString(amount.String()) } } @@ -39,7 +50,7 @@ func ExprMonetary( asset Expression[ExprTypeAsset], amount Expression[ExprTypeNumber], ) Expression[ExprTypeMonetary] { - return func(env env, w int) { + return func(env *env, w int) { env.builder.WriteString("[") asset(env, w) env.builder.WriteString(" ") diff --git a/builder/pool.go b/builder/pool.go index 54161543..2093dc40 100644 --- a/builder/pool.go +++ b/builder/pool.go @@ -1,7 +1,8 @@ package builder type pool[T comparable] struct { - elems map[T]int + nextId int + elems map[T]int } func newPool[T comparable]() pool[T] { diff --git a/builder/source.go b/builder/source.go index 7ae2c974..7da25da8 100644 --- a/builder/source.go +++ b/builder/source.go @@ -10,7 +10,7 @@ func SrcColored( accountExpr Expression[ExprTypeAccount], colorExpr Expression[ExprTypeString], ) Source { - return func(env env, w int) { + return func(env *env, w int) { accountExpr(env, w) env.builder.WriteString(" \\ ") colorExpr(env, w) @@ -18,7 +18,7 @@ func SrcColored( } func SrcInorder(sources ...Source) Source { - return func(env env, w int) { + return func(env *env, w int) { env.builder.WriteString("{\n") for _, src := range sources { writeIndentation(env, w+1) diff --git a/builder/statement.go b/builder/statement.go index b95df040..96f90c8e 100644 --- a/builder/statement.go +++ b/builder/statement.go @@ -8,7 +8,7 @@ func StmtSend( source Source, destination Destination, ) Statement { - return func(env env, w int) { + return func(env *env, w int) { env.builder.WriteString("send ") monetary(env, 0) env.builder.WriteString(" (") diff --git a/builder/vars.go b/builder/vars.go new file mode 100644 index 00000000..5e30d988 --- /dev/null +++ b/builder/vars.go @@ -0,0 +1,63 @@ +package builder + +import "math/big" + +type var_[T ExprType] struct { + name string + set bool + alloc func(env *env) string +} + +type Var[T ExprType] var_[ExprType] + +func NewAccountVar() Var[ExprTypeAccount] { + return Var[ExprTypeAccount]{ + alloc: func(env *env) string { + id := env.accountsPool.getFreshId() + return accountToName(id) + }, + } +} + +func NewAssetVar() Var[ExprTypeAsset] { + return Var[ExprTypeAsset]{ + alloc: func(env *env) string { + id := env.assetsPool.getFreshId() + return assetToName(id) + }, + } +} + +func NewStringVar() Var[ExprTypeString] { + return Var[ExprTypeString]{ + alloc: func(env *env) string { + id := env.stringsPool.getFreshId() + return stringToName(id) + }, + } +} + +func NewNumberVar() Var[ExprTypeNumber] { + return Var[ExprTypeNumber]{ + alloc: func(env *env) string { + id := env.numbersPool.getFreshId() + return numberToName(id) + }, + } +} + +func (v Var[ExprTypeAccount]) FillAccount(account string) (string, string) { + return v.name, account +} + +func (v Var[ExprTypeAsset]) FillAsset(asset string) (string, string) { + return v.name, asset +} + +func (v Var[ExprTypeString]) FillString(str string) (string, string) { + return v.name, str +} + +func (v Var[ExprTypeNumber]) FillNumber(bi *big.Int) (string, string) { + return v.name, bi.String() +} From ba27dbeadf8c455fed1ffb52c7cdcee590ca5af7 Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 19 Jun 2026 09:47:17 +0200 Subject: [PATCH 6/8] reworked api --- builder/builder.go | 9 +++++---- builder/builder_test.go | 14 +++++++------- builder/expression.go | 9 +++++---- builder/vars.go | 32 ++++++++++++++++++++++---------- 4 files changed, 39 insertions(+), 25 deletions(-) diff --git a/builder/builder.go b/builder/builder.go index 6d1d3d05..d4cc68d2 100644 --- a/builder/builder.go +++ b/builder/builder.go @@ -31,6 +31,7 @@ type env struct { assetsPool pool[string] stringsPool pool[string] numbersPool pool[*big.Int] + varsEnv VarsEnv } func writeIndentation(env *env, w int) { @@ -50,6 +51,7 @@ func newEnv() env { assetsPool: newPool[string](), stringsPool: newPool[string](), numbersPool: newPool[*big.Int](), + varsEnv: VarsEnv{bindings: map[anyVar]string{}}, } } @@ -110,8 +112,7 @@ func renderVar[T comparable]( } -func stringId(x string) string { return x } -func bigIntToString(bi *big.Int) string { return bi.String() } +func stringId(x string) string { return x } func renderVars( st *varRenderState, @@ -135,7 +136,7 @@ func renderVars( } // TODO double check this one (do we need to handle vars?) -func BuildProgram(statements ...Statement) (any, string) { +func BuildProgram(statements ...Statement) (map[string]string, VarsEnv, string) { env := newEnv() for _, stmt := range statements { stmt(&env, 0) @@ -147,5 +148,5 @@ func BuildProgram(statements ...Statement) (any, string) { // AFTER we've rendered the whole program, we can render the vars block vars := renderVars(&st, &env) - return st.knownBindings, vars + env.builder.String() + return st.knownBindings, env.varsEnv, vars + env.builder.String() } diff --git a/builder/builder_test.go b/builder/builder_test.go index a860ef1f..0916c19e 100644 --- a/builder/builder_test.go +++ b/builder/builder_test.go @@ -23,7 +23,7 @@ func TestSimpleSend(t *testing.T) { ), ) - _, script := builder.BuildProgram(stmt) + _, _, script := builder.BuildProgram(stmt) snaps.MatchInlineSnapshot(t, script, snaps.Inline(`vars { account $account_0 account $account_1 @@ -55,7 +55,7 @@ func TestInorder(t *testing.T) { ), ) - vars, script := builder.BuildProgram(stmt) + vars, _, script := builder.BuildProgram(stmt) snaps.MatchInlineSnapshot(t, script, snaps.Inline(`vars { account $account_0 account $account_1 @@ -109,7 +109,7 @@ func TestInorderNested(t *testing.T) { ), ) - _, script := builder.BuildProgram(stmt) + _, _, script := builder.BuildProgram(stmt) snaps.MatchInlineSnapshot(t, script, snaps.Inline(`vars { account $account_0 account $account_1 @@ -154,7 +154,7 @@ func TestInorderWithColors(t *testing.T) { ), ) - _, script := builder.BuildProgram(stmt) + _, _, script := builder.BuildProgram(stmt) snaps.MatchInlineSnapshot(t, script, snaps.Inline(`vars { account $account_0 account $account_1 @@ -189,13 +189,13 @@ func TestWithExternVar(t *testing.T) { ), ) - vars, script := builder.BuildProgram(stmt) + vars, varsEnv, script := builder.BuildProgram(stmt) - k, v := accVar.FillAccount("my_src") + k, v := varsEnv.FillAccount(&accVar, "my_src") require.Equal(t, "account_0", k) require.Equal(t, "my_src", v) - k, v = amtVar.FillNumber(big.NewInt(42)) + k, v = varsEnv.FillNumber(&amtVar, big.NewInt(42)) require.Equal(t, "number_0", k) require.Equal(t, "42", v) diff --git a/builder/expression.go b/builder/expression.go index 5882ba4d..0fe5c550 100644 --- a/builder/expression.go +++ b/builder/expression.go @@ -6,12 +6,13 @@ type Expression[T ExprType] render func ExprVar[T ExprType](v *Var[T]) Expression[T] { return func(env *env, w int) { - if !v.set { - v.name = v.alloc(env) - v.set = true + varName, hasPreviousLookup := env.varsEnv.bindings[v] + if !hasPreviousLookup { + varName = v.alloc(env) + env.varsEnv.bindings[v] = varName } env.builder.WriteByte('$') - env.builder.WriteString(v.name) + env.builder.WriteString(varName) } } diff --git a/builder/vars.go b/builder/vars.go index 5e30d988..2cf9acee 100644 --- a/builder/vars.go +++ b/builder/vars.go @@ -3,11 +3,19 @@ package builder import "math/big" type var_[T ExprType] struct { - name string - set bool alloc func(env *env) string } +type anyVar interface { + anyVar() +} + +func (*Var[T]) anyVar() {} + +type VarsEnv struct { + bindings map[anyVar]string +} + type Var[T ExprType] var_[ExprType] func NewAccountVar() Var[ExprTypeAccount] { @@ -46,18 +54,22 @@ func NewNumberVar() Var[ExprTypeNumber] { } } -func (v Var[ExprTypeAccount]) FillAccount(account string) (string, string) { - return v.name, account +func (v VarsEnv) FillAccount(var_ *Var[ExprTypeAccount], account string) (string, string) { + name, _ := v.bindings[anyVar(var_)] + return name, account } -func (v Var[ExprTypeAsset]) FillAsset(asset string) (string, string) { - return v.name, asset +func (v VarsEnv) FillAsset(var_ *Var[ExprTypeAsset], asset string) (string, string) { + name, _ := v.bindings[anyVar(var_)] + return name, asset } -func (v Var[ExprTypeString]) FillString(str string) (string, string) { - return v.name, str +func (v VarsEnv) FillString(var_ *Var[ExprTypeString], str string) (string, string) { + name, _ := v.bindings[anyVar(var_)] + return name, str } -func (v Var[ExprTypeNumber]) FillNumber(bi *big.Int) (string, string) { - return v.name, bi.String() +func (v VarsEnv) FillNumber(var_ *Var[ExprTypeNumber], bi *big.Int) (string, string) { + name, _ := v.bindings[anyVar(var_)] + return name, bi.String() } From 126e9a15e436d6b35742e043c473860b1d47b802 Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 19 Jun 2026 10:00:03 +0200 Subject: [PATCH 7/8] add docs --- builder/builder_test.go | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/builder/builder_test.go b/builder/builder_test.go index 0916c19e..2433514f 100644 --- a/builder/builder_test.go +++ b/builder/builder_test.go @@ -173,13 +173,20 @@ send [$asset_0 42] ( } func TestWithExternVar(t *testing.T) { + // The builder module exposes a type-safe API to create scripts + + // We can create (typed) vars this way: accVar := builder.NewAccountVar() amtVar := builder.NewNumberVar() + // sources, destinations, expressions, and statements are typed, so you can never + // use a source node instead of a expression node, and so on. + // In addition to that, expressions are typed, so you can't use a string expression + // instead of a number expression stmt := builder.StmtSend( builder.ExprMonetary( builder.ExprAsset("USD/2"), - builder.ExprVar(&amtVar), + builder.ExprVar(&amtVar), // <- you can reference vars this way (identified by address) ), builder.SrcAccount( builder.ExprVar(&accVar), @@ -189,8 +196,27 @@ func TestWithExternVar(t *testing.T) { ), ) + // When you build the program, it'll create 3 values: vars, varsEnv, script := builder.BuildProgram(stmt) + // 1: (vars) the map[string]string of KNOWN variables. This is generated via the + // strings literals you pass (in the example above, USD/2 and @dest). + // This way, you'll pass this map to the tx and numscript will handle interpolation instead of + // handling that in this lib + require.Equal(t, map[string]string{ + "asset_0": "USD/2", + "account_1": "dest", + }, vars) + + // 2: (varsEnv) The env of the NAMES of each variable that is referenced within the script. + // You'll reference them by ptr address. The "Fill*()" methods are typed, and return you the name of the var, + // and the "stringified" value of the var content (in the case of account/asset/string, the string itself) + // user code would likely be something like: + // + // varsCp := maps.Clone(vars) + // k, v := varsEnv.FillAccount(..) + // varsCp[k] = v + // (etc) k, v := varsEnv.FillAccount(&accVar, "my_src") require.Equal(t, "account_0", k) require.Equal(t, "my_src", v) @@ -199,11 +225,8 @@ func TestWithExternVar(t *testing.T) { require.Equal(t, "number_0", k) require.Equal(t, "42", v) - require.Equal(t, map[string]string{ - "asset_0": "USD/2", - "account_1": "dest", - }, vars) - + // 3: (script) the script itself. It's generated while trying to be as "stable" as possible, + // e.g. vars are declared with an hardcoded order of types, and with an increasing order of names snaps.MatchInlineSnapshot(t, script, snaps.Inline(`vars { account $account_0 account $account_1 From 4eb046cd99fdfc5d78943c6fdea1aa3613367519 Mon Sep 17 00:00:00 2001 From: ascandone Date: Fri, 19 Jun 2026 10:03:41 +0200 Subject: [PATCH 8/8] add comments --- builder/builder_test.go | 3 +++ builder/vars.go | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/builder/builder_test.go b/builder/builder_test.go index 2433514f..589dc5dc 100644 --- a/builder/builder_test.go +++ b/builder/builder_test.go @@ -211,6 +211,9 @@ func TestWithExternVar(t *testing.T) { // 2: (varsEnv) The env of the NAMES of each variable that is referenced within the script. // You'll reference them by ptr address. The "Fill*()" methods are typed, and return you the name of the var, // and the "stringified" value of the var content (in the case of account/asset/string, the string itself) + // Behaviour of Fill*() of vars that are never referenced in the script (thus, whose name is never allocated) is undefined + // (it may panic in the future) + // // user code would likely be something like: // // varsCp := maps.Clone(vars) diff --git a/builder/vars.go b/builder/vars.go index 2cf9acee..ee9b0c2a 100644 --- a/builder/vars.go +++ b/builder/vars.go @@ -55,21 +55,21 @@ func NewNumberVar() Var[ExprTypeNumber] { } func (v VarsEnv) FillAccount(var_ *Var[ExprTypeAccount], account string) (string, string) { - name, _ := v.bindings[anyVar(var_)] + name := v.bindings[anyVar(var_)] return name, account } func (v VarsEnv) FillAsset(var_ *Var[ExprTypeAsset], asset string) (string, string) { - name, _ := v.bindings[anyVar(var_)] + name := v.bindings[anyVar(var_)] return name, asset } func (v VarsEnv) FillString(var_ *Var[ExprTypeString], str string) (string, string) { - name, _ := v.bindings[anyVar(var_)] + name := v.bindings[anyVar(var_)] return name, str } func (v VarsEnv) FillNumber(var_ *Var[ExprTypeNumber], bi *big.Int) (string, string) { - name, _ := v.bindings[anyVar(var_)] + name := v.bindings[anyVar(var_)] return name, bi.String() }