diff --git a/errors_test.go b/errors_test.go index 7ebeac6e..abc487a4 100644 --- a/errors_test.go +++ b/errors_test.go @@ -3,6 +3,7 @@ package numscript_test import ( "context" "errors" + "maps" "math/big" "testing" @@ -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) + }) + } +} diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go index 9db110a2..24b6ec02 100644 --- a/internal/compiler/compiler.go +++ b/internal/compiler/compiler.go @@ -1,7 +1,6 @@ package compiler import ( - "fmt" "maps" "math/big" "slices" @@ -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 { diff --git a/internal/compiler/compiler_error.go b/internal/compiler/compiler_error.go index a923f5c7..6d4c5b2c 100644 --- a/internal/compiler/compiler_error.go +++ b/internal/compiler/compiler_error.go @@ -10,6 +10,7 @@ import ( type ( CompilerError interface { + error parser.Ranged compileError() } diff --git a/internal/compiler/vars_encoder.go b/internal/compiler/vars_encoder.go index b5e2be70..f67e166b 100644 --- a/internal/compiler/vars_encoder.go +++ b/internal/compiler/vars_encoder.go @@ -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) @@ -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} } } diff --git a/numscript.go b/numscript.go index aa930ea4..db3b5c64 100644 --- a/numscript.go +++ b/numscript.go @@ -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" ) @@ -191,6 +192,58 @@ type ( Vars = vm.Vars ) +// Compiler error types, aliased so callers of Compile and VarsEncoder.Encode +// 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