Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package numscript_test
import (
"context"
"errors"
"maps"
"math/big"
"testing"

Expand Down Expand Up @@ -357,3 +358,177 @@ func TestResolveDependenciesRejectsScaling(t *testing.T) {
_, err := parsed.ResolveDependencies(context.Background(), nil, numscript.StaticStore{})
require.ErrorIs(t, err, numscript.ErrScalingNotSupported)
}

// Hosts classify compile failures the same way, so the compiler's error types
// carry the same contract as the interpreter's.
func TestCompilerErrorsAreClassifiable(t *testing.T) {
cases := []struct {
name string
script string
flags []string
check func(t *testing.T, err error)
}{
{
name: "type mismatch",
script: `vars { number $n } send $n ( source = @a destination = @b )`,
check: func(t *testing.T, err error) {
kind := requireAs[numscript.CompilerTypeError](t, err).Kind
require.Equal(t, numscript.TypecheckTypeMismatch{Expected: "monetary", Got: "number"}, kind)
},
},
{
name: "unbound variable",
script: `send $m ( source = @a destination = @b )`,
check: func(t *testing.T, err error) {
kind := requireAs[numscript.CompilerTypeError](t, err).Kind
require.Equal(t, numscript.TypecheckUnboundVariable{Name: "m", Type: "monetary"}, kind)
},
},
{
name: "invalid type",
script: `vars { invalidt $x }`,
check: func(t *testing.T, err error) {
kind := requireAs[numscript.CompilerTypeError](t, err).Kind
require.Equal(t, numscript.TypecheckInvalidType{Name: "invalidt"}, kind)
},
},
{
name: "bad arity",
script: `set_tx_meta()`,
check: func(t *testing.T, err error) {
kind := requireAs[numscript.CompilerTypeError](t, err).Kind
require.Equal(t, numscript.TypecheckBadArity{Expected: 2, Actual: 0}, kind)
},
},
{
name: "unknown function",
script: `unbound_fn(1, 2)`,
check: func(t *testing.T, err error) {
kind := requireAs[numscript.CompilerTypeError](t, err).Kind
require.Equal(t, numscript.TypecheckUnknownFunction{Name: "unbound_fn"}, kind)
},
},
{
name: "duplicate variable",
script: `vars { number $a number $a }`,
check: func(t *testing.T, err error) {
kind := requireAs[numscript.CompilerTypeError](t, err).Kind
require.Equal(t, numscript.TypecheckDuplicateVariable{Name: "a"}, kind)
},
},
{
name: "allotment in send all",
script: `send [USD *] (
source = { 1/2 from @a remaining from @b }
destination = @c
)`,
check: func(t *testing.T, err error) { requireAs[numscript.CompilerInvalidUncappedSource](t, err) },
},
{
name: "duplicate remaining allotment",
script: `send [USD 10] (
source = { remaining from @world remaining from @world }
destination = @b
)`,
check: func(t *testing.T, err error) { requireAs[numscript.CompilerDuplicateRemaining](t, err) },
},
{
name: "meta outside a variable origin",
script: `set_tx_meta("k", meta(@acc, "k"))`,
flags: []string{flags.ExperimentalMidScriptFunctionCall},
check: func(t *testing.T, err error) { requireAs[numscript.CompilerInvalidMetaPosition](t, err) },
},
{
name: "cannot cast to string",
script: `vars { monetary $m } set_tx_meta("k", @acc:$m)`,
flags: []string{flags.ExperimentalAccountInterpolationFlag},
check: func(t *testing.T, err error) {
require.Equal(t, "monetary", requireAs[numscript.CompilerCannotCastToString](t, err).Type)
},
},
{
name: "cannot store scoped account in meta",
script: `vars { account $s = scoped(@a, "s") } set_tx_meta("k", $s)`,
flags: []string{flags.ExperimentalScopedFunction},
check: func(t *testing.T, err error) {
requireAs[numscript.CompilerCannotStoreScopedAccountInMeta](t, err)
},
},
{
name: "experimental feature",
script: `vars { account $s = scoped(@b, "s") }`,
check: func(t *testing.T, err error) {
e := requireAs[numscript.CompilerExperimentalFeature](t, err)
require.Equal(t, flags.ExperimentalScopedFunction, e.FlagName)
},
},
{
name: "invalid feature",
script: `#![feature("nope")]`,
check: func(t *testing.T, err error) {
require.Equal(t, "nope", requireAs[numscript.CompilerInvalidFeature](t, err).Feature)
},
},
{
name: "feature not implemented",
script: `send [EUR/2 100] ( source = @a with scaling through @swap destination = @b )`,
flags: []string{flags.AssetScaling},
check: func(t *testing.T, err error) {
require.Equal(t, "scaling", requireAs[numscript.CompilerFeatureNotImplemented](t, err).Feature)
},
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
featureFlags := map[string]struct{}{}
for _, f := range tc.flags {
featureFlags[f] = struct{}{}
}

_, _, err := numscript.CompileWithFeatureFlags(tc.script, featureFlags)
require.Error(t, err)
requireAs[numscript.CompilerError](t, err)
tc.check(t, err)
})
}
}

func TestVarsEncodingErrorsAreClassifiable(t *testing.T) {
enc, _, err := numscript.Compile(`vars {
account $a
number $n
monetary $m
}`)
require.NoError(t, err)

valid := map[string]string{"a": "alice", "n": "1", "m": "USD 1"}
with := func(name, raw string) map[string]string {
vars := maps.Clone(valid)
vars[name] = raw
return vars
}

t.Run("missing variable", func(t *testing.T) {
vars := maps.Clone(valid)
delete(vars, "n")
_, err := enc.Encode(vars)
require.Equal(t, "n", requireAs[numscript.CompilerMissingVariable](t, err).Name)
})

for _, tc := range []struct {
name, typ, raw string
}{
{"a", "account", "@world"},
{"n", "number", "abc"},
{"m", "monetary", "USD"},
} {
t.Run("invalid "+tc.typ, func(t *testing.T) {
_, err := enc.Encode(with(tc.name, tc.raw))
e := requireAs[numscript.CompilerInvalidVariableValue](t, err)
require.Equal(t, tc.name, e.Name)
require.Equal(t, tc.typ, e.Type)
require.Equal(t, tc.raw, e.Raw)
})
}
}
3 changes: 1 addition & 2 deletions internal/compiler/compiler.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package compiler

import (
"fmt"
"maps"
"math/big"
"slices"
Expand All @@ -24,7 +23,7 @@ import (
func Compile(program parser.Program, featureFlags map[string]struct{}) (VarsEncoder, vm.Program, error) {
compiled, cErr := compileProgramToIR(program, featureFlags)
if cErr != nil {
return VarsEncoder{}, vm.Program{}, fmt.Errorf("%v", cErr)
return VarsEncoder{}, vm.Program{}, cErr
}

if err := ir.Typecheck(compiled.instructions); err != nil {
Expand Down
1 change: 1 addition & 0 deletions internal/compiler/compiler_error.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

type (
CompilerError interface {
error
parser.Ranged
compileError()
}
Expand Down
31 changes: 29 additions & 2 deletions internal/compiler/vars_encoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,33 @@ type varDecl struct {
typ typecheck.Type
}

// MissingVariable is returned by Encode when the payload has no value for a
// declared variable.
type MissingVariable struct {
Name string
}

func (e MissingVariable) Error() string {
return fmt.Sprintf("missing variable: $%s", e.Name)
}

// InvalidVariableValue is returned by Encode when a variable's raw value does
// not parse as its declared type.
type InvalidVariableValue struct {
Name string
Type typecheck.Type
Raw string
Cause error
}

func (e InvalidVariableValue) Error() string {
return fmt.Sprintf("variable $%s: %v", e.Name, e.Cause)
}

func (e InvalidVariableValue) Unwrap() error {
return e.Cause
}

// TODO review AI blob
func (e VarsEncoder) Encode(vars map[string]string) (vm.Vars, error) {
strs := make([]string, 0, e.nStr)
Expand All @@ -28,13 +55,13 @@ func (e VarsEncoder) Encode(vars map[string]string) (vm.Vars, error) {
for _, d := range e.decls {
raw, ok := vars[d.name]
if !ok {
return vm.Vars{}, fmt.Errorf("missing variable: $%s", d.name)
return vm.Vars{}, MissingVariable{Name: d.name}
}

var err error
strs, ints, err = appendVar(strs, ints, d.typ, raw)
if err != nil {
return vm.Vars{}, fmt.Errorf("variable $%s: %w", d.name, err)
return vm.Vars{}, InvalidVariableValue{Name: d.name, Type: d.typ, Raw: raw, Cause: err}
}
}

Expand Down
53 changes: 53 additions & 0 deletions numscript.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/formancehq/numscript/internal/compiler"
"github.com/formancehq/numscript/internal/interpreter"
"github.com/formancehq/numscript/internal/parser"
"github.com/formancehq/numscript/internal/typecheck"
"github.com/formancehq/numscript/internal/vm"
)

Expand Down Expand Up @@ -191,6 +192,58 @@ type (
Vars = vm.Vars
)

// Compiler error types, aliased so callers of Compile and VarsEncoder.Encode
Comment thread
Azorlogh marked this conversation as resolved.
// can classify failures with errors.As without reaching into internal
// packages. All are returned by value. As with the interpreter's error types,
// their exported fields, other than the embedded source range, are part of the
// public contract: only ever added to, never renamed or removed. The Compiler
// prefix keeps them apart from the interpreter's error types of the same name.
//
// CompilerError is implemented by every error Compile reports about the
// script itself; the others in this block are caused by the script.
type (
CompilerError = compiler.CompilerError
CompilerTypeError = compiler.TypeError
CompilerInvalidUncappedSource = compiler.InvalidUncappedSource
CompilerDuplicateRemaining = compiler.DuplicateRemaining
CompilerInvalidMetaPosition = compiler.InvalidMetaPosition
CompilerCannotCastToString = compiler.CannotCastToString
CompilerCannotStoreScopedAccountInMeta = compiler.CannotStoreScopedAccountInMeta
CompilerExperimentalFeature = compiler.ExperimentalFeature
CompilerInvalidFeature = compiler.InvalidFeature
)

// CompilerFeatureNotImplemented reports a valid construct the compiler does not
// support yet; the interpreter may still run the script.
type CompilerFeatureNotImplemented = compiler.FeatureNotImplemented

// Compiler errors reporting defects in numscript itself, never caused by the
// script. Their fields are diagnostic only and not part of the contract.
type (
CompilerUnboundVar = compiler.UnboundVar
CompilerInvalidScopedAccountPosition = compiler.InvalidScopedAccountPosition
CompilerUnsupportedNode = compiler.UnsupportedNode
)

// Errors returned by VarsEncoder.Encode, caused by the vars. Cause is the
// parser's error, diagnostic only.
type (
CompilerMissingVariable = compiler.MissingVariable
CompilerInvalidVariableValue = compiler.InvalidVariableValue
)

// The type checker's findings, carried by CompilerTypeError.Kind. Each kind is
// a value type whose exported fields follow the same contract as above.
type (
TypecheckErrorKind = typecheck.ErrorKind
TypecheckTypeMismatch = typecheck.TypeMismatch
TypecheckUnboundVariable = typecheck.UnboundVariable
TypecheckInvalidType = typecheck.InvalidType
TypecheckBadArity = typecheck.BadArity
TypecheckUnknownFunction = typecheck.UnknownFunction
TypecheckDuplicateVariable = typecheck.DuplicateVariable
)

var NewVm = vm.NewVm

var DecodeVars = vm.DecodeVars
Expand Down
Loading